@procedures/client 0.6.0 → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +63 -7
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +5 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +10 -4
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/server/ndjson.d.ts +73 -0
  9. package/dist/adapters/http/server/ndjson.d.ts.map +1 -0
  10. package/dist/adapters/http/server/ndjson.js +228 -0
  11. package/dist/adapters/http/server/ndjson.js.map +1 -0
  12. package/dist/adapters/http/server/transport.d.ts +15 -4
  13. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/http/server/transport.js +29 -69
  15. package/dist/adapters/http/server/transport.js.map +1 -1
  16. package/dist/adapters/http/server/types.d.ts +15 -0
  17. package/dist/adapters/http/server/types.d.ts.map +1 -1
  18. package/dist/adapters/sequence.d.ts +14 -0
  19. package/dist/adapters/sequence.d.ts.map +1 -1
  20. package/dist/adapters/sequence.js +16 -0
  21. package/dist/adapters/sequence.js.map +1 -1
  22. package/dist/adapters/websocket/client/transport.d.ts +7 -3
  23. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  24. package/dist/adapters/websocket/client/transport.js +9 -3
  25. package/dist/adapters/websocket/client/transport.js.map +1 -1
  26. package/dist/adapters/websocket/server/transport.d.ts +10 -0
  27. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  28. package/dist/adapters/websocket/server/transport.js +22 -2
  29. package/dist/adapters/websocket/server/transport.js.map +1 -1
  30. package/dist/browser.d.ts +5 -5
  31. package/dist/browser.d.ts.map +1 -1
  32. package/dist/browser.js +4 -3
  33. package/dist/browser.js.map +1 -1
  34. package/dist/client/errors/registry.d.ts.map +1 -1
  35. package/dist/client/errors/registry.js +1 -0
  36. package/dist/client/errors/registry.js.map +1 -1
  37. package/dist/client/middleware/retry.d.ts +4 -0
  38. package/dist/client/middleware/retry.d.ts.map +1 -1
  39. package/dist/client/middleware/retry.js +86 -78
  40. package/dist/client/middleware/retry.js.map +1 -1
  41. package/dist/events/bus.d.ts +32 -14
  42. package/dist/events/bus.d.ts.map +1 -1
  43. package/dist/events/bus.js +119 -65
  44. package/dist/events/bus.js.map +1 -1
  45. package/dist/events/index.d.ts +3 -2
  46. package/dist/events/index.d.ts.map +1 -1
  47. package/dist/events/index.js.map +1 -1
  48. package/dist/events/publisher.d.ts.map +1 -1
  49. package/dist/events/publisher.js +20 -6
  50. package/dist/events/publisher.js.map +1 -1
  51. package/dist/events/remote.d.ts +80 -18
  52. package/dist/events/remote.d.ts.map +1 -1
  53. package/dist/events/remote.js +448 -58
  54. package/dist/events/remote.js.map +1 -1
  55. package/dist/events/types.d.ts +13 -2
  56. package/dist/events/types.d.ts.map +1 -1
  57. package/dist/node.d.ts +6 -0
  58. package/dist/node.d.ts.map +1 -1
  59. package/dist/node.js +5 -0
  60. package/dist/node.js.map +1 -1
  61. package/dist/procedures/caller.d.ts +33 -0
  62. package/dist/procedures/caller.d.ts.map +1 -0
  63. package/dist/procedures/caller.js +27 -0
  64. package/dist/procedures/caller.js.map +1 -0
  65. package/dist/procedures/collection/index.d.ts +2 -2
  66. package/dist/procedures/collection/index.d.ts.map +1 -1
  67. package/dist/procedures/collection/index.js +1 -1
  68. package/dist/procedures/collection/index.js.map +1 -1
  69. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  70. package/dist/procedures/collection/procedures.js +21 -1
  71. package/dist/procedures/collection/procedures.js.map +1 -1
  72. package/dist/procedures/collection/schemas.d.ts +9 -0
  73. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  74. package/dist/procedures/collection/schemas.js +28 -0
  75. package/dist/procedures/collection/schemas.js.map +1 -1
  76. package/dist/procedures/collection/storage.d.ts +6 -0
  77. package/dist/procedures/collection/storage.d.ts.map +1 -1
  78. package/dist/procedures/index.d.ts +3 -1
  79. package/dist/procedures/index.d.ts.map +1 -1
  80. package/dist/procedures/index.js +2 -0
  81. package/dist/procedures/index.js.map +1 -1
  82. package/dist/procedures/invoke.d.ts.map +1 -1
  83. package/dist/procedures/invoke.js +3 -0
  84. package/dist/procedures/invoke.js.map +1 -1
  85. package/dist/procedures/types.d.ts +50 -0
  86. package/dist/procedures/types.d.ts.map +1 -1
  87. package/dist/procedures/types.js.map +1 -1
  88. package/dist/protocol.d.ts +1 -1
  89. package/dist/protocol.d.ts.map +1 -1
  90. package/dist/protocol.js +1 -0
  91. package/dist/protocol.js.map +1 -1
  92. package/dist/server/procedure-server.d.ts +41 -5
  93. package/dist/server/procedure-server.d.ts.map +1 -1
  94. package/dist/server/procedure-server.js +94 -9
  95. package/dist/server/procedure-server.js.map +1 -1
  96. package/dist/timers.d.ts +9 -0
  97. package/dist/timers.d.ts.map +1 -1
  98. package/dist/timers.js +6 -1
  99. package/dist/timers.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/adapters/http/client/transport.ts +10 -4
  102. package/src/adapters/http/server/ndjson.ts +259 -0
  103. package/src/adapters/http/server/transport.ts +31 -69
  104. package/src/adapters/http/server/types.ts +17 -0
  105. package/src/adapters/sequence.ts +18 -0
  106. package/src/adapters/websocket/client/transport.ts +9 -3
  107. package/src/adapters/websocket/server/transport.ts +23 -2
  108. package/src/browser.ts +9 -2
  109. package/src/client/errors/registry.ts +1 -0
  110. package/src/client/middleware/retry.ts +89 -78
  111. package/src/events/bus.ts +141 -73
  112. package/src/events/index.ts +3 -0
  113. package/src/events/publisher.ts +23 -6
  114. package/src/events/remote.ts +575 -73
  115. package/src/events/types.ts +15 -2
  116. package/src/node.ts +6 -0
  117. package/src/procedures/caller.ts +45 -0
  118. package/src/procedures/collection/index.ts +3 -0
  119. package/src/procedures/collection/procedures.ts +22 -0
  120. package/src/procedures/collection/schemas.ts +37 -0
  121. package/src/procedures/collection/storage.ts +6 -0
  122. package/src/procedures/index.ts +4 -0
  123. package/src/procedures/invoke.ts +2 -0
  124. package/src/procedures/types.ts +53 -0
  125. package/src/protocol.ts +3 -1
  126. package/src/server/procedure-server.ts +111 -11
  127. package/src/timers.ts +15 -1
@@ -82,9 +82,10 @@ export interface EventBus {
82
82
  *
83
83
  * @param channel - Channel to subscribe to
84
84
  * @param handler - Handler function called for each event
85
+ * @param options - `onClear`: the function that runs when `clear()` or `clearAll()` removes the handler
85
86
  * @returns Unsubscribe function
86
87
  */
87
- on<T>(channel: string, handler: EventHandler<T>): Unsubscribe;
88
+ on<T>(channel: string, handler: EventHandler<T>, options?: EventListenOptions): Unsubscribe;
88
89
 
89
90
  /**
90
91
  * Subscribe to a single event on a channel.
@@ -177,7 +178,8 @@ export interface TypedEventBus<TChannels extends ChannelMap> {
177
178
  emit<K extends keyof TChannels & string>(channel: K, data: TChannels[K]): void;
178
179
  on<K extends keyof TChannels & string>(
179
180
  channel: K,
180
- handler: EventHandler<TChannels[K]>
181
+ handler: EventHandler<TChannels[K]>,
182
+ options?: EventListenOptions
181
183
  ): Unsubscribe;
182
184
  once<K extends keyof TChannels & string>(channel: K, options?: EventWaitOptions): Promise<TChannels[K]>;
183
185
  stream<K extends keyof TChannels & string>(channel: K, options?: EventWaitOptions): AsyncIterable<TChannels[K]>;
@@ -238,6 +240,17 @@ export interface EventWaitOptions {
238
240
  signal?: AbortSignal | undefined;
239
241
  }
240
242
 
243
+ /**
244
+ * Options for a handler of `on()`.
245
+ */
246
+ export interface EventListenOptions {
247
+ /**
248
+ * This function runs one time when `clear()` or `clearAll()` removes the handler. An unsubscribe
249
+ * does not run it. The publisher of the remote events uses it to end a subscription with notice.
250
+ */
251
+ onClear?: (() => void) | undefined;
252
+ }
253
+
241
254
  /**
242
255
  * Options for creating an event bus.
243
256
  */
package/src/node.ts CHANGED
@@ -5,7 +5,13 @@
5
5
  * uses the `WebSocket` class of the `ws` package, so a drop ends a socket at once with
6
6
  * `terminate()` (finding of roadmap R36). The other entries keep the global `WebSocket`: a browser
7
7
  * bundle imports `@procedures/client/browser`, and that entry does not import `ws`.
8
+ *
9
+ * This entry also exports `writeNdjsonStream`: the writer of an NDJSON stream response of wire
10
+ * protocol v1, for a `ServerResponse` of `node:http`. `HttpServerTransport` uses it, so a server
11
+ * without Express writes the same bytes.
8
12
  */
9
13
 
10
14
  export { WebSocketTransport } from "./adapters/websocket/client/node.js";
11
15
  export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/websocket/client/types.js";
16
+ export { writeNdjsonStream } from "./adapters/http/server/ndjson.js";
17
+ export type { WriteNdjsonStreamOptions, NdjsonError } from "./adapters/http/server/ndjson.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A caller of the procedures of some modules (`callerOf`). An app that calls the procedures of a
3
+ * package in its own process repeats three steps: a registry of the module, `invokePath` and
4
+ * `outputValue`. A caller does the three steps in one call. jarvis had four copies of them.
5
+ */
6
+
7
+ import type { ProcedurePath } from "./types.js";
8
+ import type { ProcedureRegistry } from "./registry.js";
9
+ import { invokePath, outputValue, type InvokeOptions } from "./invoke.js";
10
+ import { createRegistry, type ModuleInput } from "./module.js";
11
+
12
+ /** The options of one call of a caller: the options of `invokePath` without the registry. */
13
+ export type CallerOptions = Omit<InvokeOptions, "registry">;
14
+
15
+ /** A function that runs the procedures of its modules (`callerOf`). */
16
+ export interface Caller {
17
+ /**
18
+ * This function runs the procedure at `path` with `input`, and gives one value: the value of
19
+ * the procedure, or the last item of its stream. An empty stream rejects with `NO_OUTPUT`, and
20
+ * a path that the modules do not have rejects with `NOT_FOUND`.
21
+ */
22
+ <T = unknown>(path: ProcedurePath, input: unknown, options?: CallerOptions): Promise<T>;
23
+ /** The registry of the caller: `clientModule` and the modules, with their required modules. The first use makes it. */
24
+ readonly registry: ProcedureRegistry;
25
+ }
26
+
27
+ /**
28
+ * This function makes a caller of the procedures of the modules. The registry of the modules is
29
+ * made at the first call, so a caller costs nothing until it calls. The caller does not use the
30
+ * global registry.
31
+ *
32
+ * @example
33
+ * const gh = callerOf(ghModule);
34
+ * const runs = await gh<RunList>(["gh", "run", "list"], { repo: "owner/name" });
35
+ */
36
+ export function callerOf(...modules: ModuleInput[]): Caller {
37
+ let registry: ProcedureRegistry | undefined;
38
+ const registryOf = (): ProcedureRegistry => (registry ??= createRegistry(...modules));
39
+ const call = async <T = unknown>(path: ProcedurePath, input: unknown, options: CallerOptions = {}): Promise<T> => {
40
+ const output = await invokePath<T>(path, input, { ...options, registry: registryOf() });
41
+ return outputValue(output, path);
42
+ };
43
+ Object.defineProperty(call, "registry", { get: registryOf, enumerable: true });
44
+ return call as Caller;
45
+ }
@@ -21,6 +21,8 @@ export {
21
21
  getAllOutputSchema,
22
22
  entriesInputSchema,
23
23
  entriesOutputSchema,
24
+ idsInputSchema,
25
+ idsOutputSchema,
24
26
  changesInputSchema,
25
27
  changesOutputSchema,
26
28
  sizeInputSchema,
@@ -44,6 +46,7 @@ export type {
44
46
  GetAllInput,
45
47
  EntriesInput,
46
48
  CollectionEntry,
49
+ IdsInput,
47
50
  ChangesInput,
48
51
  ChangesOutput,
49
52
  SizeInput,
@@ -20,6 +20,8 @@ import {
20
20
  getAllOutputSchema,
21
21
  entriesInputSchema,
22
22
  entriesOutputSchema,
23
+ idsInputSchema,
24
+ idsOutputSchema,
23
25
  changesInputSchema,
24
26
  changesOutputSchema,
25
27
  sizeInputSchema,
@@ -39,6 +41,7 @@ import {
39
41
  type HasInput,
40
42
  type GetAllInput,
41
43
  type EntriesInput,
44
+ type IdsInput,
42
45
  type ChangesInput,
43
46
  type ChangesOutput,
44
47
  type SizeInput,
@@ -199,6 +202,25 @@ export function createCollectionProcedures(collectionName: string): AnyProcedure
199
202
  },
200
203
  }),
201
204
 
205
+ // Ids: the ids of the items without the values, for a full sync
206
+ defineProcedure({
207
+ path: [...basePath, "ids"],
208
+ input: idsInputSchema,
209
+ output: idsOutputSchema,
210
+ metadata: {
211
+ effects: { reads: [resource], writes: [] },
212
+ description: `Get the ids of the items of ${collectionName}, without the values`,
213
+ tags: ["collections", collectionName, "read"],
214
+ },
215
+ handler: async (_input: IdsInput, ctx: ProcedureContext) => {
216
+ const storage = getStorage(ctx);
217
+ if (storage.getIds) return storage.getIds();
218
+ // A storage without getIds() gives its entries: the values stay on the server
219
+ if (storage.getEntries) return (await storage.getEntries()).map(([id]) => id);
220
+ throw new Error(`The storage of ${collectionName} cannot give the ids of its items`);
221
+ },
222
+ }),
223
+
202
224
  // Changes: the items that changed after a version, for a delta sync (roadmap R28)
203
225
  defineProcedure({
204
226
  path: [...basePath, "changes"],
@@ -406,6 +406,43 @@ export const entriesOutputSchema : ZodLike<CollectionEntry[]> = createSchema<Col
406
406
  };
407
407
  });
408
408
 
409
+ // =============================================================================
410
+ // Ids Operation Schemas
411
+ // =============================================================================
412
+
413
+ /** Input for ids operation - empty object. */
414
+ export interface IdsInput {}
415
+
416
+ export const idsInputSchema : ZodLike<IdsInput> = createSchema<IdsInput>((data) => {
417
+ if (!isObject(data)) {
418
+ return {
419
+ success: false,
420
+ error: {
421
+ message: "Input must be an object",
422
+ errors: [{ path: [], message: "Input must be an object" }],
423
+ },
424
+ };
425
+ }
426
+ return { success: true, data: {} };
427
+ });
428
+
429
+ /**
430
+ * Output for ids operation: the ids of the items, in the order of the entries, without the
431
+ * values. A full sync needs only the ids.
432
+ */
433
+ export const idsOutputSchema : ZodLike<string[]> = createSchema<string[]>((data) => {
434
+ if (isArray(data) && data.every((id) => isString(id))) {
435
+ return { success: true, data: data as string[] };
436
+ }
437
+ return {
438
+ success: false,
439
+ error: {
440
+ message: "Output must be an array of ids",
441
+ errors: [{ path: [], message: "Output must be an array of ids" }],
442
+ },
443
+ };
444
+ });
445
+
409
446
  // =============================================================================
410
447
  // Changes Operation Schemas
411
448
  // =============================================================================
@@ -13,6 +13,7 @@
13
13
  * The contract (the suite of `@procedures/collections/testing` tests it for each storage):
14
14
  * - A storage keeps a copy of each value that a write takes, and each read gives a new copy.
15
15
  * - A stored `null` reads back as `null`. A missing id reads back as `undefined`.
16
+ * - `getIds()` gives the ids of `getEntries()`, in the same order.
16
17
  * - A delta of `getChangesSince` follows the order of the last change of each id.
17
18
  * - After `close()`, each operation rejects with an error whose message says "closed". A second
18
19
  * `close()` does nothing.
@@ -27,6 +28,11 @@ export interface CollectionStorage<T> {
27
28
  * them does not have this method.
28
29
  */
29
30
  getEntries?(): Promise<Array<[string, T]>>;
31
+ /**
32
+ * The ids of the items, in the order of the entries, without the values. A storage that cannot
33
+ * give the ids cheaply does not have this method.
34
+ */
35
+ getIds?(): Promise<string[]>;
30
36
  /**
31
37
  * The changes after a version (roadmap R28). A storage without a change log does not have
32
38
  * this method, and a sync uses `getEntries()`.
@@ -25,6 +25,7 @@ export type {
25
25
  RegistrationOptions,
26
26
  ProcedureRegistryLike,
27
27
  Effects,
28
+ FsRootRules,
28
29
  PermittedEffects,
29
30
  Register,
30
31
  ClearanceOf,
@@ -107,6 +108,9 @@ export {
107
108
  registerClientProcedures,
108
109
  } from "./module.js";
109
110
  export type { ModuleInput } from "./module.js";
111
+ // One call for a registry of some modules, `invokePath` and `outputValue`
112
+ export { callerOf } from "./caller.js";
113
+ export type { Caller, CallerOptions } from "./caller.js";
110
114
 
111
115
  // Procedure reference system (procedure-as-data)
112
116
  export {
@@ -486,6 +486,8 @@ export function createProcedureContext(
486
486
  if (options.expose) context.expose = options.expose;
487
487
  if (options.repository) context.repository = options.repository;
488
488
  if (options.onRef) context.onRef = options.onRef;
489
+ // The procedure of the parent invocation made this call (roadmap R33: the root of the files)
490
+ if (invocation?.parent) context.caller = invocation.parent.procedure;
489
491
  // Every context has a bus. With a signal, the waits of the handler on the bus end when the
490
492
  // invocation aborts, so a stream handler that waits on ctx.bus.stream() can finish. (Before,
491
493
  // no host set ctx.bus: deep dive CORE-15.)
@@ -83,10 +83,56 @@ export interface ProcedureMetadata {
83
83
  */
84
84
  stream?: { readonly unbounded?: boolean } | undefined;
85
85
 
86
+ /**
87
+ * The rules of the procedure under a root of the files (roadmap R33): the inputs that name a
88
+ * path, or the reason that a root cannot confine it. A host with a root (`--fs-root`,
89
+ * `MCP_FS_ROOT`) reads them. Without the rules, the host decides from `effects`.
90
+ */
91
+ fsRoot?: FsRootRules | undefined;
92
+
86
93
  /** Custom metadata fields */
87
94
  [key: string]: unknown;
88
95
  }
89
96
 
97
+ /**
98
+ * The rules of a procedure under a root of the files (roadmap R33). A host with a root keeps each
99
+ * path of these inputs in the root, or it refuses the procedure. `confineToRoot()` of
100
+ * `@procedures/fs` applies the rules. Without a root, the rules change nothing.
101
+ */
102
+ export interface FsRootRules {
103
+ /**
104
+ * The input that names the working folder. When the call gives no value, the procedure gets the
105
+ * root. A relative value starts at the root.
106
+ */
107
+ readonly cwd?: string;
108
+ /**
109
+ * The inputs that name a file or a folder: a text, or a list of texts. A relative path starts at
110
+ * the root. The procedure gets the absolute path.
111
+ */
112
+ readonly paths?: readonly string[];
113
+ /**
114
+ * The inputs that name a file or a folder relative to the working folder, for example the files
115
+ * of an archive. A relative name starts at the working folder, or at the root without `cwd`.
116
+ * The host examines each path, and it does not change the value.
117
+ */
118
+ readonly names?: readonly string[];
119
+ /**
120
+ * The inputs of `paths` whose default is outside the working folder. Under a root, a call
121
+ * without a value of such an input is refused.
122
+ */
123
+ readonly required?: readonly string[];
124
+ /** The values of the inputs that are not paths, for example `:memory:` of SQLite. */
125
+ readonly values?: readonly string[];
126
+ /** The reason that a root cannot confine the procedure. Under a root, the host refuses it. */
127
+ readonly refuse?: string;
128
+ /**
129
+ * True for a procedure that starts the program of its input (`shell.run`). Under a root, the
130
+ * host refuses a call of a client. It accepts the nested call of a confined procedure that
131
+ * names this procedure in `metadata.calls`.
132
+ */
133
+ readonly program?: boolean;
134
+ }
135
+
90
136
  // =============================================================================
91
137
  // Capabilities (CAPABILITIES-2026-10)
92
138
  // =============================================================================
@@ -294,6 +340,13 @@ export interface ProcedureContext {
294
340
  * database for a lower clearance, for example. Empty when the host gives none.
295
341
  */
296
342
  resources: ResourcesOf;
343
+
344
+ /**
345
+ * The procedure whose handler made this call through its `ctx.client`. A call of a host or a
346
+ * client has no caller. The framework sets it from the invocation, so an input cannot change
347
+ * it. The root of the files reads it (roadmap R33).
348
+ */
349
+ caller?: AnyProcedure | undefined;
297
350
  }
298
351
 
299
352
  /**
package/src/protocol.ts CHANGED
@@ -47,7 +47,8 @@ export type ProtocolErrorCode =
47
47
  | "UNAVAILABLE"
48
48
  | "CONNECTION_CLOSED"
49
49
  | "CONNECTION_NOT_FOUND"
50
- | "TOPIC_NOT_FOUND";
50
+ | "TOPIC_NOT_FOUND"
51
+ | "TOPIC_CLEARED";
51
52
 
52
53
  /**
53
54
  * The error codes of the protocol, on each face (HTTP, WebSocket, MCP, local). An error that a
@@ -78,6 +79,7 @@ export const ERROR_CODES: Readonly<Record<ProtocolErrorCode, ErrorCodeInfo>> = {
78
79
  CONNECTION_CLOSED: { http: 503, retryable: true, meaning: "The connection closed before the call left" },
79
80
  CONNECTION_NOT_FOUND: { http: 404, retryable: false, meaning: "The server has no connection with this id" },
80
81
  TOPIC_NOT_FOUND: { http: 404, retryable: false, meaning: "The topic rule of the host has no topic with this name" },
82
+ TOPIC_CLEARED: { http: 410, retryable: false, meaning: "The host cleared a topic of the subscription: clear() of its bus removed the listener" },
81
83
  };
82
84
 
83
85
  /** True when a code is an error code of the protocol. */
@@ -24,6 +24,7 @@ import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptio
24
24
  import { optionalMs, type Duration } from "../duration.js";
25
25
  import type { CollectionStorage } from "../procedures/collection/storage.js";
26
26
  import { TIMER_DEFAULTS } from "../timers.js";
27
+ import { realClock, type Clock } from "../clock.js";
27
28
  import { errorCodeOf } from "../protocol.js";
28
29
  import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
29
30
  import { getGlobalEventBus } from "../events/bus.js";
@@ -49,8 +50,18 @@ export interface UseStorageOptions {
49
50
  * flush fails, the old storage stays and `useStorage` throws.
50
51
  */
51
52
  drain?: boolean | undefined;
52
- /** True: the server closes the old storage after the change. */
53
+ /**
54
+ * True: the server closes the old storage after the change. The close waits for the calls that
55
+ * took the old storage, for the time of `grace` at most.
56
+ */
53
57
  close?: boolean | undefined;
58
+ /**
59
+ * The longest time that the close waits for the calls on the old storage, for example
60
+ * `seconds(5)`. When the time ends, the server closes the old storage. A call that still uses it
61
+ * then fails with the error of a closed storage. 0: the server closes it at once.
62
+ * @default 30 seconds (`TIMER_DEFAULTS.server.storageGraceMs`)
63
+ */
64
+ grace?: Duration | undefined;
54
65
  }
55
66
 
56
67
  /**
@@ -66,6 +77,12 @@ export interface ProcedureServerOptions extends ServerOptions {
66
77
  /** Collections to configure with storage backends */
67
78
  collections?: Record<string, CollectionStorage<unknown>>;
68
79
 
80
+ /**
81
+ * The clock of the wait of `useStorage` for the calls on an old storage (roadmap R35). The
82
+ * default is the clock of the platform. A test gives a fake clock.
83
+ */
84
+ clock?: Clock | undefined;
85
+
69
86
  /**
70
87
  * The procedures that the callers of this server can reach. `registerFromRegistry()` registers
71
88
  * only these procedures. A data-driven procedure (control flow, `eval`, a procedure that
@@ -143,13 +160,16 @@ export function isUnboundedStream(response: { metadata?: Record<string, unknown>
143
160
  }
144
161
 
145
162
  /**
146
- * True when the presented token is the expected one. The comparison takes the same time for each
147
- * presented token of the same length, so the time does not tell the token.
163
+ * True when the presented token is the expected one. The comparison reads each character of the
164
+ * expected token for each presented token, so its time depends only on the expected token. The
165
+ * time tells neither the token nor its length. The function runs in a browser too, so it uses
166
+ * no `node:crypto`.
148
167
  */
149
168
  export function tokenMatches(expected: string, presented: string | undefined): boolean {
150
- if (presented === undefined || presented.length !== expected.length) return false;
151
- let difference = 0;
152
- for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ presented.charCodeAt(index);
169
+ if (presented === undefined) return false;
170
+ // A difference of the lengths makes the result false, after the same loop as an equal length
171
+ let difference = expected.length ^ presented.length;
172
+ for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ (presented.charCodeAt(index) | 0);
153
173
  return difference === 0;
154
174
  }
155
175
 
@@ -163,6 +183,15 @@ export interface StopOptions {
163
183
  grace?: Duration | undefined;
164
184
  }
165
185
 
186
+ /** The items of a stream. At the end of the stream, or when its reader stops, `release` runs. */
187
+ async function* releasedAtEnd<T>(items: AsyncIterable<T>, release: () => void): AsyncGenerator<T, void, undefined> {
188
+ try {
189
+ yield* items;
190
+ } finally {
191
+ release();
192
+ }
193
+ }
194
+
166
195
  /** A signal that aborts when one of the signals aborts. */
167
196
  function joinSignals(first: AbortSignal | undefined, second: AbortSignal): AbortSignal {
168
197
  if (!first) return second;
@@ -268,6 +297,10 @@ export function errorResponse(id: string, error: unknown): ServerResponse<unknow
268
297
  export class ProcedureServer extends Server implements RepositoryProvider {
269
298
  private readonly procedureRegistry: ProcedureRegistry;
270
299
  private readonly storages = new Map<string, CollectionStorage<unknown>>();
300
+ /** For each storage: the calls in flight that took it, and the closes that wait for them */
301
+ private readonly storageUses = new Map<CollectionStorage<unknown>, { calls: number; waiters: Array<() => void> }>();
302
+ /** The clock of the wait of `useStorage` */
303
+ private readonly clock: Clock;
271
304
  /** The procedures that `registerProcedure` pinned, by key */
272
305
  private readonly pinnedProcedures = new Map<string, AnyProcedure>();
273
306
  /**
@@ -305,6 +338,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
305
338
  super(options);
306
339
 
307
340
  this.procedureRegistry = options.registry ?? PROCEDURE_REGISTRY;
341
+ this.clock = options.clock ?? realClock;
308
342
  this.expose = options.expose;
309
343
  this.strictCalls = options.strictCalls ?? false;
310
344
  this.interceptors = options.interceptors ?? [];
@@ -376,7 +410,11 @@ export class ProcedureServer extends Server implements RepositoryProvider {
376
410
  * failed flush keeps the old storage and throws. A call that arrives during the flush goes
377
411
  * to the old storage.
378
412
  * - `close`: the server closes the old storage after the change. The close of a
379
- * `HybridStorage` sends the writes that arrived during the flush.
413
+ * `HybridStorage` sends the writes that arrived during the flush. An old storage that another
414
+ * collection still uses stays open: the server closes it when the last collection leaves it.
415
+ * - The new calls use the new storage at once. Before the close, the server waits for the calls
416
+ * that took the old storage, for the time of `grace` at most. Before, such a call could fail
417
+ * because the storage was closed.
380
418
  *
381
419
  * @param name - Collection name
382
420
  * @param storage - Storage backend
@@ -393,20 +431,76 @@ export class ProcedureServer extends Server implements RepositoryProvider {
393
431
  return this.replaceStorage(name, storage as CollectionStorage<unknown>, options);
394
432
  }
395
433
 
396
- /** The change of `useStorage` with options: flush the old storage, change, close the old one. */
434
+ /**
435
+ * The change of `useStorage` with options: flush the old storage, change, wait for the calls on
436
+ * the old storage, and close it.
437
+ */
397
438
  private async replaceStorage(name: string, storage: CollectionStorage<unknown>, options: UseStorageOptions): Promise<this> {
439
+ const graceMs = optionalMs(options.grace, "grace") ?? TIMER_DEFAULTS.server.storageGraceMs;
398
440
  const old = this.storages.get(name);
399
441
  const other = old !== undefined && old !== storage ? old : undefined;
400
442
  if (other && options.drain) {
401
443
  await other.flush?.();
402
444
  }
403
445
  this.storages.set(name, storage);
404
- if (other && options.close) {
446
+ // Another collection that uses the same storage keeps it open. Before, the close broke the
447
+ // calls of that collection (tracker O31).
448
+ if (other && options.close && ![...this.storages.values()].includes(other)) {
449
+ await this.callsOnStorageEnded(other, graceMs);
405
450
  await other.close();
406
451
  }
407
452
  return this;
408
453
  }
409
454
 
455
+ /** A promise that resolves when no call uses the storage, or when `graceMs` ends. */
456
+ private callsOnStorageEnded(storage: CollectionStorage<unknown>, graceMs: number): Promise<void> {
457
+ const use = this.storageUses.get(storage);
458
+ if (!use || graceMs <= 0) return Promise.resolve();
459
+ return new Promise<void>((resolve) => {
460
+ const done = (): void => {
461
+ cancel();
462
+ const index = use.waiters.indexOf(done);
463
+ if (index >= 0) use.waiters.splice(index, 1);
464
+ resolve();
465
+ };
466
+ const cancel = this.clock.after(graceMs, done);
467
+ use.waiters.push(done);
468
+ });
469
+ }
470
+
471
+ /**
472
+ * The repository of one call. The call is a user of each storage that it takes, until
473
+ * `release()`. Then a close of `useStorage` can go on.
474
+ */
475
+ private callRepository(): { repository: RepositoryProvider; release: () => void } {
476
+ const taken = new Set<CollectionStorage<unknown>>();
477
+ let released = false;
478
+ const repository: RepositoryProvider = {
479
+ getStorage: <T>(collection: string): CollectionStorage<T> => {
480
+ const storage = this.getStorage<unknown>(collection);
481
+ if (!released && !taken.has(storage)) {
482
+ taken.add(storage);
483
+ const use = this.storageUses.get(storage) ?? { calls: 0, waiters: [] };
484
+ use.calls++;
485
+ this.storageUses.set(storage, use);
486
+ }
487
+ return storage as CollectionStorage<T>;
488
+ },
489
+ hasCollection: (collection: string): boolean => this.hasCollection(collection),
490
+ };
491
+ const release = (): void => {
492
+ if (released) return;
493
+ released = true;
494
+ for (const storage of taken) {
495
+ const use = this.storageUses.get(storage);
496
+ if (!use || --use.calls > 0) continue;
497
+ this.storageUses.delete(storage);
498
+ for (const waiter of use.waiters.slice()) waiter();
499
+ }
500
+ };
501
+ return { repository, release };
502
+ }
503
+
410
504
  /**
411
505
  * Configure multiple storages at once.
412
506
  *
@@ -642,6 +736,9 @@ export class ProcedureServer extends Server implements RepositoryProvider {
642
736
 
643
737
  /** Run a procedure for a request with a signal. */
644
738
  private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
739
+ // The call uses the storages that it takes until its end: the end of a stream, for a stream
740
+ const { repository, release } = this.callRepository();
741
+ let streams = false;
645
742
  try {
646
743
  // One invocation path for every host (ARCHITECTURE-PROPOSALS P1): input validation, the
647
744
  // context, the expose rule for nested calls (BUGS-2026-07 H18) and output validation
@@ -650,7 +747,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
650
747
  registry: this.procedureRegistry,
651
748
  metadata: request.metadata,
652
749
  signal,
653
- repository: this,
750
+ repository,
654
751
  expose: this.expose,
655
752
  strictCalls: this.strictCalls,
656
753
  interceptors: this.interceptors,
@@ -666,16 +763,19 @@ export class ProcedureServer extends Server implements RepositoryProvider {
666
763
  if (output.kind === "stream") {
667
764
  // The faces that collect a stream read this mark (roadmap R15)
668
765
  const unbounded = (procedure.metadata as { stream?: { unbounded?: boolean } } | undefined)?.stream?.unbounded === true;
766
+ streams = true;
669
767
  return {
670
768
  id: request.id,
671
769
  status: { type: "success", code: 200 },
672
- stream: output.items,
770
+ stream: releasedAtEnd(output.items, release),
673
771
  metadata: unbounded ? { [STREAM_METADATA_KEY]: "unbounded" } : {},
674
772
  };
675
773
  }
676
774
  return { id: request.id, status: { type: "success", code: 200 }, payload: output.value, metadata: {} };
677
775
  } catch (error) {
678
776
  return errorResponse(request.id, error);
777
+ } finally {
778
+ if (!streams) release();
679
779
  }
680
780
  }
681
781
 
package/src/timers.ts CHANGED
@@ -47,6 +47,10 @@ export interface TimerDefaults {
47
47
  /** The time until the first frame of a call of the server to a client. */
48
48
  readonly reverseRequestTimeoutMs: number;
49
49
  };
50
+ readonly httpServer: {
51
+ /** The quiet time of an NDJSON stream. After it, the server writes a keep-alive line. */
52
+ readonly keepAliveMs: number;
53
+ };
50
54
  readonly idempotency: {
51
55
  /** The time that the server keeps the result of a keyed write. */
52
56
  readonly ttlMs: number;
@@ -54,6 +58,11 @@ export interface TimerDefaults {
54
58
  readonly server: {
55
59
  /** The time that `ProcedureServer.stop()` waits for the calls that run. */
56
60
  readonly stopGraceMs: number;
61
+ /**
62
+ * The longest time that `ProcedureServer.useStorage` with `close` waits for the calls on the
63
+ * old storage, before it closes the old storage.
64
+ */
65
+ readonly storageGraceMs: number;
57
66
  };
58
67
  }
59
68
 
@@ -77,6 +86,11 @@ export const TIMER_DEFAULTS: TimerDefaults = Object.freeze({
77
86
  // 15 s: with 2 misses, the server finds a dead client after 30 s, not later than a call of the
78
87
  // server to it reaches its request time of 30 s. Before, 30 s gave 60 s (the timer law, R34).
79
88
  webSocketServer: Object.freeze({ heartbeatIntervalMs: 15_000, heartbeatMisses: 2, reverseRequestTimeoutMs: 30_000 }),
89
+ // 15 s: less than the idle time of 60 s that many proxies and load balancers use. A quiet NDJSON
90
+ // stream then stays open (WIRE-PROTOCOL.md, "The keep-alive of an NDJSON stream").
91
+ httpServer: Object.freeze({ keepAliveMs: 15_000 }),
80
92
  idempotency: Object.freeze({ ttlMs: 10 * 60_000 }),
81
- server: Object.freeze({ stopGraceMs: 0 }),
93
+ // 30 s: the request time of the WebSocket client. A call that runs longer timed out at its
94
+ // client already.
95
+ server: Object.freeze({ stopGraceMs: 0, storageGraceMs: 30_000 }),
82
96
  });