@procedures/client 0.8.1 → 0.9.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 (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  4. package/dist/adapters/websocket/client/transport.js +4 -12
  5. package/dist/adapters/websocket/client/transport.js.map +1 -1
  6. package/dist/browser.d.ts +2 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +2 -0
  9. package/dist/browser.js.map +1 -1
  10. package/dist/events/publisher.d.ts +11 -1
  11. package/dist/events/publisher.d.ts.map +1 -1
  12. package/dist/events/publisher.js +27 -8
  13. package/dist/events/publisher.js.map +1 -1
  14. package/dist/procedures/collection/changes.d.ts +3 -3
  15. package/dist/procedures/collection/changes.d.ts.map +1 -1
  16. package/dist/procedures/collection/changes.js +4 -4
  17. package/dist/procedures/collection/changes.js.map +1 -1
  18. package/dist/procedures/collection/index.d.ts +2 -0
  19. package/dist/procedures/collection/index.d.ts.map +1 -1
  20. package/dist/procedures/collection/index.js +2 -0
  21. package/dist/procedures/collection/index.js.map +1 -1
  22. package/dist/procedures/collection/partition.d.ts +67 -0
  23. package/dist/procedures/collection/partition.d.ts.map +1 -0
  24. package/dist/procedures/collection/partition.js +82 -0
  25. package/dist/procedures/collection/partition.js.map +1 -0
  26. package/dist/procedures/collection/procedures.d.ts +4 -4
  27. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  28. package/dist/procedures/collection/procedures.js +55 -55
  29. package/dist/procedures/collection/procedures.js.map +1 -1
  30. package/dist/procedures/index.d.ts +2 -2
  31. package/dist/procedures/index.d.ts.map +1 -1
  32. package/dist/procedures/index.js +1 -1
  33. package/dist/procedures/index.js.map +1 -1
  34. package/dist/procedures/invoke.d.ts +4 -2
  35. package/dist/procedures/invoke.d.ts.map +1 -1
  36. package/dist/procedures/invoke.js +1 -1
  37. package/dist/procedures/invoke.js.map +1 -1
  38. package/dist/procedures/types.d.ts +13 -0
  39. package/dist/procedures/types.d.ts.map +1 -1
  40. package/dist/procedures/types.js.map +1 -1
  41. package/dist/server/procedure-server.d.ts +30 -1
  42. package/dist/server/procedure-server.d.ts.map +1 -1
  43. package/dist/server/procedure-server.js +59 -11
  44. package/dist/server/procedure-server.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/adapters/websocket/client/transport.ts +4 -12
  47. package/src/browser.ts +6 -0
  48. package/src/events/publisher.ts +29 -9
  49. package/src/procedures/collection/changes.ts +4 -4
  50. package/src/procedures/collection/index.ts +4 -0
  51. package/src/procedures/collection/partition.ts +123 -0
  52. package/src/procedures/collection/procedures.ts +72 -73
  53. package/src/procedures/index.ts +5 -0
  54. package/src/procedures/invoke.ts +4 -2
  55. package/src/procedures/types.ts +16 -0
  56. package/src/server/procedure-server.ts +65 -12
@@ -28,8 +28,10 @@ import { z } from "zod";
28
28
  import { createProcedure } from "../procedures/define.js";
29
29
  import { InvocationError } from "../procedures/invoke.js";
30
30
  import { capabilityRefusal } from "../procedures/capability.js";
31
- import { collectionTopicReads } from "../procedures/collection/changes.js";
31
+ import { collectionChangeTopic, collectionTopicReads } from "../procedures/collection/changes.js";
32
+ import { collectionTopicPartition, partitionOfCall } from "../procedures/collection/partition.js";
32
33
  import type { AnyProcedure, Capability, ProcedureContext } from "../procedures/types.js";
34
+ import type { Principal } from "../server/types.js";
33
35
  import type { EventBus, Unsubscribe } from "./types.js";
34
36
  import { getGlobalEventBus } from "./bus.js";
35
37
  import { EVENTS_SUBSCRIBE_PATH, type EventItem, type SubscribeInput } from "./remote.js";
@@ -53,6 +55,8 @@ export interface TopicCall {
53
55
  readonly capability: Capability;
54
56
  /** The metadata of the call: the connection id and the headers of a network request. */
55
57
  readonly metadata: Readonly<Record<string, unknown>>;
58
+ /** The principal of the call, from the authentication of the host. */
59
+ readonly principal?: Principal | undefined;
56
60
  }
57
61
 
58
62
  /** The options of an `EventPublisher`. */
@@ -161,17 +165,18 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
161
165
 
162
166
  constructor(
163
167
  bus: EventBus,
164
- topics: readonly string[],
168
+ /** The topics of the subscriber, each with the topic of the bus that it listens to */
169
+ routes: ReadonlyArray<readonly [topic: string, busTopic: string]>,
165
170
  private readonly buffer: number,
166
171
  private readonly signal: AbortSignal | undefined,
167
172
  private readonly onClose: (subscription: TopicSubscription) => void,
168
173
  /** The output filter of the host for this subscriber: each row of a value passes it */
169
174
  private readonly admit: ((item: unknown) => Promise<boolean>) | undefined,
170
175
  ) {
171
- this.confirmation = { type: "subscribed", topics };
176
+ this.confirmation = { type: "subscribed", topics: routes.map(([topic]) => topic) };
172
177
  // Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
173
- this.unsubscribes = topics.map((topic) =>
174
- bus.on(topic, (data) => this.push(topic, data), { onClear: () => this.fail(clearedError(topic), { keepQueue: true }) }),
178
+ this.unsubscribes = routes.map(([topic, busTopic]) =>
179
+ bus.on(busTopic, (data) => this.push(topic, data), { onClear: () => this.fail(clearedError(topic), { keepQueue: true }) }),
175
180
  );
176
181
  signal?.addEventListener("abort", this.onAbort, { once: true });
177
182
  }
@@ -344,11 +349,24 @@ export class EventPublisher {
344
349
  for (const subscription of [...this.open]) subscription.fail(stoppedError());
345
350
  }
346
351
 
352
+ /**
353
+ * The topic of the bus for a topic of a subscriber (roadmap CLI-17). A topic of a partitioned
354
+ * collection names the partition that the subscriber asks for, as the input of a procedure does:
355
+ * the resolver of the repository gives the partition, so a subscriber cannot name the topic of
356
+ * another tenant. A collection without partitions refuses a topic with a partition.
357
+ */
358
+ private async busTopic(topic: string, ctx: ProcedureContext): Promise<string> {
359
+ const parts = collectionTopicPartition(topic);
360
+ if (!parts) return topic;
361
+ const partition = await partitionOfCall(ctx, parts.collection, parts.partition);
362
+ return collectionChangeTopic(parts.collection, partition);
363
+ }
364
+
347
365
  /**
348
366
  * The stream of one subscription. The publisher examines each topic before it adds a listener:
349
- * the topic rule first, then `allow`. A call with one bad topic makes no subscription.
367
+ * the topic rule, the reads, `allow`, and the partition. A call with one bad topic makes no subscription.
350
368
  */
351
- private subscribe(topics: readonly string[], ctx: ProcedureContext): TopicSubscription {
369
+ private async subscribe(topics: readonly string[], ctx: ProcedureContext): Promise<TopicSubscription> {
352
370
  const unique = [...new Set(topics)];
353
371
  for (const topic of unique) {
354
372
  if (!this.known(topic)) throw new TopicNotFoundError(`The host has no topic "${topic}"`);
@@ -361,14 +379,16 @@ export class EventPublisher {
361
379
  if (refusal) throw new InvocationError("FORBIDDEN", `The topic "${topic}": ${refusal.message}`, ctx.path, refusal.reason);
362
380
  }
363
381
  if (this.allow) {
364
- const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata };
382
+ const call: TopicCall = { capability: ctx.capability, metadata: ctx.metadata, ...(ctx.principal ? { principal: ctx.principal } : {}) };
365
383
  for (const topic of unique) {
366
384
  if (!this.allow(topic, call)) {
367
385
  throw new InvocationError("FORBIDDEN", `The capability of the call does not permit the topic "${topic}"`, ctx.path, "topic-not-allowed");
368
386
  }
369
387
  }
370
388
  }
371
- const subscription = new TopicSubscription(this.bus, unique, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
389
+ const routes: Array<readonly [string, string]> = [];
390
+ for (const topic of unique) routes.push([topic, await this.busTopic(topic, ctx)]);
391
+ const subscription = new TopicSubscription(this.bus, routes, this.buffer, ctx.signal, (closed) => this.open.delete(closed), ctx.admit);
372
392
  this.open.add(subscription);
373
393
  return subscription.closeIfAborted();
374
394
  }
@@ -56,11 +56,11 @@ export interface CollectionChangeEvent {
56
56
  }
57
57
 
58
58
  /**
59
- * This function gives the topic of the change events of a collection: `collections.<name>`. The
60
- * partition of roadmap CLI-17 comes after the name, with the option `topic` of the procedures.
59
+ * This function gives the topic of the change events of a collection: `collections.<name>`, or
60
+ * `collections.<name>.<partition>` for a partition (roadmap CLI-17).
61
61
  */
62
- export function collectionChangeTopic(collection: string): string {
63
- return `collections.${collection}`;
62
+ export function collectionChangeTopic(collection: string, partition?: string): string {
63
+ return partition === undefined ? `collections.${collection}` : `collections.${collection}.${partition}`;
64
64
  }
65
65
 
66
66
  /**
@@ -70,6 +70,10 @@ export type { CollectionProceduresOptions, CollectionChangeEventsOptions } from
70
70
  export { collectionChangeTopic, collectionTopicReads } from "./changes.js";
71
71
  export type { CollectionChangeEvent } from "./changes.js";
72
72
 
73
+ // The partitions of a collection (roadmap CLI-17)
74
+ export { PARTITION_PATTERN, collectionTopicPartition, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
75
+ export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
76
+
73
77
  // The storage interface of the core (the shape of client-collections' CollectionStorage)
74
78
  export type {
75
79
  CollectionStorage,
@@ -0,0 +1,123 @@
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 } 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
+ export type PartitionResolver = (ctx: ProcedureContext, requested: string | undefined) => string | undefined | null | Promise<string | undefined | null>;
36
+
37
+ /**
38
+ * This function adds the field `partition` to the input schema of a collection procedure. The
39
+ * repository of the call decides at the call if the collection has partitions.
40
+ */
41
+ export function withPartition<T>(schema: ZodLike<T>): ZodLike<T & PartitionInput> {
42
+ const validate = (data: unknown): { success: true; data: T & PartitionInput } | { success: false; error: { message: string; errors: { path: (string | number)[]; message: string }[] } } => {
43
+ const fail = (message: string) => ({ success: false as const, error: { message, errors: [{ path: ["partition"], message }] } });
44
+ const requested = typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record<string, unknown>)["partition"] : undefined;
45
+ if (requested !== undefined && (typeof requested !== "string" || !PARTITION_PATTERN.test(requested))) {
46
+ return fail("partition must have 1 to 256 letters, digits or the characters _ . : @ ~ + | / -");
47
+ }
48
+ const inner = schema.safeParse(data);
49
+ if (!inner.success) return inner as { success: false; error: { message: string; errors: { path: (string | number)[]; message: string }[] } };
50
+ const parsed = inner.data as T & PartitionInput;
51
+ return { success: true, data: requested === undefined ? parsed : { ...parsed, partition: requested as string } };
52
+ };
53
+ return {
54
+ parse(data: unknown) {
55
+ const result = validate(data);
56
+ if (result.success) return result.data;
57
+ throw new Error(result.error.message);
58
+ },
59
+ safeParse: validate,
60
+ } as ZodLike<T & PartitionInput>;
61
+ }
62
+
63
+ /** The partitions of a collection: the storage of each partition, and the resolver of a call. */
64
+ export interface CollectionPartitions<T = unknown> {
65
+ /**
66
+ * The storage of a partition. Give the same storage for the same partition each time: the change
67
+ * events of a storage keep their order with it.
68
+ */
69
+ storageOf(partition: string): CollectionStorage<T>;
70
+ /** The resolver of the partition of a call. */
71
+ resolve: PartitionResolver;
72
+ }
73
+
74
+ /** The refusal of a partition. */
75
+ function partitionRefused(ctx: ProcedureContext, message: string): InvocationError {
76
+ return new InvocationError("FORBIDDEN", message, ctx.path, "partition-not-allowed");
77
+ }
78
+
79
+ /**
80
+ * This function gives the partition of a call to a collection: the resolver of the repository
81
+ * gives it from the context and the requested partition. It throws `FORBIDDEN` (reason
82
+ * `partition-not-allowed`) when the resolver refuses, or when the collection has no partitions and
83
+ * the call asks for one.
84
+ */
85
+ export async function partitionOfCall(ctx: ProcedureContext, collection: string, requested: string | undefined): Promise<string | undefined> {
86
+ const repository = ctx.repository;
87
+ if (!repository?.hasPartitions?.(collection) || !repository.resolvePartition) {
88
+ if (requested !== undefined) throw partitionRefused(ctx, `The collection ${collection} has no partitions`);
89
+ return undefined;
90
+ }
91
+ const partition = await repository.resolvePartition(collection, ctx, requested);
92
+ if (partition === null) {
93
+ throw partitionRefused(ctx, `The call cannot use the partition${requested === undefined ? "" : ` "${requested}"`} of ${collection}`);
94
+ }
95
+ if (partition !== undefined && !PARTITION_PATTERN.test(partition)) {
96
+ throw new Error(`The partition resolver gave a partition that is not valid: ${JSON.stringify(partition).slice(0, 80)}`);
97
+ }
98
+ return partition;
99
+ }
100
+
101
+ /**
102
+ * This function gives the storage of a call: of the collection, or of its partition. A repository
103
+ * without `getPartitionStorage` refuses a partition.
104
+ */
105
+ export function storageOfCall<T>(ctx: ProcedureContext, collection: string, partition: string | undefined): CollectionStorage<T> {
106
+ const repository = ctx.repository;
107
+ if (!repository) throw new Error("Repository not available in procedure context");
108
+ if (partition === undefined) return repository.getStorage<T>(collection);
109
+ if (!repository.getPartitionStorage) {
110
+ throw new Error(`The repository has no partitions, so it cannot give the partition "${partition}" of ${collection}`);
111
+ }
112
+ return repository.getPartitionStorage<T>(collection, partition);
113
+ }
114
+
115
+ /**
116
+ * This function gives the collection and the partition part of a topic `collections.<name>` or
117
+ * `collections.<name>.<partition>`, or undefined for another topic.
118
+ */
119
+ export function collectionTopicPartition(topic: string): { collection: string; partition: string | undefined } | undefined {
120
+ const match = /^collections\.([^.]+)(?:\.(.+))?$/.exec(topic);
121
+ if (!match) return undefined;
122
+ return { collection: match[1]!, partition: match[2] };
123
+ }
@@ -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
  ];
@@ -127,6 +127,8 @@ export {
127
127
  createCollectionProcedures,
128
128
  collectionChangeTopic,
129
129
  collectionTopicReads,
130
+ PARTITION_PATTERN,
131
+ collectionTopicPartition,
130
132
  DEFAULT_FIND_LIMIT,
131
133
  DEFAULT_QUERY_OPERATORS,
132
134
  DEFAULT_QUERY_POLICY,
@@ -146,6 +148,9 @@ export type {
146
148
  CollectionProceduresOptions,
147
149
  CollectionChangeEventsOptions,
148
150
  CollectionChangeEvent,
151
+ CollectionPartitions,
152
+ PartitionInput,
153
+ PartitionResolver,
149
154
  StoragePatchOperations,
150
155
  StorageQueryOperator,
151
156
  StorageQueryPolicy,