@procedures/client 0.7.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 (104) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +20 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/server/transport.d.ts +6 -0
  5. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/server/transport.js +10 -1
  7. package/dist/adapters/http/server/transport.js.map +1 -1
  8. package/dist/adapters/websocket/client/transport.d.ts +7 -3
  9. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/client/transport.js +9 -3
  11. package/dist/adapters/websocket/client/transport.js.map +1 -1
  12. package/dist/adapters/websocket/server/transport.d.ts +10 -0
  13. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/websocket/server/transport.js +22 -2
  15. package/dist/adapters/websocket/server/transport.js.map +1 -1
  16. package/dist/browser.d.ts +4 -4
  17. package/dist/browser.d.ts.map +1 -1
  18. package/dist/browser.js +1 -1
  19. package/dist/browser.js.map +1 -1
  20. package/dist/client/errors/registry.d.ts.map +1 -1
  21. package/dist/client/errors/registry.js +1 -0
  22. package/dist/client/errors/registry.js.map +1 -1
  23. package/dist/client/middleware/retry.d.ts +4 -0
  24. package/dist/client/middleware/retry.d.ts.map +1 -1
  25. package/dist/client/middleware/retry.js +86 -78
  26. package/dist/client/middleware/retry.js.map +1 -1
  27. package/dist/events/bus.d.ts +32 -14
  28. package/dist/events/bus.d.ts.map +1 -1
  29. package/dist/events/bus.js +119 -65
  30. package/dist/events/bus.js.map +1 -1
  31. package/dist/events/index.d.ts +3 -2
  32. package/dist/events/index.d.ts.map +1 -1
  33. package/dist/events/index.js.map +1 -1
  34. package/dist/events/publisher.d.ts.map +1 -1
  35. package/dist/events/publisher.js +20 -6
  36. package/dist/events/publisher.js.map +1 -1
  37. package/dist/events/remote.d.ts +80 -18
  38. package/dist/events/remote.d.ts.map +1 -1
  39. package/dist/events/remote.js +448 -58
  40. package/dist/events/remote.js.map +1 -1
  41. package/dist/events/types.d.ts +13 -2
  42. package/dist/events/types.d.ts.map +1 -1
  43. package/dist/procedures/caller.d.ts +33 -0
  44. package/dist/procedures/caller.d.ts.map +1 -0
  45. package/dist/procedures/caller.js +27 -0
  46. package/dist/procedures/caller.js.map +1 -0
  47. package/dist/procedures/collection/index.d.ts +2 -2
  48. package/dist/procedures/collection/index.d.ts.map +1 -1
  49. package/dist/procedures/collection/index.js +1 -1
  50. package/dist/procedures/collection/index.js.map +1 -1
  51. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  52. package/dist/procedures/collection/procedures.js +21 -1
  53. package/dist/procedures/collection/procedures.js.map +1 -1
  54. package/dist/procedures/collection/schemas.d.ts +9 -0
  55. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  56. package/dist/procedures/collection/schemas.js +28 -0
  57. package/dist/procedures/collection/schemas.js.map +1 -1
  58. package/dist/procedures/collection/storage.d.ts +6 -0
  59. package/dist/procedures/collection/storage.d.ts.map +1 -1
  60. package/dist/procedures/index.d.ts +3 -1
  61. package/dist/procedures/index.d.ts.map +1 -1
  62. package/dist/procedures/index.js +2 -0
  63. package/dist/procedures/index.js.map +1 -1
  64. package/dist/procedures/invoke.d.ts.map +1 -1
  65. package/dist/procedures/invoke.js +3 -0
  66. package/dist/procedures/invoke.js.map +1 -1
  67. package/dist/procedures/types.d.ts +50 -0
  68. package/dist/procedures/types.d.ts.map +1 -1
  69. package/dist/procedures/types.js.map +1 -1
  70. package/dist/protocol.d.ts +1 -1
  71. package/dist/protocol.d.ts.map +1 -1
  72. package/dist/protocol.js +1 -0
  73. package/dist/protocol.js.map +1 -1
  74. package/dist/server/procedure-server.d.ts +37 -3
  75. package/dist/server/procedure-server.d.ts.map +1 -1
  76. package/dist/server/procedure-server.js +86 -4
  77. package/dist/server/procedure-server.js.map +1 -1
  78. package/dist/timers.d.ts +5 -0
  79. package/dist/timers.d.ts.map +1 -1
  80. package/dist/timers.js +3 -1
  81. package/dist/timers.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/adapters/http/server/transport.ts +11 -1
  84. package/src/adapters/websocket/client/transport.ts +9 -3
  85. package/src/adapters/websocket/server/transport.ts +23 -2
  86. package/src/browser.ts +6 -0
  87. package/src/client/errors/registry.ts +1 -0
  88. package/src/client/middleware/retry.ts +89 -78
  89. package/src/events/bus.ts +141 -73
  90. package/src/events/index.ts +3 -0
  91. package/src/events/publisher.ts +23 -6
  92. package/src/events/remote.ts +575 -73
  93. package/src/events/types.ts +15 -2
  94. package/src/procedures/caller.ts +45 -0
  95. package/src/procedures/collection/index.ts +3 -0
  96. package/src/procedures/collection/procedures.ts +22 -0
  97. package/src/procedures/collection/schemas.ts +37 -0
  98. package/src/procedures/collection/storage.ts +6 -0
  99. package/src/procedures/index.ts +4 -0
  100. package/src/procedures/invoke.ts +2 -0
  101. package/src/procedures/types.ts +53 -0
  102. package/src/protocol.ts +3 -1
  103. package/src/server/procedure-server.ts +103 -6
  104. package/src/timers.ts +8 -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
  */
@@ -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
@@ -166,6 +183,15 @@ export interface StopOptions {
166
183
  grace?: Duration | undefined;
167
184
  }
168
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
+
169
195
  /** A signal that aborts when one of the signals aborts. */
170
196
  function joinSignals(first: AbortSignal | undefined, second: AbortSignal): AbortSignal {
171
197
  if (!first) return second;
@@ -271,6 +297,10 @@ export function errorResponse(id: string, error: unknown): ServerResponse<unknow
271
297
  export class ProcedureServer extends Server implements RepositoryProvider {
272
298
  private readonly procedureRegistry: ProcedureRegistry;
273
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;
274
304
  /** The procedures that `registerProcedure` pinned, by key */
275
305
  private readonly pinnedProcedures = new Map<string, AnyProcedure>();
276
306
  /**
@@ -308,6 +338,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
308
338
  super(options);
309
339
 
310
340
  this.procedureRegistry = options.registry ?? PROCEDURE_REGISTRY;
341
+ this.clock = options.clock ?? realClock;
311
342
  this.expose = options.expose;
312
343
  this.strictCalls = options.strictCalls ?? false;
313
344
  this.interceptors = options.interceptors ?? [];
@@ -379,7 +410,11 @@ export class ProcedureServer extends Server implements RepositoryProvider {
379
410
  * failed flush keeps the old storage and throws. A call that arrives during the flush goes
380
411
  * to the old storage.
381
412
  * - `close`: the server closes the old storage after the change. The close of a
382
- * `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.
383
418
  *
384
419
  * @param name - Collection name
385
420
  * @param storage - Storage backend
@@ -396,20 +431,76 @@ export class ProcedureServer extends Server implements RepositoryProvider {
396
431
  return this.replaceStorage(name, storage as CollectionStorage<unknown>, options);
397
432
  }
398
433
 
399
- /** 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
+ */
400
438
  private async replaceStorage(name: string, storage: CollectionStorage<unknown>, options: UseStorageOptions): Promise<this> {
439
+ const graceMs = optionalMs(options.grace, "grace") ?? TIMER_DEFAULTS.server.storageGraceMs;
401
440
  const old = this.storages.get(name);
402
441
  const other = old !== undefined && old !== storage ? old : undefined;
403
442
  if (other && options.drain) {
404
443
  await other.flush?.();
405
444
  }
406
445
  this.storages.set(name, storage);
407
- 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);
408
450
  await other.close();
409
451
  }
410
452
  return this;
411
453
  }
412
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
+
413
504
  /**
414
505
  * Configure multiple storages at once.
415
506
  *
@@ -645,6 +736,9 @@ export class ProcedureServer extends Server implements RepositoryProvider {
645
736
 
646
737
  /** Run a procedure for a request with a signal. */
647
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;
648
742
  try {
649
743
  // One invocation path for every host (ARCHITECTURE-PROPOSALS P1): input validation, the
650
744
  // context, the expose rule for nested calls (BUGS-2026-07 H18) and output validation
@@ -653,7 +747,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
653
747
  registry: this.procedureRegistry,
654
748
  metadata: request.metadata,
655
749
  signal,
656
- repository: this,
750
+ repository,
657
751
  expose: this.expose,
658
752
  strictCalls: this.strictCalls,
659
753
  interceptors: this.interceptors,
@@ -669,16 +763,19 @@ export class ProcedureServer extends Server implements RepositoryProvider {
669
763
  if (output.kind === "stream") {
670
764
  // The faces that collect a stream read this mark (roadmap R15)
671
765
  const unbounded = (procedure.metadata as { stream?: { unbounded?: boolean } } | undefined)?.stream?.unbounded === true;
766
+ streams = true;
672
767
  return {
673
768
  id: request.id,
674
769
  status: { type: "success", code: 200 },
675
- stream: output.items,
770
+ stream: releasedAtEnd(output.items, release),
676
771
  metadata: unbounded ? { [STREAM_METADATA_KEY]: "unbounded" } : {},
677
772
  };
678
773
  }
679
774
  return { id: request.id, status: { type: "success", code: 200 }, payload: output.value, metadata: {} };
680
775
  } catch (error) {
681
776
  return errorResponse(request.id, error);
777
+ } finally {
778
+ if (!streams) release();
682
779
  }
683
780
  }
684
781
 
package/src/timers.ts CHANGED
@@ -58,6 +58,11 @@ export interface TimerDefaults {
58
58
  readonly server: {
59
59
  /** The time that `ProcedureServer.stop()` waits for the calls that run. */
60
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;
61
66
  };
62
67
  }
63
68
 
@@ -85,5 +90,7 @@ export const TIMER_DEFAULTS: TimerDefaults = Object.freeze({
85
90
  // stream then stays open (WIRE-PROTOCOL.md, "The keep-alive of an NDJSON stream").
86
91
  httpServer: Object.freeze({ keepAliveMs: 15_000 }),
87
92
  idempotency: Object.freeze({ ttlMs: 10 * 60_000 }),
88
- 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 }),
89
96
  });