@ultimat3/realtime 11.3.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +15 -3
- package/README.md +10 -7
- package/package.json +3 -3
- package/src/cursor.ts +15 -40
- package/src/index.ts +0 -3
- package/src/json.ts +6 -15
- package/src/sync-protocol.ts +11 -3
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`
|
|
608
|
-
|
|
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
|
|
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 `
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
- **
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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": "
|
|
3
|
+
"version": "13.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": "
|
|
40
|
-
"@ultimat3/query": "
|
|
39
|
+
"@ultimat3/core": "13.0.0",
|
|
40
|
+
"@ultimat3/query": "13.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,
|
|
6
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
7
|
import { CursorStaleError } from './errors';
|
|
8
|
-
import {
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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`
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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;
|
package/src/sync-protocol.ts
CHANGED
|
@@ -14,7 +14,17 @@ import {
|
|
|
14
14
|
type RowPatch,
|
|
15
15
|
} from './json';
|
|
16
16
|
|
|
17
|
-
|
|
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
|
}
|