@ultimat3/query 2.0.0 → 4.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 +68 -14
- package/README.md +14 -7
- package/package.json +6 -6
- package/src/cache.ts +21 -5
- package/src/matcher.ts +21 -5
- package/src/query.ts +1 -1
- package/src/source.ts +9 -1
- package/src/sql.ts +3 -1
- package/src/stable.ts +7 -55
package/CLAUDE.md
CHANGED
|
@@ -154,6 +154,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
154
154
|
`query.page(input, { first, after })` — a page is the read's own answer, not an imported helper.
|
|
155
155
|
`src/index.ts` exports `Page` and `PaginateArgs` and not the function: re-exporting it would be
|
|
156
156
|
a second way to ask for the thing `.page()` already does.
|
|
157
|
+
- **A `RowProvider` may be a list, a sync function or an async one** (`As of 2026-08-19`).
|
|
158
|
+
`execute()` awaits whatever the function returns, so all three were always accepted at runtime —
|
|
159
|
+
the type declared only `() => Promise<readonly TRow[]>`, which refused a repo method already
|
|
160
|
+
holding its page and every in-memory fixture. `source.test.ts` pins all three.
|
|
157
161
|
- **A cursor is a position, not a row.** `isAfterKey` in `source.ts` is the one definition of
|
|
158
162
|
"after this position": `Builder.seek()` compiles it to SQL and `paginate()` applies it when a
|
|
159
163
|
source cannot push the seek down. The fallback used to find the cursor's row by id and slice
|
|
@@ -213,10 +217,25 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
213
217
|
`["10", "100", "9"]`. `bigint` is the physical type of every `<p>_minor` column and
|
|
214
218
|
`@ultimat3/entity`'s `count-by.ts` lists it as groupable, so the in-memory source, the live
|
|
215
219
|
matcher and the seek fallback ALL disagreed with the database on any bigint-ordered read.
|
|
216
|
-
`shape-order.test.ts` is the pin
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
`shape-order.test.ts` is the pin, and `As of 2026-08` it reads a REAL kind list: `COLUMN_KINDS`
|
|
221
|
+
is the runtime array `@ultimat3/entity`'s `ColumnKind` derives from, and entity is tier 2 so a
|
|
222
|
+
test here may import it as a VALUE — which is what a `satisfies Record<ColumnKind, …>` could not
|
|
223
|
+
be, since `tsconfig.json` excludes `*.test.ts` and `tsc` never reads one. It had a spelled-out
|
|
224
|
+
list and `const COUNT = 9` beside a union of THIRTEEN members: `9 === 9`, a test that could not
|
|
225
|
+
fail, with `numeric`, `date`, `bytea` and `array` carrying no case at all.
|
|
226
|
+
- **`numeric` and the TEXT form of `bigint` are a DECLARED gap here, and closing it is a
|
|
227
|
+
declaration change** (`shape-order.test.ts`, `As of 2026-08`). `@ultimat3/entity`'s `bigint()`
|
|
228
|
+
and `decimal()` hand digits back as strings, so `["9","10","100","2"]` sorts to
|
|
229
|
+
`["10","100","2","9"]` here and `["2","9","10","100"]` in the database — and a cursor's revived
|
|
230
|
+
`bigint` against a stored decimal string (`compareValues(9n, "10")` → `1`) cuts page two where
|
|
231
|
+
the database does not. It is **not** fixed by calling `@ultimat3/core`'s `compareDecimalText`
|
|
232
|
+
from `compareValues`: that function answers only for a caller holding the column's declared kind
|
|
233
|
+
(`@ultimat3/entity`'s `compareByKind`), and `QueryShape.orderBy` is a name and a direction —
|
|
234
|
+
nothing here can tell a `numeric` holding `"10"` from a `text` holding `"10"`, which Postgres
|
|
235
|
+
orders lexically, so a comparator guessing would trade one disagreement with the SQL it prints
|
|
236
|
+
for another. The fix is an `OrderKey` that carries a kind, from `sourceFor` down. Until then the
|
|
237
|
+
`DECLARED_GAP` block asserts both halves, so the gap cannot be silently re-discovered or
|
|
238
|
+
silently widened.
|
|
220
239
|
- **A read's `input:` must survive a query STRING, and `query()` refuses one that cannot**
|
|
221
240
|
(`input-shape.ts`, `X_QUERY_INPUT_UNENCODABLE`, `As of 2026-08`). `client.ts` encoded a nested
|
|
222
241
|
member as `JSON.stringify(item)` and skipped a `null`, while `coerceQuery` has no inverse for
|
|
@@ -229,6 +248,17 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
229
248
|
`"null"`. Refused: a structural member (`object`, `record`, `money`, or an array/union of one),
|
|
230
249
|
a REQUIRED nullable member, and a top-level input that is not an object. A schema
|
|
231
250
|
`tryIntrospect` cannot read is left alone, or `configureSchemaProvider` would be unusable.
|
|
251
|
+
- **A refill is owed by a FULL window and by nothing else** (`matcher.ts`, `As of 2026-08`).
|
|
252
|
+
`removeAt` pushed one whenever `shape.limit !== null`, with no reference to how many rows the
|
|
253
|
+
window holds: three rows under `limit: 50`, delete one, and the patch list was
|
|
254
|
+
`[{remove, position:1}, {refill, from:49}]` — a position no two-row result set has. It is not a
|
|
255
|
+
harmless extra: `@ultimat3/realtime`'s `matcher-bridge` folds any refill into
|
|
256
|
+
`BridgeResult.refill`, and `live-fanout` then sends **no patch frame at all** that round, marking
|
|
257
|
+
every subscriber desynced instead — so on a quiet feed the deleted row stays rendered until some
|
|
258
|
+
other change to the same query id arrives, and on a busy one it is a full DB re-read plus one
|
|
259
|
+
snapshot per subscriber per delete. A window under `limit` has no unknown tail: the source served
|
|
260
|
+
fewer rows than it was allowed to, so what the client holds IS the result set. `held >=
|
|
261
|
+
shape.limit` is the gate, and it is `wasFull` one branch away, already written.
|
|
232
262
|
- **A move OUT of a full window is a `refill`, never an `add`** (`matcher.ts`). `insert()` places a
|
|
233
263
|
moved row among the `limit - 1` rows the client still holds, so its position can never reach
|
|
234
264
|
`shape.limit` and the `position >= shape.limit` bail is unreachable on that path — the row was
|
|
@@ -247,11 +277,23 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
247
277
|
`@ultimat3/http`. `tagKey` went with it: `serializeTag` under a second name, zero call sites.
|
|
248
278
|
`@ultimat3/render` exports a *different* function under the same name (declaration order kept);
|
|
249
279
|
never import that one here.
|
|
250
|
-
- **A fingerprint is an identity, so two different inputs may not share one
|
|
251
|
-
`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
280
|
+
- **A fingerprint is an identity, so two different inputs may not share one — and it is
|
|
281
|
+
`@ultimat3/core`'s, not this package's, `As of 2026-08`.** `canonicalJson` + `fingerprint` moved
|
|
282
|
+
down to tier 0 because `@ultimat3/action` and `@ultimat3/realtime` needed the identical function
|
|
283
|
+
and all three are tier 3, so a copy in any of them was a second answer for the other two — and
|
|
284
|
+
the copies had already diverged. This one had **no `Date` branch**: `Object.keys(date)` is `[]`,
|
|
285
|
+
so the object branch rendered every date as `{}` and `queryHash({from: 2020…, to: 2020…})`
|
|
286
|
+
equalled `queryHash({from: 2026…, to: 2026…})`. Reachable on the ordinary HTTP path — `http.ts`
|
|
287
|
+
decodes a query string through `coerceQuery`, which turns a `t.date` member into a real `Date`,
|
|
288
|
+
and `input-shape.ts` permits `date` members — so ONE read-cache entry answered every date window
|
|
289
|
+
of that read for the TTL, page two of range A was served from range B's cursor scope, and every
|
|
290
|
+
date window shared one live query id. The hash form tags a `Date`, a `Map` and a `Set`
|
|
291
|
+
(`Date(<epoch>)`, `Map(…)`, `Set(…)`) beside the bare `NaN` / `±Infinity` / `-0` tokens it
|
|
292
|
+
already emitted, all for the reason a bare token exists: `'null'` collided with JSON `null` and
|
|
293
|
+
`String(-0)` is `"0"`. `stable.ts` keeps `isJsonObject`/`columnOf` and nothing else. Ordinary
|
|
294
|
+
inputs are byte-identical, so the durable-key cost is confined to reads whose input carries a
|
|
295
|
+
`Date`, a `Map` or a `Set`: those cursors answer `X_CURSOR_INVALID` once, and their cache entries
|
|
296
|
+
are cold once. `query-hash.test.ts` is the pin, at `queryHash` and at `cacheKeyFor`.
|
|
255
297
|
- The cursor codec is `@ultimat3/core`'s (`encodeCursor` / `decodeCursor` / `configureCursorSigning`).
|
|
256
298
|
This package supplies only the scope a cursor is bound to — `queryHash(name, input)` — and never
|
|
257
299
|
signs, encodes or parses one itself. An unverified or foreign cursor is `X_CURSOR_INVALID`, thrown
|
|
@@ -278,19 +320,31 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
278
320
|
is the mechanism: declaring nothing gets the narrowest key. `'tenant'` and `'global'` are written
|
|
279
321
|
statements about the rows — the `unenforced:` shape one field over — and `'tenant'` with no
|
|
280
322
|
`orgId` narrows to the actor rather than widening to everyone, because nothing here can prove two
|
|
281
|
-
org-less callers share a tenant.
|
|
323
|
+
org-less callers share a tenant. **All THREE spellings of "no org" take that branch**, `As of
|
|
324
|
+
2026-08`: `undefined`, `''` and `null`. The last one missed it, so every org-less caller shared
|
|
325
|
+
the single key `["org",null]` and was served the rows of whoever asked first. `orgless()` widens
|
|
326
|
+
its parameter past core's `orgId?: string` because **`orgId` is a value off the wire** — an app's
|
|
327
|
+
adapter, a decoded session row, a JSON payload — not because a declared type permits a `null`.
|
|
328
|
+
`@ultimat3/policy`'s `PolicyActorFields` reads like the reason and is not it (corrected
|
|
329
|
+
2026-08-19): `Actor = CoreActor & PolicyActorFields`, and that intersection collapses its
|
|
330
|
+
`string | null | undefined` back to `string | undefined`, so the widening is **inert** at the type
|
|
331
|
+
level and `{ orgId: null }` is a type error. Its `testActor` mints `orgId: null` through the one
|
|
332
|
+
cast left in `packages/policy/src/test-kit.ts`, which is why `cache-authority.test.ts` can reach
|
|
333
|
+
this branch at all — the repo's only producer of that `null`, and a test seam rather than a proof.
|
|
334
|
+
The authority is JSON, never a joined string, for the reason
|
|
282
335
|
`@ultimat3/entity`'s `scopeKey` gives: an actor id is app data and may carry the separator.
|
|
283
336
|
- **`cache.ttlMs` is judged at `query()`, not on the first read.** Every `CacheTier` refuses a
|
|
284
337
|
lease that is not positive and finite (`assertTtl`), and the read tier's one catch absorbs
|
|
285
338
|
`X_CACHE_TOO_LARGE` only — so `ttlMs: Infinity` turned a typo into a permanently failing business
|
|
286
339
|
read whose cause named a cache key. `X_QUERY_CACHE_TTL_INVALID`, on the line that wrote it. It
|
|
287
340
|
restates `assertTtl`'s bar as a refusal and never as a second resolution.
|
|
288
|
-
- **`fingerprint` is SHA-256/16, never a 32-bit hash** (
|
|
341
|
+
- **`fingerprint` is SHA-256/16, never a 32-bit hash** (`@ultimat3/core`, `As of 2026-08`). It is a
|
|
289
342
|
SHARING key over client-chosen input — which read-cache entry two callers are served from, which
|
|
290
343
|
scope a cursor is bound to — so FNV-1a/32's 4×10⁹ values are a collision found offline in
|
|
291
|
-
seconds.
|
|
292
|
-
|
|
293
|
-
|
|
344
|
+
seconds. `@ultimat3/realtime`'s `stableDigest` was the same primitive at the same width and is
|
|
345
|
+
gone with the rest of that copy: a `qid` is `queryHash(name, input)` now, imported across the
|
|
346
|
+
declared `realtime -> query` edge, so the two hashes cannot drift apart while `planResume`
|
|
347
|
+
compares one against a cursor's.
|
|
294
348
|
- **A fill is FENCED, and the fence is `@ultimat3/cache`'s** (`As of 2026-08`). `run()` answers with
|
|
295
349
|
rows it read in the past: a mutator committing in between busts a key not yet in the tier, so the
|
|
296
350
|
drop is a no-op reporting `errors: []`, and the fill then publishes the pre-write rows for the
|
package/README.md
CHANGED
|
@@ -143,13 +143,20 @@ the package, and rotating the secret is what invalidates every open cursor. This
|
|
|
143
143
|
the only thing that is its business — the scope, `queryHash(name, input)` — and re-exports
|
|
144
144
|
`CursorInvalidError` so the failure keeps its name on this surface.
|
|
145
145
|
|
|
146
|
-
The scope's hash is **SHA-256, first 16 hex**
|
|
147
|
-
width `@ultimat3/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
The scope's hash is **SHA-256, first 16 hex** — `fingerprint` from `@ultimat3/core`, the primitive
|
|
147
|
+
and width `@ultimat3/entity`'s `planScope` already uses, and the same function
|
|
148
|
+
`@ultimat3/action`'s `requestHash` and `@ultimat3/realtime`'s `qid` are taken over `As of 2026-08`.
|
|
149
|
+
It was FNV-1a/32 until 2026-08 — 4×10⁹ values over input a client chooses, brute-forceable offline
|
|
150
|
+
in seconds, and a fingerprint here is a *sharing* key: which read-cache entry two callers are served
|
|
151
|
+
from, and which scope a cursor is bound to.
|
|
152
|
+
|
|
153
|
+
The canonical form is **injective**, which is the other half of the same requirement. It had no
|
|
154
|
+
`Date` branch until 2026-08: `Object.keys(date)` is `[]`, so every date rendered `{}` and one key
|
|
155
|
+
answered for every date window a read ever served — a real leak on the ordinary HTTP path, since
|
|
156
|
+
`coerceQuery` turns a `t.date` member into a `Date` and `input-shape.ts` permits `date` members. A
|
|
157
|
+
`Date`, a `Map` and a `Set` are tagged now. Ordinary inputs are byte-identical, so the cost is
|
|
158
|
+
confined to reads whose input carries one of the three: those cursors answer `X_CURSOR_INVALID`
|
|
159
|
+
once, with "request the first page again" as their fix, and their cache entries are cold once.
|
|
153
160
|
|
|
154
161
|
A cursor names a **position in the ordering**, never a row and never a count. Both seek paths
|
|
155
162
|
answer "is this row after that position?" through the one predicate, `isAfterKey`: `Builder.seek()`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/cache": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/http": "
|
|
37
|
-
"@ultimat3/policy": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/cache": "4.0.0",
|
|
35
|
+
"@ultimat3/core": "4.0.0",
|
|
36
|
+
"@ultimat3/http": "4.0.0",
|
|
37
|
+
"@ultimat3/policy": "4.0.0",
|
|
38
|
+
"@ultimat3/schema": "4.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/cache.ts
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
import type { CacheStack, CacheTag, CacheTier } from '@ultimat3/cache';
|
|
9
9
|
import { createCacheStack, registeredTiers, tagKeys } from '@ultimat3/cache';
|
|
10
10
|
import type { Actor, Clock, Ctx } from '@ultimat3/core';
|
|
11
|
-
import { assertNever } from '@ultimat3/core';
|
|
12
|
-
import { fingerprint } from './stable';
|
|
11
|
+
import { assertNever, fingerprint } from '@ultimat3/core';
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* A `cache:` block with no `ttlMs`. Tag invalidation is the primary eviction, so this is the
|
|
@@ -47,6 +46,24 @@ export function requestMemo(ctx: Ctx): Map<string, Promise<unknown>> {
|
|
|
47
46
|
*/
|
|
48
47
|
export type QueryCacheScope = 'actor' | 'tenant' | 'global';
|
|
49
48
|
|
|
49
|
+
/**
|
|
50
|
+
* "This actor is inside no org", in all three spellings it arrives in. The parameter is widened
|
|
51
|
+
* past core's `Actor.orgId` (`string | undefined`) because `orgId` is a value off the wire, not a
|
|
52
|
+
* value this process minted: an app's own adapter, a decoded session row or a JSON payload can put
|
|
53
|
+
* a `null` here, and it used to miss the `undefined`/`''` test below — which handed every org-less
|
|
54
|
+
* caller the single shared key `["org",null]` and served each one the rows of whoever asked first.
|
|
55
|
+
* `actorAuthority` already wrote `?? null` for the same reason.
|
|
56
|
+
*
|
|
57
|
+
* `@ultimat3/policy`'s `PolicyActorFields` is NOT the justification, though it reads like one:
|
|
58
|
+
* `Actor = CoreActor & PolicyActorFields`, and that intersection collapses its
|
|
59
|
+
* `string | null | undefined` back to core's `string | undefined`, so the widening is inert at the
|
|
60
|
+
* type level. Its `testActor` mints `orgId: null` through the one cast left in that file, which is
|
|
61
|
+
* why `cache-authority.test.ts` can exercise this branch at all — a test producer, not a proof
|
|
62
|
+
* that the declared type permits one.
|
|
63
|
+
*/
|
|
64
|
+
const orgless = (orgId: string | null | undefined): boolean =>
|
|
65
|
+
orgId === undefined || orgId === null || orgId === '';
|
|
66
|
+
|
|
50
67
|
/**
|
|
51
68
|
* The authority a read was answered under, as a key component.
|
|
52
69
|
*
|
|
@@ -67,9 +84,8 @@ export function readAuthority(actor: Actor, scope: QueryCacheScope): string {
|
|
|
67
84
|
// An actor inside no org is not a shared tenant. Nothing here can prove two org-less callers
|
|
68
85
|
// see the same rows, so the key narrows to the actor rather than widening to everyone —
|
|
69
86
|
// declining instead of guessing, which is the only safe direction for a sharing key.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
: JSON.stringify(['org', actor.orgId]);
|
|
87
|
+
//
|
|
88
|
+
return orgless(actor.orgId) ? actorAuthority(actor) : JSON.stringify(['org', actor.orgId]);
|
|
73
89
|
case 'actor':
|
|
74
90
|
return actorAuthority(actor);
|
|
75
91
|
default:
|
package/src/matcher.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function match<TRow extends object>(
|
|
|
61
61
|
const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
|
|
62
62
|
|
|
63
63
|
if (event.op === 'delete' || (inSet && !belongs)) {
|
|
64
|
-
return inSet ? removeAt(shape, index, id, true) : [];
|
|
64
|
+
return inSet ? removeAt(shape, index, id, true, rows.length) : [];
|
|
65
65
|
}
|
|
66
66
|
if (!belongs) return [];
|
|
67
67
|
if (!inSet) return insert(shape, rows, event.row);
|
|
@@ -84,9 +84,12 @@ export function match<TRow extends object>(
|
|
|
84
84
|
// too, so the refill covers both.
|
|
85
85
|
const wasFull = shape.limit !== null && rows.length >= shape.limit;
|
|
86
86
|
if (wasFull && positionFor(shape, without, event.row) >= without.length) {
|
|
87
|
-
return removeAt<TRow>(shape, index, id, true);
|
|
87
|
+
return removeAt<TRow>(shape, index, id, true, rows.length);
|
|
88
88
|
}
|
|
89
|
-
return [
|
|
89
|
+
return [
|
|
90
|
+
...removeAt<TRow>(shape, index, id, false, rows.length),
|
|
91
|
+
...insert(shape, without, event.row),
|
|
92
|
+
];
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
function insert<TRow extends object>(
|
|
@@ -107,15 +110,28 @@ function insert<TRow extends object>(
|
|
|
107
110
|
return patches;
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `held` is how many rows the window actually holds, and it is the whole condition on the refill.
|
|
115
|
+
*
|
|
116
|
+
* A refill says the tail is unknown to the client, and it is answered by a full re-read: the bridge
|
|
117
|
+
* folds it into `BridgeResult.refill`, and the fanout then sends NO patch frame that round —
|
|
118
|
+
* suppressing the `remove` beside it and leaving a deleted row on screen until the next change to
|
|
119
|
+
* the same query. A window under `limit` has no unknown tail: the source served fewer rows than it
|
|
120
|
+
* was allowed to, so what the client holds IS the result set. Unconditional, this also named a
|
|
121
|
+
* position no result set has — `limit: 50` over three rows emitted `{ refill, from: 49 }`.
|
|
122
|
+
*/
|
|
110
123
|
function removeAt<TRow extends object>(
|
|
111
124
|
shape: QueryShape,
|
|
112
125
|
index: number,
|
|
113
126
|
id: string,
|
|
114
127
|
refill: boolean,
|
|
128
|
+
held: number,
|
|
115
129
|
): readonly Patch<TRow>[] {
|
|
116
130
|
const patches: Patch<TRow>[] = [{ kind: 'remove', position: index, id }];
|
|
117
|
-
// A
|
|
118
|
-
if (refill && shape.limit !== null
|
|
131
|
+
// A window that WAS full is now one row short, and that row lives on the server.
|
|
132
|
+
if (refill && shape.limit !== null && held >= shape.limit) {
|
|
133
|
+
patches.push({ kind: 'refill', from: shape.limit - 1 });
|
|
134
|
+
}
|
|
119
135
|
return patches;
|
|
120
136
|
}
|
|
121
137
|
|
package/src/query.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { CacheTag } from '@ultimat3/cache';
|
|
10
10
|
import { tagKeys } from '@ultimat3/cache';
|
|
11
11
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
12
|
+
import { fingerprint } from '@ultimat3/core';
|
|
12
13
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
14
|
import type { QueryCacheScope } from './cache';
|
|
14
15
|
import type { QueryClientMethod, QueryClientOptions } from './client';
|
|
@@ -23,7 +24,6 @@ import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
|
23
24
|
import { policyCapability, policyPermissions } from './policy-gate';
|
|
24
25
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
25
26
|
import type { SqlSource } from './source';
|
|
26
|
-
import { fingerprint } from './stable';
|
|
27
27
|
|
|
28
28
|
export interface QueryCache {
|
|
29
29
|
/** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
|
package/src/source.ts
CHANGED
|
@@ -35,7 +35,15 @@ export interface SqlSource<TRow> {
|
|
|
35
35
|
seek?(after: SeekKey | null, limit: number): SqlSource<TRow>;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Rows, or a function that answers them. The function half is `readonly TRow[] | Promise<…>`
|
|
40
|
+
* because `execute()` **awaits** whatever it returns — declaring only the promise refused a
|
|
41
|
+
* synchronous provider the implementation has always accepted, which is a repo method that
|
|
42
|
+
* already has its page in hand, and every in-memory fixture.
|
|
43
|
+
*/
|
|
44
|
+
export type RowProvider<TRow> =
|
|
45
|
+
| readonly TRow[]
|
|
46
|
+
| (() => readonly TRow[] | Promise<readonly TRow[]>);
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
49
|
* In-memory reference source. `from<Post>('posts', rows).where({ orgId }).orderBy('createdAt')`
|
package/src/sql.ts
CHANGED
|
@@ -59,7 +59,9 @@ export async function describeSql(
|
|
|
59
59
|
const entries: QuerySqlInfo[] = [];
|
|
60
60
|
for (const target of queries) {
|
|
61
61
|
const name = queryName(target);
|
|
62
|
-
|
|
62
|
+
// `Object.hasOwn`, because `samples` is a caller's object literal and a name it never carried
|
|
63
|
+
// — `constructor`, `toString` — would otherwise resolve to a FUNCTION and be parsed as input.
|
|
64
|
+
const sample = Object.hasOwn(samples, name) ? samples[name] : undefined;
|
|
63
65
|
if (sample === undefined) {
|
|
64
66
|
entries.push({ query: name, live: target.isLive, sql: null });
|
|
65
67
|
continue;
|
package/src/stable.ts
CHANGED
|
@@ -1,65 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* What a JSON object IS to this package, and how a column is read off a row that declares no index
|
|
3
|
+
* signature. Two small predicates the read path needs everywhere.
|
|
4
|
+
*
|
|
5
|
+
* The deterministic-JSON half used to live here and no longer does: `canonicalJson` and
|
|
6
|
+
* `fingerprint` are `@ultimat3/core`'s, because `@ultimat3/action` and `@ultimat3/realtime` need
|
|
7
|
+
* the identical function and all three are tier 3 — so a copy in any of them was a second answer
|
|
8
|
+
* for the other two, and the copies had already diverged. This one rendered every `Date` as `{}`.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
8
12
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
export function stableStringify(value: unknown): string {
|
|
12
|
-
if (value === null) return 'null';
|
|
13
|
-
switch (typeof value) {
|
|
14
|
-
case 'string':
|
|
15
|
-
return JSON.stringify(value);
|
|
16
|
-
// A bare token, never `'null'` and never a quoted string: this output is only ever hashed, so
|
|
17
|
-
// an unquoted word cannot collide with the `string` branch (which always quotes) while
|
|
18
|
-
// `'null'` collided with JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }` and `{ n: null }`
|
|
19
|
-
// fingerprinted identically and shared one cache entry and one cursor scope. `-0` is spelled
|
|
20
|
-
// out for the same reason: `String(-0)` is `"0"`, so `-0` and `0` were one key too.
|
|
21
|
-
case 'number':
|
|
22
|
-
if (Number.isNaN(value)) return 'NaN';
|
|
23
|
-
if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
|
|
24
|
-
return Object.is(value, -0) ? '-0' : String(value);
|
|
25
|
-
case 'boolean':
|
|
26
|
-
return String(value);
|
|
27
|
-
case 'bigint':
|
|
28
|
-
return JSON.stringify(`${value}n`);
|
|
29
|
-
case 'undefined':
|
|
30
|
-
case 'function':
|
|
31
|
-
case 'symbol':
|
|
32
|
-
return 'null';
|
|
33
|
-
default:
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
37
|
-
const record = value as Record<string, unknown>;
|
|
38
|
-
const keys = Object.keys(record)
|
|
39
|
-
.filter((key) => record[key] !== undefined)
|
|
40
|
-
.sort();
|
|
41
|
-
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
|
42
|
-
return `{${entries.join(',')}}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* SHA-256, first 16 hex characters — the same primitive and the same width `@ultimat3/realtime`'s
|
|
47
|
-
* `stableDigest` and `@ultimat3/entity`'s `planScope` already chose, and for the same reason.
|
|
48
|
-
*
|
|
49
|
-
* A fingerprint is a SHARING key, not a checksum: it decides which read-cache entry two callers
|
|
50
|
-
* are served from and which scope a cursor is bound to, over input a client chooses. FNV-1a/32 —
|
|
51
|
-
* what this was — is 4x10^9 values, brute-forceable offline in seconds, so an attacker could mint
|
|
52
|
-
* an input that lands on another read's entry or another page's scope. It identifies, and here
|
|
53
|
-
* identifying IS the boundary.
|
|
54
|
-
*
|
|
55
|
-
* The canonical form above is unchanged, so the only thing that moved is the hash: a cursor issued
|
|
56
|
-
* before this fails its scope check as `X_CURSOR_INVALID` — cleanly, with "request the first page
|
|
57
|
-
* again" as its fix — and a warm read cache is cold once.
|
|
58
|
-
*/
|
|
59
|
-
export function fingerprint(value: unknown): string {
|
|
60
|
-
return new Bun.CryptoHasher('sha256').update(stableStringify(value)).digest('hex').slice(0, 16);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
15
|
/** Column read that works for interfaces without an index signature. */
|
|
64
16
|
export function columnOf(row: object, column: string): unknown {
|
|
65
17
|
const record: unknown = row;
|