@procedures/client 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/addressing.d.ts +78 -0
  4. package/dist/adapters/http/server/addressing.d.ts.map +1 -0
  5. package/dist/adapters/http/server/addressing.js +62 -0
  6. package/dist/adapters/http/server/addressing.js.map +1 -0
  7. package/dist/adapters/http/server/cors.d.ts.map +1 -1
  8. package/dist/adapters/http/server/cors.js +3 -0
  9. package/dist/adapters/http/server/cors.js.map +1 -1
  10. package/dist/adapters/http/server/handler.d.ts +9 -1
  11. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  12. package/dist/adapters/http/server/handler.js +49 -23
  13. package/dist/adapters/http/server/handler.js.map +1 -1
  14. package/dist/adapters/http/server/strategies.d.ts +1 -0
  15. package/dist/adapters/http/server/strategies.d.ts.map +1 -1
  16. package/dist/adapters/http/server/strategies.js +6 -0
  17. package/dist/adapters/http/server/strategies.js.map +1 -1
  18. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  19. package/dist/adapters/http/server/transport.js +7 -2
  20. package/dist/adapters/http/server/transport.js.map +1 -1
  21. package/dist/adapters/http/server/types.d.ts +7 -0
  22. package/dist/adapters/http/server/types.d.ts.map +1 -1
  23. package/dist/adapters/http/shared/strategies.d.ts +7 -0
  24. package/dist/adapters/http/shared/strategies.d.ts.map +1 -1
  25. package/dist/adapters/http/shared/strategies.js +26 -7
  26. package/dist/adapters/http/shared/strategies.js.map +1 -1
  27. package/dist/adapters/local/client/types.d.ts.map +1 -1
  28. package/dist/adapters/local/client/types.js +3 -1
  29. package/dist/adapters/local/client/types.js.map +1 -1
  30. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  31. package/dist/adapters/websocket/client/transport.js +4 -12
  32. package/dist/adapters/websocket/client/transport.js.map +1 -1
  33. package/dist/browser.d.ts +6 -3
  34. package/dist/browser.d.ts.map +1 -1
  35. package/dist/browser.js +5 -1
  36. package/dist/browser.js.map +1 -1
  37. package/dist/client/client.d.ts +6 -0
  38. package/dist/client/client.d.ts.map +1 -1
  39. package/dist/client/client.js +3 -1
  40. package/dist/client/client.js.map +1 -1
  41. package/dist/client/errors/factory.d.ts.map +1 -1
  42. package/dist/client/errors/factory.js +2 -3
  43. package/dist/client/errors/factory.js.map +1 -1
  44. package/dist/client/middleware/cache.d.ts.map +1 -1
  45. package/dist/client/middleware/cache.js +2 -1
  46. package/dist/client/middleware/cache.js.map +1 -1
  47. package/dist/events/publisher.d.ts +25 -2
  48. package/dist/events/publisher.d.ts.map +1 -1
  49. package/dist/events/publisher.js +42 -9
  50. package/dist/events/publisher.js.map +1 -1
  51. package/dist/node.d.ts +6 -0
  52. package/dist/node.d.ts.map +1 -1
  53. package/dist/node.js +6 -0
  54. package/dist/node.js.map +1 -1
  55. package/dist/procedures/collection/changes.d.ts +3 -3
  56. package/dist/procedures/collection/changes.d.ts.map +1 -1
  57. package/dist/procedures/collection/changes.js +4 -4
  58. package/dist/procedures/collection/changes.js.map +1 -1
  59. package/dist/procedures/collection/index.d.ts +2 -0
  60. package/dist/procedures/collection/index.d.ts.map +1 -1
  61. package/dist/procedures/collection/index.js +2 -0
  62. package/dist/procedures/collection/index.js.map +1 -1
  63. package/dist/procedures/collection/partition.d.ts +86 -0
  64. package/dist/procedures/collection/partition.d.ts.map +1 -0
  65. package/dist/procedures/collection/partition.js +100 -0
  66. package/dist/procedures/collection/partition.js.map +1 -0
  67. package/dist/procedures/collection/procedures.d.ts +4 -4
  68. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  69. package/dist/procedures/collection/procedures.js +55 -55
  70. package/dist/procedures/collection/procedures.js.map +1 -1
  71. package/dist/procedures/index.d.ts +4 -2
  72. package/dist/procedures/index.d.ts.map +1 -1
  73. package/dist/procedures/index.js +3 -1
  74. package/dist/procedures/index.js.map +1 -1
  75. package/dist/procedures/invoke.d.ts +17 -2
  76. package/dist/procedures/invoke.d.ts.map +1 -1
  77. package/dist/procedures/invoke.js +30 -1
  78. package/dist/procedures/invoke.js.map +1 -1
  79. package/dist/procedures/narrowing.d.ts +93 -0
  80. package/dist/procedures/narrowing.d.ts.map +1 -0
  81. package/dist/procedures/narrowing.js +328 -0
  82. package/dist/procedures/narrowing.js.map +1 -0
  83. package/dist/procedures/types.d.ts +19 -0
  84. package/dist/procedures/types.d.ts.map +1 -1
  85. package/dist/procedures/types.js.map +1 -1
  86. package/dist/server/index.d.ts +2 -2
  87. package/dist/server/index.d.ts.map +1 -1
  88. package/dist/server/index.js +1 -1
  89. package/dist/server/index.js.map +1 -1
  90. package/dist/server/method.d.ts +8 -2
  91. package/dist/server/method.d.ts.map +1 -1
  92. package/dist/server/method.js +16 -5
  93. package/dist/server/method.js.map +1 -1
  94. package/dist/server/procedure-server.d.ts +47 -1
  95. package/dist/server/procedure-server.d.ts.map +1 -1
  96. package/dist/server/procedure-server.js +89 -12
  97. package/dist/server/procedure-server.js.map +1 -1
  98. package/dist/server/types.d.ts +16 -0
  99. package/dist/server/types.d.ts.map +1 -1
  100. package/dist/server/types.js +2 -1
  101. package/dist/server/types.js.map +1 -1
  102. package/package.json +3 -3
  103. package/src/adapters/http/server/addressing.ts +118 -0
  104. package/src/adapters/http/server/cors.ts +3 -0
  105. package/src/adapters/http/server/handler.ts +57 -23
  106. package/src/adapters/http/server/strategies.ts +7 -0
  107. package/src/adapters/http/server/transport.ts +8 -3
  108. package/src/adapters/http/server/types.ts +8 -0
  109. package/src/adapters/http/shared/strategies.ts +25 -7
  110. package/src/adapters/local/client/types.ts +3 -1
  111. package/src/adapters/websocket/client/transport.ts +4 -12
  112. package/src/browser.ts +12 -1
  113. package/src/client/client.ts +8 -1
  114. package/src/client/errors/factory.ts +2 -3
  115. package/src/client/middleware/cache.ts +2 -1
  116. package/src/events/publisher.ts +50 -11
  117. package/src/node.ts +7 -0
  118. package/src/procedures/collection/changes.ts +4 -4
  119. package/src/procedures/collection/index.ts +4 -0
  120. package/src/procedures/collection/partition.ts +145 -0
  121. package/src/procedures/collection/procedures.ts +72 -73
  122. package/src/procedures/index.ts +9 -0
  123. package/src/procedures/invoke.ts +42 -2
  124. package/src/procedures/narrowing.ts +357 -0
  125. package/src/procedures/types.ts +23 -0
  126. package/src/server/index.ts +2 -1
  127. package/src/server/method.ts +17 -5
  128. package/src/server/procedure-server.ts +106 -13
  129. package/src/server/types.ts +19 -1
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The partitions of a collection (roadmap CLI-17). One `collections.<name>.*` surface serves the
3
+ * items of several partitions, for example one per tenant, or one per tenant and scope. Each call
4
+ * reads and writes one partition.
5
+ *
6
+ * - The host registers the partitions of a collection with its repository: the storage of each
7
+ * partition and the resolver (`ProcedureServer.usePartitions(name, { storageOf, resolve })`).
8
+ * - The input of a call can ask for a partition (`partition`, for example a scope). The resolver
9
+ * gives the partition of the call from its context: the tenant comes from the principal, never
10
+ * from the input alone. It can refuse the call.
11
+ * - A subscriber names the partition that it asks for in the topic, as an input does:
12
+ * `collections.<name>.<requested>`. The publisher resolves it in the same way and listens to the
13
+ * topic of the resolved partition, so a subscriber cannot name the topic of another tenant.
14
+ * - A collection without partitions refuses a call or a topic with a partition.
15
+ */
16
+ import type { ZodLike } from "../../client/validation/types.js";
17
+ import { InvocationError } from "../invoke.js";
18
+ import type { ProcedureContext, RepositoryProvider } from "../types.js";
19
+ import type { CollectionStorage } from "./storage.js";
20
+
21
+ /** The characters of a partition: letters, digits and `_ . : @ ~ + | / -`, 256 at most. */
22
+ export const PARTITION_PATTERN: RegExp = /^[A-Za-z0-9_.:@~+|/-]{1,256}$/;
23
+
24
+ /** The field of an input that asks for a partition. */
25
+ export interface PartitionInput {
26
+ partition?: string;
27
+ }
28
+
29
+ /**
30
+ * The resolver of the partition of a call. It gets the context (with `ctx.principal`) and the
31
+ * partition that the input asks for. It gives the partition, `undefined` for the storage of the
32
+ * collection without a partition, or `null` to refuse the call (`FORBIDDEN`, reason
33
+ * `partition-not-allowed`). An error that it throws passes through.
34
+ *
35
+ * The contract: the resolver gives a partition of the caller, or `null`. It never gives the
36
+ * partition of another tenant. A resolver that maps a request of tenant B to the partition of
37
+ * tenant A is a defect of the app: the events of A then reach B under the topic that B named, and
38
+ * a `HybridStorage` of B keeps the items of A.
39
+ */
40
+ export type PartitionResolver = (ctx: ProcedureContext, requested: string | undefined) => string | undefined | null | Promise<string | undefined | null>;
41
+
42
+ /**
43
+ * This function adds the field `partition` to the input schema of a collection procedure. The
44
+ * repository of the call decides at the call if the collection has partitions.
45
+ */
46
+ export function withPartition<T>(schema: ZodLike<T>): ZodLike<T & PartitionInput> {
47
+ const validate = (data: unknown): { success: true; data: T & PartitionInput } | { success: false; error: { message: string; errors: { path: (string | number)[]; message: string }[] } } => {
48
+ const fail = (message: string) => ({ success: false as const, error: { message, errors: [{ path: ["partition"], message }] } });
49
+ const requested = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record<string, unknown>)["partition"] : undefined;
50
+ if (requested !== undefined && (typeof requested !== "string" || !PARTITION_PATTERN.test(requested))) {
51
+ return fail("partition must have 1 to 256 letters, digits or the characters _ . : @ ~ + | / -");
52
+ }
53
+ const inner = schema.safeParse(data);
54
+ if (!inner.success) return inner as { success: false; error: { message: string; errors: { path: (string | number)[]; message: string }[] } };
55
+ const parsed = inner.data as T & PartitionInput;
56
+ return { success: true, data: requested === undefined ? parsed : { ...parsed, partition: requested as string } };
57
+ };
58
+ return {
59
+ parse(data: unknown) {
60
+ const result = validate(data);
61
+ if (result.success) return result.data;
62
+ throw new Error(result.error.message);
63
+ },
64
+ safeParse: validate,
65
+ } as ZodLike<T & PartitionInput>;
66
+ }
67
+
68
+ /** The partitions of a collection: the storage of each partition, and the resolver of a call. */
69
+ export interface CollectionPartitions<T = unknown> {
70
+ /**
71
+ * The storage of a partition. Give the same storage for the same partition each time: the change
72
+ * events of a storage keep their order with it.
73
+ */
74
+ storageOf(partition: string): CollectionStorage<T>;
75
+ /** The resolver of the partition of a call. */
76
+ resolve: PartitionResolver;
77
+ }
78
+
79
+ /** The refusal of a partition. */
80
+ function partitionRefused(ctx: ProcedureContext, message: string): InvocationError {
81
+ return new InvocationError("FORBIDDEN", message, ctx.path, "partition-not-allowed");
82
+ }
83
+
84
+ /**
85
+ * This function gives the partition of a call to a collection: the resolver of the repository
86
+ * gives it from the context and the requested partition. It throws `FORBIDDEN` (reason
87
+ * `partition-not-allowed`) when the resolver refuses, or when the collection has no partitions and
88
+ * the call asks for one.
89
+ */
90
+ export async function partitionOfCall(ctx: ProcedureContext, collection: string, requested: string | undefined): Promise<string | undefined> {
91
+ const repository = ctx.repository;
92
+ if (!repository?.hasPartitions?.(collection) || !repository.resolvePartition) {
93
+ if (requested !== undefined) throw partitionRefused(ctx, `The collection ${collection} has no partitions`);
94
+ return undefined;
95
+ }
96
+ const partition = await repository.resolvePartition(collection, ctx, requested);
97
+ if (partition === null) {
98
+ throw partitionRefused(ctx, `The call cannot use the partition${requested === undefined ? "" : ` "${requested}"`} of ${collection}`);
99
+ }
100
+ if (partition !== undefined && !PARTITION_PATTERN.test(partition)) {
101
+ throw new Error(`The partition resolver gave a partition that is not valid: ${JSON.stringify(partition).slice(0, 80)}`);
102
+ }
103
+ return partition;
104
+ }
105
+
106
+ /**
107
+ * This function gives the storage of a call: of the collection, or of its partition. A repository
108
+ * without `getPartitionStorage` refuses a partition.
109
+ */
110
+ export function storageOfCall<T>(ctx: ProcedureContext, collection: string, partition: string | undefined): CollectionStorage<T> {
111
+ const repository = ctx.repository;
112
+ if (!repository) throw new Error("Repository not available in procedure context");
113
+ if (partition === undefined) return repository.getStorage<T>(collection);
114
+ if (!repository.getPartitionStorage) {
115
+ throw new Error(`The repository has no partitions, so it cannot give the partition "${partition}" of ${collection}`);
116
+ }
117
+ return repository.getPartitionStorage<T>(collection, partition);
118
+ }
119
+
120
+ /**
121
+ * This function gives the collection and the partition part of a topic of a call, with the
122
+ * repository of the call: one rule for the read check and for the listener of the publisher.
123
+ *
124
+ * - A collection of the repository without partitions whose name is the whole rest of the topic
125
+ * wins: its name can have a dot.
126
+ * - Else the first part after `collections.` names the collection, and the rest is the partition
127
+ * that the call asks for. A collection with partitions has no dot in its name.
128
+ */
129
+ export function collectionTopicTarget(topic: string, repository: RepositoryProvider | undefined): { collection: string; partition: string | undefined } | undefined {
130
+ if (!topic.startsWith("collections.")) return undefined;
131
+ const whole = topic.slice("collections.".length);
132
+ if (whole !== "" && repository?.hasCollection(whole) && !repository.hasPartitions?.(whole)) return { collection: whole, partition: undefined };
133
+ return collectionTopicPartition(topic);
134
+ }
135
+
136
+ /**
137
+ * This function gives the collection and the partition part of a topic `collections.<name>` or
138
+ * `collections.<name>.<partition>`, or undefined for another topic. It reads the topic alone:
139
+ * `collectionTopicTarget` also knows the collections of a repository.
140
+ */
141
+ export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
142
+ const match = /^collections\.([^.]+)(?:\.(.+))?$/.exec(topic);
143
+ if (!match) return undefined;
144
+ return { collection: match[1]!, partition: match[2] };
145
+ }
@@ -59,6 +59,7 @@ import {
59
59
  import { DEFAULT_FIND_LIMIT, applyPatch, queryEntries, type StoragePatch, type StorageQueryOptions, type StorageQueryPolicy } from "./query.js";
60
60
  import type { CollectionStorage, StorageWriteOptions } from "./storage.js";
61
61
  import { collectionChangeTopic, startChangeChain, writeWithChangeEvent } from "./changes.js";
62
+ import { partitionOfCall, storageOfCall, withPartition, type PartitionInput } from "./partition.js";
62
63
 
63
64
  // =============================================================================
64
65
  // Handler Factory
@@ -75,17 +76,6 @@ function getCollectionFromPath(path: string[]): string {
75
76
  return path[1]!;
76
77
  }
77
78
 
78
- /**
79
- * Get storage from context, throwing if repository is not available.
80
- */
81
- function getStorage<T>(ctx: ProcedureContext): ReturnType<NonNullable<ProcedureContext["repository"]>["getStorage"]> {
82
- if (!ctx.repository) {
83
- throw new Error("Repository not available in procedure context");
84
- }
85
- const collection = getCollectionFromPath(ctx.path);
86
- return ctx.repository.getStorage<T>(collection);
87
- }
88
-
89
79
  /** The options of a query from the input of `find`. */
90
80
  function queryOptionsOf(input: FindInput, policy: StorageQueryPolicy): StorageQueryOptions {
91
81
  // A find without a limit gets the maxLimit of the policy (roadmap CLI-14)
@@ -100,11 +90,11 @@ function queryOptionsOf(input: FindInput, policy: StorageQueryPolicy): StorageQu
100
90
  /** The options of the change events of the collection procedures (roadmap CLI-15). */
101
91
  export interface CollectionChangeEventsOptions {
102
92
  /**
103
- * The topic of the change events of a call, or undefined for no event. The partition of a
104
- * collection (roadmap CLI-17) goes into the topic here, for example from the scope of the call.
105
- * @default the topic `collections.<name>` of `collectionChangeTopic()`
93
+ * The topic of the change events of a call, or undefined for no event. It gets the partition of
94
+ * the call (roadmap CLI-17).
95
+ * @default `collectionChangeTopic(name, partition)`: `collections.<name>`, or `collections.<name>.<partition>`
106
96
  */
107
- topic?: ((collection: string, ctx: ProcedureContext) => string | undefined) | undefined;
97
+ topic?: ((collection: string, ctx: ProcedureContext, partition: string | undefined) => string | undefined) | undefined;
108
98
  /**
109
99
  * True: the events also carry the values of the changed items. Each row `{ id, value }` passes
110
100
  * the output filter of the host (`admitItem`) for each subscriber, and a refused row stays out of
@@ -196,29 +186,38 @@ export function createCollectionProcedures(collectionName: string, options: Coll
196
186
  const queryPolicy: StorageQueryPolicy = { ...options.queryPolicy, maxLimit: options.queryPolicy?.maxLimit ?? DEFAULT_FIND_LIMIT };
197
187
  // Each collection is a resource of its own (CAPABILITIES-2026-10): a capability can permit one
198
188
  const resource = `collection:${collectionName}`;
199
- // The topic of the change events of a call (roadmap CLI-15). CLI-17 adds the partition with the option topic.
189
+ // The topic of the change events of a call (roadmap CLI-15), with its partition (roadmap CLI-17)
200
190
  const events = options.changeEvents ?? true;
201
- const topicRule: ((collection: string, ctx: ProcedureContext) => string | undefined) | undefined =
202
- events === false ? undefined : (events !== true && events.topic) || ((name: string) => collectionChangeTopic(name));
203
- const topicOf = (ctx: ProcedureContext): string | undefined => topicRule?.(collectionName, ctx);
191
+ const topicRule: ((collection: string, ctx: ProcedureContext, partition: string | undefined) => string | undefined) | undefined =
192
+ events === false ? undefined : (events !== true && events.topic) || ((name: string, _ctx: ProcedureContext, partition: string | undefined) => collectionChangeTopic(name, partition));
193
+ const topicOf = (ctx: ProcedureContext, partition: string | undefined): string | undefined => topicRule?.(collectionName, ctx, partition);
204
194
  const withValues = events !== false && events !== true && events.values === true;
205
195
  /** A write, and then its change event on the bus of the context. */
206
- const changing = <R>(ctx: ProcedureContext, storage: CollectionStorage<unknown>, ids: readonly string[] | "*", origin: string | undefined, write: () => Promise<R>): Promise<R> =>
207
- writeWithChangeEvent(ctx.bus, storage, topicRule ? topicOf(ctx) : undefined, ids, origin, write, withValues);
196
+ const changing = <R>(ctx: ProcedureContext, partition: string | undefined, storage: CollectionStorage<unknown>, ids: readonly string[] | "*", origin: string | undefined, write: () => Promise<R>): Promise<R> =>
197
+ writeWithChangeEvent(ctx.bus, storage, topicRule ? topicOf(ctx, partition) : undefined, ids, origin, write, withValues);
198
+ /**
199
+ * The partition and the storage of a call (roadmap CLI-17). The repository of the call has the
200
+ * partitions of the collection and their resolver: the tenant comes from the principal.
201
+ */
202
+ const storageOf = async (input: PartitionInput, ctx: ProcedureContext): Promise<{ storage: CollectionStorage<unknown>; partition: string | undefined }> => {
203
+ const collection = getCollectionFromPath(ctx.path);
204
+ const partition = await partitionOfCall(ctx, collection, input.partition);
205
+ return { storage: storageOfCall<unknown>(ctx, collection, partition), partition };
206
+ };
208
207
 
209
208
  return [
210
209
  // Get
211
210
  defineProcedure({
212
211
  path: [...basePath, "get"],
213
- input: getInputSchema,
212
+ input: withPartition(getInputSchema),
214
213
  output: getOutputSchema,
215
214
  metadata: {
216
215
  effects: { reads: [resource], writes: [] },
217
216
  description: `Get item from ${collectionName} by ID`,
218
217
  tags: ["collections", collectionName, "read"],
219
218
  },
220
- handler: async (input: GetInput, ctx : ProcedureContext) => {
221
- const storage = getStorage(ctx);
219
+ handler: async (input: GetInput & PartitionInput, ctx: ProcedureContext) => {
220
+ const { storage } = await storageOf(input, ctx);
222
221
  const value = await storage.get(input.id);
223
222
  if (input.found !== true) return value;
224
223
  // The output with `found` keeps a stored null apart from a missing id, in one call
@@ -230,47 +229,47 @@ export function createCollectionProcedures(collectionName: string, options: Coll
230
229
  // Set
231
230
  defineProcedure({
232
231
  path: [...basePath, "set"],
233
- input: setInputSchema,
232
+ input: withPartition(setInputSchema),
234
233
  output: setOutputSchema,
235
234
  metadata: {
236
235
  effects: { reads: [resource], writes: [resource], idempotent: true },
237
236
  description: `Set item in ${collectionName}`,
238
237
  tags: ["collections", collectionName, "write"],
239
238
  },
240
- handler: async (input: SetInput, ctx: ProcedureContext) => {
241
- const storage = getStorage(ctx);
242
- await changing(ctx, storage, [input.id], input.origin, () => storage.set(input.id, input.value, writeOptionsOf(input)));
239
+ handler: async (input: SetInput & PartitionInput, ctx: ProcedureContext) => {
240
+ const { storage, partition } = await storageOf(input, ctx);
241
+ await changing(ctx, partition, storage, [input.id], input.origin, () => storage.set(input.id, input.value, writeOptionsOf(input)));
243
242
  },
244
243
  }),
245
244
 
246
245
  // Delete
247
246
  defineProcedure({
248
247
  path: [...basePath, "delete"],
249
- input: deleteInputSchema,
248
+ input: withPartition(deleteInputSchema),
250
249
  output: deleteOutputSchema,
251
250
  metadata: {
252
251
  effects: { reads: [resource], writes: [resource], idempotent: true },
253
252
  description: `Delete item from ${collectionName}`,
254
253
  tags: ["collections", collectionName, "write"],
255
254
  },
256
- handler: async (input: DeleteInput, ctx: ProcedureContext) => {
257
- const storage = getStorage(ctx);
258
- return changing(ctx, storage, [input.id], input.origin, () => storage.delete(input.id, writeOptionsOf(input)));
255
+ handler: async (input: DeleteInput & PartitionInput, ctx: ProcedureContext) => {
256
+ const { storage, partition } = await storageOf(input, ctx);
257
+ return changing(ctx, partition, storage, [input.id], input.origin, () => storage.delete(input.id, writeOptionsOf(input)));
259
258
  },
260
259
  }),
261
260
 
262
261
  // Has
263
262
  defineProcedure({
264
263
  path: [...basePath, "has"],
265
- input: hasInputSchema,
264
+ input: withPartition(hasInputSchema),
266
265
  output: hasOutputSchema,
267
266
  metadata: {
268
267
  effects: { reads: [resource], writes: [] },
269
268
  description: `Check if item exists in ${collectionName}`,
270
269
  tags: ["collections", collectionName, "read"],
271
270
  },
272
- handler: async (input: HasInput, ctx: ProcedureContext) => {
273
- const storage = getStorage(ctx);
271
+ handler: async (input: HasInput & PartitionInput, ctx: ProcedureContext) => {
272
+ const { storage } = await storageOf(input, ctx);
274
273
  return storage.has(input.id);
275
274
  },
276
275
  }),
@@ -278,15 +277,15 @@ export function createCollectionProcedures(collectionName: string, options: Coll
278
277
  // GetAll
279
278
  defineProcedure({
280
279
  path: [...basePath, "getAll"],
281
- input: getAllInputSchema,
280
+ input: withPartition(getAllInputSchema),
282
281
  output: getAllOutputSchema,
283
282
  metadata: {
284
283
  effects: { reads: [resource], writes: [] },
285
284
  description: `Get all items from ${collectionName}`,
286
285
  tags: ["collections", collectionName, "read"],
287
286
  },
288
- handler: async (_input: GetAllInput, ctx: ProcedureContext) => {
289
- const storage = getStorage(ctx);
287
+ handler: async (input: GetAllInput & PartitionInput, ctx: ProcedureContext) => {
288
+ const { storage } = await storageOf(input, ctx);
290
289
  return storage.getAll();
291
290
  },
292
291
  }),
@@ -294,15 +293,15 @@ export function createCollectionProcedures(collectionName: string, options: Coll
294
293
  // Entries: each item with its id, for a sync (roadmap R2)
295
294
  defineProcedure({
296
295
  path: [...basePath, "entries"],
297
- input: entriesInputSchema,
296
+ input: withPartition(entriesInputSchema),
298
297
  output: entriesOutputSchema,
299
298
  metadata: {
300
299
  effects: { reads: [resource], writes: [] },
301
300
  description: `Get all items of ${collectionName} with their ids`,
302
301
  tags: ["collections", collectionName, "read"],
303
302
  },
304
- handler: async (_input: EntriesInput, ctx: ProcedureContext) => {
305
- const storage = getStorage(ctx);
303
+ handler: async (input: EntriesInput & PartitionInput, ctx: ProcedureContext) => {
304
+ const { storage } = await storageOf(input, ctx);
306
305
  if (!storage.getEntries) {
307
306
  throw new Error(`The storage of ${collectionName} cannot give the ids of its items`);
308
307
  }
@@ -313,15 +312,15 @@ export function createCollectionProcedures(collectionName: string, options: Coll
313
312
  // Ids: the ids of the items without the values, for a full sync
314
313
  defineProcedure({
315
314
  path: [...basePath, "ids"],
316
- input: idsInputSchema,
315
+ input: withPartition(idsInputSchema),
317
316
  output: idsOutputSchema,
318
317
  metadata: {
319
318
  effects: { reads: [resource], writes: [] },
320
319
  description: `Get the ids of the items of ${collectionName}, without the values`,
321
320
  tags: ["collections", collectionName, "read"],
322
321
  },
323
- handler: async (_input: IdsInput, ctx: ProcedureContext) => {
324
- const storage = getStorage(ctx);
322
+ handler: async (input: IdsInput & PartitionInput, ctx: ProcedureContext) => {
323
+ const { storage } = await storageOf(input, ctx);
325
324
  if (storage.getIds) return storage.getIds();
326
325
  // A storage without getIds() gives its entries: the values stay on the server
327
326
  if (storage.getEntries) return (await storage.getEntries()).map(([id]) => id);
@@ -332,20 +331,20 @@ export function createCollectionProcedures(collectionName: string, options: Coll
332
331
  // Changes: the items that changed after a version, for a delta sync (roadmap R28)
333
332
  defineProcedure({
334
333
  path: [...basePath, "changes"],
335
- input: changesInputSchema,
334
+ input: withPartition(changesInputSchema),
336
335
  output: changesOutputSchema,
337
336
  metadata: {
338
337
  effects: { reads: [resource], writes: [] },
339
338
  description: `Get the items of ${collectionName} that changed after a version`,
340
339
  tags: ["collections", collectionName, "read"],
341
340
  },
342
- handler: async (input: ChangesInput, ctx: ProcedureContext): Promise<ChangesOutput> => {
343
- const storage = getStorage(ctx);
341
+ handler: async (input: ChangesInput & PartitionInput, ctx: ProcedureContext): Promise<ChangesOutput> => {
342
+ const { storage, partition } = await storageOf(input, ctx);
344
343
  if (storage.getChangesSince) {
345
344
  const changes = await storage.getChangesSince(input.since);
346
345
  // The chain of the change events starts at this version: the caller then applies the
347
346
  // next event without a read (roadmap CLI-15)
348
- if (topicRule) startChangeChain(ctx.bus, storage, topicOf(ctx), changes.version);
347
+ if (topicRule) startChangeChain(ctx.bus, storage, topicOf(ctx, partition), changes.version);
349
348
  return {
350
349
  version: changes.version,
351
350
  full: changes.full,
@@ -366,15 +365,15 @@ export function createCollectionProcedures(collectionName: string, options: Coll
366
365
  // Find: the items that match a JSON selector, with an order, a skip and a limit (roadmap CLI-14)
367
366
  defineProcedure({
368
367
  path: [...basePath, "find"],
369
- input: findInputSchemaOf(queryPolicy),
368
+ input: withPartition(findInputSchemaOf(queryPolicy)),
370
369
  output: findOutputSchema,
371
370
  metadata: {
372
371
  effects: { reads: [resource], writes: [] },
373
372
  description: `Find the items of ${collectionName} that match a selector, with their ids, an order, a skip and a limit`,
374
373
  tags: ["collections", collectionName, "read", "query"],
375
374
  },
376
- handler: async (input: FindInput, ctx: ProcedureContext) => {
377
- const storage = getStorage(ctx);
375
+ handler: async (input: FindInput & PartitionInput, ctx: ProcedureContext) => {
376
+ const { storage } = await storageOf(input, ctx);
378
377
  const selector = input.selector ?? {};
379
378
  const query = queryOptionsOf(input, queryPolicy);
380
379
  // The storage runs the selector itself when it can: a database does not read each item. It
@@ -390,17 +389,17 @@ export function createCollectionProcedures(collectionName: string, options: Coll
390
389
  // Patch: merge fields into one item, a JSON Merge Patch (roadmap CLI-14)
391
390
  defineProcedure({
392
391
  path: [...basePath, "patch"],
393
- input: patchInputSchema,
392
+ input: withPartition(patchInputSchema),
394
393
  output: patchOutputSchema,
395
394
  metadata: {
396
395
  effects: { reads: [resource], writes: [resource], idempotent: true },
397
396
  description: `Merge fields into one item of ${collectionName}: a JSON Merge Patch, or the operations $set and $unset`,
398
397
  tags: ["collections", collectionName, "write"],
399
398
  },
400
- handler: async (input: PatchInput, ctx: ProcedureContext) => {
401
- const storage = getStorage(ctx);
399
+ handler: async (input: PatchInput & PartitionInput, ctx: ProcedureContext) => {
400
+ const { storage, partition } = await storageOf(input, ctx);
402
401
  const write = writeOptionsOf(input);
403
- const value = await changing(ctx, storage, [input.id], input.origin, () =>
402
+ const value = await changing(ctx, partition, storage, [input.id], input.origin, () =>
404
403
  storage.patch ? storage.patch(input.id, input.fields, write) : patchInParts(storage, input.id, input.fields, write),
405
404
  );
406
405
  const output: GetFoundOutput = value === undefined ? { found: false } : { found: true, value };
@@ -411,15 +410,15 @@ export function createCollectionProcedures(collectionName: string, options: Coll
411
410
  // Size
412
411
  defineProcedure({
413
412
  path: [...basePath, "size"],
414
- input: sizeInputSchema,
413
+ input: withPartition(sizeInputSchema),
415
414
  output: sizeOutputSchema,
416
415
  metadata: {
417
416
  effects: { reads: [resource], writes: [] },
418
417
  description: `Get number of items in ${collectionName}`,
419
418
  tags: ["collections", collectionName, "read"],
420
419
  },
421
- handler: async (_input: SizeInput, ctx: ProcedureContext) => {
422
- const storage = getStorage(ctx);
420
+ handler: async (input: SizeInput & PartitionInput, ctx: ProcedureContext) => {
421
+ const { storage } = await storageOf(input, ctx);
423
422
  return storage.size();
424
423
  },
425
424
  }),
@@ -427,31 +426,31 @@ export function createCollectionProcedures(collectionName: string, options: Coll
427
426
  // Clear
428
427
  defineProcedure({
429
428
  path: [...basePath, "clear"],
430
- input: clearInputSchema,
429
+ input: withPartition(clearInputSchema),
431
430
  output: clearOutputSchema,
432
431
  metadata: {
433
432
  effects: { reads: [resource], writes: [resource], idempotent: true },
434
433
  description: `Clear all items from ${collectionName}`,
435
434
  tags: ["collections", collectionName, "write"],
436
435
  },
437
- handler: async (input: ClearInput, ctx: ProcedureContext) => {
438
- const storage = getStorage(ctx);
439
- await changing(ctx, storage, "*", input.origin, () => storage.clear(writeOptionsOf(input)));
436
+ handler: async (input: ClearInput & PartitionInput, ctx: ProcedureContext) => {
437
+ const { storage, partition } = await storageOf(input, ctx);
438
+ await changing(ctx, partition, storage, "*", input.origin, () => storage.clear(writeOptionsOf(input)));
440
439
  },
441
440
  }),
442
441
 
443
442
  // GetBatch
444
443
  defineProcedure({
445
444
  path: [...basePath, "getBatch"],
446
- input: getBatchInputSchema,
445
+ input: withPartition(getBatchInputSchema),
447
446
  output: getBatchOutputSchema,
448
447
  metadata: {
449
448
  effects: { reads: [resource], writes: [] },
450
449
  description: `Get multiple items from ${collectionName}`,
451
450
  tags: ["collections", collectionName, "read", "batch"],
452
451
  },
453
- handler: async (input: GetBatchInput, ctx: ProcedureContext) => {
454
- const storage = getStorage(ctx);
452
+ handler: async (input: GetBatchInput & PartitionInput, ctx: ProcedureContext) => {
453
+ const { storage } = await storageOf(input, ctx);
455
454
  const result = await storage.getBatch(input.ids);
456
455
  // Convert Map to plain object
457
456
  return Object.fromEntries(result);
@@ -461,36 +460,36 @@ export function createCollectionProcedures(collectionName: string, options: Coll
461
460
  // SetBatch
462
461
  defineProcedure({
463
462
  path: [...basePath, "setBatch"],
464
- input: setBatchInputSchema,
463
+ input: withPartition(setBatchInputSchema),
465
464
  output: setBatchOutputSchema,
466
465
  metadata: {
467
466
  effects: { reads: [resource], writes: [resource], idempotent: true },
468
467
  description: `Set multiple items in ${collectionName}`,
469
468
  tags: ["collections", collectionName, "write", "batch"],
470
469
  },
471
- handler: async (input: SetBatchInput, ctx: ProcedureContext) => {
472
- const storage = getStorage(ctx);
470
+ handler: async (input: SetBatchInput & PartitionInput, ctx: ProcedureContext) => {
471
+ const { storage, partition } = await storageOf(input, ctx);
473
472
  const items: Array<[string, unknown]> = input.items.map((item) => [
474
473
  item.id,
475
474
  item.value,
476
475
  ]);
477
- await changing(ctx, storage, items.map(([id]) => id), input.origin, () => storage.setBatch(items, writeOptionsOf(input)));
476
+ await changing(ctx, partition, storage, items.map(([id]) => id), input.origin, () => storage.setBatch(items, writeOptionsOf(input)));
478
477
  },
479
478
  }),
480
479
 
481
480
  // DeleteBatch
482
481
  defineProcedure({
483
482
  path: [...basePath, "deleteBatch"],
484
- input: deleteBatchInputSchema,
483
+ input: withPartition(deleteBatchInputSchema),
485
484
  output: deleteBatchOutputSchema,
486
485
  metadata: {
487
486
  effects: { reads: [resource], writes: [resource], idempotent: true },
488
487
  description: `Delete multiple items from ${collectionName}`,
489
488
  tags: ["collections", collectionName, "write", "batch"],
490
489
  },
491
- handler: async (input: DeleteBatchInput, ctx: ProcedureContext) => {
492
- const storage = getStorage(ctx);
493
- return changing(ctx, storage, input.ids, input.origin, () => storage.deleteBatch(input.ids, writeOptionsOf(input)));
490
+ handler: async (input: DeleteBatchInput & PartitionInput, ctx: ProcedureContext) => {
491
+ const { storage, partition } = await storageOf(input, ctx);
492
+ return changing(ctx, partition, storage, input.ids, input.origin, () => storage.deleteBatch(input.ids, writeOptionsOf(input)));
494
493
  },
495
494
  }),
496
495
  ];
@@ -107,6 +107,9 @@ export type { ToolHintOverride } from "./hints.js";
107
107
  export { untrustedText, untrustedFieldsOf, untrustedTextsOf } from "./untrusted.js";
108
108
  export type { UntrustedText, UntrustedSource, UntrustedValue } from "./untrusted.js";
109
109
  export type { ToolHints, CapabilityRefusal, CapabilityRefusalReason, InvocationExplanation, EffectsViolation } from "./capability.js";
110
+ // The narrowing key of a call (CLI-30)
111
+ export { NARROWING_METADATA_KEY, NARROWING_HEADER, narrowingOf, narrowWithin, addNarrowing } from "./narrowing.js";
112
+ export type { NarrowingNote, WireNarrowing, NarrowedCapability } from "./narrowing.js";
110
113
 
111
114
  // Registry
112
115
  export { ProcedureRegistry, RegistryError, PROCEDURE_REGISTRY, assertValidPath } from "./registry.js";
@@ -127,6 +130,9 @@ export {
127
130
  createCollectionProcedures,
128
131
  collectionChangeTopic,
129
132
  collectionTopicReads,
133
+ PARTITION_PATTERN,
134
+ collectionTopicPartition,
135
+ collectionTopicTarget,
130
136
  DEFAULT_FIND_LIMIT,
131
137
  DEFAULT_QUERY_OPERATORS,
132
138
  DEFAULT_QUERY_POLICY,
@@ -146,6 +152,9 @@ export type {
146
152
  CollectionProceduresOptions,
147
153
  CollectionChangeEventsOptions,
148
154
  CollectionChangeEvent,
155
+ CollectionPartitions,
156
+ PartitionInput,
157
+ PartitionResolver,
149
158
  StoragePatchOperations,
150
159
  StorageQueryOperator,
151
160
  StorageQueryPolicy,
@@ -39,6 +39,7 @@ import { PROCEDURE_REGISTRY } from "./registry.js";
39
39
  import { nestedCallRefusal } from "./policy.js";
40
40
  import { scopeOf, withScope, type HydrationEvent } from "./ref.js";
41
41
  import { UNLIMITED_CAPABILITY, capabilityRefusal, examinesNestedCall, narrowCapability, type CapabilityRefusalReason } from "./capability.js";
42
+ import { NARROWING_METADATA_KEY, narrowWithin, narrowingOf, type NarrowingNote } from "./narrowing.js";
42
43
  import { timeoutAfterMs } from "./interceptors.js";
43
44
  import type { Elicit } from "./elicit.js";
44
45
  import type { ReportProgress } from "./progress.js";
@@ -73,8 +74,10 @@ export type InvocationErrorCode =
73
74
  * to such a host (`createHttpModule({ allow })` of `@procedures/http`).
74
75
  * - `topic-not-allowed`: the `allow` function of an `EventPublisher` refuses a topic of a
75
76
  * subscription (`documentation/EVENTS.md`).
77
+ * - `partition-not-allowed`: the partition resolver of the collection procedures refuses the
78
+ * partition of a call (roadmap CLI-17).
76
79
  */
77
- export type ForbiddenReason = CapabilityRefusalReason | "outside-root" | "host-not-allowed" | "topic-not-allowed";
80
+ export type ForbiddenReason = CapabilityRefusalReason | "outside-root" | "host-not-allowed" | "topic-not-allowed" | "partition-not-allowed";
78
81
 
79
82
  /** An error of the invocation itself. An error that the handler throws passes through unchanged. */
80
83
  export class InvocationError extends Error {
@@ -84,7 +87,7 @@ export class InvocationError extends Error {
84
87
  readonly retryable: boolean = false;
85
88
  /**
86
89
  * The reason of a `FORBIDDEN` error: `effects-undeclared`, `effects-exceeded`, `clearance`,
87
- * `deadline`, `outside-root`, `host-not-allowed` or `topic-not-allowed`.
90
+ * `deadline`, `outside-root`, `host-not-allowed`, `topic-not-allowed` or `partition-not-allowed`.
88
91
  */
89
92
  readonly reason: ForbiddenReason | undefined;
90
93
 
@@ -158,6 +161,18 @@ export interface InvokeOptions {
158
161
  * from its authentication. The nested calls get it, or a narrower one. Absent: no limits.
159
162
  */
160
163
  capability?: Capability | undefined;
164
+ /**
165
+ * A narrower capability that the caller asks for (CLI-30). The invocation of the host combines
166
+ * it with the narrowing key of `metadata` (`procedure-narrowing`) by the meet, so each source can
167
+ * only narrow. A part that asks for more has no effect. The nested calls get the narrowed
168
+ * capability.
169
+ */
170
+ narrow?: CapabilityRequest | undefined;
171
+ /**
172
+ * This function gets the notes of the narrowing of the call (CLI-30): each part that has no
173
+ * effect, and the part that refuses the call. A server gives them to its debug reports.
174
+ */
175
+ onNarrowingNotes?: ((notes: readonly NarrowingNote[]) => void) | undefined;
161
176
  /** The order of the clearance values. Narrowing two different clearances needs it. */
162
177
  clearanceOrder?: ClearanceOrder | undefined;
163
178
  /** The resources of an invocation for its capability (`ctx.resources`). */
@@ -306,6 +321,28 @@ async function* validatedItems(
306
321
  }
307
322
  }
308
323
 
324
+ /**
325
+ * The options of an invocation with its narrowing applied (CLI-30). The sources are the narrowing
326
+ * key of the metadata, for the call of a host only (the nested calls get the metadata of their
327
+ * caller), and the option `narrow`. They combine by the meet. A part that is not valid throws:
328
+ * `INVALID_REQUEST` for a part that is not well formed, `FORBIDDEN` (reason `clearance`) for a
329
+ * clearance outside the order or clearances without a meet.
330
+ */
331
+ function narrowedOptions(path: ProcedurePath, options: InvokeOptions): InvokeOptions {
332
+ const metadata = options.parent === undefined ? options.metadata : undefined;
333
+ const keyed = metadata !== undefined && Object.hasOwn(metadata, NARROWING_METADATA_KEY);
334
+ if (!keyed && !options.narrow) return options;
335
+ const wire = metadata !== undefined && keyed ? narrowingOf(metadata[NARROWING_METADATA_KEY]) : narrowingOf(undefined);
336
+ const sources = options.narrow ? [...wire.narrowings, options.narrow] : wire.narrowings;
337
+ const narrowed = narrowWithin(options.capability, sources, options.clearanceOrder);
338
+ const notes = [...wire.notes, ...narrowed.notes];
339
+ if (notes.length > 0) options.onNarrowingNotes?.(notes);
340
+ const refusal = notes.find((note) => note.kind === "refused");
341
+ if (refusal?.code === "FORBIDDEN") throw new InvocationError("FORBIDDEN", refusal.message, path, "clearance");
342
+ if (refusal) throw Object.assign(new Error(refusal.message), { code: "INVALID_REQUEST" });
343
+ return { ...options, narrow: undefined, capability: narrowed.capability };
344
+ }
345
+
309
346
  /**
310
347
  * This function runs a procedure: it validates the input, makes the context, runs the handler
311
348
  * and validates the output. It throws an `InvocationError` for an invocation that cannot run,
@@ -318,6 +355,9 @@ export async function invokeProcedure<TOutput = unknown>(
318
355
  options: InvokeOptions = {},
319
356
  ): Promise<ProcedureOutput<TOutput>> {
320
357
  const { path } = procedure;
358
+ // The narrowing of the call applies one time, here, for each face and each path of the process:
359
+ // the nested calls get the result (CLI-30)
360
+ options = narrowedOptions(path, options);
321
361
  if (!procedure.handler) {
322
362
  throw new InvocationError("NO_HANDLER", `Procedure has no handler: ${pathToKey(path)}`, path);
323
363
  }