@ultimat3/query 31.4.1 → 31.5.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
@@ -14,6 +14,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
14
14
  |---|---|
15
15
  | `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the front door for the read path |
16
16
  | `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in |
17
+ | `parsed-input.ts` | `parsedQueryInput`: the parse alone, the input every `(query, input)` key hashes |
17
18
  | `rows-override.ts`, `adapter.ts` | `@ultimat3/query/adapter`'s `overrideRows(query, rows \| fn \| undefined)`: canned rows where `sql()` is, answered in `buildSource` AFTER the parse and the policy, never cached (`rows-override.test.ts`). Never on the barrel — `@ultimat3/testing`'s `stub()` is its one caller |
18
19
  | `contract-test.ts` | `.contract()`, the generated suite. The three `X_CONTRACT_*` codes and `X_CONTRACT_DRIFT` are borrowed (`QUERY_BORROWED_ERROR_CODES`) |
19
20
  | `exercise.ts` | the exercise twin. "Reached" means `sql()` built a source, read off the refusal (`beforeSql`), because `read.ts` keeps no counter. No transaction: a read writes nothing |
@@ -217,7 +218,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
217
218
  `min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
218
219
  tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
219
220
  read. `Builder.seek()` takes the smaller limit too. `pagination-limit.test.ts`.
220
- - **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
221
+ - **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`). A
222
+ function is HANDED the `RowWindow` (`after`, `limit`, `order`, `filters`) on every `execute()`,
223
+ and the builder re-applies all four over its answer (`pagination-window.test.ts`).
221
224
  - **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
222
225
  never reintroduce a row lookup. The seek predicate is spelled out per key
223
226
  (`(a < $1) or (a = $2 and id > $3)`), never a row-value comparison.
@@ -252,14 +255,17 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
252
255
  `Map`, `Set`. `query-hash.test.ts` pins it at `queryHash` and `cacheKeyFor`. `stable.ts` keeps
253
256
  `columnOf`; core's `isJsonObject` is imported from core (`X_HELPER_COPY` refuses a re-export).
254
257
  - The cursor codec is `@ultimat3/core`'s; this package supplies only the scope (`queryHash(name,
255
- input)`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, core's — imported from `@ultimat3/core`, never re-exported).
258
+ input)`) — of the RAW input (known limit: a stripped key re-scopes a cursor; kept so outstanding
259
+ cursors stay valid). A live hash is the parsed input's (`builtFor`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, core's — imported from `@ultimat3/core`, never re-exported).
256
260
 
257
261
  ## Invariants — caching
258
262
 
259
263
  - **The request memo holds the read, not the rows**: `readOnce` publishes the in-flight promise
260
264
  before its first await; a rejection is evicted. `requestMemo(ctx)` is `Map<string, Promise<unknown>>`.
261
265
  - **Every read is memoized; only a `cache:` read goes through the tier** (`readThrough`). One key
262
- function, `cacheKeyFor`.
266
+ function, `cacheKeyFor`, over the PARSED input `admitAndBuild` returns — never `raw`, or a
267
+ stripped `?junk=N` is an entry and a read per N (`read-cache-key.test.ts`). Outside, a key
268
+ hashes `parsedQueryInput(query, raw)`.
263
269
  - **A cache key carries the read's AUTHORITY**: `readAuthority(actor, scope)` is its one producer and
264
270
  `cacheKeyFor`'s fourth argument is required. `scope` defaults to `'actor'`; `'tenant'` with no
265
271
  `orgId` (`undefined`, `''` or `null` — `orgless()`) narrows to the actor. The authority is JSON.
package/README.md CHANGED
@@ -218,6 +218,7 @@ forgetting the policy, and the reason is what tells the next reader which of the
218
218
  |---|---|
219
219
  | `query.ts` | the primitive, `describeQuery`, `queryHash` |
220
220
  | `read.ts` | the one read path — `runQuery`, `sourceFor` — and the declaration store |
221
+ | `parsed-input.ts` | `parsedQueryInput(query, raw)` — the input every key over `(query, input)` hashes |
221
222
  | `facade.ts` | binds each projection to the query; re-implements none of them |
222
223
  | `mcp-tool.ts` | the MCP read descriptor |
223
224
  | `client.ts` | the typed read client (browser-safe) |
@@ -399,6 +400,14 @@ HMAC-signed, and bound to one query + arguments — a cursor from another query
399
400
  `query.page(input, { first, after })` is the only way to ask for one — `paginate` backs it and is
400
401
  not exported, because a page is the read's own answer rather than an imported helper.
401
402
 
403
+ **A function `RowProvider` is handed the window** (`RowWindow`: `after`, `limit`, `order`,
404
+ `filters`; `As of 31.4.3`). `from('posts', (window) => repo.page(window))` pushes the cursor down,
405
+ so a provider that reads a bounded window (a repository's top N) reaches every row: called with
406
+ nothing, it served page one's rows to every page and page two came back short. `from()` re-applies
407
+ the filter, order, seek and cut over what comes back, so ignoring the argument is still correct —
408
+ but a provider that honours `limit` must honour `after`, `order` and `filters` too
409
+ (`pagination-window.test.ts`; the reference app's `org-posts.ts` is the worked push-down).
410
+
402
411
  ### The same page over HTTP — `?_first=` and `?_after=`
403
412
 
404
413
  The route reads two controls off the search string BEFORE the schema sees it, and with either
@@ -538,6 +547,12 @@ up. Keys are `query:<name>:<authority>:<input fingerprint>:<tags>`. An action's
538
547
  and a query's `cache.tags` meet in the one graph owned by `@ultimat3/cache`, because there is one
539
548
  registry and this package holds no store of its own.
540
549
 
550
+ **The input fingerprint is the PARSED input, never the raw payload** (`As of 31.4.3`). A key the
551
+ schema strips — `?junk=1`, `?junk=2`, … — is one entry and one read, not a new entry per value
552
+ that evicts a hot one (`read-cache-key.test.ts`). Anything else keyed on `(query, input)` asks for
553
+ the same value: `parsedQueryInput(query, raw)` is the parse alone (`X_INPUT_INVALID` on a refused
554
+ input; no actor, policy, limit or `sql()`), then `queryHash(name, parsed)`.
555
+
541
556
  **The authority is who the read was answered for, and it is not optional.** `sql(input, ctx)` is
542
557
  handed the context and `@ultimat3/entity` derives every tenant predicate from `ctx.actor.orgId`,
543
558
  never from the input — so a key made of the name, the input and the tags did not identify a read's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "31.4.1",
3
+ "version": "31.5.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -49,12 +49,12 @@
49
49
  "test": "bun test"
50
50
  },
51
51
  "dependencies": {
52
- "@ultimat3/cache": "31.4.1",
53
- "@ultimat3/core": "31.4.1",
54
- "@ultimat3/db": "31.4.1",
55
- "@ultimat3/entity": "31.4.1",
56
- "@ultimat3/http": "31.4.1",
57
- "@ultimat3/policy": "31.4.1",
58
- "@ultimat3/schema": "31.4.1"
52
+ "@ultimat3/cache": "31.5.0",
53
+ "@ultimat3/core": "31.5.0",
54
+ "@ultimat3/db": "31.5.0",
55
+ "@ultimat3/entity": "31.5.0",
56
+ "@ultimat3/http": "31.5.0",
57
+ "@ultimat3/policy": "31.5.0",
58
+ "@ultimat3/schema": "31.5.0"
59
59
  }
60
60
  }
package/src/index.ts CHANGED
@@ -128,6 +128,8 @@ export { MAX_WIRE_PAGE_SIZE } from './page-controls';
128
128
  * `@ultimat3/core`'s — one place to encode, decode or re-key a cursor.
129
129
  */
130
130
  export type { Page, PaginateArgs } from './pagination';
131
+ /** The input as the schema keeps it — what a key over `(query, input)` hashes (`queryHash`). */
132
+ export { parsedQueryInput } from './parsed-input';
131
133
  export type { QueryPolicy, QuerySubject, QuerySurface } from './policy-gate';
132
134
  /**
133
135
  * The one authz gate for reads, named after what it guards. The display label
@@ -171,7 +173,7 @@ export {
171
173
  /** What a `single: true` read answers — one rule, also `@ultimat3/mcp`'s served tool's. */
172
174
  export { readAnswer } from './single-answer';
173
175
  /** `Builder` is a type only: `from()` is the one way to build one, so there is no `new Builder()`. */
174
- export type { Builder, RowProvider, SqlSource, SqlText } from './source';
176
+ export type { Builder, RowProvider, RowWindow, SqlSource, SqlText } from './source';
175
177
  /** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
176
178
  export { from, isAfterKey } from './source';
177
179
  export type { ExplainResult, QuerySqlInfo } from './sql';
package/src/live.ts CHANGED
@@ -18,7 +18,7 @@ import type { AnyQuery, Query } from './query';
18
18
  import { queryHash } from './query';
19
19
  import type { QueryLimitCaller } from './rate-limit-gate';
20
20
  import { spendReadLimit } from './rate-limit-gate';
21
- import { queryName, sourceFor } from './read';
21
+ import { builtFor, queryName } from './read';
22
22
  import { assertNoSealedKey } from './sealed-shape';
23
23
  import type { QueryShape, SeekKey } from './shape';
24
24
  import { seekKeyOf } from './shape';
@@ -131,7 +131,9 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
131
131
  options: ToLiveOptions = {},
132
132
  ): Promise<LiveQuery> {
133
133
  const name = queryName(target);
134
- const source = await sourceFor(target, input, {
134
+ // `builtFor`, not `sourceFor`: the hash below is the PARSED input's, so a key the schema strips
135
+ // (`{ q, junk }`) is the same live identity and cursor scope as `{ q }` (`read-cache-key.test.ts`).
136
+ const { source, input: parsed } = await builtFor(target, input, {
135
137
  ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
136
138
  // The boolean stays this layer's spelling because it has exactly ONE reason, stated on
137
139
  // `ToLiveOptions.enforce` above; `sourceFor` takes that reason as a string so every skipped
@@ -150,7 +152,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
150
152
  assertNoSealedKey(name, shape);
151
153
 
152
154
  const epoch = options.epoch ?? liveEpoch();
153
- const hash = queryHash(name, input);
155
+ const hash = queryHash(name, parsed);
154
156
  const reads = [...new Set([shape.entity, ...tagKeys(target.cache?.tags ?? [])])].sort();
155
157
  // The query's own policy object, not a copy: the same authz a direct read runs.
156
158
  const policy = target.policy;
@@ -0,0 +1,16 @@
1
+ // A read's input as its declared schema keeps it — what every key over `(query, input)` hashes.
2
+ // Keyed raw, an unknown key the schema strips (`?junk=N`) minted a new entry per value; the read
3
+ // cache keys on the parse inside `read.ts`, and a live window (`@ultimat3/realtime`) asks here.
4
+
5
+ import type { AnyQuery } from './query';
6
+ import { defOf, queryName } from './read';
7
+ import { parseQueryInput } from './validate';
8
+
9
+ /**
10
+ * The parse alone: no actor, no policy, no rate limit, no `sql()`. `X_INPUT_INVALID` for an input
11
+ * the schema refuses, exactly as the read path answers it. The caller still reads through
12
+ * `sourceFor` / `.live()` — this decides a KEY, never an admission.
13
+ */
14
+ export function parsedQueryInput(target: AnyQuery, raw: unknown): Promise<unknown> {
15
+ return parseQueryInput(defOf(target).input, raw, queryName(target));
16
+ }
package/src/read.ts CHANGED
@@ -127,18 +127,35 @@ export function runQuery(
127
127
  * `explain` and the MCP tool all build on this, so none of them re-implement the
128
128
  * front half and none of them can skip the policy while doing it.
129
129
  */
130
- export function sourceFor(
130
+ export async function sourceFor(
131
131
  target: AnyQuery,
132
132
  raw: unknown,
133
133
  options: SourceOptions = {},
134
134
  ): Promise<SqlSource<object>> {
135
- return asActor(options, (ctx) => {
135
+ return (await builtFor(target, raw, options)).source;
136
+ }
137
+
138
+ /**
139
+ * `sourceFor` keeping the input `admit` parsed — `toLiveQuery` hashes THAT, so a key the schema
140
+ * strips cannot mint a second live identity. Internal: never re-exported from `src/index.ts`.
141
+ */
142
+ export function builtFor(
143
+ target: AnyQuery,
144
+ raw: unknown,
145
+ options: SourceOptions = {},
146
+ ): Promise<{ readonly source: SqlSource<object>; readonly input: unknown }> {
147
+ return asActor(options, async (ctx) => {
136
148
  const audit = auditFor(target, ctx, options);
137
149
  // A source handed out is executed by its caller — `@ultimat3/mcp`'s served tool among them —
138
150
  // so an audited read records per `execute()`, and a build that refuses records here.
139
- return audit === null
140
- ? buildSource(target, raw, ctx, options, null)
141
- : auditedBuild(audit, () => buildSource(target, raw, ctx, options, audit.trace));
151
+ if (audit === null) return admitAndBuild(target, raw, ctx, options, null);
152
+ let input: unknown;
153
+ const source = await auditedBuild(audit, async () => {
154
+ const built = await admitAndBuild(target, raw, ctx, options, audit.trace);
155
+ input = built.input;
156
+ return built.source;
157
+ });
158
+ return { source, input };
142
159
  });
143
160
  }
144
161
 
@@ -252,7 +269,7 @@ async function readRowsIn(
252
269
  ): Promise<readonly object[]> {
253
270
  const def = defOf(target);
254
271
  const name = queryName(target);
255
- const source = await buildSource(target, raw, ctx, options, trace);
272
+ const { source, input } = await admitAndBuild(target, raw, ctx, options, trace);
256
273
  // Whether THIS call executed, for the audit record: a memo or tier hit never calls `read`, and
257
274
  // its record says so (`replayed: true`) rather than being skipped.
258
275
  if (trace !== null) trace.replayed = true;
@@ -275,7 +292,9 @@ async function readRowsIn(
275
292
  // nothing — a memo is already per-ctx — and it is the whole of what the tier was missing: keyed
276
293
  // on the name, the input and the tags alone, the process-wide tier handed one org's rows to the
277
294
  // next org that asked for them. `actor` when the declaration named no scope, always.
278
- const key = cacheKeyFor(name, raw, tags, readAuthority(ctx.actor, def.cache?.scope ?? 'actor'));
295
+ // The PARSED input, never `raw`: a key the schema strips (`?junk=N`) would otherwise be a new
296
+ // entry and a database read per value, each evicting a hot one (`read-cache-key.test.ts`).
297
+ const key = cacheKeyFor(name, input, tags, readAuthority(ctx.actor, def.cache?.scope ?? 'actor'));
279
298
  // `fresh` is the caller saying no cache may answer this one — the memo included, a memo being
280
299
  // a cache whose lifetime is the request. It still *publishes* into the memo: this read is the
281
300
  // newest answer the request has, so the next plain read of the key joins it rather than the
@@ -335,7 +354,28 @@ async function buildSource(
335
354
  options: SourceOptions,
336
355
  trace: ReadTrace | null,
337
356
  ): Promise<SqlSource<object>> {
357
+ return (await admitAndBuild(target, raw, ctx, options, trace)).source;
358
+ }
359
+
360
+ /** `buildSource`, keeping the admitted input: the cache key is what the policy decided about. */
361
+ async function admitAndBuild(
362
+ target: AnyQuery,
363
+ raw: unknown,
364
+ ctx: Ctx,
365
+ options: SourceOptions,
366
+ trace: ReadTrace | null,
367
+ ): Promise<{ readonly source: SqlSource<object>; readonly input: unknown }> {
338
368
  const input = await admit(target, raw, ctx, options, trace);
369
+ return { source: sourceOver(target, input, ctx, options), input };
370
+ }
371
+
372
+ /** `sql()` — or the canned rows — over an input `admit` already let through. */
373
+ function sourceOver(
374
+ target: AnyQuery,
375
+ input: unknown,
376
+ ctx: Ctx,
377
+ options: SourceOptions,
378
+ ): SqlSource<object> {
339
379
  const canned = rowsOverrideOf(defOf(target));
340
380
  if (canned !== undefined) {
341
381
  const rows = canned;
package/src/source.ts CHANGED
@@ -40,15 +40,34 @@ export interface SqlSource<TRow> {
40
40
  seek?(after: SeekKey | null, limit: number): SqlSource<TRow>;
41
41
  }
42
42
 
43
+ /**
44
+ * The window a source is asked for, handed to a function `RowProvider` on every `execute()`.
45
+ * `after` is the keyset position (rows strictly past it in `order`), `limit` the most rows wanted,
46
+ * `order` the order actually served (the declared keys, then `id` once paged), `filters` every
47
+ * `.where()`/`.compare()` on the chain. The builder re-applies all four over what comes back, so
48
+ * a provider may honour any of them or none — but one that honours `limit` must also honour
49
+ * `after`, `order` and `filters`, or the cut lands before the rows it was meant to keep.
50
+ */
51
+ export interface RowWindow {
52
+ readonly after: SeekKey | null;
53
+ readonly limit: number | null;
54
+ readonly order: readonly OrderKey[];
55
+ readonly filters: readonly Filter[];
56
+ }
57
+
43
58
  /**
44
59
  * Rows, or a function that answers them. The function half is `readonly TRow[] | Promise<…>`
45
60
  * because `execute()` **awaits** whatever it returns — declaring only the promise refused a
46
61
  * synchronous provider the implementation has always accepted, which is a repo method that
47
62
  * already has its page in hand, and every in-memory fixture.
63
+ *
64
+ * The function is handed the `RowWindow`. Called with nothing, a repository's top N was the same N
65
+ * rows on every page: rows past it were unreachable and page two came back short. A provider that
66
+ * reads a window pushes `after` and `limit` down; one that reads everything ignores the argument.
48
67
  */
49
68
  export type RowProvider<TRow> =
50
69
  | readonly TRow[]
51
- | (() => readonly TRow[] | Promise<readonly TRow[]>);
70
+ | ((window: RowWindow) => readonly TRow[] | Promise<readonly TRow[]>);
52
71
 
53
72
  /**
54
73
  * In-memory reference source. `from<Post>('posts', rows).where({ orgId }).orderBy('createdAt')`
@@ -141,11 +160,20 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
141
160
  }
142
161
 
143
162
  async execute(): Promise<readonly TRow[]> {
144
- const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
163
+ const keys = this.servedOrder();
164
+ const source =
165
+ typeof this.rows === 'function'
166
+ ? await this.rows({
167
+ after: this.after,
168
+ limit: this.rowLimit,
169
+ order: keys,
170
+ filters: this.filters,
171
+ })
172
+ : this.rows;
145
173
  this.assertCarried(source);
146
174
  const kindOf = kindsOf(this.entity);
175
+ // Idempotent over a window the provider already cut: filter, sort, seek and slice again.
147
176
  let result = source.filter((row) => matchesFilters(row, this.filters, kindOf));
148
- const keys = this.servedOrder();
149
177
  if (keys.length > 0) {
150
178
  result = [...result].sort((a, b) => compareRows(a, b, keys, kindOf));
151
179
  }