@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
package/src/server.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // island realtime promises unbuildable. Every name here has exactly one home; the shared
5
5
  // vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
6
6
 
7
+ export { type AdvisoryLock, type MemoryAdvisoryLock, memoryAdvisoryLock } from './advisory-lock';
7
8
  // ---- the retained change window one node fans out from ------------------------------------------
8
9
  export {
9
10
  type ChangeBufferOptions,
@@ -18,11 +19,13 @@ export {
18
19
  type ChangeFeedStartOptions,
19
20
  type ChangeOp,
20
21
  formatLsn,
21
- InMemoryChangeFeed,
22
- type InMemoryChangeFeedOptions,
23
- PgLogicalReplicationFeed,
24
- type PgLogicalReplicationOptions,
22
+ type MemoryChangeFeed,
23
+ type MemoryChangeFeedOptions,
24
+ memoryChangeFeed,
25
+ type PostgresChangeFeed,
26
+ type PostgresChangeFeedOptions,
25
27
  parseLsn,
28
+ postgresChangeFeed,
26
29
  } from './changefeed';
27
30
  export {
28
31
  type ChangeFeedSelection,
@@ -113,24 +116,30 @@ export {
113
116
  export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
114
117
  export { openNatsClient } from './nats-open';
115
118
  export { NatsTransport, type NatsTransportOptions } from './nats-transport';
116
- export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
119
+ export {
120
+ type PostgresAdvisoryLock,
121
+ type PostgresAdvisoryLockOptions,
122
+ postgresAdvisoryLock,
123
+ } from './pg-advisory-lock';
117
124
  // ---- the postgres replication path ------------------------------------------------------------
118
125
  export { entityRow } from './pg-entity-row';
126
+ export { paramsChannelTables } from './pg-identity-tables';
127
+ export { CHANNEL_IDENTITY_EVENT } from './pg-preflight';
119
128
  export {
120
129
  changeLsn,
121
130
  commitPositionOf,
122
131
  type ReplicationStreamStats,
123
132
  } from './pg-replication';
124
133
  export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
134
+ export { answersWeakAuth } from './pg-tls';
125
135
  // The value domain a WAL tuple lands in. Public because it is `PgOutputMessage`'s and
126
136
  // `entityRow`'s: a caller naming either type has to be able to name what is inside one.
127
137
  export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
128
138
  export type { PgStream } from './pg-wire';
129
- export {
130
- type PgColumn,
131
- PgOutputDecoder,
132
- type PgOutputMessage,
133
- type PgRelation,
139
+ export type {
140
+ PgColumn,
141
+ PgOutputMessage,
142
+ PgRelation,
134
143
  } from './pgoutput';
135
144
  export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
136
145
  export {
@@ -143,43 +152,55 @@ export {
143
152
  type PresenceRoster,
144
153
  presenceFrame,
145
154
  } from './presence';
155
+ export { ANONYMOUS_SOCKET_MULTIPLIER, DEFAULT_MAX_SOCKETS_PER_ACTOR } from './principal-sockets';
146
156
  export {
147
- createEntry,
148
157
  DEFAULT_READ_DEADLINE_MS,
149
158
  type EntryOptions,
150
159
  fillWindow,
151
160
  orgIdOf,
152
161
  type PendingRead,
153
162
  type QueryEntry,
163
+ queryEntry,
154
164
  refillWindowInLane,
155
165
  } from './query-window';
166
+ // ---- channel records with no replicator: an app's committed rows, published by the app ----------
167
+ export {
168
+ type PublishOptions,
169
+ type RecordPublisher,
170
+ type RecordPublisherOptions,
171
+ recordPublisher,
172
+ } from './record-publisher';
156
173
  export {
157
- type AdvisoryLock,
158
174
  CHANGE_SUBJECT_PREFIX,
159
- type ChangeEnvelope,
175
+ changeFeedReplicator,
160
176
  changeSubject,
161
- createReplicator,
162
- InMemoryAdvisoryLock,
163
177
  normalize,
164
- parseChange,
165
- parseEnvelope,
166
178
  type Replicator,
167
179
  type ReplicatorOptions,
168
180
  type ReplicatorStats,
169
- SeqGapDetector,
170
181
  } from './replicator';
182
+ export {
183
+ type ChangeEnvelope,
184
+ type EnvelopeSource,
185
+ encodeEnvelope,
186
+ parseChange,
187
+ parseEnvelope,
188
+ SeqGapDetector,
189
+ } from './replicator-envelope';
171
190
  export {
172
191
  actorIdOf,
173
192
  CLOSE,
174
- DEFAULT_FRAME_BURST,
175
- DEFAULT_MAX_BUFFERED_BYTES,
176
- DEFAULT_MAX_FRAMES_PER_SECOND,
177
193
  SocketRegistry,
178
194
  type SocketRegistryOptions,
179
195
  SyncSocket,
180
196
  type SyncSocketOptions,
181
197
  type WsLike,
182
198
  } from './socket';
199
+ export {
200
+ DEFAULT_FRAME_BURST,
201
+ DEFAULT_MAX_BUFFERED_BYTES,
202
+ DEFAULT_MAX_FRAMES_PER_SECOND,
203
+ } from './socket-defaults';
183
204
  export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
184
205
  export type {
185
206
  GateFailed,
@@ -188,6 +209,7 @@ export type {
188
209
  Subscriber,
189
210
  SubscriberGateOptions,
190
211
  } from './subscriber-gate';
212
+ export { DEFAULT_MAX_PER_ACTOR, DEFAULT_MAX_PER_SOCKET } from './subscription-book';
191
213
  export {
192
214
  GrantBook,
193
215
  type GrantSweepDeps,
@@ -197,9 +219,9 @@ export {
197
219
  sweepGrants,
198
220
  } from './sync-auth';
199
221
  export {
200
- createFrameRouter,
201
222
  type FrameRouter,
202
223
  type FrameRouterOptions,
224
+ frameRouter,
203
225
  } from './sync-frames';
204
226
  export {
205
227
  type ListenOptions,
@@ -207,13 +229,13 @@ export {
207
229
  type SyncListener,
208
230
  } from './sync-listen';
209
231
  export {
210
- createSyncNode,
211
232
  DEFAULT_MAX_CONNECTIONS,
212
233
  DEFAULT_MAX_FRAME_BYTES,
213
234
  DEFAULT_REAUTH_INTERVAL_MS,
214
235
  type SyncNode,
215
236
  type SyncNodeOptions,
216
237
  type SyncWs,
238
+ syncNode,
217
239
  type UpgradeTarget,
218
240
  type WsData,
219
241
  } from './sync-node';
@@ -0,0 +1,20 @@
1
+ // The default ceilings one server socket is built with. Split from `socket.ts` at its size
2
+ // ceiling: the socket owns behaviour, this owns the numbers it and `sync-node.ts` agree on.
3
+
4
+ /**
5
+ * What one socket may ask this node to do per second, and how much of it may arrive at once.
6
+ *
7
+ * The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
8
+ * legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
9
+ * to consume a node through the subscribe path's amplifiers.
10
+ */
11
+ export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
12
+ export const DEFAULT_FRAME_BURST = 256;
13
+ /**
14
+ * Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
15
+ * desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
16
+ * it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
17
+ * check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
18
+ * it is browser code, and importing this file would pull the node's socket registry into the tab.
19
+ */
20
+ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
@@ -173,6 +173,9 @@ export class SocketEngine {
173
173
  switch (frame.type) {
174
174
  case 'hello':
175
175
  this.#hello = frame;
176
+ // The node names the beat. Every tab hears the same number on its next beat (this frame is
177
+ // what `#helloReply` answers with), so the socket and the ports keep one rhythm.
178
+ if (frame.heartbeatMs !== undefined) this.#heartbeat.follow(frame.heartbeatMs);
176
179
  return;
177
180
  case 'snapshot':
178
181
  case 'patch':
@@ -301,12 +304,21 @@ export class SocketEngine {
301
304
  if (this.#reaper !== null) return;
302
305
  this.#reaper = this.#schedule(() => {
303
306
  this.#reaper = null;
304
- const cutoff = this.#now() - REAP_AFTER_BEATS * this.#beatMs;
307
+ const cutoff = this.#now() - REAP_AFTER_BEATS * this.#portBeatMs();
305
308
  for (const attached of [...this.#ports.values()]) {
306
309
  if (attached.lastSeen < cutoff) this.#reap(attached);
307
310
  }
308
311
  if (this.#ports.size > 0) this.#armReaper();
309
- }, this.#beatMs);
312
+ }, this.#portBeatMs());
313
+ }
314
+
315
+ /**
316
+ * The slowest a healthy tab can be beating: its own default until it hears the node's `hello`,
317
+ * the node's figure after. The LONGER of the two — a tab is never reaped for keeping a beat the
318
+ * node asked for, nor for not having heard a shorter one yet.
319
+ */
320
+ #portBeatMs(): number {
321
+ return Math.max(this.#beatMs, this.#heartbeat.intervalMs);
310
322
  }
311
323
 
312
324
  #beat(): void {
@@ -6,7 +6,9 @@
6
6
  * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
7
7
  * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
8
8
  * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
9
- * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
9
+ * every `DEFAULT_HEARTBEAT_MS` (10s), so this is twelve missed beats — and it stays a multiple of
10
+ * the beat whatever either is set to: the node TELLS the client its beat (`hello.heartbeatMs`),
11
+ * never slower than a quarter of this budget (`clientHeartbeatMs`, `sync-node-bounds.ts`).
10
12
  */
11
13
  export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
12
14
 
@@ -122,9 +122,14 @@ export class PortRouter {
122
122
  }
123
123
  if (held.ports.has(attached.id)) return;
124
124
  held.ports.add(attached.id);
125
- // The node never hears this add, so it cannot answer it with the `replay-gap` a fresh seat
126
- // gets; the engine does. With no epoch yet, the node's own answer is still on its way and now
127
- // reaches this port too.
125
+ // Forwarded as a BEAT (no `since`): to the node it is the seat it already holds, repeated, and
126
+ // what a seated socket is answered with is the room's roster — the only frame an events-only
127
+ // channel ever gets, so unforwarded this tab read `joining` until the next beat, and with no
128
+ // presence on the node, until the first app event.
129
+ this.#deps.send(withoutSince(held.add));
130
+ // A beat is never answered with the `replay-gap` a fresh seat gets, so the engine says it.
131
+ // With no epoch yet, the node's own answer to the first add is on its way and reaches this
132
+ // port too.
128
133
  if (held.epoch === null) return;
129
134
  const gap: Frame = {
130
135
  type: 'replay-gap',
package/src/socket.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  logger,
15
15
  recordConnection,
16
16
  systemClock,
17
- uuid,
17
+ uuidV7,
18
18
  } from '@ultimat3/core';
19
19
  import { GapRepairs } from './channel-gaps';
20
20
  import type { ChannelRecordsFrame } from './channel-wire';
@@ -55,6 +55,11 @@ export interface SyncSocketOptions {
55
55
  readonly clientBuildId: string;
56
56
  readonly serverBuildId: string;
57
57
  readonly actor?: Actor | null;
58
+ /**
59
+ * The caller's address, resolved ONCE at the upgrade (`UpgradeDeps.clientAddressOf`) — the
60
+ * only subject an anonymous reader has. `null` when the host cannot say.
61
+ */
62
+ readonly clientAddress?: string | null;
58
63
  readonly id?: string;
59
64
  readonly clock?: Clock;
60
65
  /** Frames are dropped rather than queued past this. See `desynced`. */
@@ -66,23 +71,11 @@ export interface SyncSocketOptions {
66
71
  readonly frameBurst?: number;
67
72
  }
68
73
 
69
- /**
70
- * What one socket may ask this node to do per second, and how much of it may arrive at once.
71
- *
72
- * The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
73
- * legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
74
- * to consume a node through the subscribe path's amplifiers.
75
- */
76
- export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
77
- export const DEFAULT_FRAME_BURST = 256;
78
- /**
79
- * Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
80
- * desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
81
- * it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
82
- * check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
83
- * it is browser code, and importing this file would pull the node's socket registry into the tab.
84
- */
85
- export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
74
+ import {
75
+ DEFAULT_FRAME_BURST,
76
+ DEFAULT_MAX_BUFFERED_BYTES,
77
+ DEFAULT_MAX_FRAMES_PER_SECOND,
78
+ } from './socket-defaults';
86
79
 
87
80
  /**
88
81
  * Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
@@ -142,6 +135,7 @@ export class SyncSocket {
142
135
  readonly frameBudget: AcceptBudget;
143
136
 
144
137
  actor: Actor | null;
138
+ readonly clientAddress: string | null;
145
139
  /**
146
140
  * MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
147
141
  * read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
@@ -163,10 +157,11 @@ export class SyncSocket {
163
157
  constructor(options: SyncSocketOptions) {
164
158
  this.#ws = options.ws;
165
159
  this.#clock = options.clock ?? systemClock;
166
- this.id = options.id ?? uuid();
160
+ this.id = options.id ?? uuidV7();
167
161
  this.#clientBuildId = options.clientBuildId;
168
162
  this.serverBuildId = options.serverBuildId;
169
163
  this.actor = options.actor ?? null;
164
+ this.clientAddress = options.clientAddress ?? null;
170
165
  this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
171
166
  finiteOption('SyncSocket', 'maxBufferedBytes', this.#maxBufferedBytes);
172
167
  this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
@@ -7,6 +7,7 @@ import type { Actor } from '@ultimat3/core';
7
7
  import { isPolicyDenial } from './errors';
8
8
  import type { JsonValue, Row, RowPatch } from './json';
9
9
  import type { LiveQueryDefinition } from './live-contract';
10
+ import { narrowRow, projectionOf } from './matcher-bridge';
10
11
 
11
12
  /**
12
13
  * Who a decision is being made for. Every policy call in the live pipeline takes one, which is the
@@ -219,7 +220,16 @@ export class SubscriberGate {
219
220
  // subscriber is not entitled to keep, and one that holds it is told so.
220
221
  if (full === undefined) return holds ? withdrawn(patch) : null;
221
222
  const row: Row = { ...full, ...patch.row, id: patch.id };
222
- if (await this.#visible(target, who, row, 'patch')) return patch;
223
+ if (await this.#visible(target, who, row, 'patch')) {
224
+ // An update carries the changed columns only. A subscriber who was never sent this row —
225
+ // denied at its snapshot, admitted by this change — would fold them onto nothing, and its
226
+ // cursor would never record the id, so the row's later delete was withheld from it. It gets
227
+ // the whole row as an insert, which is also what makes `Holding` and `advance` count it.
228
+ if (patch.op === 'update' && !holds) {
229
+ return { ...patch, op: 'insert', row: narrowRow(row, projectionOf(target.rows)) };
230
+ }
231
+ return patch;
232
+ }
223
233
  this.#denied(target.qid, who, patch.id);
224
234
  return holds ? withdrawn(patch) : null;
225
235
  }
@@ -1,13 +1,14 @@
1
1
  // Who holds which subscription, and the composite identity that makes that answerable. A `sid`
2
2
  // is CLIENT data — unique only to the socket that chose it — so every lookup here takes the
3
- // owner too, and the per-socket and per-tenant caps are answered from this book because it is
4
- // the only thing that knows what exists. Every question it answers is indexed, never scanned.
3
+ // owner too, and the per-socket, per-tenant and per-actor caps are answered from this book because
4
+ // it is the only thing that knows what exists. Every question it answers is indexed, never scanned.
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
- import { finiteOption } from '@ultimat3/core';
7
+ import { addressNetwork, ConfigInvalidError, finiteOption, isAnonymous } from '@ultimat3/core';
8
8
  import { SubscriptionIdTakenError, SubscriptionLimitError } from './errors';
9
9
  import type { LiveSubscription } from './live-contract';
10
10
  import type { SyncSocket } from './socket';
11
+ import { CappedCount } from './subscription-count';
11
12
 
12
13
  /**
13
14
  * A slot taken synchronously at the top of `subscribe` and given back when it has either become a
@@ -32,11 +33,50 @@ export interface SubscriptionCaps {
32
33
  readonly maxPerSocket?: number;
33
34
  readonly maxPerTenant?: number;
34
35
  readonly tenantOf?: (actor: Actor | null) => string | null;
36
+ /** Live subscriptions one actor may hold on this node, across all of its sockets. */
37
+ readonly maxPerActor?: number;
35
38
  }
36
39
 
37
40
  /** Sockets may open this many live queries before `X_SUBSCRIPTION_LIMIT`. */
38
41
  export const DEFAULT_MAX_PER_SOCKET = 128;
39
42
 
43
+ /**
44
+ * One actor's share of a node, across every socket it opens. A tenth of `DEFAULT_MAX_ENTRIES`
45
+ * (10,000), so no single actor reaches the node cap — before it, 79 sockets at 128 each filled the
46
+ * node and every other user's next subscribe was refused — and eight tabs at the per-socket 128
47
+ * still fit under it. `@ultimat3/core`'s `realtime.maxSubscriptionsPerActor` defaults to the same
48
+ * number; `runtime-realtime.test.ts` in `@ultimat3/cli` holds the two together.
49
+ */
50
+ export const DEFAULT_MAX_PER_ACTOR = 1_000;
51
+
52
+ /**
53
+ * Who a socket counts against for the per-actor caps (subscriptions, and sockets at the upgrade):
54
+ * the actor, or — anonymous — the NETWORK of the client address resolved at the upgrade
55
+ * (`addressNetwork`: IPv4 exact, IPv6 by /64), so a visitor with no account is bounded too, and one
56
+ * IPv6 host cannot rotate through its /64 for a fresh budget per address. A socket that names
57
+ * neither shares ONE principal, `address:unknown`: fail closed, bounded together, not unbounded.
58
+ */
59
+ export function principalOf(socket: Pick<SyncSocket, 'actor' | 'clientAddress'>): string {
60
+ return principalFor(socket.actor, socket.clientAddress);
61
+ }
62
+
63
+ /** The same key from its two parts — the upgrade has them before any socket exists. */
64
+ export function principalFor(actor: Actor | null, clientAddress: string | null): string {
65
+ if (actor !== null && !isAnonymous(actor)) return `actor:${actor.id}`;
66
+ return `address:${clientAddress === null ? 'unknown' : addressNetwork(clientAddress)}`;
67
+ }
68
+
69
+ /** A per-actor ceiling is a COUNT of at least 1, refused as the config key of the same name is. */
70
+ function actorCeiling(value: number | undefined): number {
71
+ const max = value ?? DEFAULT_MAX_PER_ACTOR;
72
+ if (Number.isSafeInteger(max) && max >= 1) return max;
73
+ throw new ConfigInvalidError({
74
+ cause: `maxPerActor (realtime.maxSubscriptionsPerActor) must be a whole number of at least 1, not ${String(max)} — 0 refuses every subscribe, and a fraction or a NaN is a cap no count ever reaches`,
75
+ fix: `new LiveQueryRegistry({ maxPerActor: ${String(DEFAULT_MAX_PER_ACTOR)} }) # or realtime: { maxSubscriptionsPerActor: ${String(DEFAULT_MAX_PER_ACTOR)} } in app.config.ts`,
76
+ meta: { key: 'realtime.maxSubscriptionsPerActor', value: max },
77
+ });
78
+ }
79
+
40
80
  const SUBJECT = 'the subscription caps';
41
81
 
42
82
  /**
@@ -60,21 +100,16 @@ export class SubscriptionBook {
60
100
  readonly #bySid = new Map<string, LiveSubscription>();
61
101
  /** socket id -> its sids. The drop list on close, the retry list on re-auth. */
62
102
  readonly #bySocket = new Map<string, Set<string>>();
63
- /** tenant -> live subscriptions held by its sockets. The per-tenant cap's whole answer. */
64
- readonly #perTenant = new Map<string, number>();
65
- /**
66
- * The tenant each socket's subscriptions were counted under. Remembered rather than re-derived,
67
- * because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
68
- * decrement a tenant that was never incremented and leave the old one counting forever.
69
- */
70
- readonly #tenantOfSocket = new Map<string, string>();
71
103
  /** sids a socket has claimed but not yet attached. Empty between subscribes, so it never grows. */
72
104
  readonly #claimedBySocket = new Map<string, Set<string>>();
73
- /** The same claims counted per tenant, because that cap spans sockets and a lane cannot see it. */
74
- readonly #claimedPerTenant = new Map<string, number>();
105
+ /** The keys each in-flight claim was counted under, by `(socket, sid)` — read at attach. */
106
+ readonly #claimKeys = new Map<string, { tenant: string | null; actor: string | null }>();
107
+ /** The caps that span sockets: one per tenant (opt-in), one per actor (always). */
108
+ readonly #tenants: CappedCount;
109
+ readonly #actors: CappedCount;
75
110
  readonly #caps: SubscriptionCaps;
76
111
  /**
77
- * BOTH caps screened here, once, rather than on the subscribe path — and `maxPerTenant` was not
112
+ * Every cap screened here, once, rather than on the subscribe path — and `maxPerTenant` was not
78
113
  * screened at all: it has no `??` default, which is the one shape `bun run finite-bounds` states
79
114
  * in its own header that it cannot see. `count >= NaN` is false, so the only cap that spans the
80
115
  * sockets of one tenant was off in silence; measured, a book built with `maxPerTenant: NaN`
@@ -82,7 +117,6 @@ export class SubscriptionBook {
82
117
  * caller is saying "no per-tenant cap" rather than handing a number that is not one.
83
118
  */
84
119
  readonly #maxPerSocket: number;
85
- readonly #maxPerTenant: number | undefined;
86
120
 
87
121
  constructor(caps: SubscriptionCaps = {}) {
88
122
  this.#caps = caps;
@@ -91,10 +125,21 @@ export class SubscriptionBook {
91
125
  'maxPerSocket',
92
126
  caps.maxPerSocket ?? DEFAULT_MAX_PER_SOCKET,
93
127
  );
94
- this.#maxPerTenant =
95
- caps.maxPerTenant === undefined
96
- ? undefined
97
- : finiteOption(SUBJECT, 'maxPerTenant', caps.maxPerTenant);
128
+ this.#tenants = new CappedCount({
129
+ principalOf: (socket) => this.#caps.tenantOf?.(socket.actor) ?? null,
130
+ max:
131
+ caps.maxPerTenant === undefined
132
+ ? undefined
133
+ : finiteOption(SUBJECT, 'maxPerTenant', caps.maxPerTenant),
134
+ scope: 'tenant',
135
+ knob: 'maxPerTenant',
136
+ });
137
+ this.#actors = new CappedCount({
138
+ principalOf,
139
+ max: actorCeiling(caps.maxPerActor),
140
+ scope: 'actor',
141
+ knob: 'realtime.maxSubscriptionsPerActor',
142
+ });
98
143
  }
99
144
 
100
145
  get(socketId: string, sid: string): LiveSubscription | undefined {
@@ -115,10 +160,8 @@ export class SubscriptionBook {
115
160
  const sids = this.#bySocket.get(socketId);
116
161
  if (sids) sids.add(subscription.sid);
117
162
  else this.#bySocket.set(socketId, new Set([subscription.sid]));
118
- const tenant = this.#tenantFor(subscription.socket);
119
- if (tenant === null) return;
120
- this.#tenantOfSocket.set(socketId, tenant);
121
- this.#perTenant.set(tenant, (this.#perTenant.get(tenant) ?? 0) + 1);
163
+ this.#tenants.added(subscription.socket);
164
+ this.#actors.added(subscription.socket);
122
165
  }
123
166
 
124
167
  delete(socketId: string, sid: string): void {
@@ -127,10 +170,8 @@ export class SubscriptionBook {
127
170
  sids?.delete(sid);
128
171
  const empty = sids === undefined || sids.size === 0;
129
172
  if (empty) this.#bySocket.delete(socketId);
130
- const tenant = this.#tenantOfSocket.get(socketId);
131
- if (tenant === undefined) return;
132
- this.#bump(tenant, -1);
133
- if (empty) this.#tenantOfSocket.delete(socketId);
173
+ this.#tenants.deleted(socketId, empty);
174
+ this.#actors.deleted(socketId, empty);
134
175
  }
135
176
 
136
177
  /** A copy, because every caller mutates the book while walking it. */
@@ -152,29 +193,67 @@ export class SubscriptionBook {
152
193
 
153
194
  /** Live subscriptions counted against one tenant. The metric the cap reads. */
154
195
  tenantCount(tenant: string): number {
155
- return this.#perTenant.get(tenant) ?? 0;
196
+ return this.#tenants.count(tenant);
156
197
  }
157
198
 
158
199
  /**
159
- * A re-auth moved this socket to another tenant, so its subscriptions move with it. Without
160
- * this the count the cap reads drifts from the book for the rest of the process — one tenant
161
- * refused for subscriptions it does not hold, another admitted past its cap.
200
+ * A re-auth moved this socket to another tenant — or another actor — so its subscriptions move
201
+ * with it. Without this the counts the caps read drift from the book for the rest of the process:
202
+ * one key refused for subscriptions it does not hold, another admitted past its cap.
162
203
  */
163
204
  retenant(socket: SyncSocket): void {
164
205
  const held = this.#bySocket.get(socket.id)?.size ?? 0;
165
- const before = this.#tenantOfSocket.get(socket.id) ?? null;
166
- const after = this.#caps.tenantOf?.(socket.actor) ?? null;
167
- if (before === after) return;
168
- if (before !== null) this.#bump(before, -held);
169
- if (after === null) this.#tenantOfSocket.delete(socket.id);
170
- else {
171
- this.#tenantOfSocket.set(socket.id, after);
172
- if (held > 0) this.#perTenant.set(after, (this.#perTenant.get(after) ?? 0) + held);
173
- }
206
+ this.#tenants.reassign(socket, held);
207
+ this.#actors.reassign(socket, held);
174
208
  }
175
209
 
176
210
  /**
177
- * Refuse a subscribe that would exceed a cap. Load shedding, not a crash: both scopes throw
211
+ * About to attach `(socket, sid)`: if a re-auth moved the socket to another tenant or actor
212
+ * while the subscribe was in flight, the new key was never asked — ask it now, and refuse when
213
+ * it is full. Called before the attach writes anything, so a refusal leaves nothing behind.
214
+ */
215
+ assertAttachable(socket: SyncSocket, sid: string): void {
216
+ const claimed = this.#claimKeys.get(subscriptionKey(socket.id, sid));
217
+ if (claimed === undefined) return;
218
+ this.#tenants.assertIfMoved(socket, claimed.tenant);
219
+ this.#actors.assertIfMoved(socket, claimed.actor);
220
+ }
221
+
222
+ /**
223
+ * How many of this socket's subscriptions a re-auth must refuse so that neither its new tenant
224
+ * nor its new actor ends above its cap, and the refusal to send each — `null` when all fit.
225
+ * Asked BEFORE `retenant`, so the refused ones leave the key they were counted under.
226
+ */
227
+ overflowOnRekey(
228
+ socket: SyncSocket,
229
+ ): { readonly excess: number; readonly error: SubscriptionLimitError } | null {
230
+ const held = this.#bySocket.get(socket.id)?.size ?? 0;
231
+ const byActor = this.#actors.overflowOnRekey(socket, held);
232
+ const byTenant = this.#tenants.overflowOnRekey(socket, held);
233
+ if (byActor === 0 && byTenant === 0) return null;
234
+ return byActor >= byTenant
235
+ ? { excess: byActor, error: this.#actors.refusalFor(socket) }
236
+ : { excess: byTenant, error: this.#tenants.refusalFor(socket) };
237
+ }
238
+
239
+ /** The socket closed: forget the keys it was counted under, held or not. */
240
+ forgetSocket(socketId: string): void {
241
+ this.#tenants.forget(socketId);
242
+ this.#actors.forget(socketId);
243
+ }
244
+
245
+ /** Sockets either spanning cap still remembers — a leak shows here first. */
246
+ get trackedSockets(): number {
247
+ return this.#tenants.remembered + this.#actors.remembered;
248
+ }
249
+
250
+ /** Live subscriptions counted against one principal (`actor:<id>` / `address:<ip>`). */
251
+ actorCount(principal: string): number {
252
+ return this.#actors.count(principal);
253
+ }
254
+
255
+ /**
256
+ * Refuse a subscribe that would exceed a cap. Load shedding, not a crash: every scope throws
178
257
  * `X_SUBSCRIPTION_LIMIT` naming which one refused, so the fix line points at one knob.
179
258
  *
180
259
  * Claims count, because the thing being bounded is work that starts before it is held: a
@@ -192,17 +271,8 @@ export class SubscriptionBook {
192
271
  knob: 'maxPerSocket',
193
272
  });
194
273
  }
195
- const perTenant = this.#maxPerTenant;
196
- const tenant = this.#tenantFor(socket);
197
- if (perTenant === undefined || tenant === null) return;
198
- if (this.tenantCount(tenant) + (this.#claimedPerTenant.get(tenant) ?? 0) >= perTenant) {
199
- throw new SubscriptionLimitError({
200
- scope: 'tenant',
201
- id: tenant,
202
- limit: perTenant,
203
- knob: 'maxPerTenant',
204
- });
205
- }
274
+ this.#tenants.assert(socket);
275
+ this.#actors.assert(socket);
206
276
  }
207
277
 
208
278
  /**
@@ -226,10 +296,10 @@ export class SubscriptionBook {
226
296
  const claims = this.#claimedBySocket.get(socketId);
227
297
  if (claims) claims.add(sid);
228
298
  else this.#claimedBySocket.set(socketId, new Set([sid]));
229
- const tenant = this.#tenantFor(socket);
230
- if (tenant !== null) {
231
- this.#claimedPerTenant.set(tenant, (this.#claimedPerTenant.get(tenant) ?? 0) + 1);
232
- }
299
+ const tenant = this.#tenants.claim(socket);
300
+ const actor = this.#actors.claim(socket);
301
+ const claimKey = subscriptionKey(socketId, sid);
302
+ this.#claimKeys.set(claimKey, { tenant: tenant.principal, actor: actor.principal });
233
303
  let released = false;
234
304
  return {
235
305
  release: (): void => {
@@ -238,22 +308,10 @@ export class SubscriptionBook {
238
308
  const held = this.#claimedBySocket.get(socketId);
239
309
  held?.delete(sid);
240
310
  if (held !== undefined && held.size === 0) this.#claimedBySocket.delete(socketId);
241
- if (tenant === null) return;
242
- const next = (this.#claimedPerTenant.get(tenant) ?? 0) - 1;
243
- if (next > 0) this.#claimedPerTenant.set(tenant, next);
244
- else this.#claimedPerTenant.delete(tenant);
311
+ this.#claimKeys.delete(claimKey);
312
+ tenant.release();
313
+ actor.release();
245
314
  },
246
315
  };
247
316
  }
248
-
249
- /** The tenant this socket's subscriptions are counted under: the remembered one, or the actor's. */
250
- #tenantFor(socket: SyncSocket): string | null {
251
- return this.#tenantOfSocket.get(socket.id) ?? this.#caps.tenantOf?.(socket.actor) ?? null;
252
- }
253
-
254
- #bump(tenant: string, by: number): void {
255
- const next = (this.#perTenant.get(tenant) ?? 0) + by;
256
- if (next > 0) this.#perTenant.set(tenant, next);
257
- else this.#perTenant.delete(tenant);
258
- }
259
317
  }