@ultimat3/realtime 23.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 (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -0,0 +1,91 @@
1
+ // @ultimat3/realtime's owned codes and their TITLES, registered at import — the one module
2
+ // `sideEffects` names, so `errors.ts` (the sync node's refusal classes) is unlisted and no hook's
3
+ // chunk carries it (`page-errors-bundle.test.ts`): measured on Bun 1.4.2, −1,081 B on `feed`, −1,051 B on `likes-badge`, −1,047 B on
4
+ // `run-console`, every title still registered. Imported bare by `index.ts` (a page
5
+ // rebuilds a node's refusal by code in `refusalError`, and `page-errors.ts` constructs codes from
6
+ // here without importing it) and by `errors.ts`, so constructing a server refusal registers too.
7
+
8
+ import { registerErrorCodes } from '@ultimat3/core';
9
+
10
+ /** Codes this package declares and owns. */
11
+ export const REALTIME_OWNED_ERROR_CODES = [
12
+ 'X_TOPIC_FORBIDDEN',
13
+ 'X_SUBSCRIPTION_LIMIT',
14
+ 'X_SUBSCRIPTION_ID_TAKEN',
15
+ 'X_FRAME_RATE_LIMIT',
16
+ 'X_PROTOCOL_VERSION',
17
+ 'X_CURSOR_STALE',
18
+ 'X_REBASE_CONFLICT',
19
+ 'X_TRANSPORT_UNAVAILABLE',
20
+ 'X_TRANSPORT_PROTOCOL',
21
+ 'X_REPLICATION_PROTOCOL',
22
+ 'X_REPLICATION_FAILED',
23
+ 'X_REPLICATOR_SLOT_HELD',
24
+ // Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
25
+ // forever: a log line from an older build still resolves through `x errors explain`.
26
+ 'X_LIVE_CLIENT_MISSING',
27
+ 'X_QUERY_NOT_SUBSCRIBABLE',
28
+ 'X_REALTIME_UNINSTALLED',
29
+ 'X_SYNC_UNCONFIGURED',
30
+ 'X_RECORD_REJECTED',
31
+ 'X_CHANNEL_DECLARATION_INVALID',
32
+ 'X_LOCAL_STORE_UNAVAILABLE',
33
+ 'X_LIVE_SERVER_RENDER',
34
+ 'X_LIVE_ROW_UNIDENTIFIED',
35
+ 'X_LIVE_QUERY_UNKNOWN',
36
+ 'X_LIVE_REPLICA_IDENTITY',
37
+ 'X_SOCKET_UNAUTHENTICATED',
38
+ 'X_SOCKET_AUTH_UNAVAILABLE',
39
+ 'X_REALTIME_TOPOLOGY',
40
+ 'X_REPLICATION_TLS',
41
+ 'X_SOCKET_ORIGIN_REFUSED',
42
+ 'X_OFFLINE_QUEUE_ABANDONED',
43
+ 'X_SOCKET_LIMIT',
44
+ 'X_REALTIME_PRODUCER_CONFLICT',
45
+ ] as const;
46
+
47
+ export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
48
+
49
+ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
50
+ X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
51
+ X_SUBSCRIPTION_LIMIT: 'socket, actor, tenant or node hit its subscription cap',
52
+ X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
53
+ X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
54
+ X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
55
+ X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
56
+ X_REBASE_CONFLICT: 'a local mutation could not be rebased',
57
+ X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
58
+ X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
59
+ X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
60
+ X_REPLICATION_FAILED: 'the replication connection was refused',
61
+ X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
62
+ X_LIVE_CLIENT_MISSING:
63
+ 'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
64
+ X_QUERY_NOT_SUBSCRIBABLE:
65
+ 'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
66
+ X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
67
+ X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
68
+ X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
69
+ X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
70
+ X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
71
+ X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
72
+ X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
73
+ X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
74
+ X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
75
+ X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
76
+ X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
77
+ X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
78
+ X_REPLICATION_TLS: 'the replication connection failed TLS',
79
+ X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
80
+ X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
81
+ X_SOCKET_LIMIT: 'one principal holds too many sockets on this node',
82
+ X_REALTIME_PRODUCER_CONFLICT: 'two kinds of producer carry one table on the change bus',
83
+ };
84
+
85
+ // One unconditional call, so a second package claiming one of realtime's codes throws
86
+ // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
87
+ registerErrorCodes(
88
+ Object.fromEntries(
89
+ Object.entries(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
90
+ ),
91
+ );
package/src/errors.ts CHANGED
@@ -1,42 +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 { registerErrorCodes } 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
- ] as const;
12
+ export {
13
+ REALTIME_ERROR_TITLES,
14
+ REALTIME_OWNED_ERROR_CODES,
15
+ type RealtimeOwnedErrorCode,
16
+ } from './error-titles';
40
17
 
41
18
  /**
42
19
  * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
@@ -61,6 +38,24 @@ export const POLICY_DENIAL_CODES: ReadonlySet<string> = new Set([
61
38
  'X_UNAUTHENTICATED',
62
39
  ]);
63
40
 
41
+ /**
42
+ * `@ultimat3/entity`'s tenant guard refusing a read FOR THIS ACTOR: it carries no org, names
43
+ * another one, or reached across tenants without the capability. Verdicts about who is asking, so
44
+ * a channel's row loader raising one is a denial. `X_TENANCY_UNSCOPED` is deliberately absent —
45
+ * that is a query written without a tenant predicate, which is the app's defect and no decision.
46
+ * Spelled by name, never matched by prefix: a code's meaning is its owner's, not its shape's.
47
+ */
48
+ export const TENANCY_DENIAL_CODES: ReadonlySet<string> = new Set([
49
+ 'X_TENANCY_ACTOR_ORG_REQUIRED',
50
+ 'X_TENANCY_ACTOR_MISMATCH',
51
+ 'X_TENANCY_CROSS_DENIED',
52
+ ]);
53
+
54
+ /** True when the tenant guard decided against the actor — see `TENANCY_DENIAL_CODES`. */
55
+ export function isTenancyDenial(error: unknown): boolean {
56
+ return TENANCY_DENIAL_CODES.has(codeOf(error) ?? '');
57
+ }
58
+
64
59
  /**
65
60
  * The sync protocol's answer to "which of these is a 4xx". A denied topic, a subscription cap, a
66
61
  * skewed protocol version and a cursor that fell out of the buffer are all conditions the CLIENT
@@ -112,53 +107,12 @@ export const REALTIME_ERROR_CODES = [
112
107
  ...REALTIME_BORROWED_ERROR_CODES,
113
108
  ] as const;
114
109
 
115
- export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
116
110
  export type RealtimeErrorCode = (typeof REALTIME_ERROR_CODES)[number];
117
111
 
118
- export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
119
- X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
120
- X_SUBSCRIPTION_LIMIT: 'socket, tenant or node hit its subscription cap',
121
- X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
122
- X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
123
- X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
124
- X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
125
- X_REBASE_CONFLICT: 'a local mutation could not be rebased',
126
- X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
127
- X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
128
- X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
129
- X_REPLICATION_FAILED: 'the replication connection was refused',
130
- X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
131
- X_LIVE_CLIENT_MISSING:
132
- 'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
133
- X_QUERY_NOT_SUBSCRIBABLE:
134
- 'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
135
- X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
136
- X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
137
- X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
138
- X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
139
- X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
140
- X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
141
- X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
142
- X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
143
- X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
144
- X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
145
- X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
146
- X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
147
- X_REPLICATION_TLS: 'the replication connection failed TLS',
148
- X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
149
- };
150
-
151
- // One unconditional call, so a second package claiming one of realtime's codes throws
152
- // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
153
- registerErrorCodes(
154
- Object.fromEntries(
155
- Object.entries(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
156
- ),
157
- );
158
-
159
112
  export {
160
113
  CursorStaleError,
161
114
  LocalStoreUnavailableError,
115
+ OfflineQueueAbandonedError,
162
116
  ProtocolVersionError,
163
117
  RealtimeUninstalledError,
164
118
  RebaseConflictError,
@@ -168,9 +122,9 @@ export {
168
122
  } from './page-errors';
169
123
  // Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
170
124
  // replication errors in `replication-errors.ts` and the browser-reachable refusals in
171
- // `page-errors.ts`, so this file is the CODE TABLE plus the server's refusals. Every name is still
172
- // importable from `./errors`, which is what the `pg-*` modules and the barrel already do; a
173
- // 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.
174
128
  export { RealtimeError } from './realtime-error';
175
129
  export {
176
130
  ReplicaIdentityError,
@@ -193,17 +147,19 @@ export class TopicForbiddenError extends RealtimeError {
193
147
  }
194
148
 
195
149
  /**
196
- * 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.
197
151
  *
198
152
  * `knob` is the option that raises it, and it is passed rather than derived because the `node`
199
153
  * scope has more than one — a live-query entry ceiling and a channel-topic ceiling are two
200
- * different numbers on two different objects. The fix names the constructor option, never an
201
- * `app.config.ts` field: there is none (`docs/architecture/07-realtime-internals.md:244`), and a
202
- * 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.
203
159
  */
204
160
  export class SubscriptionLimitError extends RealtimeError {
205
161
  constructor(args: {
206
- scope: 'socket' | 'tenant' | 'node';
162
+ scope: 'socket' | 'tenant' | 'actor' | 'node';
207
163
  id: string;
208
164
  limit: number;
209
165
  knob?: string;
@@ -212,7 +168,10 @@ export class SubscriptionLimitError extends RealtimeError {
212
168
  super({
213
169
  code: 'X_SUBSCRIPTION_LIMIT',
214
170
  cause: `${args.scope} ${args.id} reached the subscription cap of ${args.limit}`,
215
- 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`,
216
175
  });
217
176
  }
218
177
  }
@@ -230,7 +189,7 @@ export class FrameRateLimitError extends RealtimeError {
230
189
  super({
231
190
  code: 'X_FRAME_RATE_LIMIT',
232
191
  cause: `socket ${args.socketId} exceeded ${args.perSecond} frames per second`,
233
- 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',
234
193
  });
235
194
  }
236
195
  }
@@ -300,17 +259,33 @@ export class TransportProtocolError extends RealtimeError {
300
259
  }
301
260
 
302
261
  /**
303
- * A subscribable read projected a row with no `id`. Patches, cursors and the local store all
304
- * address a row by `id`, so such a row cannot be delivered — and delivering it anyway produces a
305
- * subscription that looks correct until the first update nobody can apply.
262
+ * A subscribable read projected a row with no usable `id`. Patches, cursors and the local store all
263
+ * address a row by a text `id`, so such a row cannot be delivered — and delivering it anyway
264
+ * produces a subscription that looks correct until the first update nobody can apply.
265
+ *
266
+ * `idType` is set when the row HAS an id that is neither text nor a safe integer (the one numeric
267
+ * shape the change feed and the snapshot both stringify): "select the primary key" is then the
268
+ * wrong instruction, because the key is selected.
306
269
  */
307
270
  export class LiveRowUnidentifiedError extends RealtimeError {
308
- constructor(args: { query: string; keys: readonly string[] }) {
309
- super({
310
- code: 'X_LIVE_ROW_UNIDENTIFIED',
311
- cause: `live query "${args.query}" returned a row with no id (columns: ${args.keys.join(', ') || 'none'})`,
312
- fix: `select the primary key in ${args.query}'s sql(), or drop live: true from it`,
313
- });
271
+ constructor(args: { query: string; keys: readonly string[]; idType?: string }) {
272
+ const columns = args.keys.join(', ') || 'none';
273
+ super(
274
+ args.idType === undefined
275
+ ? {
276
+ code: 'X_LIVE_ROW_UNIDENTIFIED',
277
+ cause: `live query "${args.query}" returned a row with no id (columns: ${columns})`,
278
+ fix: `select the primary key in ${args.query}'s sql(), or drop live: true from it`,
279
+ }
280
+ : {
281
+ code: 'X_LIVE_ROW_UNIDENTIFIED',
282
+ cause: `live query "${args.query}" returned a row whose id is a ${args.idType}, not text or a safe integer — a live row is addressed by a text id, so project the key as text (a uuid or text column) or drop live: true from the query`,
283
+ // A name the shell would not read verbatim does not travel: the list names it instead.
284
+ fix: isFixShellSafe(args.query)
285
+ ? `x queries describe ${renderFixShellArg(args.query, 'name')} --json`
286
+ : 'x queries list --json',
287
+ },
288
+ );
314
289
  }
315
290
  }
316
291
 
@@ -351,7 +326,26 @@ export class SocketUnauthenticatedError extends RealtimeError {
351
326
  super({
352
327
  code: 'X_SOCKET_UNAUTHENTICATED',
353
328
  cause: `the websocket upgrade was refused: ${args.reason}`,
354
- 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`,
355
349
  });
356
350
  }
357
351
  }
@@ -363,11 +357,21 @@ export class SocketUnauthenticatedError extends RealtimeError {
363
357
  * the accept budget, so a hostile page costs neither.
364
358
  */
365
359
  export class SocketOriginRefusedError extends RealtimeError {
366
- constructor(args: { reason: string }) {
360
+ constructor(args: { reason: string; asked: string | null; admitted: readonly string[] }) {
361
+ // The asking origin is a request header: it travels only as the origin a URL parser read out
362
+ // of it AND only when the shell screen carries it verbatim, so neither the cause nor the
363
+ // pasted `export` can hold anything the client wrote beyond a scheme, a host and a port.
364
+ const parsed = URL.parse(args.asked ?? '')?.origin;
365
+ const asked = isFixShellSafe(parsed) ? parsed : null;
366
+ const { admitted } = args;
367
+ const list =
368
+ admitted.length === 0
369
+ ? 'only the origin it was reached on (no APP_URL declared)'
370
+ : `only ${admitted.join(', ')}`;
367
371
  super({
368
372
  code: 'X_SOCKET_ORIGIN_REFUSED',
369
- cause: `the websocket upgrade was refused: ${args.reason}`,
370
- fix: 'export APP_URL="https://www.example.com" # on the sync role: the origin the page is served on (or createSyncNode({ allowedOrigins }))',
373
+ cause: `the websocket upgrade was refused: ${args.reason} — asked from ${asked ?? 'an origin that cannot be rendered'}; this node admits ${list}`,
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 }))`,
371
375
  });
372
376
  }
373
377
  }
@@ -405,13 +409,17 @@ export class WindowReadTimeoutError extends RealtimeError {
405
409
  }
406
410
  }
407
411
 
408
- /** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */
409
- export class NotImplementedError extends RealtimeError {
410
- constructor(args: { what: string; fix: string }) {
412
+ /**
413
+ * A grant's `refresh()` — the app's token service — did not answer inside its deadline. The grant
414
+ * is NOT renewed and NOT revoked: it stays expired and the next re-auth pass asks again. What the
415
+ * deadline buys is the rest of the pass, which one call that never settles used to park forever.
416
+ */
417
+ export class GrantRefreshTimeoutError extends RealtimeError {
418
+ constructor(args: { socketId: string; afterMs: number }) {
411
419
  super({
412
- code: 'X_NOT_IMPLEMENTED',
413
- cause: `${args.what} is interface-complete but not implemented in this build`,
414
- fix: args.fix,
420
+ code: 'X_TIMEOUT',
421
+ cause: `refresh() for the grant of socket ${args.socketId} did not answer within ${args.afterMs}ms, so the re-auth pass moved on; the grant stays expired and is asked again on the next pass`,
422
+ fix: 'x doctor --json',
415
423
  });
416
424
  }
417
425
  }
package/src/fanout.ts CHANGED
@@ -36,6 +36,14 @@ export interface Transport {
36
36
  publish(subject: string, payload: string): Promise<void>;
37
37
  subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription>;
38
38
  readonly shared: TransportSet;
39
+ /**
40
+ * Told each time the connection behind this transport comes back from a drop. Fanout is
41
+ * at-most-once, so whatever was published in between never arrived — and with no later message
42
+ * there is no sequence number to notice it by. A subscriber that keeps derived state (a `sync`
43
+ * node's windows) invalidates it here. Returns the unsubscribe. A transport with no connection
44
+ * to lose never calls the listener.
45
+ */
46
+ onReconnect(listener: () => void): () => void;
39
47
  close(): Promise<void>;
40
48
  }
41
49
 
@@ -125,6 +133,11 @@ export class InProcessTransport implements Transport {
125
133
  }
126
134
  }
127
135
 
136
+ /** One process, no connection: there is nothing to come back from, so nothing is ever said. */
137
+ onReconnect(): () => void {
138
+ return () => undefined;
139
+ }
140
+
128
141
  async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
129
142
  this.#assertOpen();
130
143
  const handlers = this.#handlers.get(subject) ?? new Set<TransportHandler>();
@@ -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 --------
@@ -62,7 +68,7 @@ export {
62
68
  FrameRateLimitError,
63
69
  LiveQueryUnknownError,
64
70
  LiveRowUnidentifiedError,
65
- NotImplementedError,
71
+ OfflineQueueAbandonedError,
66
72
  ProtocolVersionError,
67
73
  REALTIME_ERROR_CODES,
68
74
  REALTIME_ERROR_TITLES,
@@ -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;