@ultimat3/realtime 20.2.1 → 21.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 (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  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 +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -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 +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. 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
17
  import { type BackoffPolicy, backoffDelay, defaultBackoff, 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;
@@ -261,6 +264,9 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
261
264
  txid: typeof shape.txid === 'string' ? shape.txid : '',
262
265
  orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
263
266
  at: typeof shape.at === 'number' ? shape.at : 0,
267
+ // Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
268
+ // label here would cost every member the whole frame rather than one page its echo.
269
+ ...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
264
270
  },
265
271
  seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
266
272
  producer: typeof shape.producer === 'string' ? shape.producer : null,
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The SERVER half of the public API: the bus, the Postgres replication path, the sync node and the
2
2
  // live-query registry it fans out through. Split from `index.ts` because `nats` require()s
3
3
  // `stream/web` and the WAL decoder is a Postgres client — one barrel carrying both made the browser
4
- // island `useLive` promises unbuildable. Every name here has exactly one home; the shared
4
+ // island realtime promises unbuildable. Every name here has exactly one home; the shared
5
5
  // vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
6
6
 
7
7
  // ---- the retained change window one node fans out from ------------------------------------------
@@ -38,14 +38,9 @@ export {
38
38
  export {
39
39
  ChannelHub,
40
40
  type ChannelHubOptions,
41
- channelFrame,
42
41
  DEFAULT_MAX_TOPICS_PER_NODE,
43
- type Topic,
44
- type TopicGuard,
45
- type TopicGuardArgs,
46
- type TopicGuardResult,
47
- topic,
48
42
  } from './channel';
43
+ export { type ChannelDescription, describeChannels } from './channel-describe';
49
44
  export {
50
45
  InProcessTransport,
51
46
  type InProcessTransportOptions,
@@ -200,7 +195,6 @@ export {
200
195
  createFrameRouter,
201
196
  type FrameRouter,
202
197
  type FrameRouterOptions,
203
- type MutationHandler,
204
198
  } from './sync-frames';
205
199
  export {
206
200
  type ListenOptions,