@procedures/client 0.33.0 → 0.35.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 (90) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/addressing.d.ts.map +1 -1
  4. package/dist/adapters/http/server/addressing.js +6 -2
  5. package/dist/adapters/http/server/addressing.js.map +1 -1
  6. package/dist/adapters/local/client/transport.js +2 -1
  7. package/dist/adapters/local/client/transport.js.map +1 -1
  8. package/dist/browser.d.ts +3 -2
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +5 -3
  11. package/dist/browser.js.map +1 -1
  12. package/dist/events/publisher.d.ts +6 -8
  13. package/dist/events/publisher.d.ts.map +1 -1
  14. package/dist/events/publisher.js +7 -31
  15. package/dist/events/publisher.js.map +1 -1
  16. package/dist/procedures/collection/changes.d.ts +2 -2
  17. package/dist/procedures/collection/changes.js +2 -2
  18. package/dist/procedures/collection/index.js +1 -1
  19. package/dist/procedures/collection/index.js.map +1 -1
  20. package/dist/procedures/collection/partition.d.ts +8 -56
  21. package/dist/procedures/collection/partition.d.ts.map +1 -1
  22. package/dist/procedures/collection/partition.js +7 -73
  23. package/dist/procedures/collection/partition.js.map +1 -1
  24. package/dist/procedures/collection/topic.d.ts +27 -39
  25. package/dist/procedures/collection/topic.d.ts.map +1 -1
  26. package/dist/procedures/collection/topic.js +55 -38
  27. package/dist/procedures/collection/topic.js.map +1 -1
  28. package/dist/procedures/core/schemas.d.ts +26 -10
  29. package/dist/procedures/core/schemas.d.ts.map +1 -1
  30. package/dist/procedures/core/schemas.js +34 -16
  31. package/dist/procedures/core/schemas.js.map +1 -1
  32. package/dist/procedures/define-procedure.d.ts.map +1 -1
  33. package/dist/procedures/define-procedure.js +5 -5
  34. package/dist/procedures/define-procedure.js.map +1 -1
  35. package/dist/procedures/index.d.ts +4 -3
  36. package/dist/procedures/index.d.ts.map +1 -1
  37. package/dist/procedures/index.js +5 -3
  38. package/dist/procedures/index.js.map +1 -1
  39. package/dist/procedures/module.d.ts +22 -0
  40. package/dist/procedures/module.d.ts.map +1 -1
  41. package/dist/procedures/module.js +18 -0
  42. package/dist/procedures/module.js.map +1 -1
  43. package/dist/procedures/registry.d.ts +2 -1
  44. package/dist/procedures/registry.d.ts.map +1 -1
  45. package/dist/procedures/registry.js +4 -2
  46. package/dist/procedures/registry.js.map +1 -1
  47. package/dist/procedures/segments.d.ts +61 -0
  48. package/dist/procedures/segments.d.ts.map +1 -0
  49. package/dist/procedures/segments.js +71 -0
  50. package/dist/procedures/segments.js.map +1 -0
  51. package/dist/procedures/types.d.ts +5 -2
  52. package/dist/procedures/types.d.ts.map +1 -1
  53. package/dist/procedures/types.js +8 -4
  54. package/dist/procedures/types.js.map +1 -1
  55. package/dist/server/host-policy.d.ts +1 -1
  56. package/dist/server/host-policy.d.ts.map +1 -1
  57. package/dist/server/host-policy.js +5 -3
  58. package/dist/server/host-policy.js.map +1 -1
  59. package/dist/server/index.d.ts +1 -0
  60. package/dist/server/index.d.ts.map +1 -1
  61. package/dist/server/index.js +2 -0
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/method.d.ts +18 -3
  64. package/dist/server/method.d.ts.map +1 -1
  65. package/dist/server/method.js +23 -6
  66. package/dist/server/method.js.map +1 -1
  67. package/dist/server/procedure-server.d.ts +5 -23
  68. package/dist/server/procedure-server.d.ts.map +1 -1
  69. package/dist/server/procedure-server.js +4 -29
  70. package/dist/server/procedure-server.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/adapters/http/server/addressing.ts +6 -2
  73. package/src/adapters/local/client/transport.ts +2 -2
  74. package/src/browser.ts +6 -1
  75. package/src/events/publisher.ts +14 -40
  76. package/src/procedures/collection/changes.ts +2 -2
  77. package/src/procedures/collection/index.ts +1 -1
  78. package/src/procedures/collection/partition.ts +10 -87
  79. package/src/procedures/collection/topic.ts +48 -51
  80. package/src/procedures/core/schemas.ts +35 -16
  81. package/src/procedures/define-procedure.ts +5 -7
  82. package/src/procedures/index.ts +6 -2
  83. package/src/procedures/module.ts +34 -0
  84. package/src/procedures/registry.ts +4 -2
  85. package/src/procedures/segments.ts +74 -0
  86. package/src/procedures/types.ts +8 -4
  87. package/src/server/host-policy.ts +5 -4
  88. package/src/server/index.ts +2 -0
  89. package/src/server/method.ts +23 -6
  90. package/src/server/procedure-server.ts +6 -34
@@ -1,95 +1,91 @@
1
1
  /**
2
- * The topics of the change events of a collection (roadmap CLI-15, CLI-17 and CLI-42). This module
3
- * has the one function that makes a topic, the functions that read it, and the topic rule of a
4
- * collection. Each part of the client that makes or reads the topic of a collection or of a
2
+ * The topics of the change events of a collection (roadmap CLI-15, CLI-17, CLI-42 and CLI-75). This
3
+ * module has the one function that makes a topic, the functions that read it, and the topic rule
4
+ * of a collection. Each part of the client that makes or reads the topic of a collection or of a
5
5
  * partition uses them.
6
6
  *
7
- * - `collections.<name>` is the topic of a collection. The name stays as it is, also with a dot.
8
- * - `collections.<name>.<partition>` is the topic of a partition. In this form, the name encodes
9
- * `%` as `%25` and `.` as `%2E`. Thus the first dot after `collections.` ends the name, and the
10
- * partition after that dot can have each character, also `.`, `/` and `%`.
11
- * - The partition stays as it is, and so does a name without `.` and `%`. Thus each topic of a
12
- * partition of a name without `.` and `%` stays the same as before CLI-42.
7
+ * - `collections.<name>` is the topic of a collection, and `collections.<name>.<partition>` is the
8
+ * topic of a partition.
9
+ * - In each topic, the name encodes `%` as `%25` and `.` as `%2E` (`encodeSegment()`). Thus the
10
+ * first dot after `collections.` ends the name, and the partition after that dot can have each
11
+ * character, also `.`, `/` and `%`. The partition stays as it is.
12
+ * - A name without `.` and `%` stays as it is. Thus each topic of such a name stays the same.
13
+ * - Before CLI-75, the plain topic kept a dotted name as it is: `collections.a.b` was the topic of
14
+ * the collection `a.b` and also of the partition `b` of `a`. Now the topic of `a.b` is
15
+ * `collections.a%2Eb`, so the two topics are not the same.
13
16
  * - Only the canonical form of a topic counts: a parse gives a collection and a partition only when
14
17
  * `collectionChangeTopic(collection, partition)` gives the same topic. Thus `%2e` is no alias of
15
18
  * `%2E`, and a rule or an `allow` function that compares the strings sees each topic one way.
16
- * - The topic of a collection whose name has a dot, `collections.a.b`, has the same form as the
17
- * topic of the partition `b` of the collection `a`. Only the repository knows the collection, so
18
- * `collectionTopicTarget()` decides with it.
19
- * - `ProcedureServer` refuses a storage of `a.b` while `a` has partitions, in each order (roadmap
20
- * CLI-57, decision D23). Thus the readers of `a.b` do not get the events of a partition of `a`.
21
19
  */
20
+ import { encodeSegment, decodeSegment } from "../segments.js";
22
21
  import type { RepositoryProvider } from "../types.js";
23
22
 
24
23
  /** The prefix of the topic of each collection. */
25
24
  const PREFIX = "collections.";
26
25
 
27
- /** This function encodes the name of a collection for a topic with a partition: `%` and `.` become `%25` and `%2E`. */
28
- function encodeTopicName(name: string): string {
29
- return name.replace(/[%.]/g, (char) => (char === "%" ? "%25" : "%2E"));
30
- }
31
-
32
- /** This function decodes the name of a topic with a partition. The parse then checks the canonical form. */
33
- function decodeTopicName(segment: string): string {
34
- return segment.replace(/%(25|2E)/g, (_sequence, code: string) => (code === "25" ? "%" : "."));
35
- }
36
-
37
26
  /**
38
27
  * This function gives the topic of the change events of a collection: `collections.<name>`, or
39
- * `collections.<name>.<partition>` for a partition (roadmap CLI-17). In the topic of a partition,
40
- * the name encodes `%` and `.` (roadmap CLI-42), so `collectionTopicPartition()` gives back the
41
- * same collection and the same partition.
28
+ * `collections.<name>.<partition>` for a partition (roadmap CLI-17). The name encodes `%` and `.`
29
+ * (roadmap CLI-42 and CLI-75), so `collectionTopicPartition()` gives back the same collection and
30
+ * the same partition.
42
31
  *
43
32
  * @example
44
33
  * ```typescript
45
34
  * collectionChangeTopic("notes", "t1/board:b1"); // "collections.notes.t1/board:b1"
46
35
  * collectionChangeTopic("graph.nodes", "acme/space"); // "collections.graph%2Enodes.acme/space"
47
- * collectionChangeTopic("graph.nodes"); // "collections.graph.nodes"
36
+ * collectionChangeTopic("graph.nodes"); // "collections.graph%2Enodes"
48
37
  * ```
49
38
  */
50
39
  export function collectionChangeTopic(collection: string, partition?: string): string {
51
- return partition === undefined ? `${PREFIX}${collection}` : `${PREFIX}${encodeTopicName(collection)}.${partition}`;
40
+ const name = `${PREFIX}${encodeSegment(collection)}`;
41
+ return partition === undefined ? name : `${name}.${partition}`;
52
42
  }
53
43
 
54
44
  /**
55
45
  * This function gives the collection and the partition of a topic `collections.<name>` or
56
- * `collections.<name>.<partition>`, or undefined for another topic. It reads the topic alone:
57
- * `collectionTopicTarget()` also knows the collections of a repository.
46
+ * `collections.<name>.<partition>`, or undefined for another topic. A host reads the collection
47
+ * of a topic with it, and not with its own pattern.
58
48
  *
59
- * - The first dot after the name ends the name. In a topic with a partition, the name is decoded:
60
- * `%2E` is a dot and `%25` is a `%` (roadmap CLI-42).
49
+ * - The first dot after the name ends the name. The name is decoded: `%2E` is a dot and `%25` is a
50
+ * `%` (roadmap CLI-42 and CLI-75).
61
51
  * - Each character after that dot is the partition. A partition has one character at least.
62
52
  * - A topic that is not in the canonical form gives undefined: `collectionChangeTopic()` of the
63
53
  * result always gives the topic again. Thus `collections.a%2eb.t1` names no collection.
64
54
  * - For each name and each partition, `collectionTopicPartition(collectionChangeTopic(name,
65
55
  * partition))` gives the same name and the same partition.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * collectionTopicPartition("collections.e2e%2Etabs"); // { collection: "e2e.tabs", partition: undefined }
60
+ * collectionTopicPartition("collections.e2e%2Etabs.t1"); // { collection: "e2e.tabs", partition: "t1" }
61
+ * ```
66
62
  */
67
63
  export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
68
64
  if (!topic.startsWith(PREFIX)) return undefined;
69
65
  const rest = topic.slice(PREFIX.length);
70
66
  const dot = rest.indexOf(".");
71
- if (dot === -1) return rest === "" ? undefined : { collection: rest, partition: undefined };
72
- if (dot === 0 || dot === rest.length - 1) return undefined;
73
- const collection = decodeTopicName(rest.slice(0, dot));
74
- const partition = rest.slice(dot + 1);
67
+ if (dot === 0 || dot === rest.length - 1 || rest === "") return undefined;
68
+ const collection = decodeSegment(dot === -1 ? rest : rest.slice(0, dot));
69
+ const partition = dot === -1 ? undefined : rest.slice(dot + 1);
75
70
  // Only the canonical form counts: an alias of an escape names no collection
76
71
  return collectionChangeTopic(collection, partition) === topic ? { collection, partition } : undefined;
77
72
  }
78
73
 
79
74
  /**
80
- * This function gives the collection and the partition part of a topic of a call, with the
81
- * repository of the call: one rule for the read check and for the listener of the publisher.
75
+ * This function gives the collection and the partition part of a topic of a call: one rule for
76
+ * the read check and for the listener of the publisher. It gives the result of
77
+ * `collectionTopicPartition()`.
82
78
  *
83
- * - A collection of the repository whose name is the whole rest of the topic wins, with or without
84
- * partitions: its name can have a dot. The call then asks for no partition.
85
- * - Else `collectionTopicPartition()` reads the topic: the first dot after the name ends the name,
86
- * and the rest is the partition that the call asks for. In this form, the name encodes `%` and
87
- * `.`, so the name of a collection with partitions can have a dot (roadmap CLI-42).
79
+ * - Before CLI-75, a collection of the repository whose name was the whole rest of the topic won,
80
+ * because the plain topic of a dotted name did not encode the name. Now each topic encodes the
81
+ * name, so the function does not read the repository.
82
+ *
83
+ * @param repository - Deprecated: not used since roadmap CLI-75. The parameter stays for the
84
+ * callers, and a later MINOR removes it. (TSDoc has no `@deprecated` for one parameter, and the
85
+ * block tag would deprecate the whole function, which stays.)
88
86
  */
89
87
  export function collectionTopicTarget(topic: string, repository: RepositoryProvider | undefined): { collection: string; partition: string | undefined } | undefined {
90
- if (!topic.startsWith(PREFIX)) return undefined;
91
- const whole = topic.slice(PREFIX.length);
92
- if (whole !== "" && repository?.hasCollection(whole)) return { collection: whole, partition: undefined };
88
+ void repository;
93
89
  return collectionTopicPartition(topic);
94
90
  }
95
91
 
@@ -97,21 +93,22 @@ export function collectionTopicTarget(topic: string, repository: RepositoryProvi
97
93
  * This function gives the topic rule of a collection for `EventPublisher` (the option `topics`):
98
94
  * the topic of the collection, and the pattern of the topics of its partitions (roadmap CLI-42).
99
95
  * Build the rule of a collection with it. Do not join the parts yourself: a dotted name needs its
100
- * encoded form in the pattern.
96
+ * encoded form.
101
97
  *
102
98
  * - The rule always has the two forms. For a collection with partitions, the plain topic asks for
103
99
  * no partition, and the resolver of the partition decides.
104
100
  * - For a collection without partitions, a topic of a partition passes the rule. The publisher then
105
101
  * refuses it with `FORBIDDEN` (reason `partition-not-allowed`).
106
- * - The pattern matches only the canonical form of a topic, so an alias of an escape gets
102
+ * - The rule matches only the canonical form of a topic, so an alias of an escape gets
107
103
  * `TOPIC_NOT_FOUND`.
108
104
  *
109
105
  * @example
110
106
  * ```typescript
111
- * collectionTopicRule("graph.nodes"); // ["collections.graph.nodes", "collections.graph%2Enodes.*"]
107
+ * collectionTopicRule("graph.nodes"); // ["collections.graph%2Enodes", "collections.graph%2Enodes.*"]
112
108
  * new EventPublisher({ bus, topics: ["orders.*", ...collectionTopicRule("graph.nodes")] });
113
109
  * ```
114
110
  */
115
111
  export function collectionTopicRule(collection: string): string[] {
116
- return [collectionChangeTopic(collection), `${PREFIX}${encodeTopicName(collection)}.*`];
112
+ const topic = collectionChangeTopic(collection);
113
+ return [topic, `${topic}.*`];
117
114
  }
@@ -47,8 +47,30 @@ export const anySchema: ZodLike<any> & StandardSchemaV1<unknown, any> & { readon
47
47
  };
48
48
 
49
49
  /**
50
- * Typed version of anySchema for specific type annotations.
51
- * Useful when you want to assert a specific type at compile time.
50
+ * This function gives a pass-through schema for the type `T`: it validates nothing and has no JSON
51
+ * Schema. The type parameter only gives the TypeScript type.
52
+ *
53
+ * Use it for internal procedures where a real schema adds nothing. Give a real schema (zod) to a
54
+ * procedure that an MCP server or the CLI exposes. Then the host validates its input, and its
55
+ * tools and help describe the fields (roadmap 2.1).
56
+ *
57
+ * ```ts
58
+ * createProcedure().path(["a", "b"]).input(typed<{ id: string }>()).output(typed<number>())
59
+ * ```
60
+ */
61
+ export function typed<T>(): ZodLikeSchema<T> & StandardSchemaV1<unknown, T> {
62
+ return {
63
+ parse: (data: unknown) => data as T,
64
+ safeParse: (data: unknown) => ({ success: true as const, data: data as T }),
65
+ _output: undefined as unknown as T,
66
+ "~standard": passThrough<T>(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * This function gives a pass-through schema for the type `T`.
72
+ *
73
+ * @deprecated Use `typed<T>()`. A later MINOR removes `typedAnySchema` (tracker item O58, decision D13).
52
74
  */
53
75
  export function typedAnySchema<T>(): ZodLike<T> & StandardSchemaV1<unknown, T> {
54
76
  return {
@@ -63,11 +85,16 @@ export function typedAnySchema<T>(): ZodLike<T> & StandardSchemaV1<unknown, T> {
63
85
  // =============================================================================
64
86
 
65
87
  /**
66
- * Gives a zod schema the type of a procedure schema (`ZodLikeSchema<T>`). A zod 4 schema is a
67
- * Standard Schema already, so the function returns it unchanged: the validation and the JSON
68
- * Schema come from zod itself.
88
+ * This function gives a zod schema the type of a procedure schema (`ZodLikeSchema<T>`). A zod 4
89
+ * schema is a Standard Schema already, so the function returns it unchanged: the validation and
90
+ * the JSON Schema come from zod itself.
69
91
  *
70
92
  * An object with only a `parse` method gets `safeParse` and `~standard` around that method.
93
+ *
94
+ * @deprecated Give the zod schema to `input()` and `output()` directly: they take each Standard
95
+ * Schema and infer its type. To check a schema against a type, write `const s: z.ZodType<T> = ...`.
96
+ * For a parse-only object, write a Standard Schema (`~standard`). A later MINOR removes
97
+ * `zodAdapter` (tracker item O58, decision D13).
71
98
  */
72
99
  export function zodAdapter<T>(schema: { parse: (data: unknown) => T }): ZodLikeSchema<T> {
73
100
  if (isStandardSchema(schema) && typeof (schema as { safeParse?: unknown }).safeParse === "function") {
@@ -106,18 +133,10 @@ export function zodAdapter<T>(schema: { parse: (data: unknown) => T }): ZodLikeS
106
133
  // =============================================================================
107
134
 
108
135
  /**
109
- * A pass-through schema: it validates nothing and has no JSON Schema. The type parameter only
110
- * gives the TypeScript type.
136
+ * This function gives a pass-through schema for the type `T`.
111
137
  *
112
- * Use it for internal procedures where a real schema adds nothing. A procedure that an MCP
113
- * server exposes, or that a user calls from the CLI, takes a real schema (zod), so its input
114
- * is validated and its tools and help describe the fields (roadmap 2.1).
138
+ * @deprecated Use `typed<T>()`. A later MINOR removes `outputSchema` (tracker item O58, decision D13).
115
139
  */
116
140
  export function outputSchema<T>(): ZodLikeSchema<T> & StandardSchemaV1<unknown, T> {
117
- return {
118
- parse: (data: unknown) => data as T,
119
- safeParse: (data: unknown) => ({ success: true as const, data: data as T }),
120
- _output: undefined as unknown as T,
121
- "~standard": passThrough<T>(),
122
- };
141
+ return typed<T>();
123
142
  }
@@ -37,7 +37,7 @@ import type {
37
37
  Effects,
38
38
  } from "./types.js";
39
39
  import { z } from "zod";
40
- import { anySchema, zodAdapter } from "./core/schemas.js";
40
+ import { anySchema } from "./core/schemas.js";
41
41
  import { keyToPath, pathToKey } from "./types.js";
42
42
  import {
43
43
  createRefScope,
@@ -326,7 +326,7 @@ export const defineProcedureProcedure: Procedure<
326
326
  throw new Error('procedure.define: the aggregation must be a procedure ref: { "$proc": [...], "input": {...} }');
327
327
  }
328
328
 
329
- const pathKey = path.join(".");
329
+ const pathKey = pathToKey(path);
330
330
  const existing = registry.get(path);
331
331
  // A procedure outside the expose rule of the host is not the caller's to replace (R17)
332
332
  if (existing && ctx?.expose && !ctx.expose(path)) {
@@ -463,7 +463,7 @@ export const getProcedureProcedure: Procedure<
463
463
  ProcedureMetadata
464
464
  > = defineProcedure({
465
465
  path: ["procedure", "get"],
466
- input: zodAdapter(getInputSchema),
466
+ input: getInputSchema,
467
467
  output: GetOutputSchema,
468
468
  metadata: {
469
469
  description: "Describe a procedure by path or key",
@@ -503,7 +503,7 @@ export const listProceduresProcedure: Procedure<
503
503
  ProcedureMetadata
504
504
  > = defineProcedure({
505
505
  path: ["procedure", "list"],
506
- input: zodAdapter(listInputSchema),
506
+ input: listInputSchema,
507
507
  output: ListOutputSchema,
508
508
  metadata: {
509
509
  description: "List the registered procedures",
@@ -530,9 +530,7 @@ export const listProceduresProcedure: Procedure<
530
530
  */
531
531
  export const manifestProcedure: Procedure<{ namespace?: string | undefined }, ProcedureManifest, ProcedureMetadata> = defineProcedure({
532
532
  path: ["procedure", "manifest"],
533
- input: zodAdapter(
534
- z.object({ namespace: z.string().optional().describe("Only the procedures under this first path segment") }) as z.ZodType<{ namespace?: string | undefined }>,
535
- ),
533
+ input: z.object({ namespace: z.string().optional().describe("Only the procedures under this first path segment") }) as z.ZodType<{ namespace?: string | undefined }>,
536
534
  output: ManifestOutputSchema,
537
535
  metadata: {
538
536
  description: "The manifest of the registry: each exposed procedure with its schemas and effects",
@@ -38,6 +38,8 @@ export type {
38
38
 
39
39
  // Type utilities
40
40
  export { pathToKey, keyToPath } from "./types.js";
41
+ // The escape of a segment with a dot, for a method name, a key and a topic (roadmap CLI-75)
42
+ export { encodeSegment, decodeSegment, segmentsToTopic, topicToSegments } from "./segments.js";
41
43
 
42
44
  // Capabilities (CAPABILITIES-2026-10)
43
45
  export {
@@ -134,7 +136,7 @@ export {
134
136
  collectionTopicPartition,
135
137
  collectionTopicTarget,
136
138
  collectionTopicRule,
137
- // The refusal of a storage whose topic is the topic of a partition (roadmap CLI-57)
139
+ // The refusal of a storage whose topic is the topic of a partition (roadmap CLI-57). Deprecated since CLI-75
138
140
  CollectionTopicCollisionError,
139
141
  // The rules of the rows of a collection (roadmap CLI-41)
140
142
  admittedStorage,
@@ -212,12 +214,13 @@ export {
212
214
  export {
213
215
  clientModule,
214
216
  defineModule,
217
+ definePackage,
215
218
  moduleClosure,
216
219
  registerModules,
217
220
  createRegistry,
218
221
  registerClientProcedures,
219
222
  } from "./module.js";
220
- export type { ModuleInput } from "./module.js";
223
+ export type { ModuleInput, PackageDefinition } from "./module.js";
221
224
  // One call for a registry of some modules, `invokePath` and `outputValue`
222
225
  export { callerOf } from "./caller.js";
223
226
  export type { Caller, CallerOptions } from "./caller.js";
@@ -345,6 +348,7 @@ export {
345
348
  tryCatchProcedure,
346
349
  // Schemas and result types
347
350
  anySchema,
351
+ typed,
348
352
  typedAnySchema,
349
353
  zodAdapter,
350
354
  outputSchema,
@@ -42,6 +42,40 @@ export function defineModule(
42
42
  return { name, procedures: [...procedures], requires };
43
43
  }
44
44
 
45
+ /** The module of a package and the function that registers it. `definePackage` gives one. */
46
+ export interface PackageDefinition {
47
+ /** The module: the procedures as data. The `./module` entry of the package exports it. */
48
+ readonly module: ProcedureModule;
49
+ /**
50
+ * This function registers the procedures of the module, and of the modules that it uses, in a
51
+ * registry. The default is the global registry. The `./register` entry of the package starts it.
52
+ */
53
+ readonly register: (registry?: ProcedureRegistry) => void;
54
+ }
55
+
56
+ /**
57
+ * This function gives the module of a package and its `register` function. The call registers
58
+ * nothing, so the `./module` entry of a package can use it.
59
+ *
60
+ * ```ts
61
+ * // src/module.ts
62
+ * const vitest: PackageDefinition = definePackage("@procedures/vitest", [vitestRunProcedure]);
63
+ * export const vitestModule: ProcedureModule = vitest.module;
64
+ * export const registerVitestProcedures: PackageDefinition["register"] = vitest.register;
65
+ * ```
66
+ */
67
+ export function definePackage(
68
+ name: string,
69
+ procedures: readonly AnyProcedure[],
70
+ requires: readonly ProcedureModule[] = []
71
+ ): PackageDefinition {
72
+ const module = defineModule(name, procedures, requires);
73
+ const register = (registry: ProcedureRegistry = PROCEDURE_REGISTRY): void => {
74
+ registerModules(registry, [module]);
75
+ };
76
+ return { module, register };
77
+ }
78
+
45
79
  /**
46
80
  * The modules and their required modules, each one time, with each required module before the
47
81
  * module that requires it.
@@ -53,7 +53,8 @@ const RESERVED_SEGMENTS: ReadonlySet<string> = new Set(["__proto__", "constructo
53
53
  * This function checks a procedure path: at least one segment, each a non-empty string, and
54
54
  * none of `__proto__`, `constructor` or `prototype`. A reserved segment reaches
55
55
  * `Object.prototype` in a tree walk (deep dive CORE-6). A segment can contain a dot
56
- * (`["fs", "read.json"]`), but the registry rejects two different paths with the same key.
56
+ * (`["collections", "e2e.tabs", "find"]`). The key encodes the dot (roadmap CLI-75), so two
57
+ * different paths do not have the same key.
57
58
  *
58
59
  * @throws RegistryError when the path is not valid
59
60
  */
@@ -141,7 +142,8 @@ export class ProcedureRegistry {
141
142
  if (hintRefusal) throw new RegistryError(`${hintRefusal} (${key})`, path);
142
143
 
143
144
  const replaced = this.procedures.get(key);
144
- // The key joins the segments with dots: ["a.b", "c"] and ["a", "b.c"] have one key
145
+ // The key encodes each segment (roadmap CLI-75), so ["a.b", "c"] and ["a", "b.c"] have different
146
+ // keys. The check stays as a guard: two different paths must never share one key
145
147
  if (replaced && !samePath(replaced.path, path)) {
146
148
  throw new RegistryError(
147
149
  `Procedure path ${JSON.stringify(path)} has the same key as ${JSON.stringify(replaced.path)}: ${key}`,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The one escape of a segment of a procedure path or of a topic (roadmap CLI-75, decision D36).
3
+ *
4
+ * A method name, a registry key and a topic join their segments with ".". Before, a segment with a
5
+ * dot did not come back: `["collections", "e2e.tabs", "find"]` went over the wire as
6
+ * `collections.e2e.tabs.find` and came back as four segments. Now each segment encodes `%` as
7
+ * `%25` and then `.` as `%2E` before the join.
8
+ *
9
+ * - A segment without `.` and `%` encodes to itself. Thus the wire text of each such name stays the same.
10
+ * - The decode changes `%2E` and `%2e` to `.`, and `%25` to `%`. It keeps each other `%` sequence,
11
+ * so the raw `%` of an old client stays a `%`.
12
+ * - For each segment, `decodeSegment(encodeSegment(segment))` gives the same segment.
13
+ */
14
+
15
+ /**
16
+ * This function encodes one segment for a text with dots between the segments. It encodes `%` as
17
+ * `%25` and then `.` as `%2E`. A segment without `.` and `%` stays the same.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * encodeSegment("notes"); // "notes"
22
+ * encodeSegment("e2e.tabs"); // "e2e%2Etabs"
23
+ * encodeSegment("50%"); // "50%25"
24
+ * ```
25
+ */
26
+ export function encodeSegment(segment: string): string {
27
+ return segment.replace(/[%.]/g, (char) => (char === "%" ? "%25" : "%2E"));
28
+ }
29
+
30
+ /**
31
+ * This function decodes one segment of a text with dots between the segments. `%2E` and `%2e`
32
+ * become `.`, and `%25` becomes `%`. Each other `%` sequence stays the same.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * decodeSegment("e2e%2Etabs"); // "e2e.tabs"
37
+ * decodeSegment("50%25"); // "50%"
38
+ * decodeSegment("50%"); // "50%"
39
+ * ```
40
+ */
41
+ export function decodeSegment(segment: string): string {
42
+ return segment.replace(/%(25|2[Ee])/g, (_sequence, code: string) => (code === "25" ? "%" : "."));
43
+ }
44
+
45
+ /**
46
+ * This function gives the text of a list of raw segments: it encodes each segment and joins them
47
+ * with dots. Use it for a topic, so that a segment with a dot stays one segment.
48
+ * `topicToSegments()` gives the segments again.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * segmentsToTopic(["orders", "created"]); // "orders.created"
53
+ * segmentsToTopic(["collections", "e2e.tabs"]); // "collections.e2e%2Etabs"
54
+ * ```
55
+ */
56
+ export function segmentsToTopic(segments: readonly string[]): string {
57
+ // A segment that is not a string becomes text as with join(): null and undefined give "" (a
58
+ // caller can check a path that is not valid, for example the compiler)
59
+ return segments.map((segment: unknown) => encodeSegment(segment === null || segment === undefined ? "" : String(segment))).join(".");
60
+ }
61
+
62
+ /**
63
+ * This function gives the raw segments of a text with dots between the segments: it splits the
64
+ * text on each dot and decodes each part. For each list of segments with one segment or more,
65
+ * `topicToSegments(segmentsToTopic(segments))` gives the same list.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * topicToSegments("collections.e2e%2Etabs"); // ["collections", "e2e.tabs"]
70
+ * ```
71
+ */
72
+ export function topicToSegments(topic: string): string[] {
73
+ return topic.split(".").map(decodeSegment);
74
+ }
@@ -15,6 +15,7 @@ import type { EventBus } from "../events/types.js";
15
15
  import type { HydrationEvent } from "./ref.js";
16
16
  import type { Principal } from "../server/types.js";
17
17
  import type { Invocation } from "./invoke.js";
18
+ import { segmentsToTopic, topicToSegments } from "./segments.js";
18
19
 
19
20
  // =============================================================================
20
21
  // Procedure Path
@@ -33,17 +34,20 @@ import type { Invocation } from "./invoke.js";
33
34
  export type ProcedurePath = string[];
34
35
 
35
36
  /**
36
- * Convert path array to dot-separated key.
37
+ * This function gives the key of a path: the segments with dots between them. Each segment encodes
38
+ * `%` as `%25` and `.` as `%2E` (`encodeSegment()`, roadmap CLI-75). Thus the key of
39
+ * `["a.b", "c"]` is `a%2Eb.c`, and not the key of `["a", "b", "c"]`. A plain path keeps its key.
37
40
  */
38
41
  export function pathToKey(path: ProcedurePath): string {
39
- return path.join(".");
42
+ return segmentsToTopic(path);
40
43
  }
41
44
 
42
45
  /**
43
- * Parse dot-separated key to path array.
46
+ * This function gives the path of a key: it splits the key on each dot and decodes each segment
47
+ * (`decodeSegment()`, roadmap CLI-75). For each path, `keyToPath(pathToKey(path))` gives the path.
44
48
  */
45
49
  export function keyToPath(key: string): ProcedurePath {
46
- return key.split(".");
50
+ return topicToSegments(key);
47
51
  }
48
52
 
49
53
  // =============================================================================
@@ -22,7 +22,7 @@
22
22
  * keyed limiter for each limit, and refuses a call above a limit with `RATE_LIMITED`.
23
23
  */
24
24
 
25
- import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
25
+ import { pathToKey, type Capability, type ClearanceOrder, type ProcedurePath, type ResourcesOf } from "../procedures/types.js";
26
26
  import type { AdmitItem, Interceptor, Invocation } from "../procedures/invoke.js";
27
27
  import { narrowCapability } from "../procedures/capability.js";
28
28
  import type { PrincipalOf, ServerRequest } from "./types.js";
@@ -53,7 +53,8 @@ export function exposePatterns(patterns: readonly string[]): (path: ProcedurePat
53
53
  }
54
54
  return (path) => {
55
55
  if (all) return true;
56
- const key = path.join(".");
56
+ // The key encodes a dot in a segment (roadmap CLI-75), as the method name does
57
+ const key = pathToKey(path);
57
58
  return exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix));
58
59
  };
59
60
  }
@@ -62,7 +63,7 @@ export function exposePatterns(patterns: readonly string[]): (path: ProcedurePat
62
63
  export function surfaceRule(surface: ToolSurface): (path: ProcedurePath) => boolean {
63
64
  const namespaces = new Set(surface.namespaces);
64
65
  const excluded = new Set(surface.exclude ?? []);
65
- return (path) => namespaces.has(path[0] ?? "") && !excluded.has(path.join("."));
66
+ return (path) => namespaces.has(path[0] ?? "") && !excluded.has(pathToKey(path));
66
67
  }
67
68
 
68
69
  /** The options of `hostPolicy()`. */
@@ -162,7 +163,7 @@ function exposeOf(options: HostPolicyOptions): ((path: ProcedurePath) => boolean
162
163
  : surfaceRule(expose as ToolSurface);
163
164
  const excluded = new Set(options.exclude ?? []);
164
165
  if (excluded.size === 0) return rule;
165
- return (path) => !excluded.has(path.join(".")) && (rule ? rule(path) : true);
166
+ return (path) => !excluded.has(pathToKey(path)) && (rule ? rule(path) : true);
166
167
  }
167
168
 
168
169
  /** This function builds the policy of a host from its options. */
@@ -44,6 +44,8 @@ export type { ServerOptions } from "./server.js";
44
44
  export { ProcedureServer, createProcedureServer, STREAM_METADATA_KEY, isUnboundedStream } from "./procedure-server.js";
45
45
  export type { ProcedureServerOptions, RegisterProcedureOptions, StorageConfig, StopOptions, StopParts, StorageHandle, UseStorageOptions } from "./procedure-server.js";
46
46
  export { pathToMethod, methodToPath, methodName } from "./method.js";
47
+ // The escape of a segment with a dot, for a method name, a key and a topic (roadmap CLI-75)
48
+ export { encodeSegment, decodeSegment, segmentsToTopic, topicToSegments } from "../procedures/segments.js";
47
49
  export {
48
50
  HandlerNotFoundError,
49
51
  ServerError,
@@ -8,26 +8,41 @@
8
8
  * (deep dive architecture review 3.3).
9
9
  *
10
10
  * `methodToPath` splits both the service and the operation on ".", so it reads both forms.
11
+ *
12
+ * Each segment encodes `%` as `%25` and then `.` as `%2E` before the join (`encodeSegment()`,
13
+ * roadmap CLI-75). Thus the path `["collections", "e2e.tabs", "find"]` is the method
14
+ * `{ service: "collections.e2e%2Etabs", operation: "find" }`, and `methodToPath` gives the same
15
+ * three segments again. A segment without `.` and `%` stays the same, so a plain path keeps its method.
11
16
  */
12
17
 
13
18
  import type { Method } from "../client/types.js";
14
19
  import type { ProcedurePath } from "../procedures/types.js";
20
+ import { encodeSegment, topicToSegments } from "../procedures/segments.js";
15
21
 
16
22
  /**
17
23
  * This function gives the transport method of a procedure path. The last segment is the
18
- * operation, and the other segments, joined with dots, are the service. A path of one segment,
19
- * for example `["morning"]`, has the empty service (CLI-28). An empty path throws.
24
+ * operation, and the other segments, joined with dots, are the service. Each segment is encoded
25
+ * first (`encodeSegment()`, roadmap CLI-75). A path of one segment, for example `["morning"]`, has
26
+ * the empty service (CLI-28). An empty path throws.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * pathToMethod(["docker", "compose", "up"]); // { service: "docker.compose", operation: "up" }
31
+ * pathToMethod(["collections", "e2e.tabs", "find"]); // { service: "collections.e2e%2Etabs", operation: "find" }
32
+ * ```
20
33
  */
21
34
  export function pathToMethod(path: readonly string[]): Method {
22
35
  if (path.length === 0) {
23
36
  throw new Error("Invalid procedure path: it needs one segment or more");
24
37
  }
25
- return { service: path.slice(0, -1).join("."), operation: path[path.length - 1]! };
38
+ const segments = path.map(encodeSegment);
39
+ return { service: segments.slice(0, -1).join("."), operation: segments[segments.length - 1]! };
26
40
  }
27
41
 
28
42
  /**
29
43
  * The name of a method with dots: `docker.compose.up`, or only the operation for the empty
30
- * service of a path of one segment (`morning`).
44
+ * service of a path of one segment (`morning`). The segments of the method are already encoded,
45
+ * so the name of `["collections", "e2e.tabs", "find"]` is `collections.e2e%2Etabs.find`.
31
46
  */
32
47
  export function methodName(method: Pick<Method, "service" | "operation">): string {
33
48
  return method.service === "" ? method.operation : `${method.service}.${method.operation}`;
@@ -36,10 +51,12 @@ export function methodName(method: Pick<Method, "service" | "operation">): strin
36
51
  /**
37
52
  * This function gives the procedure path of a transport method. It accepts both encodings:
38
53
  * `{ service: "a.b", operation: "c" }` and `{ service: "a", operation: "b.c" }` give `["a", "b", "c"]`.
54
+ * It splits on each dot and then decodes each segment (`decodeSegment()`, roadmap CLI-75): `%2E`
55
+ * gives `.` and `%25` gives `%`.
39
56
  */
40
57
  export function methodToPath(method: Pick<Method, "service" | "operation">): ProcedurePath {
41
58
  // The empty service of a path of one segment has no segment. Each other empty segment stays, so
42
59
  // a method with ".." or a stray dot names no procedure: the registry has no empty segment (CLI-28)
43
- const service = method.service === "" ? [] : method.service.split(".");
44
- return [...service, ...method.operation.split(".")];
60
+ const service = method.service === "" ? [] : topicToSegments(method.service);
61
+ return [...service, ...topicToSegments(method.operation)];
45
62
  }