@ultimat3/query 16.0.0 → 18.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 CHANGED
@@ -22,6 +22,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
22
22
  | `naming.ts` | export name → `/_x/query/<kebab>`. Pure string math. **Paths only** — no tool name |
23
23
  | `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
24
24
  | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
25
+ | `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
25
26
  | `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
26
27
  | `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
27
28
  | `cursor-value.ts` | what a sort value becomes inside a cursor, and what it becomes again |
@@ -208,6 +209,11 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
208
209
  --json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
209
210
  packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
210
211
  and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
212
+ - **Every numeric option is refused when it is not a FINITE number, `As of 2026-08-26`.**
213
+ `@ultimat3/core`'s `finiteOption()` guards the read cache's `ttlMs` and `search()`'s `page.max`,
214
+ `page.default` and `termMax`; `bun run finite-bounds` is the ratchet and this package is pinned
215
+ at **zero**. `??` guards nullish and `NaN` is not, so an unchecked bound is a comparison that
216
+ reads false forever — and `Math.max`/`Math.floor` propagate `NaN` rather than validating it.
211
217
  - **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
212
218
  (`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
213
219
  term, and calls `.search(term)` on the chain the app hands it — which is what makes the term
@@ -221,7 +227,44 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
221
227
  minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
222
228
  inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
223
229
  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.
230
+ `fix:` names the entity chain, which pages this read correctly. **The refusal is on the rows that
231
+ would be CUT, never on the window**, `As of 2026-08-26`: a screen demanding `first >= limit`
232
+ refuses the framework's own default pair — `limit` defaults to 20 and `first` arrives from a
233
+ client — so every `pageSize` under 20 was a 500 at page ONE, including a search matching three
234
+ rows that fit the window twice over. `windowOf.execute` asserts on `rows.length` instead, so a
235
+ page that is whole is served with `hasNextPage: false` and no cursor is ever handed back that a
236
+ second call is guaranteed to refuse. **The one page it serves is the
237
+ chain's own ORDER**, `As of 2026-08-26`: `onePage.seek` narrows the window with `Builder.limit()`
238
+ and never with `Builder.seek()`, which sets `totalized` — `servedOrder()` is then `totalOrder([])`
239
+ = `id asc`, because the ranking lives inside the chain behind the row thunk and no `OrderKey` here
240
+ can name it, and `execute()` re-sorts the page the provider already ranked. Measured with a chain
241
+ serving `z, m, a`: `runQuery` answered `z, m, a` and `.page(input, { first: 2 })` answered `a, m`,
242
+ dropping the top-ranked row off page one. It exposes no `total()` for the same reason — an absent
243
+ `total` is how `SqlSource` says the source already serves one order it can be resumed in. The
244
+ fixture in `search.test.ts` must stay NOT id-ascending: `ROWS` is `a, b, c`, which is why the
245
+ shipped page test could not see this.
246
+ - **`subscribes:` is DECLARED because nothing can derive it, and CROSS-CHECKED because a
247
+ declaration drifts** (`subscribes.ts`, `As of 2026-08-26`). `@ultimat3/realtime` refuses a
248
+ subscription to a table that is not `REPLICA IDENTITY FULL` — logical replication carries no old
249
+ row on an `UPDATE`, so no patch is computable — and nothing emitted the `alter`, so a scaffolded
250
+ app with a live query generated a schema its own preflight rejected (#357). It cannot be derived:
251
+ the relation name is a string inside `sql:`, a callback no generator can invoke without valid
252
+ input, so `QueryDescriptor` carried no table and `x db gen` had no live-query set to read. The
253
+ field is optional and a read that declares nothing is unchanged — the emitter sees no table.
254
+ The cross-check is what makes it worth shipping: `toLiveQuery` asserts `shape.entity` is among the
255
+ declared names at the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), one line under
256
+ `assertMatchable` and for its reason — a live read whose old row never arrives cannot be patched,
257
+ so a WARNING would preserve only a subscription that silently stops updating, and the stale
258
+ declaration has already granted the identity to the wrong table. `pg-preflight.ts`'s
259
+ `warnPartialIdentity` warns instead, and that precedent does NOT transfer: it fires for every app
260
+ on the postgres default, which nobody wrote, while this fires only on a declaration an author
261
+ wrote wrong. **Membership, never equality** — a `QueryShape` names one relation and a read may
262
+ join several, and `@ultimat3/db` keeps only the declared names an entity's table matches, so an
263
+ extra name costs nothing and the resolved one going missing is the whole failure. An empty list,
264
+ or one on a read that is not `live: true`, is refused at `query()`
265
+ (`X_QUERY_SUBSCRIBES_INVALID`) beside `assertCacheTtl`: a declaration nothing can act on is wrong
266
+ for every subscriber, and one on a non-live read would never reach the cross-check at all —
267
+ declared and never wired, which is the defect this field exists to close.
225
268
  - Policy runs per subscriber for live queries. Never cache a decision across actors.
226
269
  - The matcher patches from `QueryShape`, never from SQL text.
227
270
  - `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
package/README.md CHANGED
@@ -151,6 +151,42 @@ forgetting the policy, and the reason is what tells the next reader which of the
151
151
  | `policy` | evaluated **per subscriber**, on subscribe and on every fanout |
152
152
  | cursor | reconnect state: epoch + query hash + version + seek key |
153
153
 
154
+ ### `subscribes:` — the one fact about a live read nothing can derive
155
+
156
+ ```ts
157
+ import { from, type QueryPolicy, query, t } from '@ultimat3/query';
158
+
159
+ interface PostSummary {
160
+ readonly id: string;
161
+ readonly orgId: string;
162
+ readonly createdAt: Date;
163
+ }
164
+
165
+ declare const feedRead: QueryPolicy;
166
+ declare const repo: () => Promise<readonly PostSummary[]>;
167
+
168
+ export const liveFeed = query({
169
+ input: t.object({ orgId: t.uuid }),
170
+ policy: feedRead,
171
+ live: true,
172
+ subscribes: ['posts'], // the table `x db gen` grants REPLICA IDENTITY FULL
173
+ sql: ({ orgId }) => from<PostSummary>('posts', repo).where({ orgId }).orderBy('createdAt'),
174
+ });
175
+ ```
176
+
177
+ Logical replication carries no old row on an `UPDATE` unless the table is `REPLICA IDENTITY FULL`,
178
+ so no patch can be computed and `@ultimat3/realtime` refuses the subscription. The table name lives
179
+ inside `sql:` — a callback nothing can invoke without valid input — so `x db gen` has no way to
180
+ work it out and needs it declared.
181
+
182
+ Optional: a read that declares nothing behaves exactly as it did, and the generator emits nothing
183
+ for it. It is **checked, not trusted** — `toLiveQuery` refuses at the first subscribe when the
184
+ relation the read resolves to is not among the declared names (`X_QUERY_SUBSCRIBES_DRIFT`), because
185
+ a stale declaration grants the identity to the wrong table and leaves the right one unpatchable in
186
+ silence. An empty list, or one on a read that is not `live: true`, is refused at `query()`
187
+ (`X_QUERY_SUBSCRIBES_INVALID`). Naming more relations than the shape resolves to is legal — a join
188
+ reads relations the shape's single `entity` cannot name.
189
+
154
190
  ### Cursor tradeoff (the reconnect risk, stated plainly)
155
191
 
156
192
  A cursor is not a snapshot. Resume re-runs the **bounded** query from the seek key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "16.0.0",
3
+ "version": "18.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",
@@ -28,17 +28,17 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "engines": {
31
- "bun": ">=1.3.0"
31
+ "bun": ">=1.4.0"
32
32
  },
33
33
  "scripts": {
34
34
  "typecheck": "tsc --noEmit -p tsconfig.json",
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/cache": "16.0.0",
39
- "@ultimat3/core": "16.0.0",
40
- "@ultimat3/http": "16.0.0",
41
- "@ultimat3/policy": "16.0.0",
42
- "@ultimat3/schema": "16.0.0"
38
+ "@ultimat3/cache": "18.0.0",
39
+ "@ultimat3/core": "18.0.0",
40
+ "@ultimat3/http": "18.0.0",
41
+ "@ultimat3/policy": "18.0.0",
42
+ "@ultimat3/schema": "18.0.0"
43
43
  }
44
44
  }
package/src/errors.ts CHANGED
@@ -23,6 +23,8 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
23
23
  X_QUERY_INPUT_UNENCODABLE: 'a query input cannot be carried in a query string',
24
24
  X_QUERY_NOT_PAGEABLE: 'a read returned rows with no id, so a cursor cannot name a position',
25
25
  X_QUERY_POLICY_MISSING: 'a query was registered without a policy',
26
+ X_QUERY_SUBSCRIBES_DRIFT: 'a live query subscribes to a relation its own sql never reads',
27
+ X_QUERY_SUBSCRIBES_INVALID: 'a query declares subscribed relations no live read can use',
26
28
  X_QUERY_UNREGISTERED: 'a query was used before it was registered',
27
29
  };
28
30
 
@@ -248,6 +250,52 @@ export class MatcherUnsupportedError extends UltimateError {
248
250
  }
249
251
  }
250
252
 
253
+ /**
254
+ * A `subscribes:` declaration that cannot mean anything, refused at `query()` so the file that
255
+ * wrote it is the file that fails — the rule `assertCacheTtl` follows one field over.
256
+ *
257
+ * `problem` is a closed union rather than two classes: both are the same author mistake in the
258
+ * same declaration, and a caller catching one wants the other too.
259
+ */
260
+ export class QuerySubscribesInvalidError extends UltimateError {
261
+ constructor(problem: 'empty' | 'not-live') {
262
+ super({
263
+ code: 'X_QUERY_SUBSCRIBES_INVALID',
264
+ cause:
265
+ problem === 'empty'
266
+ ? 'subscribes: [] names no relation, so no table can be granted REPLICA IDENTITY FULL'
267
+ : 'subscribes: names relations on a read that is not live, and only a live read subscribes',
268
+ fix:
269
+ problem === 'empty'
270
+ ? "name the relation the read selects from — subscribes: ['posts'] — or drop the field"
271
+ : 'add `live: true` beside it, or delete `subscribes:` — nothing reads it on a plain read',
272
+ });
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The declaration and the read disagreed. Thrown at the first subscribe, where the resolved shape
278
+ * is the first thing in the framework that can answer which relation the read really names.
279
+ *
280
+ * A refusal and not a warning, for the reason `assertMatchable` refuses one line above it: a live
281
+ * query whose old row never arrives cannot be patched, so what a warning would preserve is a
282
+ * subscription that silently stops updating. `x db gen` reads this declaration, so a stale one has
283
+ * already granted REPLICA IDENTITY FULL to the wrong table and left this one without it.
284
+ */
285
+ export class QuerySubscribesDriftError extends UltimateError {
286
+ constructor(name: string, declared: readonly string[], entity: string) {
287
+ super({
288
+ code: 'X_QUERY_SUBSCRIBES_DRIFT',
289
+ cause:
290
+ `live query "${name}" declares subscribes: [${declared.join(', ')}], ` +
291
+ `and its sql reads "${entity}", which is not among them`,
292
+ fix:
293
+ `add "${entity}" to that query's subscribes:, then run \`x db gen\` — ` +
294
+ 'the migration granting REPLICA IDENTITY FULL is keyed on the declared names',
295
+ });
296
+ }
297
+ }
298
+
251
299
  export class QueryInputInvalidError extends UltimateError {
252
300
  constructor(name: string, detail: string) {
253
301
  super({
package/src/facade.ts CHANGED
@@ -25,6 +25,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
25
25
  return {
26
26
  input: def.input,
27
27
  policy: def.policy,
28
+ ...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
28
29
  ...(def.cache === undefined ? {} : { cache: def.cache }),
29
30
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
30
31
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
package/src/index.ts CHANGED
@@ -73,6 +73,8 @@ export {
73
73
  QueryNotPageableError,
74
74
  QueryPolicyMissingError,
75
75
  QueryRequestFailedError,
76
+ QuerySubscribesDriftError,
77
+ QuerySubscribesInvalidError,
76
78
  QueryUnregisteredError,
77
79
  } from './errors';
78
80
  /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
package/src/live.ts CHANGED
@@ -18,6 +18,7 @@ import { queryHash } from './query';
18
18
  import { queryName, sourceFor } from './read';
19
19
  import type { QueryShape, SeekKey } from './shape';
20
20
  import { seekKeyOf } from './shape';
21
+ import { assertSubscribed } from './subscribes';
21
22
 
22
23
  /**
23
24
  * Reconnect state. Deliberately tiny — an id, a sort key and a version — because
@@ -123,6 +124,10 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
123
124
  const shape = source.shape();
124
125
  // Fail at subscribe time, not on the first change event nobody can patch.
125
126
  assertMatchable(name, shape);
127
+ // The same moment and the same reason, one declaration further out: a `subscribes:` the read has
128
+ // outgrown means `x db gen` granted REPLICA IDENTITY FULL somewhere else, so every UPDATE on this
129
+ // relation arrives without its old row and no patch is computable from it.
130
+ assertSubscribed(name, target.subscribes, shape.entity);
126
131
 
127
132
  const epoch = options.epoch ?? liveEpoch();
128
133
  const hash = queryHash(name, input);
package/src/query.ts CHANGED
@@ -24,6 +24,7 @@ import type { QueryPolicy, QuerySurface } from './policy-gate';
24
24
  import { policyCapability, policyPermissions } from './policy-gate';
25
25
  import { hasDef, queryName, runQuery, stashDef } from './read';
26
26
  import type { SqlSource } from './source';
27
+ import { assertSubscribes } from './subscribes';
27
28
 
28
29
  export interface QueryCache {
29
30
  /** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
@@ -72,6 +73,22 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
72
73
  readonly policy: QueryPolicy;
73
74
  /** `true` makes the read subscribable — see `toLiveQuery`. */
74
75
  readonly live?: boolean;
76
+ /**
77
+ * The relations this live read is patched from — the tables `x db gen` grants
78
+ * `REPLICA IDENTITY FULL`, without which logical replication carries no old row on an UPDATE
79
+ * and `@ultimat3/realtime` refuses the subscription.
80
+ *
81
+ * A DECLARATION because nothing can derive it: the relation name lives inside `sql:`, a callback
82
+ * no generator can invoke without valid input, so there is no live-query set to read off the
83
+ * registry (#357). Optional, and a read that declares nothing behaves exactly as before —
84
+ * the emitter sees no table and emits no statement.
85
+ *
86
+ * Checked, never trusted. An empty list or a read that is not live is refused here
87
+ * (`X_QUERY_SUBSCRIBES_INVALID`); a list the resolved shape is missing from is refused at the
88
+ * first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`). Naming MORE relations than the shape resolves to
89
+ * is legal — a join reads relations the shape's single `entity` cannot name.
90
+ */
91
+ readonly subscribes?: readonly string[];
75
92
  sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
76
93
  readonly cache?: QueryCache;
77
94
  readonly mcp?: QueryMcp;
@@ -140,6 +157,13 @@ export interface QueryDescriptor {
140
157
  */
141
158
  readonly permissions: readonly string[];
142
159
  readonly tags: readonly string[];
160
+ /**
161
+ * The relations declared on a live read, or `null` when it declared none — the fact
162
+ * `@ultimat3/cli` reads out of the manifest to tell `x db gen` which tables need
163
+ * `REPLICA IDENTITY FULL`. `null` and not `[]`: an empty list is refused at `query()`, so
164
+ * absence has exactly one meaning and the manifest can drop the key rather than write it empty.
165
+ */
166
+ readonly subscribes: readonly string[] | null;
143
167
  readonly ttlMs: number | null;
144
168
  readonly rateLimit: QueryRateLimit | null;
145
169
  readonly deprecated: Deprecation | null;
@@ -154,6 +178,7 @@ export interface AnyQueryDef {
154
178
  readonly input: StandardSchemaV1;
155
179
  readonly policy: QueryPolicy;
156
180
  readonly live?: boolean;
181
+ readonly subscribes?: readonly string[];
157
182
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
158
183
  readonly cache?: QueryCache;
159
184
  readonly mcp?: QueryMcp;
@@ -169,6 +194,8 @@ export interface AnyQuery {
169
194
  /** The declaration, minus `sql`: readable, and never a way to run it. */
170
195
  readonly input: StandardSchemaV1;
171
196
  readonly policy: QueryPolicy;
197
+ /** Lifted for the same reason `rateLimit` is: `toLiveQuery` cross-checks it without `defOf`. */
198
+ readonly subscribes?: readonly string[];
172
199
  readonly cache?: QueryCache;
173
200
  readonly mcp?: QueryMcp;
174
201
  /** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
@@ -212,6 +239,7 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
212
239
  Query<TInput, TRow>,
213
240
  | 'input'
214
241
  | 'policy'
242
+ | 'subscribes'
215
243
  | 'cache'
216
244
  | 'mcp'
217
245
  | 'rateLimit'
@@ -231,7 +259,13 @@ export function query<TInput extends StandardSchemaV1, TRow extends object>(
231
259
  // cannot carry is wrong for every call, and the file that declared it is where it is repaired.
232
260
  assertEncodableInput(def.input);
233
261
  assertCacheTtl(def.cache);
234
- return build(def, '');
262
+ assertSubscribes(def.subscribes, def.live);
263
+ // Snapshot and freeze BEFORE registration. `build()` stores `def` and `facadeFor()` exposes the
264
+ // same array, so a caller holding the literal it passed could mutate the list AFTER validation
265
+ // — and that list is what the manifest publishes and what `x db gen` grants REPLICA IDENTITY
266
+ // FULL from. A frozen copy makes the declaration the descriptor carries the one that was checked.
267
+ const subscribes = def.subscribes === undefined ? undefined : Object.freeze([...def.subscribes]);
268
+ return build(subscribes === undefined ? def : { ...def, subscribes }, '');
235
269
  }
236
270
 
237
271
  /**
@@ -302,6 +336,7 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
302
336
  // The flattened list, beside the label and never instead of it: one is read, one is matched.
303
337
  permissions: policyPermissions(target.policy),
304
338
  tags: tagKeys(target.cache?.tags ?? []),
339
+ subscribes: target.subscribes ?? null,
305
340
  ttlMs: target.cache?.ttlMs ?? null,
306
341
  rateLimit: target.rateLimit ?? null,
307
342
  deprecated: target.deprecated ?? null,
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(ctx, key, def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS, read, tags);
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 rather than answered wrongly.
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>(base: SqlSource<Row>, entity: string): SqlSource<Row> => ({
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
- ...(base.total === undefined ? {} : { total: () => onePage(base.total?.() ?? base, entity) }),
95
- seek: (after: SeekKey | null, limit: number): SqlSource<Row> => {
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
- return onePage(base.seek?.(after, limit) ?? base, entity);
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(max, Math.min(page.default ?? DEFAULT_PAGE_SIZE, max)),
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
  };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The relations a live read is patched from: the one thing about a query that cannot be derived,
3
+ * and the two assertions that keep the declaration honest. `x db gen` grants those relations
4
+ * `REPLICA IDENTITY FULL`, so a name that has gone stale is worse than none — it grants it to the
5
+ * wrong table and leaves the right one unable to produce a patch, in silence (#357).
6
+ */
7
+
8
+ import { QuerySubscribesDriftError, QuerySubscribesInvalidError } from './errors';
9
+
10
+ /**
11
+ * Judged at `query()`, beside `assertCacheTtl` and for its reason: a declaration nothing can act on
12
+ * is wrong for every subscriber of that read, so it fails on the line that wrote it.
13
+ *
14
+ * A read that is not live is refused rather than ignored. `subscribes:` has exactly one reader and
15
+ * that reader only walks live queries, so an accepted one would be a declaration nothing verifies
16
+ * and nothing emits — the shape of defect this field exists to close.
17
+ */
18
+ export function assertSubscribes(
19
+ subscribes: readonly string[] | undefined,
20
+ live: boolean | undefined,
21
+ ): void {
22
+ if (subscribes === undefined) return;
23
+ if (subscribes.length === 0) throw new QuerySubscribesInvalidError('empty');
24
+ if (live !== true) throw new QuerySubscribesInvalidError('not-live');
25
+ }
26
+
27
+ /**
28
+ * Judged at the first subscribe, because `shape.entity` is the first answer the framework has to
29
+ * "which relation does this read actually name" — the string sits inside `sql:`, a callback no
30
+ * static reader can invoke without valid input.
31
+ *
32
+ * Membership, never equality: a `QueryShape` names ONE relation while a read may join several, and
33
+ * `@ultimat3/db` keeps only the declared names an entity's table matches. So a name the shape never
34
+ * resolves to costs nothing, and the resolved one going MISSING is the whole failure.
35
+ */
36
+ export function assertSubscribed(
37
+ name: string,
38
+ subscribes: readonly string[] | undefined,
39
+ entity: string,
40
+ ): void {
41
+ if (subscribes === undefined) return;
42
+ if (subscribes.includes(entity)) return;
43
+ throw new QuerySubscribesDriftError(name, subscribes, entity);
44
+ }