@ultimat3/query 20.2.1 → 22.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 +158 -544
- package/README.md +65 -8
- package/package.json +9 -7
- package/src/client.ts +39 -63
- package/src/deprecation.ts +0 -5
- package/src/errors.ts +25 -49
- package/src/facade.ts +1 -0
- package/src/http.ts +40 -39
- package/src/index.ts +5 -7
- package/src/naming.ts +10 -22
- package/src/openapi.ts +26 -14
- package/src/page-controls.ts +2 -4
- package/src/page-keys.ts +9 -0
- package/src/pagination.ts +23 -4
- package/src/query.ts +13 -0
- package/src/record-answer.ts +68 -0
- package/src/shape.ts +15 -4
- package/src/source.ts +15 -0
package/CLAUDE.md
CHANGED
|
@@ -12,20 +12,21 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
12
12
|
|
|
13
13
|
| File | Job |
|
|
14
14
|
|---|---|
|
|
15
|
-
| `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the
|
|
15
|
+
| `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the front door for the read path |
|
|
16
16
|
| `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in |
|
|
17
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
19
|
| `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
|
|
20
|
-
| `client.ts` | typed read client (browser-safe
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
| `
|
|
20
|
+
| `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
|
|
21
|
+
| `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
|
|
22
|
+
| — | flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported. No local copy |
|
|
23
|
+
| `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS core's `queryPath`. **Paths only** |
|
|
24
|
+
| `registry.ts` | export-name registration, `describeQueries()`, the `registerPrimitiveRegistrar('query', …)` announcement |
|
|
24
25
|
| `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
|
|
25
26
|
| `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
|
|
26
27
|
| `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
|
|
27
28
|
| `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
|
|
28
|
-
| `cursor-value.ts` | what a sort value becomes inside a cursor, and
|
|
29
|
+
| `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
|
|
29
30
|
| `input-shape.ts` | what a read's `input:` may be, given that its route is a query STRING |
|
|
30
31
|
| `sql.ts` | `explain()` / `describeSql()` |
|
|
31
32
|
| `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
|
|
@@ -33,529 +34,159 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
33
34
|
| `source.ts` | `SqlSource` contract + `from()` in-memory reference |
|
|
34
35
|
| `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
|
|
35
36
|
| `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
|
|
36
|
-
| `deprecation.ts` | `Deprecation` +
|
|
37
|
+
| `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
|
|
37
38
|
|
|
38
|
-
## Invariants
|
|
39
|
+
## Invariants — the read path
|
|
40
|
+
|
|
41
|
+
- Every surface goes through `sourceFor`: parse input, evaluate policy, build the source. A second
|
|
42
|
+
read path is the one unforgivable change here.
|
|
43
|
+
- **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
|
|
44
|
+
`@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
|
|
45
|
+
`read-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
|
|
46
|
+
- **Skipping a read's policy costs a WRITTEN REASON** (`unenforced?: string`; blank refused).
|
|
47
|
+
`ToLiveOptions.enforce` stays a boolean, translated into `SHARED_WINDOW_REASON`.
|
|
48
|
+
- The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are never re-exported from
|
|
49
|
+
`src/index.ts` — that omission is the enforcement. A query has no `.def`; outside the package read
|
|
50
|
+
`.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`. A projection is reached through the
|
|
51
|
+
query (`liveFeed.tool()`); new methods are bound in `facade.ts`.
|
|
52
|
+
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
|
|
53
|
+
- **`LiveQuery` describes the read *and* runs it** (`execute()`, never memoised).
|
|
54
|
+
`@ultimat3/realtime`'s shared window reads through it.
|
|
55
|
+
- `isLive` is the declared boolean, `live()` the subscription. `QueryDescriptor.live` keeps its name
|
|
56
|
+
(`manifest` and `admin` read it).
|
|
57
|
+
- `mcp` is opt-in; `isExposed` delegates to core's `isMcpExposed`.
|
|
58
|
+
- **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
|
|
59
|
+
`mcp-tool.test.ts` asserts `toQueryTool(q).name === queryName(q)`; `index.test.ts` asserts the
|
|
60
|
+
barrel exports nothing matching `/tool_?name/i`.
|
|
61
|
+
- **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
|
|
62
|
+
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
|
|
63
|
+
- **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
|
|
64
|
+
`meta.input` on the route.
|
|
65
|
+
- **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
|
|
66
|
+
`meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
|
|
67
|
+
- **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
|
|
68
|
+
answer, rendered ONCE at projection (`X_QUERY_DEPRECATION_INVALID` at mount). Versioning is two
|
|
69
|
+
deployments behind one ingress.
|
|
70
|
+
- **The span wraps the whole read** (`readRows` holds it, `readRowsIn` is the body); bounded
|
|
71
|
+
attributes only — never the input or an actor id. `telemetry.test.ts` asserts the extent through
|
|
72
|
+
`currentSpan()`.
|
|
73
|
+
- **`policyCapability` is a display label; `policyPermissions` (`QueryDescriptor.permissions`) is what
|
|
74
|
+
a report matches on.**
|
|
75
|
+
- **`client.ts` injects `traceparent`** before the caller's headers; an incomplete span context sends
|
|
76
|
+
nothing.
|
|
77
|
+
- **A read is `no-store`, and its policy is `enforcedBy: 'handler'`** — `runQuery` is the one
|
|
78
|
+
evaluation (`http.test.ts` counts exactly one).
|
|
79
|
+
- **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, via
|
|
80
|
+
`policy-gate.ts`), never the root combinator.
|
|
81
|
+
- `registry.ts` announces `registerQueries` in core's registrar table at import; `defineApi` in
|
|
82
|
+
`@ultimat3/action` depends on it (`X_REGISTRAR_MISSING` otherwise).
|
|
83
|
+
- Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
|
|
84
|
+
close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
|
|
85
|
+
decision across actors.
|
|
86
|
+
|
|
87
|
+
## Invariants — the client
|
|
88
|
+
|
|
89
|
+
- **Flight control is `@ultimat3/core`'s, re-exported** (`createClientFlight`,
|
|
90
|
+
`DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded`, the types) — the same objects
|
|
91
|
+
`@ultimat3/action` exports. Fix the pipeline in `packages/core/src/client-flight.ts`. No new error
|
|
92
|
+
code, curve, fence or retry loop here; `bun run flight-copies` says so.
|
|
93
|
+
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** — a caller's own
|
|
94
|
+
`AbortError` is terminal. Must survive.
|
|
95
|
+
- **`ClientFlight` is a TYPE inside `client.ts`, never a value.**
|
|
96
|
+
- **Every read dispatches through core's `clientTransport`; `client.ts` calls no `fetch`.** The
|
|
97
|
+
transport owns `Accept`, trace/budget headers, the principal fence, the error decode and the record
|
|
98
|
+
envelope; the flight handed to it dedups concurrent identical reads. `bun run browser-transport`
|
|
99
|
+
is the guard.
|
|
100
|
+
- **`@ultimat3/query/client` is the read client without the barrel** — `client.ts` and `naming.ts`
|
|
101
|
+
import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
|
|
102
|
+
`client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
|
|
103
|
+
titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
|
|
104
|
+
(`As of 2026-09-22`, browser, minified): barrel `queryClient` 24,114 B; `./client` 10,210 B.
|
|
105
|
+
- **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
|
|
106
|
+
`@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
|
|
107
|
+
answer. No `rows:` is byte-identical on the wire.
|
|
108
|
+
- **The `sideEffects` array is load-bearing**: `errors.ts` and `registry.ts` run at import. Never `false`.
|
|
109
|
+
|
|
110
|
+
## Invariants — numbers, search, live declarations
|
|
111
|
+
|
|
112
|
+
- **Every numeric option is refused when not FINITE** (`finiteOption()`: the read cache's `ttlMs`,
|
|
113
|
+
`search()`'s `page.max`, `page.default`, `termMax`). `finite-bounds` pins this package at zero.
|
|
114
|
+
- **`search()` is a FACTORY over `query()`**: it owns the input schema (`q` + a bounded `limit`),
|
|
115
|
+
refuses a blank term and calls `.search(term)` on the chain the app hands it (structural
|
|
116
|
+
`SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
|
|
117
|
+
ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
|
|
118
|
+
`Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
|
|
119
|
+
`search.test.ts`'s fixture must stay NOT id-ascending.
|
|
120
|
+
- **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
|
|
121
|
+
`shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
|
|
122
|
+
membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
|
|
123
|
+
(`X_QUERY_SUBSCRIBES_INVALID`).
|
|
124
|
+
- **A read's `input:` must survive a query STRING** (`input-shape.ts`, `X_QUERY_INPUT_UNENCODABLE`):
|
|
125
|
+
refused are structural members (`object`, `record`, `money`, or an array/union of one), a REQUIRED
|
|
126
|
+
nullable member, and a non-object top level. An un-introspectable schema is left alone.
|
|
127
|
+
|
|
128
|
+
## Invariants — ordering, cursors, the matcher
|
|
39
129
|
|
|
40
|
-
- Every surface goes through `sourceFor`: parse input, evaluate policy, build the source.
|
|
41
|
-
Adding a second read path is the one unforgivable change here.
|
|
42
|
-
- **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `asActor` used to hand
|
|
43
|
-
`options.ctx` to `run(ctx)` and enter no `runWithContext` unless an `actor` was also given, so
|
|
44
|
-
`guard()` decided about that actor while everything reading the AMBIENT context — above all
|
|
45
|
-
`@ultimat3/entity`'s tenant guard, which derives from `tryUseContext()` and not from the ctx it is
|
|
46
|
-
handed — saw a different identity, or none. A read was authorised as one caller and scoped to
|
|
47
|
-
nobody. Absent a `ctx` it reinstalls the ambient one, which is a no-op on every path that already
|
|
48
|
-
worked. The twin fix is `@ultimat3/action`'s `invoke`, and `read-context.test.ts` is written as an
|
|
49
|
-
equality between the three spellings of one caller — ambient, `options.actor`, `options.ctx` —
|
|
50
|
-
because three independent expectations are exactly what let this ship.
|
|
51
|
-
- **Skipping a read's policy costs a WRITTEN REASON, never a boolean** (`As of 2026-08`).
|
|
52
|
-
`SourceOptions.enforce?: boolean` is gone; it is `unenforced?: string`, and a blank one is
|
|
53
|
-
refused before the source is built. The bar is `@ultimat3/entity`'s `cross-tenant.ts`: a boolean
|
|
54
|
-
argument "reads exactly like forgetting the tenant", and a forgotten policy reads the same way —
|
|
55
|
-
so the reason IS the mechanism and every skipped policy is one `grep` away with its justification
|
|
56
|
-
attached. It is deliberately NOT capability-gated the way `crossTenant` is: `explain` runs from
|
|
57
|
-
the CLI with no actor to check a scope against, and gating it would close the one surface it
|
|
58
|
-
exists for. **`ToLiveOptions.enforce` stays a boolean**, because it is that one use with exactly
|
|
59
|
-
one reason — `toLiveQuery` translates it into the reason string spelled once as
|
|
60
|
-
`SHARED_WINDOW_REASON`, so a sync node and this file cannot disagree about why the shared window
|
|
61
|
-
has no subject. Two shipped callers, both reading no rows for a subscriber: `sql.ts` and that
|
|
62
|
-
window.
|
|
63
|
-
- The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are internal and must
|
|
64
|
-
never be re-exported from `src/index.ts` — that omission is the enforcement.
|
|
65
|
-
- A query has no `.def`. Inside the package read it with `defOf(target)`; outside, read the
|
|
66
|
-
lifted `.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`.
|
|
67
|
-
- App code reaches a projection through the query (`liveFeed.tool()`), never through `.def`
|
|
68
|
-
and never by importing the projection function. `facade.ts` is where a new method is bound;
|
|
69
|
-
the projection itself keeps living in its own file.
|
|
70
|
-
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a query file imports
|
|
71
|
-
one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
|
|
72
|
-
access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
73
|
-
- **`LiveQuery` describes the read *and* runs it.** `execute()` is the source the shape, the reads
|
|
74
|
-
and `sqlText` were taken from — one build of one `(query, input)`. A caller that wanted rows and
|
|
75
|
-
called `sourceFor` itself was a second build: twice the parse, twice the `sql()`, and a matcher
|
|
76
|
-
describing a source the rows never came from. `@ultimat3/realtime`'s shared window is the one
|
|
77
|
-
consumer, and it reads through `execute()`. It never memoises — a subscriber joining an existing
|
|
78
|
-
subscription must see the rows as they are now, not the window someone else opened.
|
|
79
|
-
- `isLive` is the declared boolean, `live()` is the subscription. Never name one after the other.
|
|
80
|
-
`QueryDescriptor.live` keeps its name — `@ultimat3/manifest` and `@ultimat3/admin` read it.
|
|
81
|
-
- `mcp` is opt-in (`expose: true`), exactly as it is for an action: rows reach an agent only when
|
|
82
|
-
the author said so. `isExposed` here delegates to `isMcpExposed` in `@ultimat3/core` — the one
|
|
83
|
-
predicate every reader in the framework asks — rather than spelling `=== true` a second time.
|
|
84
|
-
- **A read has ONE tool name, and it is the export name verbatim** (`As of 2026-08`). `toQueryTool`
|
|
85
|
-
snake_cased it (`liveFeed` → `live_feed`) while `@ultimat3/mcp` serves the read under
|
|
86
|
-
`queryName(target)` and answers `tools/call` for nothing else — so anything that read the name off
|
|
87
|
-
the descriptor rather than off `tools/list` called a tool the server had never heard of, and the
|
|
88
|
-
scope map in `defineAppMcp` is keyed on the verbatim name too. `toToolName` is **deleted**, not
|
|
89
|
-
merely unused: an exported derivation is a second way to spell one tool. Two pins, both in this
|
|
90
|
-
package because this is where the rule can be broken — `mcp-tool.test.ts` asserts
|
|
91
|
-
`toQueryTool(q).name === queryName(q)` (the presence), and `index.test.ts` asserts the barrel
|
|
92
|
-
exports no key matching `/tool_?name/i` (the absence, which nothing else catches: the only other
|
|
93
|
-
guard is `packages/mcp/src/cross-surface.test.ts`, tier 4 and unimportable from here).
|
|
94
|
-
`naming.ts` derives PATHS only.
|
|
95
|
-
- `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action`
|
|
96
|
-
is the same tier, so its naming is ported here, never imported.
|
|
97
|
-
- **`queryClient` is the map-wide read client and the mirror of `rpc`; both spellings run
|
|
98
|
-
`queryClientMethodFor`.** `queryClient<Api['queries']>({ baseUrl })` is how a surface that must
|
|
99
|
-
not import a feature reaches every registered read — `site/` in an app, whose one edge into
|
|
100
|
-
`app/` is a boundary violation, so `.client()` (which needs the query object) is unreachable
|
|
101
|
-
there. The map-wide client re-deriving a path from the property name would be the second URL
|
|
102
|
-
derivation this package spent a release removing; it proxies to the per-query method instead.
|
|
103
|
-
- **`toQueryRoute` is the other half of `client()`, and the two derive the same URL from the same
|
|
104
|
-
`naming.ts`.** The client shipped fetching `/_x/query/<kebab>` while nothing built a route for
|
|
105
|
-
it, so every typed read compiled and 404'd; a projection whose only consumer is a URL string is
|
|
106
|
-
the failure this pairing exists to prevent. Named for the primitive rather than `toRoute`,
|
|
107
|
-
because a host mounts it beside `@ultimat3/action`'s — the same reason the tool projection here
|
|
108
|
-
is `toQueryTool`.
|
|
109
|
-
- **The route coerces, `runQuery` validates, and only the first belongs to the wire.** A search
|
|
110
|
-
string is characters, so the boundary decodes it with `@ultimat3/schema`'s `coerceQuery` — the
|
|
111
|
-
one HTTP-boundary decoder, which never invents data and hands on what it cannot convert.
|
|
112
|
-
Validating there as well (`request.query(schema)`) would be the second parser: the same read
|
|
113
|
-
would answer `X_BODY_INVALID` where every other surface answers `X_INPUT_INVALID` and prints
|
|
114
|
-
its schema. For the same reason `meta.input` stays **absent** — the pipeline's body stage
|
|
115
|
-
validates it against a body, and a GET has none, so declaring it fails every read on nothing.
|
|
116
|
-
- **`rateLimit:` is declarable on a read, and `toQueryRoute` sets the NAME and the NUMBERS.**
|
|
117
|
-
`QueryDef` had neither until 2026-08, so every `GET /_x/query/*` fell through `bucketFor` to
|
|
118
|
-
`default` — 120 burst, 2/s per actor — and one authenticated caller could hold 120 cross-tenant
|
|
119
|
-
aggregates in flight and then 2/s indefinitely, from a single account, with no declaration able
|
|
120
|
-
to say otherwise. The conversion is `toBucket` from **`@ultimat3/http`**, never a copy here:
|
|
121
|
-
http owns `Bucket` and the maths, `@ultimat3/action` is the same tier as this package, and a
|
|
122
|
-
copy in either is a second answer for the other. The field is lifted onto the facade so
|
|
123
|
-
`toQueryRoute` reads it without `defOf`, exactly as `cache` and `mcp` are.
|
|
124
|
-
- **`deprecated:` is a compat WINDOW; versioning is two deployments behind one ingress.** Headers
|
|
125
|
-
are rendered ONCE at projection, so an unparseable date is `X_QUERY_DEPRECATION_INVALID` at
|
|
126
|
-
mount and not on the first read. `deprecation.ts` is a TWIN of `@ultimat3/action`'s — both are
|
|
127
|
-
tier 3, so neither may import the other, and the shared home is `@ultimat3/http` if it grows
|
|
128
|
-
one. The same compromise `naming.ts` is ported under; keep the two files identical in behaviour.
|
|
129
|
-
- **The span wraps the whole read, not `source.execute()`.** Parse, policy and `sql()`'s own
|
|
130
|
-
construction were outside every span, so a read whose cost was in building the source reported
|
|
131
|
-
milliseconds under a parent that reported seconds — a gap with no name. `readRows` holds the
|
|
132
|
-
span and `readRowsIn` is the body; attributes are bounded (surface, actor KIND, `live`,
|
|
133
|
-
`cached`, `fresh`) plus the row count, and never the input or an actor id — a read is keyed per
|
|
134
|
-
tenant and per cursor, so either would be unbounded. `telemetry.test.ts` asserts the extent
|
|
135
|
-
structurally through `currentSpan()`, because the test clock is frozen. `sourceFor` still has no
|
|
136
|
-
span of its own: adding one would double-span every read that goes through `readRows`.
|
|
137
|
-
- **`policyCapability` is a display label and `policyPermissions` is what a report matches on.**
|
|
138
|
-
A composite renders as `or(feed:read, org:administer)`, which equals no permission string, so
|
|
139
|
-
`x policy list` matching on `capability` reported every non-trivially-guarded read's permissions
|
|
140
|
-
as unenforced. `QueryDescriptor.permissions` is the flattened list, published beside
|
|
141
|
-
`capability` and never instead of it.
|
|
142
|
-
- **`client.ts` injects `traceparent`**, before the caller's own headers so an explicit one wins,
|
|
143
|
-
and sends nothing when the span context is incomplete — `00-<trace>--01` is a header every
|
|
144
|
-
collector drops. The twin of `@ultimat3/action`'s, ported for the same tier reason.
|
|
145
|
-
- **A read is `no-store`, and its policy is `enforcedBy: 'handler'`.** The URL names no actor
|
|
146
|
-
while the answer is scoped to one, so `public` would hand one reader's rows to the next caller
|
|
147
|
-
of that URL; and `runQuery` is the read's one evaluation, deciding from the parsed input, so an
|
|
148
|
-
authz stage deciding first would be a second authz system holding raw strings — and would
|
|
149
|
-
demand an `authorize` hook to decide at all. `http.test.ts` drives both over the real pipeline
|
|
150
|
-
with no hook wired and counts the evaluations: exactly one.
|
|
151
|
-
- **`meta.auth` is derived from a WALK of the policy tree**, never from the root combinator.
|
|
152
|
-
`target.policy.kind === 'allow'` answered `'required'` for `or(allow(), can('x:y'))`, so the
|
|
153
|
-
pipeline's `auth` stage 401'd an anonymous caller the policy itself ALLOWS — while the MCP tool
|
|
154
|
-
and a direct server read let that caller through the same object. One policy, a different answer
|
|
155
|
-
per surface. `'public'` here is `meta.auth` only: the read is still `cache: no-store` and
|
|
156
|
-
`runQuery` still evaluates the policy per caller.
|
|
157
|
-
**`admitsAnonymous` is `@ultimat3/policy`'s** (`policy.ts`, beside `policyPermissions`) and
|
|
158
|
-
reaches this package through `policy-gate.ts` like every other authz question — never a copy
|
|
159
|
-
here. It cannot be one: `@ultimat3/action` needs the identical answer and is the same tier, so a
|
|
160
|
-
copy in either is a second answer for the other. It is EXACT rather than heuristic — with
|
|
161
|
-
`actor === null`, `can()` short-circuits before its predicate and `allow()`/`deny()` ignore their
|
|
162
|
-
arguments, so the tree alone decides. `packages/policy/src/policy.test.ts` asserts it against
|
|
163
|
-
`policy.run({ actor: null })` itself, case for case; `http.test.ts` proves this projection reads
|
|
164
|
-
the answer, over the real pipeline.
|
|
165
|
-
- `registry.ts` announces `registerQueries` in core's registrar table at import. That is how
|
|
166
|
-
`defineApi({ queries })` in `@ultimat3/action` registers a read without importing this package
|
|
167
|
-
sideways. Never remove the announcement: `defineApi` would then throw `X_REGISTRAR_MISSING`.
|
|
168
|
-
- **Flight control is `@ultimat3/core`'s, and this package RE-EXPORTS it.** `client-flight.ts` and
|
|
169
|
-
`client-wire.ts` shipped here and in the other tier-3 client package as byte-identical copies —
|
|
170
|
-
288 and 85 lines — kept in step by a `client-twin.test.ts` in each. A test that makes drift LOUD
|
|
171
|
-
is not the same as a file that cannot drift, and this package's own thesis is that duplication is
|
|
172
|
-
the defect. Both files import nothing but tier 0, which was always the argument for where they
|
|
173
|
-
belong; the one blocker was `isJsonObject`, now `@ultimat3/core`'s `json-object.ts`, re-exported
|
|
174
|
-
from `./stable` here. `createClientFlight`, `DEFAULT_CLIENT_RETRY`, `isTransientFailure`,
|
|
175
|
-
`isSuperseded`, `ClientFlight`, `ClientFlightOptions`, `ClientRetry`, `FlightKeyOptions`,
|
|
176
|
-
`FlightPlan` and `WireAnswer` are all still importable from `@ultimat3/query` — the same names,
|
|
177
|
-
and now literally the same objects the other package exports. Never re-declare one here; the
|
|
178
|
-
fix for anything wrong with the pipeline is an edit in `packages/core/src/client-flight.ts`.
|
|
179
|
-
- **Every mechanism underneath the flight is `@ultimat3/core`'s, the pipeline included.**
|
|
180
|
-
`createSingleFlight` for dedup, `createFence`/`isSuperseded` for supersession, `createFlightGate`
|
|
181
|
-
for the ceiling, `retry` + `backoffDelay` for the schedule, `isRetryableStatus` for the status
|
|
182
|
-
table, `X_TIMEOUT` for the deadline — and `createClientFlight`, which composes them. This package
|
|
183
|
-
declares NO new error code for any of it; never add a second curve, a second fence or a private
|
|
184
|
-
retry loop here, and `bun run flight-copies` is what says so.
|
|
185
|
-
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default, and the inversion must
|
|
186
|
-
survive** (`As of 2026-08-23`). `retryDecision` sends a throw nobody classified again until the
|
|
187
|
-
attempts run out; `@ultimat3/ai` and `@ultimat3/db` each refused the executor outright over it.
|
|
188
|
-
The client keeps the executor and supplies a predicate instead: a declared
|
|
189
|
-
`retryable`/`retry-after`, plus a dispatch that produced no response at all (`fetch` rejecting
|
|
190
|
-
with a plain `TypeError`), and nothing else — a caller's own `AbortError` and a foreign value are
|
|
191
|
-
terminal. The loop is stopped by RESOLVING to a private sentinel rather than by throwing, so the
|
|
192
|
-
original value still reaches the caller unwrapped, which is the property `retry`'s own header
|
|
193
|
-
promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
|
|
194
|
-
side still drive it through this package's own client.
|
|
195
|
-
- **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
|
|
196
|
-
tree-shaking story: `rpc` alone is 14,759 B minified for the browser and `queryClient` alone is
|
|
197
|
-
12,755 B, against 20,292 B / 17,912 B with `createClientFlight` imported beside them — ±376 B run
|
|
198
|
-
to run, which is `Bun.build` 1.4.0 dropping core's `schema-error-codes.ts` (issue #273). A caller
|
|
199
|
-
who wants a plain typed fetch must not pay for the fence, the dedup map or the retry loop —
|
|
200
|
-
`packages/cli/src/templates/resource-form-island.ts` and `examples/dummy`'s contact-sales island
|
|
201
|
-
both write a bare `fetch` today because that bill used to be unavoidable. Never import
|
|
202
|
-
`createClientFlight` for a VALUE from `client.ts` — `ClientFlight` and `ClientRetry` are
|
|
203
|
-
`import type` from `@ultimat3/core` and must stay that way.
|
|
204
|
-
- **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
|
|
205
|
-
2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
|
|
206
|
-
`import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
|
|
207
|
-
'@ultimat3/query'` was 40,859 B — three times the deep-import cost, through the ONLY specifier
|
|
208
|
-
the `exports` map offers. The arrays are the ones `bun run scripts/side-effects.ts --explain
|
|
209
|
-
--json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
|
|
210
|
-
packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
|
|
211
|
-
and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
|
|
212
|
-
- **Every numeric option is refused when it is not a FINITE number, `As of 2026-08-26`.**
|
|
213
|
-
`@ultimat3/core`'s `finiteOption()` guards the read cache's `ttlMs` and `search()`'s `page.max`,
|
|
214
|
-
`page.default` and `termMax`; `bun run finite-bounds` is the ratchet and this package is pinned
|
|
215
|
-
at **zero**. `??` guards nullish and `NaN` is not, so an unchecked bound is a comparison that
|
|
216
|
-
reads false forever — and `Math.max`/`Math.floor` propagate `NaN` rather than validating it.
|
|
217
|
-
- **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
|
|
218
|
-
(`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
|
|
219
|
-
term, and calls `.search(term)` on the chain the app hands it — which is what makes the term
|
|
220
|
-
unable to arrive any other way. The chain crosses **structurally** (`SearchChain`): this package
|
|
221
|
-
may import `@ultimat3/entity` and nothing here does, so four methods are not worth a new
|
|
222
|
-
`package.json` edge and a new `bun.lock` block — the trade `@ultimat3/db`'s `entity-shape.ts`
|
|
223
|
-
makes one tier down. It needs no row in `PRIMITIVE_FACTORIES`: that table is for a factory
|
|
224
|
-
returning an `action` or a `job` from OUTSIDE the primitive's own package, and this returns a
|
|
225
|
-
query from the query package. **It serves ONE page and refuses a second**, because a `SqlSource`
|
|
226
|
-
is handed a `SeekKey` and the entity chain wants its own signed, plan-scoped cursor — there is no
|
|
227
|
-
minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
|
|
228
|
-
inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
|
|
229
|
-
on no page at all is the defect 12.0.0 spent a release removing from the timestamp seek; the
|
|
230
|
-
`fix:` names the entity chain, which pages this read correctly. **The refusal is on the rows that
|
|
231
|
-
would be CUT, never on the window**, `As of 2026-08-26`: a screen demanding `first >= limit`
|
|
232
|
-
refuses the framework's own default pair — `limit` defaults to 20 and `first` arrives from a
|
|
233
|
-
client — so every `pageSize` under 20 was a 500 at page ONE, including a search matching three
|
|
234
|
-
rows that fit the window twice over. `windowOf.execute` asserts on `rows.length` instead, so a
|
|
235
|
-
page that is whole is served with `hasNextPage: false` and no cursor is ever handed back that a
|
|
236
|
-
second call is guaranteed to refuse. **The one page it serves is the
|
|
237
|
-
chain's own ORDER**, `As of 2026-08-26`: `onePage.seek` narrows the window with `Builder.limit()`
|
|
238
|
-
and never with `Builder.seek()`, which sets `totalized` — `servedOrder()` is then `totalOrder([])`
|
|
239
|
-
= `id asc`, because the ranking lives inside the chain behind the row thunk and no `OrderKey` here
|
|
240
|
-
can name it, and `execute()` re-sorts the page the provider already ranked. Measured with a chain
|
|
241
|
-
serving `z, m, a`: `runQuery` answered `z, m, a` and `.page(input, { first: 2 })` answered `a, m`,
|
|
242
|
-
dropping the top-ranked row off page one. It exposes no `total()` for the same reason — an absent
|
|
243
|
-
`total` is how `SqlSource` says the source already serves one order it can be resumed in. The
|
|
244
|
-
fixture in `search.test.ts` must stay NOT id-ascending: `ROWS` is `a, b, c`, which is why the
|
|
245
|
-
shipped page test could not see this.
|
|
246
|
-
- **`subscribes:` is DECLARED because nothing can derive it, and CROSS-CHECKED because a
|
|
247
|
-
declaration drifts** (`subscribes.ts`, `As of 2026-08-26`). `@ultimat3/realtime` refuses a
|
|
248
|
-
subscription to a table that is not `REPLICA IDENTITY FULL` — logical replication carries no old
|
|
249
|
-
row on an `UPDATE`, so no patch is computable — and nothing emitted the `alter`, so a scaffolded
|
|
250
|
-
app with a live query generated a schema its own preflight rejected (#357). It cannot be derived:
|
|
251
|
-
the relation name is a string inside `sql:`, a callback no generator can invoke without valid
|
|
252
|
-
input, so `QueryDescriptor` carried no table and `x db gen` had no live-query set to read. The
|
|
253
|
-
field is optional and a read that declares nothing is unchanged — the emitter sees no table.
|
|
254
|
-
The cross-check is what makes it worth shipping: `toLiveQuery` asserts `shape.entity` is among the
|
|
255
|
-
declared names at the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), one line under
|
|
256
|
-
`assertMatchable` and for its reason — a live read whose old row never arrives cannot be patched,
|
|
257
|
-
so a WARNING would preserve only a subscription that silently stops updating, and the stale
|
|
258
|
-
declaration has already granted the identity to the wrong table. `pg-preflight.ts`'s
|
|
259
|
-
`warnPartialIdentity` warns instead, and that precedent does NOT transfer: it fires for every app
|
|
260
|
-
on the postgres default, which nobody wrote, while this fires only on a declaration an author
|
|
261
|
-
wrote wrong. **Membership, never equality** — a `QueryShape` names one relation and a read may
|
|
262
|
-
join several, and `@ultimat3/db` keeps only the declared names an entity's table matches, so an
|
|
263
|
-
extra name costs nothing and the resolved one going missing is the whole failure. An empty list,
|
|
264
|
-
or one on a read that is not `live: true`, is refused at `query()`
|
|
265
|
-
(`X_QUERY_SUBSCRIBES_INVALID`) beside `assertCacheTtl`: a declaration nothing can act on is wrong
|
|
266
|
-
for every subscriber, and one on a non-live read would never reach the cross-check at all —
|
|
267
|
-
declared and never wired, which is the defect this field exists to close.
|
|
268
|
-
- Policy runs per subscriber for live queries. Never cache a decision across actors.
|
|
269
130
|
- The matcher patches from `QueryShape`, never from SQL text.
|
|
270
|
-
- `paginate` has no `offset`
|
|
271
|
-
`query.page(input, { first, after })
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
- **A
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
- **
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
`
|
|
303
|
-
|
|
304
|
-
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
`
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
- **A
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
`
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
array, `NaN`, `±Infinity` — is `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is MINTED, never
|
|
330
|
-
`X_CURSOR_INVALID`: the mistake is the read's own `orderBy` and no retry repairs it.
|
|
331
|
-
- **`compareValues` orders numbers and bigints in ONE order, because Postgres does.** The numeric
|
|
332
|
-
fast path was `typeof === 'number'` on both sides, so an `int8` fell to
|
|
333
|
-
`String(left) < String(right)`: `compareValues(9n, 10n)` answered `1` and a sort came out
|
|
334
|
-
`["10", "100", "9"]`. `bigint` is the physical type of every `<p>_minor` column and
|
|
335
|
-
`@ultimat3/entity`'s `count-by.ts` lists it as groupable, so the in-memory source, the live
|
|
336
|
-
matcher and the seek fallback ALL disagreed with the database on any bigint-ordered read.
|
|
337
|
-
`shape-order.test.ts` is the pin, and `As of 2026-08` it reads a REAL kind list: `COLUMN_KINDS`
|
|
338
|
-
is the runtime array `@ultimat3/entity`'s `ColumnKind` derives from, and entity is tier 2 so a
|
|
339
|
-
test here may import it as a VALUE — which is what a `satisfies Record<ColumnKind, …>` could not
|
|
340
|
-
be, since `tsconfig.json` excludes `*.test.ts` and `tsc` never reads one. It had a spelled-out
|
|
341
|
-
list and `const COUNT = 9` beside a union of THIRTEEN members: `9 === 9`, a test that could not
|
|
342
|
-
fail, with `numeric`, `date`, `bytea` and `array` carrying no case at all.
|
|
343
|
-
- **`numeric` and the TEXT form of `bigint` are a DECLARED gap here, and closing it is a
|
|
344
|
-
declaration change** (`shape-order.test.ts`, `As of 2026-08`). `@ultimat3/entity`'s `bigint()`
|
|
345
|
-
and `decimal()` hand digits back as strings, so `["9","10","100","2"]` sorts to
|
|
346
|
-
`["10","100","2","9"]` here and `["2","9","10","100"]` in the database — and a cursor's revived
|
|
347
|
-
`bigint` against a stored decimal string (`compareValues(9n, "10")` → `1`) cuts page two where
|
|
348
|
-
the database does not. It is **not** fixed by calling `@ultimat3/core`'s `compareDecimalText`
|
|
349
|
-
from `compareValues`: that function answers only for a caller holding the column's declared kind
|
|
350
|
-
(`@ultimat3/entity`'s `compareByKind`), and `QueryShape.orderBy` is a name and a direction —
|
|
351
|
-
nothing here can tell a `numeric` holding `"10"` from a `text` holding `"10"`, which Postgres
|
|
352
|
-
orders lexically, so a comparator guessing would trade one disagreement with the SQL it prints
|
|
353
|
-
for another. The fix is an `OrderKey` that carries a kind, from `sourceFor` down. Until then the
|
|
354
|
-
`DECLARED_GAP` block asserts both halves, so the gap cannot be silently re-discovered or
|
|
355
|
-
silently widened.
|
|
356
|
-
- **A read's `input:` must survive a query STRING, and `query()` refuses one that cannot**
|
|
357
|
-
(`input-shape.ts`, `X_QUERY_INPUT_UNENCODABLE`, `As of 2026-08`). `client.ts` encoded a nested
|
|
358
|
-
member as `JSON.stringify(item)` and skipped a `null`, while `coerceQuery` has no inverse for
|
|
359
|
-
either — `case 'object'` hands the raw value back untouched — so the typed client type-checked
|
|
360
|
-
calls the server's own route then rejected, which is the exact failure `client.ts`'s header
|
|
361
|
-
claims to prevent. **The declaration is the fix, not the encoder**: teaching `coerceQuery` to
|
|
362
|
-
`JSON.parse` a string would make the ONE HTTP-boundary decoder invent structure for every
|
|
363
|
-
surface that shares it — forms and route params included — against that file's own rule that it
|
|
364
|
-
never invents data, and a `null` sentinel would be a reserved string colliding with the value
|
|
365
|
-
`"null"`. Refused: a structural member (`object`, `record`, `money`, or an array/union of one),
|
|
366
|
-
a REQUIRED nullable member, and a top-level input that is not an object. A schema
|
|
367
|
-
`tryIntrospect` cannot read is left alone, or `configureSchemaProvider` would be unusable.
|
|
368
|
-
- **A refill is owed by a FULL window and by nothing else** (`matcher.ts`, `As of 2026-08`).
|
|
369
|
-
`removeAt` pushed one whenever `shape.limit !== null`, with no reference to how many rows the
|
|
370
|
-
window holds: three rows under `limit: 50`, delete one, and the patch list was
|
|
371
|
-
`[{remove, position:1}, {refill, from:49}]` — a position no two-row result set has. It is not a
|
|
372
|
-
harmless extra: `@ultimat3/realtime`'s `matcher-bridge` folds any refill into
|
|
373
|
-
`BridgeResult.refill`, and `live-fanout` then sends **no patch frame at all** that round, marking
|
|
374
|
-
every subscriber desynced instead — so on a quiet feed the deleted row stays rendered until some
|
|
375
|
-
other change to the same query id arrives, and on a busy one it is a full DB re-read plus one
|
|
376
|
-
snapshot per subscriber per delete. A window under `limit` has no unknown tail: the source served
|
|
377
|
-
fewer rows than it was allowed to, so what the client holds IS the result set. `held >=
|
|
378
|
-
shape.limit` is the gate, and it is `wasFull` one branch away, already written.
|
|
379
|
-
- **A move OUT of a full window is a `refill`, never an `add`** (`matcher.ts`). `insert()` places a
|
|
380
|
-
moved row among the `limit - 1` rows the client still holds, so its position can never reach
|
|
381
|
-
`shape.limit` and the `position >= shape.limit` bail is unreachable on that path — the row was
|
|
382
|
-
re-inserted INSIDE the window. Proven with `limit: 3`, window `[a:1, b:2, c:3]` and a server also
|
|
383
|
-
holding `d:4, e:5`: moving `a` to `99` rendered `[b, c, a:99]` where the true window is
|
|
384
|
-
`[b, c, d]`. Only the server can answer the tail, and whether the moved row is still in the
|
|
385
|
-
window is its answer too.
|
|
386
|
-
- **A page is bounded whether or not the caller bounded it.** `paginate` asserts `first` is a whole
|
|
387
|
-
number of rows in `1…MAX_PAGE_SIZE` (10,000) before anything else — `args.first + 1` bound
|
|
388
|
-
whatever an action's input or a route parameter carried, so one request could ask for five
|
|
389
|
-
million rows. The constant is a TWIN of `@ultimat3/entity`'s, under the same tier compromise
|
|
390
|
-
`naming.ts` and `deprecation.ts` are ported under.
|
|
391
|
-
- **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `src/tags.ts` here and
|
|
392
|
-
`@ultimat3/action`'s were byte-identical, and both packages are tier 3, so neither can import the
|
|
393
|
-
other and a copy in either is a second answer for the other — the same move `toBucket` made into
|
|
394
|
-
`@ultimat3/http`. `tagKey` went with it: `serializeTag` under a second name, zero call sites.
|
|
395
|
-
`@ultimat3/render` exports a *different* function under the same name (declaration order kept);
|
|
396
|
-
never import that one here.
|
|
397
|
-
- **A fingerprint is an identity, so two different inputs may not share one — and it is
|
|
398
|
-
`@ultimat3/core`'s, not this package's, `As of 2026-08`.** `canonicalJson` + `fingerprint` moved
|
|
399
|
-
down to tier 0 because `@ultimat3/action` and `@ultimat3/realtime` needed the identical function
|
|
400
|
-
and all three are tier 3, so a copy in any of them was a second answer for the other two — and
|
|
401
|
-
the copies had already diverged. This one had **no `Date` branch**: `Object.keys(date)` is `[]`,
|
|
402
|
-
so the object branch rendered every date as `{}` and `queryHash({from: 2020…, to: 2020…})`
|
|
403
|
-
equalled `queryHash({from: 2026…, to: 2026…})`. Reachable on the ordinary HTTP path — `http.ts`
|
|
404
|
-
decodes a query string through `coerceQuery`, which turns a `t.date` member into a real `Date`,
|
|
405
|
-
and `input-shape.ts` permits `date` members — so ONE read-cache entry answered every date window
|
|
406
|
-
of that read for the TTL, page two of range A was served from range B's cursor scope, and every
|
|
407
|
-
date window shared one live query id. The hash form tags a `Date`, a `Map` and a `Set`
|
|
408
|
-
(`Date(<epoch>)`, `Map(…)`, `Set(…)`) beside the bare `NaN` / `±Infinity` / `-0` tokens it
|
|
409
|
-
already emitted, all for the reason a bare token exists: `'null'` collided with JSON `null` and
|
|
410
|
-
`String(-0)` is `"0"`. `stable.ts` keeps `columnOf` and a re-export of core's `isJsonObject` —
|
|
411
|
-
that predicate went down to tier 0 the same way, when `client-wire.ts` did. Ordinary
|
|
412
|
-
inputs are byte-identical, so the durable-key cost is confined to reads whose input carries a
|
|
413
|
-
`Date`, a `Map` or a `Set`: those cursors answer `X_CURSOR_INVALID` once, and their cache entries
|
|
414
|
-
are cold once. `query-hash.test.ts` is the pin, at `queryHash` and at `cacheKeyFor`.
|
|
415
|
-
- The cursor codec is `@ultimat3/core`'s (`encodeCursor` / `decodeCursor` / `configureCursorSigning`).
|
|
416
|
-
This package supplies only the scope a cursor is bound to — `queryHash(name, input)` — and never
|
|
417
|
-
signs, encodes or parses one itself. An unverified or foreign cursor is `X_CURSOR_INVALID`, thrown
|
|
418
|
-
by core's `CursorInvalidError`, which `errors.ts` re-exports so the name stays on this surface.
|
|
419
|
-
- **The request memo holds the read, not the rows.** `readOnce` publishes the in-flight promise
|
|
420
|
-
before its first await, so two reads of one key in one request are one execution and one tier
|
|
421
|
-
round trip whether the second follows the first or races it. A value-keyed map could not express
|
|
422
|
-
that, and could not tell a memoized `undefined` from a miss either. A rejection is evicted — a
|
|
423
|
-
failed read is not the request's answer, and the next read retries. `requestMemo(ctx)` is
|
|
424
|
-
therefore `Map<string, Promise<unknown>>`; never put a settled value in it.
|
|
425
|
-
- **Every read is memoized; only a `cache:` read goes through the tier.** `readThrough` is
|
|
426
|
-
`readOnce` plus the fill through the ladder, and `readRows` picks between them on `def.cache`
|
|
427
|
-
alone. The memo is not what `cache:` buys — a list that renders one uncached lookup per row pays for
|
|
428
|
-
every row otherwise, which is the N+1 this collapses. Never gate `readOnce` on `def.cache`
|
|
429
|
-
again, and never let a second key function grow beside `cacheKeyFor`.
|
|
430
|
-
- **A cache key carries the read's AUTHORITY, and `cache.scope` is what widens it** (`As of
|
|
431
|
-
2026-08`). `cacheKeyFor` held the name, the input and the tags — nothing about who asked — while
|
|
432
|
-
`sql(input, ctx)` is handed the context and `@ultimat3/entity` derives every tenant predicate
|
|
433
|
-
from `ctx.actor.orgId`. The tier is process-wide, so the first actor to ask filled the entry and
|
|
434
|
-
the next was served it: a query filtering on `ctx.actor.orgId` returned `org-a`'s row to an
|
|
435
|
-
`org-b` actor. `readAuthority(actor, scope)` is the ONE producer of the component and
|
|
436
|
-
`cacheKeyFor`'s fourth argument is **required and positional**, because an optional one is one a
|
|
437
|
-
call site forgets and a forgotten one is that read. `scope` defaults to `'actor'` and the default
|
|
438
|
-
is the mechanism: declaring nothing gets the narrowest key. `'tenant'` and `'global'` are written
|
|
439
|
-
statements about the rows — the `unenforced:` shape one field over — and `'tenant'` with no
|
|
440
|
-
`orgId` narrows to the actor rather than widening to everyone, because nothing here can prove two
|
|
441
|
-
org-less callers share a tenant. **All THREE spellings of "no org" take that branch**, `As of
|
|
442
|
-
2026-08`: `undefined`, `''` and `null`. The last one missed it, so every org-less caller shared
|
|
443
|
-
the single key `["org",null]` and was served the rows of whoever asked first. `orgless()` widens
|
|
444
|
-
its parameter past core's `orgId?: string` because **`orgId` is a value off the wire** — an app's
|
|
445
|
-
adapter, a decoded session row, a JSON payload — not because a declared type permits a `null`.
|
|
446
|
-
`@ultimat3/policy`'s `PolicyActorFields` reads like the reason and is not it (corrected
|
|
447
|
-
2026-08-19): `Actor = CoreActor & PolicyActorFields`, and that intersection collapses its
|
|
448
|
-
`string | null | undefined` back to `string | undefined`, so the widening is **inert** at the type
|
|
449
|
-
level and `{ orgId: null }` is a type error. Its `testActor` mints `orgId: null` through the one
|
|
450
|
-
cast left in `packages/policy/src/test-kit.ts`, which is why `cache-authority.test.ts` can reach
|
|
451
|
-
this branch at all — the repo's only producer of that `null`, and a test seam rather than a proof.
|
|
452
|
-
The authority is JSON, never a joined string, for the reason
|
|
453
|
-
`@ultimat3/entity`'s `scopeKey` gives: an actor id is app data and may carry the separator.
|
|
454
|
-
- **`cache.ttlMs` is judged at `query()`, not on the first read.** Every `CacheTier` refuses a
|
|
455
|
-
lease that is not positive and finite (`assertTtl`), and the read tier's one catch absorbs
|
|
456
|
-
`X_CACHE_TOO_LARGE` only — so `ttlMs: Infinity` turned a typo into a permanently failing business
|
|
457
|
-
read whose cause named a cache key. `X_QUERY_CACHE_TTL_INVALID`, on the line that wrote it. It
|
|
458
|
-
restates `assertTtl`'s bar as a refusal and never as a second resolution.
|
|
459
|
-
- **`fingerprint` is SHA-256/16, never a 32-bit hash** (`@ultimat3/core`, `As of 2026-08`). It is a
|
|
460
|
-
SHARING key over client-chosen input — which read-cache entry two callers are served from, which
|
|
461
|
-
scope a cursor is bound to — so FNV-1a/32's 4×10⁹ values are a collision found offline in
|
|
462
|
-
seconds. `@ultimat3/realtime`'s `stableDigest` was the same primitive at the same width and is
|
|
463
|
-
gone with the rest of that copy: a `qid` is `queryHash(name, input)` now, imported across the
|
|
464
|
-
declared `realtime -> query` edge, so the two hashes cannot drift apart while `planResume`
|
|
465
|
-
compares one against a cursor's.
|
|
466
|
-
- **A fill is FENCED, and the fence is `@ultimat3/cache`'s** (`As of 2026-08`). `run()` answers with
|
|
467
|
-
rows it read in the past: a mutator committing in between busts a key not yet in the tier, so the
|
|
468
|
-
drop is a no-op reporting `errors: []`, and the fill then publishes the pre-write rows for the
|
|
469
|
-
full TTL — invisible to every reader until it expires. The sample happens inside
|
|
470
|
-
`createCacheStack.read`, immediately before `load()`, and is re-asked per rung before each write.
|
|
471
|
-
This package no longer samples one of its own — that copy went with the private store. The caller
|
|
472
|
-
is answered either way: those rows ARE its answer, and only publishing is refused.
|
|
473
|
-
`cache-fence.test.ts` is the proof the property survived the move.
|
|
474
|
-
- **This package owns NO cache store, and that is the enforcement** (`As of 2026-08`). A `cache:`
|
|
475
|
-
read fills `createCacheStack(registeredTiers(), { clock })` — the tiers `@ultimat3/cache` has
|
|
476
|
-
registered — and there is nothing here to install, swap or wire. There used to be: a private
|
|
477
|
-
`ReadCache` seam (`setReadCache`/`getReadCache`/`MemoryReadCache`) that `invalidateTags` could not
|
|
478
|
-
reach, because that fan-out walks the registered `CacheTier`s and nothing else. The gap was closed
|
|
479
|
-
by `packages/cli/src/dev-cache.ts` installing the read cache **over** an object it also
|
|
480
|
-
registered — a correctness property held by a wiring line in a CLI file, one edit from being
|
|
481
|
-
wrong, and carrying a second `ReadCache` implementation (`tierReadCache`) that dated entries with
|
|
482
|
-
`Date.now()`. And `invalidateQueryTags` was a second fan-out path, which
|
|
483
|
-
`packages/cache/CLAUDE.md` forbids in as many words. One registry, one fan-out, no seam. **Never
|
|
484
|
-
reintroduce a store here**, and never call `tier.invalidateTags()` from this package.
|
|
485
|
-
- **A tier refusal degrades the cache, never the read** — and so does every other tier concern.
|
|
486
|
-
`bestEffort`, the fence, the single flight, the promotion and the TTL are all `createCacheStack`'s,
|
|
487
|
-
which is what "no store here" buys: a refused `get` reads as a miss, a refused `set` as "that tier
|
|
488
|
-
is unchanged", and the failure lands in `recentTierFailures()` under the name of the tier that
|
|
489
|
-
actually refused rather than under a `'query-read'` label for a rung in no registry. Never wrap a
|
|
490
|
-
tier call here in a private try/catch, and never sample a second fence.
|
|
491
|
-
- **The read path reads NO clock, and that is what makes it drivable.** It hands the stack a
|
|
492
|
-
RELATIVE `ttlMs`; the tier's own clock turns it into an absolute expiry. `fill` used to compute
|
|
493
|
-
`nowMs(clock) + ttlMs` and `tierReadCache` used to compute `expiresAt - Date.now()`, so the two
|
|
494
|
-
`ReadCache` implementations disagreed about "now" and no frozen clock could drive the
|
|
495
|
-
Redis-backed one. `read-tier.test.ts` pins it: a `createLruTier({ clock, jitterFraction: 0 })` and
|
|
496
|
-
a `ctx.clock` frozen at the same instant produce an expiry a test can assert exactly.
|
|
497
|
-
- **A `cache:` read always expires, and the bound is the tier's.**
|
|
498
|
-
`def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60s) is what `readRows` passes — a query keyed on
|
|
499
|
-
`{ orgId, cursor }` has as many distinct keys as the deployment has tenants, and unbounded that is
|
|
500
|
-
one permanent entry per page per org. `null` is "the caller named none", never "never": it reaches
|
|
501
|
-
the stack as an OMITTED `ttlMs`, which is how a tier is asked for its own default. Every tier
|
|
502
|
-
refuses a non-positive lease outright, so there is no immortal entry to spell.
|
|
503
|
-
- **A process that registered no tier reads uncached.** `createCacheStack([])` loads, answers and
|
|
504
|
-
writes nowhere — correct, and slower. That is the trade for deleting the module-default store: a
|
|
505
|
-
script, a worker boot or a test that wants caching calls `registerTier`, the same call every
|
|
506
|
-
other cached surface in the framework already uses.
|
|
507
|
-
- **The memo holds an execution, never a decision.** `readRows` runs `buildSource` — parse, guard,
|
|
508
|
-
`sql()` — *before* it reaches the memo, on every call, and `.as()` reads in a child context whose
|
|
509
|
-
identity is its own memo. So a memoized answer is still one this actor was allowed to ask for,
|
|
510
|
-
and no impersonated read can join a read made as someone else. Moving the memo above
|
|
511
|
-
`buildSource` would turn it into an authz bypass.
|
|
512
|
-
- **`fresh: true` skips the memo on the way in and publishes to it on the way out.** A memo is a
|
|
513
|
-
cache whose lifetime is the request, so `fresh` refuses to *join* an entry — `readFresh` is
|
|
514
|
-
`readOnce` minus the join, both sharing one `publish` — but it must still *become* one. Returning
|
|
515
|
-
the rows early instead left the pre-write entry standing, so "the one way to read past a write
|
|
516
|
-
made earlier in the same request" ended at the single call that asked for it and the next plain
|
|
517
|
-
read of that key got the stale answer back. Invalidation still drops tier entries only.
|
|
518
|
-
- **A read can declare a `rateLimit:`, and `toQueryRoute` enforces it — added 2026-08.** `QueryDef`
|
|
519
|
-
had no such field and the route set no bucket, so **every** `GET /_x/query/*` fell through
|
|
520
|
-
`bucketFor` to `default` — 120 burst, 2/s per actor. One authenticated caller could hold 120
|
|
521
|
-
cross-tenant aggregates in flight and then 2/s indefinitely, from a single account, and the
|
|
522
|
-
declaration that would have throttled it did not exist in the type. `meta.rateLimit` (the name)
|
|
523
|
-
**and** `meta.rateLimitBucket` (the numbers) are both set, because a name nothing registers is
|
|
524
|
-
the same silent fall-through. The conversion is `toBucket` from **`@ultimat3/http`** — http owns
|
|
525
|
-
`Bucket` and the maths, and `@ultimat3/action` is this tier, so a copy here would be a second
|
|
526
|
-
answer for the write half. Never derive a bucket locally.
|
|
527
|
-
- **`deprecated:` is a compat WINDOW; versioning is not here and will not be.** `Deprecation`
|
|
528
|
-
(RFC 9745, `@<unix seconds>`) and `Sunset` (RFC 8594, IMF-fixdate) on every answer including the
|
|
529
|
-
failures, a `rel="successor-version"` link built through `derivePath` — the same derivation
|
|
530
|
-
`client()` uses, never a second one — the dates on the descriptor, and
|
|
531
|
-
`deprecated_calls_total{primitive,name}`, which is the only way to answer "is anyone still
|
|
532
|
-
reading it?" before deleting the read. Rendered ONCE at projection, so a date that cannot become
|
|
533
|
-
a header is `X_QUERY_DEPRECATION_INVALID` at mount rather than on the first read. Running two
|
|
534
|
-
versions side by side is two deployments behind one ingress (axiom 7). `deprecation.ts` is a
|
|
535
|
-
twin of `@ultimat3/action`'s: both are tier 3, the shared home is `@ultimat3/http` if it ever
|
|
536
|
-
grows one, and this is the same compromise `naming.ts` is ported under.
|
|
537
|
-
- **The span wraps the whole read, not `source.execute()`.** Wrapping the execution alone left the
|
|
538
|
-
input parse, the policy evaluation and `sql()`'s own construction outside every span, so a read
|
|
539
|
-
whose cost was in building the source reported milliseconds under a parent reporting seconds —
|
|
540
|
-
a gap with no name, which reads as framework overhead. Attributes are bounded: surface, actor
|
|
541
|
-
KIND, `live`, `cached`, `fresh`, and the row count. Never the input and never an actor id — a
|
|
542
|
-
read is keyed per tenant and per cursor, so either would be unbounded. `telemetry.test.ts`
|
|
543
|
-
asserts the EXTENT structurally, reading `currentSpan()` from inside the policy predicate and
|
|
544
|
-
`sql:`, because the test clock is frozen and a timing assertion would hang on it.
|
|
545
|
-
- **`policyCapability` is a display label; `policyPermissions` is what a report matches on.** A
|
|
546
|
-
composite renders as `or(feed:read, org:administer)`, which equals no permission string, so
|
|
547
|
-
`x policy list` matching on `capability` reported every non-trivially-guarded read's permissions
|
|
548
|
-
as *unenforced*. `QueryDescriptor.permissions` is the flattened list from `@ultimat3/policy`,
|
|
549
|
-
published beside `capability` and never instead of it.
|
|
550
|
-
- **`queryClient`/`client()` inject `traceparent`.** Core's `traceparent()` had no caller in the
|
|
551
|
-
repo, so a service-to-service read began a fresh root trace on the far side. Set BEFORE the
|
|
552
|
-
caller's own headers so an explicit one wins; an incomplete span context (`spanId: ''`) sends
|
|
553
|
-
nothing rather than a header every collector drops. In a browser there is no ambient context, so
|
|
554
|
-
a cross-origin read gains no CORS preflight it did not already have. The helper is twinned in
|
|
555
|
-
`@ultimat3/action`'s client for the same tier reason `naming.ts` is.
|
|
556
|
-
- Authz goes through `enforce(surface, policy, { input, actor, ctx })` from
|
|
557
|
-
`@ultimat3/policy`; a live denial keeps its 4403 close code on `QueryDeniedError.denial`.
|
|
558
|
-
`policy-gate.ts` is the only file that imports the policy package.
|
|
131
|
+
- `paginate` has no `offset` and never grows one; it is reachable only as
|
|
132
|
+
`query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
|
|
133
|
+
(10,000, a twin of `@ultimat3/entity`'s).
|
|
134
|
+
- **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
|
|
135
|
+
- **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
|
|
136
|
+
never reintroduce a row lookup. The seek predicate is spelled out per key
|
|
137
|
+
(`(a < $1) or (a = $2 and id > $3)`), never a row-value comparison.
|
|
138
|
+
- **NULL has one meaning, `isNull`**: `=`/`!=`/`in` treat NULL as a value (`is null`,
|
|
139
|
+
`is distinct from`, `in (…) or is null`); range operators treat it as unknown; `order by` treats it
|
|
140
|
+
as the largest value (`asc nulls last` / `desc nulls first`). Never bind a parameter where NULL is
|
|
141
|
+
the tested value. `in` with an empty or non-array operand is `1 = 0`.
|
|
142
|
+
- **The id is the tiebreak that makes the order total.** No id is `X_QUERY_NOT_PAGEABLE`, never
|
|
143
|
+
`String(undefined)`. **`totalOrder`** (declared keys, then `id asc`) is the served order for SQL,
|
|
144
|
+
memory and `positionFor`; never add `id` to `QueryShape.orderBy`. A live read asks for it via
|
|
145
|
+
`SqlSource.total()` (called by `buildSource` for `surface === 'live'`), never `seek(null, limit)`.
|
|
146
|
+
- **The keyset tiebreak keeps the id's TYPE** (`SeekKey.id: unknown`), carried at the tail of `key`
|
|
147
|
+
through `serializeSortValue` (`pagination-id-type.test.ts`).
|
|
148
|
+
- **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
|
|
149
|
+
v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
|
|
150
|
+
minted.
|
|
151
|
+
- **`compareValues` orders numbers and bigints in one order**; equality treats `number` and `bigint`
|
|
152
|
+
as one family (`shape.ts` `same`). `shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`.
|
|
153
|
+
- **`numeric` and the TEXT form of `bigint` are a DECLARED gap** (`DECLARED_GAP` in
|
|
154
|
+
`shape-order.test.ts`): closing it needs an `OrderKey` carrying a kind, never a guessing comparator.
|
|
155
|
+
- **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
|
|
156
|
+
a `refill`, never an `add`.**
|
|
157
|
+
- **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
|
|
158
|
+
missing an ordering column (`Object.hasOwn`, never a value check) gets a `refill` rather than a
|
|
159
|
+
guess. A delete is addressed by index and still patches.
|
|
160
|
+
- **`tagKeys` is `@ultimat3/cache`'s.** `@ultimat3/render` exports a different `tagKeys`; never import
|
|
161
|
+
that one here.
|
|
162
|
+
- **A fingerprint is `@ultimat3/core`'s** (`canonicalJson` + SHA-256/16 `fingerprint`), tagging `Date`,
|
|
163
|
+
`Map`, `Set`. `query-hash.test.ts` pins it at `queryHash` and `cacheKeyFor`. `stable.ts` keeps
|
|
164
|
+
`columnOf` and re-exports core's `isJsonObject`.
|
|
165
|
+
- The cursor codec is `@ultimat3/core`'s; this package supplies only the scope (`queryHash(name,
|
|
166
|
+
input)`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, re-exported).
|
|
167
|
+
|
|
168
|
+
## Invariants — caching
|
|
169
|
+
|
|
170
|
+
- **The request memo holds the read, not the rows**: `readOnce` publishes the in-flight promise
|
|
171
|
+
before its first await; a rejection is evicted. `requestMemo(ctx)` is `Map<string, Promise<unknown>>`.
|
|
172
|
+
- **Every read is memoized; only a `cache:` read goes through the tier** (`readThrough`). One key
|
|
173
|
+
function, `cacheKeyFor`.
|
|
174
|
+
- **A cache key carries the read's AUTHORITY**: `readAuthority(actor, scope)` is its one producer and
|
|
175
|
+
`cacheKeyFor`'s fourth argument is required. `scope` defaults to `'actor'`; `'tenant'` with no
|
|
176
|
+
`orgId` (`undefined`, `''` or `null` — `orgless()`) narrows to the actor. The authority is JSON.
|
|
177
|
+
- **`cache.ttlMs` is judged at `query()`** (`X_QUERY_CACHE_TTL_INVALID`). A `cache:` read always
|
|
178
|
+
expires: `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60 s); an unset TTL is OMITTED so the tier
|
|
179
|
+
defaults.
|
|
180
|
+
- **A fill is FENCED, by `@ultimat3/cache`'s fence inside `createCacheStack.read`** — never a second
|
|
181
|
+
one here (`cache-fence.test.ts`).
|
|
182
|
+
- **This package owns NO cache store**: a `cache:` read fills
|
|
183
|
+
`createCacheStack(registeredTiers(), { clock })`. Never reintroduce a store, never call
|
|
184
|
+
`tier.invalidateTags()` here. A tier refusal degrades the cache, never the read.
|
|
185
|
+
- **The read path reads NO clock** — it hands the stack a relative `ttlMs` (`read-tier.test.ts`).
|
|
186
|
+
- **A process that registered no tier reads uncached** — correct, and slower.
|
|
187
|
+
- **The memo holds an execution, never a decision**: `buildSource` (parse, guard, `sql()`) runs on
|
|
188
|
+
every call before the memo. Moving the memo above it would be an authz bypass.
|
|
189
|
+
- **`fresh: true` skips the memo on the way in and publishes to it on the way out.**
|
|
559
190
|
|
|
560
191
|
## Commands
|
|
561
192
|
|
|
@@ -564,21 +195,4 @@ bun test packages/query
|
|
|
564
195
|
bun run typecheck
|
|
565
196
|
```
|
|
566
197
|
|
|
567
|
-
|
|
568
|
-
`refill` rather than a guess** — `unprojectedOrderKey`, `As of 2026-08-20`. A result set is whatever
|
|
569
|
-
the query's `sql` returned, and a PROJECTION that drops an ordering column left `compareRows`
|
|
570
|
-
measuring the change row's real value against nothing: never equal on the update path, so every
|
|
571
|
-
change read as a move, and arbitrary on the insert path, so a row created last landed wherever
|
|
572
|
-
`undefined` sorted. `examples/dummy`'s feed ordered by `createdAt` and projected without it, and one
|
|
573
|
-
publish became a `remove` + `insert` whose re-inserted row was the raw table row (#230).
|
|
574
|
-
|
|
575
|
-
The discriminator is **`Object.hasOwn`, never a value check**: a nullable column that IS null still
|
|
576
|
-
carries its key, and everywhere else in this package an absent key and a SQL NULL are one absence
|
|
577
|
-
(`isNull` says so). Here they are different facts — "this row's value is nothing" versus "this shape
|
|
578
|
-
cannot answer" — and only the key tells them apart. Asked of the row the window holds and never of
|
|
579
|
-
the change row, which comes off the table and can always answer. A **delete** never reaches the rule:
|
|
580
|
-
it is addressed by the index its id was found at, so a projected query still removes incrementally.
|
|
581
|
-
|
|
582
|
-
The rule generalises what `assertSeekable` already applies to a cursor: **a sort key has to be
|
|
583
|
-
readable on the row.** A live query whose rows omit one still works — it re-reads instead of
|
|
584
|
-
patching — which is correct and slower, and the fix is to project the key.
|
|
198
|
+
Why each rule above is shaped the way it is: [`docs/history/query.md`](../../docs/history/query.md).
|