@procedures/client 0.13.1 → 0.14.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 +41 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/handler.js +5 -0
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/procedures/collection/procedures.js +1 -1
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/query.d.ts +30 -6
- package/dist/procedures/collection/query.d.ts.map +1 -1
- package/dist/procedures/collection/query.js +115 -13
- package/dist/procedures/collection/query.js.map +1 -1
- package/dist/procedures/collection/schemas.d.ts +2 -1
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +1 -1
- package/dist/server/procedure-server.d.ts +1 -0
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +6 -3
- package/dist/server/procedure-server.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/server/handler.ts +4 -0
- package/src/procedures/collection/procedures.ts +1 -1
- package/src/procedures/collection/query.ts +128 -14
- package/src/procedures/collection/schemas.ts +2 -1
- package/src/procedures/collection/storage.ts +1 -1
- package/src/server/procedure-server.ts +6 -3
|
@@ -193,7 +193,8 @@ export const DEFAULT_FIND_LIMIT: number = 1000;
|
|
|
193
193
|
* - A JSON Merge Patch (RFC 7386): each field replaces the field of the item, an object merges
|
|
194
194
|
* into an object field, and `null` removes the field.
|
|
195
195
|
* - Operations (`StoragePatchOperations`): each key starts with `$`. `$set` can set `null` and
|
|
196
|
-
* nested field paths, and `$unset` removes field paths.
|
|
196
|
+
* nested field paths, and `$unset` removes field paths. `$remove` and `$insert` remove and add
|
|
197
|
+
* elements of arrays, as the operations `remove` and `add` of JSON Patch (RFC 6902).
|
|
197
198
|
*/
|
|
198
199
|
export type StoragePatch = Readonly<Record<string, unknown>>;
|
|
199
200
|
|
|
@@ -206,6 +207,17 @@ export type StoragePatchOperations = {
|
|
|
206
207
|
readonly $set?: Readonly<Record<string, unknown>> | undefined;
|
|
207
208
|
/** The field paths to remove. An element of an array becomes `null`. */
|
|
208
209
|
readonly $unset?: readonly string[] | undefined;
|
|
210
|
+
/**
|
|
211
|
+
* The field paths to remove. An element of an array leaves the array, and the next elements move
|
|
212
|
+
* down by one. A field of an object goes, as with `$unset`. A missing path changes nothing.
|
|
213
|
+
*/
|
|
214
|
+
readonly $remove?: readonly string[] | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* The elements to add to arrays. Each path ends with an index, for example `{ "tags.1": "x" }`.
|
|
217
|
+
* The element at that index and the next elements move up by one. The index that is equal to the
|
|
218
|
+
* length of the array adds the element at the end.
|
|
219
|
+
*/
|
|
220
|
+
readonly $insert?: Readonly<Record<string, unknown>> | undefined;
|
|
209
221
|
};
|
|
210
222
|
|
|
211
223
|
//
|
|
@@ -870,7 +882,13 @@ export function queryEntries<T>(
|
|
|
870
882
|
// ═══ The patch ═══
|
|
871
883
|
//
|
|
872
884
|
|
|
873
|
-
const PATCH_OPERATIONS = new Set(["$set", "$unset"]);
|
|
885
|
+
const PATCH_OPERATIONS = new Set(["$set", "$unset", "$remove", "$insert"]);
|
|
886
|
+
|
|
887
|
+
/** The path of the array of an element path: the path without its last part. */
|
|
888
|
+
function arrayPathOf(key: string): string {
|
|
889
|
+
const dot = key.lastIndexOf(".");
|
|
890
|
+
return dot === -1 ? "" : key.slice(0, dot);
|
|
891
|
+
}
|
|
874
892
|
|
|
875
893
|
/** True for the operations form of a patch: an object whose defined keys each start with `$`. */
|
|
876
894
|
export function isPatchOperations(fields: unknown): fields is StoragePatchOperations {
|
|
@@ -879,8 +897,13 @@ export function isPatchOperations(fields: unknown): fields is StoragePatchOperat
|
|
|
879
897
|
|
|
880
898
|
/**
|
|
881
899
|
* This function gives the problems of the fields of a patch. An empty list means that the patch
|
|
882
|
-
* is correct: a JSON Merge Patch, or the operations `$set` and `$
|
|
883
|
-
*
|
|
900
|
+
* is correct: a JSON Merge Patch, or the operations `$set`, `$unset`, `$remove` and `$insert`.
|
|
901
|
+
*
|
|
902
|
+
* - A patch that mixes the two forms is not correct.
|
|
903
|
+
* - Two paths of a patch must not overlap: a path must not be the same as another path, or start
|
|
904
|
+
* with it.
|
|
905
|
+
* - Each path of `$insert` ends with an index.
|
|
906
|
+
* - A patch must not remove and insert elements of the same array.
|
|
884
907
|
*/
|
|
885
908
|
export function patchProblems(fields: unknown): string[] {
|
|
886
909
|
if (!isObject(fields)) return ["the fields of a patch must be an object"];
|
|
@@ -888,12 +911,12 @@ export function patchProblems(fields: unknown): string[] {
|
|
|
888
911
|
const operations = keys.filter((key) => key.startsWith("$"));
|
|
889
912
|
if (operations.length === 0) return [];
|
|
890
913
|
if (operations.length < keys.length) {
|
|
891
|
-
return ["the patch mixes operations and fields: give
|
|
914
|
+
return ["the patch mixes operations and fields: give operations ($set, $unset, $remove, $insert), or only fields (a JSON Merge Patch)"];
|
|
892
915
|
}
|
|
893
916
|
const problems: string[] = [];
|
|
894
917
|
const paths: string[] = [];
|
|
895
918
|
for (const operation of operations) {
|
|
896
|
-
if (!PATCH_OPERATIONS.has(operation)) problems.push(`the patch operation ${operation} is not known: use $set and $
|
|
919
|
+
if (!PATCH_OPERATIONS.has(operation)) problems.push(`the patch operation ${operation} is not known: use $set, $unset, $remove and $insert`);
|
|
897
920
|
}
|
|
898
921
|
const set = fields["$set"];
|
|
899
922
|
if (set !== undefined) {
|
|
@@ -905,6 +928,34 @@ export function patchProblems(fields: unknown): string[] {
|
|
|
905
928
|
if (!Array.isArray(unset) || !unset.every((path) => typeof path === "string")) problems.push("$unset takes a list of field paths");
|
|
906
929
|
else for (const key of unset as string[]) if (pathOf(key, "$unset", problems)) paths.push(key);
|
|
907
930
|
}
|
|
931
|
+
const remove = fields["$remove"];
|
|
932
|
+
const removedArrays = new Set<string>();
|
|
933
|
+
if (remove !== undefined) {
|
|
934
|
+
if (!Array.isArray(remove) || !remove.every((path) => typeof path === "string")) problems.push("$remove takes a list of field paths");
|
|
935
|
+
else {
|
|
936
|
+
for (const key of remove as string[]) {
|
|
937
|
+
if (!pathOf(key, "$remove", problems)) continue;
|
|
938
|
+
paths.push(key);
|
|
939
|
+
removedArrays.add(arrayPathOf(key));
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const insert = fields["$insert"];
|
|
944
|
+
if (insert !== undefined) {
|
|
945
|
+
if (!isObject(insert)) problems.push("$insert takes an object of field paths and values");
|
|
946
|
+
else {
|
|
947
|
+
for (const key of definedKeys(insert)) {
|
|
948
|
+
const segments = pathOf(key, "$insert", problems);
|
|
949
|
+
if (!segments) continue;
|
|
950
|
+
if (!INDEX.test(segments.at(-1)!)) {
|
|
951
|
+
problems.push(`$insert: the field path "${key}" does not end with an index`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
paths.push(key);
|
|
955
|
+
if (removedArrays.has(arrayPathOf(key))) problems.push(`the patch removes and inserts elements of the same array ${arrayPathOf(key)}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
908
959
|
for (let first = 0; first < paths.length; first++) {
|
|
909
960
|
for (let second = first + 1; second < paths.length; second++) {
|
|
910
961
|
const a = paths[first]!;
|
|
@@ -969,34 +1020,92 @@ function setIn(container: unknown, path: readonly string[], index: number, value
|
|
|
969
1020
|
return copy;
|
|
970
1021
|
}
|
|
971
1022
|
|
|
972
|
-
/**
|
|
973
|
-
|
|
1023
|
+
/**
|
|
1024
|
+
* The container without the value at a path: a copy of each object and array on the path. With
|
|
1025
|
+
* `splice`, an element of an array leaves the array (`$remove`). Without it, the element becomes
|
|
1026
|
+
* `null`, as in MongoDB (`$unset`).
|
|
1027
|
+
*/
|
|
1028
|
+
function unsetIn(container: unknown, path: readonly string[], index: number, splice = false): unknown {
|
|
974
1029
|
const key = path[index]!;
|
|
975
1030
|
const last = index === path.length - 1;
|
|
976
1031
|
if (Array.isArray(container)) {
|
|
977
1032
|
if (!INDEX.test(key) || Number(key) >= container.length) return container;
|
|
978
1033
|
const copy = container.slice();
|
|
979
|
-
|
|
980
|
-
copy[Number(key)] = last ? null : unsetIn(copy[Number(key)], path, index + 1);
|
|
1034
|
+
if (last && splice) copy.splice(Number(key), 1);
|
|
1035
|
+
else copy[Number(key)] = last ? null : unsetIn(copy[Number(key)], path, index + 1, splice);
|
|
981
1036
|
return copy;
|
|
982
1037
|
}
|
|
983
1038
|
if (!isObject(container) || !Object.hasOwn(container, key)) return container;
|
|
984
1039
|
const copy = copyOf(container);
|
|
985
1040
|
if (last) delete copy[key];
|
|
986
|
-
else put(copy, key, unsetIn(copy[key], path, index + 1));
|
|
1041
|
+
else put(copy, key, unsetIn(copy[key], path, index + 1, splice));
|
|
1042
|
+
return copy;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** The container with a new element in an array at a path: a copy of each object and array on the path. */
|
|
1046
|
+
function insertIn(container: unknown, path: readonly string[], index: number, value: unknown): unknown {
|
|
1047
|
+
const key = path[index]!;
|
|
1048
|
+
const where = path.slice(0, index).join(".") || "the item";
|
|
1049
|
+
if (index === path.length - 1) {
|
|
1050
|
+
if (!Array.isArray(container)) throw new TypeError(`The patch cannot insert at ${path.join(".")}: ${where} is not an array`);
|
|
1051
|
+
const position = Number(key);
|
|
1052
|
+
if (position > container.length) {
|
|
1053
|
+
throw new TypeError(`The patch cannot insert at ${path.join(".")}: ${where} has ${container.length} elements`);
|
|
1054
|
+
}
|
|
1055
|
+
const copy = container.slice();
|
|
1056
|
+
copy.splice(position, 0, value);
|
|
1057
|
+
return copy;
|
|
1058
|
+
}
|
|
1059
|
+
if (Array.isArray(container)) {
|
|
1060
|
+
if (!INDEX.test(key) || Number(key) >= container.length) {
|
|
1061
|
+
throw new TypeError(`The patch cannot insert at ${path.join(".")}: ${where} has no element ${key}`);
|
|
1062
|
+
}
|
|
1063
|
+
const copy = container.slice();
|
|
1064
|
+
copy[Number(key)] = insertIn(copy[Number(key)], path, index + 1, value);
|
|
1065
|
+
return copy;
|
|
1066
|
+
}
|
|
1067
|
+
if (!isObject(container) || !Object.hasOwn(container, key)) {
|
|
1068
|
+
throw new TypeError(`The patch cannot insert at ${path.join(".")}: ${where} has no field ${key}`);
|
|
1069
|
+
}
|
|
1070
|
+
const copy = copyOf(container);
|
|
1071
|
+
put(copy, key, insertIn(copy[key], path, index + 1, value));
|
|
987
1072
|
return copy;
|
|
988
1073
|
}
|
|
989
1074
|
|
|
1075
|
+
/** An operation that moves elements of an array: a `$remove` or an `$insert`. */
|
|
1076
|
+
interface Move {
|
|
1077
|
+
readonly path: readonly string[];
|
|
1078
|
+
readonly insert: boolean;
|
|
1079
|
+
readonly value?: unknown;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The order of the moves. Each index refers to the item before the patch. A move changes the
|
|
1084
|
+
* indices of the next elements of its array, and of the paths through them. Thus the deeper paths
|
|
1085
|
+
* go first, and in one array the higher indices go first.
|
|
1086
|
+
*/
|
|
1087
|
+
function compareMoves(a: Move, b: Move): number {
|
|
1088
|
+
if (a.path.length !== b.path.length) return b.path.length - a.path.length;
|
|
1089
|
+
return Number(b.path.at(-1)) - Number(a.path.at(-1)) || 0;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
990
1092
|
/**
|
|
991
1093
|
* This function applies a patch to a value, and gives the new value: a JSON Merge Patch, or the
|
|
992
|
-
* operations `$set` and `$
|
|
1094
|
+
* operations `$set`, `$unset`, `$remove` and `$insert`. It does not change the value or the patch.
|
|
993
1095
|
*
|
|
994
1096
|
* - `$set` sets each field path, also to `null`. A missing or null part of a path becomes an object.
|
|
995
1097
|
* - `$unset` removes each field path. An element of an array becomes `null`.
|
|
1098
|
+
* - `$remove` removes each field path. An element of an array leaves the array, as the operation
|
|
1099
|
+
* `remove` of JSON Patch (RFC 6902). A missing path changes nothing.
|
|
1100
|
+
* - `$insert` adds each element to an array, as the operation `add` of JSON Patch. The index that
|
|
1101
|
+
* is equal to the length adds the element at the end.
|
|
1102
|
+
* - Each index refers to the value before the patch. `$set` and `$unset` go first. Then the
|
|
1103
|
+
* function removes and adds the elements from the deepest path, and from the highest index.
|
|
996
1104
|
* - A value that is not an object becomes an empty object first.
|
|
997
1105
|
*
|
|
998
|
-
* @throws TypeError when the patch has a problem (refer to `patchProblems`),
|
|
999
|
-
* `$set` goes through a value that is not an object, or past the end of an array
|
|
1106
|
+
* @throws TypeError when the patch has a problem (refer to `patchProblems`), when a path of
|
|
1107
|
+
* `$set` goes through a value that is not an object, or past the end of an array, or when a path
|
|
1108
|
+
* of `$insert` is not in an array, or goes past the end of the array
|
|
1000
1109
|
*/
|
|
1001
1110
|
export function applyPatch(value: unknown, fields: StoragePatch): Record<string, unknown> {
|
|
1002
1111
|
const problems = patchProblems(fields);
|
|
@@ -1005,5 +1114,10 @@ export function applyPatch(value: unknown, fields: StoragePatch): Record<string,
|
|
|
1005
1114
|
let result: unknown = isObject(value) ? value : {};
|
|
1006
1115
|
for (const [key, field] of definedEntries(fields.$set ?? {})) result = setIn(result, key.split("."), 0, field);
|
|
1007
1116
|
for (const key of fields.$unset ?? []) result = unsetIn(result, key.split("."), 0);
|
|
1117
|
+
const moves: Move[] = [
|
|
1118
|
+
...(fields.$remove ?? []).map((key): Move => ({ path: key.split("."), insert: false })),
|
|
1119
|
+
...definedEntries(fields.$insert ?? {}).map(([key, field]): Move => ({ path: key.split("."), insert: true, value: field })),
|
|
1120
|
+
].sort(compareMoves);
|
|
1121
|
+
for (const move of moves) result = move.insert ? insertIn(result, move.path, 0, move.value) : unsetIn(result, move.path, 0, true);
|
|
1008
1122
|
return result === value ? copyOf(value as Record<string, unknown>) : (result as Record<string, unknown>);
|
|
1009
1123
|
}
|
|
@@ -522,7 +522,8 @@ export const findOutputSchema : ZodLike<CollectionEntry[]> = createSchema<Collec
|
|
|
522
522
|
|
|
523
523
|
/**
|
|
524
524
|
* Input for patch operation: the id, and the fields. The fields are a JSON Merge Patch (RFC 7386),
|
|
525
|
-
* or the operations `{ $set, $unset }
|
|
525
|
+
* or the operations `{ $set, $unset, $remove, $insert }`. They can set `null` and nested field paths,
|
|
526
|
+
* and remove and add the elements of arrays.
|
|
526
527
|
*/
|
|
527
528
|
export interface PatchInput {
|
|
528
529
|
id: string;
|
|
@@ -84,7 +84,7 @@ export interface CollectionStorage<T> {
|
|
|
84
84
|
clear(options?: StorageWriteOptions): Promise<void>;
|
|
85
85
|
/**
|
|
86
86
|
* This method changes the item of an id in one step, and gives the new item, or undefined for a
|
|
87
|
-
* missing id. The fields are a JSON Merge Patch, or the operations `{ $set, $unset }`. The
|
|
87
|
+
* missing id. The fields are a JSON Merge Patch, or the operations `{ $set, $unset, $remove, $insert }`. The
|
|
88
88
|
* procedure `patch` uses it. A storage without it gets a read and a write of the item.
|
|
89
89
|
*/
|
|
90
90
|
patch?(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined>;
|
|
@@ -26,7 +26,7 @@ import { optionalMs, type Duration } from "../duration.js";
|
|
|
26
26
|
import type { CollectionStorage } from "../procedures/collection/storage.js";
|
|
27
27
|
import { TIMER_DEFAULTS } from "../timers.js";
|
|
28
28
|
import { realClock, type Clock } from "../clock.js";
|
|
29
|
-
import { errorCodeOf } from "../protocol.js";
|
|
29
|
+
import { ERROR_CODES, errorCodeOf, isProtocolErrorCode } from "../protocol.js";
|
|
30
30
|
import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
|
|
31
31
|
import { getGlobalEventBus } from "../events/bus.js";
|
|
32
32
|
import type { CollectionProceduresOptions } from "../procedures/collection/procedures.js";
|
|
@@ -323,6 +323,7 @@ function withRequestTimeout(
|
|
|
323
323
|
* The error response of a failed invocation. An invocation error keeps its code
|
|
324
324
|
* (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
|
|
325
325
|
* when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
|
|
326
|
+
* An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
|
|
326
327
|
*/
|
|
327
328
|
export function errorResponse(id: string, error: unknown): ServerResponse<unknown> {
|
|
328
329
|
// An error that says it is retryable stays retryable: before, every error lost the flag
|
|
@@ -334,13 +335,15 @@ export function errorResponse(id: string, error: unknown): ServerResponse<unknow
|
|
|
334
335
|
const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)?.retryAfterMs;
|
|
335
336
|
const metadata: Record<string, unknown> = reason ? { reason } : {};
|
|
336
337
|
if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) metadata["retry-after"] = retryAfterMs;
|
|
338
|
+
const code = errorCodeOf(error);
|
|
337
339
|
return {
|
|
338
340
|
id,
|
|
339
341
|
status: {
|
|
340
342
|
type: "error",
|
|
341
|
-
code
|
|
343
|
+
code,
|
|
342
344
|
message: error instanceof Error ? error.message : String(error),
|
|
343
|
-
|
|
345
|
+
// Without a flag, the default of the code: UNAVAILABLE stays retryable, and keeps HTTP 503 (CLI-48)
|
|
346
|
+
retryable: typeof retryable === "boolean" ? retryable : isProtocolErrorCode(code) ? ERROR_CODES[code].retryable : false,
|
|
344
347
|
},
|
|
345
348
|
metadata,
|
|
346
349
|
};
|