@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
package/src/errors.ts CHANGED
@@ -18,12 +18,19 @@ 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',
29
36
  ] as const;
@@ -118,12 +125,19 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
118
125
  X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
119
126
  X_REPLICATION_FAILED: 'the replication connection was refused',
120
127
  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',
128
+ X_LIVE_CLIENT_MISSING:
129
+ 'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
130
+ X_QUERY_NOT_SUBSCRIBABLE:
131
+ 'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
132
+ X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
133
+ X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
134
+ X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
135
+ X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
136
+ X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
122
137
  X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
123
138
  X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
124
139
  X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
125
140
  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
141
  X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
128
142
  X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
129
143
  };
@@ -136,10 +150,21 @@ registerErrorCodes(
136
150
  ),
137
151
  );
138
152
 
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.
153
+ export {
154
+ CursorStaleError,
155
+ LocalStoreUnavailableError,
156
+ ProtocolVersionError,
157
+ RealtimeUninstalledError,
158
+ RebaseConflictError,
159
+ RecordRejectedError,
160
+ ServerRenderLiveError,
161
+ SyncUnconfiguredError,
162
+ } from './page-errors';
163
+ // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
164
+ // replication errors in `replication-errors.ts` and the browser-reachable refusals in
165
+ // `page-errors.ts`, so this file is the CODE TABLE plus the server's refusals. Every name is still
166
+ // importable from `./errors`, which is what the `pg-*` modules and the barrel already do; a
167
+ // browser module imports `./page-errors` so it does not load the table.
143
168
  export { RealtimeError } from './realtime-error';
144
169
  export {
145
170
  ReplicaIdentityError,
@@ -154,7 +179,8 @@ export class TopicForbiddenError extends RealtimeError {
154
179
  super({
155
180
  code: 'X_TOPIC_FORBIDDEN',
156
181
  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 }) => ...)`,
182
+ // The topic's first segment is its channel's name; the policy on that declaration decides.
183
+ 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
184
  });
159
185
  }
160
186
  }
@@ -217,44 +243,6 @@ export class SubscriptionIdTakenError extends RealtimeError {
217
243
  }
218
244
  }
219
245
 
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
246
  /** The fanout bus is down. `sync` nodes are stateless, so this is always recoverable. */
259
247
  export class TransportUnavailableError extends RealtimeError {
260
248
  constructor(args: { transport: string; reason: string; fix?: string }) {
@@ -285,44 +273,6 @@ export class TransportProtocolError extends RealtimeError {
285
273
  }
286
274
  }
287
275
 
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
276
  /**
327
277
  * A subscribable read projected a row with no `id`. Patches, cursors and the local store all
328
278
  * address a row by `id`, so such a row cannot be delivered — and delivering it anyway produces a
@@ -362,23 +312,6 @@ export class LiveQueryUnknownError extends RealtimeError {
362
312
  }
363
313
  }
364
314
 
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
315
  /**
383
316
  * The app's `authenticate` decided this upgrade belongs to nobody. A **decision**, so it is the
384
317
  * 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,113 @@
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
+
18
+ /** A fresh, empty database server. Share one instance to simulate a reload over the same disk. */
19
+ export function fakeIndexedDb(options: FakeIdbOptions = {}): IdbFactoryLike {
20
+ const databases = new Map<string, { version: number; tables: Tables }>();
21
+ return {
22
+ open(name: string, version: number): IdbRequestLike<IdbDatabaseLike> {
23
+ const request = pendingRequest<IdbDatabaseLike>();
24
+ queueMicrotask(() => {
25
+ if (options.blocked === true) {
26
+ request.fail(new DOMException('The operation is insecure.', 'SecurityError'));
27
+ return;
28
+ }
29
+ const known = databases.get(name) ?? { version: 0, tables: new Map() };
30
+ databases.set(name, known);
31
+ const db = database(known.tables);
32
+ request.result = db;
33
+ if (known.version < version) {
34
+ known.version = version;
35
+ request.onupgradeneeded?.();
36
+ }
37
+ request.onsuccess?.();
38
+ });
39
+ return request;
40
+ },
41
+ };
42
+ }
43
+
44
+ function database(tables: Tables): IdbDatabaseLike {
45
+ return {
46
+ objectStoreNames: { contains: (name: string): boolean => tables.has(name) },
47
+ createObjectStore: (name: string): void => {
48
+ tables.set(name, new Map());
49
+ },
50
+ transaction(names: string | readonly string[]) {
51
+ let open = 0;
52
+ const failed = false;
53
+ const tx = {
54
+ oncomplete: null as (() => void) | null,
55
+ onerror: null as (() => void) | null,
56
+ error: null as unknown,
57
+ objectStore: (name: string): IdbStoreLike => store(name),
58
+ };
59
+ const settleLater = (): void => {
60
+ queueMicrotask(() => {
61
+ if (open > 0 || failed) return;
62
+ tx.oncomplete?.();
63
+ });
64
+ };
65
+ const run = <T>(work: () => T): IdbRequestLike<T> => {
66
+ const request = pendingRequest<T>();
67
+ open += 1;
68
+ queueMicrotask(() => {
69
+ open -= 1;
70
+ request.result = work();
71
+ request.onsuccess?.();
72
+ settleLater();
73
+ });
74
+ return request;
75
+ };
76
+ const store = (name: string): IdbStoreLike => {
77
+ const allowed = typeof names === 'string' ? [names] : names;
78
+ const table = tables.get(name);
79
+ if (!allowed.includes(name) || table === undefined) {
80
+ throw new DOMException(`no object store ${name}`, 'NotFoundError');
81
+ }
82
+ const sorted = (): [string, unknown][] =>
83
+ [...table].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
84
+ return {
85
+ put: (value, key) => run(() => void table.set(key, structuredClone(value))),
86
+ delete: (key) => run(() => void table.delete(key)),
87
+ getAll: () => run(() => sorted().map(([, value]) => structuredClone(value))),
88
+ getAllKeys: () => run(() => sorted().map(([key]) => key)),
89
+ };
90
+ };
91
+ settleLater();
92
+ return tx;
93
+ },
94
+ };
95
+ }
96
+
97
+ type PendingRequest<T> = IdbRequestLike<T> & { fail(error: unknown): void };
98
+
99
+ function pendingRequest<T>(): PendingRequest<T> {
100
+ const request: PendingRequest<T> = {
101
+ result: undefined as T,
102
+ error: null,
103
+ onsuccess: null,
104
+ onerror: null,
105
+ onupgradeneeded: null,
106
+ onblocked: null,
107
+ fail(error: unknown): void {
108
+ request.error = error;
109
+ request.onerror?.();
110
+ },
111
+ };
112
+ return request;
113
+ }
@@ -0,0 +1,41 @@
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
+ put(value: unknown, key: string): IdbRequestLike<unknown>;
18
+ delete(key: string): IdbRequestLike<unknown>;
19
+ getAll(): IdbRequestLike<unknown[]>;
20
+ getAllKeys(): IdbRequestLike<unknown[]>;
21
+ }
22
+
23
+ export interface IdbTransactionLike {
24
+ oncomplete: (() => void) | null;
25
+ onerror: (() => void) | null;
26
+ error: unknown;
27
+ objectStore(name: string): IdbStoreLike;
28
+ }
29
+
30
+ export interface IdbDatabaseLike {
31
+ readonly objectStoreNames: { contains(name: string): boolean };
32
+ createObjectStore(name: string): unknown;
33
+ transaction(
34
+ names: string | readonly string[],
35
+ mode?: 'readonly' | 'readwrite',
36
+ ): IdbTransactionLike;
37
+ }
38
+
39
+ export interface IdbFactoryLike {
40
+ open(name: string, version: number): IdbRequestLike<IdbDatabaseLike>;
41
+ }
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,9 @@ export {
169
157
  // ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
170
158
  export {
171
159
  type BackoffPolicy,
160
+ BROWSER_RECONNECT_MAX_MS,
172
161
  backoffDelay,
162
+ browserBackoff,
173
163
  defaultBackoff,
174
164
  type JitterMode,
175
165
  type ReconnectReason,
@@ -177,3 +167,19 @@ export {
177
167
  type Scheduler,
178
168
  timeoutScheduler,
179
169
  } from './thundering-herd';
170
+ export {
171
+ type ChannelAccessor,
172
+ type PresenceAccessor,
173
+ useChannel,
174
+ usePresence,
175
+ } from './use-channel';
176
+ export { type Connection, useConnection } from './use-connection';
177
+ export {
178
+ type Mutate,
179
+ type MutationQueue,
180
+ type MutatorLike,
181
+ useMutation,
182
+ useMutationQueue,
183
+ } from './use-mutation';
184
+ export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
185
+ export { type RecordAccessor, type RecordsAccessor, useRecord, useRecords } from './use-record';
package/src/json.ts CHANGED
@@ -34,6 +34,11 @@ export interface RowPatch {
34
34
  readonly row: JsonObject | null;
35
35
  readonly lsn: string;
36
36
  readonly index?: number;
37
+ /**
38
+ * The row's RECORD key when it is not its `id` — the entity's primary key, rendered by its
39
+ * projection on the server. Absent means the key is the `id`.
40
+ */
41
+ readonly key?: string;
37
42
  }
38
43
 
39
44
  export function isJsonObject(value: unknown): value is JsonObject {
@@ -41,6 +41,11 @@ export interface LiveQueryDefinition<R extends Row = Row> {
41
41
  * rows private to that one subscription rather than guessing.
42
42
  */
43
43
  rowEntity?(input: JsonValue): string | null;
44
+ /**
45
+ * The record key each row travels under, resolved with `rowEntity`: the entity's own projection.
46
+ * `null` (or absent) is a plain table, whose rows are keyed by `id`.
47
+ */
48
+ rowKey?(input: JsonValue): ((row: Row) => string) | null;
44
49
  /**
45
50
  * Resolve whatever this input needs before an entry is built. `matcher` is synchronous by
46
51
  * design — a change event must not await anything — so a definition that has to compile a