@ultimat3/query 3.0.0 → 4.1.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 +57 -14
- package/README.md +14 -7
- package/package.json +6 -6
- package/src/cache.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
|
|
@@ -258,11 +277,23 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
258
277
|
`@ultimat3/http`. `tagKey` went with it: `serializeTag` under a second name, zero call sites.
|
|
259
278
|
`@ultimat3/render` exports a *different* function under the same name (declaration order kept);
|
|
260
279
|
never import that one here.
|
|
261
|
-
- **A fingerprint is an identity, so two different inputs may not share one
|
|
262
|
-
`
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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`.
|
|
266
297
|
- The cursor codec is `@ultimat3/core`'s (`encodeCursor` / `decodeCursor` / `configureCursorSigning`).
|
|
267
298
|
This package supplies only the scope a cursor is bound to — `queryHash(name, input)` — and never
|
|
268
299
|
signs, encodes or parses one itself. An unverified or foreign cursor is `X_CURSOR_INVALID`, thrown
|
|
@@ -289,19 +320,31 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
289
320
|
is the mechanism: declaring nothing gets the narrowest key. `'tenant'` and `'global'` are written
|
|
290
321
|
statements about the rows — the `unenforced:` shape one field over — and `'tenant'` with no
|
|
291
322
|
`orgId` narrows to the actor rather than widening to everyone, because nothing here can prove two
|
|
292
|
-
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
|
|
293
335
|
`@ultimat3/entity`'s `scopeKey` gives: an actor id is app data and may carry the separator.
|
|
294
336
|
- **`cache.ttlMs` is judged at `query()`, not on the first read.** Every `CacheTier` refuses a
|
|
295
337
|
lease that is not positive and finite (`assertTtl`), and the read tier's one catch absorbs
|
|
296
338
|
`X_CACHE_TOO_LARGE` only — so `ttlMs: Infinity` turned a typo into a permanently failing business
|
|
297
339
|
read whose cause named a cache key. `X_QUERY_CACHE_TTL_INVALID`, on the line that wrote it. It
|
|
298
340
|
restates `assertTtl`'s bar as a refusal and never as a second resolution.
|
|
299
|
-
- **`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
|
|
300
342
|
SHARING key over client-chosen input — which read-cache entry two callers are served from, which
|
|
301
343
|
scope a cursor is bound to — so FNV-1a/32's 4×10⁹ values are a collision found offline in
|
|
302
|
-
seconds.
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
305
348
|
- **A fill is FENCED, and the fence is `@ultimat3/cache`'s** (`As of 2026-08`). `run()` answers with
|
|
306
349
|
rows it read in the past: a mutator committing in between busts a key not yet in the tier, so the
|
|
307
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.1.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.1.0",
|
|
35
|
+
"@ultimat3/core": "4.1.0",
|
|
36
|
+
"@ultimat3/http": "4.1.0",
|
|
37
|
+
"@ultimat3/policy": "4.1.0",
|
|
38
|
+
"@ultimat3/schema": "4.1.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/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;
|