@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
package/src/errors.ts CHANGED
@@ -18,14 +18,22 @@ export const REALTIME_OWNED_ERROR_CODES = [
18
18
  'X_REPLICATION_PROTOCOL',
19
19
  'X_REPLICATION_FAILED',
20
20
  'X_REPLICATOR_SLOT_HELD',
21
+ // Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
22
+ // forever: a log line from an older build still resolves through `x errors explain`.
21
23
  'X_LIVE_CLIENT_MISSING',
24
+ 'X_QUERY_NOT_SUBSCRIBABLE',
25
+ 'X_REALTIME_UNINSTALLED',
26
+ 'X_SYNC_UNCONFIGURED',
27
+ 'X_RECORD_REJECTED',
28
+ 'X_CHANNEL_DECLARATION_INVALID',
29
+ 'X_LOCAL_STORE_UNAVAILABLE',
22
30
  'X_LIVE_SERVER_RENDER',
23
31
  'X_LIVE_ROW_UNIDENTIFIED',
24
32
  'X_LIVE_QUERY_UNKNOWN',
25
33
  'X_LIVE_REPLICA_IDENTITY',
26
- 'X_QUERY_NOT_SUBSCRIBABLE',
27
34
  'X_SOCKET_UNAUTHENTICATED',
28
35
  'X_SOCKET_AUTH_UNAVAILABLE',
36
+ 'X_REALTIME_TOPOLOGY',
29
37
  ] as const;
30
38
 
31
39
  /**
@@ -118,14 +126,22 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
118
126
  X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
119
127
  X_REPLICATION_FAILED: 'the replication connection was refused',
120
128
  X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
121
- X_LIVE_CLIENT_MISSING: 'a realtime hook ran in a browser with no LiveClient registered',
129
+ X_LIVE_CLIENT_MISSING:
130
+ 'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
131
+ X_QUERY_NOT_SUBSCRIBABLE:
132
+ 'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
133
+ X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
134
+ X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
135
+ X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
136
+ X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
137
+ X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
122
138
  X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
123
139
  X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
124
140
  X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
125
141
  X_LIVE_REPLICA_IDENTITY: 'a replicated table sends a key-only row on delete',
126
- X_QUERY_NOT_SUBSCRIBABLE: 'a hook was bound to a query that is not declared live',
127
142
  X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
128
143
  X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
144
+ X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
129
145
  };
130
146
 
131
147
  // One unconditional call, so a second package claiming one of realtime's codes throws
@@ -136,10 +152,21 @@ registerErrorCodes(
136
152
  ),
137
153
  );
138
154
 
139
- // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts` and the four
140
- // replication errors in `replication-errors.ts`, so this file stays the CODE TABLE plus the
141
- // client-reachable refusals. Every name is still importable from `./errors`, which is what the
142
- // seventeen `pg-*` modules and the barrel already do.
155
+ export {
156
+ CursorStaleError,
157
+ LocalStoreUnavailableError,
158
+ ProtocolVersionError,
159
+ RealtimeUninstalledError,
160
+ RebaseConflictError,
161
+ RecordRejectedError,
162
+ ServerRenderLiveError,
163
+ SyncUnconfiguredError,
164
+ } from './page-errors';
165
+ // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
166
+ // replication errors in `replication-errors.ts` and the browser-reachable refusals in
167
+ // `page-errors.ts`, so this file is the CODE TABLE plus the server's refusals. Every name is still
168
+ // importable from `./errors`, which is what the `pg-*` modules and the barrel already do; a
169
+ // browser module imports `./page-errors` so it does not load the table.
143
170
  export { RealtimeError } from './realtime-error';
144
171
  export {
145
172
  ReplicaIdentityError,
@@ -154,7 +181,8 @@ export class TopicForbiddenError extends RealtimeError {
154
181
  super({
155
182
  code: 'X_TOPIC_FORBIDDEN',
156
183
  cause: `actor ${args.actorId ?? '<anonymous>'} may not subscribe to "${args.topic}": ${args.reason}`,
157
- fix: `declare a guard for this topic: hub.guard('${args.topic}', ({ actor }) => ...)`,
184
+ // The topic's first segment is its channel's name; the policy on that declaration decides.
185
+ fix: 'x policy list --json # then widen the policy on the channel() declaration this topic belongs to, or subscribe as an actor it allows',
158
186
  });
159
187
  }
160
188
  }
@@ -217,44 +245,6 @@ export class SubscriptionIdTakenError extends RealtimeError {
217
245
  }
218
246
  }
219
247
 
220
- /**
221
- * Client and server disagree on the wire format — a version mismatch or a malformed frame.
222
- * Both are the same class of bug (a peer speaking a shape we do not have), so both get one code.
223
- */
224
- export class ProtocolVersionError extends RealtimeError {
225
- constructor(args: { got: unknown; expected: number; detail?: string }) {
226
- super({
227
- code: 'X_PROTOCOL_VERSION',
228
- cause:
229
- args.detail ??
230
- `frame protocol version ${String(args.got)} is not the server version ${args.expected}`,
231
- fix: 'x build && redeploy the client; the sync node sends `update-available` before it drains',
232
- });
233
- }
234
- }
235
-
236
- /** A resume cursor cannot be honoured and no snapshot path was supplied. */
237
- export class CursorStaleError extends RealtimeError {
238
- constructor(args: { qid: string; lsn: string; reason: string }) {
239
- super({
240
- code: 'X_CURSOR_STALE',
241
- cause: `cursor for query ${args.qid} at lsn ${args.lsn} cannot be resumed: ${args.reason}`,
242
- fix: 'pass `snapshot` to resumeFrom() so the fallback path can re-snapshot instead of failing',
243
- });
244
- }
245
- }
246
-
247
- /** A rebase could not be resolved: `custom(merge)` returned nothing, or the base row vanished. */
248
- export class RebaseConflictError extends RealtimeError {
249
- constructor(args: { key: string; entity: string; reason: string }) {
250
- super({
251
- code: 'X_REBASE_CONFLICT',
252
- cause: `mutation ${args.key} on ${args.entity} could not be rebased: ${args.reason}`,
253
- fix: "set conflict: 'server-wins' on the mutator, or return a row from custom(merge)",
254
- });
255
- }
256
- }
257
-
258
248
  /** The fanout bus is down. `sync` nodes are stateless, so this is always recoverable. */
259
249
  export class TransportUnavailableError extends RealtimeError {
260
250
  constructor(args: { transport: string; reason: string; fix?: string }) {
@@ -267,6 +257,25 @@ export class TransportUnavailableError extends RealtimeError {
267
257
  }
268
258
  }
269
259
 
260
+ /**
261
+ * A `sync` node that can hear no change: a real database, the in-process bus, and no replicator in
262
+ * this process. A replicator in another process publishes into ITS in-process bus, so every live
263
+ * query and channel here is silent, with no error on either side. Refused at boot, where the
264
+ * topology is known, rather than discovered as a live feature that never updates.
265
+ */
266
+ export class RealtimeTopologyError extends RealtimeError {
267
+ constructor() {
268
+ super({
269
+ code: 'X_REALTIME_TOPOLOGY',
270
+ cause:
271
+ 'role sync runs on an external database over the in-process transport with no replicator in this process, so no committed change can reach it',
272
+ // Since 22.0.0 NATS_URL alone selects nothing: `realtime.transport` does, and a set NATS_URL
273
+ // under `'memory'` is refused, so the fix has to name both halves.
274
+ fix: "set realtime: { transport: 'nats', urlEnv: 'NATS_URL' } in app.config.ts and NATS_URL for every realtime role (web, sync, replicator), or run ROLE=sync with the replicator in one process: x dev --role sync,replicator",
275
+ });
276
+ }
277
+ }
278
+
270
279
  /**
271
280
  * The bytes on the bus socket are not the protocol we speak: an unknown NATS verb, a header block
272
281
  * that is not `NATS/1.0`, a JetStream reply in a shape the API never produces. Always a version or
@@ -285,44 +294,6 @@ export class TransportProtocolError extends RealtimeError {
285
294
  }
286
295
  }
287
296
 
288
- /**
289
- * A hook was called IN A BROWSER before the app entry registered its client. Never a transient
290
- * fault: the registration is a single call in the entry, so the fix is the call itself rather than
291
- * a retry.
292
- *
293
- * A server render is deliberately not this error, and never was a missing registration: there is
294
- * no socket to register a client for. It gets `serverRenderLiveClient()` instead — the same rule
295
- * `@ultimat3/ui`'s `solid()` follows for a missing Solid runtime, one package over.
296
- */
297
- export class LiveClientMissingError extends RealtimeError {
298
- constructor(args: { hook: string }) {
299
- super({
300
- code: 'X_LIVE_CLIENT_MISSING',
301
- cause: `${args.hook}() ran in a browser before any LiveClient was registered`,
302
- fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
303
- });
304
- }
305
- }
306
-
307
- /**
308
- * Something that can only mean "talk to the socket" ran on the server client — a mutation, a
309
- * publish, a topic subscription, a dial. There is no socket during a server render and there never
310
- * will be one: the document is built and sent, and the browser opens the connection.
311
- *
312
- * A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
313
- * hold one process-wide queue on behalf of whichever request happened to render, and dropping it
314
- * would make a write that never happened look like one that did.
315
- */
316
- export class ServerRenderLiveError extends RealtimeError {
317
- constructor(args: { operation: string }) {
318
- super({
319
- code: 'X_LIVE_SERVER_RENDER',
320
- cause: `${args.operation} ran during a server render, where this app has no live socket`,
321
- fix: 'call it from an island mount() instead of from the page — or guard it with hasLiveClient(), which answers false on the server',
322
- });
323
- }
324
- }
325
-
326
297
  /**
327
298
  * A subscribable read projected a row with no `id`. Patches, cursors and the local store all
328
299
  * address a row by `id`, so such a row cannot be delivered — and delivering it anyway produces a
@@ -362,23 +333,6 @@ export class LiveQueryUnknownError extends RealtimeError {
362
333
  }
363
334
  }
364
335
 
365
- /**
366
- * `liveHookFor` was handed a read that never patches. Refused where the binding is written rather
367
- * than at the first render, because a hook over a non-live query has nothing to subscribe to — it
368
- * would return an empty set forever and look like a policy denial or an empty table.
369
- */
370
- export class QueryNotSubscribableError extends RealtimeError {
371
- constructor(args: { name: string }) {
372
- super({
373
- code: 'X_QUERY_NOT_SUBSCRIBABLE',
374
- // Empty at module load, when the binding runs and `registerQueries()` has not stamped a
375
- // name yet — say so rather than printing `query ""`.
376
- cause: `query ${args.name === '' ? '<unregistered>' : `"${args.name}"`} is not declared live: true, so it has no subscription for a hook to read`,
377
- fix: 'add live: true to the query declaration, or read it once through query.client({ baseUrl }) — wiki/Queries-And-Live-Queries.md',
378
- });
379
- }
380
- }
381
-
382
336
  /**
383
337
  * The app's `authenticate` decided this upgrade belongs to nobody. A **decision**, so it is the
384
338
  * client's own condition and never pages anyone: the refusal is the whole point of the hook.
@@ -1,6 +1,6 @@
1
1
  // The order this node applies one socket's inbound frames in. `sync-node.message` dispatches every
2
2
  // frame as `void (async () => routeFrame(…))()`, so nothing upstream orders them and a router that
3
- // awaits a policy, a snapshot read or `onMutate` finishes in whatever order those settle.
3
+ // awaits a policy or a snapshot read finishes in whatever order those settle.
4
4
  //
5
5
  // **Not one lane per socket.** A global per-socket lane puts every frame behind the slowest one,
6
6
  // and the slowest one is a snapshot read — a database round trip that every reconnecting client
@@ -9,9 +9,8 @@
9
9
  //
10
10
  // | Frames | Lane | Why that is the unit |
11
11
  // |---|---|---|
12
- // | `mutate` | `mutate` (one per socket) | they write the database, and the client numbered them |
13
12
  // | `subscribe` on a query | `sub:<sid>` | `add` then `drop` for one sid, or the drop finds nothing and the add strands the subscription it was meant to end |
14
- // | `subscribe` on a topic | `topic:<name>` | the same add/drop pair, one membership |
13
+ // | `subscribe` on a channel | `channel:<name>:<params>` | the same add/drop pair, one membership |
15
14
  // | `hello`, server-authored kinds | none | they read state and write none of it |
16
15
  //
17
16
  // The caps are NOT this file's job — a lane makes concurrent frames sequential, and N sequential
@@ -52,7 +51,12 @@ export class FrameLanes {
52
51
 
53
52
  /** The lane a frame belongs in, or `null` for the kinds nothing has to order. */
54
53
  export function laneKeyOf(frame: Frame): string | null {
55
- if (frame.type === 'mutate') return 'mutate';
56
54
  if (frame.type !== 'subscribe') return null;
57
- return frame.target.kind === 'topic' ? `topic:${frame.target.topic}` : `sub:${frame.sid}`;
55
+ const target = frame.target;
56
+ if (target.kind !== 'channel') return `sub:${frame.sid}`;
57
+ // The channel's TOPIC, spelled from what fixes it — the declaration name and its params — so an
58
+ // add and a drop of one topic queue behind each other whatever sids they carry. Sorted, so two
59
+ // spellings of one params object are one lane. Entry order is sorted, never trusted.
60
+ const params = Object.entries(target.params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
61
+ return `channel:${target.channel}:${JSON.stringify(params)}`;
58
62
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * An in-memory IndexedDB with exactly the surface `local-store-idb.ts` uses — open with an
3
+ * upgrade, one transaction over named stores, `put` / `delete` / `getAll` / `getAllKeys` — and
4
+ * IndexedDB's own asynchrony: every request answers on a later microtask and a transaction
5
+ * completes after its last request. Bun ships no IndexedDB, and no dependency is worth a test seam.
6
+ * Values are structured-cloned in and out, as the real one does.
7
+ */
8
+
9
+ import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike, IdbStoreLike } from './idb-types';
10
+
11
+ type Tables = Map<string, Map<string, unknown>>;
12
+
13
+ export interface FakeIdbOptions {
14
+ /** `open` fails, as it does in a private window or with storage blocked. */
15
+ readonly blocked?: boolean;
16
+ /**
17
+ * Every write ABORTS its transaction, as a quota refusal does: `abort` fires and nothing else —
18
+ * no `complete`, no transaction `error` event. Read at write time, so a test can flip it.
19
+ */
20
+ quotaExceeded?: boolean;
21
+ }
22
+
23
+ /** A fresh, empty database server. Share one instance to simulate a reload over the same disk. */
24
+ export function fakeIndexedDb(options: FakeIdbOptions = {}): IdbFactoryLike {
25
+ const databases = new Map<string, { version: number; tables: Tables }>();
26
+ return {
27
+ open(name: string, version: number): IdbRequestLike<IdbDatabaseLike> {
28
+ const request = pendingRequest<IdbDatabaseLike>();
29
+ queueMicrotask(() => {
30
+ if (options.blocked === true) {
31
+ request.fail(new DOMException('The operation is insecure.', 'SecurityError'));
32
+ return;
33
+ }
34
+ const known = databases.get(name) ?? { version: 0, tables: new Map() };
35
+ databases.set(name, known);
36
+ const db = database(known.tables, options);
37
+ request.result = db;
38
+ if (known.version < version) {
39
+ known.version = version;
40
+ request.onupgradeneeded?.();
41
+ }
42
+ request.onsuccess?.();
43
+ });
44
+ return request;
45
+ },
46
+ };
47
+ }
48
+
49
+ function database(tables: Tables, options: FakeIdbOptions): IdbDatabaseLike {
50
+ return {
51
+ objectStoreNames: { contains: (name: string): boolean => tables.has(name) },
52
+ createObjectStore: (name: string): void => {
53
+ tables.set(name, new Map());
54
+ },
55
+ transaction(names: string | readonly string[]) {
56
+ let open = 0;
57
+ let failed = false;
58
+ const tx = {
59
+ oncomplete: null as (() => void) | null,
60
+ onerror: null as (() => void) | null,
61
+ onabort: null as (() => void) | null,
62
+ error: null as unknown,
63
+ objectStore: (name: string): IdbStoreLike => store(name),
64
+ };
65
+ const abort = (): void => {
66
+ if (failed) return;
67
+ failed = true;
68
+ tx.error = new DOMException('The quota has been exceeded.', 'QuotaExceededError');
69
+ queueMicrotask(() => tx.onabort?.());
70
+ };
71
+ const settleLater = (): void => {
72
+ queueMicrotask(() => {
73
+ if (open > 0 || failed) return;
74
+ tx.oncomplete?.();
75
+ });
76
+ };
77
+ const run = <T>(work: () => T): IdbRequestLike<T> => {
78
+ const request = pendingRequest<T>();
79
+ open += 1;
80
+ queueMicrotask(() => {
81
+ open -= 1;
82
+ request.result = work();
83
+ request.onsuccess?.();
84
+ settleLater();
85
+ });
86
+ return request;
87
+ };
88
+ const store = (name: string): IdbStoreLike => {
89
+ const allowed = typeof names === 'string' ? [names] : names;
90
+ const table = tables.get(name);
91
+ if (!allowed.includes(name) || table === undefined) {
92
+ throw new DOMException(`no object store ${name}`, 'NotFoundError');
93
+ }
94
+ const sorted = (): [string, unknown][] =>
95
+ [...table].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
96
+ return {
97
+ get: (key) => run(() => structuredClone(table.get(key))),
98
+ put: (value, key) =>
99
+ run(() => {
100
+ if (options.quotaExceeded === true) {
101
+ abort();
102
+ return;
103
+ }
104
+ table.set(key, structuredClone(value));
105
+ }),
106
+ delete: (key) => run(() => void table.delete(key)),
107
+ getAll: () => run(() => sorted().map(([, value]) => structuredClone(value))),
108
+ getAllKeys: () => run(() => sorted().map(([key]) => key)),
109
+ };
110
+ };
111
+ settleLater();
112
+ return tx;
113
+ },
114
+ };
115
+ }
116
+
117
+ type PendingRequest<T> = IdbRequestLike<T> & { fail(error: unknown): void };
118
+
119
+ function pendingRequest<T>(): PendingRequest<T> {
120
+ const request: PendingRequest<T> = {
121
+ result: undefined as T,
122
+ error: null,
123
+ onsuccess: null,
124
+ onerror: null,
125
+ onupgradeneeded: null,
126
+ onblocked: null,
127
+ fail(error: unknown): void {
128
+ request.error = error;
129
+ request.onerror?.();
130
+ },
131
+ };
132
+ return request;
133
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The slice of IndexedDB `local-store-idb.ts` touches, as structural types: the browser's
3
+ * `indexedDB` satisfies them and so does `idb-fake.ts`, so the store is tested against the same
4
+ * surface it runs on without a DOM shim.
5
+ */
6
+
7
+ export interface IdbRequestLike<T> {
8
+ result: T;
9
+ error: unknown;
10
+ onsuccess: (() => void) | null;
11
+ onerror: (() => void) | null;
12
+ onupgradeneeded?: (() => void) | null;
13
+ onblocked?: (() => void) | null;
14
+ }
15
+
16
+ export interface IdbStoreLike {
17
+ get(key: string): IdbRequestLike<unknown>;
18
+ put(value: unknown, key: string): IdbRequestLike<unknown>;
19
+ delete(key: string): IdbRequestLike<unknown>;
20
+ getAll(): IdbRequestLike<unknown[]>;
21
+ getAllKeys(): IdbRequestLike<unknown[]>;
22
+ }
23
+
24
+ export interface IdbTransactionLike {
25
+ oncomplete: (() => void) | null;
26
+ onerror: (() => void) | null;
27
+ /**
28
+ * A transaction the browser ABORTED — a quota refusal is the ordinary one — fires `abort` and
29
+ * nothing else: no `complete`, and no `error` on the transaction. Unlistened, every write awaiting
30
+ * it hung forever.
31
+ */
32
+ onabort: (() => void) | null;
33
+ error: unknown;
34
+ objectStore(name: string): IdbStoreLike;
35
+ }
36
+
37
+ export interface IdbDatabaseLike {
38
+ readonly objectStoreNames: { contains(name: string): boolean };
39
+ createObjectStore(name: string): unknown;
40
+ transaction(
41
+ names: string | readonly string[],
42
+ mode?: 'readonly' | 'readwrite',
43
+ ): IdbTransactionLike;
44
+ }
45
+
46
+ export interface IdbFactoryLike {
47
+ open(name: string, version: number): IdbRequestLike<IdbDatabaseLike>;
48
+ }
package/src/index.ts CHANGED
@@ -1,23 +1,45 @@
1
1
  // The CLIENT half of the public API — everything a browser island may bundle. Explicit, tier by
2
- // tier: the wire, the hooks, the identity map, the offline queue and the reconnect vocabulary.
2
+ // tier: the wire, the hooks, the record store, the offline outbox and the reconnect vocabulary.
3
3
  // Nothing here reaches `nats`, a Postgres socket or the sync node; those are `./server`, and
4
4
  // `packages/cli/src/realtime-browser-barrel.test.ts` is the build error that keeps them apart.
5
5
 
6
6
  // ---- the client's one stateless piece, reusable against an app's own store ----------------------
7
7
  export { applyPatches, orderAfterPatches } from './apply-patches';
8
- // ---- server + client halves -------------------------------------------------------------------
8
+ // ---- channels: the declaration is the only way to spell a topic, and the frames it rides --------
9
9
  export {
10
- type ClientSocket,
11
- LiveClient,
12
- type LiveClientLike,
13
- type LiveClientOptions,
14
- type LiveHandle,
15
- type LiveQueryRef,
16
- type LiveState,
17
- type MutatorRef,
18
- type SignalFactory,
19
- type Unsubscribe,
20
- } from './client';
10
+ type Channel,
11
+ type ChannelEntity,
12
+ type ChannelInit,
13
+ type ChannelParams,
14
+ type ChannelRowLoader,
15
+ type ChannelServerInit,
16
+ channel,
17
+ } from './channel-decl';
18
+ // Presence rides a channel's `events`: the payload shape, and the one reader a client needs.
19
+ export { type PresenceEvent, type PresenceOp, readPresence } from './channel-presence';
20
+ // The client half of a channel an island holds — no entity, no policy (`channel-ref.ts`).
21
+ export { type ChannelHandle, channelRef, type Topic, topic } from './channel-ref';
22
+ // The process's channel table: what a hub serves by default. `describeChannels` is `./server`'s.
23
+ export { clearChannels, registeredChannels } from './channel-registry';
24
+ export type {
25
+ ChannelAdopt,
26
+ ChannelEventsFrame,
27
+ ChannelRecordsFrame,
28
+ ChannelRemove,
29
+ ChannelSince,
30
+ ChannelSubscribeTarget,
31
+ ChannelWireFrame,
32
+ ReplayGapFrame,
33
+ } from './channel-wire';
34
+ // ---- the hooks: every read and write a component makes -----------------------------------------
35
+ export type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
36
+ // ---- the shapes a hook hands back ----------------------------------------------------------------
37
+ export type {
38
+ LiveHandle,
39
+ LiveQueryRef,
40
+ SignalFactory,
41
+ Unsubscribe,
42
+ } from './client-contract';
21
43
  // ---- reconnect ----------------------------------------------------------------------------------
22
44
  export {
23
45
  advance,
@@ -38,54 +60,29 @@ export {
38
60
  export {
39
61
  CursorStaleError,
40
62
  FrameRateLimitError,
41
- LiveClientMissingError,
42
63
  LiveQueryUnknownError,
43
64
  LiveRowUnidentifiedError,
44
65
  NotImplementedError,
45
66
  ProtocolVersionError,
46
- QueryNotSubscribableError,
47
67
  REALTIME_ERROR_CODES,
48
68
  REALTIME_ERROR_TITLES,
49
69
  RealtimeError,
50
70
  type RealtimeErrorCode,
71
+ RealtimeUninstalledError,
51
72
  RebaseConflictError,
73
+ RecordRejectedError,
52
74
  ReplicaIdentityError,
53
75
  ReplicationFailedError,
54
76
  ReplicationProtocolError,
55
77
  ReplicatorSlotHeldError,
56
78
  ServerRenderLiveError,
57
79
  SubscriptionLimitError,
80
+ SyncUnconfiguredError,
58
81
  TopicForbiddenError,
59
82
  TransportProtocolError,
60
83
  TransportUnavailableError,
61
84
  WindowReadTimeoutError,
62
85
  } from './errors';
63
- // ---- the client hooks --------------------------------------------------------------------------
64
- export {
65
- type ConflictLike,
66
- type Connection,
67
- clearLiveClient,
68
- hasLiveClient,
69
- type LiveInput,
70
- type LiveRows,
71
- type Mutate,
72
- type MutationQueue,
73
- type MutatorLike,
74
- setLiveClient,
75
- useConnection,
76
- useLive,
77
- useMutation,
78
- useMutationQueue,
79
- } from './hooks';
80
- // ---- the client's single source of truth: one row per (entity, id) ------------------------------
81
- export {
82
- type IdentityListener,
83
- IdentityMap,
84
- privateScope,
85
- type RowKey,
86
- type RowScope,
87
- rowKey,
88
- } from './identity-map';
89
86
  // ---- shared value domain ---------------------------------------------------------------------
90
87
  export {
91
88
  changedColumns,
@@ -97,54 +94,48 @@ export {
97
94
  type RowOp,
98
95
  type RowPatch,
99
96
  } from './json';
100
- export { type Registration, RowWindows } from './live-rows';
101
- // ---- tier 3: local-first ------------------------------------------------------------------------
97
+ export { type LiveState, type Registration, RowWindows, unnamedType } from './live-rows';
98
+ // ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
102
99
  export {
103
- createOpfsLocalStore,
104
100
  type LocalStore,
105
- type LocalTable,
106
- type LocalTx,
107
101
  MemoryLocalStore,
108
- type OpfsLocalStoreOptions,
109
- type TableMap,
110
- } from './local-store';
102
+ openLocalStore,
103
+ pageLocalStore,
104
+ scopeKey,
105
+ } from './local-store-idb';
106
+ // ---- the offline outbox (wired over HTTP in plan 101 slice 12) ----------------------------------
111
107
  export {
112
108
  type DrainReport,
113
109
  MemoryQueueStore,
114
110
  type MutationSender,
115
111
  type MutationStatus,
116
- mutateFrame,
117
112
  OfflineQueue,
118
113
  type QueuedMutation,
119
114
  type QueueState,
120
115
  type QueueStore,
121
116
  } from './offline-queue';
122
- /** The typed projection: one query bound to one named hook, `useLiveFeed({ orgId })`. */
123
117
  export {
124
- type LiveQueryHook,
125
- type LiveQuerySource,
126
- liveHookFor,
127
- } from './query-hook';
118
+ createOutbox,
119
+ listenForDrain,
120
+ type OutboxEntry,
121
+ type PageOutbox,
122
+ pageOutbox,
123
+ } from './page-outbox';
124
+ // ---- the page: one record store, one socket, installed by the island bootstrap ------------------
125
+ export { hasPageSocket, type SyncTarget } from './page-store';
126
+ export { installRealtime, type RealtimeInstall } from './reactivity';
127
+ export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
128
128
  export {
129
- type ConflictStrategy,
130
- type CustomMerge,
131
- custom,
132
- type MergeArgs,
133
- type RebaseEntry,
134
- RebaseLog,
135
- type ReconcileOptions,
136
- type ReconcileResult,
137
- rebaseFrame,
138
- reconcile,
139
- type ServerAck,
140
- strategyName,
141
- } from './rebase';
142
- // ---- what a LiveClient IS on the server: it serves the first render and opens no socket --------
143
- export { serverRenderLiveClient } from './server-render-client';
129
+ type RecordKey,
130
+ type RecordListener,
131
+ RecordStore,
132
+ type RecordStoreOptions,
133
+ recordKey,
134
+ } from './record-store';
135
+ export type { LocalTable, LocalTx, TableMap } from './record-tx';
144
136
  // ---- the wire -------------------------------------------------------------------------------------
145
137
  export {
146
138
  type AckFrame,
147
- type ConflictStrategyName,
148
139
  decode,
149
140
  encode,
150
141
  FRAME_KINDS,
@@ -152,12 +143,9 @@ export {
152
143
  type Frame,
153
144
  type FrameKind,
154
145
  type HelloFrame,
155
- type MutateFrame,
156
146
  type PatchFrame,
157
147
  PROTOCOL_VERSION,
158
- type PresenceFrame,
159
148
  type PresenceMember,
160
- type RebaseFrame,
161
149
  type ReconnectFrame,
162
150
  type SnapshotFrame,
163
151
  type SubscribeFrame,
@@ -169,7 +157,8 @@ export {
169
157
  // ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
170
158
  export {
171
159
  type BackoffPolicy,
172
- backoffDelay,
160
+ BROWSER_RECONNECT_MAX_MS,
161
+ browserBackoff,
173
162
  defaultBackoff,
174
163
  type JitterMode,
175
164
  type ReconnectReason,
@@ -177,3 +166,19 @@ export {
177
166
  type Scheduler,
178
167
  timeoutScheduler,
179
168
  } from './thundering-herd';
169
+ export {
170
+ type ChannelAccessor,
171
+ type PresenceAccessor,
172
+ useChannel,
173
+ usePresence,
174
+ } from './use-channel';
175
+ export { type Connection, useConnection } from './use-connection';
176
+ export {
177
+ type Mutate,
178
+ type MutationQueue,
179
+ type MutatorLike,
180
+ useMutation,
181
+ useMutationQueue,
182
+ } from './use-mutation';
183
+ export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
184
+ export { type RecordAccessor, type RecordsAccessor, useRecord, useRecords } from './use-record';