@ultimat3/query 12.0.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +15 -0
- package/README.md +48 -0
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/search.ts +145 -0
package/CLAUDE.md
CHANGED
|
@@ -28,6 +28,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
28
28
|
| `input-shape.ts` | what a read's `input:` may be, given that its route is a query STRING |
|
|
29
29
|
| `sql.ts` | `explain()` / `describeSql()` |
|
|
30
30
|
| `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
|
|
31
|
+
| `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
|
|
31
32
|
| `source.ts` | `SqlSource` contract + `from()` in-memory reference |
|
|
32
33
|
| `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
|
|
33
34
|
| `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
|
|
@@ -207,6 +208,20 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
207
208
|
--json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
|
|
208
209
|
packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
|
|
209
210
|
and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
|
|
211
|
+
- **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
|
|
212
|
+
(`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
|
|
213
|
+
term, and calls `.search(term)` on the chain the app hands it — which is what makes the term
|
|
214
|
+
unable to arrive any other way. The chain crosses **structurally** (`SearchChain`): this package
|
|
215
|
+
may import `@ultimat3/entity` and nothing here does, so four methods are not worth a new
|
|
216
|
+
`package.json` edge and a new `bun.lock` block — the trade `@ultimat3/db`'s `entity-shape.ts`
|
|
217
|
+
makes one tier down. It needs no row in `PRIMITIVE_FACTORIES`: that table is for a factory
|
|
218
|
+
returning an `action` or a `job` from OUTSIDE the primitive's own package, and this returns a
|
|
219
|
+
query from the query package. **It serves ONE page and refuses a second**, because a `SqlSource`
|
|
220
|
+
is handed a `SeekKey` and the entity chain wants its own signed, plan-scoped cursor — there is no
|
|
221
|
+
minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
|
|
222
|
+
inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
|
|
223
|
+
on no page at all is the defect 12.0.0 spent a release removing from the timestamp seek; the
|
|
224
|
+
`fix:` names the entity chain, which pages this read correctly.
|
|
210
225
|
- Policy runs per subscriber for live queries. Never cache a decision across actors.
|
|
211
226
|
- The matcher patches from `QueryShape`, never from SQL text.
|
|
212
227
|
- `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
|
package/README.md
CHANGED
|
@@ -172,6 +172,54 @@ server memory bought with an occasional extra page fetch.
|
|
|
172
172
|
An honest refusal beats a silently wrong result set, so unsupported shapes fail at
|
|
173
173
|
**subscribe** time, not on the first change event.
|
|
174
174
|
|
|
175
|
+
## `search()` — the query factory over a searchable entity
|
|
176
|
+
|
|
177
|
+
A model call is an `action` and a sweep is a `job`; a search is a **read**, so `search()` returns a
|
|
178
|
+
`query`. It inherits the policy, the cache tags, the MCP tool, the typed client, the route and its
|
|
179
|
+
manifest row — there is no ninth primitive.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { type QueryPolicy, search, type SearchChain, t } from '@ultimat3/query';
|
|
183
|
+
|
|
184
|
+
interface PostRow {
|
|
185
|
+
readonly id: string;
|
|
186
|
+
readonly title: string;
|
|
187
|
+
readonly createdAt: Date;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `@ultimat3/entity`'s chain, crossed STRUCTURALLY: a real `db.posts` satisfies `SearchChain`
|
|
191
|
+
// as written, so this package holds no dependency edge on entity.
|
|
192
|
+
declare const db: {
|
|
193
|
+
readonly posts: {
|
|
194
|
+
where(filter: { readonly orgId: string }): {
|
|
195
|
+
orderBy(column: keyof PostRow & string, direction: 'asc' | 'desc'): SearchChain<PostRow>;
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
declare const postRead: QueryPolicy;
|
|
200
|
+
declare const orgId: string;
|
|
201
|
+
|
|
202
|
+
export const searchPosts = search({
|
|
203
|
+
input: { orgId: t.uuid }, // your own keys — `q` and `limit` are added
|
|
204
|
+
policy: postRead,
|
|
205
|
+
page: { max: 50, default: 20 },
|
|
206
|
+
// The chain WITHOUT the term: tenancy, filters, and the order the page is served in.
|
|
207
|
+
in: ({ input }) => db.posts.where({ orgId: input.orgId }).orderBy('createdAt', 'desc'),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// A query is CALLABLE — there is no `.run()`; `.as()`, `.page()` and `.client()` are the rest.
|
|
211
|
+
await searchPosts({ orgId, q: 'cats -dogs "exact phrase"' });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is what makes the term
|
|
215
|
+
unable to arrive any other way. A blank term is refused rather than answered with no rows — an
|
|
216
|
+
empty box is not an empty result set.
|
|
217
|
+
|
|
218
|
+
**It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
|
|
219
|
+
that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
|
|
220
|
+
page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
|
|
221
|
+
refused, and the `fix:` names the chain — `db.posts.search(term).after(cursor).page()`.
|
|
222
|
+
|
|
175
223
|
## Pagination is cursor-only
|
|
176
224
|
|
|
177
225
|
`offset` does not exist in this package on purpose: it makes the database count rows it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/http": "
|
|
41
|
-
"@ultimat3/policy": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/cache": "14.0.0",
|
|
39
|
+
"@ultimat3/core": "14.0.0",
|
|
40
|
+
"@ultimat3/http": "14.0.0",
|
|
41
|
+
"@ultimat3/policy": "14.0.0",
|
|
42
|
+
"@ultimat3/schema": "14.0.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/index.ts
CHANGED
|
@@ -133,6 +133,9 @@ export {
|
|
|
133
133
|
registerQuery,
|
|
134
134
|
resetRegistry,
|
|
135
135
|
} from './registry';
|
|
136
|
+
/** The query FACTORY over an entity's searchable columns — a `query`, never a ninth primitive. */
|
|
137
|
+
export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search';
|
|
138
|
+
export { search } from './search';
|
|
136
139
|
export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
137
140
|
/**
|
|
138
141
|
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `search()` — a QUERY FACTORY over an entity's `.searchable()` columns, the shape `llm()` and
|
|
3
|
+
* `backfill()` already have. It returns a `query`, so a search inherits the policy, the cache tags,
|
|
4
|
+
* the MCP tool, the typed client, the route and its manifest row rather than becoming a ninth
|
|
5
|
+
* primitive. What it adds is the one thing a hand-written read gets wrong: the term never becomes
|
|
6
|
+
* syntax, and the tenant predicate is never optional.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assert, type Ctx } from '@ultimat3/core';
|
|
10
|
+
import type { InferOutput, Shape, Simplify } from '@ultimat3/schema';
|
|
11
|
+
import { t } from '@ultimat3/schema';
|
|
12
|
+
import type { QueryPolicy } from './policy-gate';
|
|
13
|
+
import type { QueryCache, QueryMcp, QueryRateLimit } from './query';
|
|
14
|
+
import { query } from './query';
|
|
15
|
+
import type { SeekKey } from './shape';
|
|
16
|
+
import type { SqlSource, SqlText } from './source';
|
|
17
|
+
import { from } from './source';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What `@ultimat3/entity`'s `ReadBuilder` answers, crossed STRUCTURALLY.
|
|
21
|
+
*
|
|
22
|
+
* This package may import `@ultimat3/entity` (tier 2) and deliberately does not: nothing here does
|
|
23
|
+
* today, so a real dependency would be a new edge in `package.json` and a new block in `bun.lock`
|
|
24
|
+
* for four methods. Same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down, and the same
|
|
25
|
+
* discipline — the shape is the contract, and a chain that does not satisfy it does not compile.
|
|
26
|
+
*/
|
|
27
|
+
export interface SearchChain<Row extends object> {
|
|
28
|
+
/** Appends the full-text predicate. The TERM, never a tsquery. */
|
|
29
|
+
search(term: string): SearchChain<Row>;
|
|
30
|
+
limit(rows: number): SearchChain<Row>;
|
|
31
|
+
all(): Promise<readonly Row[]>;
|
|
32
|
+
/** Only `entity` is read — the name the `SqlSource` and every cache tag are keyed by. */
|
|
33
|
+
plan(): { readonly entity: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SearchPage {
|
|
37
|
+
/** The largest page this read will serve. Bounded in the INPUT SCHEMA, so a client cannot ask past it. */
|
|
38
|
+
readonly max?: number;
|
|
39
|
+
readonly default?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_PAGE_MAX = 100;
|
|
43
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
44
|
+
/**
|
|
45
|
+
* The longest term accepted. A `tsquery` past ~1 MB is a server error, and a search box does not
|
|
46
|
+
* send a novel — the bound belongs in the schema so it is refused before a statement exists.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_TERM_MAX = 200;
|
|
49
|
+
|
|
50
|
+
export interface SearchDef<S extends Shape, Row extends object> {
|
|
51
|
+
/** The read's own input keys, beside `q` and `limit` — a tenant id, a status filter, a date. */
|
|
52
|
+
readonly input?: S;
|
|
53
|
+
readonly policy: QueryPolicy;
|
|
54
|
+
/**
|
|
55
|
+
* The chain this search runs on, WITHOUT the term: the tenancy, the filters and the ordering the
|
|
56
|
+
* page is served in. `search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is
|
|
57
|
+
* what makes the term unable to arrive any other way.
|
|
58
|
+
*/
|
|
59
|
+
in(args: { readonly input: SearchInput<S>; readonly ctx: Ctx }): SearchChain<Row>;
|
|
60
|
+
readonly termMax?: number;
|
|
61
|
+
readonly page?: SearchPage;
|
|
62
|
+
readonly cache?: QueryCache;
|
|
63
|
+
readonly mcp?: QueryMcp;
|
|
64
|
+
readonly rateLimit?: QueryRateLimit;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type SearchShape<S extends Shape> = Simplify<
|
|
68
|
+
S & { q: ReturnType<typeof termSchema>; limit: ReturnType<typeof limitSchema> }
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
export type SearchInput<S extends Shape> = InferOutput<ReturnType<typeof t.object<SearchShape<S>>>>;
|
|
72
|
+
|
|
73
|
+
const termSchema = (max: number) => t.string.min(1).max(max);
|
|
74
|
+
|
|
75
|
+
const limitSchema = (max: number, fallback: number) =>
|
|
76
|
+
t.number.int().min(1).max(max).default(fallback);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A search serves ONE page, and asking for a second is refused rather than answered wrongly.
|
|
80
|
+
*
|
|
81
|
+
* The rows come from the entity chain, which pages by its own keyset cursor — proven against a real
|
|
82
|
+
* server in `packages/entity/src/pg-search.live.test.ts`. That cursor cannot cross this seam: a
|
|
83
|
+
* `SqlSource` is handed a `SeekKey` (the previous page's sort VALUES) and the chain wants its own
|
|
84
|
+
* signed, plan-scoped string, and there is no way to mint one from the other here. Falling through
|
|
85
|
+
* to `paginate`'s in-memory slice would cut inside the one page the provider fetched and report
|
|
86
|
+
* `hasNextPage: false` at its edge — rows served on no page at all, which is the defect 12.0.0 spent
|
|
87
|
+
* a release removing from the timestamp seek. So it is a refusal with the alternative in the `fix`:
|
|
88
|
+
* page with the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
|
|
89
|
+
*/
|
|
90
|
+
const onePage = <Row extends object>(base: SqlSource<Row>, entity: string): SqlSource<Row> => ({
|
|
91
|
+
toSQL: (): SqlText => base.toSQL(),
|
|
92
|
+
execute: () => base.execute(),
|
|
93
|
+
shape: () => base.shape(),
|
|
94
|
+
...(base.total === undefined ? {} : { total: () => onePage(base.total?.() ?? base, entity) }),
|
|
95
|
+
seek: (after: SeekKey | null, limit: number): SqlSource<Row> => {
|
|
96
|
+
assert(
|
|
97
|
+
after === null,
|
|
98
|
+
`search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry`,
|
|
99
|
+
`db.${entity}.search(term).orderBy('<key>').after(cursor).page() # the entity chain pages this read — or raise its limit`,
|
|
100
|
+
);
|
|
101
|
+
return onePage(base.seek?.(after, limit) ?? base, entity);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The factory. `live: false` and the source declares itself unpatchable, because the incremental
|
|
107
|
+
* matcher decides membership from `QueryShape` filters and a `tsvector` match is not one of them —
|
|
108
|
+
* a live search would have to re-read on every write to the table.
|
|
109
|
+
*/
|
|
110
|
+
export const search = <Row extends object, S extends Shape = Record<string, never>>(
|
|
111
|
+
def: SearchDef<S, Row>,
|
|
112
|
+
) => {
|
|
113
|
+
const page = def.page ?? {};
|
|
114
|
+
const max = page.max ?? DEFAULT_PAGE_MAX;
|
|
115
|
+
const shape = {
|
|
116
|
+
...((def.input ?? {}) as S),
|
|
117
|
+
q: termSchema(def.termMax ?? DEFAULT_TERM_MAX),
|
|
118
|
+
limit: limitSchema(max, Math.min(page.default ?? DEFAULT_PAGE_SIZE, max)),
|
|
119
|
+
} as SearchShape<S>;
|
|
120
|
+
|
|
121
|
+
return query({
|
|
122
|
+
input: t.object(shape),
|
|
123
|
+
policy: def.policy,
|
|
124
|
+
live: false,
|
|
125
|
+
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
126
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
127
|
+
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
|
128
|
+
sql: (input, ctx) => {
|
|
129
|
+
const parsed = input as SearchInput<S> & { readonly q: string; readonly limit: number };
|
|
130
|
+
// Trimmed and refused HERE, before the chain exists: `websearch_to_tsquery('english', ' ')`
|
|
131
|
+
// is a legal empty tsquery matching nothing, so a blank box would answer "no results" as if
|
|
132
|
+
// it had searched. Saying so is the difference between an empty answer and an empty question.
|
|
133
|
+
const term = parsed.q.trim();
|
|
134
|
+
assert(
|
|
135
|
+
term.length > 0,
|
|
136
|
+
'a search term of only whitespace is not a search',
|
|
137
|
+
'guard the input before calling: if (term.trim() === "") return [] — an empty box is not an empty result set',
|
|
138
|
+
);
|
|
139
|
+
const chain = def.in({ input: parsed, ctx });
|
|
140
|
+
const name = chain.plan().entity;
|
|
141
|
+
const rows = () => chain.search(term).limit(parsed.limit).all();
|
|
142
|
+
return onePage(from<Row>(name, rows).raw('full-text search'), name);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
};
|