@procedures/client 0.47.2 → 0.48.1

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 (138) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/client/batch.d.ts.map +1 -1
  4. package/dist/adapters/http/client/batch.js.map +1 -1
  5. package/dist/adapters/http/server/body.d.ts.map +1 -1
  6. package/dist/adapters/http/server/body.js.map +1 -1
  7. package/dist/adapters/http/shared/constants.d.ts +82 -78
  8. package/dist/adapters/http/shared/constants.d.ts.map +1 -1
  9. package/dist/adapters/http/shared/constants.js +78 -80
  10. package/dist/adapters/http/shared/constants.js.map +1 -1
  11. package/dist/adapters/http/shared/errors.d.ts +15 -13
  12. package/dist/adapters/http/shared/errors.d.ts.map +1 -1
  13. package/dist/adapters/http/shared/errors.js +13 -14
  14. package/dist/adapters/http/shared/errors.js.map +1 -1
  15. package/dist/adapters/port/transport.d.ts.map +1 -1
  16. package/dist/adapters/port/transport.js.map +1 -1
  17. package/dist/adapters/sequence.d.ts.map +1 -1
  18. package/dist/adapters/sequence.js.map +1 -1
  19. package/dist/adapters/websocket/client/types.d.ts +16 -6
  20. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  21. package/dist/adapters/websocket/client/types.js +7 -8
  22. package/dist/adapters/websocket/client/types.js.map +1 -1
  23. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  24. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  25. package/dist/browser.d.ts +3 -1
  26. package/dist/browser.d.ts.map +1 -1
  27. package/dist/browser.js +2 -0
  28. package/dist/browser.js.map +1 -1
  29. package/dist/client/batch-executor.d.ts.map +1 -1
  30. package/dist/client/batch-executor.js.map +1 -1
  31. package/dist/client/client.d.ts.map +1 -1
  32. package/dist/client/client.js +3 -4
  33. package/dist/client/client.js.map +1 -1
  34. package/dist/client/errors/codes.d.ts +66 -64
  35. package/dist/client/errors/codes.d.ts.map +1 -1
  36. package/dist/client/errors/codes.js +64 -65
  37. package/dist/client/errors/codes.js.map +1 -1
  38. package/dist/client/errors/http-status.d.ts +69 -67
  39. package/dist/client/errors/http-status.d.ts.map +1 -1
  40. package/dist/client/errors/http-status.js +67 -68
  41. package/dist/client/errors/http-status.js.map +1 -1
  42. package/dist/client/errors/types.d.ts +38 -15
  43. package/dist/client/errors/types.d.ts.map +1 -1
  44. package/dist/client/errors/types.js +17 -19
  45. package/dist/client/errors/types.js.map +1 -1
  46. package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
  47. package/dist/client/middleware/circuit-breaker.js.map +1 -1
  48. package/dist/client/middleware/lru.d.ts +1 -1
  49. package/dist/client/middleware/lru.d.ts.map +1 -1
  50. package/dist/client/middleware/lru.js.map +1 -1
  51. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  52. package/dist/client/middleware/rate-limit.js.map +1 -1
  53. package/dist/client/middleware/resilience.d.ts.map +1 -1
  54. package/dist/client/middleware/resilience.js +2 -1
  55. package/dist/client/middleware/resilience.js.map +1 -1
  56. package/dist/client/route-resolver.d.ts.map +1 -1
  57. package/dist/client/route-resolver.js.map +1 -1
  58. package/dist/client/types.d.ts.map +1 -1
  59. package/dist/client/types.js.map +1 -1
  60. package/dist/client/validation/types.d.ts +1 -7
  61. package/dist/client/validation/types.d.ts.map +1 -1
  62. package/dist/client/validation/types.js +4 -7
  63. package/dist/client/validation/types.js.map +1 -1
  64. package/dist/events/bus.d.ts +35 -0
  65. package/dist/events/bus.d.ts.map +1 -1
  66. package/dist/events/bus.js +135 -22
  67. package/dist/events/bus.js.map +1 -1
  68. package/dist/events/index.d.ts +5 -1
  69. package/dist/events/index.d.ts.map +1 -1
  70. package/dist/events/index.js +3 -0
  71. package/dist/events/index.js.map +1 -1
  72. package/dist/events/live-state.d.ts +289 -0
  73. package/dist/events/live-state.d.ts.map +1 -0
  74. package/dist/events/live-state.js +501 -0
  75. package/dist/events/live-state.js.map +1 -0
  76. package/dist/events/live-view.d.ts +38 -0
  77. package/dist/events/live-view.d.ts.map +1 -0
  78. package/dist/events/live-view.js +109 -0
  79. package/dist/events/live-view.js.map +1 -0
  80. package/dist/events/publisher.d.ts +18 -1
  81. package/dist/events/publisher.d.ts.map +1 -1
  82. package/dist/events/publisher.js +27 -8
  83. package/dist/events/publisher.js.map +1 -1
  84. package/dist/events/recheck.d.ts.map +1 -1
  85. package/dist/events/recheck.js.map +1 -1
  86. package/dist/events/remote.d.ts +1 -7
  87. package/dist/events/remote.d.ts.map +1 -1
  88. package/dist/events/remote.js +4 -7
  89. package/dist/events/remote.js.map +1 -1
  90. package/dist/events/types.d.ts +30 -0
  91. package/dist/events/types.d.ts.map +1 -1
  92. package/dist/middleware/compose.d.ts.map +1 -1
  93. package/dist/middleware/compose.js +6 -10
  94. package/dist/middleware/compose.js.map +1 -1
  95. package/dist/procedures/collection/hint-filter.d.ts +1 -7
  96. package/dist/procedures/collection/hint-filter.d.ts.map +1 -1
  97. package/dist/procedures/collection/hint-filter.js +4 -7
  98. package/dist/procedures/collection/hint-filter.js.map +1 -1
  99. package/dist/server/procedure-server.d.ts.map +1 -1
  100. package/dist/server/procedure-server.js +3 -0
  101. package/dist/server/procedure-server.js.map +1 -1
  102. package/dist/server/types.d.ts.map +1 -1
  103. package/dist/server/types.js.map +1 -1
  104. package/package.json +7 -4
  105. package/src/adapters/http/client/batch.ts +4 -1
  106. package/src/adapters/http/server/body.ts +4 -1
  107. package/src/adapters/http/shared/constants.ts +84 -78
  108. package/src/adapters/http/shared/errors.ts +16 -13
  109. package/src/adapters/port/transport.ts +4 -1
  110. package/src/adapters/sequence.ts +5 -1
  111. package/src/adapters/websocket/client/types.ts +19 -6
  112. package/src/adapters/websocket/shared/rpc.ts +15 -3
  113. package/src/browser.ts +20 -0
  114. package/src/client/batch-executor.ts +5 -1
  115. package/src/client/client.ts +3 -4
  116. package/src/client/errors/README.md +12 -12
  117. package/src/client/errors/codes.ts +67 -64
  118. package/src/client/errors/http-status.ts +70 -67
  119. package/src/client/errors/types.ts +44 -15
  120. package/src/client/middleware/circuit-breaker.ts +7 -2
  121. package/src/client/middleware/lru.ts +7 -2
  122. package/src/client/middleware/rate-limit.ts +4 -1
  123. package/src/client/middleware/resilience.ts +2 -1
  124. package/src/client/route-resolver.ts +5 -1
  125. package/src/client/types.ts +7 -2
  126. package/src/client/validation/types.ts +13 -6
  127. package/src/events/bus.ts +152 -23
  128. package/src/events/index.ts +30 -0
  129. package/src/events/live-state.ts +666 -0
  130. package/src/events/live-view.ts +134 -0
  131. package/src/events/publisher.ts +53 -10
  132. package/src/events/recheck.ts +4 -1
  133. package/src/events/remote.ts +33 -12
  134. package/src/events/types.ts +34 -0
  135. package/src/middleware/compose.ts +6 -22
  136. package/src/procedures/collection/hint-filter.ts +13 -6
  137. package/src/server/procedure-server.ts +2 -0
  138. package/src/server/types.ts +7 -2
@@ -0,0 +1,666 @@
1
+ /**
2
+ * The live state of a topic (documentation/LIVE-STATE-2026-11.md).
3
+ *
4
+ * Some state changes many times a second, and each reader needs only its latest value: who is here,
5
+ * and where a cursor is. This state keeps the latest value of each key of a topic in memory. It
6
+ * makes no store write and no numbered change.
7
+ *
8
+ * - A set keeps the value and publishes it to the subscribers of the topic, through the bus. The
9
+ * first change on an idle topic goes at once and opens a window. One event at the end of the
10
+ * window carries each key that changed in it, and that event opens the next window.
11
+ * - A snapshot gives the live values, so a new reader sees them at once.
12
+ * - A key ends after a stated time with no set, when the connection of its owner closes, and at a
13
+ * leave call. Each end publishes a `gone` item.
14
+ *
15
+ * The state fills from the bus too. A bus that hosts share thus shares the state. A snapshot holds
16
+ * only what this host saw.
17
+ */
18
+ import * as z from "zod";
19
+ import { createProcedure } from "../procedures/define.js";
20
+ import { InvocationError } from "../procedures/invoke.js";
21
+ import { decodeSegment, encodeSegment } from "../procedures/segments.js";
22
+ import type { PartitionResolver } from "../procedures/collection/partition.js";
23
+ import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
24
+ import type { Principal } from "../server/types.js";
25
+ import { ms, toMs, type Duration } from "../duration.js";
26
+ import { realClock, type Cancel, type Clock } from "../clock.js";
27
+ import type { EventBus, Unsubscribe } from "./types.js";
28
+
29
+ /** The path of the procedure that sets a key of a live topic. */
30
+ export const LIVE_SET_PATH: readonly string[] = ["live", "set"];
31
+ /** The path of the procedure that gives the live values of a topic. */
32
+ export const LIVE_SNAPSHOT_PATH: readonly string[] = ["live", "snapshot"];
33
+ /** The path of the procedure that ends a key of a live topic. */
34
+ export const LIVE_LEAVE_PATH: readonly string[] = ["live", "leave"];
35
+ /** The longest key of a live topic, in characters. */
36
+ export const MAX_LIVE_KEY_LENGTH = 256;
37
+ /** The default limit of the keys of one live topic. */
38
+ export const DEFAULT_LIVE_KEYS = 1024;
39
+ /** The default limit of the bytes of one value, as JSON. */
40
+ export const DEFAULT_LIVE_VALUE_BYTES = 16384;
41
+ /** The default limit of the topics of one live state. */
42
+ export const DEFAULT_LIVE_TOPICS = 4096;
43
+
44
+ /** The prefix of each live topic. */
45
+ const PREFIX = "live.";
46
+ /** The default window that joins the sets of a topic, in milliseconds. */
47
+ const DEFAULT_WINDOW_MS = 120;
48
+
49
+ /** The live value of a key. */
50
+ export interface LiveEntry<T = unknown> {
51
+ /** The key. */
52
+ readonly key: string;
53
+ /** The latest value. */
54
+ readonly value: T;
55
+ /** The name from `admitSet`. A value from the caller cannot replace it. */
56
+ readonly by?: string | undefined;
57
+ /** The time of the set on the clock of the host that took it, in milliseconds. */
58
+ readonly at: number;
59
+ }
60
+
61
+ /** Why a key ended: no set for the stated time, the connection of its owner closed, or a leave call ended it. */
62
+ export type LiveGoneReason = "expired" | "closed" | "left";
63
+
64
+ /** The end of a key. */
65
+ export interface LiveGone {
66
+ /** The key. */
67
+ readonly key: string;
68
+ /** Why the key ended. */
69
+ readonly reason: LiveGoneReason;
70
+ }
71
+
72
+ /**
73
+ * The event of a live topic: each key that changed in one window. The two lists have no key in
74
+ * common, and the latest change of a key wins.
75
+ */
76
+ export interface LiveEvent<T = unknown> {
77
+ /** The id of the live state that published the event. A live state ignores its own events. */
78
+ readonly origin: string;
79
+ /** The keys with a new value. */
80
+ readonly set: ReadonlyArray<LiveEntry<T>>;
81
+ /** The keys that ended. */
82
+ readonly gone: readonly LiveGone[];
83
+ }
84
+
85
+ /** The facts of a set, for `admitSet`. */
86
+ export interface LiveSetCall {
87
+ /** The topic of the bus, after the partition rule. */
88
+ readonly topic: string;
89
+ /** The key. */
90
+ readonly key: string;
91
+ /** The value of the caller. */
92
+ readonly value: unknown;
93
+ /** The capability of the call. */
94
+ readonly capability: Capability;
95
+ /** The metadata of the call: the connection id and the headers of a network request. */
96
+ readonly metadata: Readonly<Record<string, unknown>>;
97
+ /** The principal of the call, from the authentication of the host. */
98
+ readonly principal?: Principal | undefined;
99
+ }
100
+
101
+ /** What `admitSet` gives for a set that it admits. */
102
+ export interface LiveAdmission {
103
+ /** The name of who set the value. Each reader gets it, and a value from the caller cannot replace it. */
104
+ readonly by?: string | undefined;
105
+ /** The value to keep in place of the value of the caller, when this field is present: a value with owner fields, for example. */
106
+ readonly value?: unknown;
107
+ }
108
+
109
+ /** The right to set a key. An admission admits the set. Null refuses it with `FORBIDDEN` and the reason `write-not-allowed`. */
110
+ export type AdmitLiveSet = (call: LiveSetCall) => LiveAdmission | null | Promise<LiveAdmission | null>;
111
+
112
+ /** The facts of a leave call, for `admitLeave`. */
113
+ export interface LiveLeaveCall {
114
+ /** The topic of the bus, after the partition rule. */
115
+ readonly topic: string;
116
+ /** The key. */
117
+ readonly key: string;
118
+ /** The name that `admitSet` gave for the last set of the key. */
119
+ readonly by?: string | undefined;
120
+ /** The connection of the last set of the key on this host. */
121
+ readonly owner?: string | undefined;
122
+ /** The capability of the call. */
123
+ readonly capability: Capability;
124
+ /** The metadata of the call: the connection id and the headers of a network request. */
125
+ readonly metadata: Readonly<Record<string, unknown>>;
126
+ /** The principal of the call, from the authentication of the host. */
127
+ readonly principal?: Principal | undefined;
128
+ }
129
+
130
+ /** The right to end a key. False refuses the call with `FORBIDDEN` and the reason `write-not-allowed`. */
131
+ export type AdmitLiveLeave = (call: LiveLeaveCall) => boolean | Promise<boolean>;
132
+
133
+ /** The options of a live state. */
134
+ export interface LiveStateOptions {
135
+ /** The time with no set after which a key ends. Only the app knows how often its clients repeat. */
136
+ expiresAfter: Duration;
137
+ /** The right to set a key, with the name and the value to keep. A host must give this option, so each host states who can write. */
138
+ admitSet: AdmitLiveSet;
139
+ /**
140
+ * The right to end a key with `live.leave`. Without this rule, only the connection of the last
141
+ * set of a key on this host can end the key.
142
+ */
143
+ admitLeave?: AdmitLiveLeave | undefined;
144
+ /**
145
+ * The window that joins the changes of a topic into one event, or a function that gives the window
146
+ * of a topic of the bus. The first change on an idle topic goes at once. `ms(0)` publishes each
147
+ * change at once.
148
+ * @default ms(120)
149
+ */
150
+ window?: Duration | ((topic: string) => Duration) | undefined;
151
+ /**
152
+ * The rule of the partition of a live topic, as for a collection. Null refuses the call. Without
153
+ * this rule, a topic with a partition gets `FORBIDDEN`.
154
+ */
155
+ partition?: PartitionResolver | undefined;
156
+ /**
157
+ * The limit of the keys of one topic. A set of a new key at the limit gets `FORBIDDEN`.
158
+ * @default DEFAULT_LIVE_KEYS
159
+ */
160
+ maxKeysPerTopic?: number | undefined;
161
+ /**
162
+ * The limit of the bytes of one value, as JSON. A larger value gets `FORBIDDEN`.
163
+ * @default DEFAULT_LIVE_VALUE_BYTES
164
+ */
165
+ maxValueBytes?: number | undefined;
166
+ /**
167
+ * The limit of the topics. A set on a new topic at the limit gets `FORBIDDEN`, unless a topic
168
+ * without a key can go.
169
+ * @default DEFAULT_LIVE_TOPICS
170
+ */
171
+ maxTopics?: number | undefined;
172
+ /**
173
+ * The clock of the deadlines and of the window. A test gives the `fakeClock` of
174
+ * `@mark1russell7/service/testing`.
175
+ * @default the clock of the publisher
176
+ */
177
+ clock?: Clock | undefined;
178
+ }
179
+
180
+ /** The parts of a publisher that a live state uses. */
181
+ export interface LiveStateHost {
182
+ /** The bus of the events. */
183
+ readonly bus: EventBus;
184
+ /** The clock, when the options give none. */
185
+ readonly clock?: Clock | undefined;
186
+ /** The schema of a topic of the publisher. */
187
+ readonly topicSchema: z.ZodString;
188
+ /** This function runs the checks of a subscription on a topic, and gives the topic of the bus. */
189
+ admit(topic: string, ctx: ProcedureContext): Promise<string>;
190
+ }
191
+
192
+ /** The options of a direct set by the host. */
193
+ export interface LiveSetOptions {
194
+ /** The name of who set the value. */
195
+ readonly by?: string | undefined;
196
+ /** The connection that owns the key: its close ends the key. */
197
+ readonly owner?: string | undefined;
198
+ }
199
+
200
+ /**
201
+ * This function gives a live topic: `live.<name>`, or `live.<name>.<partition>`. The name encodes
202
+ * `%` and `.`, so the first dot after the name ends the name, as in a topic of a collection.
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * liveTopic("presence"); // "live.presence"
207
+ * liveTopic("presence", "acme/board:b1"); // "live.presence.acme/board:b1"
208
+ * ```
209
+ */
210
+ export function liveTopic(name: string, partition?: string): string {
211
+ const topic = `${PREFIX}${encodeSegment(name)}`;
212
+ return partition === undefined ? topic : `${topic}.${partition}`;
213
+ }
214
+
215
+ /**
216
+ * This function gives the name and the partition of a live topic, or undefined for another topic.
217
+ * Only the canonical form counts: `liveTopic()` gives the same topic back.
218
+ */
219
+ export function liveTopicTarget(topic: string): { readonly name: string; readonly partition?: string } | undefined {
220
+ if (!topic.startsWith(PREFIX)) return undefined;
221
+ const rest = topic.slice(PREFIX.length);
222
+ const dot = rest.indexOf(".");
223
+ const name = decodeSegment(dot === -1 ? rest : rest.slice(0, dot));
224
+ if (name.length === 0) return undefined;
225
+ if (dot === -1) return liveTopic(name) === topic ? { name } : undefined;
226
+ const partition = rest.slice(dot + 1);
227
+ if (partition.length === 0) return undefined;
228
+ return liveTopic(name, partition) === topic ? { name, partition } : undefined;
229
+ }
230
+
231
+ /** A live key. */
232
+ interface Slot {
233
+ readonly state: TopicState;
234
+ readonly key: string;
235
+ entry: LiveEntry;
236
+ deadline: number;
237
+ owner: string | undefined;
238
+ }
239
+
240
+ /** The changes of a topic that wait for the end of its window. */
241
+ interface Waiting {
242
+ readonly set: Map<string, LiveEntry>;
243
+ readonly gone: Map<string, LiveGone>;
244
+ }
245
+
246
+ /** The state of a topic. */
247
+ interface TopicState {
248
+ readonly topic: string;
249
+ readonly keys: Map<string, Slot>;
250
+ readonly stop: Unsubscribe;
251
+ readonly windowMs: number;
252
+ waiting: Waiting | undefined;
253
+ /** This field holds the timer of the open window. It is undefined when the topic is idle. */
254
+ window: Cancel | undefined;
255
+ }
256
+
257
+ /** True for a value of the form of a live event. */
258
+ function isLiveEvent(value: unknown): value is LiveEvent {
259
+ if (typeof value !== "object" || value === null) return false;
260
+ const event = value as Partial<LiveEvent>;
261
+ return typeof event.origin === "string" && Array.isArray(event.set) && Array.isArray(event.gone);
262
+ }
263
+
264
+ /** This function gives a refusal of a set. */
265
+ function refused(message: string, reason: "write-not-allowed" | "partition-not-allowed", path: readonly string[] = LIVE_SET_PATH): InvocationError {
266
+ return new InvocationError("FORBIDDEN", message, [...path], reason);
267
+ }
268
+
269
+ /** This function gives a new id for a live state. */
270
+ function newOrigin(): string {
271
+ const random = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID;
272
+ return random ? random.call((globalThis as unknown as { crypto: unknown }).crypto) : `live-${Date.now()}-${Math.random()}`;
273
+ }
274
+
275
+ const entrySchema = z.object({
276
+ key: z.string(),
277
+ value: z.unknown(),
278
+ by: z.string().optional(),
279
+ at: z.number(),
280
+ });
281
+
282
+ /**
283
+ * The live values of the topics of one publisher. A host gets one with the option `live` of
284
+ * `EventPublisher`, and the server registers its `procedures`.
285
+ */
286
+ export class LiveState {
287
+ /** The procedures `live.set`, `live.snapshot` and `live.leave`. The expose rule of the host applies to them. */
288
+ readonly procedures: readonly AnyProcedure[];
289
+
290
+ private readonly bus: EventBus;
291
+ private readonly clock: Clock;
292
+ private readonly admit: LiveStateHost["admit"];
293
+ private readonly admitSet: AdmitLiveSet;
294
+ private readonly admitLeave: AdmitLiveLeave | undefined;
295
+ private readonly partition: PartitionResolver | undefined;
296
+ private readonly expiresMs: number;
297
+ private readonly windowOf: (topic: string) => Duration;
298
+ private readonly maxKeys: number;
299
+ private readonly maxValueBytes: number;
300
+ private readonly maxTopics: number;
301
+ private readonly origin = newOrigin();
302
+ private readonly topics = new Map<string, TopicState>();
303
+ /** Each key in the order of its last set. All keys have the same time, so this is the order of the deadlines. */
304
+ private readonly deadlines = new Set<Slot>();
305
+ private readonly owned = new Map<string, Set<Slot>>();
306
+ private timer: Cancel | undefined;
307
+ private closed = false;
308
+
309
+ constructor(options: LiveStateOptions, host: LiveStateHost) {
310
+ if (typeof options.admitSet !== "function") throw new TypeError("LiveState needs the option admitSet");
311
+ this.bus = host.bus;
312
+ this.clock = options.clock ?? host.clock ?? realClock;
313
+ this.admit = host.admit;
314
+ this.admitSet = options.admitSet;
315
+ this.admitLeave = options.admitLeave;
316
+ this.partition = options.partition;
317
+ this.expiresMs = toMs(options.expiresAfter, "expiresAfter");
318
+ if (this.expiresMs <= 0) throw new RangeError("expiresAfter must be more than 0");
319
+ const window = options.window ?? ms(DEFAULT_WINDOW_MS);
320
+ this.windowOf = typeof window === "function" ? window : () => window;
321
+ this.maxKeys = options.maxKeysPerTopic ?? DEFAULT_LIVE_KEYS;
322
+ this.maxValueBytes = options.maxValueBytes ?? DEFAULT_LIVE_VALUE_BYTES;
323
+ this.maxTopics = options.maxTopics ?? DEFAULT_LIVE_TOPICS;
324
+ this.procedures = [this.setProcedure(host.topicSchema), this.snapshotProcedure(host.topicSchema), this.leaveProcedure(host.topicSchema)];
325
+ }
326
+
327
+ /** The number of live keys of this state. */
328
+ get size(): number {
329
+ return this.deadlines.size;
330
+ }
331
+
332
+ /**
333
+ * The topic of the bus for a live topic of a call, or undefined for another topic. The publisher
334
+ * calls it for each subscription and each re-check, so a change of the partition ends a
335
+ * subscription.
336
+ */
337
+ async route(topic: string, ctx: ProcedureContext): Promise<string | undefined> {
338
+ const target = liveTopicTarget(topic);
339
+ if (!target) return undefined;
340
+ if (!this.partition) {
341
+ if (target.partition !== undefined) throw refused(`The live topic "${topic}" has no partitions`, "partition-not-allowed");
342
+ return topic;
343
+ }
344
+ const partition = await this.partition(ctx, target.partition);
345
+ if (partition === null) throw refused(`The call cannot use the partition of the live topic "${topic}"`, "partition-not-allowed");
346
+ return liveTopic(target.name, partition);
347
+ }
348
+
349
+ /**
350
+ * This method keeps the latest value of a key and publishes it. This is the direct form for the
351
+ * host: it runs no check of a right, and `topic` is a topic of the bus.
352
+ *
353
+ * @returns The time of the set on the clock
354
+ * @throws `InvocationError` with the code `FORBIDDEN` at a limit, and the existing values stay
355
+ */
356
+ set(topic: string, key: string, value: unknown, options: LiveSetOptions = {}): number {
357
+ if (this.closed) throw refused("The live state is closed", "write-not-allowed");
358
+ this.checkValue(value);
359
+ const state = this.stateOf(topic);
360
+ if (!state) throw refused(`The live state has its limit of ${this.maxTopics} topics`, "write-not-allowed");
361
+ const at = this.clock.now();
362
+ const entry: LiveEntry = options.by === undefined ? { key, value, at } : { key, value, by: options.by, at };
363
+ let slot = state.keys.get(key);
364
+ if (slot) {
365
+ slot.entry = entry;
366
+ } else {
367
+ if (state.keys.size >= this.maxKeys) throw refused(`The live topic "${topic}" has its limit of ${this.maxKeys} keys`, "write-not-allowed");
368
+ slot = { state, key, entry, deadline: 0, owner: undefined };
369
+ state.keys.set(key, slot);
370
+ }
371
+ this.renew(slot, at);
372
+ this.own(slot, options.owner);
373
+ this.queue(state, (waiting) => {
374
+ waiting.gone.delete(key);
375
+ waiting.set.set(key, entry);
376
+ });
377
+ return at;
378
+ }
379
+
380
+ /** This method gives the live values of a topic of the bus. */
381
+ snapshot(topic: string): LiveEntry[] {
382
+ if (this.closed) return [];
383
+ const state = this.stateOf(topic);
384
+ return state ? Array.from(state.keys.values(), (slot) => slot.entry) : [];
385
+ }
386
+
387
+ /**
388
+ * This method ends a key and publishes the end with the reason `left`. This is the direct form for
389
+ * the host: it runs no check of a right, and `topic` is a topic of the bus.
390
+ *
391
+ * @returns True when the key was live
392
+ */
393
+ leave(topic: string, key: string): boolean {
394
+ const slot = this.topics.get(topic)?.keys.get(key);
395
+ if (this.closed || !slot) return false;
396
+ this.end(slot, "left", true);
397
+ return true;
398
+ }
399
+
400
+ /** This method ends each key that the connection owns. The transport of the host calls it at a close. */
401
+ connectionClosed(connection: string): void {
402
+ const slots = this.owned.get(connection);
403
+ if (!slots) return;
404
+ this.owned.delete(connection);
405
+ for (const slot of slots) {
406
+ slot.owner = undefined;
407
+ this.end(slot, "closed", true);
408
+ }
409
+ }
410
+
411
+ /** This method stops the timers and the listeners. The state then holds nothing. */
412
+ close(): void {
413
+ if (this.closed) return;
414
+ this.closed = true;
415
+ this.timer?.();
416
+ this.timer = undefined;
417
+ for (const state of this.topics.values()) {
418
+ state.window?.();
419
+ state.stop();
420
+ }
421
+ this.topics.clear();
422
+ this.deadlines.clear();
423
+ this.owned.clear();
424
+ }
425
+
426
+ /** This method refuses a value above the limit. A text has at most 3 bytes for each UTF-16 unit. */
427
+ private checkValue(value: unknown): void {
428
+ const text = JSON.stringify(value);
429
+ if (text === undefined) throw refused("A live value must have a JSON form", "write-not-allowed");
430
+ const fits = text.length * 3 <= this.maxValueBytes
431
+ || (text.length <= this.maxValueBytes && new TextEncoder().encode(text).length <= this.maxValueBytes);
432
+ if (!fits) throw refused(`A live value has more than ${this.maxValueBytes} bytes`, "write-not-allowed");
433
+ }
434
+
435
+ /**
436
+ * The state of a topic. The first use starts the listener of the bus. A topic stays after its
437
+ * last key, and goes when a new topic needs its place. Undefined means that no place is free.
438
+ */
439
+ private stateOf(topic: string): TopicState | undefined {
440
+ const known = this.topics.get(topic);
441
+ if (known) return known;
442
+ if (this.topics.size >= this.maxTopics) {
443
+ for (const state of this.topics.values()) {
444
+ if (state.keys.size === 0 && !state.window) {
445
+ state.stop();
446
+ this.topics.delete(state.topic);
447
+ }
448
+ }
449
+ if (this.topics.size >= this.maxTopics) return undefined;
450
+ }
451
+ const stop = this.bus.on<unknown>(topic, (event) => this.heard(topic, event));
452
+ const state: TopicState = { topic, keys: new Map(), stop, windowMs: toMs(this.windowOf(topic), "window"), waiting: undefined, window: undefined };
453
+ this.topics.set(topic, state);
454
+ return state;
455
+ }
456
+
457
+ /** This method applies an event of another host. Its subscribers on this host got it from the bus. */
458
+ private heard(topic: string, event: unknown): void {
459
+ if (this.closed || !isLiveEvent(event) || event.origin === this.origin) return;
460
+ const state = this.topics.get(topic);
461
+ if (!state) return;
462
+ const now = this.clock.now();
463
+ for (const entry of event.set) {
464
+ let slot = state.keys.get(entry.key);
465
+ if (slot) {
466
+ slot.entry = entry;
467
+ } else {
468
+ if (state.keys.size >= this.maxKeys) continue;
469
+ slot = { state, key: entry.key, entry, deadline: 0, owner: undefined };
470
+ state.keys.set(entry.key, slot);
471
+ }
472
+ // The connection of this key is on the other host: its deadline ends the key if that host dies
473
+ this.own(slot, undefined);
474
+ this.renew(slot, now);
475
+ }
476
+ for (const { key, reason } of event.gone) {
477
+ const slot = state.keys.get(key);
478
+ if (slot) this.end(slot, reason, false);
479
+ }
480
+ }
481
+
482
+ /** This method moves a key to the end of the deadlines. */
483
+ private renew(slot: Slot, now: number): void {
484
+ slot.deadline = now + this.expiresMs;
485
+ this.deadlines.delete(slot);
486
+ this.deadlines.add(slot);
487
+ this.armDeadline();
488
+ }
489
+
490
+ /** This method sets the owner of a key. The owner is the connection of the last set. */
491
+ private own(slot: Slot, owner: string | undefined): void {
492
+ if (slot.owner === owner) return;
493
+ if (slot.owner !== undefined) {
494
+ const slots = this.owned.get(slot.owner);
495
+ slots?.delete(slot);
496
+ if (slots?.size === 0) this.owned.delete(slot.owner);
497
+ }
498
+ slot.owner = owner;
499
+ if (owner === undefined) return;
500
+ const slots = this.owned.get(owner);
501
+ if (slots) slots.add(slot);
502
+ else this.owned.set(owner, new Set([slot]));
503
+ }
504
+
505
+ /** This method removes a key, and publishes its end when this host decided it. */
506
+ private end(slot: Slot, reason: LiveGoneReason, publish: boolean): void {
507
+ this.own(slot, undefined);
508
+ this.deadlines.delete(slot);
509
+ slot.state.keys.delete(slot.key);
510
+ if (!publish) return;
511
+ this.queue(slot.state, (waiting) => {
512
+ waiting.set.delete(slot.key);
513
+ waiting.gone.set(slot.key, { key: slot.key, reason });
514
+ });
515
+ }
516
+
517
+ /** This method starts the one timer of the deadlines, at the first deadline. */
518
+ private armDeadline(): void {
519
+ if (this.timer || this.closed) return;
520
+ const first = this.deadlines.values().next();
521
+ if (first.done) return;
522
+ const timer = this.clock.after(Math.max(0, first.value.deadline - this.clock.now()), () => {
523
+ this.timer = undefined;
524
+ this.expire();
525
+ });
526
+ // A deadline does not keep a Node process alive
527
+ timer.unref?.();
528
+ this.timer = timer;
529
+ }
530
+
531
+ /** This method ends each key at its deadline. */
532
+ private expire(): void {
533
+ const now = this.clock.now();
534
+ for (const slot of this.deadlines) {
535
+ if (slot.deadline > now) break;
536
+ this.end(slot, "expired", true);
537
+ }
538
+ this.armDeadline();
539
+ }
540
+
541
+ /**
542
+ * This method adds a change to a topic. On an idle topic the change goes at once, so one writer
543
+ * has no added delay, and the window opens. In an open window the change waits, and the latest
544
+ * change of a key wins.
545
+ */
546
+ private queue(state: TopicState, change: (waiting: Waiting) => void): void {
547
+ let waiting = state.waiting;
548
+ if (!waiting) {
549
+ waiting = { set: new Map(), gone: new Map() };
550
+ state.waiting = waiting;
551
+ }
552
+ change(waiting);
553
+ if (!state.window) this.publishWaiting(state);
554
+ }
555
+
556
+ /** This method publishes the changes that wait as one event, and opens the next window. */
557
+ private publishWaiting(state: TopicState): void {
558
+ const waiting = state.waiting;
559
+ if (!waiting || this.closed) return;
560
+ state.waiting = undefined;
561
+ if (state.windowMs > 0) {
562
+ const timer = this.clock.after(state.windowMs, () => {
563
+ state.window = undefined;
564
+ this.publishWaiting(state);
565
+ });
566
+ timer.unref?.();
567
+ state.window = timer;
568
+ }
569
+ const event: LiveEvent = { origin: this.origin, set: [...waiting.set.values()], gone: [...waiting.gone.values()] };
570
+ this.bus.emit(state.topic, event);
571
+ }
572
+
573
+ /** The procedure `live.set`: the checks of a subscription, then `admitSet`, then the set. */
574
+ private setProcedure(topicSchema: z.ZodString): AnyProcedure {
575
+ return createProcedure()
576
+ .path([...LIVE_SET_PATH])
577
+ .input(z.object({
578
+ topic: topicSchema,
579
+ key: z.string().min(1).max(MAX_LIVE_KEY_LENGTH).describe("The key of the value"),
580
+ value: z.unknown().refine((value) => value !== undefined, "A live set needs a value").describe("The latest value of the key"),
581
+ }))
582
+ .output(z.object({ at: z.number().describe("The time of the set on the clock of the host") }))
583
+ .meta({
584
+ description: "This procedure keeps the latest value of a key of a live topic and publishes it. It makes no store write.",
585
+ effects: { reads: ["events"], writes: ["live"] },
586
+ })
587
+ .handler(async (input: { topic: string; key: string; value?: unknown }, ctx: ProcedureContext) => {
588
+ const topic = await this.admit(input.topic, ctx);
589
+ if (!liveTopicTarget(topic)) throw refused(`The topic "${input.topic}" is not a live topic`, "write-not-allowed");
590
+ const admission = await this.admitSet({
591
+ topic,
592
+ key: input.key,
593
+ value: input.value,
594
+ capability: ctx.capability,
595
+ metadata: ctx.metadata,
596
+ ...(ctx.principal ? { principal: ctx.principal } : {}),
597
+ });
598
+ if (admission === null) throw refused(`The call cannot set a key of the live topic "${input.topic}"`, "write-not-allowed");
599
+ const connection = ctx.metadata["connectionId"];
600
+ const owner = typeof connection === "string" ? connection : undefined;
601
+ // A connection that closed during the checks owns no new key
602
+ if (ctx.signal?.aborted) throw refused("The call ended before the set", "write-not-allowed");
603
+ const value = admission.value !== undefined ? admission.value : input.value;
604
+ return { at: this.set(topic, input.key, value, { by: admission.by, owner }) };
605
+ })
606
+ .build() as AnyProcedure;
607
+ }
608
+
609
+ /**
610
+ * The procedure `live.leave`: the checks of a subscription, then the right to end the key. A set
611
+ * that comes during the check of the right keeps the key, because the check read the older set.
612
+ */
613
+ private leaveProcedure(topicSchema: z.ZodString): AnyProcedure {
614
+ return createProcedure()
615
+ .path([...LIVE_LEAVE_PATH])
616
+ .input(z.object({
617
+ topic: topicSchema,
618
+ key: z.string().min(1).max(MAX_LIVE_KEY_LENGTH).describe("The key to end"),
619
+ }))
620
+ .output(z.object({ ended: z.boolean().describe("True when the call ended a live key") }))
621
+ .meta({
622
+ description: "This procedure ends a key of a live topic. Each reader gets the end at once, or at the end of an open window.",
623
+ effects: { reads: ["events"], writes: ["live"] },
624
+ })
625
+ .handler(async (input: { topic: string; key: string }, ctx: ProcedureContext) => {
626
+ const topic = await this.admit(input.topic, ctx);
627
+ if (!liveTopicTarget(topic)) throw refused(`The topic "${input.topic}" is not a live topic`, "write-not-allowed", LIVE_LEAVE_PATH);
628
+ const slot = this.topics.get(topic)?.keys.get(input.key);
629
+ if (!slot) return { ended: false };
630
+ const { entry, owner } = slot;
631
+ const allowed = this.admitLeave
632
+ ? await this.admitLeave({
633
+ topic,
634
+ key: input.key,
635
+ by: entry.by,
636
+ owner,
637
+ capability: ctx.capability,
638
+ metadata: ctx.metadata,
639
+ ...(ctx.principal ? { principal: ctx.principal } : {}),
640
+ })
641
+ : owner !== undefined && owner === ctx.metadata["connectionId"];
642
+ if (!allowed) throw refused(`The call cannot end a key of the live topic "${input.topic}"`, "write-not-allowed", LIVE_LEAVE_PATH);
643
+ if (slot.entry !== entry || this.topics.get(topic)?.keys.get(input.key) !== slot) return { ended: false };
644
+ return { ended: this.leave(topic, input.key) };
645
+ })
646
+ .build() as AnyProcedure;
647
+ }
648
+
649
+ /** The procedure `live.snapshot`: the checks of a subscription, then the live values. */
650
+ private snapshotProcedure(topicSchema: z.ZodString): AnyProcedure {
651
+ return createProcedure()
652
+ .path([...LIVE_SNAPSHOT_PATH])
653
+ .input(z.object({ topic: topicSchema }))
654
+ .output(z.object({ entries: z.array(entrySchema).describe("The live values of the topic") }))
655
+ .meta({
656
+ description: "This procedure gives the live values of a topic. A new subscriber reads it after its subscription starts.",
657
+ effects: { reads: ["events"], writes: [] },
658
+ })
659
+ .handler(async (input: { topic: string }, ctx: ProcedureContext) => {
660
+ const topic = await this.admit(input.topic, ctx);
661
+ if (!liveTopicTarget(topic)) throw refused(`The topic "${input.topic}" is not a live topic`, "write-not-allowed");
662
+ return { entries: this.snapshot(topic) };
663
+ })
664
+ .build() as AnyProcedure;
665
+ }
666
+ }