@ultimat3/query 23.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 +75 -37
- package/README.md +147 -32
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +19 -6
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +43 -33
- package/src/index.ts +37 -61
- package/src/input-shape.ts +21 -1
- package/src/live.ts +19 -3
- package/src/matcher.ts +14 -7
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +88 -46
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -56
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +53 -35
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +26 -11
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -81
- package/src/naming.ts +0 -22
package/CLAUDE.md
CHANGED
|
@@ -16,74 +16,88 @@ 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` | `
|
|
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) |
|
|
37
|
+
| `column-kinds.ts` | `kindsOf(relation)`: a column's DECLARED kind, from `QueryShape.entity` |
|
|
38
38
|
| `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
|
|
39
|
-
| `
|
|
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`) |
|
|
40
41
|
|
|
41
42
|
## Invariants — the read path
|
|
42
43
|
|
|
43
|
-
- Every surface goes through `sourceFor`: the policy's actor half (`
|
|
44
|
+
- Every surface goes through `sourceFor`: the policy's actor half (`guardQueryBeforeInput`), parse input,
|
|
44
45
|
evaluate policy, build the source. A second
|
|
45
46
|
read path is the one unforgivable change here.
|
|
46
47
|
- **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
|
|
47
|
-
`@ultimat3/entity`'s tenant guard reads) must be the identity `
|
|
48
|
+
`@ultimat3/entity`'s tenant guard reads) must be the identity `guardQuery()` decided about.
|
|
48
49
|
`read-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
|
|
49
50
|
- **Skipping a read's policy costs a WRITTEN REASON** (`unenforced?: string`; blank refused).
|
|
50
51
|
`ToLiveOptions.enforce` stays a boolean, translated into `SHARED_WINDOW_REASON`.
|
|
51
52
|
- The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are never re-exported from
|
|
52
53
|
`src/index.ts` — that omission is the enforcement. A query has no `.def`; outside the package read
|
|
53
54
|
`.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`. A projection is reached through the
|
|
54
|
-
query (`liveFeed.
|
|
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`.
|
|
55
59
|
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
|
|
56
60
|
- **`LiveQuery` describes the read *and* runs it** (`execute()`, never memoised).
|
|
57
61
|
`@ultimat3/realtime`'s shared window reads through it.
|
|
58
62
|
- `isLive` is the declared boolean, `live()` the subscription. `QueryDescriptor.live` keeps its name
|
|
59
63
|
(`manifest` and `admin` read it).
|
|
60
|
-
- `mcp` is opt-in; `
|
|
64
|
+
- `mcp` is opt-in; `openapi.ts` asks core's `isMcpExposed` directly — the `isExposed` wrapper is gone.
|
|
61
65
|
- **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
|
|
62
|
-
`
|
|
63
|
-
barrel exports nothing matching `/tool_?name/i`.
|
|
66
|
+
`index.test.ts` asserts the barrel exports nothing matching `/tool_?name/i`.
|
|
64
67
|
- **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
|
|
65
|
-
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `
|
|
68
|
+
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from core's `queryPath`.**
|
|
66
69
|
- **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
|
|
67
70
|
excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
|
|
68
71
|
it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
|
|
69
72
|
- **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
|
|
70
73
|
`meta.input` on the route.
|
|
71
|
-
-
|
|
72
|
-
|
|
74
|
+
- **The client and the route are two halves of one encoding.** `searchOf` sends a `Date` as its ISO
|
|
75
|
+
instant and nothing for `[]`; `absentArraysOf` (`input-shape.ts`) reads a REQUIRED array nobody
|
|
76
|
+
sent as `[]`. Never for an optional or defaulted one — absence there is the schema's answer.
|
|
77
|
+
`http-round-trip.test.ts` pins both ends against each other.
|
|
78
|
+
- **A `single: true` read answers the row or `X_NOT_FOUND` by ONE rule** (`single-answer.ts`:
|
|
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`.
|
|
73
86
|
- **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
|
|
74
87
|
answer, rendered ONCE at projection (`X_QUERY_DEPRECATION_INVALID` at mount). Versioning is two
|
|
75
88
|
deployments behind one ingress.
|
|
76
89
|
- **The span wraps the whole read** (`readRows` holds it, `readRowsIn` is the body); bounded
|
|
77
90
|
attributes only — never the input or an actor id. `telemetry.test.ts` asserts the extent through
|
|
78
91
|
`currentSpan()`.
|
|
79
|
-
- **`policyCapability` is a display label; `policyPermissions` (`QueryDescriptor.permissions`) is
|
|
80
|
-
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.
|
|
81
94
|
- **`client.ts` injects `traceparent`** before the caller's headers; an incomplete span context sends
|
|
82
95
|
nothing.
|
|
83
96
|
- **A read is `no-store`, and its policy is `enforcedBy: 'handler'`** — `runQuery` is the one
|
|
84
97
|
evaluation (`http.test.ts` counts exactly one).
|
|
85
|
-
- **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree,
|
|
86
|
-
`
|
|
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.
|
|
87
101
|
- `registry.ts` announces `registerQueries` in core's registrar table at import; `defineApi` in
|
|
88
102
|
`@ultimat3/action` depends on it (`X_REGISTRAR_MISSING` otherwise).
|
|
89
103
|
- Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
|
|
@@ -99,11 +113,25 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
99
113
|
`toLiveQuery`): a change row carries none, so the window would be read once and never patched.
|
|
100
114
|
The refusal names the column, never the filter's value.
|
|
101
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
|
+
|
|
102
130
|
## Invariants — the client
|
|
103
131
|
|
|
104
|
-
- **Flight control is `@ultimat3/core`'s, re-exported** (`
|
|
105
|
-
`DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded
|
|
106
|
-
|
|
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
|
|
107
135
|
code, curve, fence or retry loop here; `bun run flight-copies` says so.
|
|
108
136
|
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** — a caller's own
|
|
109
137
|
`AbortError` is terminal. Must survive.
|
|
@@ -112,11 +140,12 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
112
140
|
transport owns `Accept`, trace/budget headers, the principal fence, the error decode and the record
|
|
113
141
|
envelope; the flight handed to it dedups concurrent identical reads. `bun run browser-transport`
|
|
114
142
|
is the guard.
|
|
115
|
-
- **`@ultimat3/query/client` is the read client without the barrel** — `client.ts`
|
|
116
|
-
|
|
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
|
|
117
145
|
`client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
|
|
118
|
-
titles table in the graph or past
|
|
119
|
-
(
|
|
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).
|
|
120
149
|
- **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
|
|
121
150
|
`@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
|
|
122
151
|
answer. No `rows:` is byte-identical on the wire.
|
|
@@ -131,7 +160,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
131
160
|
`SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
|
|
132
161
|
ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
|
|
133
162
|
`Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
|
|
134
|
-
|
|
163
|
+
Its three refusals (blank term, a cursor, a window that cuts rows) are caller-reachable, so each
|
|
164
|
+
is `X_INPUT_INVALID` — never core's `assert`. `search.test.ts`'s fixture must stay NOT
|
|
165
|
+
id-ascending.
|
|
135
166
|
- **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
|
|
136
167
|
`shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
|
|
137
168
|
membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
|
|
@@ -145,7 +176,11 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
145
176
|
- The matcher patches from `QueryShape`, never from SQL text.
|
|
146
177
|
- `paginate` has no `offset` and never grows one; it is reachable only as
|
|
147
178
|
`query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
|
|
148
|
-
(10,000
|
|
179
|
+
(10,000 — `@ultimat3/entity`'s, imported; this package never re-publishes it).
|
|
180
|
+
- **A declared `.limit()` bounds the LISTING, on every page**: the window is
|
|
181
|
+
`min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
|
|
182
|
+
tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
|
|
183
|
+
read. `Builder.seek()` takes the smaller limit too. `pagination-limit.test.ts`.
|
|
149
184
|
- **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
|
|
150
185
|
- **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
|
|
151
186
|
never reintroduce a row lookup. The seek predicate is spelled out per key
|
|
@@ -163,22 +198,25 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
163
198
|
- **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
|
|
164
199
|
v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
|
|
165
200
|
minted.
|
|
166
|
-
-
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
`
|
|
201
|
+
- **This package has NO comparator.** How two values compare is `@ultimat3/entity`'s
|
|
202
|
+
`compareByKind` / `sameValueOfKind`, asked under the column's declared kind (`kindsOf(shape.entity)`,
|
|
203
|
+
`column-kinds.ts`). `compareRows`, `matchesFilter(s)` and `isAfterKey` REQUIRE the `KindOf`; never
|
|
204
|
+
add a `typeof` branch here — fix the rule in entity. `equalOrBothNull` adds only this package's
|
|
205
|
+
NULL rule. `compare-parity-fixture.ts` is the one table: `compare-parity.test.ts` (entity, matcher,
|
|
206
|
+
`from()`) and `compare-parity.live.test.ts` (Postgres). `grep compareValues src` stays empty.
|
|
207
|
+
- **A relation no entity declares has no kinds** — digits there are text, as in a `text` column.
|
|
208
|
+
`shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`, one case per kind.
|
|
170
209
|
- **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
|
|
171
210
|
a `refill`, never an `add`.**
|
|
172
211
|
- **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
|
|
173
212
|
missing an ordering column (`Object.hasOwn`, never a value check) gets a `refill` rather than a
|
|
174
213
|
guess. A delete is addressed by index and still patches.
|
|
175
|
-
- **`tagKeys` is `@ultimat3/cache`'s
|
|
176
|
-
that one here.
|
|
214
|
+
- **`tagKeys` is `@ultimat3/cache`'s** — the only public one; never restore a local copy.
|
|
177
215
|
- **A fingerprint is `@ultimat3/core`'s** (`canonicalJson` + SHA-256/16 `fingerprint`), tagging `Date`,
|
|
178
216
|
`Map`, `Set`. `query-hash.test.ts` pins it at `queryHash` and `cacheKeyFor`. `stable.ts` keeps
|
|
179
|
-
`columnOf`
|
|
217
|
+
`columnOf`; core's `isJsonObject` is imported from core (`X_HELPER_COPY` refuses a re-export).
|
|
180
218
|
- The cursor codec is `@ultimat3/core`'s; this package supplies only the scope (`queryHash(name,
|
|
181
|
-
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).
|
|
182
220
|
|
|
183
221
|
## Invariants — caching
|
|
184
222
|
|
|
@@ -192,10 +230,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
192
230
|
- **`cache.ttlMs` is judged at `query()`** (`X_QUERY_CACHE_TTL_INVALID`). A `cache:` read always
|
|
193
231
|
expires: `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60 s); an unset TTL is OMITTED so the tier
|
|
194
232
|
defaults.
|
|
195
|
-
- **A fill is FENCED, by `@ultimat3/cache`'s fence inside `
|
|
233
|
+
- **A fill is FENCED, by `@ultimat3/cache`'s fence inside `cacheStack.read`** — never a second
|
|
196
234
|
one here (`cache-fence.test.ts`).
|
|
197
235
|
- **This package owns NO cache store**: a `cache:` read fills
|
|
198
|
-
`
|
|
236
|
+
`cacheStack(registeredTiers(), { clock })`. Never reintroduce a store, never call
|
|
199
237
|
`tier.invalidateTags()` here. A tier refusal degrades the cache, never the read.
|
|
200
238
|
- **The read path reads NO clock** — it hands the stack a relative `ttlMs` (`read-tier.test.ts`).
|
|
201
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
|
|
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
|
|
|
@@ -68,8 +68,11 @@ A detail read declares `single: true`. The route answers the first row as the bo
|
|
|
68
68
|
`X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
|
|
69
69
|
(400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
|
|
70
70
|
`queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
|
|
71
|
-
`.as()`, `.page()
|
|
72
|
-
|
|
71
|
+
`.as()`, `.page()` and `.live()` still answer `readonly TRow[]`, so opting in breaks no `[0]`
|
|
72
|
+
already written. `As of 2026-09-29`. **`tool().read()` answers as the route does**
|
|
73
|
+
(`As of 2026-10`): the row itself, and `X_NOT_FOUND` when `sql` returns none — an agent given `[]`
|
|
74
|
+
for a missing id read "found, and empty" where HTTP said 404. The tool `@ultimat3/mcp` serves over
|
|
75
|
+
`tools/call` answers the same two ways.
|
|
73
76
|
|
|
74
77
|
```ts
|
|
75
78
|
import { can } from '@ultimat3/policy';
|
|
@@ -119,20 +122,21 @@ or with a schema no `entity()` branded (a `t.object` look-alike, a `.pick()`ed p
|
|
|
119
122
|
is the bare rows, byte-identical to before. `rows:` exists because a read's `sql:` names its table
|
|
120
123
|
as a STRING, so nothing else carries a schema the envelope could be derived from.
|
|
121
124
|
|
|
122
|
-
### Flight control — `
|
|
125
|
+
### Flight control — `clientFlight`, opt-in
|
|
123
126
|
|
|
124
127
|
A typed read is one dispatch and nothing else until a `flight` is installed. With one, N concurrent
|
|
125
128
|
identical reads become ONE dispatch, a fence can retire everything issued before now, and a failure
|
|
126
129
|
worth sending again is sent again on `@ultimat3/core`'s one backoff curve.
|
|
127
130
|
|
|
128
131
|
```ts
|
|
129
|
-
import {
|
|
132
|
+
import { clientFlight, isSuperseded } from '@ultimat3/core';
|
|
133
|
+
import { queryClient } from '@ultimat3/query';
|
|
130
134
|
|
|
131
135
|
declare const session: () => { userId: string };
|
|
132
136
|
declare const baseUrl: string;
|
|
133
137
|
type Api = { queries: Record<string, never> };
|
|
134
138
|
|
|
135
|
-
const flight =
|
|
139
|
+
const flight = clientFlight({
|
|
136
140
|
principal: () => session().userId, // dedup is OFF without this — see below
|
|
137
141
|
retry: { attempts: 3 }, // default is `attempts: 1`, i.e. no retry
|
|
138
142
|
deadlineMs: 10_000,
|
|
@@ -156,10 +160,11 @@ flight.bump();
|
|
|
156
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 |
|
|
157
161
|
| a deadline is `X_TIMEOUT` | core's code, already classified `retryable`, so a caller's own loop needs no table |
|
|
158
162
|
|
|
159
|
-
`
|
|
160
|
-
`@ultimat3/action`
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
163
168
|
|
|
164
169
|
Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value. Importing
|
|
165
170
|
`queryClient` alone through this package's barrel, `bun build --target=browser --minify`,
|
|
@@ -176,7 +181,7 @@ merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN`
|
|
|
176
181
|
### Skipping the policy costs a written reason
|
|
177
182
|
|
|
178
183
|
Two reads have no subscriber to decide about: developer tooling that returns the statement and no
|
|
179
|
-
rows (`
|
|
184
|
+
rows (`explainQuery`, `describeSql`), and the shared, subject-less window a sync node builds once per
|
|
180
185
|
`(query, input)`. Both say so, in words:
|
|
181
186
|
|
|
182
187
|
```ts
|
|
@@ -199,15 +204,17 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
199
204
|
| `mcp-tool.ts` | the MCP read descriptor |
|
|
200
205
|
| `client.ts` | the typed read client (browser-safe) |
|
|
201
206
|
| `http.ts` | the route projection — `GET /_x/query/<kebab>`, the URL the client derives |
|
|
202
|
-
| `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire
|
|
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` |
|
|
203
208
|
| `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query |
|
|
204
|
-
| `naming.ts` | export name → wire path. The MCP tool name is the export name verbatim |
|
|
205
209
|
| `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
|
|
206
210
|
| `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) |
|
|
207
211
|
| `pagination.ts` | `paginate()` — keyset pages over core's cursor codec |
|
|
208
|
-
| `sql.ts` | `
|
|
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` |
|
|
209
215
|
| `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
|
|
210
216
|
| `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
|
|
217
|
+
| `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
|
|
211
218
|
| `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
|
|
212
219
|
|
|
213
220
|
## Live queries
|
|
@@ -340,8 +347,17 @@ empty box is not an empty result set.
|
|
|
340
347
|
|
|
341
348
|
**It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
|
|
342
349
|
that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
|
|
343
|
-
page the provider fetched and report `
|
|
344
|
-
refused, and the
|
|
350
|
+
page the provider fetched and report `hasMore: false` at its edge. So a second page is
|
|
351
|
+
refused, and the cause names the chain — `db.posts.search(term).after(cursor).page()`.
|
|
352
|
+
|
|
353
|
+
| A request `search()` refuses | Answer |
|
|
354
|
+
|---|---|
|
|
355
|
+
| `q` of only whitespace (`?q=%20%20` passes `t.string.min(1)`) | **400** `X_INPUT_INVALID` |
|
|
356
|
+
| a cursor (`?_after=…`) | **400** `X_INPUT_INVALID` |
|
|
357
|
+
| a window that would cut rows (`?_first=1` with two matches) | **400** `X_INPUT_INVALID`, naming both edits: widen `first` to the read's `limit`, or narrow `limit` |
|
|
358
|
+
|
|
359
|
+
All three arrive on the request, so all three are the caller's. `As of 2026-10`; until then they
|
|
360
|
+
were core `assert`s — `X_INVARIANT`, a 500 blaming the server.
|
|
345
361
|
|
|
346
362
|
## Pagination is cursor-only
|
|
347
363
|
|
|
@@ -358,17 +374,19 @@ not exported, because a page is the read's own answer rather than an imported he
|
|
|
358
374
|
|
|
359
375
|
The route reads two controls off the search string BEFORE the schema sees it, and with either
|
|
360
376
|
present answers the `Page` envelope `.page()` answers a server caller with — `{ rows, nextCursor,
|
|
361
|
-
hasMore
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
366
384
|
client written before the controls existed keeps reading rows. `As of 2026-09`; until then the
|
|
367
385
|
route had no envelope, and a query over a paged external source carried its page marker on a row
|
|
368
386
|
(an `olderCursor` column on the oldest message), which is a cursor on the wrong side of the shape.
|
|
369
387
|
|
|
370
388
|
```
|
|
371
|
-
GET /_x/query/live-feed?orgId=…&_first=20 → { rows, nextCursor, hasMore
|
|
389
|
+
GET /_x/query/live-feed?orgId=…&_first=20 → { rows, nextCursor, hasMore }
|
|
372
390
|
GET /_x/query/live-feed?orgId=…&_first=20&_after=<c> → the next page (<c> = nextCursor)
|
|
373
391
|
GET /_x/query/live-feed?orgId=… → [ …rows ] (unchanged)
|
|
374
392
|
```
|
|
@@ -380,19 +398,22 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
|
|
|
380
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 |
|
|
381
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 |
|
|
382
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 |
|
|
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()` |
|
|
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 |
|
|
383
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 |
|
|
384
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 |
|
|
385
405
|
|
|
386
406
|
The MCP read tool does not page: `read()` answers the source's rows, bounded by the read's own
|
|
387
|
-
`limit
|
|
407
|
+
`limit` — or the one row of a `single: true` read — and an agent that wants more asks a narrower
|
|
408
|
+
question.
|
|
388
409
|
|
|
389
410
|
The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
|
|
390
411
|
`configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
|
|
391
412
|
open cursor) and `usesDevCursorSecret` are all imported from there. `As of 2026-08`, `x doctor`
|
|
392
413
|
reports `X_CURSOR_SECRET_DEV` when a production process is still signing with the key shipped in
|
|
393
414
|
the package, and rotating the secret is what invalidates every open cursor. This package supplies
|
|
394
|
-
the only thing that is its business — the scope, `queryHash(name, input)`
|
|
395
|
-
|
|
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).
|
|
396
417
|
|
|
397
418
|
The scope's hash is **SHA-256, first 16 hex** — `fingerprint` from `@ultimat3/core`, the primitive
|
|
398
419
|
and width `@ultimat3/entity`'s `planScope` already uses, and the same function
|
|
@@ -453,13 +474,36 @@ the rows after it were unreachable through a cursor. An ascending key now reache
|
|
|
453
474
|
NULL under `nulls last`, and the page continues on the id tiebreak, which is never NULL.
|
|
454
475
|
|
|
455
476
|
`nulls last` / `nulls first` are Postgres' own defaults, written down rather than inherited: it is
|
|
456
|
-
the rule `
|
|
477
|
+
the rule `compareRows` implements, so the in-memory sort and the seek predicate can only agree
|
|
457
478
|
with it, and a driver whose default differs cannot re-open the divergence.
|
|
458
479
|
|
|
480
|
+
## How two values compare
|
|
481
|
+
|
|
482
|
+
**By the column's declared kind, and by `@ultimat3/entity`'s comparator — this package has none.**
|
|
483
|
+
`kindsOf(shape.entity)` resolves each column's `ColumnKind` from the entity declared on that table,
|
|
484
|
+
and `compareRows`, `matchesFilter`, `isAfterKey` and the matcher hand it to entity's
|
|
485
|
+
`compareByKind` / `sameValueOfKind`: the one JS statement of how Postgres compares a column.
|
|
486
|
+
`As of 2026-10`; until then a second comparator here decided by `typeof`.
|
|
487
|
+
|
|
488
|
+
| Column | What its row holds | Decided as |
|
|
489
|
+
|---|---|---|
|
|
490
|
+
| `bigint()`, `decimal()` | decimal TEXT | digits, exactly: `'9'` before `'10'`, `'2.50'` equal to `'2.5'` and to `2.5` |
|
|
491
|
+
| `uuid()` | text | a value: upper and lower case are one id |
|
|
492
|
+
| `timestamp()` | `Date` | an instant: a `Date` equals the ISO text naming it |
|
|
493
|
+
| `text()` | text | characters: `'10'` before `'9'`, as `order by` on a text column answers |
|
|
494
|
+
| a relation no entity declares | whatever the row provider returned | no kind — numbers numerically (a `number` and a `bigint` in one order), `Date`s by instant, the rest as text |
|
|
495
|
+
|
|
496
|
+
`compare-parity-fixture.ts` is the one `(kind, left, right)` table: `compare-parity.test.ts` runs it
|
|
497
|
+
through entity's evaluator, the matcher and `from()`; `compare-parity.live.test.ts` runs it through
|
|
498
|
+
Postgres, and walks a live window ordered on a `bigint()` and a `decimal()` column against the
|
|
499
|
+
database's own `order by`. A custom `SqlSource` calling `compareRows`, `matchesFilter` or
|
|
500
|
+
`isAfterKey` passes `kindsOf(shape.entity)` — the argument is required, because a comparison with
|
|
501
|
+
no kind is the defect.
|
|
502
|
+
|
|
459
503
|
## Caching
|
|
460
504
|
|
|
461
505
|
Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
|
|
462
|
-
`@ultimat3/cache` has registered — `
|
|
506
|
+
`@ultimat3/cache` has registered — `cacheStack(registeredTiers())`, read down and promoted
|
|
463
507
|
up. Keys are `query:<name>:<authority>:<input fingerprint>:<tags>`. An action's `cache.invalidates`
|
|
464
508
|
and a query's `cache.tags` meet in the one graph owned by `@ultimat3/cache`, because there is one
|
|
465
509
|
registry and this package holds no store of its own.
|
|
@@ -482,7 +526,7 @@ skipped policy. `readAuthority(actor, scope)` is the only producer of the compon
|
|
|
482
526
|
required positional argument of `cacheKeyFor`, because an optional one is one a call site forgets.
|
|
483
527
|
|
|
484
528
|
**The fill is fenced, best-effort and single-flighted, and none of that is written here.**
|
|
485
|
-
`
|
|
529
|
+
`cacheStack` samples `@ultimat3/cache`'s fence immediately before it runs the source and
|
|
486
530
|
re-asks it per rung before each write, so a bust that lands mid-read cannot be republished for the
|
|
487
531
|
whole TTL — the caller still gets the rows it read, because those are its answer; only publishing is
|
|
488
532
|
refused. Every tier call goes through `bestEffort`, so a Redis refusal is a miss rather than a
|
|
@@ -535,8 +579,21 @@ rateLimit: { limit: 3, windowMs: 600_000 }, // 3 held, one back every three an
|
|
|
535
579
|
Symmetric with an action's, and for a reason: without it **every** `GET /_x/query/*` fell to the
|
|
536
580
|
`default` bucket — 120 burst, 2/s per actor — so one authenticated caller could hold 120
|
|
537
581
|
cross-tenant aggregates in flight and then 2/s indefinitely, from a single account, with no
|
|
538
|
-
declaration able to say otherwise.
|
|
539
|
-
|
|
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
|
+
|
|
540
597
|
The conversion is `toBucket` from `@ultimat3/http` — the same one the action route uses, because
|
|
541
598
|
http owns `Bucket` and `action` is the same tier as this package. A pair the limiter cannot run
|
|
542
599
|
on is `X_RATE_LIMIT_INVALID`, at projection.
|
|
@@ -554,19 +611,75 @@ before the read is deleted. A date that cannot be rendered is `X_QUERY_DEPRECATI
|
|
|
554
611
|
projection, not on the first read. Versioning is two deployments behind one ingress, never a
|
|
555
612
|
router feature here.
|
|
556
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
|
+
|
|
557
668
|
## Errors
|
|
558
669
|
|
|
559
670
|
| Code | When | Fix |
|
|
560
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 |
|
|
561
674
|
| `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
|
|
562
675
|
| `X_QUERY_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
|
|
563
676
|
| `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
|
|
564
677
|
| `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
|
|
565
678
|
| `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
|
|
566
679
|
| `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
|
|
567
|
-
| `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
|
|
680
|
+
| `X_INPUT_INVALID` | input failed the Standard Schema; a page control the wire refuses; a `search()` handed a blank term, a cursor, or a window that would cut rows | `x queries describe <name> --json` |
|
|
568
681
|
| `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
|
|
569
|
-
| `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404
|
|
682
|
+
| `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404, and the MCP tool's refusal (entity's code) | `x queries describe <name> --json` prints the SQL |
|
|
570
683
|
| `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
|
|
571
684
|
| `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
|
|
572
685
|
| `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
|
|
@@ -583,6 +696,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
583
696
|
|---|---|---|
|
|
584
697
|
| `CursorValueUnsupportedError` | `X_CURSOR_VALUE_UNSUPPORTED` | `src/errors.ts` |
|
|
585
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` |
|
|
586
701
|
| `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
|
|
587
702
|
| `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
|
|
588
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": "
|
|
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.
|
|
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": "
|
|
41
|
-
"@ultimat3/core": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/http": "
|
|
44
|
-
"@ultimat3/policy": "
|
|
45
|
-
"@ultimat3/schema": "
|
|
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
|
}
|