@ultimat3/query 22.14.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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 |
@@ -61,6 +63,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
61
63
  barrel exports nothing matching `/tool_?name/i`.
62
64
  - **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
63
65
  `queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
66
+ - **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
67
+ excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
68
+ it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
64
69
  - **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
65
70
  `meta.input` on the route.
66
71
  - **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
@@ -84,6 +89,15 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
84
89
  - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
85
90
  close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
86
91
  decision across actors.
92
+ - **This package strips NO column, and must not start.** A query has no output parse; a
93
+ `.sealed()` column is absent from every projection because `@ultimat3/entity`'s repository row
94
+ does not ENUMERATE it (`serverOnly`, `packages/entity/src/sealed.ts`). So nothing on the read
95
+ path may rebuild a row property by property from a column list or `Object.getOwnPropertyNames`
96
+ — `JSON.stringify`, a spread and `Object.keys` are the only walks. `sealed-egress.test.ts` pins
97
+ HTTP (list, `Page`, single, envelope), MCP, live, the cache tier and a derived row.
98
+ - **A live read keyed on a sealed column is refused at subscribe** (`assertNoSealedKey`, called by
99
+ `toLiveQuery`): a change row carries none, so the window would be read once and never patched.
100
+ The refusal names the column, never the filter's value.
87
101
 
88
102
  ## Invariants — the client
89
103
 
@@ -102,7 +116,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
102
116
  import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
103
117
  `client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
104
118
  titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
105
- (`As of 2026-09-22`, browser, minified): barrel `queryClient` 24,114 B; `./client` 10,210 B.
119
+ (`As of 2026-10-01`, browser, minified): barrel `queryClient` 23,997 B; `./client` 10,899 B.
106
120
  - **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
107
121
  `@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
108
122
  answer. No `rows:` is byte-identical on the wire.
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
@@ -205,6 +208,7 @@ forgetting the policy, and the reason is what tells the next reader which of the
205
208
  | `sql.ts` | `explain()` — the generated SQL, verbatim |
206
209
  | `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
207
210
  | `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
211
+ | `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
208
212
 
209
213
  ## Live queries
210
214
 
@@ -262,6 +266,22 @@ cursor cannot prove that rows sorting *before* it are unchanged, so any epoch ch
262
266
  new build, new policy, new schema — forces a full refetch instead of a resume. Bounded
263
267
  server memory bought with an occasional extra page fetch.
264
268
 
269
+ ## A sealed column never rides on a row
270
+
271
+ A query has no output schema; nothing here strips a column. A `.sealed()` column is absent from
272
+ every projection because `@ultimat3/entity` answers it as a server-only (non-enumerable) property —
273
+ `row.password` on the server, nothing in `JSON.stringify(row)`.
274
+
275
+ | Projection | Sealed column |
276
+ |---|---|
277
+ | HTTP — list, `Page`, `single: true`, the record envelope | absent |
278
+ | MCP read, a `cache:` entry, a live snapshot | absent |
279
+ | a derived row `({ ...row, extra })` | absent |
280
+ | a derived row naming it `({ token: row.token })` | sent — the name is the statement |
281
+ | `query(input)` on the server | `row.password` reads the plaintext |
282
+
283
+ `sealed-egress.test.ts` holds one assertion per projection.
284
+
265
285
  ## Matcher support
266
286
 
267
287
  | Shape | Result |
@@ -270,6 +290,7 @@ server memory bought with an occasional extra page fetch.
270
290
  | `orderBy` (any number of keys) | insert position computed, moves become remove + add |
271
291
  | `limit` | tail eviction on insert, `refill` patch on removal |
272
292
  | joins, aggregates, `group by`, subqueries | `X_MATCHER_UNSUPPORTED` with a fix line |
293
+ | a `.sealed()` column in a filter or an order | `X_MATCHER_UNSUPPORTED` — a change row carries no sealed column |
273
294
 
274
295
  An honest refusal beats a silently wrong result set, so unsupported shapes fail at
275
296
  **subscribe** time, not on the first change event.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "22.14.0",
3
+ "version": "23.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": "22.14.0",
40
- "@ultimat3/core": "22.14.0",
41
- "@ultimat3/entity": "22.14.0",
42
- "@ultimat3/http": "22.14.0",
43
- "@ultimat3/policy": "22.14.0",
44
- "@ultimat3/schema": "22.14.0"
40
+ "@ultimat3/cache": "23.0.0",
41
+ "@ultimat3/core": "23.0.0",
42
+ "@ultimat3/entity": "23.0.0",
43
+ "@ultimat3/http": "23.0.0",
44
+ "@ultimat3/policy": "23.0.0",
45
+ "@ultimat3/schema": "23.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/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);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A live read may not FILTER or ORDER on a sealed column, refused at subscribe. The matcher places
3
+ * a change from the row the change feed carries, and that row names no sealed column — the log
4
+ * holds the stored string, which `@ultimat3/realtime` drops rather than forwards. So a window keyed
5
+ * on one could be read once and never patched: every later change would miss the filter or land
6
+ * at no position, silently.
7
+ */
8
+
9
+ import { entityForTable, sealedFields } from '@ultimat3/entity';
10
+ import { MatcherUnsupportedError } from './errors';
11
+ import type { QueryShape } from './shape';
12
+
13
+ export function assertNoSealedKey(name: string, shape: QueryShape): void {
14
+ // A relation no entity declares has no sealed column to name: `from('report', rows)` over a
15
+ // projection is common, and it is not this rule's to refuse.
16
+ const entity = entityForTable(shape.entity);
17
+ if (entity === undefined) return;
18
+ const sealed = new Set(sealedFields(entity).map((field) => field.property));
19
+ if (sealed.size === 0) return;
20
+ const keyed = [...shape.filters, ...shape.orderBy].find((key) => sealed.has(key.column));
21
+ if (keyed === undefined) return;
22
+ // The column's NAME, never the filter's value: that value is the secret being matched on.
23
+ throw new MatcherUnsupportedError(
24
+ name,
25
+ `the sealed column "${keyed.column}" in a filter or an order — a change row carries no sealed column`,
26
+ );
27
+ }