@ultimat3/realtime 11.3.0 → 12.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
@@ -604,8 +604,10 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
604
604
  `liveQueryDefinition` keys the shared window by the qid — so keeping both correct was never the
605
605
  option; the first time either moved, every resume decision and every window lookup were keyed
606
606
  differently. `realtime -> query` is the one declared sideways edge and this package already
607
- imports it. **`fnv1a` stays here**, and only it: its job is the cursor's result-set digest, where
608
- a collision costs a missed re-sort and never one client served out of another's window.
607
+ imports it. **`fnv1a` is gone too, `As of 2026-08-24`**: it stayed for one job the cursor's
608
+ result-set digest and `LiveCursor.digest` was deleted for having no reader, so this package now
609
+ owns no hash at all. A 32-bit hash nothing calls is one the next caller reaches for as a sharing
610
+ key, which is the single thing `json.test.ts` used to pin it against.
609
611
  `live-contract.test.ts` is the pin — it reads `registry.subscriberCount(queryHash(name, input))`
610
612
  through a real subscribe, so a local derivation fails it. Cost of the move: none observable on the
611
613
  server. Every qid a node computes comes from a DECODED frame, and `JSON.parse` produces no
@@ -821,7 +823,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
821
823
  | `policy-gate.ts` | the only authz seam |
822
824
  | `subscriber-gate.ts` | the per-subscriber pass of a definition's row policy, and its two counters — `rowsDenied` and `gateFailures`. Evaluates no policy of its own |
823
825
  | `live-contract.ts` | what a live query IS: `LiveQueryDefinition`, `SnapshotResult`, `LiveSubscription`. Four modules need the shape and none of them needs the registry that runs it. The **id** is not here and not anywhere in this package — it is `@ultimat3/query`'s `queryHash` |
824
- | `json.ts` | the wire's value types and `fnv1a` (drift), the one hash still owned here. The canonical form and the sharing-key hash are `@ultimat3/core`'s (`canonicalJson`, `fingerprint`) `json.test.ts` pins that `fnv1a` is never mistakable for one |
826
+ | `json.ts` | the wire's value types. **No hash**: the canonical form and the sharing-key hash are `@ultimat3/core`'s (`canonicalJson`, `fingerprint`), and the 32-bit `fnv1a` that stayed for `LiveCursor.digest` went with it (2026-08-24) |
825
827
  | `live-definition.ts` | the only bridge from a declared `query({ live: true })` to a registrable definition — and `policy-gate.ts`'s only caller |
826
828
  | `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam — and where a patch row is narrowed to the columns the query returned |
827
829
 
@@ -847,6 +849,16 @@ field always held. Bumping for either refuses every in-flight client on a rollin
847
849
  nothing — the version guards incompatibility, not novelty. Removing a field something *does* read
848
850
  is the opposite case and bumps.
849
851
 
852
+ **"Nothing read it" is decided by the DECODER, not by the callers — and that half is what moved
853
+ `PROTOCOL_VERSION` to 2 (2026-08-24, BREAKING).** `hello.resume` was free because `decode` read it
854
+ through `list()`, which answers `[]` for an absent field; `cursor.digest` and `cursor.count` were
855
+ read through `str()` and `num()`, which **throw**. So deleting two fields no *caller* consumed
856
+ still made the frame unreadable to a peer one deploy behind — in **both** directions, since a
857
+ cursor rides the client's `subscribe` and the node's `snapshot`. Without the bump the skew shows up
858
+ as a per-frame `field "digest" must be a string`, which is the same refusal with none of the
859
+ instruction. Before claiming a removal is free, read the field's line in `decode`: a `list()` is
860
+ free, a `str()`/`num()` is a bump.
861
+
850
862
  **A patch carries the result set's columns, never the table's** — `narrowRow` in
851
863
  `matcher-bridge.ts`, `As of 2026-08-20`. A `ChangeEvent` carries the whole TABLE row (that is what
852
864
  logical replication emits, and what `@ultimat3/entity`'s `setRowObserver` emits), while a live
package/README.md CHANGED
@@ -269,9 +269,11 @@ that outlasts the deploy. The design confronts it with exactly two paths and no
269
269
  | **delta** | the cursor's gap is inside the retained window and inside the budget | one buffer read, zero DB work |
270
270
  | **snapshot** | out of window, past `maxLagMs`, or past `reconnectBudget` | one bounded indexed query |
271
271
 
272
- A `LiveCursor` is `lsn` + result-set `digest` + last-seen `ids` + `count`. The digest is
273
- order-sensitive and server-side (see `cursor.ts#digestOf` for why a client cannot reproduce one); the ids let a delta be re-filtered per subscriber, because
274
- the retained window stores **pre-policy** patches. `resumeFrom()` picks the path,
272
+ A `LiveCursor` is `qid` + `lsn` + last-seen `ids` + `at`, and nothing else. The ids let a delta be
273
+ re-filtered per subscriber, because the retained window stores **pre-policy** patches. It carried a
274
+ result-set `digest` and a `count` until 2026-08-24; both were written by every snapshot and read by
275
+ nobody, and the digest cost a canonical serialize plus a hash over every row of every snapshot —
276
+ paid once per live query per reconnecting socket. `resumeFrom()` picks the path,
275
277
  `shouldResnapshot()` explains it, and the budget is a cost model in patch-equivalents
276
278
  (`snapshotCost: 250` = "replaying 250 patches costs a snapshot") so the expensive path is *chosen*,
277
279
  never stumbled into.
@@ -361,10 +363,11 @@ wire twice by a reconnect that raced an ack.
361
363
  the rebase log rather than retried: a denial is a decision about that intent, and replaying it
362
364
  would put the write the server refused back on the screen. Idempotent for a key the log does not
363
365
  hold, because a denial can arrive twice and tier 2 records nothing to undo.
364
- - **A delta resume leaves the digest unverified** (`DIGEST_UNVERIFIED`). Only a snapshot re-establishes
365
- it. The digest is the SERVER's own nothing on the client reproduces it, and `verifyDigest()`,
366
- which claimed otherwise and had no caller, is deleted (2026-08-23). What detects drift on the
367
- client is the server's `desynced` mark and the re-snapshot it triggers.
366
+ - **Nothing on the client detects drift, and nothing ever did.** `verifyDigest()` claimed to and had
367
+ no caller (deleted 2026-08-23); the `digest` it read went with it (2026-08-24), along with the
368
+ `count` beside it. What detects drift is the server's `desynced` mark and the re-snapshot it
369
+ triggers. Removing the two fields moved `PROTOCOL_VERSION` to **2** `cursor()` decodes through
370
+ readers that throw on an absent field, unlike the `list()` that made `hello.resume`'s removal free.
368
371
  - **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
369
372
  is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
370
373
  - **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "11.3.0",
3
+ "version": "12.0.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,8 +36,8 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/core": "11.3.0",
40
- "@ultimat3/query": "11.3.0",
39
+ "@ultimat3/core": "12.0.0",
40
+ "@ultimat3/query": "12.0.0",
41
41
  "nats": "2.29.3"
42
42
  }
43
43
  }
package/src/cursor.ts CHANGED
@@ -3,26 +3,28 @@
3
3
  // and the budget exists to make the expensive answer (a snapshot) the *chosen* one, not the
4
4
  // accidental one. See README "Reconnect is the hard part".
5
5
 
6
- import { type Clock, canonicalJson, systemClock } from '@ultimat3/core';
6
+ import { type Clock, systemClock } from '@ultimat3/core';
7
7
  import { CursorStaleError } from './errors';
8
- import { fnv1a, type Row, type RowPatch } from './json';
8
+ import type { Row, RowPatch } from './json';
9
9
 
10
10
  /** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
11
11
  export const CURSOR_ID_LIMIT = 512;
12
12
 
13
- /** A digest of `''` means "not verified at this lsn" — set on every delta resume. */
14
- export const DIGEST_UNVERIFIED = '';
15
-
13
+ /**
14
+ * What a resume decides from, and nothing else. `digest` and `count` were here until 2026-08-24
15
+ * and were written by every snapshot, encoded and validated on every `subscribe` frame, and read
16
+ * by nothing — the same shape `verifyDigest()` was deleted for one release earlier, one field
17
+ * down. `digest` cost a `canonicalJson` plus a hash over EVERY ROW of every snapshot, paid once
18
+ * per live query per reconnecting socket in the restart storm this package is measured on; and
19
+ * `count` would have been wrong had it ever gained a reader, because `advance` seeds its set from
20
+ * the already-truncated `ids`, so a delete of a row past `CURSOR_ID_LIMIT` never decremented it.
21
+ */
16
22
  export interface LiveCursor {
17
23
  readonly qid: string;
18
24
  /** Last lsn the subscriber has applied. Lexicographically comparable (see `formatLsn`). */
19
25
  readonly lsn: string;
20
- /** Result-set digest at the last snapshot, or `DIGEST_UNVERIFIED` after a delta resume. */
21
- readonly digest: string;
22
26
  /** Last-seen row ids, truncated at `CURSOR_ID_LIMIT`. */
23
27
  readonly ids: readonly string[];
24
- /** Number of rows in the result set — survives id truncation. */
25
- readonly count: number;
26
28
  readonly at: number;
27
29
  }
28
30
 
@@ -53,8 +55,7 @@ export type ResumeReason =
53
55
  | 'unknown-query'
54
56
  | 'out-of-window'
55
57
  | 'lag-exceeded'
56
- | 'budget-exceeded'
57
- | 'digest-unverified';
58
+ | 'budget-exceeded';
58
59
 
59
60
  export interface ResumeDecision {
60
61
  readonly resnapshot: boolean;
@@ -98,30 +99,11 @@ export function makeCursor(
98
99
  return {
99
100
  qid,
100
101
  lsn,
101
- digest: digestOf(rows),
102
102
  ids: rows.slice(0, CURSOR_ID_LIMIT).map((r) => r.id),
103
- count: rows.length,
104
103
  at: now,
105
104
  };
106
105
  }
107
106
 
108
- /**
109
- * FNV-1a over `id:row` pairs in result order — order-sensitive, so a re-sort is detected.
110
- *
111
- * **Server-side only, and it is not reproducible by a client.** `verifyDigest()` used to sit here
112
- * and was DELETED (2026-08-23): it was documented as "how a client detects drift", had no caller
113
- * outside its own test, and could not have had one. Three reasons, any one of them fatal.
114
- * `canonicalJson` tags a `Date` as `Date(<epoch>)` while the client holds the ISO string
115
- * `JSON.stringify` sent it. A delta-resumed cursor carries `DIGEST_UNVERIFIED`, so the check
116
- * answered `false` for every cursor a delta produced — which is the only state drift can be
117
- * detected in. And `identity-map.ts` MERGES columns across queries on purpose, so a client's row
118
- * for one id is legitimately a superset of the row any single snapshot sent: an app with two reads
119
- * over one entity would have reported permanent drift.
120
- */
121
- export function digestOf(rows: readonly Row[]): string {
122
- return fnv1a(rows.map((row) => `${row.id}:${canonicalJson(row)}`).join(';'));
123
- }
124
-
125
107
  export function shouldResnapshot(
126
108
  cursor: LiveCursor,
127
109
  available: readonly RowPatch[] | null,
@@ -172,7 +154,7 @@ export async function resumeFrom<R extends Row = Row>(
172
154
  };
173
155
  }
174
156
 
175
- /** Advance a cursor across a delta without re-reading rows: ids are patchable, the digest is not. */
157
+ /** Advance a cursor across a delta without re-reading rows: ids are patchable. */
176
158
  export function advance(
177
159
  cursor: LiveCursor,
178
160
  patches: readonly RowPatch[],
@@ -180,21 +162,14 @@ export function advance(
180
162
  now: number,
181
163
  ): LiveCursor {
182
164
  const ids = new Set(cursor.ids);
183
- let count = cursor.count;
184
165
  for (const patch of patches) {
185
- if (patch.op === 'delete') {
186
- if (ids.delete(patch.id)) count -= 1;
187
- } else if (patch.op === 'insert' && !ids.has(patch.id)) {
188
- ids.add(patch.id);
189
- count += 1;
190
- }
166
+ if (patch.op === 'delete') ids.delete(patch.id);
167
+ else if (patch.op === 'insert') ids.add(patch.id);
191
168
  }
192
169
  return {
193
170
  qid: cursor.qid,
194
171
  lsn,
195
- digest: DIGEST_UNVERIFIED,
196
172
  ids: [...ids].slice(0, CURSOR_ID_LIMIT),
197
- count,
198
173
  at: now,
199
174
  };
200
175
  }
package/src/index.ts CHANGED
@@ -22,9 +22,7 @@ export {
22
22
  export {
23
23
  advance,
24
24
  CURSOR_ID_LIMIT,
25
- DIGEST_UNVERIFIED,
26
25
  defaultReconnectBudget,
27
- digestOf,
28
26
  type LiveCursor,
29
27
  makeCursor,
30
28
  type ReconnectBudget,
@@ -91,7 +89,6 @@ export {
91
89
  // ---- shared value domain ---------------------------------------------------------------------
92
90
  export {
93
91
  changedColumns,
94
- fnv1a,
95
92
  isJsonObject,
96
93
  isRow,
97
94
  type JsonObject,
package/src/json.ts CHANGED
@@ -1,11 +1,12 @@
1
- // The JSON value domain shared by the wire, the matcher, and the local store, plus `fnv1a` — the
2
- // one hash this package still owns.
1
+ // The JSON value domain shared by the wire, the matcher, and the local store. This package now
2
+ // owns NO hash at all.
3
3
  //
4
4
  // `canonicalJson` and `stableDigest` USED to live here and are `@ultimat3/core`'s now: they were a
5
5
  // third copy of one injective canonical form and one sharing-key hash, beside `@ultimat3/action`'s
6
- // and `@ultimat3/query`'s, and the copies had already diverged. `fnv1a` stays because its job is
7
- // genuinely different — it is a cursor's result-set digest, where a collision costs a missed
8
- // re-sort and never one client served out of another's window.
6
+ // and `@ultimat3/query`'s, and the copies had already diverged. `fnv1a` stayed for one job — a
7
+ // cursor's result-set digest and went with it (2026-08-24) when `LiveCursor.digest` was deleted
8
+ // for having no reader. A 32-bit hash nothing calls is a hash the next caller reaches for as a
9
+ // sharing key, which is the one thing it must never be.
9
10
 
10
11
  export type JsonValue =
11
12
  | string
@@ -55,16 +56,6 @@ export function changedColumns(before: JsonObject | null, after: JsonObject): Js
55
56
  return out;
56
57
  }
57
58
 
58
- /** FNV-1a, 32-bit, hex. Not cryptographic — it identifies and detects drift, it does not protect. */
59
- export function fnv1a(text: string): string {
60
- let hash = 0x811c9dc5;
61
- for (let i = 0; i < text.length; i += 1) {
62
- hash ^= text.charCodeAt(i);
63
- hash = Math.imul(hash, 0x01000193) >>> 0;
64
- }
65
- return hash.toString(16).padStart(8, '0');
66
- }
67
-
68
59
  function sameJson(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
69
60
  if (a === b) return true;
70
61
  if (a === undefined || b === undefined || a === null || b === null) return false;
@@ -14,7 +14,17 @@ import {
14
14
  type RowPatch,
15
15
  } from './json';
16
16
 
17
- export const PROTOCOL_VERSION = 1;
17
+ /**
18
+ * **2 since 2026-08-24**, when `cursor.digest` and `cursor.count` were deleted. The version guards
19
+ * incompatibility, never novelty — an additive optional field (`snapshot.entity`) and a removed
20
+ * field read through `list()` (`hello.resume`) both stayed at 1, because `decode` is a whitelist
21
+ * and `list()` answers `[]` for an absent field. `cursor()` is the other kind of reader: it reads
22
+ * through `str`/`num`, which THROW on an absent field, so a cursor without those two is a frame a
23
+ * node or a client one deploy behind cannot read — in BOTH directions, since a cursor rides the
24
+ * client's `subscribe` and the node's `snapshot`. That is exactly what this number refuses, with
25
+ * one instruction instead of a per-frame "field \"digest\" must be a string".
26
+ */
27
+ export const PROTOCOL_VERSION = 2;
18
28
 
19
29
  /**
20
30
  * What one frame may contain. Hard ceilings a caller cannot widen — the shape
@@ -410,12 +420,10 @@ function cursor(value: unknown): LiveCursor {
410
420
  return {
411
421
  qid: str(value, 'qid'),
412
422
  lsn: str(value, 'lsn'),
413
- digest: str(value, 'digest'),
414
423
  ids: list(value, 'ids', FRAME_LIMITS.cursorIds, 'cursor.ids').map((id) => {
415
424
  if (typeof id !== 'string') throw fail('cursor.ids must be strings');
416
425
  return id;
417
426
  }),
418
- count: num(value, 'count'),
419
427
  at: num(value, 'at'),
420
428
  };
421
429
  }