@ultimat3/query 22.15.0 → 24.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 +38 -6
- package/README.md +67 -7
- package/package.json +8 -7
- package/src/client-scale-pins.ts +78 -0
- package/src/client.ts +15 -1
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/facade.ts +1 -1
- package/src/http.ts +5 -9
- package/src/index.ts +11 -7
- package/src/input-shape.ts +20 -0
- package/src/live.ts +3 -0
- package/src/matcher.ts +14 -7
- package/src/mcp-tool.ts +29 -8
- package/src/pagination.ts +58 -15
- package/src/query.ts +2 -0
- package/src/sealed-shape.ts +27 -0
- package/src/search.ts +46 -28
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +24 -9
package/CLAUDE.md
CHANGED
|
@@ -18,12 +18,14 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
18
18
|
| `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
|
|
19
19
|
| `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
|
|
20
20
|
| `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
|
|
21
|
+
| `client-scale-pins.ts` | compile-time pin: `queryClient<Api['queries']>` over 100 reads in 50 modules — list, `.page` and `single: true` |
|
|
21
22
|
| `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
|
|
22
23
|
| — | flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported. No local copy |
|
|
23
24
|
| `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS core's `queryPath`. **Paths only** |
|
|
24
25
|
| `registry.ts` | export-name registration, `describeQueries()`, the `registerPrimitiveRegistrar('query', …)` announcement |
|
|
25
26
|
| `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
|
|
26
27
|
| `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
|
|
28
|
+
| `sealed-shape.ts` | a live read keyed on a `.sealed()` column is refused at subscribe (`X_MATCHER_UNSUPPORTED`) |
|
|
27
29
|
| `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
|
|
28
30
|
| `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
|
|
29
31
|
| `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
|
|
@@ -33,6 +35,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
33
35
|
| `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
|
|
34
36
|
| `source.ts` | `SqlSource` contract + `from()` in-memory reference |
|
|
35
37
|
| `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
|
|
38
|
+
| `column-kinds.ts` | `kindsOf(relation)`: a column's DECLARED kind, from `QueryShape.entity` |
|
|
36
39
|
| `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
|
|
37
40
|
| `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
|
|
38
41
|
|
|
@@ -61,8 +64,18 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
61
64
|
barrel exports nothing matching `/tool_?name/i`.
|
|
62
65
|
- **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
|
|
63
66
|
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
|
|
67
|
+
- **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
|
|
68
|
+
excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
|
|
69
|
+
it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
|
|
64
70
|
- **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
|
|
65
71
|
`meta.input` on the route.
|
|
72
|
+
- **The client and the route are two halves of one encoding.** `searchOf` sends a `Date` as its ISO
|
|
73
|
+
instant and nothing for `[]`; `absentArraysOf` (`input-shape.ts`) reads a REQUIRED array nobody
|
|
74
|
+
sent as `[]`. Never for an optional or defaulted one — absence there is the schema's answer.
|
|
75
|
+
`http-round-trip.test.ts` pins both ends against each other.
|
|
76
|
+
- **A `single: true` read answers the row or `X_NOT_FOUND` by ONE rule** (`single-answer.ts`:
|
|
77
|
+
`oneRowOf`, `readAnswer`) — the route, `tool().read()` and `@ultimat3/mcp`'s served tool all call
|
|
78
|
+
it. `QueryToolDescriptor<TSingle>` types the answer: rows for a list read, the row for a single.
|
|
66
79
|
- **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
|
|
67
80
|
`meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
|
|
68
81
|
- **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
|
|
@@ -84,6 +97,15 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
84
97
|
- Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
|
|
85
98
|
close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
|
|
86
99
|
decision across actors.
|
|
100
|
+
- **This package strips NO column, and must not start.** A query has no output parse; a
|
|
101
|
+
`.sealed()` column is absent from every projection because `@ultimat3/entity`'s repository row
|
|
102
|
+
does not ENUMERATE it (`serverOnly`, `packages/entity/src/sealed.ts`). So nothing on the read
|
|
103
|
+
path may rebuild a row property by property from a column list or `Object.getOwnPropertyNames`
|
|
104
|
+
— `JSON.stringify`, a spread and `Object.keys` are the only walks. `sealed-egress.test.ts` pins
|
|
105
|
+
HTTP (list, `Page`, single, envelope), MCP, live, the cache tier and a derived row.
|
|
106
|
+
- **A live read keyed on a sealed column is refused at subscribe** (`assertNoSealedKey`, called by
|
|
107
|
+
`toLiveQuery`): a change row carries none, so the window would be read once and never patched.
|
|
108
|
+
The refusal names the column, never the filter's value.
|
|
87
109
|
|
|
88
110
|
## Invariants — the client
|
|
89
111
|
|
|
@@ -102,7 +124,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
102
124
|
import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
|
|
103
125
|
`client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
|
|
104
126
|
titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
|
|
105
|
-
(`As of 2026-
|
|
127
|
+
(`As of 2026-10-02`, browser, minified): barrel `queryClient` 25,237 B; `./client` 11,012 B.
|
|
106
128
|
- **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
|
|
107
129
|
`@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
|
|
108
130
|
answer. No `rows:` is byte-identical on the wire.
|
|
@@ -117,7 +139,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
117
139
|
`SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
|
|
118
140
|
ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
|
|
119
141
|
`Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
|
|
120
|
-
|
|
142
|
+
Its three refusals (blank term, a cursor, a window that cuts rows) are caller-reachable, so each
|
|
143
|
+
is `X_INPUT_INVALID` — never core's `assert`. `search.test.ts`'s fixture must stay NOT
|
|
144
|
+
id-ascending.
|
|
121
145
|
- **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
|
|
122
146
|
`shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
|
|
123
147
|
membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
|
|
@@ -132,6 +156,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
132
156
|
- `paginate` has no `offset` and never grows one; it is reachable only as
|
|
133
157
|
`query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
|
|
134
158
|
(10,000, a twin of `@ultimat3/entity`'s).
|
|
159
|
+
- **A declared `.limit()` bounds the LISTING, on every page**: the window is
|
|
160
|
+
`min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
|
|
161
|
+
tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
|
|
162
|
+
read. `Builder.seek()` takes the smaller limit too. `pagination-limit.test.ts`.
|
|
135
163
|
- **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
|
|
136
164
|
- **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
|
|
137
165
|
never reintroduce a row lookup. The seek predicate is spelled out per key
|
|
@@ -149,10 +177,14 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
149
177
|
- **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
|
|
150
178
|
v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
|
|
151
179
|
minted.
|
|
152
|
-
-
|
|
153
|
-
|
|
154
|
-
-
|
|
155
|
-
`
|
|
180
|
+
- **This package has NO comparator.** How two values compare is `@ultimat3/entity`'s
|
|
181
|
+
`compareByKind` / `sameValueOfKind`, asked under the column's declared kind (`kindsOf(shape.entity)`,
|
|
182
|
+
`column-kinds.ts`). `compareRows`, `matchesFilter(s)` and `isAfterKey` REQUIRE the `KindOf`; never
|
|
183
|
+
add a `typeof` branch here — fix the rule in entity. `equalOrBothNull` adds only this package's
|
|
184
|
+
NULL rule. `compare-parity-fixture.ts` is the one table: `compare-parity.test.ts` (entity, matcher,
|
|
185
|
+
`from()`) and `compare-parity.live.test.ts` (Postgres). `grep compareValues src` stays empty.
|
|
186
|
+
- **A relation no entity declares has no kinds** — digits there are text, as in a `text` column.
|
|
187
|
+
`shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`, one case per kind.
|
|
156
188
|
- **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
|
|
157
189
|
a `refill`, never an `add`.**
|
|
158
190
|
- **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
|
package/README.md
CHANGED
|
@@ -53,6 +53,9 @@ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
|
|
|
53
53
|
|
|
54
54
|
Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
|
|
55
55
|
|
|
56
|
+
`queryClient<Api['queries']>` typechecks at any app size: `client-scale-pins.ts` compiles it over
|
|
57
|
+
100 reads in 50 modules — a list read, its `.page` and a `single: true` read — on every `typecheck`.
|
|
58
|
+
|
|
56
59
|
Every read goes through `@ultimat3/core`'s **`clientTransport`** — the one browser HTTP function,
|
|
57
60
|
`As of 2026-09-22` (21.0.0). There is no `fetch` call in this package: the `fetch` option is handed
|
|
58
61
|
on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
|
|
@@ -65,8 +68,11 @@ A detail read declares `single: true`. The route answers the first row as the bo
|
|
|
65
68
|
`X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
|
|
66
69
|
(400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
|
|
67
70
|
`queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
|
|
68
|
-
`.as()`, `.page()
|
|
69
|
-
|
|
71
|
+
`.as()`, `.page()` and `.live()` still answer `readonly TRow[]`, so opting in breaks no `[0]`
|
|
72
|
+
already written. `As of 2026-09-29`. **`tool().read()` answers as the route does**
|
|
73
|
+
(`As of 2026-10`): the row itself, and `X_NOT_FOUND` when `sql` returns none — an agent given `[]`
|
|
74
|
+
for a missing id read "found, and empty" where HTTP said 404. The tool `@ultimat3/mcp` serves over
|
|
75
|
+
`tools/call` answers the same two ways.
|
|
70
76
|
|
|
71
77
|
```ts
|
|
72
78
|
import { can } from '@ultimat3/policy';
|
|
@@ -205,6 +211,8 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
205
211
|
| `sql.ts` | `explain()` — the generated SQL, verbatim |
|
|
206
212
|
| `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
|
|
207
213
|
| `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
|
|
214
|
+
| `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
|
|
215
|
+
| `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
|
|
208
216
|
|
|
209
217
|
## Live queries
|
|
210
218
|
|
|
@@ -262,6 +270,22 @@ cursor cannot prove that rows sorting *before* it are unchanged, so any epoch ch
|
|
|
262
270
|
new build, new policy, new schema — forces a full refetch instead of a resume. Bounded
|
|
263
271
|
server memory bought with an occasional extra page fetch.
|
|
264
272
|
|
|
273
|
+
## A sealed column never rides on a row
|
|
274
|
+
|
|
275
|
+
A query has no output schema; nothing here strips a column. A `.sealed()` column is absent from
|
|
276
|
+
every projection because `@ultimat3/entity` answers it as a server-only (non-enumerable) property —
|
|
277
|
+
`row.password` on the server, nothing in `JSON.stringify(row)`.
|
|
278
|
+
|
|
279
|
+
| Projection | Sealed column |
|
|
280
|
+
|---|---|
|
|
281
|
+
| HTTP — list, `Page`, `single: true`, the record envelope | absent |
|
|
282
|
+
| MCP read, a `cache:` entry, a live snapshot | absent |
|
|
283
|
+
| a derived row `({ ...row, extra })` | absent |
|
|
284
|
+
| a derived row naming it `({ token: row.token })` | sent — the name is the statement |
|
|
285
|
+
| `query(input)` on the server | `row.password` reads the plaintext |
|
|
286
|
+
|
|
287
|
+
`sealed-egress.test.ts` holds one assertion per projection.
|
|
288
|
+
|
|
265
289
|
## Matcher support
|
|
266
290
|
|
|
267
291
|
| Shape | Result |
|
|
@@ -270,6 +294,7 @@ server memory bought with an occasional extra page fetch.
|
|
|
270
294
|
| `orderBy` (any number of keys) | insert position computed, moves become remove + add |
|
|
271
295
|
| `limit` | tail eviction on insert, `refill` patch on removal |
|
|
272
296
|
| joins, aggregates, `group by`, subqueries | `X_MATCHER_UNSUPPORTED` with a fix line |
|
|
297
|
+
| a `.sealed()` column in a filter or an order | `X_MATCHER_UNSUPPORTED` — a change row carries no sealed column |
|
|
273
298
|
|
|
274
299
|
An honest refusal beats a silently wrong result set, so unsupported shapes fail at
|
|
275
300
|
**subscribe** time, not on the first change event.
|
|
@@ -320,7 +345,16 @@ empty box is not an empty result set.
|
|
|
320
345
|
**It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
|
|
321
346
|
that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
|
|
322
347
|
page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
|
|
323
|
-
refused, and the
|
|
348
|
+
refused, and the cause names the chain — `db.posts.search(term).after(cursor).page()`.
|
|
349
|
+
|
|
350
|
+
| A request `search()` refuses | Answer |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `q` of only whitespace (`?q=%20%20` passes `t.string.min(1)`) | **400** `X_INPUT_INVALID` |
|
|
353
|
+
| a cursor (`?_after=…`) | **400** `X_INPUT_INVALID` |
|
|
354
|
+
| a window that would cut rows (`?_first=1` with two matches) | **400** `X_INPUT_INVALID`, naming both edits: widen `first` to the read's `limit`, or narrow `limit` |
|
|
355
|
+
|
|
356
|
+
All three arrive on the request, so all three are the caller's. `As of 2026-10`; until then they
|
|
357
|
+
were core `assert`s — `X_INVARIANT`, a 500 blaming the server.
|
|
324
358
|
|
|
325
359
|
## Pagination is cursor-only
|
|
326
360
|
|
|
@@ -359,11 +393,14 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
|
|
|
359
393
|
| `_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 |
|
|
360
394
|
| 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 |
|
|
361
395
|
| a control sent twice | refused, never resolved to the last one as a declared scalar is: two page sizes is two pages asked for in one request |
|
|
396
|
+
| a declared `.limit()` is the size of the LISTING | `first` is the size of a page and never widens it: the window is `min(what the limit has left, first + 1)`, a limited read's cursor carries how many rows are already served, and the page after the last is empty with `nextCursor: null`. `As of 2026-10`; until then `first` REPLACED the limit, so `?_first=10000` walked a "top 3" to the end of the table. A read paged past its limit on purpose drops the `.limit()` |
|
|
397
|
+
| 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 |
|
|
362
398
|
| 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 |
|
|
363
399
|
| `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 |
|
|
364
400
|
|
|
365
401
|
The MCP read tool does not page: `read()` answers the source's rows, bounded by the read's own
|
|
366
|
-
`limit
|
|
402
|
+
`limit` — or the one row of a `single: true` read — and an agent that wants more asks a narrower
|
|
403
|
+
question.
|
|
367
404
|
|
|
368
405
|
The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
|
|
369
406
|
`configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
|
|
@@ -432,9 +469,32 @@ the rows after it were unreachable through a cursor. An ascending key now reache
|
|
|
432
469
|
NULL under `nulls last`, and the page continues on the id tiebreak, which is never NULL.
|
|
433
470
|
|
|
434
471
|
`nulls last` / `nulls first` are Postgres' own defaults, written down rather than inherited: it is
|
|
435
|
-
the rule `
|
|
472
|
+
the rule `compareRows` implements, so the in-memory sort and the seek predicate can only agree
|
|
436
473
|
with it, and a driver whose default differs cannot re-open the divergence.
|
|
437
474
|
|
|
475
|
+
## How two values compare
|
|
476
|
+
|
|
477
|
+
**By the column's declared kind, and by `@ultimat3/entity`'s comparator — this package has none.**
|
|
478
|
+
`kindsOf(shape.entity)` resolves each column's `ColumnKind` from the entity declared on that table,
|
|
479
|
+
and `compareRows`, `matchesFilter`, `isAfterKey` and the matcher hand it to entity's
|
|
480
|
+
`compareByKind` / `sameValueOfKind`: the one JS statement of how Postgres compares a column.
|
|
481
|
+
`As of 2026-10`; until then a second comparator here decided by `typeof`.
|
|
482
|
+
|
|
483
|
+
| Column | What its row holds | Decided as |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| `bigint()`, `decimal()` | decimal TEXT | digits, exactly: `'9'` before `'10'`, `'2.50'` equal to `'2.5'` and to `2.5` |
|
|
486
|
+
| `uuid()` | text | a value: upper and lower case are one id |
|
|
487
|
+
| `timestamp()` | `Date` | an instant: a `Date` equals the ISO text naming it |
|
|
488
|
+
| `text()` | text | characters: `'10'` before `'9'`, as `order by` on a text column answers |
|
|
489
|
+
| a relation no entity declares | whatever the row provider returned | no kind — numbers numerically (a `number` and a `bigint` in one order), `Date`s by instant, the rest as text |
|
|
490
|
+
|
|
491
|
+
`compare-parity-fixture.ts` is the one `(kind, left, right)` table: `compare-parity.test.ts` runs it
|
|
492
|
+
through entity's evaluator, the matcher and `from()`; `compare-parity.live.test.ts` runs it through
|
|
493
|
+
Postgres, and walks a live window ordered on a `bigint()` and a `decimal()` column against the
|
|
494
|
+
database's own `order by`. A custom `SqlSource` calling `compareRows`, `matchesFilter` or
|
|
495
|
+
`isAfterKey` passes `kindsOf(shape.entity)` — the argument is required, because a comparison with
|
|
496
|
+
no kind is the defect.
|
|
497
|
+
|
|
438
498
|
## Caching
|
|
439
499
|
|
|
440
500
|
Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
|
|
@@ -543,9 +603,9 @@ router feature here.
|
|
|
543
603
|
| `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
|
|
544
604
|
| `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
|
|
545
605
|
| `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
|
|
546
|
-
| `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
|
|
606
|
+
| `X_INPUT_INVALID` | input failed the Standard Schema; a page control the wire refuses; a `search()` handed a blank term, a cursor, or a window that would cut rows | `x queries describe <name> --json` |
|
|
547
607
|
| `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
|
|
548
|
-
| `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404
|
|
608
|
+
| `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404, and the MCP tool's refusal (entity's code) | `x queries describe <name> --json` prints the SQL |
|
|
549
609
|
| `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
|
|
550
610
|
| `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
|
|
551
611
|
| `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
26
|
"!src/**/*.test.ts",
|
|
27
|
+
"!src/**/*-fixture.ts",
|
|
27
28
|
"CLAUDE.md",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE"
|
|
@@ -36,11 +37,11 @@
|
|
|
36
37
|
"test": "bun test"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/entity": "
|
|
42
|
-
"@ultimat3/http": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/schema": "
|
|
40
|
+
"@ultimat3/cache": "24.0.0",
|
|
41
|
+
"@ultimat3/core": "24.0.0",
|
|
42
|
+
"@ultimat3/entity": "24.0.0",
|
|
43
|
+
"@ultimat3/http": "24.0.0",
|
|
44
|
+
"@ultimat3/policy": "24.0.0",
|
|
45
|
+
"@ultimat3/schema": "24.0.0"
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Compile-time pin: `queryClient<Api['queries']>` at 100 reads in 50 modules. Source, not a
|
|
2
|
+
// `.test.ts` — `tsconfig.json` excludes those, so `tsc -b` would never read the claim. Types only:
|
|
3
|
+
// nothing here is emitted, and a regression is a build error in the `typecheck` step.
|
|
4
|
+
//
|
|
5
|
+
// `QueryClient` is its own mapped type and regresses on its own. `defineApi` is `@ultimat3/action`'s
|
|
6
|
+
// (sideways), so the map is spelled the way its `Merge` hands it over: one intersection of every
|
|
7
|
+
// module's registered reads. `@ultimat3/action`'s `client-scale-pins.ts` holds the `Merge` half.
|
|
8
|
+
|
|
9
|
+
import type { NumberSchema, ObjectSchema, StringSchema } from '@ultimat3/schema';
|
|
10
|
+
import type { queryClient } from './client';
|
|
11
|
+
import type { Page } from './pagination';
|
|
12
|
+
import type { Query } from './query';
|
|
13
|
+
|
|
14
|
+
type Assert<T extends true> = T;
|
|
15
|
+
|
|
16
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
17
|
+
|
|
18
|
+
/** Distinct per module, so no two modules collapse into one instantiation. */
|
|
19
|
+
type ScaleInput<N extends number> = ObjectSchema<
|
|
20
|
+
{ readonly orgId: StringSchema; readonly limit: NumberSchema } & {
|
|
21
|
+
readonly [K in `q${N}`]: StringSchema;
|
|
22
|
+
}
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
type ScaleRow<N extends number> = { readonly id: string } & { readonly [K in `col${N}`]: number };
|
|
26
|
+
|
|
27
|
+
/** One feature's reads: a list and a `single: true`, the two methods `QueryClient` projects. */
|
|
28
|
+
type QueryModule<N extends number> = {
|
|
29
|
+
readonly [K in `listRows${N}`]: Query<ScaleInput<N>, ScaleRow<N>>;
|
|
30
|
+
} & { readonly [K in `oneRow${N}`]: Query<ScaleInput<N>, ScaleRow<N>, true> };
|
|
31
|
+
|
|
32
|
+
/** `Module<0> & … & Module<TCount - 1>`. A tail call, so the count costs no depth here. */
|
|
33
|
+
type MergedQueries<
|
|
34
|
+
TCount extends number,
|
|
35
|
+
TSeen extends readonly unknown[] = [],
|
|
36
|
+
TMap = unknown,
|
|
37
|
+
> = TSeen['length'] extends TCount
|
|
38
|
+
? TMap
|
|
39
|
+
: MergedQueries<TCount, [...TSeen, unknown], TMap & QueryModule<TSeen['length']>>;
|
|
40
|
+
|
|
41
|
+
type ScaleQueries = MergedQueries<50>;
|
|
42
|
+
|
|
43
|
+
/** The idiom itself, through `queryClient`'s own `QueryMap` constraint. */
|
|
44
|
+
type ScaleClient = ReturnType<typeof queryClient<ScaleQueries>>;
|
|
45
|
+
|
|
46
|
+
export type _EveryModuleReachesTheClient = Assert<
|
|
47
|
+
'listRows0' | 'oneRow24' | 'listRows49' extends keyof ScaleClient ? true : false
|
|
48
|
+
>;
|
|
49
|
+
|
|
50
|
+
export type _TheClientHasExactlyTheMergedReads = Assert<
|
|
51
|
+
Equals<keyof ScaleClient, keyof ScaleQueries>
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
type LastInput = { readonly orgId: string; readonly limit: number; readonly q49: string };
|
|
55
|
+
|
|
56
|
+
/** One call of each shape, read off the LAST module's own declaration. */
|
|
57
|
+
export type _AListReadAnswersRows = Assert<
|
|
58
|
+
Equals<
|
|
59
|
+
[Parameters<ScaleClient['listRows49']>[0], Awaited<ReturnType<ScaleClient['listRows49']>>],
|
|
60
|
+
[LastInput, readonly ScaleRow<49>[]]
|
|
61
|
+
>
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
export type _AListReadPages = Assert<
|
|
65
|
+
Equals<Awaited<ReturnType<ScaleClient['listRows49']['page']>>, Page<ScaleRow<49>>>
|
|
66
|
+
>;
|
|
67
|
+
|
|
68
|
+
export type _ASingleReadAnswersTheRow = Assert<
|
|
69
|
+
Equals<
|
|
70
|
+
[Parameters<ScaleClient['oneRow49']>[0], Awaited<ReturnType<ScaleClient['oneRow49']>>],
|
|
71
|
+
[LastInput, ScaleRow<49>]
|
|
72
|
+
>
|
|
73
|
+
>;
|
|
74
|
+
|
|
75
|
+
/** A single read has no `page` — the route refuses the controls, so the method would be a 400. */
|
|
76
|
+
export type _ASingleReadHasNoPage = Assert<
|
|
77
|
+
'page' extends keyof ScaleClient['oneRow49'] ? false : true
|
|
78
|
+
>;
|
package/src/client.ts
CHANGED
|
@@ -203,6 +203,9 @@ function read(
|
|
|
203
203
|
* The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
|
|
204
204
|
* plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
|
|
205
205
|
* split at `_first=` to recover the read underneath.
|
|
206
|
+
*
|
|
207
|
+
* An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
|
|
208
|
+
* (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
|
|
206
209
|
*/
|
|
207
210
|
function searchOf(input: unknown, page?: PageControls): string {
|
|
208
211
|
const params = new URLSearchParams();
|
|
@@ -211,7 +214,7 @@ function searchOf(input: unknown, page?: PageControls): string {
|
|
|
211
214
|
const value = input[key];
|
|
212
215
|
if (value === undefined || value === null) continue;
|
|
213
216
|
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
214
|
-
params.append(key,
|
|
217
|
+
params.append(key, wireText(item));
|
|
215
218
|
}
|
|
216
219
|
}
|
|
217
220
|
}
|
|
@@ -221,3 +224,14 @@ function searchOf(input: unknown, page?: PageControls): string {
|
|
|
221
224
|
}
|
|
222
225
|
return params.toString();
|
|
223
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
|
|
230
|
+
* `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
|
|
231
|
+
* Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
|
|
232
|
+
* as the text it prints and the read's own schema says what is wrong with it.
|
|
233
|
+
*/
|
|
234
|
+
function wireText(item: unknown): string {
|
|
235
|
+
if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
|
|
236
|
+
return typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
237
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: the DECLARED kind of each column of the relation a read names. Postgres
|
|
2
|
+
// compares two values by their column's type, so every comparison this package makes asks here
|
|
3
|
+
// first and hands the answer to `@ultimat3/entity`'s comparator — never `typeof` the value.
|
|
4
|
+
|
|
5
|
+
import type { ColumnKind } from '@ultimat3/entity';
|
|
6
|
+
import { entityForTable } from '@ultimat3/entity';
|
|
7
|
+
|
|
8
|
+
/** A column's declared kind, or `undefined` when nothing declares one. */
|
|
9
|
+
export type KindOf = (column: string) => ColumnKind | undefined;
|
|
10
|
+
|
|
11
|
+
const UNDECLARED: KindOf = () => undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The kinds of `QueryShape.entity`'s columns. Resolved once per read, sort or change event — the
|
|
15
|
+
* registry is a scan — and never per comparison.
|
|
16
|
+
*
|
|
17
|
+
* A relation no entity declares (`from('report', rows)` over a projection) has no kinds: its
|
|
18
|
+
* values compare as `@ultimat3/entity` compares an undeclared column, by what they are. `money` is
|
|
19
|
+
* two physical columns behind one property, so the property alone names no single value to order.
|
|
20
|
+
*/
|
|
21
|
+
export function kindsOf(relation: string): KindOf {
|
|
22
|
+
const columns = entityForTable(relation)?.$columns;
|
|
23
|
+
if (columns === undefined) return UNDECLARED;
|
|
24
|
+
return (column) => {
|
|
25
|
+
const kind = Object.hasOwn(columns, column) ? columns[column]?.$meta.kind : undefined;
|
|
26
|
+
return kind === 'money' ? undefined : kind;
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/cursor-value.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Single responsibility: what a sort value becomes inside a cursor, and what it becomes again on
|
|
2
2
|
// the way out. The codec is `@ultimat3/core`'s and it is JSON, so a `Date` went in and an ISO
|
|
3
|
-
// STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"`
|
|
4
|
-
//
|
|
3
|
+
// STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` as
|
|
4
|
+
// text and page two came back empty. A `bigint` was worse — a bare
|
|
5
5
|
// `TypeError` out of `JSON.stringify`, with no code and no fix.
|
|
6
6
|
//
|
|
7
7
|
// `@ultimat3/entity`'s `cursor.ts` solves the same problem by reading the column's declared kind.
|
|
8
|
-
// A
|
|
8
|
+
// A read over a relation NO entity declares has no kinds to read (`column-kinds.ts`), so the value
|
|
9
9
|
// carries its own tag instead. Self-describing, which is also what makes the revive total: nothing
|
|
10
10
|
// here has to know which read minted the cursor.
|
|
11
11
|
|
package/src/facade.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function facadeFor<
|
|
|
45
45
|
// signed cursor is only reachable through the query that issued it.
|
|
46
46
|
page: (input, args) => paginate(self(), input, args),
|
|
47
47
|
live: (input, options) => toLiveQuery(self(), input, options),
|
|
48
|
-
tool: () => toQueryTool(self()),
|
|
48
|
+
tool: () => toQueryTool<TSingle>(self()),
|
|
49
49
|
// One implementation for both wire shapes: the route decides the body, the transport parses
|
|
50
50
|
// it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
|
|
51
51
|
client: (options) =>
|
package/src/http.ts
CHANGED
|
@@ -11,17 +11,15 @@ import { toBucket } from '@ultimat3/http';
|
|
|
11
11
|
import { coerceQuery } from '@ultimat3/schema';
|
|
12
12
|
import type { Deprecation } from './deprecation';
|
|
13
13
|
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
QueryInputInvalidError,
|
|
17
|
-
QueryRowNotFoundError,
|
|
18
|
-
} from './errors';
|
|
14
|
+
import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
|
|
15
|
+
import { absentArraysOf } from './input-shape';
|
|
19
16
|
import { derivePath } from './naming';
|
|
20
17
|
import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
|
|
21
18
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
22
19
|
import type { AnyQuery } from './query';
|
|
23
20
|
import { queryName, runQuery } from './read';
|
|
24
21
|
import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
|
|
22
|
+
import { oneRowOf } from './single-answer';
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
25
|
* `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
|
|
@@ -63,7 +61,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
63
61
|
// The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
|
|
64
62
|
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
65
63
|
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
66
|
-
const input = coerceQuery(target.input, values);
|
|
64
|
+
const input = absentArraysOf(target.input, coerceQuery(target.input, values));
|
|
67
65
|
if (target.single === true) {
|
|
68
66
|
// Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
|
|
69
67
|
// a silently dropped `_first` would answer a shape it did not ask for.
|
|
@@ -75,9 +73,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
75
73
|
}
|
|
76
74
|
// The first row — what every in-process `[0]` of the same read already takes. None is the
|
|
77
75
|
// 404 a detail URL means, where a list read answers `200 []`.
|
|
78
|
-
|
|
79
|
-
if (row === undefined) throw new QueryRowNotFoundError(name);
|
|
80
|
-
return answerRow(row);
|
|
76
|
+
return answerRow(oneRowOf(name, await runQuery(target, input, { surface: 'http' })));
|
|
81
77
|
}
|
|
82
78
|
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
83
79
|
// caller with — `{ rows, nextCursor, hasMore, endCursor, hasNextPage }`, the same names (the
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,14 @@ export type {
|
|
|
65
65
|
} from './client';
|
|
66
66
|
/** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
|
|
67
67
|
export { queryClient, queryClientMethodFor } from './client';
|
|
68
|
+
/**
|
|
69
|
+
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
|
70
|
+
* `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
|
|
71
|
+
* `matchesFilter` and `isAfterKey` take the relation's declared kinds — `kindsOf(shape.entity)` —
|
|
72
|
+
* because how two values compare is `@ultimat3/entity`'s answer for the column's kind.
|
|
73
|
+
*/
|
|
74
|
+
export type { KindOf } from './column-kinds';
|
|
75
|
+
export { kindsOf } from './column-kinds';
|
|
68
76
|
/** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
|
|
69
77
|
export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
|
|
70
78
|
export { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
@@ -93,7 +101,7 @@ export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } fro
|
|
|
93
101
|
export { planResume, seekOf, toLiveQuery } from './live';
|
|
94
102
|
export type { ChangeEvent, ChangeOp, Patch } from './matcher';
|
|
95
103
|
export { assertMatchable, match, positionFor } from './matcher';
|
|
96
|
-
export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
|
|
104
|
+
export type { QueryToolAnswer, QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
|
|
97
105
|
export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
|
|
98
106
|
/**
|
|
99
107
|
* Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
|
|
@@ -151,7 +159,6 @@ export type {
|
|
|
151
159
|
export { describeQuery, isQuery, query, queryHash } from './query';
|
|
152
160
|
/** The one read path. `defOf` stays unexported — that is the enforcement. */
|
|
153
161
|
export { queryName, runQuery, sourceFor } from './read';
|
|
154
|
-
|
|
155
162
|
export {
|
|
156
163
|
describeQueries,
|
|
157
164
|
getQuery,
|
|
@@ -164,18 +171,15 @@ export {
|
|
|
164
171
|
export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search';
|
|
165
172
|
export { search } from './search';
|
|
166
173
|
export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
167
|
-
/**
|
|
168
|
-
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
|
169
|
-
* `totalOrder` is the one definition of the order it must serve a page in.
|
|
170
|
-
*/
|
|
171
174
|
export {
|
|
172
175
|
compareRows,
|
|
173
|
-
compareValues,
|
|
174
176
|
isNull,
|
|
175
177
|
matchesFilter,
|
|
176
178
|
seekKeyOf,
|
|
177
179
|
totalOrder,
|
|
178
180
|
} from './shape';
|
|
181
|
+
/** What a `single: true` read answers — one rule, also `@ultimat3/mcp`'s served tool's. */
|
|
182
|
+
export { readAnswer } from './single-answer';
|
|
179
183
|
export type { RowProvider, SqlSource, SqlText } from './source';
|
|
180
184
|
/** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
|
|
181
185
|
export { Builder, from, isAfterKey } from './source';
|
package/src/input-shape.ts
CHANGED
|
@@ -79,3 +79,23 @@ export function assertEncodableInput(input: unknown): void {
|
|
|
79
79
|
const offender = unencodable(node);
|
|
80
80
|
if (offender !== undefined) throw new QueryInputUnencodableError(offender);
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The route's half of an empty array. `searchOf` sends NOTHING for `[]` — a query string has no
|
|
85
|
+
* spelling for "this key, zero times" — so a REQUIRED array nobody sent can only be the empty one:
|
|
86
|
+
* left absent it fails validation, on a call the typed client accepted.
|
|
87
|
+
*
|
|
88
|
+
* Required only. An optional or defaulted array already has a meaning for absence, and it is the
|
|
89
|
+
* schema's: `[]` there would overwrite a declared default with a value the caller never wrote.
|
|
90
|
+
*/
|
|
91
|
+
export function absentArraysOf(
|
|
92
|
+
schema: unknown,
|
|
93
|
+
input: Readonly<Record<string, unknown>>,
|
|
94
|
+
): Record<string, unknown> {
|
|
95
|
+
const out: Record<string, unknown> = { ...input };
|
|
96
|
+
for (const [key, child] of Object.entries(tryIntrospect(schema)?.properties ?? {})) {
|
|
97
|
+
if (child.kind !== 'array' || child.optional === true || child.hasDefault === true) continue;
|
|
98
|
+
if (!Object.hasOwn(input, key)) out[key] = [];
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/src/live.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { guard } from './policy-gate';
|
|
|
16
16
|
import type { Query } from './query';
|
|
17
17
|
import { queryHash } from './query';
|
|
18
18
|
import { queryName, sourceFor } from './read';
|
|
19
|
+
import { assertNoSealedKey } from './sealed-shape';
|
|
19
20
|
import type { QueryShape, SeekKey } from './shape';
|
|
20
21
|
import { seekKeyOf } from './shape';
|
|
21
22
|
import { assertSubscribed } from './subscribes';
|
|
@@ -128,6 +129,8 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
|
|
|
128
129
|
// outgrown means `x db gen` granted REPLICA IDENTITY FULL somewhere else, so every UPDATE on this
|
|
129
130
|
// relation arrives without its old row and no patch is computable from it.
|
|
130
131
|
assertSubscribed(name, target.subscribes, shape.entity);
|
|
132
|
+
// And once more for the same moment: a window keyed on a sealed column is read and never patched.
|
|
133
|
+
assertNoSealedKey(name, shape);
|
|
131
134
|
|
|
132
135
|
const epoch = options.epoch ?? liveEpoch();
|
|
133
136
|
const hash = queryHash(name, input);
|