@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.
- package/CHANGELOG.md +49 -0
- package/README.md +20 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/transport.d.ts +6 -0
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +10 -1
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts +7 -3
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +9 -3
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts +10 -0
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +22 -2
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/browser.d.ts +4 -4
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +1 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/middleware/retry.d.ts +4 -0
- package/dist/client/middleware/retry.d.ts.map +1 -1
- package/dist/client/middleware/retry.js +86 -78
- package/dist/client/middleware/retry.js.map +1 -1
- package/dist/events/bus.d.ts +32 -14
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +119 -65
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +3 -2
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +20 -6
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/remote.d.ts +80 -18
- package/dist/events/remote.d.ts.map +1 -1
- package/dist/events/remote.js +448 -58
- package/dist/events/remote.js.map +1 -1
- package/dist/events/types.d.ts +13 -2
- package/dist/events/types.d.ts.map +1 -1
- package/dist/procedures/caller.d.ts +33 -0
- package/dist/procedures/caller.d.ts.map +1 -0
- package/dist/procedures/caller.js +27 -0
- package/dist/procedures/caller.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +2 -2
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +1 -1
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +21 -1
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +9 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +28 -0
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +6 -0
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/index.d.ts +3 -1
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +2 -0
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +3 -0
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/types.d.ts +50 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/procedure-server.d.ts +37 -3
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +86 -4
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/timers.d.ts +5 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +3 -1
- package/dist/timers.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/server/transport.ts +11 -1
- package/src/adapters/websocket/client/transport.ts +9 -3
- package/src/adapters/websocket/server/transport.ts +23 -2
- package/src/browser.ts +6 -0
- package/src/client/errors/registry.ts +1 -0
- package/src/client/middleware/retry.ts +89 -78
- package/src/events/bus.ts +141 -73
- package/src/events/index.ts +3 -0
- package/src/events/publisher.ts +23 -6
- package/src/events/remote.ts +575 -73
- package/src/events/types.ts +15 -2
- package/src/procedures/caller.ts +45 -0
- package/src/procedures/collection/index.ts +3 -0
- package/src/procedures/collection/procedures.ts +22 -0
- package/src/procedures/collection/schemas.ts +37 -0
- package/src/procedures/collection/storage.ts +6 -0
- package/src/procedures/index.ts +4 -0
- package/src/procedures/invoke.ts +2 -0
- package/src/procedures/types.ts +53 -0
- package/src/protocol.ts +3 -1
- package/src/server/procedure-server.ts +103 -6
- package/src/timers.ts +8 -1
package/src/events/types.ts
CHANGED
|
@@ -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
|
|
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()`.
|
package/src/procedures/index.ts
CHANGED
|
@@ -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 {
|
package/src/procedures/invoke.ts
CHANGED
|
@@ -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.)
|
package/src/procedures/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|