@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 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: one case per `ColumnKind`, each asserting the order Postgres
217
- returns, plus the NULL rule and the cursor round trip. The kind list is spelled out rather than
218
- imported `tsconfig.json` excludes `*.test.ts`, so a `satisfies Record<ColumnKind, …>` written
219
- in a test is a type assertion `tsc` never reads; a runtime count is the enforceable half.
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** (`stable.ts`).
251
- `NaN`, `±Infinity` and JSON `null` all encoded as `'null'`, and `String(-0)` is `"0"` — so four
252
- distinct inputs shared one read-cache entry and one cursor scope. They are bare tokens now
253
- (`NaN`, `Infinity`, `-Infinity`, `-0`), which the `string` branch cannot spell because it always
254
- quotes. Ordinary numbers are byte-identical, so no existing cursor scope moved.
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. The authority is JSON, never a joined string, for the reason
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** (`stable.ts`, `As of 2026-08`). It is a
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. Same primitive and width as `@ultimat3/realtime`'s `stableDigest`. `stableStringify` did
292
- not move, so the only cost is one cold cache and every open cursor answering `X_CURSOR_INVALID`
293
- with its own "request the first page again" fix.
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** (`fingerprint` in `stable.ts`), the primitive and
147
- width `@ultimat3/realtime`'s `stableDigest` and `@ultimat3/entity`'s `planScope` already use. It
148
- was FNV-1a/32 until 2026-08 4×10⁹ values over input a client chooses, brute-forceable offline in
149
- seconds, and a fingerprint here is a *sharing* key: which read-cache entry two callers are served
150
- from, and which scope a cursor is bound to. The canonical serialization did not change, only the
151
- hash, so a cursor issued before it fails its scope check as `X_CURSOR_INVALID` with "request the
152
- first page again" as its fix, and a warm read cache is cold once.
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": "2.0.0",
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": "2.0.0",
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/http": "2.0.0",
37
- "@ultimat3/policy": "2.0.0",
38
- "@ultimat3/schema": "2.0.0"
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
- return actor.orgId === undefined || actor.orgId === ''
71
- ? actorAuthority(actor)
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 [...removeAt<TRow>(shape, index, id, false), ...insert(shape, without, event.row)];
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 limited window may now be one row short, and the tail lives on the server.
118
- if (refill && shape.limit !== null) patches.push({ kind: 'refill', from: shape.limit - 1 });
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
- export type RowProvider<TRow> = readonly TRow[] | (() => Promise<readonly TRow[]>);
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
- const sample = samples[name];
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
- * Deterministic JSON plus a content hash. Query hashes, cursor payloads and
3
- * cache keys all need byte-stable serialization, so nothing here may depend on
4
- * key insertion order.
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;