@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
@@ -9,9 +9,9 @@
9
9
  // to a socket and nothing more, so a drained mutation is `inflight` — not `acked` — until an
10
10
  // `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
11
11
 
12
- import { renderThrowable, stringField } from '@ultimat3/core';
12
+ import { renderThrowable, stringField } from '@ultimat3/core/page';
13
13
  import type { JsonValue } from './json';
14
- import { type Frame, PROTOCOL_VERSION, type WireError } from './sync-protocol';
14
+ import type { WireError } from './sync-protocol';
15
15
 
16
16
  export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
17
17
 
@@ -33,21 +33,38 @@ export interface QueueState {
33
33
  readonly nextSeq: number;
34
34
  }
35
35
 
36
- /** Durability seam: OPFS/IndexedDB in the browser, memory in tests. */
36
+ /**
37
+ * One change to the durable queue: these entries written BY KEY, these keys deleted, and the
38
+ * sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
39
+ * save let the last tab to save erase the other's queued write.
40
+ */
41
+ export interface QueueChange {
42
+ readonly puts: readonly QueuedMutation[];
43
+ readonly deletes: readonly string[];
44
+ readonly nextSeq: number;
45
+ }
46
+
47
+ /** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
37
48
  export interface QueueStore {
38
49
  load(): Promise<QueueState>;
39
- save(state: QueueState): Promise<void>;
50
+ write(change: QueueChange): Promise<void>;
40
51
  }
41
52
 
42
53
  export class MemoryQueueStore implements QueueStore {
43
- #state: QueueState = { mutations: [], nextSeq: 1 };
54
+ readonly #mutations = new Map<string, QueuedMutation>();
55
+ #nextSeq = 1;
44
56
 
45
57
  async load(): Promise<QueueState> {
46
- return this.#state;
58
+ return {
59
+ mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
60
+ nextSeq: this.#nextSeq,
61
+ };
47
62
  }
48
63
 
49
- async save(state: QueueState): Promise<void> {
50
- this.#state = { mutations: state.mutations.map((m) => ({ ...m })), nextSeq: state.nextSeq };
64
+ async write(change: QueueChange): Promise<void> {
65
+ for (const key of change.deletes) this.#mutations.delete(key);
66
+ for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
67
+ this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
51
68
  }
52
69
  }
53
70
 
@@ -81,9 +98,37 @@ export class OfflineQueue {
81
98
  this.#nextSeq = state.nextSeq;
82
99
  }
83
100
 
84
- /** Rehydrates from durable storage, so a reload resumes the same queue with the same sequence. */
101
+ /**
102
+ * Rehydrates from durable storage, so a reload resumes the same queue with the same sequence.
103
+ *
104
+ * An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
105
+ * as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
106
+ * and every later write overtook it. The replay carries its idempotency key, so a write the old
107
+ * page did get through is answered from the action's idempotency store, never applied twice.
108
+ */
85
109
  static async open(store: QueueStore): Promise<OfflineQueue> {
86
- return new OfflineQueue(store, await store.load());
110
+ const queue = new OfflineQueue(store, await store.load());
111
+ await queue.#reclaimInflight();
112
+ return queue;
113
+ }
114
+
115
+ /**
116
+ * Re-reads the durable queue, which another tab of the same user may have written since this one
117
+ * opened. Call ONLY while this queue is the one draining — `page-outbox.ts` holds a Web Lock for
118
+ * exactly that — because it reclaims every `inflight` entry as `pending`: with the lock held, no
119
+ * other pass can have one on the wire.
120
+ */
121
+ async reload(): Promise<void> {
122
+ const state = await this.#store.load();
123
+ this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
124
+ this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
125
+ await this.#reclaimInflight();
126
+ }
127
+
128
+ async #reclaimInflight(): Promise<void> {
129
+ const reclaimed = this.#mutations.filter((mutation) => mutation.status === 'inflight');
130
+ for (const mutation of reclaimed) mutation.status = 'pending';
131
+ if (reclaimed.length > 0) await this.#persist(reclaimed);
87
132
  }
88
133
 
89
134
  get size(): number {
@@ -131,8 +176,10 @@ export class OfflineQueue {
131
176
  // UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
132
177
  // session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
133
178
  // entry is dropped and this one takes a new sequence at the back of the queue.
134
- if (existing)
135
- this.#mutations = this.#mutations.filter((candidate) => candidate.key !== args.key);
179
+ // By identity: `existing` IS the entry for this key, found above — no second key comparison.
180
+ if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
181
+ // Another tab of the same user may have taken sequence numbers since this one loaded.
182
+ this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
136
183
  const mutation: QueuedMutation = {
137
184
  key: args.key,
138
185
  seq: this.#nextSeq,
@@ -145,7 +192,7 @@ export class OfflineQueue {
145
192
  };
146
193
  this.#nextSeq += 1;
147
194
  this.#mutations.push(mutation);
148
- await this.#persist();
195
+ await this.#persist([mutation]);
149
196
  return mutation;
150
197
  }
151
198
 
@@ -190,12 +237,14 @@ export class OfflineQueue {
190
237
  async requeueInflight(): Promise<number> {
191
238
  this.#epoch += 1;
192
239
  let returned = 0;
240
+ const back: QueuedMutation[] = [];
193
241
  for (const mutation of this.#mutations) {
194
242
  if (mutation.status !== 'inflight') continue;
195
243
  mutation.status = 'pending';
196
244
  returned += 1;
245
+ back.push(mutation);
197
246
  }
198
- if (returned > 0) await this.#persist();
247
+ if (returned > 0) await this.#persist(back);
199
248
  return returned;
200
249
  }
201
250
 
@@ -204,8 +253,9 @@ export class OfflineQueue {
204
253
  const mutation = this.find(key);
205
254
  if (!mutation) return;
206
255
  mutation.status = 'acked';
207
- this.#mutations = this.#mutations.filter((candidate) => candidate.key !== key);
208
- await this.#persist();
256
+ // By identity: `find` already matched it, and a second comparison of the key is the same question.
257
+ this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
258
+ await this.#persist([], [key]);
209
259
  }
210
260
 
211
261
  /** Terminal failure (policy denial, validation): kept for the UI, never retried blindly. */
@@ -214,7 +264,7 @@ export class OfflineQueue {
214
264
  if (!mutation) return;
215
265
  mutation.status = 'failed';
216
266
  mutation.error = error;
217
- await this.#persist();
267
+ await this.#persist([mutation]);
218
268
  }
219
269
 
220
270
  /**
@@ -251,6 +301,7 @@ export class OfflineQueue {
251
301
  return { sent: 0, collapsed: this.#collapsed, remaining: 0, stoppedAt: null };
252
302
  }
253
303
  let sent = 0;
304
+ const touched: QueuedMutation[] = [];
254
305
  for (const mutation of sendable) {
255
306
  // The connection this pass was draining into is gone, and `requeueInflight` has already
256
307
  // handed back what was on it. Everything left stays `pending` for the pass the next
@@ -268,6 +319,7 @@ export class OfflineQueue {
268
319
  if (!this.#stillSendable(mutation)) continue;
269
320
  mutation.status = 'inflight';
270
321
  mutation.attempts += 1;
322
+ touched.push(mutation);
271
323
  try {
272
324
  await send(mutation);
273
325
  // Stays `inflight`. `send` resolving means the frame reached a socket — a browser
@@ -279,7 +331,7 @@ export class OfflineQueue {
279
331
  } catch (error) {
280
332
  mutation.status = 'pending';
281
333
  mutation.error = toQueueError(error);
282
- await this.#persist();
334
+ await this.#persist([mutation]);
283
335
  return {
284
336
  sent,
285
337
  collapsed: this.#collapsed,
@@ -288,7 +340,9 @@ export class OfflineQueue {
288
340
  };
289
341
  }
290
342
  }
291
- await this.#persist();
343
+ // Only what this pass touched AND the queue still holds: an entry the server settled during the
344
+ // pass was already deleted by `ack`, and writing it back would resurrect it.
345
+ await this.#persist(touched.filter((mutation) => this.#mutations.includes(mutation)));
292
346
  return {
293
347
  sent,
294
348
  collapsed: this.#collapsed,
@@ -298,36 +352,28 @@ export class OfflineQueue {
298
352
  }
299
353
 
300
354
  async clear(): Promise<void> {
355
+ const keys = this.#mutations.map((mutation) => mutation.key);
301
356
  this.#mutations = [];
302
- await this.#persist();
357
+ await this.#persist([], keys);
303
358
  }
304
359
 
305
360
  /**
306
- * A snapshot, never the live entries. `save` is a durable write — OPFS, IndexedDB — and it is
307
- * allowed to await before it reads. Handed the array itself, a store that resolves after the next
308
- * pass has moved on persists a status that was never true when it was called; `inflight` is the
309
- * one a reload cannot recover from, because `#sendable` skips it and no ack is coming.
361
+ * Snapshots, never the live entries. `write` is a durable write — IndexedDB — and it is allowed
362
+ * to await before it reads. Handed an entry itself, a store that resolves after the next pass has
363
+ * moved on persists a status that was never true when it was called. BY KEY, never the whole
364
+ * queue: a second tab's entries are not this tab's to overwrite or delete.
310
365
  */
311
- async #persist(): Promise<void> {
312
- await this.#store.save({
313
- mutations: this.#mutations.map((mutation) => ({ ...mutation })),
366
+ async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
367
+ await this.#store.write({
368
+ puts: puts.map((mutation) => ({ ...mutation })),
369
+ deletes,
314
370
  nextSeq: this.#nextSeq,
315
371
  });
316
372
  }
317
373
  }
318
374
 
319
- export function mutateFrame(mutation: QueuedMutation): Frame {
320
- return {
321
- type: 'mutate',
322
- v: PROTOCOL_VERSION,
323
- key: mutation.key,
324
- seq: mutation.seq,
325
- name: mutation.name,
326
- input: mutation.input,
327
- };
328
- }
329
-
330
- function toQueueError(error: unknown): WireError {
375
+ /** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
376
+ export function toQueueError(error: unknown): WireError {
331
377
  return {
332
378
  // `stringField`, not `shape?.code`: the sender is a transport the app supplied, so the probe
333
379
  // for "did it throw a coded error" is itself a property read on an app value. A getter that
@@ -0,0 +1,31 @@
1
+ // The page's outbox as an ISLAND reaches it: read off the page, never built. The page boot
2
+ // (`boot.ts`) is the one module that constructs it — IndexedDB, the queue, the drain listeners —
3
+ // so a writing island ships this reader and none of that (~7.5 kB it would otherwise carry).
4
+
5
+ import type { JsonValue } from './json';
6
+
7
+ export interface OutboxEntry {
8
+ /** The idempotency key the write was first attempted under — the SAME key on every replay. */
9
+ readonly key: string;
10
+ /** The mutator's action name; the replay POSTs to `actionPath(name)`. */
11
+ readonly name: string;
12
+ readonly input: JsonValue;
13
+ }
14
+
15
+ /** What a hook asks of the outbox the boot opened: queue a write, list them, replay them. */
16
+ export interface OutboxHandle {
17
+ enqueue(entry: OutboxEntry): Promise<void>;
18
+ replay(): Promise<unknown>;
19
+ pending(): readonly OutboxEntry[];
20
+ /** Settles once the current principal's queue is open. */
21
+ readonly ready: Promise<void>;
22
+ }
23
+
24
+ /** Where the page keeps it: shared by every bundle on the page, as the record store is. */
25
+ export const OUTBOX_KEY: unique symbol = Symbol.for('ultimate.outbox');
26
+ export type OutboxHost = { [OUTBOX_KEY]?: OutboxHandle };
27
+
28
+ /** The outbox the page boot opened, or `undefined` on a page with no boot (nothing persisted). */
29
+ export function peekOutbox(): OutboxHandle | undefined {
30
+ return (globalThis as OutboxHost)[OUTBOX_KEY];
31
+ }
@@ -0,0 +1,124 @@
1
+ // The refusals a BROWSER can reach — the page store, the hooks, the socket's wire check, the local
2
+ // store. Apart from `errors.ts` for bytes: that module registers the whole code table at import,
3
+ // and an island that renders one record has no use for sixty titles. The codes stay in `errors.ts`
4
+ // (its `registerErrorCodes()` is what `package.json`'s `sideEffects` names, anchored by the
5
+ // barrel); in a browser that loaded no table a code titles itself from its name, and `code`,
6
+ // `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
7
+
8
+ import { renderFixShellArg } from '@ultimat3/core/page';
9
+ import { RealtimeError } from './realtime-error';
10
+
11
+ /**
12
+ * Client and server disagree on the wire format — a version mismatch or a malformed frame.
13
+ * Both are the same class of bug (a peer speaking a shape we do not have), so both get one code.
14
+ */
15
+ export class ProtocolVersionError extends RealtimeError {
16
+ constructor(args: { got: unknown; expected: number; detail?: string }) {
17
+ super({
18
+ code: 'X_PROTOCOL_VERSION',
19
+ cause:
20
+ args.detail ??
21
+ `frame protocol version ${String(args.got)} is not the server version ${args.expected}`,
22
+ fix: 'x build && redeploy the client; the sync node sends `update-available` before it drains',
23
+ });
24
+ }
25
+ }
26
+
27
+ /** A rebase could not be resolved: `custom(merge)` returned nothing, or the base row vanished. */
28
+ export class RebaseConflictError extends RealtimeError {
29
+ constructor(args: { key: string; entity: string; reason: string }) {
30
+ super({
31
+ code: 'X_REBASE_CONFLICT',
32
+ cause: `mutation ${args.key} on ${args.entity} could not be rebased: ${args.reason}`,
33
+ fix: "set conflict: 'server-wins' on the mutator, or return a row from custom(merge)",
34
+ });
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`.
40
+ * Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
41
+ * hook renders through has to be that island's own — the page-wide store cannot hold one.
42
+ *
43
+ * A server render is deliberately not this error: no DOM means no reactive runtime to install,
44
+ * and the hooks answer the honest server-render state instead.
45
+ */
46
+ export class RealtimeUninstalledError extends RealtimeError {
47
+ constructor(args: { hook: string }) {
48
+ super({
49
+ code: 'X_REALTIME_UNINSTALLED',
50
+ cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime()`,
51
+ fix: "x build # the island bootstrap installs it; a hand-built island calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
52
+ });
53
+ }
54
+ }
55
+
56
+ /** A live hook needed the page's one socket, and nothing told this page where the sync node is. */
57
+ export class SyncUnconfiguredError extends RealtimeError {
58
+ constructor(args: { hook: string }) {
59
+ super({
60
+ code: 'X_SYNC_UNCONFIGURED',
61
+ cause: `${args.hook}() needs the page socket, and installRealtime() was given no sync target`,
62
+ fix: "x build # the island bootstrap passes it; a hand-built island calls installRealtime({ signal: createSignal, sync: { url, buildId } }) from '@ultimat3/realtime'",
63
+ });
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A row reached the record store it cannot hold: not an object, or under no key. Dropped and
69
+ * reported, never partially merged — a keyless row would overwrite another record.
70
+ */
71
+ export class RecordRejectedError extends RealtimeError {
72
+ constructor(args: { type: string; reason: string }) {
73
+ super({
74
+ code: 'X_RECORD_REJECTED',
75
+ cause: `a ${args.type === '' ? 'record' : `"${args.type}" record`} was rejected by the page's record store: ${args.reason}`,
76
+ fix: `x entities describe ${renderFixShellArg(args.type, '<entity>')} --json # the primary key every row of it must carry; return whole rows from the handler that built this one`,
77
+ });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * IndexedDB would not open (a private window, storage disabled), so the page keeps records and its
83
+ * outbox in memory. A WARNING, never thrown: a page must not break because it cannot remember, so
84
+ * `openLocalStore` hands this to its `warn` once and carries on.
85
+ */
86
+ export class LocalStoreUnavailableError extends RealtimeError {
87
+ constructor(args: { reason: string }) {
88
+ super({
89
+ code: 'X_LOCAL_STORE_UNAVAILABLE',
90
+ cause: `the page's durable store could not open (${args.reason}), so records and queued writes live in memory and are lost on reload`,
91
+ fix: 'nothing to do in the app — allow site storage in the browser (a private window blocks it) and reload',
92
+ });
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Something that can only mean "talk to the socket" ran on the server client — a mutation, a
98
+ * publish, a topic subscription, a dial. There is no socket during a server render and there never
99
+ * will be one: the document is built and sent, and the browser opens the connection.
100
+ *
101
+ * A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
102
+ * hold one process-wide queue on behalf of whichever request happened to render, and dropping it
103
+ * would make a write that never happened look like one that did.
104
+ */
105
+ export class ServerRenderLiveError extends RealtimeError {
106
+ constructor(args: { operation: string }) {
107
+ super({
108
+ code: 'X_LIVE_SERVER_RENDER',
109
+ cause: `${args.operation} ran during a server render, where this app has no live socket`,
110
+ fix: 'move the call into an island: x g island <route-dir> --at <route-dir>, import from its mount(), declare island({ src })',
111
+ });
112
+ }
113
+ }
114
+
115
+ /** A resume cursor cannot be honoured and no snapshot path was supplied. */
116
+ export class CursorStaleError extends RealtimeError {
117
+ constructor(args: { qid: string; lsn: string; reason: string }) {
118
+ super({
119
+ code: 'X_CURSOR_STALE',
120
+ cause: `cursor for query ${args.qid} at lsn ${args.lsn} cannot be resumed: ${args.reason}`,
121
+ fix: 'pass `snapshot` to resumeFrom() so the fallback path can re-snapshot instead of failing',
122
+ });
123
+ }
124
+ }