@ultimat3/realtime 24.0.0 → 25.1.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/server.ts CHANGED
@@ -4,7 +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, InMemoryAdvisoryLock } from './advisory-lock';
7
+ export { type AdvisoryLock, type MemoryAdvisoryLock, memoryAdvisoryLock } from './advisory-lock';
8
8
  // ---- the retained change window one node fans out from ------------------------------------------
9
9
  export {
10
10
  type ChangeBufferOptions,
@@ -19,11 +19,13 @@ export {
19
19
  type ChangeFeedStartOptions,
20
20
  type ChangeOp,
21
21
  formatLsn,
22
- InMemoryChangeFeed,
23
- type InMemoryChangeFeedOptions,
24
- PgLogicalReplicationFeed,
25
- type PgLogicalReplicationOptions,
22
+ type MemoryChangeFeed,
23
+ type MemoryChangeFeedOptions,
24
+ memoryChangeFeed,
25
+ type PostgresChangeFeed,
26
+ type PostgresChangeFeedOptions,
26
27
  parseLsn,
28
+ postgresChangeFeed,
27
29
  } from './changefeed';
28
30
  export {
29
31
  type ChangeFeedSelection,
@@ -114,7 +116,11 @@ export {
114
116
  export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
115
117
  export { openNatsClient } from './nats-open';
116
118
  export { NatsTransport, type NatsTransportOptions } from './nats-transport';
117
- export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
119
+ export {
120
+ type PostgresAdvisoryLock,
121
+ type PostgresAdvisoryLockOptions,
122
+ postgresAdvisoryLock,
123
+ } from './pg-advisory-lock';
118
124
  // ---- the postgres replication path ------------------------------------------------------------
119
125
  export { entityRow } from './pg-entity-row';
120
126
  export { paramsChannelTables } from './pg-identity-tables';
@@ -130,11 +136,10 @@ export { answersWeakAuth } from './pg-tls';
130
136
  // `entityRow`'s: a caller naming either type has to be able to name what is inside one.
131
137
  export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
132
138
  export type { PgStream } from './pg-wire';
133
- export {
134
- type PgColumn,
135
- PgOutputDecoder,
136
- type PgOutputMessage,
137
- type PgRelation,
139
+ export type {
140
+ PgColumn,
141
+ PgOutputMessage,
142
+ PgRelation,
138
143
  } from './pgoutput';
139
144
  export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
140
145
  export {
@@ -147,20 +152,28 @@ export {
147
152
  type PresenceRoster,
148
153
  presenceFrame,
149
154
  } from './presence';
155
+ export { ANONYMOUS_SOCKET_MULTIPLIER, DEFAULT_MAX_SOCKETS_PER_ACTOR } from './principal-sockets';
150
156
  export {
151
- createEntry,
152
157
  DEFAULT_READ_DEADLINE_MS,
153
158
  type EntryOptions,
154
159
  fillWindow,
155
160
  orgIdOf,
156
161
  type PendingRead,
157
162
  type QueryEntry,
163
+ queryEntry,
158
164
  refillWindowInLane,
159
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';
160
173
  export {
161
174
  CHANGE_SUBJECT_PREFIX,
175
+ changeFeedReplicator,
162
176
  changeSubject,
163
- createReplicator,
164
177
  normalize,
165
178
  type Replicator,
166
179
  type ReplicatorOptions,
@@ -168,6 +181,8 @@ export {
168
181
  } from './replicator';
169
182
  export {
170
183
  type ChangeEnvelope,
184
+ type EnvelopeSource,
185
+ encodeEnvelope,
171
186
  parseChange,
172
187
  parseEnvelope,
173
188
  SeqGapDetector,
@@ -175,15 +190,17 @@ export {
175
190
  export {
176
191
  actorIdOf,
177
192
  CLOSE,
178
- DEFAULT_FRAME_BURST,
179
- DEFAULT_MAX_BUFFERED_BYTES,
180
- DEFAULT_MAX_FRAMES_PER_SECOND,
181
193
  SocketRegistry,
182
194
  type SocketRegistryOptions,
183
195
  SyncSocket,
184
196
  type SyncSocketOptions,
185
197
  type WsLike,
186
198
  } from './socket';
199
+ export {
200
+ DEFAULT_FRAME_BURST,
201
+ DEFAULT_MAX_BUFFERED_BYTES,
202
+ DEFAULT_MAX_FRAMES_PER_SECOND,
203
+ } from './socket-defaults';
187
204
  export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
188
205
  export type {
189
206
  GateFailed,
@@ -192,6 +209,7 @@ export type {
192
209
  Subscriber,
193
210
  SubscriberGateOptions,
194
211
  } from './subscriber-gate';
212
+ export { DEFAULT_MAX_PER_ACTOR, DEFAULT_MAX_PER_SOCKET } from './subscription-book';
195
213
  export {
196
214
  GrantBook,
197
215
  type GrantSweepDeps,
@@ -201,9 +219,9 @@ export {
201
219
  sweepGrants,
202
220
  } from './sync-auth';
203
221
  export {
204
- createFrameRouter,
205
222
  type FrameRouter,
206
223
  type FrameRouterOptions,
224
+ frameRouter,
207
225
  } from './sync-frames';
208
226
  export {
209
227
  type ListenOptions,
@@ -211,13 +229,13 @@ export {
211
229
  type SyncListener,
212
230
  } from './sync-listen';
213
231
  export {
214
- createSyncNode,
215
232
  DEFAULT_MAX_CONNECTIONS,
216
233
  DEFAULT_MAX_FRAME_BYTES,
217
234
  DEFAULT_REAUTH_INTERVAL_MS,
218
235
  type SyncNode,
219
236
  type SyncNodeOptions,
220
237
  type SyncWs,
238
+ syncNode,
221
239
  type UpgradeTarget,
222
240
  type WsData,
223
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;
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;
@@ -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
  }