@ultimat3/realtime 20.2.1 → 22.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.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,364 @@
1
+ // The page's ONE record store — Ember Data's shape: one record per `type:key`, shown in N places,
2
+ // updated once. Two layers: SYNCED is server truth (HTTP envelopes, socket frames), and the
3
+ // OVERLAY is every pending optimistic write, replayed over synced truth on every change. It is
4
+ // core's `RecordSink`, so `clientTransport` adopts into it without importing this package.
5
+
6
+ import type { ConflictPolicy, RecordRows, RecordSink, Row } from '@ultimat3/core/page';
7
+ import { resolveConflict } from '@ultimat3/core/page';
8
+ import { RebaseConflictError, RecordRejectedError } from './page-errors';
9
+ import { ServerWait } from './record-await';
10
+ import { isRow, type RecordKey, recordKey } from './record-key';
11
+ import { WriteNames } from './record-names';
12
+ import { SyncedLayer } from './record-synced';
13
+ import { type OverlayEntry, replayOverlays, sameRow } from './record-tx';
14
+ import type { Scheduler } from './thundering-herd';
15
+
16
+ // The store's own names for a record, kept importable from here: every caller names a record
17
+ // through the store it reads.
18
+ export { DEFAULT_AWAIT_SERVER_MS } from './record-await';
19
+ export { carriedBy, type RecordKey, recordKey } from './record-key';
20
+
21
+ export type RecordListener = (changed: ReadonlySet<RecordKey>) => void;
22
+
23
+ export interface RecordStoreOptions {
24
+ /** Where a rejected row is reported. Browser code: `console.error`, never core's `logger`. */
25
+ readonly report?: (error: unknown) => void;
26
+ /** How an overlay's wait for server truth is bounded. Defaults to `setTimeout`; tests inject. */
27
+ readonly schedule?: Scheduler;
28
+ /** How long an overlay waits for the server's row before the synced layer stands. */
29
+ readonly awaitMs?: number;
30
+ }
31
+
32
+ export class RecordStore implements RecordSink {
33
+ readonly #synced = new SyncedLayer();
34
+ /** The overlay's answer per key: a row, or `null` for a record the overlay deleted. */
35
+ #view = new Map<RecordKey, Row | null>();
36
+ readonly #overlays = new Map<string, OverlayEntry>();
37
+ /** Which keys each pending overlay wrote on its last replay — what a settle resolves over. */
38
+ #touched = new Map<string, ReadonlySet<RecordKey>>();
39
+ readonly #listeners = new Set<RecordListener>();
40
+ readonly #report: (error: unknown) => void;
41
+ #changed: Set<RecordKey> | null = null;
42
+ #syncedMoved = false;
43
+ /** Synced keys moved in the open batch — what an overlay awaiting server truth is checked against. */
44
+ readonly #moved = new Set<RecordKey>();
45
+ readonly #wait: ServerWait;
46
+ /** Which pending overlay each write digest names — what a frame's `write` is looked up in. */
47
+ readonly #names = new WriteNames();
48
+
49
+ constructor(options: RecordStoreOptions = {}) {
50
+ this.#report = options.report ?? ((error) => console.error(error));
51
+ this.#wait = new ServerWait(options.schedule, options.awaitMs);
52
+ }
53
+
54
+ /** The record as every holder sees it: the overlay's answer over synced truth. */
55
+ peek(type: string, key: string): Row | undefined {
56
+ const rk = recordKey(type, key);
57
+ const overlaid = this.#view.get(rk);
58
+ if (overlaid !== undefined) return overlaid ?? undefined;
59
+ return this.#synced.get(rk);
60
+ }
61
+
62
+ /** Every record of one type, as seen — what a mutator's `tx.<type>.all()` walks. */
63
+ all(type: string): readonly Row[] {
64
+ const prefix = `${type}:`;
65
+ const out: Row[] = [];
66
+ const seen = new Set<RecordKey>();
67
+ for (const [rk, row] of this.#view) {
68
+ if (!rk.startsWith(prefix)) continue;
69
+ seen.add(rk);
70
+ if (row !== null) out.push(row);
71
+ }
72
+ for (const [rk, row] of this.#synced.entries()) {
73
+ if (rk.startsWith(prefix) && !seen.has(rk)) out.push(row);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * `RecordSink.adopt`: server truth, keyed by the server (it holds the entity's primary key; the
80
+ * browser does not). Merged column by column. A structurally bad row is dropped, never merged.
81
+ */
82
+ adopt(type: string, rows: RecordRows): void {
83
+ this.batch(() => {
84
+ for (const [key, row] of Object.entries(rows)) {
85
+ if (!isRow(row) || key === '') {
86
+ this.#report(
87
+ new RecordRejectedError({
88
+ type,
89
+ reason: key === '' ? 'it arrived under an empty key' : 'it is not an object',
90
+ }),
91
+ );
92
+ continue;
93
+ }
94
+ this.merge(type, key, row);
95
+ }
96
+ });
97
+ }
98
+
99
+ /** `RecordSink.remove`: the server says these records are gone, for every holder at once. */
100
+ remove(type: string, keys: readonly string[]): void {
101
+ this.batch(() => {
102
+ for (const key of keys) {
103
+ const rk = recordKey(type, key);
104
+ if (this.#synced.remove(rk)) this.#touchSynced(rk);
105
+ }
106
+ });
107
+ }
108
+
109
+ /** The server's row alone — no overlay, no notification. What the persister writes to disk. */
110
+ synced(type: string, key: string): Row | undefined {
111
+ return this.#synced.get(recordKey(type, key));
112
+ }
113
+
114
+ /**
115
+ * Rows put back from disk before the socket connects. Inserted only where the server has not
116
+ * already answered, and PROVISIONAL: stale until confirmed, so the first server row for that key
117
+ * replaces it outright rather than merging a column the server has since dropped.
118
+ */
119
+ restore(type: string, rows: RecordRows): void {
120
+ this.batch(() => {
121
+ for (const [key, row] of Object.entries(rows)) {
122
+ const rk = recordKey(type, key);
123
+ if (!isRow(row) || key === '') continue;
124
+ if (this.#synced.restore(rk, row)) this.#touchSynced(rk);
125
+ }
126
+ });
127
+ }
128
+
129
+ /** One server write, merged column by column (`SyncedLayer.merge`). */
130
+ merge(type: string, key: string, columns: Row): Row {
131
+ const rk = recordKey(type, key);
132
+ const { row, changed } = this.#synced.merge(rk, columns);
133
+ if (changed) {
134
+ this.#touchSynced(rk);
135
+ } else if (this.#overlays.size > 0) {
136
+ // Unchanged is still an answer: an overlay awaiting server truth for this row — or still in
137
+ // flight, and about to — gets it here.
138
+ this.batch(() => {
139
+ this.#moved.add(rk);
140
+ this.#syncedMoved = true;
141
+ });
142
+ }
143
+ return row;
144
+ }
145
+
146
+ retain(type: string, key: string): void {
147
+ this.#synced.retain(recordKey(type, key));
148
+ }
149
+
150
+ /** The last holder leaving evicts the synced record: an infinite scroll must not keep every row. */
151
+ release(type: string, key: string): void {
152
+ const rk = recordKey(type, key);
153
+ if (this.#synced.release(rk)) this.#touchSynced(rk);
154
+ }
155
+
156
+ /**
157
+ * An optimistic write, visible to every holder before this returns. `apply` is the mutator's
158
+ * pure `local` half: it is REPLAYED over synced truth whenever synced truth moves, which is what
159
+ * makes two pending writes on one row land in order over a server update.
160
+ */
161
+ push(key: string, apply: OverlayEntry['apply'], policy: ConflictPolicy): void {
162
+ this.batch(() => {
163
+ this.#overlays.set(key, { key, apply, policy });
164
+ this.#replay();
165
+ });
166
+ this.#names.name(key, (named) => this.#overlays.has(named));
167
+ }
168
+
169
+ /** Take the write back — the server refused it. Everything behind it replays without it. */
170
+ drop(key: string): void {
171
+ this.#wait.forget(key);
172
+ this.batch(() => {
173
+ if (this.#forget(key)) this.#replay();
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The server took the write, and its records were adopted BEFORE this runs (the transport adopts
179
+ * inside the call), so the overlay is dropped over truth that already carries it: no flicker.
180
+ * A non-default policy decides each record the write touched, local view against server row.
181
+ *
182
+ * `carried` is the answer's own records. A row the write touched that the answer did NOT carry
183
+ * (an action that returns a view, not the entity) has no server truth yet: dropping the overlay
184
+ * would show the pre-write value until a frame arrives. So the overlay waits for that row's next
185
+ * server write, bounded by `awaitMs`, after which the synced layer stands — unless the server
186
+ * already reached it while the write was in flight.
187
+ */
188
+ settle(key: string, carried?: ReadonlySet<RecordKey>): void {
189
+ const entry = this.#overlays.get(key);
190
+ if (entry === undefined) return;
191
+ this.batch(() => {
192
+ const touched = this.#touched.get(key) ?? new Set<RecordKey>();
193
+ if (entry.policy !== 'server-wins') {
194
+ for (const rk of touched)
195
+ if (carried === undefined || carried.has(rk)) this.#resolve(entry, rk);
196
+ }
197
+ const heard = this.#wait.takeHeard(key);
198
+ const missing =
199
+ carried === undefined
200
+ ? []
201
+ : [...touched].filter((rk) => !carried.has(rk) && heard?.has(rk) !== true);
202
+ if (missing.length > 0) {
203
+ // What the server did answer holds this write already: the twin stays on the rest only.
204
+ const answered = [...touched].filter((rk) => !missing.includes(rk));
205
+ if (answered.length > 0) {
206
+ const confirmed = new Set([...(entry.confirmed ?? []), ...answered]);
207
+ this.#overlays.set(key, { ...entry, confirmed });
208
+ this.#replay();
209
+ }
210
+ this.#wait.start(key, new Set(missing), () => this.drop(key));
211
+ return;
212
+ }
213
+ this.#forget(key);
214
+ this.#replay();
215
+ });
216
+ }
217
+
218
+ /**
219
+ * A `records` frame's rows, merged in the OPEN batch, named the write `digest`. When that is a
220
+ * write this page still holds, it is settled here against the rows the frame carried — in the
221
+ * same notification as the merge, so no holder ever sees the write's own row with its twin
222
+ * replayed over it (a like counted twice). A digest this page never pushed, or one already
223
+ * settled, changes nothing: the rows are server truth under whatever is still pending.
224
+ */
225
+ settleWrite(digest: string, carried: ReadonlySet<RecordKey>): void {
226
+ const key = this.#names.keyOf(digest);
227
+ if (key === undefined || !this.#overlays.has(key)) return;
228
+ this.batch(() => {
229
+ // The view the settle resolves a custom policy against is the overlay over the NEW truth —
230
+ // what an HTTP answer's settle sees too, because its adopt closed a batch first.
231
+ if (this.#syncedMoved) this.#replay();
232
+ this.settle(key, carried);
233
+ });
234
+ }
235
+
236
+ /**
237
+ * The write's answer was unreadable (a 2xx that is not JSON): it MAY have landed. Its overlay
238
+ * stays on screen until server truth next reaches a row it wrote — then the server's row stands.
239
+ * Dropping it now could flash the pre-write value over a write that did land.
240
+ */
241
+ awaitServer(key: string): void {
242
+ if (!this.#overlays.has(key)) return;
243
+ this.#wait.start(key, this.#touched.get(key) ?? new Set(), () => this.drop(key));
244
+ }
245
+
246
+ /** Pending optimistic writes, oldest first. */
247
+ pending(): readonly string[] {
248
+ return [...this.#overlays.keys()];
249
+ }
250
+
251
+ /** A principal change: nothing of the previous one survives in memory. */
252
+ clear(): void {
253
+ this.batch(() => {
254
+ for (const rk of this.#synced.keys()) this.#touch(rk);
255
+ for (const rk of this.#view.keys()) this.#touch(rk);
256
+ this.#synced.clear();
257
+ this.#overlays.clear();
258
+ this.#names.clear();
259
+ this.#wait.clear();
260
+ this.#view = new Map();
261
+ this.#touched = new Map();
262
+ });
263
+ }
264
+
265
+ /** Every write inside `fn` is one notification — one frame, one response, one render. */
266
+ batch<T>(fn: () => T): T {
267
+ if (this.#changed !== null) return fn();
268
+ const collected = new Set<RecordKey>();
269
+ this.#changed = collected;
270
+ try {
271
+ return fn();
272
+ } finally {
273
+ // Synced truth moved under pending writes: replay them once, at the end, over the new truth.
274
+ if (this.#syncedMoved) {
275
+ this.#syncedMoved = false;
276
+ // A row put back from disk is a guess, not the server reaching it.
277
+ this.#wait.hear(
278
+ [...this.#overlays.keys()].map((key) => [key, this.#touched.get(key) ?? new Set()]),
279
+ (rk) => this.#moved.has(rk) && !this.#synced.isProvisional(rk),
280
+ );
281
+ const answered = this.#wait.resolve((key) => this.#overlays.has(key), this.#moved);
282
+ for (const key of answered) this.#forget(key);
283
+ if (this.#overlays.size > 0 || answered.length > 0) this.#replay();
284
+ }
285
+ this.#moved.clear();
286
+ this.#changed = null;
287
+ if (collected.size > 0) for (const listener of this.#listeners) listener(collected);
288
+ }
289
+ }
290
+
291
+ subscribe(listener: RecordListener): () => void {
292
+ this.#listeners.add(listener);
293
+ return () => {
294
+ this.#listeners.delete(listener);
295
+ };
296
+ }
297
+
298
+ /** Synced records held. Tests assert on it; nothing branches on a count. */
299
+ get size(): number {
300
+ return this.#synced.size;
301
+ }
302
+
303
+ #resolve(entry: OverlayEntry, rk: RecordKey): void {
304
+ const local = this.#view.get(rk);
305
+ const server = this.#synced.get(rk);
306
+ // A delete on either side leaves nothing to merge: the server's answer stands.
307
+ if (local === undefined || local === null || server === undefined) return;
308
+ const kept = resolveConflict(entry.policy, local, server);
309
+ if (kept === server) return;
310
+ if (!isRow(kept)) {
311
+ throw new RebaseConflictError({
312
+ key: entry.key,
313
+ entity: rk.slice(0, rk.indexOf(':')),
314
+ reason: 'the custom merge returned something other than a row',
315
+ });
316
+ }
317
+ this.#synced.set(rk, kept);
318
+ this.#touch(rk);
319
+ }
320
+
321
+ #replay(): void {
322
+ const previous = this.#view;
323
+ const result = replayOverlays(
324
+ [...this.#overlays.values()],
325
+ (rk) => this.#synced.get(rk),
326
+ (type) => this.#synced.ofType(type),
327
+ (error) => this.#report(error),
328
+ );
329
+ for (const failed of result.failed) {
330
+ this.#forget(failed);
331
+ this.#wait.unhear(failed);
332
+ }
333
+ const next = new Map<RecordKey, Row | null>();
334
+ for (const [rk, row] of result.view) {
335
+ const before = previous.get(rk);
336
+ // Same content keeps the same object: a replay that changed nothing re-renders nothing.
337
+ next.set(rk, before !== undefined && sameRow(before, row) ? before : row);
338
+ if (before === undefined || !sameRow(before, row)) this.#touch(rk);
339
+ }
340
+ for (const rk of previous.keys()) if (!next.has(rk)) this.#touch(rk);
341
+ this.#view = next;
342
+ this.#touched = result.touched;
343
+ }
344
+
345
+ /** The one way an overlay leaves: its entry and the digest that named it go together. */
346
+ #forget(key: string): boolean {
347
+ this.#names.forget(key);
348
+ return this.#overlays.delete(key);
349
+ }
350
+
351
+ #touchSynced(rk: RecordKey): void {
352
+ this.#syncedMoved = true;
353
+ this.#moved.add(rk);
354
+ this.#touch(rk);
355
+ }
356
+
357
+ #touch(rk: RecordKey): void {
358
+ if (this.#changed !== null) {
359
+ this.#changed.add(rk);
360
+ return;
361
+ }
362
+ this.batch(() => this.#changed?.add(rk));
363
+ }
364
+ }
@@ -0,0 +1,100 @@
1
+ // The store's SYNCED layer: server truth per `type:key`, merged column by column, with the holds
2
+ // that evict a record nobody renders and the provisional mark on rows restored from disk. Pure
3
+ // state — it answers what moved, and the store decides who hears about it.
4
+
5
+ import type { Row } from '@ultimat3/core/page';
6
+ import type { RecordKey } from './record-key';
7
+
8
+ export class SyncedLayer {
9
+ readonly #rows = new Map<RecordKey, Row>();
10
+ readonly #holds = new Map<RecordKey, number>();
11
+ /** Keys restored from disk and not yet confirmed: the first server row REPLACES, never merges. */
12
+ readonly #provisional = new Set<RecordKey>();
13
+
14
+ get(rk: RecordKey): Row | undefined {
15
+ return this.#rows.get(rk);
16
+ }
17
+
18
+ get size(): number {
19
+ return this.#rows.size;
20
+ }
21
+
22
+ keys(): IterableIterator<RecordKey> {
23
+ return this.#rows.keys();
24
+ }
25
+
26
+ entries(): IterableIterator<[RecordKey, Row]> {
27
+ return this.#rows.entries();
28
+ }
29
+
30
+ /** Every row of one type — what a replay's `tx.<type>.all()` reads under the overlay. */
31
+ ofType(type: string): readonly [RecordKey, Row][] {
32
+ const prefix = `${type}:`;
33
+ return [...this.#rows].filter(([rk]) => rk.startsWith(prefix));
34
+ }
35
+
36
+ isProvisional(rk: RecordKey): boolean {
37
+ return this.#provisional.has(rk);
38
+ }
39
+
40
+ /**
41
+ * One server write. Merged, never replaced: a projection that selected fewer columns must not
42
+ * blank the columns another is rendering, so an omitted (or `undefined`) field is left alone.
43
+ * Answers the row as it now stands, and whether anything in it changed.
44
+ */
45
+ merge(rk: RecordKey, columns: Row): { readonly row: Row; readonly changed: boolean } {
46
+ // A restored row is a guess from disk: the server's first answer is the whole truth.
47
+ const current = this.#provisional.delete(rk) ? undefined : this.#rows.get(rk);
48
+ const next: Record<string, unknown> = { ...current };
49
+ let changed = current === undefined;
50
+ for (const [column, value] of Object.entries(columns)) {
51
+ if (value === undefined) continue;
52
+ if (current === undefined || current[column] !== value) changed = true;
53
+ next[column] = value;
54
+ }
55
+ if (!changed && current !== undefined) return { row: current, changed: false };
56
+ const row = Object.freeze(next);
57
+ this.#rows.set(rk, row);
58
+ return { row, changed: true };
59
+ }
60
+
61
+ /** A conflict policy's verdict replacing the server row outright. */
62
+ set(rk: RecordKey, row: Row): void {
63
+ this.#rows.set(rk, Object.freeze({ ...row }));
64
+ }
65
+
66
+ /** A row from disk, only where the server has not answered. Answers whether it went in. */
67
+ restore(rk: RecordKey, row: Row): boolean {
68
+ if (this.#rows.has(rk)) return false;
69
+ this.#rows.set(rk, Object.freeze({ ...row }));
70
+ this.#provisional.add(rk);
71
+ return true;
72
+ }
73
+
74
+ /** Answers whether a row was there to remove. */
75
+ remove(rk: RecordKey): boolean {
76
+ this.#provisional.delete(rk);
77
+ return this.#rows.delete(rk);
78
+ }
79
+
80
+ retain(rk: RecordKey): void {
81
+ this.#holds.set(rk, (this.#holds.get(rk) ?? 0) + 1);
82
+ }
83
+
84
+ /** The last holder leaving evicts the row. Answers whether a row went. */
85
+ release(rk: RecordKey): boolean {
86
+ const holds = this.#holds.get(rk);
87
+ if (holds === undefined) return false;
88
+ if (holds > 1) {
89
+ this.#holds.set(rk, holds - 1);
90
+ return false;
91
+ }
92
+ this.#holds.delete(rk);
93
+ return this.remove(rk);
94
+ }
95
+
96
+ clear(): void {
97
+ this.#rows.clear();
98
+ this.#provisional.clear();
99
+ }
100
+ }
@@ -0,0 +1,145 @@
1
+ // The optimistic layer: every pending mutator's pure `local(tx, input)` half, replayed in order
2
+ // over synced truth. Replay, not stored results, is the point — a like counted as `likeCount + 1`
3
+ // over the OLD row must become `+ 1` over the row the server just sent, never the stale sum.
4
+
5
+ import type { ConflictPolicy, Row } from '@ultimat3/core/page';
6
+
7
+ /** One table as a mutator's `local` half sees it, keyed by record type (the entity's name). */
8
+ export interface LocalTable<R extends Row = Row> {
9
+ get(key: string): R | undefined;
10
+ all(): readonly R[];
11
+ /**
12
+ * The key is the caller's, never derived here: the browser holds no entity schema, so it cannot
13
+ * know a primary key. An optimistic insert names the key its server twin will answer under.
14
+ */
15
+ insert(key: string, row: R): void;
16
+ upsert(key: string, row: R): void;
17
+ /** Changed fields only — or a function returning them; an omitted field is left as it was. */
18
+ update(key: string, patch: Partial<R> | ((row: R) => Partial<R>)): void;
19
+ delete(key: string): void;
20
+ }
21
+
22
+ export type TableMap = Record<string, Row>;
23
+
24
+ /** `tx.posts`, typed by the app's own entity rows. `tx.table(name)` is the string-named door. */
25
+ export type LocalTx<T extends TableMap = TableMap> = { readonly [K in keyof T]: LocalTable<T[K]> };
26
+
27
+ export interface OverlayEntry {
28
+ /** The write's idempotency key — the same key the HTTP dispatch carries. */
29
+ readonly key: string;
30
+ readonly policy: ConflictPolicy;
31
+ apply(tx: LocalTx): void;
32
+ /**
33
+ * Rows the server has already answered for THIS write (its echo, or its answer, carried them):
34
+ * their synced truth holds the write, so a replay leaves them alone and only the rows still
35
+ * awaiting truth show the twin. Without it, a write whose echo carried one of its two rows
36
+ * replayed the twin over that row's truth too — the write counted twice until the other arrived.
37
+ */
38
+ readonly confirmed?: ReadonlySet<string>;
39
+ }
40
+
41
+ export interface ReplayResult {
42
+ readonly view: ReadonlyMap<string, Row | null>;
43
+ readonly touched: Map<string, ReadonlySet<string>>;
44
+ /** Entries whose `local` threw: dropped from the overlay and reported, never half-applied. */
45
+ readonly failed: readonly string[];
46
+ }
47
+
48
+ /** Shallow content equality — a replay producing the same columns must not re-render anything. */
49
+ export function sameRow(a: Row | null, b: Row | null): boolean {
50
+ if (a === b) return true;
51
+ if (a === null || b === null) return false;
52
+ const keys = Object.keys(a);
53
+ if (keys.length !== Object.keys(b).length) return false;
54
+ return keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key]);
55
+ }
56
+
57
+ export function replayOverlays(
58
+ entries: readonly OverlayEntry[],
59
+ synced: (recordKey: string) => Row | undefined,
60
+ syncedOf: (type: string) => readonly (readonly [string, Row])[],
61
+ report: (error: unknown) => void,
62
+ ): ReplayResult {
63
+ const view = new Map<string, Row | null>();
64
+ const touched = new Map<string, ReadonlySet<string>>();
65
+ const failed: string[] = [];
66
+ for (const entry of entries) {
67
+ // Each entry writes into a scratch copy, so a `local` that throws halfway leaves nothing.
68
+ const scratch = new Map(view);
69
+ const wrote = new Set<string>();
70
+ try {
71
+ entry.apply(txOver(scratch, wrote, synced, syncedOf));
72
+ } catch (error) {
73
+ report(error);
74
+ failed.push(entry.key);
75
+ continue;
76
+ }
77
+ const confirmed = entry.confirmed;
78
+ const pending = new Set<string>();
79
+ for (const rk of wrote) {
80
+ if (confirmed?.has(rk) === true) continue;
81
+ view.set(rk, scratch.get(rk) ?? null);
82
+ pending.add(rk);
83
+ }
84
+ touched.set(entry.key, pending);
85
+ }
86
+ return { view, touched, failed };
87
+ }
88
+
89
+ function txOver(
90
+ view: Map<string, Row | null>,
91
+ wrote: Set<string>,
92
+ synced: (recordKey: string) => Row | undefined,
93
+ syncedOf: (type: string) => readonly (readonly [string, Row])[],
94
+ ): LocalTx {
95
+ const tables = new Map<string, LocalTable>();
96
+ const table = (type: string): LocalTable => {
97
+ const existing = tables.get(type);
98
+ if (existing !== undefined) return existing;
99
+ const rk = (key: string): string => `${type}:${key}`;
100
+ const read = (key: string): Row | undefined => {
101
+ const overlaid = view.get(rk(key));
102
+ return overlaid === undefined ? synced(rk(key)) : (overlaid ?? undefined);
103
+ };
104
+ const write = (key: string, row: Row | null): void => {
105
+ view.set(rk(key), row === null ? null : Object.freeze({ ...row }));
106
+ wrote.add(rk(key));
107
+ };
108
+ const created: LocalTable = {
109
+ get: read,
110
+ all: () => {
111
+ const prefix = `${type}:`;
112
+ const out = new Map<string, Row | null>();
113
+ for (const [key, row] of syncedOf(type)) out.set(key, row);
114
+ for (const [key, row] of view) if (key.startsWith(prefix)) out.set(key, row);
115
+ return [...out.values()].filter((row): row is Row => row !== null);
116
+ },
117
+ insert: (key, row) => write(key, row),
118
+ upsert: (key, row) => write(key, { ...read(key), ...definedOf(row) }),
119
+ update: (key, patch) => {
120
+ const current = read(key);
121
+ if (current === undefined) return;
122
+ const changed = typeof patch === 'function' ? patch(current) : patch;
123
+ write(key, { ...current, ...definedOf(changed) });
124
+ },
125
+ delete: (key) => write(key, null),
126
+ };
127
+ tables.set(type, created);
128
+ return created;
129
+ };
130
+ return new Proxy({} as LocalTx, {
131
+ get: (_target, property) => {
132
+ if (typeof property !== 'string') return undefined;
133
+ if (property === 'table') return table;
134
+ return table(property);
135
+ },
136
+ });
137
+ }
138
+
139
+ /** `undefined` in a patch means "leave it alone", exactly as a server merge reads it. */
140
+ function definedOf(columns: Readonly<Record<string, unknown>>): Record<string, unknown> {
141
+ const out: Record<string, unknown> = {};
142
+ for (const [column, value] of Object.entries(columns))
143
+ if (value !== undefined) out[column] = value;
144
+ return out;
145
+ }
package/src/replicator.ts CHANGED
@@ -11,13 +11,16 @@
11
11
  // its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
12
12
  // the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
13
13
 
14
- import { logger, uuid, withSpan } from '@ultimat3/core';
14
+ import { isWriteDigest, logger, uuid, withSpan } from '@ultimat3/core';
15
15
  import type { ChangeEvent, ChangeFeed } from './changefeed';
16
16
  import type { Transport } from './fanout';
17
- import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
17
+ import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
18
18
 
19
19
  export const CHANGE_SUBJECT_PREFIX = 'x.change';
20
20
 
21
+ /** Every change subject: what a `sync` node subscribes to. A changefeed subject, never a channel. */
22
+ export const CHANGE_SUBJECT_ALL = `${CHANGE_SUBJECT_PREFIX}.>`;
23
+
21
24
  /** Session-scoped mutual exclusion. Postgres-backed in production, in-memory for `x dev`. */
22
25
  export interface AdvisoryLock {
23
26
  readonly key: string;
@@ -222,13 +225,16 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
222
225
  },
223
226
 
224
227
  retryDelayMs(attempt: number): number {
225
- return backoffDelay(attempt, backoff, options.rng ?? Math.random);
228
+ // This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
229
+ return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
226
230
  },
227
231
  };
228
232
  }
229
233
 
230
234
  /** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
231
235
  export function normalize(change: ChangeEvent): ChangeEvent | null {
236
+ // The one change that carries no row by design; nothing to hoist a tenant out of.
237
+ if (change.op === 'truncate') return change;
232
238
  const row = change.after ?? change.before;
233
239
  if (!row) return null;
234
240
  if (change.op === 'insert' && change.after === null) return null;
@@ -250,7 +256,14 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
250
256
  if (typeof parsed !== 'object' || parsed === null) return null;
251
257
  const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
252
258
  if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
253
- if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
259
+ if (
260
+ shape.op !== 'insert' &&
261
+ shape.op !== 'update' &&
262
+ shape.op !== 'delete' &&
263
+ shape.op !== 'truncate'
264
+ ) {
265
+ return null;
266
+ }
254
267
  return {
255
268
  change: {
256
269
  entity: shape.entity,
@@ -261,6 +274,9 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
261
274
  txid: typeof shape.txid === 'string' ? shape.txid : '',
262
275
  orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
263
276
  at: typeof shape.at === 'number' ? shape.at : 0,
277
+ // Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
278
+ // label here would cost every member the whole frame rather than one page its echo.
279
+ ...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
264
280
  },
265
281
  seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
266
282
  producer: typeof shape.producer === 'string' ? shape.producer : null,