@ultimat3/query 24.0.0 → 25.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
@@ -16,54 +16,56 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
16
16
  | `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in |
17
17
  | `facade.ts` | the fluent surface — binds each projection to the query, re-implements none |
18
18
  | `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
19
- | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
20
19
  | `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
21
20
  | `client-scale-pins.ts` | compile-time pin: `queryClient<Api['queries']>` over 100 reads in 50 modules — list, `.page` and `single: true` |
22
21
  | `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
23
22
  | — | flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported. No local copy |
24
- | `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS core's `queryPath`. **Paths only** |
25
23
  | `registry.ts` | export-name registration, `describeQueries()`, the `registerPrimitiveRegistrar('query', …)` announcement |
26
- | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
24
+ | `live.ts` | `LiveQuery` descriptor + cursor arithmetic + `spendQueryLimit` (a live subscribe's spend) |
25
+ | `rate-limit-gate.ts` | **the one place** a read's declared `rateLimit:` is spent |
27
26
  | `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
28
27
  | `sealed-shape.ts` | a live read keyed on a `.sealed()` column is refused at subscribe (`X_MATCHER_UNSUPPORTED`) |
29
28
  | `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
30
29
  | `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
31
30
  | `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
32
31
  | `input-shape.ts` | what a read's `input:` may be, given that its route is a query STRING |
33
- | `sql.ts` | `explain()` / `describeSql()` |
32
+ | `sql.ts` | `explainQuery()` / `describeSql()` |
34
33
  | `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
35
34
  | `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
36
35
  | `source.ts` | `SqlSource` contract + `from()` in-memory reference |
37
36
  | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
38
37
  | `column-kinds.ts` | `kindsOf(relation)`: a column's DECLARED kind, from `QueryShape.entity` |
39
38
  | `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
40
- | `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
39
+ | `audit-gate.ts` | `audit: true` — **the only** file that calls an `AuditSink` (core's): one record per call, an audited source per `execute()` |
40
+ | `audit-errors.ts` | `X_QUERY_AUDIT_SINK_MISSING` / `X_QUERY_AUDIT_SINK_FAILED` (titles in `errors.ts`) |
41
41
 
42
42
  ## Invariants — the read path
43
43
 
44
- - Every surface goes through `sourceFor`: the policy's actor half (`guardBeforeInput`), parse input,
44
+ - Every surface goes through `sourceFor`: the policy's actor half (`guardQueryBeforeInput`), parse input,
45
45
  evaluate policy, build the source. A second
46
46
  read path is the one unforgivable change here.
47
47
  - **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
48
- `@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
48
+ `@ultimat3/entity`'s tenant guard reads) must be the identity `guardQuery()` decided about.
49
49
  `read-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
50
50
  - **Skipping a read's policy costs a WRITTEN REASON** (`unenforced?: string`; blank refused).
51
51
  `ToLiveOptions.enforce` stays a boolean, translated into `SHARED_WINDOW_REASON`.
52
52
  - The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are never re-exported from
53
53
  `src/index.ts` — that omission is the enforcement. A query has no `.def`; outside the package read
54
54
  `.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`. A projection is reached through the
55
- query (`liveFeed.tool()`); new methods are bound in `facade.ts`.
55
+ query (`liveFeed.live()`); new methods are bound in `facade.ts`. **No MCP tool is projected
56
+ here** (O-tool, 25.0.0): `@ultimat3/mcp`'s `toolFrom` is the one projection, reading through
57
+ `sourceFor(…, { surface: 'mcp' })`. `.tool()`/`toQueryTool` coming back is red in `index.test.ts`,
58
+ `dsl.test.ts` and `type-pins.ts`.
56
59
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
57
60
  - **`LiveQuery` describes the read *and* runs it** (`execute()`, never memoised).
58
61
  `@ultimat3/realtime`'s shared window reads through it.
59
62
  - `isLive` is the declared boolean, `live()` the subscription. `QueryDescriptor.live` keeps its name
60
63
  (`manifest` and `admin` read it).
61
- - `mcp` is opt-in; `isExposed` delegates to core's `isMcpExposed`.
64
+ - `mcp` is opt-in; `openapi.ts` asks core's `isMcpExposed` directly — the `isExposed` wrapper is gone.
62
65
  - **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
63
- `mcp-tool.test.ts` asserts `toQueryTool(q).name === queryName(q)`; `index.test.ts` asserts the
64
- barrel exports nothing matching `/tool_?name/i`.
66
+ `index.test.ts` asserts the barrel exports nothing matching `/tool_?name/i`.
65
67
  - **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
66
- `queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
68
+ `queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from core's `queryPath`.**
67
69
  - **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
68
70
  excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
69
71
  it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
@@ -74,24 +76,28 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
74
76
  sent as `[]`. Never for an optional or defaulted one — absence there is the schema's answer.
75
77
  `http-round-trip.test.ts` pins both ends against each other.
76
78
  - **A `single: true` read answers the row or `X_NOT_FOUND` by ONE rule** (`single-answer.ts`:
77
- `oneRowOf`, `readAnswer`) — the route, `tool().read()` and `@ultimat3/mcp`'s served tool all call
78
- it. `QueryToolDescriptor<TSingle>` types the answer: rows for a list read, the row for a single.
79
- - **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
80
- `meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
79
+ `oneRowOf`, `readAnswer`) — the route and `@ultimat3/mcp`'s served tool both call it.
80
+ - **`rateLimit:` is spent ONCE, in `buildSource`** (`rate-limit-gate.ts`'s `spendReadLimit`), for
81
+ every enforced build — route, `page()`, the MCP tool (`surface: 'mcp'`, judged as `'server'`).
82
+ `'server'` and an `unenforced` build spend nothing. A live subscribe spends via `live.ts`'s
83
+ `spendQueryLimit`, called by realtime per subscriber. `toQueryRoute` sets `rateLimitedBy:
84
+ 'handler'` and no stage bucket. Counted in `@ultimat3/http`'s installed store; key
85
+ `query:<name>|<subject>`. `toBucket` is http's — never a local copy. `rate-limit-surfaces.test.ts`.
81
86
  - **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
82
87
  answer, rendered ONCE at projection (`X_QUERY_DEPRECATION_INVALID` at mount). Versioning is two
83
88
  deployments behind one ingress.
84
89
  - **The span wraps the whole read** (`readRows` holds it, `readRowsIn` is the body); bounded
85
90
  attributes only — never the input or an actor id. `telemetry.test.ts` asserts the extent through
86
91
  `currentSpan()`.
87
- - **`policyCapability` is a display label; `policyPermissions` (`QueryDescriptor.permissions`) is what
88
- a report matches on.**
92
+ - **`policyCapability` is a display label; `policyPermissions` (`QueryDescriptor.permissions`) is
93
+ what a report matches on.** Both are `@ultimat3/policy`'s, imported from there.
89
94
  - **`client.ts` injects `traceparent`** before the caller's headers; an incomplete span context sends
90
95
  nothing.
91
96
  - **A read is `no-store`, and its policy is `enforcedBy: 'handler'`** — `runQuery` is the one
92
97
  evaluation (`http.test.ts` counts exactly one).
93
- - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, via
94
- `policy-gate.ts`), never the root combinator.
98
+ - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, imported
99
+ from `@ultimat3/policy` by `http.ts`), never the root combinator. 25.0.0 dropped this package's
100
+ re-export; `index.test.ts` pins the absence.
95
101
  - `registry.ts` announces `registerQueries` in core's registrar table at import; `defineApi` in
96
102
  `@ultimat3/action` depends on it (`X_REGISTRAR_MISSING` otherwise).
97
103
  - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
@@ -107,11 +113,25 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
107
113
  `toLiveQuery`): a change row carries none, so the window would be read once and never patched.
108
114
  The refusal names the column, never the filter's value.
109
115
 
116
+ ## Invariants — audit
117
+
118
+ - **`audit: true` observes the read path, never forks it**: `buildSource` fills the trace (`input`,
119
+ parsed-or-nothing on a pre-parse denial); `readRowsIn` sets `replayed`. The record shape and the
120
+ sink are `@ultimat3/core`'s (`AuditRecord`, `setAuditSink`) — shared with `@ultimat3/action`, and
121
+ `AUDIT_RECORD_FIELDS` is the parity pin both packages' tests assert (`audit.test.ts`).
122
+ - **A memo or cache hit is recorded** (`replayed: true`): an audit of a read is who saw what.
123
+ - **One record per CALL**: `runQuery` and `readCall` (`.page()`) record the whole call;
124
+ `sourceFor` hands out an audited source (one record per `execute()`, `@ultimat3/mcp`'s served
125
+ tool included) and records a refused build at once. An `unenforced` build records nothing.
126
+ - **Never the rows.** The sink is resolved before the parse (`X_QUERY_AUDIT_SINK_MISSING`); a
127
+ refused allowed record withholds the rows (`X_QUERY_AUDIT_SINK_FAILED`); a refused denied/failed
128
+ record is logged and the original error wins. `audit-surfaces.test.ts`.
129
+
110
130
  ## Invariants — the client
111
131
 
112
- - **Flight control is `@ultimat3/core`'s, re-exported** (`createClientFlight`,
113
- `DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded`, the types) — the same objects
114
- `@ultimat3/action` exports. Fix the pipeline in `packages/core/src/client-flight.ts`. No new error
132
+ - **Flight control is `@ultimat3/core`'s, and NOT re-exported** (`clientFlight`,
133
+ `DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded` — `X_HELPER_COPY` refuses a value
134
+ re-export; only the types `ClientFlight`/`ClientRetry` the options name). Fix the pipeline in `packages/core/src/client-flight.ts`. No new error
115
135
  code, curve, fence or retry loop here; `bun run flight-copies` says so.
116
136
  - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** — a caller's own
117
137
  `AbortError` is terminal. Must survive.
@@ -120,11 +140,12 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
120
140
  transport owns `Accept`, trace/budget headers, the principal fence, the error decode and the record
121
141
  envelope; the flight handed to it dedups concurrent identical reads. `bun run browser-transport`
122
142
  is the guard.
123
- - **`@ultimat3/query/client` is the read client without the barrel** — `client.ts` and `naming.ts`
124
- import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
143
+ - **`@ultimat3/query/client` is the read client without the barrel** — `client.ts`
144
+ imports core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
125
145
  `client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
126
- titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
127
- (`As of 2026-10-02`, browser, minified): barrel `queryClient` 25,237 B; `./client` 11,012 B.
146
+ titles table in the graph or past 12 kB; `client-subpath.test.ts` pins the specifier. Byte figures
147
+ (browser, minified): barrel `queryClient` 25,237 B (`As of 2026-10-02`); `./client` 11,983 B
148
+ (`As of 2026-10-05`, +971 B: core reads a refusal's `Retry-After` and body title).
128
149
  - **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
129
150
  `@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
130
151
  answer. No `rows:` is byte-identical on the wire.
@@ -155,7 +176,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
155
176
  - The matcher patches from `QueryShape`, never from SQL text.
156
177
  - `paginate` has no `offset` and never grows one; it is reachable only as
157
178
  `query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
158
- (10,000, a twin of `@ultimat3/entity`'s).
179
+ (10,000 — `@ultimat3/entity`'s, imported; this package never re-publishes it).
159
180
  - **A declared `.limit()` bounds the LISTING, on every page**: the window is
160
181
  `min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
161
182
  tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
@@ -190,13 +211,12 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
190
211
  - **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
191
212
  missing an ordering column (`Object.hasOwn`, never a value check) gets a `refill` rather than a
192
213
  guess. A delete is addressed by index and still patches.
193
- - **`tagKeys` is `@ultimat3/cache`'s.** `@ultimat3/render` exports a different `tagKeys`; never import
194
- that one here.
214
+ - **`tagKeys` is `@ultimat3/cache`'s** — the only public one; never restore a local copy.
195
215
  - **A fingerprint is `@ultimat3/core`'s** (`canonicalJson` + SHA-256/16 `fingerprint`), tagging `Date`,
196
216
  `Map`, `Set`. `query-hash.test.ts` pins it at `queryHash` and `cacheKeyFor`. `stable.ts` keeps
197
- `columnOf` and re-exports core's `isJsonObject`.
217
+ `columnOf`; core's `isJsonObject` is imported from core (`X_HELPER_COPY` refuses a re-export).
198
218
  - The cursor codec is `@ultimat3/core`'s; this package supplies only the scope (`queryHash(name,
199
- input)`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, re-exported).
219
+ input)`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, core's — imported from `@ultimat3/core`, never re-exported).
200
220
 
201
221
  ## Invariants — caching
202
222
 
@@ -210,10 +230,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
210
230
  - **`cache.ttlMs` is judged at `query()`** (`X_QUERY_CACHE_TTL_INVALID`). A `cache:` read always
211
231
  expires: `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60 s); an unset TTL is OMITTED so the tier
212
232
  defaults.
213
- - **A fill is FENCED, by `@ultimat3/cache`'s fence inside `createCacheStack.read`** — never a second
233
+ - **A fill is FENCED, by `@ultimat3/cache`'s fence inside `cacheStack.read`** — never a second
214
234
  one here (`cache-fence.test.ts`).
215
235
  - **This package owns NO cache store**: a `cache:` read fills
216
- `createCacheStack(registeredTiers(), { clock })`. Never reintroduce a store, never call
236
+ `cacheStack(registeredTiers(), { clock })`. Never reintroduce a store, never call
217
237
  `tier.invalidateTags()` here. A tier refusal degrades the cache, never the read.
218
238
  - **The read path reads NO clock** — it hands the stack a relative `ttlMs` (`read-tier.test.ts`).
219
239
  - **A process that registered no tier reads uncached** — correct, and slower.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Every projection is a method on the query itself. A query has no `.def`.
29
29
  | `liveFeed.as(actor, { orgId })` | the same read as another actor — the surrounding context is untouched, `null` is signed out |
30
30
  | `liveFeed.page({ orgId }, { first: 20, after })` | one bounded page plus the signed cursor that continues it. There is no `offset` |
31
31
  | `liveFeed.live({ orgId })` | the `LiveQuery` `@ultimat3/realtime` subscribes to, carrying the same policy object |
32
- | `liveFeed.tool()` | the MCP read tool, named `liveFeed`. `tool().policy === liveFeed.policy`, and it reads fresh |
32
+ | `toolFrom(liveFeed)` | the MCP read tool, from `@ultimat3/mcp` — named `liveFeed`, exactly what `tools/list` serves, reading fresh through `sourceFor` with the same policy object. There is no `liveFeed.tool()` (`As of 25.0.0`): it was a second projection a tier below the served one |
33
33
  | `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways. `.page(input, { first, after })` on the same method reads one page over the wire |
34
34
  | `liveFeed.describe()` | the manifest row |
35
35
 
@@ -122,20 +122,21 @@ or with a schema no `entity()` branded (a `t.object` look-alike, a `.pick()`ed p
122
122
  is the bare rows, byte-identical to before. `rows:` exists because a read's `sql:` names its table
123
123
  as a STRING, so nothing else carries a schema the envelope could be derived from.
124
124
 
125
- ### Flight control — `createClientFlight`, opt-in
125
+ ### Flight control — `clientFlight`, opt-in
126
126
 
127
127
  A typed read is one dispatch and nothing else until a `flight` is installed. With one, N concurrent
128
128
  identical reads become ONE dispatch, a fence can retire everything issued before now, and a failure
129
129
  worth sending again is sent again on `@ultimat3/core`'s one backoff curve.
130
130
 
131
131
  ```ts
132
- import { createClientFlight, isSuperseded, queryClient } from '@ultimat3/query';
132
+ import { clientFlight, isSuperseded } from '@ultimat3/core';
133
+ import { queryClient } from '@ultimat3/query';
133
134
 
134
135
  declare const session: () => { userId: string };
135
136
  declare const baseUrl: string;
136
137
  type Api = { queries: Record<string, never> };
137
138
 
138
- const flight = createClientFlight({
139
+ const flight = clientFlight({
139
140
  principal: () => session().userId, // dedup is OFF without this — see below
140
141
  retry: { attempts: 3 }, // default is `attempts: 1`, i.e. no retry
141
142
  deadlineMs: 10_000,
@@ -159,10 +160,11 @@ flight.bump();
159
160
  | an **unclassified** throw is not retried | core's `retryDecision` retries anything nobody classified — a caller's own `AbortError` included. A client retries a declared `retryable`/`retry-after`, plus a dispatch that produced no response at all |
160
161
  | a deadline is `X_TIMEOUT` | core's code, already classified `retryable`, so a caller's own loop needs no table |
161
162
 
162
- `createClientFlight` is **`@ultimat3/core`'s**, re-exported here: it is the same object
163
- `@ultimat3/action` re-exports, because both packages are tier 3 and neither may import the other.
164
- It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
165
- this package exactly as before.
163
+ `clientFlight` is **`@ultimat3/core`'s**, and imported from there, `isSuperseded` with it.
164
+ It shipped as a byte-identical copy here and in `@ultimat3/action` (both tier 3, neither may import
165
+ the other); the copies are gone, and since 25.0.0 so are the re-exports: one value, one import
166
+ path (`X_HELPER_COPY`, `bun run flight-copies`). The types `ClientFlight` and `ClientRetry` stay
167
+ re-exported, because `queryClient`'s options name them.
166
168
 
167
169
  Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value. Importing
168
170
  `queryClient` alone through this package's barrel, `bun build --target=browser --minify`,
@@ -179,7 +181,7 @@ merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN`
179
181
  ### Skipping the policy costs a written reason
180
182
 
181
183
  Two reads have no subscriber to decide about: developer tooling that returns the statement and no
182
- rows (`explain`, `describeSql`), and the shared, subject-less window a sync node builds once per
184
+ rows (`explainQuery`, `describeSql`), and the shared, subject-less window a sync node builds once per
183
185
  `(query, input)`. Both say so, in words:
184
186
 
185
187
  ```ts
@@ -202,13 +204,14 @@ forgetting the policy, and the reason is what tells the next reader which of the
202
204
  | `mcp-tool.ts` | the MCP read descriptor |
203
205
  | `client.ts` | the typed read client (browser-safe) |
204
206
  | `http.ts` | the route projection — `GET /_x/query/<kebab>`, the URL the client derives |
205
- | `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire; `MAX_PAGE_SIZE` |
207
+ | `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire against `@ultimat3/entity`'s `MAX_PAGE_SIZE` |
206
208
  | `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query |
207
- | `naming.ts` | export name → wire path. The MCP tool name is the export name verbatim |
208
209
  | `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
209
210
  | `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) |
210
211
  | `pagination.ts` | `paginate()` — keyset pages over core's cursor codec |
211
- | `sql.ts` | `explain()` — the generated SQL, verbatim |
212
+ | `sql.ts` | `explainQuery()` — the generated SQL, verbatim |
213
+ | `audit-gate.ts` | `audit: true` — the only file that calls an `AuditSink`; one record per call, an audited source per `execute()` |
214
+ | `audit-errors.ts` | `X_QUERY_AUDIT_SINK_MISSING` / `X_QUERY_AUDIT_SINK_FAILED` |
212
215
  | `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
213
216
  | `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
214
217
  | `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
@@ -344,7 +347,7 @@ empty box is not an empty result set.
344
347
 
345
348
  **It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
346
349
  that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
347
- page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
350
+ page the provider fetched and report `hasMore: false` at its edge. So a second page is
348
351
  refused, and the cause names the chain — `db.posts.search(term).after(cursor).page()`.
349
352
 
350
353
  | A request `search()` refuses | Answer |
@@ -371,17 +374,19 @@ not exported, because a page is the read's own answer rather than an imported he
371
374
 
372
375
  The route reads two controls off the search string BEFORE the schema sees it, and with either
373
376
  present answers the `Page` envelope `.page()` answers a server caller with — `{ rows, nextCursor,
374
- hasMore, endCursor, hasNextPage }`, the same names, so a cursor read off the wire and one read off a
375
- direct call are one string in one field. **`nextCursor` and `hasMore` are the preferred names**
376
- (`As of 22.12`); `endCursor` and `hasNextPage` carry the same two values, always, as aliases — not
377
- deprecated, so a client reading them keeps working. `openapi.json` lists all five as required, the
378
- aliases described as such. Without a control the answer is the bare array it has always been, so a
377
+ hasMore }`, the same names, so a cursor read off the wire and one read off a direct call are one
378
+ string in one field. The `endCursor`/`hasNextPage` alias pair is gone (`As of 25.0.0`): `Page` is
379
+ the framework's one page type — `@ultimat3/core`'s, re-exported here, the same an entity `findMany`
380
+ answers — and `openapi.json` lists exactly those three as required. **`nextCursor` is `null` exactly
381
+ when `hasMore` is false** (`As of 25.0.0`; the last page used to keep a cursor, so a
382
+ `while (page.nextCursor)` loop fetched an empty page past the end): `openapi.json` says so as a
383
+ `oneOf`, and the type is a union that narrows `nextCursor` to `string` under `if (page.hasMore)`. Without a control the answer is the bare array it has always been, so a
379
384
  client written before the controls existed keeps reading rows. `As of 2026-09`; until then the
380
385
  route had no envelope, and a query over a paged external source carried its page marker on a row
381
386
  (an `olderCursor` column on the oldest message), which is a cursor on the wrong side of the shape.
382
387
 
383
388
  ```
384
- GET /_x/query/live-feed?orgId=…&_first=20 → { rows, nextCursor, hasMore, endCursor, hasNextPage }
389
+ GET /_x/query/live-feed?orgId=…&_first=20 → { rows, nextCursor, hasMore }
385
390
  GET /_x/query/live-feed?orgId=…&_first=20&_after=<c> → the next page (<c> = nextCursor)
386
391
  GET /_x/query/live-feed?orgId=… → [ …rows ] (unchanged)
387
392
  ```
@@ -393,7 +398,7 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
393
398
  | `_first` outside 1–10,000, not a whole number, or `_after` without `_first` | **400** `X_INPUT_INVALID` with the read's own fix line — judged at the wire (`page-controls.ts`), because `paginate` asserts the same bound as `X_INVARIANT`, which is a 500 blaming the server for a number the caller typed |
394
399
  | a cursor that is not this read's | **400** `X_CURSOR_INVALID` — `decodeCursor` is the one judge of a cursor; the route checks only that `_after` is one non-empty string |
395
400
  | a control sent twice | refused, never resolved to the last one as a declared scalar is: two page sizes is two pages asked for in one request |
396
- | a declared `.limit()` is the size of the LISTING | `first` is the size of a page and never widens it: the window is `min(what the limit has left, first + 1)`, a limited read's cursor carries how many rows are already served, and the page after the last is empty with `nextCursor: null`. `As of 2026-10`; until then `first` REPLACED the limit, so `?_first=10000` walked a "top 3" to the end of the table. A read paged past its limit on purpose drops the `.limit()` |
401
+ | a declared `.limit()` is the size of the LISTING | `first` is the size of a page and never widens it: the window is `min(what the limit has left, first + 1)`, a limited read's cursor carries how many rows are already served, and the last page inside it answers `hasMore: false` with `nextCursor: null`. `As of 2026-10`; until then `first` REPLACED the limit, so `?_first=10000` walked a "top 3" to the end of the table. A read paged past its limit on purpose drops the `.limit()` |
397
402
  | a cursor a limited read minted before that | **400** `X_CURSOR_INVALID` — it does not say how much of the limit is spent, and honouring it would start a second "top N" below the first |
398
403
  | the typed client | `feed.client({ baseUrl }).page(input, { first, after })` and `queries.feed.page(…)` append the two controls after the sorted input, so a paged URL is the plain URL plus a suffix |
399
404
  | `openapi.json` | every read is a `GET` path item with its input as `in: query` parameters, the two controls after them, and a `200` that is `oneOf` the bare rows and the envelope — `queryOpenApiPaths`, merged into `@ultimat3/action`'s document by the CLI |
@@ -407,8 +412,8 @@ The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
407
412
  open cursor) and `usesDevCursorSecret` are all imported from there. `As of 2026-08`, `x doctor`
408
413
  reports `X_CURSOR_SECRET_DEV` when a production process is still signing with the key shipped in
409
414
  the package, and rotating the secret is what invalidates every open cursor. This package supplies
410
- the only thing that is its business — the scope, `queryHash(name, input)` — and re-exports
411
- `CursorInvalidError` so the failure keeps its name on this surface.
415
+ the only thing that is its business — the scope, `queryHash(name, input)`. `CursorInvalidError`
416
+ is core's too, imported from `@ultimat3/core` (25.0.0 dropped this package's re-export).
412
417
 
413
418
  The scope's hash is **SHA-256, first 16 hex** — `fingerprint` from `@ultimat3/core`, the primitive
414
419
  and width `@ultimat3/entity`'s `planScope` already uses, and the same function
@@ -498,7 +503,7 @@ no kind is the defect.
498
503
  ## Caching
499
504
 
500
505
  Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
501
- `@ultimat3/cache` has registered — `createCacheStack(registeredTiers())`, read down and promoted
506
+ `@ultimat3/cache` has registered — `cacheStack(registeredTiers())`, read down and promoted
502
507
  up. Keys are `query:<name>:<authority>:<input fingerprint>:<tags>`. An action's `cache.invalidates`
503
508
  and a query's `cache.tags` meet in the one graph owned by `@ultimat3/cache`, because there is one
504
509
  registry and this package holds no store of its own.
@@ -521,7 +526,7 @@ skipped policy. `readAuthority(actor, scope)` is the only producer of the compon
521
526
  required positional argument of `cacheKeyFor`, because an optional one is one a call site forgets.
522
527
 
523
528
  **The fill is fenced, best-effort and single-flighted, and none of that is written here.**
524
- `createCacheStack` samples `@ultimat3/cache`'s fence immediately before it runs the source and
529
+ `cacheStack` samples `@ultimat3/cache`'s fence immediately before it runs the source and
525
530
  re-asks it per rung before each write, so a bust that lands mid-read cannot be republished for the
526
531
  whole TTL — the caller still gets the rows it read, because those are its answer; only publishing is
527
532
  refused. Every tier call goes through `bestEffort`, so a Redis refusal is a miss rather than a
@@ -574,8 +579,21 @@ rateLimit: { limit: 3, windowMs: 600_000 }, // 3 held, one back every three an
574
579
  Symmetric with an action's, and for a reason: without it **every** `GET /_x/query/*` fell to the
575
580
  `default` bucket — 120 burst, 2/s per actor — so one authenticated caller could hold 120
576
581
  cross-tenant aggregates in flight and then 2/s indefinitely, from a single account, with no
577
- declaration able to say otherwise. `toQueryRoute` sets the bucket NAME and the NUMBERS, and
578
- `@ultimat3/http`'s `withRouteBuckets` registers them: a name alone falls through to `default`.
582
+ declaration able to say otherwise.
583
+
584
+ **One bucket, every surface.** The read path's front half (`buildSource`) spends it, so the HTTP
585
+ route, a paged read and the MCP tool (`surface: 'mcp'`) all draw on the same bucket. A live
586
+ SUBSCRIBE is realtime's — the sync node builds one shared window per `(query, input)` with no
587
+ subscriber, which spends nothing — and spends per subscriber through `spendQueryLimit(target,
588
+ { actor })`. An in-process read (`surface: 'server'`, a page's render, a service) spends nothing.
589
+
590
+ | Fact | Answer |
591
+ |---|---|
592
+ | whose bucket | the reader's: actor → org → connection address (`@ultimat3/http`'s `rateLimitSpends`) |
593
+ | where it is counted | `@ultimat3/http`'s installed store (`installRateLimitStore`) — the one `@ultimat3/action` spends from, and the instance the boot hands `httpServer` |
594
+ | what HTTP answers | `RateLimit-*` from the bucket closest to refusing; `429` + `Retry-After` on a refusal |
595
+ | `default` | not spent for this route: it is `rateLimitedBy: 'handler'`, so a read declaring more than `default` is not capped at it. The tenant allowance still applies |
596
+
579
597
  The conversion is `toBucket` from `@ultimat3/http` — the same one the action route uses, because
580
598
  http owns `Bucket` and `action` is the same tier as this package. A pair the limiter cannot run
581
599
  on is `X_RATE_LIMIT_INVALID`, at projection.
@@ -593,10 +611,66 @@ before the read is deleted. A date that cannot be rendered is `X_QUERY_DEPRECATI
593
611
  projection, not on the first read. Versioning is two deployments behind one ingress, never a
594
612
  router feature here.
595
613
 
614
+ `Deprecation`, `renderDeprecation` and `recordDeprecatedCall` are `@ultimat3/core`'s and are
615
+ imported from there — this package does not re-export them (`As of 25.0.0`).
616
+
617
+ ## `audit: true` — who saw what
618
+
619
+ ```ts
620
+ import { can } from '@ultimat3/policy';
621
+ import { from, query, t } from '@ultimat3/query';
622
+
623
+ type Chart = { readonly id: string; readonly patientId: string };
624
+ declare const charts: readonly Chart[];
625
+
626
+ export const patientChart = query({
627
+ input: t.object({ patientId: t.uuid }),
628
+ policy: can('chart:read'),
629
+ audit: true,
630
+ sql: ({ patientId }) => from<Chart>('charts', charts).where({ patientId }),
631
+ });
632
+ ```
633
+
634
+ Every **call** is one record in the installed `AuditSink` — allowed, denied (before or after the
635
+ input parse) and failed. The sink, the record and the installed slot are `@ultimat3/core`'s, the
636
+ same ones an action's `audit: true` writes to: `setAuditSink(sink)` from `@ultimat3/core` (or
637
+ `@ultimat3/action` — the same function) installs one sink for both, and `record.primitive` tells a
638
+ read from a write.
639
+
640
+ **A memo or cache hit is recorded too**, with `replayed: true`. An audit of a read is about who saw
641
+ what, not what was computed: a reader handed rows by the request memo saw them exactly as much as
642
+ the reader whose call executed the SQL.
643
+
644
+ | Field | On a read |
645
+ |---|---|
646
+ | `name` | the export name — required, and the record's only name (the `action` alias left in 25.0.0) |
647
+ | `primitive` / `mutator` | `'query'` / `false` |
648
+ | `surface` | `server` (a direct call, `.as()`, `.page()`), `http` (the route), `mcp` (`@ultimat3/mcp`'s served tool: `sourceFor(…, { surface: 'mcp' })`) |
649
+ | `input` | the **parsed** input, as an action's record carries it; `undefined` when the parse failed. Unredacted in the record — a persisting sink redacts through `auditableInput` (core's credential-key table and `Secret`), as `postgresAuditSink` does |
650
+ | `replayed` | `true` when the request memo or a cache tier answered |
651
+ | `idempotencyKey` | always `null` |
652
+ | `outcome` / `failure` | `allowed` \| `denied` \| `failed`, and the `X_*` code with the thrown value |
653
+
654
+ **The rows are never on the record** — an action's record carries no result for the same reason:
655
+ a copy of every row seen is a second table to protect as carefully as the first.
656
+
657
+ | Path | Records |
658
+ |---|---|
659
+ | `read(input)`, `.as()`, the route, `.page()` | once per call — `.page()` once even when a spent `.limit()` answers without executing |
660
+ | `sourceFor(target, input, …)` then `execute()` | once per `execute()`; a build that is refused (denied, unparsed, rate-limited) once, at the build |
661
+ | `explainQuery()`, `describeSql()`, the shared live window | never — they are built `unenforced`, with no caller to attribute a sighting to |
662
+
663
+ Failure policies are the action seam's: no sink installed is `X_QUERY_AUDIT_SINK_MISSING`, refused
664
+ before the parse; a sink refusing a **denied/failed** record is logged (`audit.sink.failed`) and the
665
+ original error still reaches the caller; a sink refusing an **allowed** record withholds the rows,
666
+ `X_QUERY_AUDIT_SINK_FAILED` — a read commits nothing, so a retry is safe.
667
+
596
668
  ## Errors
597
669
 
598
670
  | Code | When | Fix |
599
671
  |---|---|---|
672
+ | `X_QUERY_AUDIT_SINK_MISSING` | `audit: true` and no audit sink installed — refused before the parse | `setAuditSink(sink)` from `@ultimat3/core` at boot |
673
+ | `X_QUERY_AUDIT_SINK_FAILED` | the sink refused the record of an allowed read; the rows were withheld | fix the sink, then retry the read |
600
674
  | `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
601
675
  | `X_QUERY_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
602
676
  | `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
@@ -622,6 +696,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
622
696
  |---|---|---|
623
697
  | `CursorValueUnsupportedError` | `X_CURSOR_VALUE_UNSUPPORTED` | `src/errors.ts` |
624
698
  | `MatcherUnsupportedError` | `X_MATCHER_UNSUPPORTED` | `src/errors.ts` |
699
+ | `QueryAuditSinkFailedError` | `X_QUERY_AUDIT_SINK_FAILED` | `src/audit-errors.ts` |
700
+ | `QueryAuditSinkMissingError` | `X_QUERY_AUDIT_SINK_MISSING` | `src/audit-errors.ts` |
625
701
  | `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
626
702
  | `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
627
703
  | `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "24.0.0",
3
+ "version": "25.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",
@@ -30,18 +30,18 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "engines": {
33
- "bun": ">=1.4.0"
33
+ "bun": ">=1.4.2"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/cache": "24.0.0",
41
- "@ultimat3/core": "24.0.0",
42
- "@ultimat3/entity": "24.0.0",
43
- "@ultimat3/http": "24.0.0",
44
- "@ultimat3/policy": "24.0.0",
45
- "@ultimat3/schema": "24.0.0"
40
+ "@ultimat3/cache": "25.0.0",
41
+ "@ultimat3/core": "25.0.0",
42
+ "@ultimat3/entity": "25.0.0",
43
+ "@ultimat3/http": "25.0.0",
44
+ "@ultimat3/policy": "25.0.0",
45
+ "@ultimat3/schema": "25.0.0"
46
46
  }
47
47
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The two ways an audited read fails because of its audit, and nothing else. Their own module so
3
+ * `errors.ts` keeps one job — the codes' TITLES are registered there, with every other code this
4
+ * package owns — and so a read that declares no audit never constructs either.
5
+ */
6
+
7
+ import { UltimateError } from '@ultimat3/core';
8
+
9
+ /**
10
+ * `audit: true` with no sink installed. Raised before the input is parsed and before anything is
11
+ * read — the twin of `@ultimat3/action`'s `X_AUDIT_SINK_MISSING`, and for the same reason there is
12
+ * no logger-backed default to fall back on: a line nobody stores would satisfy the declaration
13
+ * while recording nothing, which for a read is "who saw what" silently answered "nobody".
14
+ */
15
+ export class QueryAuditSinkMissingError extends UltimateError {
16
+ constructor(query: string) {
17
+ super({
18
+ code: 'X_QUERY_AUDIT_SINK_MISSING',
19
+ cause: `query "${query}" declares \`audit: true\` and no audit sink is installed`,
20
+ // The registered fix, verbatim: code first, so the line can be pasted (`wiki/Error-Codes.md`).
21
+ fix: 'setAuditSink(sink) # from @ultimat3/core, at boot before defineApi()',
22
+ });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The sink refused the record of a read that SUCCEEDED, so the rows are withheld. The opposite of
28
+ * the action twin's position on one point: an action's handler had already committed and only the
29
+ * record is missing, while a read has changed nothing — so refusing the answer costs one retry,
30
+ * and handing back rows nobody recorded is exactly the gap an audited read exists to close.
31
+ */
32
+ export class QueryAuditSinkFailedError extends UltimateError {
33
+ constructor(query: string, sourceError: unknown) {
34
+ super({
35
+ code: 'X_QUERY_AUDIT_SINK_FAILED',
36
+ cause: `"${query}" was read and its audit sink refused the record, so the rows were withheld — a read commits nothing, so nothing is left to reconcile`,
37
+ // The sink's own error is `sourceError`; a read commits nothing, so the retry is safe.
38
+ fix: 'setAuditSink(sink) # install a sink that accepts the record, then retry the read',
39
+ meta: { query },
40
+ sourceError,
41
+ });
42
+ }
43
+ }