@ultimat3/realtime 24.0.0 → 25.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 (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. package/src/policy-fake.ts +0 -14
package/src/errors.ts CHANGED
@@ -1,43 +1,19 @@
1
1
  // Realtime's X_* codes. Every throw in this package goes through one of these classes so
2
2
  // the same string renders in the terminal, the browser overlay, and `--json`.
3
3
 
4
- import { isFixShellSafe, registerErrorCodes, renderFixShellArg } from '@ultimat3/core';
4
+ import { isFixShellSafe, renderFixShellArg } from '@ultimat3/core';
5
+ // The owned code list and its titles are `error-titles.ts` — the one module `sideEffects` lists,
6
+ // imported bare here and from the barrel — so this module is side-effect-free and a chunk keeps
7
+ // only the classes it constructs. Re-exported, so every name is still importable from `./errors`.
8
+ import './error-titles';
9
+ import { REALTIME_OWNED_ERROR_CODES } from './error-titles';
5
10
  import { RealtimeError } from './realtime-error';
6
11
 
7
- /** Codes this package declares and owns. */
8
- export const REALTIME_OWNED_ERROR_CODES = [
9
- 'X_TOPIC_FORBIDDEN',
10
- 'X_SUBSCRIPTION_LIMIT',
11
- 'X_SUBSCRIPTION_ID_TAKEN',
12
- 'X_FRAME_RATE_LIMIT',
13
- 'X_PROTOCOL_VERSION',
14
- 'X_CURSOR_STALE',
15
- 'X_REBASE_CONFLICT',
16
- 'X_TRANSPORT_UNAVAILABLE',
17
- 'X_TRANSPORT_PROTOCOL',
18
- 'X_REPLICATION_PROTOCOL',
19
- 'X_REPLICATION_FAILED',
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`.
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',
30
- 'X_LIVE_SERVER_RENDER',
31
- 'X_LIVE_ROW_UNIDENTIFIED',
32
- 'X_LIVE_QUERY_UNKNOWN',
33
- 'X_LIVE_REPLICA_IDENTITY',
34
- 'X_SOCKET_UNAUTHENTICATED',
35
- 'X_SOCKET_AUTH_UNAVAILABLE',
36
- 'X_REALTIME_TOPOLOGY',
37
- 'X_REPLICATION_TLS',
38
- 'X_SOCKET_ORIGIN_REFUSED',
39
- 'X_OFFLINE_QUEUE_ABANDONED',
40
- ] as const;
12
+ export {
13
+ REALTIME_ERROR_TITLES,
14
+ REALTIME_OWNED_ERROR_CODES,
15
+ type RealtimeOwnedErrorCode,
16
+ } from './error-titles';
41
17
 
42
18
  /**
43
19
  * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
@@ -131,51 +107,8 @@ export const REALTIME_ERROR_CODES = [
131
107
  ...REALTIME_BORROWED_ERROR_CODES,
132
108
  ] as const;
133
109
 
134
- export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
135
110
  export type RealtimeErrorCode = (typeof REALTIME_ERROR_CODES)[number];
136
111
 
137
- export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
138
- X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
139
- X_SUBSCRIPTION_LIMIT: 'socket, tenant or node hit its subscription cap',
140
- X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
141
- X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
142
- X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
143
- X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
144
- X_REBASE_CONFLICT: 'a local mutation could not be rebased',
145
- X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
146
- X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
147
- X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
148
- X_REPLICATION_FAILED: 'the replication connection was refused',
149
- X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
150
- X_LIVE_CLIENT_MISSING:
151
- 'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
152
- X_QUERY_NOT_SUBSCRIBABLE:
153
- 'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
154
- X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
155
- X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
156
- X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
157
- X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
158
- X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
159
- X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
160
- X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
161
- X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
162
- X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
163
- X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
164
- X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
165
- X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
166
- X_REPLICATION_TLS: 'the replication connection failed TLS',
167
- X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
168
- X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
169
- };
170
-
171
- // One unconditional call, so a second package claiming one of realtime's codes throws
172
- // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
173
- registerErrorCodes(
174
- Object.fromEntries(
175
- Object.entries(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
176
- ),
177
- );
178
-
179
112
  export {
180
113
  CursorStaleError,
181
114
  LocalStoreUnavailableError,
@@ -189,9 +122,9 @@ export {
189
122
  } from './page-errors';
190
123
  // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
191
124
  // replication errors in `replication-errors.ts` and the browser-reachable refusals in
192
- // `page-errors.ts`, so this file is the CODE TABLE plus the server's refusals. Every name is still
193
- // importable from `./errors`, which is what the `pg-*` modules and the barrel already do; a
194
- // browser module imports `./page-errors` so it does not load the table.
125
+ // `page-errors.ts`, and the code table in `error-titles.ts`, so this file is the server's refusals.
126
+ // Every name is still importable from `./errors`, which is what the `pg-*` modules and the barrel
127
+ // already do; a browser module imports `./page-errors` so it does not load these classes.
195
128
  export { RealtimeError } from './realtime-error';
196
129
  export {
197
130
  ReplicaIdentityError,
@@ -214,17 +147,19 @@ export class TopicForbiddenError extends RealtimeError {
214
147
  }
215
148
 
216
149
  /**
217
- * Load shedding, not a crash: a socket, a tenant or this node asked for more than its cap.
150
+ * Load shedding, not a crash: a socket, a tenant, an actor or this node asked for more than its cap.
218
151
  *
219
152
  * `knob` is the option that raises it, and it is passed rather than derived because the `node`
220
153
  * scope has more than one — a live-query entry ceiling and a channel-topic ceiling are two
221
- * different numbers on two different objects. The fix names the constructor option, never an
222
- * `app.config.ts` field: there is none (`docs/architecture/07-realtime-internals.md:244`), and a
223
- * fix line naming a field that does not exist is an instruction that cannot be followed.
154
+ * different numbers on two different objects. Every scope but `actor` names a CONSTRUCTOR option:
155
+ * those have no `app.config.ts` field, and a fix naming a field that does not exist is an
156
+ * instruction that cannot be followed. The actor cap is the one that is app config —
157
+ * `realtime.maxSubscriptionsPerActor` — so its fix names that key and that file first, then the
158
+ * registry option a host that builds its own `LiveQueryRegistry` sets instead.
224
159
  */
225
160
  export class SubscriptionLimitError extends RealtimeError {
226
161
  constructor(args: {
227
- scope: 'socket' | 'tenant' | 'node';
162
+ scope: 'socket' | 'tenant' | 'actor' | 'node';
228
163
  id: string;
229
164
  limit: number;
230
165
  knob?: string;
@@ -233,7 +168,10 @@ export class SubscriptionLimitError extends RealtimeError {
233
168
  super({
234
169
  code: 'X_SUBSCRIPTION_LIMIT',
235
170
  cause: `${args.scope} ${args.id} reached the subscription cap of ${args.limit}`,
236
- fix: `raise ${knob} where this sync node is constructed, or unsubscribe unused live queries`,
171
+ fix:
172
+ args.scope === 'actor'
173
+ ? `defineConfig({ realtime: { maxSubscriptionsPerActor: ${args.limit * 2} } }) # in app.config.ts — realtime.maxSubscriptionsPerActor; or maxPerActor on new LiveQueryRegistry({…}) where a host builds its own; or unsubscribe unused live queries`
174
+ : `raise ${knob} where this sync node is constructed, or unsubscribe unused live queries`,
237
175
  });
238
176
  }
239
177
  }
@@ -251,7 +189,7 @@ export class FrameRateLimitError extends RealtimeError {
251
189
  super({
252
190
  code: 'X_FRAME_RATE_LIMIT',
253
191
  cause: `socket ${args.socketId} exceeded ${args.perSecond} frames per second`,
254
- fix: 'batch subscribes into one frame per subscription and retry after the delay, or raise maxFramesPerSecond where createSyncNode() is called',
192
+ fix: 'batch subscribes into one frame per subscription and retry after the delay, or raise maxFramesPerSecond where syncNode() is called',
255
193
  });
256
194
  }
257
195
  }
@@ -388,7 +326,26 @@ export class SocketUnauthenticatedError extends RealtimeError {
388
326
  super({
389
327
  code: 'X_SOCKET_UNAUTHENTICATED',
390
328
  cause: `the websocket upgrade was refused: ${args.reason}`,
391
- fix: 'send the credential createSyncNode({ authenticate }) reads on the upgrade request, or return an anonymous Actor from it to admit this socket',
329
+ fix: 'send the credential syncNode({ authenticate }) reads on the upgrade request, or return an anonymous Actor from it to admit this socket',
330
+ });
331
+ }
332
+ }
333
+
334
+ /**
335
+ * One principal — an actor, or an anonymous caller's network — already holds this node's per-
336
+ * principal share of sockets. Answered on the upgrade with 429, before a socket exists. The
337
+ * principal is the node's own key (`actor:<id>` / `address:<network>`), never request text.
338
+ */
339
+ export class SocketLimitError extends RealtimeError {
340
+ constructor(args: { principal: string; limit: number }) {
341
+ // An anonymous network's cap is 8 x `maxSocketsPerActor` (`principal-sockets.ts`), so the
342
+ // value to set is derived from the actor cap either way, never the limit this socket hit.
343
+ const network = args.principal.startsWith('address:');
344
+ const perActor = network ? Math.ceil(args.limit / 8) : args.limit;
345
+ super({
346
+ code: 'X_SOCKET_LIMIT',
347
+ cause: `${args.principal} already holds ${args.limit} sockets on this sync node — the per-principal cap${network ? ' (an anonymous network gets 8 x realtime.maxSocketsPerActor)' : ''}`,
348
+ fix: `defineConfig({ realtime: { maxSocketsPerActor: ${perActor * 2} } }) # in app.config.ts — or maxSocketsPerActor on syncNode({…}) where a host builds its own; or close other tabs of this app`,
392
349
  });
393
350
  }
394
351
  }
@@ -414,7 +371,7 @@ export class SocketOriginRefusedError extends RealtimeError {
414
371
  super({
415
372
  code: 'X_SOCKET_ORIGIN_REFUSED',
416
373
  cause: `the websocket upgrade was refused: ${args.reason} — asked from ${asked ?? 'an origin that cannot be rendered'}; this node admits ${list}`,
417
- fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or createSyncNode({ allowedOrigins }))`,
374
+ fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or syncNode({ allowedOrigins }))`,
418
375
  });
419
376
  }
420
377
  }
@@ -0,0 +1,49 @@
1
+ // What a realtime island's bootstrap awaits before its `mount` may run (#506, owner option 3): the
2
+ // page boot's disk restore AND the outbox it opened, so the island's first render already carries
3
+ // the overlays a reload rebuilds from the queue — never the server's stale count. Capped, because
4
+ // an IndexedDB that hangs must cost the hold, never the island.
5
+
6
+ import { peekOutbox } from './outbox-slot';
7
+ import { pageRealtime } from './page-store';
8
+
9
+ /**
10
+ * The longest a realtime island waits on the restore before it mounts anyway. A warm IndexedDB
11
+ * opens in tens of milliseconds; a second is a disk that is not answering, and past it the island
12
+ * paints what it has. Below `@ultimat3/render`'s `ISLAND_HOLD_MS`, which reveals the server's
13
+ * markup regardless — so this cap, not that one, is the one a slow disk meets.
14
+ */
15
+ export const FIRST_PAINT_HOLD_MS = 1_000;
16
+
17
+ /** `settled` — the restore finished (or was refused; there is nothing more to wait for). */
18
+ export type FirstPaintHold = 'settled' | 'capped';
19
+
20
+ /**
21
+ * Resolves once the page's records are back AND its outbox is open — what `useMutation` rebuilds a
22
+ * queued write's overlay from, synchronously from then on — or at the cap. Never rejects: a hold
23
+ * that threw would take the island's module graph, and with it the island, down with the disk.
24
+ * A page with no boot resolves at once (`booted` does, and no outbox is seated). No options: the
25
+ * cap is the constant, and an option would ship its finite-number screen (~550 B) in every
26
+ * realtime island to serve tests that can stub the timer instead.
27
+ */
28
+ export async function holdFirstPaint(): Promise<FirstPaintHold> {
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ const capped = new Promise<FirstPaintHold>((resolve) => {
31
+ timer = setTimeout(() => resolve('capped'), FIRST_PAINT_HOLD_MS);
32
+ });
33
+ try {
34
+ return await Promise.race([restored(), capped]);
35
+ } finally {
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+
40
+ async function restored(): Promise<FirstPaintHold> {
41
+ try {
42
+ await pageRealtime().booted;
43
+ // Read after the boot, never before: the boot seats the outbox as its last step.
44
+ await peekOutbox()?.ready;
45
+ } catch {
46
+ // The boot already reported what the disk refused (`boot.ts`); the hold only stops waiting.
47
+ }
48
+ return 'settled';
49
+ }
package/src/idb-types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
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.
3
+ * `indexedDB` satisfies them and so does `idb-fake-fixture.ts`, so the store is tested against the
4
+ * same surface it runs on without a DOM shim.
5
5
  */
6
6
 
7
7
  export interface IdbRequestLike<T> {
package/src/index.ts CHANGED
@@ -3,6 +3,12 @@
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
+ // Imported bare, and the one module `sideEffects` lists: a page rebuilds a sync node's refusal by
7
+ // code (`refusalError`) and `page-errors.ts` constructs codes from it without importing it, so
8
+ // every chunk reaching this barrel registers the titles. `SIDE_EFFECTS_ANCHORS` carries the
9
+ // argument and `bun run side-effects` enforces it.
10
+ import './error-titles';
11
+
6
12
  // ---- the client's one stateless piece, reusable against an app's own store ----------------------
7
13
  export { applyPatches, orderAfterPatches } from './apply-patches';
8
14
  // ---- channels: the declaration is the only way to spell a topic, and the frames it rides --------
@@ -84,10 +90,15 @@ export {
84
90
  TransportUnavailableError,
85
91
  WindowReadTimeoutError,
86
92
  } from './errors';
93
+ // ---- the page: one record store, one socket, installed by the island bootstrap ------------------
94
+ export {
95
+ FIRST_PAINT_HOLD_MS,
96
+ type FirstPaintHold,
97
+ holdFirstPaint,
98
+ } from './first-paint-hold';
87
99
  // ---- shared value domain ---------------------------------------------------------------------
88
100
  export {
89
101
  changedColumns,
90
- isJsonObject,
91
102
  isRow,
92
103
  type JsonObject,
93
104
  type JsonValue,
@@ -99,7 +110,8 @@ export { type LiveState, type Registration, RowWindows, unnamedType } from './li
99
110
  // ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
100
111
  export {
101
112
  type LocalStore,
102
- MemoryLocalStore,
113
+ type MemoryLocalStore,
114
+ memoryLocalStore,
103
115
  openLocalStore,
104
116
  pageLocalStore,
105
117
  scopeKey,
@@ -107,22 +119,22 @@ export {
107
119
  // ---- the offline outbox (wired over HTTP in plan 101 slice 12) ----------------------------------
108
120
  export {
109
121
  type DrainReport,
110
- MemoryQueueStore,
122
+ type MemoryQueueStore,
111
123
  type MutationSender,
112
124
  type MutationStatus,
125
+ memoryQueueStore,
113
126
  OfflineQueue,
114
127
  type QueuedMutation,
115
128
  type QueueState,
116
129
  type QueueStore,
117
130
  } from './offline-queue';
118
131
  export {
119
- createOutbox,
120
132
  listenForDrain,
133
+ localOutbox,
121
134
  type OutboxEntry,
122
135
  type PageOutbox,
123
136
  pageOutbox,
124
137
  } from './page-outbox';
125
- // ---- the page: one record store, one socket, installed by the island bootstrap ------------------
126
138
  export { hasPageSocket, type SyncTarget } from './page-store';
127
139
  export { installRealtime, type RealtimeInstall } from './reactivity';
128
140
  export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
@@ -182,4 +194,10 @@ export {
182
194
  useMutationQueue,
183
195
  } from './use-mutation';
184
196
  export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
185
- export { type RecordAccessor, type RecordsAccessor, useRecord, useRecords } from './use-record';
197
+ export {
198
+ type RecordAccessor,
199
+ type RecordSelection,
200
+ type RecordsAccessor,
201
+ useRecord,
202
+ useRecords,
203
+ } from './use-record';
package/src/json.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  // for having no reader. A 32-bit hash nothing calls is a hash the next caller reaches for as a
9
9
  // sharing key, which is the one thing it must never be.
10
10
 
11
+ import { isJsonObject } from '@ultimat3/core/page';
12
+
11
13
  export type JsonValue =
12
14
  | string
13
15
  | number
@@ -39,14 +41,25 @@ export interface RowPatch {
39
41
  * projection on the server. Absent means the key is the `id`.
40
42
  */
41
43
  readonly key?: string;
44
+ /**
45
+ * The write that made this patch's change (`ChangeEvent.write`), held only on the RETAINED
46
+ * patch so a delta resume can name it. Server-side only: a frame names its writes once, in
47
+ * `PatchFrame.writes`, so `live-resume.ts` strips it before sending and `decode` never reads it.
48
+ */
49
+ readonly write?: string;
42
50
  }
43
51
 
44
- export function isJsonObject(value: unknown): value is JsonObject {
45
- return typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ /**
53
+ * Core's `isJsonObject`, read as the wire's value domain. Sound ONLY for a value that came out of
54
+ * `JSON.parse` (a frame, a stored row): every member of one is a `JsonValue` by construction, which
55
+ * is the one thing core's shape check cannot know and so does not claim. One implementation, core's.
56
+ */
57
+ export function isParsedJsonObject(value: unknown): value is JsonObject {
58
+ return isJsonObject(value);
46
59
  }
47
60
 
48
61
  export function isRow(value: unknown): value is Row {
49
- return isJsonObject(value) && typeof value['id'] === 'string';
62
+ return isParsedJsonObject(value) && typeof value['id'] === 'string';
50
63
  }
51
64
 
52
65
  /** Shallow diff used to keep update patches minimal — the pipeline never ships unchanged columns. */
@@ -34,6 +34,13 @@ export interface LiveQueryDefinition<R extends Row = Row> {
34
34
  snapshot(args: { input: JsonValue; tenant: string | null }): Promise<SnapshotResult<R>>;
35
35
  /** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
36
36
  authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
37
+ /**
38
+ * The read's declared `rateLimit:`, spent once per SUBSCRIBE, after `authorize` allowed it —
39
+ * the same bucket HTTP, a paged read and the MCP tool spend. Per subscriber because the shared
40
+ * window builds with no subject and spends nothing; never on a re-authorization, because the
41
+ * subscription it re-decides was already paid for. Throws `X_RATE_LIMITED` to refuse.
42
+ */
43
+ spend?(caller: LiveSpender): Promise<void>;
37
44
  /** Row-level gate, evaluated per subscriber. The only row filter in the pipeline. */
38
45
  visible(args: { actor: Actor | null; row: R; input: JsonValue }): boolean | Promise<boolean>;
39
46
  /** Built once per `qid`, since a qid pins both the query and its input. */
@@ -59,6 +66,13 @@ export interface LiveQueryDefinition<R extends Row = Row> {
59
66
  prepare?(input: JsonValue): Promise<void>;
60
67
  }
61
68
 
69
+ /** Who a live subscribe is charged to: the socket's actor, else its address, on the node's clock. */
70
+ export interface LiveSpender {
71
+ readonly actor: Actor | null;
72
+ readonly clientAddress: string | null;
73
+ readonly nowMs: number;
74
+ }
75
+
62
76
  export interface LiveSubscription {
63
77
  readonly sid: string;
64
78
  readonly qid: string;
@@ -9,8 +9,14 @@
9
9
  // every time. Collapsing the second onto the first is privilege escalation with a cache hit rate.
10
10
 
11
11
  import type { Ctx } from '@ultimat3/core';
12
- import { finiteOption, runWithContext, serviceActor, withChildContext } from '@ultimat3/core';
13
- import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
12
+ import {
13
+ anonymousActor,
14
+ finiteOption,
15
+ runWithContext,
16
+ serviceActor,
17
+ withChildContext,
18
+ } from '@ultimat3/core';
19
+ import { type AnyQuery, queryHash, queryName, spendQueryLimit } from '@ultimat3/query';
14
20
  import { LiveRowUnidentifiedError } from './errors';
15
21
  import { isRow, type JsonValue, type Row } from './json';
16
22
  import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
@@ -182,6 +188,10 @@ export function liveQueryDefinition(
182
188
  // memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
183
189
  // delivery, and there is no key here an actor could share with another actor.
184
190
  authorize: authorizeWithPolicy(target.policy, { query: name, ctx: options.ctx }),
191
+ // The read's own `rateLimit:` bucket, per subscriber — the one HTTP and MCP spend. A socket
192
+ // with no actor is the anonymous reader, keyed by its address as an unauthenticated HTTP read is.
193
+ spend: ({ actor, clientAddress, nowMs }) =>
194
+ spendQueryLimit(target, { actor: actor ?? anonymousActor(), clientAddress, nowMs }),
185
195
  visible: visibleWithPolicy(target.policy, { query: name, ctx: options.ctx }),
186
196
  };
187
197
  }
@@ -0,0 +1,55 @@
1
+ // One change, fanned out over every live window on the node. Split from `live-query.ts` at its
2
+ // size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
3
+
4
+ import type { ChangeEvent } from './changefeed';
5
+ import { type FanoutDeps, fanoutChange } from './live-fanout';
6
+ import type { QueryEntry } from './query-window';
7
+
8
+ /**
9
+ * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
10
+ * number of frames sent — the metric the reconnect benchmark watches.
11
+ *
12
+ * Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
13
+ * `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
14
+ * to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
15
+ * subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
16
+ *
17
+ * Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
18
+ * second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
19
+ * claim true: two deliveries queue onto each query id in call order, serialized per query id and
20
+ * never per node — awaiting one entry before entering the next made one slow policy pass the
21
+ * whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
22
+ */
23
+ export async function deliverChange(
24
+ entries: Iterable<QueryEntry>,
25
+ fanout: FanoutDeps,
26
+ change: ChangeEvent,
27
+ onStale: (count: number) => void,
28
+ ): Promise<number> {
29
+ const lanes = [...entries].map(async (entry) => {
30
+ try {
31
+ const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
32
+ onStale(result.stale);
33
+ return result.sent;
34
+ } catch (error) {
35
+ // The window advanced under a fanout that did not finish, so every subscriber of this one
36
+ // query id now holds a cursor below the change and no later flush would correct them:
37
+ // desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
38
+ // `markDesynced` exists, and skipping this is how a failure became one.
39
+ for (const subscription of entry.subscribers.values()) {
40
+ subscription.socket.markDesynced(subscription.sid);
41
+ }
42
+ throw error;
43
+ }
44
+ });
45
+ // `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
46
+ // first failure still reaches the caller — `sync` logs it — but it costs one query id.
47
+ let sent = 0;
48
+ let failure: { readonly error: unknown } | null = null;
49
+ for (const lane of await Promise.allSettled(lanes)) {
50
+ if (lane.status === 'fulfilled') sent += lane.value;
51
+ else failure ??= { error: lane.reason };
52
+ }
53
+ if (failure !== null) throw failure.error;
54
+ return sent;
55
+ }