@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 +15 -1
- package/README.md +21 -0
- package/package.json +8 -7
- package/src/client-scale-pins.ts +78 -0
- package/src/live.ts +3 -0
- package/src/sealed-shape.ts +27 -0
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-
|
|
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": "
|
|
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": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/entity": "
|
|
42
|
-
"@ultimat3/http": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/schema": "
|
|
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
|
+
}
|