@ultimat3/query
Advanced tools
+145
| /** | ||
| * `search()` โ a QUERY FACTORY over an entity's `.searchable()` columns, the shape `llm()` and | ||
| * `backfill()` already have. It returns a `query`, so a search inherits the policy, the cache tags, | ||
| * the MCP tool, the typed client, the route and its manifest row rather than becoming a ninth | ||
| * primitive. What it adds is the one thing a hand-written read gets wrong: the term never becomes | ||
| * syntax, and the tenant predicate is never optional. | ||
| */ | ||
| import { assert, type Ctx } from '@ultimat3/core'; | ||
| import type { InferOutput, Shape, Simplify } from '@ultimat3/schema'; | ||
| import { t } from '@ultimat3/schema'; | ||
| import type { QueryPolicy } from './policy-gate'; | ||
| import type { QueryCache, QueryMcp, QueryRateLimit } from './query'; | ||
| import { query } from './query'; | ||
| import type { SeekKey } from './shape'; | ||
| import type { SqlSource, SqlText } from './source'; | ||
| import { from } from './source'; | ||
| /** | ||
| * What `@ultimat3/entity`'s `ReadBuilder` answers, crossed STRUCTURALLY. | ||
| * | ||
| * This package may import `@ultimat3/entity` (tier 2) and deliberately does not: nothing here does | ||
| * today, so a real dependency would be a new edge in `package.json` and a new block in `bun.lock` | ||
| * for four methods. Same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down, and the same | ||
| * discipline โ the shape is the contract, and a chain that does not satisfy it does not compile. | ||
| */ | ||
| export interface SearchChain<Row extends object> { | ||
| /** Appends the full-text predicate. The TERM, never a tsquery. */ | ||
| search(term: string): SearchChain<Row>; | ||
| limit(rows: number): SearchChain<Row>; | ||
| all(): Promise<readonly Row[]>; | ||
| /** Only `entity` is read โ the name the `SqlSource` and every cache tag are keyed by. */ | ||
| plan(): { readonly entity: string }; | ||
| } | ||
| export interface SearchPage { | ||
| /** The largest page this read will serve. Bounded in the INPUT SCHEMA, so a client cannot ask past it. */ | ||
| readonly max?: number; | ||
| readonly default?: number; | ||
| } | ||
| const DEFAULT_PAGE_MAX = 100; | ||
| const DEFAULT_PAGE_SIZE = 20; | ||
| /** | ||
| * The longest term accepted. A `tsquery` past ~1 MB is a server error, and a search box does not | ||
| * send a novel โ the bound belongs in the schema so it is refused before a statement exists. | ||
| */ | ||
| const DEFAULT_TERM_MAX = 200; | ||
| export interface SearchDef<S extends Shape, Row extends object> { | ||
| /** The read's own input keys, beside `q` and `limit` โ a tenant id, a status filter, a date. */ | ||
| readonly input?: S; | ||
| readonly policy: QueryPolicy; | ||
| /** | ||
| * The chain this search runs on, WITHOUT the term: the tenancy, the filters and the ordering the | ||
| * page is served in. `search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is | ||
| * what makes the term unable to arrive any other way. | ||
| */ | ||
| in(args: { readonly input: SearchInput<S>; readonly ctx: Ctx }): SearchChain<Row>; | ||
| readonly termMax?: number; | ||
| readonly page?: SearchPage; | ||
| readonly cache?: QueryCache; | ||
| readonly mcp?: QueryMcp; | ||
| readonly rateLimit?: QueryRateLimit; | ||
| } | ||
| type SearchShape<S extends Shape> = Simplify< | ||
| S & { q: ReturnType<typeof termSchema>; limit: ReturnType<typeof limitSchema> } | ||
| >; | ||
| export type SearchInput<S extends Shape> = InferOutput<ReturnType<typeof t.object<SearchShape<S>>>>; | ||
| const termSchema = (max: number) => t.string.min(1).max(max); | ||
| const limitSchema = (max: number, fallback: number) => | ||
| t.number.int().min(1).max(max).default(fallback); | ||
| /** | ||
| * A search serves ONE page, and asking for a second is refused rather than answered wrongly. | ||
| * | ||
| * The rows come from the entity chain, which pages by its own keyset cursor โ proven against a real | ||
| * server in `packages/entity/src/pg-search.live.test.ts`. That cursor cannot cross this seam: a | ||
| * `SqlSource` is handed a `SeekKey` (the previous page's sort VALUES) and the chain wants its own | ||
| * signed, plan-scoped string, and there is no way to mint one from the other here. Falling through | ||
| * to `paginate`'s in-memory slice would cut inside the one page the provider fetched and report | ||
| * `hasNextPage: false` at its edge โ rows served on no page at all, which is the defect 12.0.0 spent | ||
| * a release removing from the timestamp seek. So it is a refusal with the alternative in the `fix`: | ||
| * page with the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`. | ||
| */ | ||
| const onePage = <Row extends object>(base: SqlSource<Row>, entity: string): SqlSource<Row> => ({ | ||
| toSQL: (): SqlText => base.toSQL(), | ||
| execute: () => base.execute(), | ||
| shape: () => base.shape(), | ||
| ...(base.total === undefined ? {} : { total: () => onePage(base.total?.() ?? base, entity) }), | ||
| seek: (after: SeekKey | null, limit: number): SqlSource<Row> => { | ||
| assert( | ||
| after === null, | ||
| `search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry`, | ||
| `db.${entity}.search(term).orderBy('<key>').after(cursor).page() # the entity chain pages this read โ or raise its limit`, | ||
| ); | ||
| return onePage(base.seek?.(after, limit) ?? base, entity); | ||
| }, | ||
| }); | ||
| /** | ||
| * The factory. `live: false` and the source declares itself unpatchable, because the incremental | ||
| * matcher decides membership from `QueryShape` filters and a `tsvector` match is not one of them โ | ||
| * a live search would have to re-read on every write to the table. | ||
| */ | ||
| export const search = <Row extends object, S extends Shape = Record<string, never>>( | ||
| def: SearchDef<S, Row>, | ||
| ) => { | ||
| const page = def.page ?? {}; | ||
| const max = page.max ?? DEFAULT_PAGE_MAX; | ||
| const shape = { | ||
| ...((def.input ?? {}) as S), | ||
| q: termSchema(def.termMax ?? DEFAULT_TERM_MAX), | ||
| limit: limitSchema(max, Math.min(page.default ?? DEFAULT_PAGE_SIZE, max)), | ||
| } as SearchShape<S>; | ||
| return query({ | ||
| input: t.object(shape), | ||
| policy: def.policy, | ||
| live: false, | ||
| ...(def.cache === undefined ? {} : { cache: def.cache }), | ||
| ...(def.mcp === undefined ? {} : { mcp: def.mcp }), | ||
| ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }), | ||
| sql: (input, ctx) => { | ||
| const parsed = input as SearchInput<S> & { readonly q: string; readonly limit: number }; | ||
| // Trimmed and refused HERE, before the chain exists: `websearch_to_tsquery('english', ' ')` | ||
| // is a legal empty tsquery matching nothing, so a blank box would answer "no results" as if | ||
| // it had searched. Saying so is the difference between an empty answer and an empty question. | ||
| const term = parsed.q.trim(); | ||
| assert( | ||
| term.length > 0, | ||
| 'a search term of only whitespace is not a search', | ||
| 'guard the input before calling: if (term.trim() === "") return [] โ an empty box is not an empty result set', | ||
| ); | ||
| const chain = def.in({ input: parsed, ctx }); | ||
| const name = chain.plan().entity; | ||
| const rows = () => chain.search(term).limit(parsed.limit).all(); | ||
| return onePage(from<Row>(name, rows).raw('full-text search'), name); | ||
| }, | ||
| }); | ||
| }; |
+15
-0
@@ -31,2 +31,3 @@ # @ultimat3/query | ||
| | `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers | | ||
| | `search.ts` | `search()` โ the query FACTORY over an entity's `.searchable()` columns | | ||
| | `source.ts` | `SqlSource` contract + `from()` in-memory reference | | ||
@@ -211,2 +212,16 @@ | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) | | ||
| and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`. | ||
| - **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema | ||
| (`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank | ||
| term, and calls `.search(term)` on the chain the app hands it โ which is what makes the term | ||
| unable to arrive any other way. The chain crosses **structurally** (`SearchChain`): this package | ||
| may import `@ultimat3/entity` and nothing here does, so four methods are not worth a new | ||
| `package.json` edge and a new `bun.lock` block โ the trade `@ultimat3/db`'s `entity-shape.ts` | ||
| makes one tier down. It needs no row in `PRIMITIVE_FACTORIES`: that table is for a factory | ||
| returning an `action` or a `job` from OUTSIDE the primitive's own package, and this returns a | ||
| query from the query package. **It serves ONE page and refuses a second**, because a `SqlSource` | ||
| is handed a `SeekKey` and the entity chain wants its own signed, plan-scoped cursor โ there is no | ||
| minting one from the other here, and falling through to `paginate`'s in-memory slice would cut | ||
| inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served | ||
| on no page at all is the defect 12.0.0 spent a release removing from the timestamp seek; the | ||
| `fix:` names the entity chain, which pages this read correctly. | ||
| - Policy runs per subscriber for live queries. Never cache a decision across actors. | ||
@@ -213,0 +228,0 @@ - The matcher patches from `QueryShape`, never from SQL text. |
+6
-6
| { | ||
| "name": "@ultimat3/query", | ||
| "version": "12.0.0", | ||
| "version": "13.0.0", | ||
| "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher", | ||
@@ -38,8 +38,8 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/cache": "12.0.0", | ||
| "@ultimat3/core": "12.0.0", | ||
| "@ultimat3/http": "12.0.0", | ||
| "@ultimat3/policy": "12.0.0", | ||
| "@ultimat3/schema": "12.0.0" | ||
| "@ultimat3/cache": "13.0.0", | ||
| "@ultimat3/core": "13.0.0", | ||
| "@ultimat3/http": "13.0.0", | ||
| "@ultimat3/policy": "13.0.0", | ||
| "@ultimat3/schema": "13.0.0" | ||
| } | ||
| } |
+48
-0
@@ -175,2 +175,50 @@ # @ultimat3/query ๐ | ||
| ## `search()` โ the query factory over a searchable entity | ||
| A model call is an `action` and a sweep is a `job`; a search is a **read**, so `search()` returns a | ||
| `query`. It inherits the policy, the cache tags, the MCP tool, the typed client, the route and its | ||
| manifest row โ there is no ninth primitive. | ||
| ```ts | ||
| import { type QueryPolicy, search, type SearchChain, t } from '@ultimat3/query'; | ||
| interface PostRow { | ||
| readonly id: string; | ||
| readonly title: string; | ||
| readonly createdAt: Date; | ||
| } | ||
| // `@ultimat3/entity`'s chain, crossed STRUCTURALLY: a real `db.posts` satisfies `SearchChain` | ||
| // as written, so this package holds no dependency edge on entity. | ||
| declare const db: { | ||
| readonly posts: { | ||
| where(filter: { readonly orgId: string }): { | ||
| orderBy(column: keyof PostRow & string, direction: 'asc' | 'desc'): SearchChain<PostRow>; | ||
| }; | ||
| }; | ||
| }; | ||
| declare const postRead: QueryPolicy; | ||
| declare const orgId: string; | ||
| export const searchPosts = search({ | ||
| input: { orgId: t.uuid }, // your own keys โ `q` and `limit` are added | ||
| policy: postRead, | ||
| page: { max: 50, default: 20 }, | ||
| // The chain WITHOUT the term: tenancy, filters, and the order the page is served in. | ||
| in: ({ input }) => db.posts.where({ orgId: input.orgId }).orderBy('createdAt', 'desc'), | ||
| }); | ||
| // A query is CALLABLE โ there is no `.run()`; `.as()`, `.page()` and `.client()` are the rest. | ||
| await searchPosts({ orgId, q: 'cats -dogs "exact phrase"' }); | ||
| ``` | ||
| `search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is what makes the term | ||
| unable to arrive any other way. A blank term is refused rather than answered with no rows โ an | ||
| empty box is not an empty result set. | ||
| **It serves one page.** The rows come from the entity chain, which pages by its own signed cursor; | ||
| that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the | ||
| page the provider fetched and report `hasNextPage: false` at its edge. So a second page is | ||
| refused, and the `fix:` names the chain โ `db.posts.search(term).after(cursor).page()`. | ||
| ## Pagination is cursor-only | ||
@@ -177,0 +225,0 @@ |
+3
-0
@@ -136,2 +136,5 @@ /** | ||
| } from './registry'; | ||
| /** The query FACTORY over an entity's searchable columns โ a `query`, never a ninth primitive. */ | ||
| export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search'; | ||
| export { search } from './search'; | ||
| export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape'; | ||
@@ -138,0 +141,0 @@ /** |
225303
4.93%27
3.85%3109
4.57%424
12.77%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated
Updated