@ultimat3/query 16.0.0 → 17.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 +21 -1
- package/package.json +6 -6
- package/src/read.ts +8 -1
- package/src/search.ts +83 -11
package/CLAUDE.md
CHANGED
|
@@ -208,6 +208,11 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
208
208
|
--json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
|
|
209
209
|
packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
|
|
210
210
|
and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
|
|
211
|
+
- **Every numeric option is refused when it is not a FINITE number, `As of 2026-08-26`.**
|
|
212
|
+
`@ultimat3/core`'s `finiteOption()` guards the read cache's `ttlMs` and `search()`'s `page.max`,
|
|
213
|
+
`page.default` and `termMax`; `bun run finite-bounds` is the ratchet and this package is pinned
|
|
214
|
+
at **zero**. `??` guards nullish and `NaN` is not, so an unchecked bound is a comparison that
|
|
215
|
+
reads false forever — and `Math.max`/`Math.floor` propagate `NaN` rather than validating it.
|
|
211
216
|
- **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
|
|
212
217
|
(`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
|
|
213
218
|
term, and calls `.search(term)` on the chain the app hands it — which is what makes the term
|
|
@@ -221,7 +226,22 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
221
226
|
minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
|
|
222
227
|
inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
|
|
223
228
|
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.
|
|
229
|
+
`fix:` names the entity chain, which pages this read correctly. **The refusal is on the rows that
|
|
230
|
+
would be CUT, never on the window**, `As of 2026-08-26`: a screen demanding `first >= limit`
|
|
231
|
+
refuses the framework's own default pair — `limit` defaults to 20 and `first` arrives from a
|
|
232
|
+
client — so every `pageSize` under 20 was a 500 at page ONE, including a search matching three
|
|
233
|
+
rows that fit the window twice over. `windowOf.execute` asserts on `rows.length` instead, so a
|
|
234
|
+
page that is whole is served with `hasNextPage: false` and no cursor is ever handed back that a
|
|
235
|
+
second call is guaranteed to refuse. **The one page it serves is the
|
|
236
|
+
chain's own ORDER**, `As of 2026-08-26`: `onePage.seek` narrows the window with `Builder.limit()`
|
|
237
|
+
and never with `Builder.seek()`, which sets `totalized` — `servedOrder()` is then `totalOrder([])`
|
|
238
|
+
= `id asc`, because the ranking lives inside the chain behind the row thunk and no `OrderKey` here
|
|
239
|
+
can name it, and `execute()` re-sorts the page the provider already ranked. Measured with a chain
|
|
240
|
+
serving `z, m, a`: `runQuery` answered `z, m, a` and `.page(input, { first: 2 })` answered `a, m`,
|
|
241
|
+
dropping the top-ranked row off page one. It exposes no `total()` for the same reason — an absent
|
|
242
|
+
`total` is how `SqlSource` says the source already serves one order it can be resumed in. The
|
|
243
|
+
fixture in `search.test.ts` must stay NOT id-ascending: `ROWS` is `a, b, c`, which is why the
|
|
244
|
+
shipped page test could not see this.
|
|
225
245
|
- Policy runs per subscriber for live queries. Never cache a decision across actors.
|
|
226
246
|
- The matcher patches from `QueryShape`, never from SQL text.
|
|
227
247
|
- `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.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": "17.0.0",
|
|
39
|
+
"@ultimat3/core": "17.0.0",
|
|
40
|
+
"@ultimat3/http": "17.0.0",
|
|
41
|
+
"@ultimat3/policy": "17.0.0",
|
|
42
|
+
"@ultimat3/schema": "17.0.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/read.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
anonymousActor,
|
|
11
11
|
assert,
|
|
12
12
|
createContext,
|
|
13
|
+
finiteOption,
|
|
13
14
|
logger,
|
|
14
15
|
runWithContext,
|
|
15
16
|
tryUseContext,
|
|
@@ -186,7 +187,13 @@ async function readRowsIn(
|
|
|
186
187
|
// the process — a paginated feed over 10k tenants is 10k immortal entries.
|
|
187
188
|
return def.cache === undefined
|
|
188
189
|
? await readOnce(ctx, key, read)
|
|
189
|
-
: await readThrough(
|
|
190
|
+
: await readThrough(
|
|
191
|
+
ctx,
|
|
192
|
+
key,
|
|
193
|
+
finiteOption('the read cache', 'ttlMs', def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS),
|
|
194
|
+
read,
|
|
195
|
+
tags,
|
|
196
|
+
);
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
async function buildSource(
|
package/src/search.ts
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* syntax, and the tenant predicate is never optional.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { assert, type Ctx } from '@ultimat3/core';
|
|
9
|
+
import { assert, type Ctx, finiteOption } from '@ultimat3/core';
|
|
10
10
|
import type { InferOutput, Shape, Simplify } from '@ultimat3/schema';
|
|
11
11
|
import { t } from '@ultimat3/schema';
|
|
12
12
|
import type { QueryPolicy } from './policy-gate';
|
|
13
13
|
import type { QueryCache, QueryMcp, QueryRateLimit } from './query';
|
|
14
14
|
import { query } from './query';
|
|
15
15
|
import type { SeekKey } from './shape';
|
|
16
|
-
import type { SqlSource, SqlText } from './source';
|
|
16
|
+
import type { Builder, SqlSource, SqlText } from './source';
|
|
17
17
|
import { from } from './source';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -76,7 +76,8 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
76
76
|
t.number.int().min(1).max(max).default(fallback);
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* A search serves ONE page, and asking for a second is refused
|
|
79
|
+
* A search serves ONE page, in the order the chain served it, and asking for a second is refused
|
|
80
|
+
* rather than answered wrongly.
|
|
80
81
|
*
|
|
81
82
|
* The rows come from the entity chain, which pages by its own keyset cursor — proven against a real
|
|
82
83
|
* server in `packages/entity/src/pg-search.live.test.ts`. That cursor cannot cross this seam: a
|
|
@@ -86,22 +87,90 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
86
87
|
* `hasNextPage: false` at its edge — rows served on no page at all, which is the defect 12.0.0 spent
|
|
87
88
|
* a release removing from the timestamp seek. So it is a refusal with the alternative in the `fix`:
|
|
88
89
|
* page with the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
|
|
90
|
+
*
|
|
91
|
+
* **The refusal is on the rows that would be CUT, never on the window** (`As of 2026-08-26`). It
|
|
92
|
+
* used to serve `first` rows, mint an `endCursor` and report `hasNextPage: true` — a connection
|
|
93
|
+
* protocol saying "call me again with this" for a call the cursor assert is guaranteed to throw on.
|
|
94
|
+
* A first repair demanded `first >= limit` and refused the window itself, which refuses the
|
|
95
|
+
* framework's OWN default pair: `limit` defaults to 20 and `first` arrives from a client
|
|
96
|
+
* (`{ first: query.pageSize }`), so every `pageSize` under 20 was a 500 at page ONE — including
|
|
97
|
+
* every search that matched three rows and fit the window twice over. A screen that fires on a
|
|
98
|
+
* request the read can answer completely is not protecting anyone.
|
|
99
|
+
*
|
|
100
|
+
* So the condition is the one thing that is actually wrong: the read answered MORE rows than the
|
|
101
|
+
* window carries, and this seam has no second page to put the rest on. Below that, the page is
|
|
102
|
+
* whole and `hasNextPage` is false by construction — the true stop signal, and no cursor is ever
|
|
103
|
+
* handed back that a second call is guaranteed to refuse.
|
|
104
|
+
*
|
|
105
|
+
* **`seek` narrows the WINDOW and never the ORDER, and that is the whole reason this wrapper takes a
|
|
106
|
+
* `Builder`.** `Builder.seek()` sets `totalized`, so `servedOrder()` becomes `totalOrder([])` —
|
|
107
|
+
* `id asc`, because the relevance ordering lives inside the chain behind the row thunk and no key
|
|
108
|
+
* here can name it — and `execute()` then re-sorts the page the provider already ranked. Measured
|
|
109
|
+
* with a chain serving `z, m, a`: `runQuery` answered `z, m, a` and `.page(input, { first: 2 })`
|
|
110
|
+
* answered `a, m`, dropping the top-ranked row off page one. `limit()` is the same window with no
|
|
111
|
+
* ordering claim attached, which is what an already-ranked page needs.
|
|
89
112
|
*/
|
|
90
|
-
const onePage = <Row extends object>(
|
|
113
|
+
const onePage = <Row extends object>(
|
|
114
|
+
base: Builder<Row>,
|
|
115
|
+
entity: string,
|
|
116
|
+
/** Rows the chain was capped at — this read's `limit` input, and its whole page. */
|
|
117
|
+
served: number,
|
|
118
|
+
): SqlSource<Row> => ({
|
|
91
119
|
toSQL: (): SqlText => base.toSQL(),
|
|
92
120
|
execute: () => base.execute(),
|
|
93
121
|
shape: () => base.shape(),
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
// No `total()`: `SqlSource` reads its absence as "the source already serves one order it can be
|
|
123
|
+
// resumed in", which is exactly true of a relevance ranking — and `total()` would totalize the
|
|
124
|
+
// Builder, re-sorting that ranking by id for the same reason `seek` must not.
|
|
125
|
+
seek: (after: SeekKey | null, window: number): SqlSource<Row> => {
|
|
96
126
|
assert(
|
|
97
127
|
after === null,
|
|
98
128
|
`search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry`,
|
|
99
129
|
`db.${entity}.search(term).orderBy('<key>').after(cursor).page() # the entity chain pages this read — or raise its limit`,
|
|
100
130
|
);
|
|
101
|
-
|
|
131
|
+
// `paginate` asks for `first + 1` — the extra row IS `hasNextPage` — so the window it names is
|
|
132
|
+
// one wider than the page it will serve.
|
|
133
|
+
return windowOf(base, entity, served, window - 1);
|
|
102
134
|
},
|
|
103
135
|
});
|
|
104
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The one page, narrowed to the window `paginate` asked for and refused only when it does not FIT.
|
|
139
|
+
*
|
|
140
|
+
* The check is on the rows that came back, not on the two numbers, because those two numbers cannot
|
|
141
|
+
* decide it: `first: 10` against a `limit` of 20 is a complete answer whenever the term matched ten
|
|
142
|
+
* rows or fewer, which is most searches. Refusing it on the declaration alone made the framework's
|
|
143
|
+
* own defaults a 500.
|
|
144
|
+
*
|
|
145
|
+
* The `fix:` names `served` — the read's declared `limit` — and never the count that came back: a
|
|
146
|
+
* window sized to today's result set breaks on the first row added to the corpus.
|
|
147
|
+
*/
|
|
148
|
+
const windowOf = <Row extends object>(
|
|
149
|
+
base: Builder<Row>,
|
|
150
|
+
entity: string,
|
|
151
|
+
served: number,
|
|
152
|
+
/** Rows `paginate` will serve. It fetched one more, to decide `hasNextPage`. */
|
|
153
|
+
first: number,
|
|
154
|
+
): SqlSource<Row> => {
|
|
155
|
+
const windowed = base.limit(first + 1);
|
|
156
|
+
return {
|
|
157
|
+
toSQL: (): SqlText => windowed.toSQL(),
|
|
158
|
+
shape: () => windowed.shape(),
|
|
159
|
+
// No `seek()` and no `total()`: `paginate` seeks once, and `SqlSource` reads an absent `total`
|
|
160
|
+
// as "the source already serves one order it can be resumed in" — exactly true of a relevance
|
|
161
|
+
// ranking, which `total()` would re-sort by id for the same reason `seek` must not.
|
|
162
|
+
execute: async () => {
|
|
163
|
+
const rows = await windowed.execute();
|
|
164
|
+
assert(
|
|
165
|
+
rows.length <= first,
|
|
166
|
+
`search of ${entity} answered more than the ${String(first)} rows .page() asked for, and this read has no second page: the rest would be on no page at all`,
|
|
167
|
+
`read.page(input, { first: ${String(served)} }) # widen the window to the read's whole page — or narrow the page: read({ q, limit: ${String(first)} })`,
|
|
168
|
+
);
|
|
169
|
+
return rows;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
105
174
|
/**
|
|
106
175
|
* The factory. `live: false` and the source declares itself unpatchable, because the incremental
|
|
107
176
|
* matcher decides membership from `QueryShape` filters and a `tsvector` match is not one of them —
|
|
@@ -111,11 +180,14 @@ export const search = <Row extends object, S extends Shape = Record<string, neve
|
|
|
111
180
|
def: SearchDef<S, Row>,
|
|
112
181
|
) => {
|
|
113
182
|
const page = def.page ?? {};
|
|
114
|
-
const max = page.max ?? DEFAULT_PAGE_MAX;
|
|
183
|
+
const max = finiteOption('search()', 'page.max', page.max ?? DEFAULT_PAGE_MAX);
|
|
115
184
|
const shape = {
|
|
116
185
|
...((def.input ?? {}) as S),
|
|
117
|
-
q: termSchema(def.termMax ?? DEFAULT_TERM_MAX),
|
|
118
|
-
limit: limitSchema(
|
|
186
|
+
q: termSchema(finiteOption('search()', 'termMax', def.termMax ?? DEFAULT_TERM_MAX)),
|
|
187
|
+
limit: limitSchema(
|
|
188
|
+
max,
|
|
189
|
+
Math.min(finiteOption('search()', 'page.default', page.default ?? DEFAULT_PAGE_SIZE), max),
|
|
190
|
+
),
|
|
119
191
|
} as SearchShape<S>;
|
|
120
192
|
|
|
121
193
|
return query({
|
|
@@ -139,7 +211,7 @@ export const search = <Row extends object, S extends Shape = Record<string, neve
|
|
|
139
211
|
const chain = def.in({ input: parsed, ctx });
|
|
140
212
|
const name = chain.plan().entity;
|
|
141
213
|
const rows = () => chain.search(term).limit(parsed.limit).all();
|
|
142
|
-
return onePage(from<Row>(name, rows).raw('full-text search'), name);
|
|
214
|
+
return onePage(from<Row>(name, rows).raw('full-text search'), name, parsed.limit);
|
|
143
215
|
},
|
|
144
216
|
});
|
|
145
217
|
};
|