@procedures/client 0.14.1 → 0.15.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.
- package/CHANGELOG.md +93 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +4 -0
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +8 -3
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/local/client/transport.d.ts.map +1 -1
- package/dist/adapters/local/client/transport.js +5 -0
- package/dist/adapters/local/client/transport.js.map +1 -1
- package/dist/browser.d.ts +4 -3
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -1
- package/dist/client/client.js +1 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +13 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/middleware/rate-limit.d.ts +17 -0
- package/dist/client/middleware/rate-limit.d.ts.map +1 -1
- package/dist/client/middleware/rate-limit.js +94 -39
- package/dist/client/middleware/rate-limit.js.map +1 -1
- package/dist/client/types.d.ts +6 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +7 -1
- package/dist/client/types.js.map +1 -1
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts +46 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +95 -5
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/recheck.d.ts +84 -0
- package/dist/events/recheck.d.ts.map +1 -0
- package/dist/events/recheck.js +135 -0
- package/dist/events/recheck.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +3 -1
- 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/precondition.d.ts +111 -0
- package/dist/procedures/collection/precondition.d.ts.map +1 -0
- package/dist/procedures/collection/precondition.js +153 -0
- package/dist/procedures/collection/precondition.js.map +1 -0
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +16 -3
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/query.d.ts +2 -0
- package/dist/procedures/collection/query.d.ts.map +1 -1
- package/dist/procedures/collection/query.js +3 -0
- package/dist/procedures/collection/query.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +16 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +26 -5
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +34 -6
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/collection/storage.js +3 -0
- package/dist/procedures/collection/storage.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/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/host-policy.d.ts +17 -0
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +7 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/limits.d.ts +122 -0
- package/dist/server/limits.d.ts.map +1 -0
- package/dist/server/limits.js +153 -0
- package/dist/server/limits.js.map +1 -0
- package/dist/server/procedure-server.d.ts +31 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +43 -2
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/request-auth.d.ts +5 -1
- package/dist/server/request-auth.d.ts.map +1 -1
- package/dist/server/request-auth.js +19 -2
- package/dist/server/request-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/http/client/transport.ts +3 -0
- package/src/adapters/http/server/handler.ts +8 -3
- package/src/adapters/local/client/transport.ts +4 -0
- package/src/browser.ts +20 -0
- package/src/client/client.ts +1 -1
- package/src/client/errors/registry.ts +14 -0
- package/src/client/middleware/rate-limit.ts +116 -41
- package/src/client/types.ts +8 -0
- package/src/events/index.ts +1 -0
- package/src/events/publisher.ts +116 -6
- package/src/events/recheck.ts +182 -0
- package/src/procedures/collection/index.ts +17 -0
- package/src/procedures/collection/precondition.ts +180 -0
- package/src/procedures/collection/procedures.ts +17 -4
- package/src/procedures/collection/query.ts +16 -0
- package/src/procedures/collection/schemas.ts +43 -6
- package/src/procedures/collection/storage.ts +36 -6
- package/src/procedures/index.ts +15 -0
- package/src/protocol.ts +5 -1
- package/src/server/host-policy.ts +22 -0
- package/src/server/limits.ts +254 -0
- package/src/server/procedure-server.ts +59 -3
- package/src/server/request-auth.ts +19 -2
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
type DeleteBatchInput,
|
|
58
58
|
} from "./schemas.js";
|
|
59
59
|
import { DEFAULT_FIND_LIMIT, queryEntries, type StorageQueryOptions, type StorageQueryPolicy } from "./query.js";
|
|
60
|
-
import type { CollectionStorage, StorageWriteOptions } from "./storage.js";
|
|
60
|
+
import type { CollectionStorage, StorageBatchWriteOptions, StorageItemWriteOptions, StorageWriteOptions } from "./storage.js";
|
|
61
61
|
import { collectionChangeTopic, startChangeChain, writeWithChangeEvent } from "./changes.js";
|
|
62
62
|
import { partitionOfCall, storageOfCall, withPartition, type PartitionInput } from "./partition.js";
|
|
63
63
|
import { admitsRow, admittedStorage, type CollectionAdmission } from "./admission.js";
|
|
@@ -138,6 +138,19 @@ function writeOptionsOf(input: { origin?: string }): StorageWriteOptions | undef
|
|
|
138
138
|
return input.origin === undefined ? undefined : { origin: input.origin };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** The write options of `set` and `delete`: the origin, and the expected version (roadmap CLI-16). */
|
|
142
|
+
function itemWriteOptionsOf(input: { origin?: string; expectedVersion?: string | null }): StorageItemWriteOptions | undefined {
|
|
143
|
+
if (input.expectedVersion === undefined) return writeOptionsOf(input);
|
|
144
|
+
return { ...writeOptionsOf(input), expectedVersion: input.expectedVersion };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The write options of `setBatch`: the origin, and the expected version of each item that has one (roadmap CLI-16). */
|
|
148
|
+
function batchWriteOptionsOf(input: SetBatchInput): StorageBatchWriteOptions | undefined {
|
|
149
|
+
const expectedVersions = input.items.flatMap((item): Array<[string, string | null]> => (item.expectedVersion === undefined ? [] : [[item.id, item.expectedVersion]]));
|
|
150
|
+
if (expectedVersions.length === 0) return writeOptionsOf(input);
|
|
151
|
+
return { ...writeOptionsOf(input), expectedVersions };
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
// =============================================================================
|
|
142
155
|
// Collection Procedure Definitions
|
|
143
156
|
// =============================================================================
|
|
@@ -216,7 +229,7 @@ export function createCollectionProcedures(collectionName: string, options: Coll
|
|
|
216
229
|
},
|
|
217
230
|
handler: async (input: SetInput & PartitionInput, ctx: ProcedureContext) => {
|
|
218
231
|
const { storage, view, partition } = await storageOf(input, ctx);
|
|
219
|
-
await changing(ctx, partition, storage, [input.id], input.origin, () => view.set(input.id, input.value,
|
|
232
|
+
await changing(ctx, partition, storage, [input.id], input.origin, () => view.set(input.id, input.value, itemWriteOptionsOf(input)));
|
|
220
233
|
},
|
|
221
234
|
}),
|
|
222
235
|
|
|
@@ -232,7 +245,7 @@ export function createCollectionProcedures(collectionName: string, options: Coll
|
|
|
232
245
|
},
|
|
233
246
|
handler: async (input: DeleteInput & PartitionInput, ctx: ProcedureContext) => {
|
|
234
247
|
const { storage, view, partition } = await storageOf(input, ctx);
|
|
235
|
-
return changing(ctx, partition, storage, [input.id], input.origin, () => view.delete(input.id,
|
|
248
|
+
return changing(ctx, partition, storage, [input.id], input.origin, () => view.delete(input.id, itemWriteOptionsOf(input)));
|
|
236
249
|
},
|
|
237
250
|
}),
|
|
238
251
|
|
|
@@ -451,7 +464,7 @@ export function createCollectionProcedures(collectionName: string, options: Coll
|
|
|
451
464
|
item.id,
|
|
452
465
|
item.value,
|
|
453
466
|
]);
|
|
454
|
-
await changing(ctx, partition, storage, items.map(([id]) => id), input.origin, () => view.setBatch(items,
|
|
467
|
+
await changing(ctx, partition, storage, items.map(([id]) => id), input.origin, () => view.setBatch(items, batchWriteOptionsOf(input)));
|
|
455
468
|
},
|
|
456
469
|
}),
|
|
457
470
|
|
|
@@ -31,6 +31,22 @@
|
|
|
31
31
|
* regular expressions and the limit. The default policy does not permit `$regex`.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
// The precondition of the collection writes is a part of the wire contract too (roadmap CLI-16).
|
|
35
|
+
// This entry gives it to the storage packages: the module has no imports.
|
|
36
|
+
export {
|
|
37
|
+
MAX_EXPECTED_VERSION_LENGTH,
|
|
38
|
+
PRECONDITION_NOT_SUPPORTED,
|
|
39
|
+
VERSION_CONFLICTS_METADATA_KEY,
|
|
40
|
+
VersionConflictError,
|
|
41
|
+
asVersionConflictError,
|
|
42
|
+
isExpectedVersion,
|
|
43
|
+
preconditionNotSupported,
|
|
44
|
+
versionConflict,
|
|
45
|
+
versionConflicts,
|
|
46
|
+
versionConflictsOf,
|
|
47
|
+
} from "./precondition.js";
|
|
48
|
+
export type { CurrentItem, VersionConflict, VersionOf } from "./precondition.js";
|
|
49
|
+
|
|
34
50
|
//
|
|
35
51
|
// ═══ The types ═══
|
|
36
52
|
//
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type StorageSelector,
|
|
17
17
|
type StorageSort,
|
|
18
18
|
} from "./query.js";
|
|
19
|
+
import { MAX_EXPECTED_VERSION_LENGTH, isExpectedVersion } from "./precondition.js";
|
|
19
20
|
|
|
20
21
|
// =============================================================================
|
|
21
22
|
// Generic Schema Builder
|
|
@@ -83,6 +84,25 @@ function withOriginOf<I extends object>(data: Record<string, unknown>, input: I)
|
|
|
83
84
|
return (isString(origin) ? { ...input, origin } : input) as I & { origin?: string };
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** The message of an expected version that is not valid (roadmap CLI-16). */
|
|
88
|
+
const EXPECTED_VERSION_MESSAGE = `expectedVersion must be a string of at most ${MAX_EXPECTED_VERSION_LENGTH} characters, or null`;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The error of the field `expectedVersion` of a write input (roadmap CLI-16), or undefined. The
|
|
92
|
+
* field is absent, a string of at most 1024 characters, or `null`.
|
|
93
|
+
*/
|
|
94
|
+
function expectedVersionError(data: Record<string, unknown>, path: (string | number)[] = []): ZodErrorLike | undefined {
|
|
95
|
+
if (!("expectedVersion" in data) || data["expectedVersion"] === undefined || isExpectedVersion(data["expectedVersion"])) return undefined;
|
|
96
|
+
const message = path.length === 0 ? EXPECTED_VERSION_MESSAGE : `${path.join(".")}.${EXPECTED_VERSION_MESSAGE}`;
|
|
97
|
+
return { message, errors: [{ path: [...path, "expectedVersion"], message }] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The input with the expected version of the data, when the data has one. */
|
|
101
|
+
function withExpectedVersionOf<I extends object>(data: Record<string, unknown>, input: I): I & { expectedVersion?: string | null } {
|
|
102
|
+
const expected = data["expectedVersion"];
|
|
103
|
+
return (isExpectedVersion(expected) ? { ...input, expectedVersion: expected } : input) as I & { expectedVersion?: string | null };
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
// =============================================================================
|
|
87
107
|
// Common Schemas
|
|
88
108
|
// =============================================================================
|
|
@@ -217,6 +237,11 @@ export interface SetInput {
|
|
|
217
237
|
value: unknown;
|
|
218
238
|
/** The origin of the write, for the observers of the storage and the change event (roadmap CLI-15). */
|
|
219
239
|
origin?: string;
|
|
240
|
+
/**
|
|
241
|
+
* The version that the write expects for the current item (roadmap CLI-16). `null` expects no
|
|
242
|
+
* item. A conflict changes nothing and answers `VERSION_CONFLICT` with the current item.
|
|
243
|
+
*/
|
|
244
|
+
expectedVersion?: string | null;
|
|
220
245
|
}
|
|
221
246
|
|
|
222
247
|
export const setInputSchema : ZodLike<SetInput> = createSchema<SetInput>((data) => {
|
|
@@ -247,9 +272,9 @@ export const setInputSchema : ZodLike<SetInput> = createSchema<SetInput>((data)
|
|
|
247
272
|
},
|
|
248
273
|
};
|
|
249
274
|
}
|
|
250
|
-
const error = originError(data);
|
|
275
|
+
const error = originError(data) ?? expectedVersionError(data);
|
|
251
276
|
if (error) return { success: false, error };
|
|
252
|
-
return { success: true, data: withOriginOf(data, { id: data["id"], value: data["value"] }) };
|
|
277
|
+
return { success: true, data: withExpectedVersionOf(data, withOriginOf(data, { id: data["id"], value: data["value"] })) };
|
|
253
278
|
});
|
|
254
279
|
|
|
255
280
|
/**
|
|
@@ -270,6 +295,11 @@ export interface DeleteInput {
|
|
|
270
295
|
id: string;
|
|
271
296
|
/** The origin of the write, for the observers of the storage and the change event (roadmap CLI-15). */
|
|
272
297
|
origin?: string;
|
|
298
|
+
/**
|
|
299
|
+
* The version that the delete expects for the current item (roadmap CLI-16). `null` expects no
|
|
300
|
+
* item. A conflict changes nothing and answers `VERSION_CONFLICT` with the current item.
|
|
301
|
+
*/
|
|
302
|
+
expectedVersion?: string | null;
|
|
273
303
|
}
|
|
274
304
|
|
|
275
305
|
export const deleteInputSchema : ZodLike<DeleteInput> = createSchema<DeleteInput>((data) => {
|
|
@@ -291,9 +321,9 @@ export const deleteInputSchema : ZodLike<DeleteInput> = createSchema<DeleteInput
|
|
|
291
321
|
},
|
|
292
322
|
};
|
|
293
323
|
}
|
|
294
|
-
const error = originError(data);
|
|
324
|
+
const error = originError(data) ?? expectedVersionError(data);
|
|
295
325
|
if (error) return { success: false, error };
|
|
296
|
-
return { success: true, data: withOriginOf(data, { id: data["id"] }) };
|
|
326
|
+
return { success: true, data: withExpectedVersionOf(data, withOriginOf(data, { id: data["id"] })) };
|
|
297
327
|
});
|
|
298
328
|
|
|
299
329
|
/**
|
|
@@ -810,7 +840,12 @@ export const getBatchOutputSchema : ZodLike<Record<string, unknown>> = createSch
|
|
|
810
840
|
* Input for setBatch operation.
|
|
811
841
|
*/
|
|
812
842
|
export interface SetBatchInput {
|
|
813
|
-
|
|
843
|
+
/**
|
|
844
|
+
* The items. An item can have `expectedVersion`: the version that the write expects for the
|
|
845
|
+
* current item of its id (roadmap CLI-16). When one item has a conflict, the batch changes no
|
|
846
|
+
* item and answers `VERSION_CONFLICT`.
|
|
847
|
+
*/
|
|
848
|
+
items: Array<{ id: string; value: unknown; expectedVersion?: string | null }>;
|
|
814
849
|
/** The origin of the write, for the observers of the storage and the change event (roadmap CLI-15). */
|
|
815
850
|
origin?: string;
|
|
816
851
|
}
|
|
@@ -845,13 +880,15 @@ export const setBatchInputSchema : ZodLike<SetBatchInput> = createSchema<SetBatc
|
|
|
845
880
|
},
|
|
846
881
|
};
|
|
847
882
|
}
|
|
883
|
+
const versionError = expectedVersionError(item, ["items", i]);
|
|
884
|
+
if (versionError) return { success: false, error: versionError };
|
|
848
885
|
}
|
|
849
886
|
const error = originError(data);
|
|
850
887
|
if (error) return { success: false, error };
|
|
851
888
|
return {
|
|
852
889
|
success: true,
|
|
853
890
|
data: withOriginOf(data, {
|
|
854
|
-
items: data["items"].map((item: any) => ({ id: item["id"], value: item["value"] })),
|
|
891
|
+
items: data["items"].map((item: any) => withExpectedVersionOf(item, { id: item["id"], value: item["value"] })),
|
|
855
892
|
}),
|
|
856
893
|
};
|
|
857
894
|
});
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* - `query()` gives the result of `queryEntries()` of `@procedures/collections` for the entries.
|
|
21
21
|
* - `observe()` gives one batch for each write that changes the storage, before the write resolves.
|
|
22
22
|
* - `patch()` merges the fields into the item in one step.
|
|
23
|
+
* - A write with `expectedVersion` (`set`, `delete`) or `expectedVersions` (`setBatch`) tests the
|
|
24
|
+
* version of the current item and writes in one step. A conflict changes nothing and rejects
|
|
25
|
+
* with `VersionConflictError` (roadmap CLI-16).
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
28
|
import type { StoragePatch, StorageQueryOptions, StorageQueryPolicy, StorageSelector } from "./query.js";
|
|
@@ -76,10 +79,13 @@ export interface CollectionStorage<T> {
|
|
|
76
79
|
has(id: string): Promise<boolean>;
|
|
77
80
|
/** The number of items. */
|
|
78
81
|
size(): Promise<number>;
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Set the item of an id. The options give the origin of the write to the observers, and the
|
|
84
|
+
* expected version of the current item (roadmap CLI-16).
|
|
85
|
+
*/
|
|
86
|
+
set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void>;
|
|
87
|
+
/** Delete the item of an id. True when it existed. The options can expect a version, as for `set`. */
|
|
88
|
+
delete(id: string, options?: StorageItemWriteOptions): Promise<boolean>;
|
|
83
89
|
/** Delete every item. */
|
|
84
90
|
clear(options?: StorageWriteOptions): Promise<void>;
|
|
85
91
|
/**
|
|
@@ -88,8 +94,8 @@ export interface CollectionStorage<T> {
|
|
|
88
94
|
* procedure `patch` uses it. A storage without it gets a read and a write of the item.
|
|
89
95
|
*/
|
|
90
96
|
patch?(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined>;
|
|
91
|
-
/** Set several items. */
|
|
92
|
-
setBatch(items: Array<[string, T]>, options?:
|
|
97
|
+
/** Set several items. The options can expect a version for each id (roadmap CLI-16). */
|
|
98
|
+
setBatch(items: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void>;
|
|
93
99
|
/** Delete several items. The number of items that existed. */
|
|
94
100
|
deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number>;
|
|
95
101
|
/** The items of several ids. An id with no item is not in the map. */
|
|
@@ -114,6 +120,30 @@ export interface StorageWriteOptions {
|
|
|
114
120
|
readonly origin?: string | undefined;
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The options of `set` and `delete`: the origin, and the precondition of the write (roadmap
|
|
125
|
+
* CLI-16). `precondition.ts` gives the rules.
|
|
126
|
+
*/
|
|
127
|
+
export interface StorageItemWriteOptions extends StorageWriteOptions {
|
|
128
|
+
/**
|
|
129
|
+
* The version that the write expects for the current item, from `versionOf` of the storage.
|
|
130
|
+
* `null` expects no item. When the current item has another version, the write changes nothing
|
|
131
|
+
* and rejects with `VersionConflictError`. A storage without `versionOf` rejects it with a
|
|
132
|
+
* `TypeError`. Absent: no precondition.
|
|
133
|
+
*/
|
|
134
|
+
readonly expectedVersion?: string | null | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The options of `setBatch`: the origin, and the precondition of each id (roadmap CLI-16). */
|
|
138
|
+
export interface StorageBatchWriteOptions extends StorageWriteOptions {
|
|
139
|
+
/**
|
|
140
|
+
* The version that the write expects for the current item of each id, as for `expectedVersion`.
|
|
141
|
+
* When one id has a conflict, the batch changes no item. An id without an entry has no
|
|
142
|
+
* precondition.
|
|
143
|
+
*/
|
|
144
|
+
readonly expectedVersions?: ReadonlyArray<readonly [string, string | null]> | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
/** A change of `observe()`: an id got a value. */
|
|
118
148
|
export interface StorageSetChange<T> {
|
|
119
149
|
readonly type: "set";
|
package/src/procedures/index.ts
CHANGED
|
@@ -150,8 +150,23 @@ export {
|
|
|
150
150
|
queryEntries,
|
|
151
151
|
queryOptionsProblems,
|
|
152
152
|
selectorProblems,
|
|
153
|
+
MAX_EXPECTED_VERSION_LENGTH,
|
|
154
|
+
PRECONDITION_NOT_SUPPORTED,
|
|
155
|
+
VERSION_CONFLICTS_METADATA_KEY,
|
|
156
|
+
VersionConflictError,
|
|
157
|
+
asVersionConflictError,
|
|
158
|
+
isExpectedVersion,
|
|
159
|
+
preconditionNotSupported,
|
|
160
|
+
versionConflict,
|
|
161
|
+
versionConflicts,
|
|
162
|
+
versionConflictsOf,
|
|
153
163
|
} from "./collection/index.js";
|
|
154
164
|
export type {
|
|
165
|
+
CurrentItem,
|
|
166
|
+
VersionConflict,
|
|
167
|
+
VersionOf,
|
|
168
|
+
StorageItemWriteOptions,
|
|
169
|
+
StorageBatchWriteOptions,
|
|
155
170
|
CollectionProceduresOptions,
|
|
156
171
|
CollectionChangeEventsOptions,
|
|
157
172
|
CollectionChangeEvent,
|
package/src/protocol.ts
CHANGED
|
@@ -48,7 +48,9 @@ export type ProtocolErrorCode =
|
|
|
48
48
|
| "CONNECTION_CLOSED"
|
|
49
49
|
| "CONNECTION_NOT_FOUND"
|
|
50
50
|
| "TOPIC_NOT_FOUND"
|
|
51
|
-
| "TOPIC_CLEARED"
|
|
51
|
+
| "TOPIC_CLEARED"
|
|
52
|
+
| "VERSION_CONFLICT"
|
|
53
|
+
| "RATE_LIMITED";
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* The error codes of the protocol, on each face (HTTP, WebSocket, MCP, local). An error that a
|
|
@@ -80,6 +82,8 @@ export const ERROR_CODES: Readonly<Record<ProtocolErrorCode, ErrorCodeInfo>> = {
|
|
|
80
82
|
CONNECTION_NOT_FOUND: { http: 404, retryable: false, meaning: "The server has no connection with this id" },
|
|
81
83
|
TOPIC_NOT_FOUND: { http: 404, retryable: false, meaning: "The topic rule of the host has no topic with this name" },
|
|
82
84
|
TOPIC_CLEARED: { http: 410, retryable: false, meaning: "The host cleared a topic of the subscription: clear() of its bus removed the listener" },
|
|
85
|
+
VERSION_CONFLICT: { http: 409, retryable: false, meaning: "The current item does not have the version that the write expects. The metadata key conflicts has the current items" },
|
|
86
|
+
RATE_LIMITED: { http: 429, retryable: true, meaning: "A limit of the host refuses the call. The metadata key retry-after gives the wait, and reason gives the cause" },
|
|
83
87
|
};
|
|
84
88
|
|
|
85
89
|
/** True when a code is an error code of the protocol. */
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* call and what the host gives for it (CAPABILITIES-2026-10).
|
|
19
19
|
* - `events`: the remote events of the host (`documentation/EVENTS.md`). The server pins
|
|
20
20
|
* `events.subscribe` with this topic rule, and its procedures get the bus as `ctx.bus`.
|
|
21
|
+
* - `limits`, `limitStores`: the limits of the calls (CLI-40). Each server of the policy makes one
|
|
22
|
+
* keyed limiter for each limit, and refuses a call above a limit with `RATE_LIMITED`.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
|
|
@@ -30,6 +32,8 @@ import type { ProcedureRegistry } from "../procedures/registry.js";
|
|
|
30
32
|
import type { ProcedureServerOptions } from "./procedure-server.js";
|
|
31
33
|
import type { EventPublisherOptions } from "../events/publisher.js";
|
|
32
34
|
import { toMs, type Duration } from "../duration.js";
|
|
35
|
+
import type { LimiterStore } from "@mark1russell7/service/limiter";
|
|
36
|
+
import type { CallLimit } from "./limits.js";
|
|
33
37
|
|
|
34
38
|
/** The procedures of a host that are tools: namespaces (first path segments), less some keys. */
|
|
35
39
|
export interface ToolSurface {
|
|
@@ -102,6 +106,15 @@ export interface HostPolicyOptions {
|
|
|
102
106
|
* `ctx.bus`. Without this option, the host does not serve `events.subscribe`.
|
|
103
107
|
*/
|
|
104
108
|
events?: EventPublisherOptions | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* The limits of the calls (CLI-40): each limit counts the calls of one key, for example the
|
|
111
|
+
* writes of each tenant. A refusal is `RATE_LIMITED` on each face. Without it: no limits.
|
|
112
|
+
* Each limit that covers a call takes one unit, in the sequence of the limits. A limit before a
|
|
113
|
+
* refusal keeps its unit: the server does not give it back.
|
|
114
|
+
*/
|
|
115
|
+
limits?: readonly CallLimit[] | undefined;
|
|
116
|
+
/** The stores that the replicas share, by the name of their dependency, for the limits with a shared store. */
|
|
117
|
+
limitStores?: Readonly<Record<string, LimiterStore>> | undefined;
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
/** What a host lets its callers do. */
|
|
@@ -120,6 +133,10 @@ export interface HostPolicy {
|
|
|
120
133
|
readonly admitItem: AdmitItem | undefined;
|
|
121
134
|
/** The remote events: each server of the policy makes its own publisher from these options. */
|
|
122
135
|
readonly events: EventPublisherOptions | undefined;
|
|
136
|
+
/** The limits of the calls (CLI-40). Each server of the policy makes its own keyed limiters from them. */
|
|
137
|
+
readonly limits?: readonly CallLimit[] | undefined;
|
|
138
|
+
/** The shared stores of the limits, by the name of their dependency. */
|
|
139
|
+
readonly limitStores?: Readonly<Record<string, LimiterStore>> | undefined;
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
/** The capability function of a policy: the capability of the options, narrowed by `readOnly`. */
|
|
@@ -167,6 +184,8 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
|
|
|
167
184
|
resources: options.resources,
|
|
168
185
|
admitItem: options.admitItem,
|
|
169
186
|
events: options.events,
|
|
187
|
+
...(options.limits && options.limits.length > 0 ? { limits: options.limits } : {}),
|
|
188
|
+
...(options.limitStores ? { limitStores: options.limitStores } : {}),
|
|
170
189
|
};
|
|
171
190
|
}
|
|
172
191
|
|
|
@@ -188,5 +207,8 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
|
|
|
188
207
|
...(policy.admitItem ? { admitItem: policy.admitItem } : {}),
|
|
189
208
|
// The remote events: the server makes its publisher and pins events.subscribe (EVENTS.md)
|
|
190
209
|
...(policy.events ? { events: policy.events } : {}),
|
|
210
|
+
// The limits of the calls: the server makes one keyed limiter for each one (CLI-40)
|
|
211
|
+
...(policy.limits ? { limits: policy.limits } : {}),
|
|
212
|
+
...(policy.limitStores ? { limitStores: policy.limitStores } : {}),
|
|
191
213
|
};
|
|
192
214
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The limits of the calls of a server (CLI-40): one admission step for each face.
|
|
3
|
+
*
|
|
4
|
+
* A limit is data: a key over the call, the calls that it covers, a limit policy, a store and a
|
|
5
|
+
* bound on its keys. Each limit is a keyed limiter of `@mark1russell7/service/limiter` (SYS-28) on
|
|
6
|
+
* the clock of the server. The server takes one unit of each limit that covers a call, after the
|
|
7
|
+
* authentication and before the run. Thus the key can read the principal of the call. It takes the
|
|
8
|
+
* units in the sequence of the limits, and the first refusal stops the call. A limit before the
|
|
9
|
+
* refusal keeps its unit: the server does not give it back.
|
|
10
|
+
*
|
|
11
|
+
* A refusal is `RATE_LIMITED` (retryable), with the wait in the metadata key `retry-after` (in
|
|
12
|
+
* milliseconds) and the cause in `reason` (`limit`, `cap` or `store`). The HTTP face answers 429
|
|
13
|
+
* with `Retry-After` in whole seconds. The WebSocket face and the MCP face give the same code and
|
|
14
|
+
* the same wait. The work did not start, so a retry after the wait is safe.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { valueIn } from "@mark1russell7/quantity";
|
|
18
|
+
import {
|
|
19
|
+
keyedLimiter,
|
|
20
|
+
memoryStore,
|
|
21
|
+
retryAfterSeconds,
|
|
22
|
+
type KeyedLimiter,
|
|
23
|
+
type LimitPolicy,
|
|
24
|
+
type LimiterEviction,
|
|
25
|
+
type LimiterStore,
|
|
26
|
+
type LimiterStoreSpec,
|
|
27
|
+
type Refused,
|
|
28
|
+
} from "@mark1russell7/service/limiter";
|
|
29
|
+
import type { Clock } from "../clock.js";
|
|
30
|
+
import { effectsOf, isReadOnly } from "../procedures/capability.js";
|
|
31
|
+
import { pathToKey, type Effects, type ProcedurePath } from "../procedures/types.js";
|
|
32
|
+
import { exposePatterns } from "./host-policy.js";
|
|
33
|
+
import type { Principal, ServerRequest, ServerResponse } from "./types.js";
|
|
34
|
+
|
|
35
|
+
/** The facts of a call that a limit reads. The server gives them after the authentication of the call. */
|
|
36
|
+
export interface LimitedCall {
|
|
37
|
+
/** The path of the procedure of the call. */
|
|
38
|
+
readonly path: ProcedurePath;
|
|
39
|
+
/** The route of the call: the key of its procedure, for example `collections.notes.set`. */
|
|
40
|
+
readonly route: string;
|
|
41
|
+
/** The effects that the procedure declares, or undefined when it declares none. */
|
|
42
|
+
readonly effects: Effects | undefined;
|
|
43
|
+
/** True when the procedure can change data: its effects do not say `writes: []`. A procedure without effects can change data. */
|
|
44
|
+
readonly writes: boolean;
|
|
45
|
+
/** The principal of the call: from the request hook, the token check or the authenticate function. Undefined for a call without one. */
|
|
46
|
+
readonly principal: Principal | undefined;
|
|
47
|
+
/** The request. Its payload and its metadata come from the caller, thus a key from them is a claim of the caller. */
|
|
48
|
+
readonly request: ServerRequest<unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A part of the key of a limit, as data:
|
|
53
|
+
* - `principal`: the id of the principal of the call.
|
|
54
|
+
* - `route`: the key of the procedure of the call.
|
|
55
|
+
* - `{ claim }`: a claim of the principal, for example `{ claim: "tenant" }`. A string or a number.
|
|
56
|
+
*/
|
|
57
|
+
export type CallLimitKeyPart = "principal" | "route" | { readonly claim: string };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The key of a limit: one part, a list of parts, or a function of the call. A call whose key has
|
|
61
|
+
* no value (a part without a value, or a function that gives undefined) is outside the limit.
|
|
62
|
+
*/
|
|
63
|
+
export type CallLimitKey = CallLimitKeyPart | readonly CallLimitKeyPart[] | ((call: LimitedCall) => string | undefined);
|
|
64
|
+
|
|
65
|
+
/** The calls that a limit covers, as data. Each given field must agree. Without a field, the limit covers each call. */
|
|
66
|
+
export interface CallLimitCovers {
|
|
67
|
+
/** True: only the calls of a procedure that can change data. False: only the calls of a procedure that changes nothing. */
|
|
68
|
+
readonly writes?: boolean | undefined;
|
|
69
|
+
/** Only the calls of these procedures: an exact key (`notes.set`), a prefix (`collections.*`) or `*`. */
|
|
70
|
+
readonly routes?: readonly string[] | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A limit of the calls of a server (CLI-40): a keyed limiter of `@mark1russell7/service/limiter`
|
|
75
|
+
* with a key over the call. The policy, the store and the bound on the keys have no defaults, as
|
|
76
|
+
* in the spec of a keyed limiter (`LimiterSpec`).
|
|
77
|
+
*/
|
|
78
|
+
export interface CallLimit {
|
|
79
|
+
/** The name of the limiter. It is unique in the service: `svc.limiter()` declares the limiter with this name. */
|
|
80
|
+
readonly name: string;
|
|
81
|
+
/** The key of each call: who or what the limit counts. */
|
|
82
|
+
readonly key: CallLimitKey;
|
|
83
|
+
/**
|
|
84
|
+
* The text of the key in the declaration of the service (`LimiterSpec.key`), for example
|
|
85
|
+
* `tenant`. The default names the parts of the key, or `call` for a function.
|
|
86
|
+
*/
|
|
87
|
+
readonly keyName?: string | undefined;
|
|
88
|
+
/** The calls that the limit covers: the fields of `CallLimitCovers`, or a function of the call. Without it, each call. */
|
|
89
|
+
readonly covers?: CallLimitCovers | ((call: LimitedCall) => boolean) | undefined;
|
|
90
|
+
/** The policy: a token bucket or a sliding window. */
|
|
91
|
+
readonly policy: LimitPolicy;
|
|
92
|
+
/**
|
|
93
|
+
* The store of the counts. `{ kind: "local" }`: the memory of this replica. `{ kind: "shared",
|
|
94
|
+
* dependency, onStoreFailure }`: the store of `limitStores` with the name of the dependency.
|
|
95
|
+
*/
|
|
96
|
+
readonly store: LimiterStoreSpec;
|
|
97
|
+
/** The bound on the keys that the store holds. */
|
|
98
|
+
readonly evict: LimiterEviction;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The refusal of a call by one limit. */
|
|
102
|
+
export interface CallLimitRefusal {
|
|
103
|
+
/** The name of the limit that refused the call. */
|
|
104
|
+
readonly limit: string;
|
|
105
|
+
readonly refused: Refused;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The limits of one server, as runtime: one keyed limiter for each limit. */
|
|
109
|
+
export interface CallLimiter {
|
|
110
|
+
/** The keyed limiters, in the sequence of the limits. A host declares each one with `svc.limiter(name, spec)`. */
|
|
111
|
+
readonly limiters: readonly KeyedLimiter[];
|
|
112
|
+
/**
|
|
113
|
+
* This method takes one unit of each limit that covers the call, in the sequence of the limits.
|
|
114
|
+
* It gives the first refusal, or undefined. A limit before the refusal keeps its unit.
|
|
115
|
+
*/
|
|
116
|
+
admit(call: LimitedCall): Promise<CallLimitRefusal | undefined>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The options of `callLimiter()`. */
|
|
120
|
+
export interface CallLimiterOptions {
|
|
121
|
+
/** The clock of the local store: give the clock of the server. */
|
|
122
|
+
readonly clock: Clock;
|
|
123
|
+
/** The stores that the replicas share, by the name of their dependency. */
|
|
124
|
+
readonly stores?: Readonly<Record<string, LimiterStore>> | undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The metadata key of the wait of a refusal, in milliseconds (the same key as `retry-after` of wire v1). */
|
|
128
|
+
const RETRY_AFTER = "retry-after";
|
|
129
|
+
|
|
130
|
+
/** This function tells if a key is a list of parts. */
|
|
131
|
+
function isPartList(key: CallLimitKey): key is readonly CallLimitKeyPart[] {
|
|
132
|
+
return Array.isArray(key);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The value of one part of a key, or undefined. */
|
|
136
|
+
function partValue(part: CallLimitKeyPart, call: LimitedCall): string | undefined {
|
|
137
|
+
if (part === "principal") return call.principal?.id;
|
|
138
|
+
if (part === "route") return call.route;
|
|
139
|
+
const value = call.principal?.claims?.[part.claim];
|
|
140
|
+
return typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The text of one part of a key, for the declaration of the service. */
|
|
144
|
+
function partName(part: CallLimitKeyPart): string {
|
|
145
|
+
return typeof part === "string" ? part : part.claim;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** This function gives the function of the key of a limit. A list of parts gives the JSON of the values. */
|
|
149
|
+
function keyFunction(key: CallLimitKey): (call: LimitedCall) => string | undefined {
|
|
150
|
+
if (typeof key === "function") return key;
|
|
151
|
+
const parts = isPartList(key) ? key : [key];
|
|
152
|
+
if (parts.length === 0) throw new Error("A key of a limit needs one part or more");
|
|
153
|
+
return (call) => {
|
|
154
|
+
const values: string[] = [];
|
|
155
|
+
for (const part of parts) {
|
|
156
|
+
const value = partValue(part, call);
|
|
157
|
+
if (value === undefined) return undefined;
|
|
158
|
+
values.push(value);
|
|
159
|
+
}
|
|
160
|
+
return values.length === 1 ? values[0] : JSON.stringify(values);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The text of the key of a limit, for the declaration of the service. */
|
|
165
|
+
function keyNameOf(limit: CallLimit): string {
|
|
166
|
+
if (limit.keyName !== undefined) return limit.keyName;
|
|
167
|
+
if (typeof limit.key === "function") return "call";
|
|
168
|
+
return (isPartList(limit.key) ? limit.key : [limit.key]).map(partName).join("+");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** This function gives the function that tells if a limit covers a call. */
|
|
172
|
+
function coversFunction(covers: CallLimit["covers"]): (call: LimitedCall) => boolean {
|
|
173
|
+
if (covers === undefined) return () => true;
|
|
174
|
+
if (typeof covers === "function") return covers;
|
|
175
|
+
const { writes, routes } = covers;
|
|
176
|
+
const routeRule = routes === undefined ? undefined : exposePatterns(routes);
|
|
177
|
+
return (call) => (writes === undefined || call.writes === writes) && (routeRule === undefined || routeRule(call.path));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** This function gives the store of a limit: the local store, or the shared store of its dependency. */
|
|
181
|
+
function storeOf(limit: CallLimit, local: LimiterStore, stores: Readonly<Record<string, LimiterStore>>): LimiterStore {
|
|
182
|
+
const spec = limit.store as LimiterStoreSpec | undefined;
|
|
183
|
+
if (spec === undefined || spec.kind !== "shared") return local;
|
|
184
|
+
const store = Object.prototype.hasOwnProperty.call(stores, spec.dependency) ? stores[spec.dependency] : undefined;
|
|
185
|
+
if (store === undefined) {
|
|
186
|
+
throw new Error(`The limit ${limit.name} keeps its counts in the shared store ${spec.dependency}, and limitStores has no store with that name`);
|
|
187
|
+
}
|
|
188
|
+
return store;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* This function makes the runtime of the limits of a server: one keyed limiter for each limit. The
|
|
193
|
+
* local limits share one memory store on the clock of the options. It refuses two limits with one
|
|
194
|
+
* name, a limit with a problem in its spec, and a shared store that `stores` does not have.
|
|
195
|
+
*/
|
|
196
|
+
export function callLimiter(limits: readonly CallLimit[], options: CallLimiterOptions): CallLimiter {
|
|
197
|
+
const local = memoryStore(options.clock);
|
|
198
|
+
const stores = options.stores ?? {};
|
|
199
|
+
const names = new Set<string>();
|
|
200
|
+
const entries = limits.map((limit) => {
|
|
201
|
+
if (names.has(limit.name)) throw new Error(`Two limits have the name ${limit.name}: the name of a limit is unique`);
|
|
202
|
+
names.add(limit.name);
|
|
203
|
+
const keyOf = keyFunction(limit.key);
|
|
204
|
+
const covers = coversFunction(limit.covers);
|
|
205
|
+
const limiter = keyedLimiter(limit.name, { key: keyNameOf(limit), policy: limit.policy, store: limit.store, evict: limit.evict }, storeOf(limit, local, stores));
|
|
206
|
+
return { limiter, keyOf, covers };
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
limiters: entries.map((entry) => entry.limiter),
|
|
210
|
+
async admit(call) {
|
|
211
|
+
for (const { limiter, keyOf, covers } of entries) {
|
|
212
|
+
if (!covers(call)) continue;
|
|
213
|
+
const key = keyOf(call);
|
|
214
|
+
if (key === undefined) continue;
|
|
215
|
+
const decision = await limiter.take(key);
|
|
216
|
+
if (!decision.allowed) return { limit: limiter.name, refused: decision };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** This function gives the facts of a call for the limits: its procedure, its effects, its principal and its request. */
|
|
224
|
+
export function limitedCall(procedure: { readonly path: ProcedurePath; readonly metadata?: unknown }, request: ServerRequest<unknown>): LimitedCall {
|
|
225
|
+
const effects = effectsOf(procedure);
|
|
226
|
+
return {
|
|
227
|
+
path: procedure.path,
|
|
228
|
+
route: pathToKey(procedure.path),
|
|
229
|
+
effects,
|
|
230
|
+
writes: !isReadOnly(effects),
|
|
231
|
+
principal: request.principal,
|
|
232
|
+
request,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* This function gives the response of a refused call: `RATE_LIMITED`, retryable, with the wait in
|
|
238
|
+
* `retry-after` (milliseconds) and the cause in `reason`. The failure of a store stays on the server.
|
|
239
|
+
*/
|
|
240
|
+
export function rateLimitedResponse(id: string, refusal: CallLimitRefusal): ServerResponse<unknown> {
|
|
241
|
+
const { limit, refused } = refusal;
|
|
242
|
+
const waitMs = valueIn(refused.retryAfter, "ms");
|
|
243
|
+
const seconds = retryAfterSeconds(refused.retryAfter);
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
status: {
|
|
247
|
+
type: "error",
|
|
248
|
+
code: "RATE_LIMITED",
|
|
249
|
+
message: `The limit ${limit} of the host refuses the call (${refused.cause}): retry after ${seconds} s`,
|
|
250
|
+
retryable: true,
|
|
251
|
+
},
|
|
252
|
+
metadata: { [RETRY_AFTER]: waitMs, reason: refused.cause },
|
|
253
|
+
};
|
|
254
|
+
}
|