@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 +54 -34
- package/README.md +102 -26
- 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 +4 -5
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +39 -25
- package/src/index.ts +27 -55
- package/src/input-shape.ts +1 -1
- package/src/live.ts +19 -3
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +34 -35
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -58
- 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 +8 -8
- package/src/source.ts +2 -2
- 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 -102
- package/src/naming.ts +0 -22
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` | `
|
|
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
|
-
| `
|
|
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 (`
|
|
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 `
|
|
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.
|
|
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; `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
`
|
|
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
|
|
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,
|
|
94
|
-
`
|
|
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** (`
|
|
113
|
-
`DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded
|
|
114
|
-
|
|
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`
|
|
124
|
-
|
|
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
|
|
127
|
-
(
|
|
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
|
|
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
|
|
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`
|
|
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 `
|
|
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
|
-
`
|
|
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
|
|
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 — `
|
|
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 {
|
|
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 =
|
|
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
|
-
`
|
|
163
|
-
`@ultimat3/action`
|
|
164
|
-
|
|
165
|
-
|
|
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 (`
|
|
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
|
|
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` | `
|
|
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 `
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
|
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)`
|
|
411
|
-
|
|
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 — `
|
|
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
|
-
`
|
|
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.
|
|
578
|
-
|
|
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": "
|
|
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
|
}
|
|
@@ -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
|
+
}
|