@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.
- package/CHANGELOG.md +20 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +4 -12
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -1
- package/dist/events/publisher.d.ts +11 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +27 -8
- package/dist/events/publisher.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +3 -3
- package/dist/procedures/collection/changes.d.ts.map +1 -1
- package/dist/procedures/collection/changes.js +4 -4
- package/dist/procedures/collection/changes.js.map +1 -1
- package/dist/procedures/collection/index.d.ts +2 -0
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +2 -0
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/partition.d.ts +67 -0
- package/dist/procedures/collection/partition.d.ts.map +1 -0
- package/dist/procedures/collection/partition.js +82 -0
- package/dist/procedures/collection/partition.js.map +1 -0
- package/dist/procedures/collection/procedures.d.ts +4 -4
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +55 -55
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/index.d.ts +2 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +1 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts +4 -2
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +1 -1
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/types.d.ts +13 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/server/procedure-server.d.ts +30 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +59 -11
- package/dist/server/procedure-server.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/websocket/client/transport.ts +4 -12
- package/src/browser.ts +6 -0
- package/src/events/publisher.ts +29 -9
- package/src/procedures/collection/changes.ts +4 -4
- package/src/procedures/collection/index.ts +4 -0
- package/src/procedures/collection/partition.ts +123 -0
- package/src/procedures/collection/procedures.ts +72 -73
- package/src/procedures/index.ts +5 -0
- package/src/procedures/invoke.ts +4 -2
- package/src/procedures/types.ts +16 -0
- package/src/server/procedure-server.ts +65 -12
package/src/events/publisher.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
174
|
-
bus.on(
|
|
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
|
|
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
|
|
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
|
|
60
|
-
* partition
|
|
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.
|
|
104
|
-
*
|
|
105
|
-
* @default
|
|
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)
|
|
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
|
|
221
|
-
const storage =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
289
|
-
const storage =
|
|
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 (
|
|
305
|
-
const storage =
|
|
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 (
|
|
324
|
-
const storage =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
422
|
-
const storage =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
];
|
package/src/procedures/index.ts
CHANGED
|
@@ -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,
|