@procedures/client 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +12 -3
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  5. package/dist/adapters/http/client/transport.js +4 -0
  6. package/dist/adapters/http/client/transport.js.map +1 -1
  7. package/dist/adapters/http/server/body.d.ts +17 -0
  8. package/dist/adapters/http/server/body.d.ts.map +1 -0
  9. package/dist/adapters/http/server/body.js +44 -0
  10. package/dist/adapters/http/server/body.js.map +1 -0
  11. package/dist/adapters/http/server/cors.d.ts +24 -1
  12. package/dist/adapters/http/server/cors.d.ts.map +1 -1
  13. package/dist/adapters/http/server/cors.js +33 -1
  14. package/dist/adapters/http/server/cors.js.map +1 -1
  15. package/dist/adapters/http/server/handler.d.ts +3 -1
  16. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  17. package/dist/adapters/http/server/handler.js +7 -34
  18. package/dist/adapters/http/server/handler.js.map +1 -1
  19. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  20. package/dist/adapters/local/client/transport.js +5 -0
  21. package/dist/adapters/local/client/transport.js.map +1 -1
  22. package/dist/browser.d.ts +4 -3
  23. package/dist/browser.d.ts.map +1 -1
  24. package/dist/browser.js +6 -1
  25. package/dist/browser.js.map +1 -1
  26. package/dist/client/client.js +1 -1
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/errors/registry.d.ts.map +1 -1
  29. package/dist/client/errors/registry.js +1 -0
  30. package/dist/client/errors/registry.js.map +1 -1
  31. package/dist/client/types.d.ts +6 -1
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/types.js +7 -1
  34. package/dist/client/types.js.map +1 -1
  35. package/dist/events/publisher.d.ts.map +1 -1
  36. package/dist/events/publisher.js +10 -5
  37. package/dist/events/publisher.js.map +1 -1
  38. package/dist/events/rows.d.ts +12 -4
  39. package/dist/events/rows.d.ts.map +1 -1
  40. package/dist/events/rows.js +13 -16
  41. package/dist/events/rows.js.map +1 -1
  42. package/dist/index.d.ts +5 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +8 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/node.d.ts +10 -1
  47. package/dist/node.d.ts.map +1 -1
  48. package/dist/node.js +11 -0
  49. package/dist/node.js.map +1 -1
  50. package/dist/procedures/collection/admission.d.ts +77 -0
  51. package/dist/procedures/collection/admission.d.ts.map +1 -0
  52. package/dist/procedures/collection/admission.js +204 -0
  53. package/dist/procedures/collection/admission.js.map +1 -0
  54. package/dist/procedures/collection/changes.d.ts +3 -1
  55. package/dist/procedures/collection/changes.d.ts.map +1 -1
  56. package/dist/procedures/collection/changes.js +10 -6
  57. package/dist/procedures/collection/changes.js.map +1 -1
  58. package/dist/procedures/collection/index.d.ts +5 -1
  59. package/dist/procedures/collection/index.d.ts.map +1 -1
  60. package/dist/procedures/collection/index.js +4 -0
  61. package/dist/procedures/collection/index.js.map +1 -1
  62. package/dist/procedures/collection/patch-in-parts.d.ts +15 -0
  63. package/dist/procedures/collection/patch-in-parts.d.ts.map +1 -0
  64. package/dist/procedures/collection/patch-in-parts.js +47 -0
  65. package/dist/procedures/collection/patch-in-parts.js.map +1 -0
  66. package/dist/procedures/collection/precondition.d.ts +111 -0
  67. package/dist/procedures/collection/precondition.d.ts.map +1 -0
  68. package/dist/procedures/collection/precondition.js +153 -0
  69. package/dist/procedures/collection/precondition.js.map +1 -0
  70. package/dist/procedures/collection/procedures.d.ts +8 -0
  71. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  72. package/dist/procedures/collection/procedures.js +66 -76
  73. package/dist/procedures/collection/procedures.js.map +1 -1
  74. package/dist/procedures/collection/query.d.ts +2 -0
  75. package/dist/procedures/collection/query.d.ts.map +1 -1
  76. package/dist/procedures/collection/query.js +3 -0
  77. package/dist/procedures/collection/query.js.map +1 -1
  78. package/dist/procedures/collection/schemas.d.ts +16 -0
  79. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  80. package/dist/procedures/collection/schemas.js +26 -5
  81. package/dist/procedures/collection/schemas.js.map +1 -1
  82. package/dist/procedures/collection/storage.d.ts +34 -6
  83. package/dist/procedures/collection/storage.d.ts.map +1 -1
  84. package/dist/procedures/collection/storage.js +3 -0
  85. package/dist/procedures/collection/storage.js.map +1 -1
  86. package/dist/procedures/index.d.ts +2 -2
  87. package/dist/procedures/index.d.ts.map +1 -1
  88. package/dist/procedures/index.js +3 -1
  89. package/dist/procedures/index.js.map +1 -1
  90. package/dist/procedures/invoke.d.ts +5 -2
  91. package/dist/procedures/invoke.d.ts.map +1 -1
  92. package/dist/procedures/invoke.js +5 -1
  93. package/dist/procedures/invoke.js.map +1 -1
  94. package/dist/procedures/types.d.ts +7 -0
  95. package/dist/procedures/types.d.ts.map +1 -1
  96. package/dist/procedures/types.js.map +1 -1
  97. package/dist/protocol.d.ts +1 -1
  98. package/dist/protocol.d.ts.map +1 -1
  99. package/dist/protocol.js +1 -0
  100. package/dist/protocol.js.map +1 -1
  101. package/dist/server/index.d.ts +70 -5
  102. package/dist/server/index.d.ts.map +1 -1
  103. package/dist/server/index.js +26 -3
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/procedure-server.d.ts.map +1 -1
  106. package/dist/server/procedure-server.js +5 -0
  107. package/dist/server/procedure-server.js.map +1 -1
  108. package/package.json +12 -1
  109. package/src/adapters/http/client/transport.ts +3 -0
  110. package/src/adapters/http/server/body.ts +43 -0
  111. package/src/adapters/http/server/cors.ts +42 -1
  112. package/src/adapters/http/server/handler.ts +10 -32
  113. package/src/adapters/local/client/transport.ts +4 -0
  114. package/src/browser.ts +25 -1
  115. package/src/client/client.ts +1 -1
  116. package/src/client/errors/registry.ts +1 -0
  117. package/src/client/types.ts +8 -0
  118. package/src/events/publisher.ts +8 -5
  119. package/src/events/rows.ts +19 -6
  120. package/src/index.ts +9 -6
  121. package/src/node.ts +13 -1
  122. package/src/procedures/collection/admission.ts +243 -0
  123. package/src/procedures/collection/changes.ts +11 -5
  124. package/src/procedures/collection/index.ts +21 -0
  125. package/src/procedures/collection/patch-in-parts.ts +56 -0
  126. package/src/procedures/collection/precondition.ts +180 -0
  127. package/src/procedures/collection/procedures.ts +69 -78
  128. package/src/procedures/collection/query.ts +16 -0
  129. package/src/procedures/collection/schemas.ts +43 -6
  130. package/src/procedures/collection/storage.ts +36 -6
  131. package/src/procedures/index.ts +22 -0
  132. package/src/procedures/invoke.ts +7 -2
  133. package/src/procedures/types.ts +8 -0
  134. package/src/protocol.ts +3 -1
  135. package/src/server/index.ts +94 -17
  136. package/src/server/procedure-server.ts +4 -0
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { EventBus } from "../../events/types.js";
28
28
  import { withRows } from "../../events/rows.js";
29
+ import type { AdmitItem } from "../invoke.js";
29
30
  import type { StorageChanges } from "./storage.js";
30
31
 
31
32
  /**
@@ -137,13 +138,16 @@ function hint(version: string | undefined, origin: string | undefined): Collecti
137
138
 
138
139
  /**
139
140
  * The event of a delta with the values of the changed items. Each row `{ id, value }` passes the
140
- * output filter of each subscriber, and a refused row goes from `values` to `withheld`.
141
+ * output filter of each subscriber and the rule of the rows of the collection, and a refused row
142
+ * goes from `values` to `withheld`.
141
143
  */
142
- function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ readonly id: string; readonly value: unknown }>): CollectionChangeEvent {
144
+ function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ readonly id: string; readonly value: unknown }>, admit: AdmitItem | undefined): CollectionChangeEvent {
143
145
  return withRows(
144
146
  { ...event, values: entries },
145
147
  {
146
148
  rows: entries,
149
+ // The rule of the rows of the collection (roadmap CLI-41): each subscriber applies it too
150
+ ...(admit && { admit }),
147
151
  keep: (admitted) => {
148
152
  const withheld = entries.filter((_, index) => !admitted[index]).map(({ id }) => id);
149
153
  return {
@@ -157,7 +161,7 @@ function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ reado
157
161
  }
158
162
 
159
163
  /** One publish: the event of the writes that ended since the last publish. */
160
- async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean): Promise<void> {
164
+ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean, admit: AdmitItem | undefined): Promise<void> {
161
165
  const writes = chain.ended.splice(0);
162
166
  // An earlier publish told these writes
163
167
  if (writes.length === 0) return;
@@ -194,7 +198,7 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
194
198
  deleted: changes.deleted,
195
199
  ...(origin !== undefined && { origin }),
196
200
  };
197
- bus.emit(topic, values ? withValues(event, changes.changed.map(([id, value]) => ({ id, value }))) : event);
201
+ bus.emit(topic, values ? withValues(event, changes.changed.map(([id, value]) => ({ id, value })), admit) : event);
198
202
  }
199
203
 
200
204
  /**
@@ -210,6 +214,7 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
210
214
  * @param origin - The origin of the write
211
215
  * @param write - The write
212
216
  * @param values - True: the events carry the values, for the output filter of each subscriber
217
+ * @param admit - The rule of the rows of the collection: each subscriber applies it to the values (roadmap CLI-41)
213
218
  */
214
219
  export async function writeWithChangeEvent<R>(
215
220
  bus: EventBus | undefined,
@@ -219,6 +224,7 @@ export async function writeWithChangeEvent<R>(
219
224
  origin: string | undefined,
220
225
  write: () => Promise<R>,
221
226
  values = false,
227
+ admit?: AdmitItem,
222
228
  ): Promise<R> {
223
229
  if (bus === undefined || topic === undefined) return write();
224
230
  const chain = chainOf(storage, topic);
@@ -227,7 +233,7 @@ export async function writeWithChangeEvent<R>(
227
233
  return await write();
228
234
  } finally {
229
235
  chain.ended.push(started);
230
- const run = chain.tail.then(() => publish(chain, storage, bus, topic, values));
236
+ const run = chain.tail.then(() => publish(chain, storage, bus, topic, values, admit));
231
237
  chain.tail = run.catch(() => undefined);
232
238
  await chain.tail;
233
239
  }
@@ -74,12 +74,18 @@ export type { CollectionChangeEvent } from "./changes.js";
74
74
  export { PARTITION_PATTERN, collectionTopicPartition, collectionTopicTarget, partitionOfCall, storageOfCall, withPartition } from "./partition.js";
75
75
  export type { CollectionPartitions, PartitionInput, PartitionResolver } from "./partition.js";
76
76
 
77
+ // The rules of the rows of a collection: a filter of the reads and a guard of the writes (roadmap CLI-41)
78
+ export { admittedStorage, admitsRow } from "./admission.js";
79
+ export type { CollectionAdmission, CollectionRow, CollectionWrite, CollectionWriteGuard } from "./admission.js";
80
+
77
81
  // The storage interface of the core (the shape of client-collections' CollectionStorage)
78
82
  export type {
79
83
  CollectionStorage,
80
84
  StorageChanges,
81
85
  StorageMetadata,
82
86
  StorageWriteOptions,
87
+ StorageItemWriteOptions,
88
+ StorageBatchWriteOptions,
83
89
  StorageSetChange,
84
90
  StorageDeleteChange,
85
91
  StorageClearChange,
@@ -88,6 +94,21 @@ export type {
88
94
  StorageObserver,
89
95
  } from "./storage.js";
90
96
 
97
+ // The precondition of the writes: the expected version and VERSION_CONFLICT (roadmap CLI-16)
98
+ export {
99
+ MAX_EXPECTED_VERSION_LENGTH,
100
+ PRECONDITION_NOT_SUPPORTED,
101
+ VERSION_CONFLICTS_METADATA_KEY,
102
+ VersionConflictError,
103
+ asVersionConflictError,
104
+ isExpectedVersion,
105
+ preconditionNotSupported,
106
+ versionConflict,
107
+ versionConflicts,
108
+ versionConflictsOf,
109
+ } from "./precondition.js";
110
+ export type { CurrentItem, VersionConflict, VersionOf } from "./precondition.js";
111
+
91
112
  // The query language of the storages: the same JSON on the wire and in query() (roadmap CLI-34:
92
113
  // the one copy of the language)
93
114
  export {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The patch of an item of a storage (roadmap CLI-14): `patch()` of the storage, or a read, a merge
3
+ * and a write for a storage without it.
4
+ */
5
+ import { applyPatch, type StoragePatch } from "./query.js";
6
+ import type { CollectionStorage, StorageWriteOptions } from "./storage.js";
7
+
8
+ /**
9
+ * The patches of a storage without `patch()` that run, for each id. A patch of an id waits for the
10
+ * patch before it, so two patches of one process keep each field. A write of another writer
11
+ * between the read and the write of a patch is lost: give the storage a `patch()` for one step.
12
+ */
13
+ const PATCHES = new WeakMap<object, Map<string, Promise<unknown>>>();
14
+
15
+ /**
16
+ * This function patches an item of a storage without `patch()`: a read, a merge and a write. The
17
+ * check gets the stored item and the merged item before the write. An error of the check stops
18
+ * the patch, and the storage does not change.
19
+ */
20
+ export function patchInParts(
21
+ storage: CollectionStorage<unknown>,
22
+ id: string,
23
+ fields: StoragePatch,
24
+ options?: StorageWriteOptions,
25
+ check?: (prev: unknown, next: unknown) => Promise<void>,
26
+ ): Promise<unknown> {
27
+ let running = PATCHES.get(storage);
28
+ if (!running) {
29
+ running = new Map();
30
+ PATCHES.set(storage, running);
31
+ }
32
+ const queue = running;
33
+ const before = queue.get(id) ?? Promise.resolve();
34
+ const patch = before.then(async () => {
35
+ const current = await storage.get(id);
36
+ if (current === undefined) return undefined;
37
+ const next = applyPatch(current, fields);
38
+ if (check) await check(current, next);
39
+ await storage.set(id, next, options);
40
+ return next;
41
+ });
42
+ const settled = patch.then(
43
+ () => undefined,
44
+ () => undefined,
45
+ );
46
+ queue.set(id, settled);
47
+ void settled.then(() => {
48
+ if (queue.get(id) === settled) queue.delete(id);
49
+ });
50
+ return patch;
51
+ }
52
+
53
+ /** This function patches an item: with `patch()` of the storage, or in parts. It gives the new item, or undefined for a missing id. */
54
+ export function patchItem(storage: CollectionStorage<unknown>, id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<unknown> {
55
+ return storage.patch ? storage.patch(id, fields, options) : patchInParts(storage, id, fields, options);
56
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The precondition of a collection write (roadmap CLI-16, decision D11).
3
+ *
4
+ * - `set` and `delete` take `expectedVersion`. `setBatch` takes `expectedVersions`, with one
5
+ * expected version for each id. The value is the version of the current item that the write
6
+ * expects. `null` expects no item.
7
+ * - The version of an item is the value that the function `versionOf` of the storage gives for
8
+ * the item, for example a revision field. `HybridStorage` reads the same versions with its own
9
+ * `versionOf`, so its queue can send the base version of a write as the expected version.
10
+ * - The storage tests the precondition and writes in one step. When the version of the current
11
+ * item is not the expected version, the write changes nothing, and it rejects with
12
+ * `VersionConflictError`.
13
+ * - The error has the code `VERSION_CONFLICT` (HTTP 409). It has the current item and its version
14
+ * for each conflict. On the wire, the conflicts go in the metadata key `conflicts`.
15
+ * - A storage without `versionOf` cannot test a precondition. It rejects such a write with a
16
+ * `TypeError`, and changes nothing. It never ignores a precondition.
17
+ * - A write without a precondition is the same as before: the last write wins.
18
+ *
19
+ * This module has no imports: `@procedures/client/query` exports it for the storage packages.
20
+ */
21
+
22
+ /** One conflict of a write with a precondition (roadmap CLI-16). The data is JSON. */
23
+ export interface VersionConflict {
24
+ /** The id of the item. */
25
+ readonly id: string;
26
+ /** The version that the write expected. `null`: the write expected no item. */
27
+ readonly expected: string | null;
28
+ /** True when the id has an item now. */
29
+ readonly found: boolean;
30
+ /** The current item, when `found` is true. */
31
+ readonly value?: unknown;
32
+ /**
33
+ * The version of the current item: `null` when the id has no item. When the current item has no
34
+ * version, the field is absent.
35
+ */
36
+ readonly version?: string | null;
37
+ }
38
+
39
+ /** The function that gives the version of an item, or undefined for an item without a version. */
40
+ export type VersionOf<T> = (item: T) => string | undefined;
41
+
42
+ /** The current state of an id: no item, or an item. */
43
+ export type CurrentItem<T> = { readonly found: false } | { readonly found: true; readonly value: T };
44
+
45
+ /** The metadata key of the conflicts of a `VERSION_CONFLICT` error on the wire. */
46
+ export const VERSION_CONFLICTS_METADATA_KEY = "conflicts";
47
+
48
+ /** The longest expected version, in characters. */
49
+ export const MAX_EXPECTED_VERSION_LENGTH = 1024;
50
+
51
+ /** The message of an error with some conflicts. */
52
+ function conflictMessage(conflicts: readonly VersionConflict[]): string {
53
+ const ids = conflicts.slice(0, 10).map((conflict) => JSON.stringify(conflict.id)).join(", ");
54
+ const more = conflicts.length > 10 ? ` and ${conflicts.length - 10} more` : "";
55
+ return `The version of the current item is not the expected version: ${ids}${more}`;
56
+ }
57
+
58
+ /**
59
+ * A write with a precondition found an item with another version (roadmap CLI-16). The write
60
+ * changed nothing. `conflicts` has the current item and its version for each id of a conflict.
61
+ */
62
+ export class VersionConflictError extends Error {
63
+ override readonly name = "VersionConflictError";
64
+ /** The error code of the protocol: HTTP 409, not retryable. */
65
+ readonly code = "VERSION_CONFLICT";
66
+ /** A retry of the same write fails again: the caller reads the current item first. */
67
+ readonly retryable = false;
68
+ /** The conflicts, one for each id whose current item does not have the expected version. */
69
+ readonly conflicts: readonly VersionConflict[];
70
+
71
+ constructor(conflicts: readonly VersionConflict[], message?: string) {
72
+ super(message ?? conflictMessage(conflicts));
73
+ this.conflicts = conflicts;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * This function gives the conflict of one id, or undefined when the current item has the expected
79
+ * version. `null` expects no item. An item without a version never has an expected string version.
80
+ *
81
+ * @param id - The id of the item
82
+ * @param expected - The expected version, or `null` for no item
83
+ * @param current - The current state of the id
84
+ * @param versionOf - The version of an item
85
+ */
86
+ export function versionConflict<T>(id: string, expected: string | null, current: CurrentItem<T>, versionOf: VersionOf<T>): VersionConflict | undefined {
87
+ if (!current.found) return expected === null ? undefined : { id, expected, found: false, version: null };
88
+ const version = versionOf(current.value);
89
+ if (expected !== null && version === expected) return undefined;
90
+ return version === undefined ? { id, expected, found: true, value: current.value } : { id, expected, found: true, value: current.value, version };
91
+ }
92
+
93
+ /**
94
+ * This function tests the preconditions of a write against the current items, and gives the
95
+ * conflicts. The list is empty when each precondition holds.
96
+ *
97
+ * @param expected - The expected version of each id
98
+ * @param currentOf - The current state of an id, before the write
99
+ * @param versionOf - The version of an item
100
+ */
101
+ export function versionConflicts<T>(
102
+ expected: Iterable<readonly [string, string | null]>,
103
+ currentOf: (id: string) => CurrentItem<T>,
104
+ versionOf: VersionOf<T>,
105
+ ): VersionConflict[] {
106
+ const conflicts: VersionConflict[] = [];
107
+ for (const [id, version] of expected) {
108
+ const conflict = versionConflict(id, version, currentOf(id), versionOf);
109
+ if (conflict) conflicts.push(conflict);
110
+ }
111
+ return conflicts;
112
+ }
113
+
114
+ /** The code of the error of a write with a precondition on a storage that cannot test it. */
115
+ export const PRECONDITION_NOT_SUPPORTED = "PRECONDITION_NOT_SUPPORTED";
116
+
117
+ /**
118
+ * The error of a write with a precondition on a storage that cannot test it: a storage without
119
+ * `versionOf`. The storage changes nothing. The error is a `TypeError` with the code
120
+ * `PRECONDITION_NOT_SUPPORTED`, so a sync does not take it for a network failure.
121
+ *
122
+ * @param storage - The name of the storage, for the message
123
+ * @param remedy - What makes the test possible
124
+ */
125
+ export function preconditionNotSupported(storage: string, remedy = "give it the option versionOf"): TypeError & { readonly code: string; readonly retryable: false } {
126
+ return Object.assign(new TypeError(`${storage} cannot test expectedVersion: ${remedy}`), { code: PRECONDITION_NOT_SUPPORTED, retryable: false as const });
127
+ }
128
+
129
+ /** True for a value that a precondition can expect: a string of at most 1024 characters, or `null`. */
130
+ export function isExpectedVersion(value: unknown): value is string | null {
131
+ return value === null || (typeof value === "string" && value.length <= MAX_EXPECTED_VERSION_LENGTH);
132
+ }
133
+
134
+ /** The conflicts of untrusted data from the wire, or undefined when the data is not a list of conflicts. */
135
+ function conflictsOfData(data: unknown): VersionConflict[] | undefined {
136
+ if (!Array.isArray(data)) return undefined;
137
+ const conflicts: VersionConflict[] = [];
138
+ for (const entry of data) {
139
+ if (typeof entry !== "object" || entry === null) return undefined;
140
+ const record = entry as Record<string, unknown>;
141
+ const { id, expected, found, version } = record;
142
+ if (typeof id !== "string" || typeof found !== "boolean" || !(expected === null || typeof expected === "string")) return undefined;
143
+ if (!(version === undefined || version === null || typeof version === "string")) return undefined;
144
+ conflicts.push({
145
+ id,
146
+ expected,
147
+ found,
148
+ ...(found && Object.prototype.hasOwnProperty.call(record, "value") && { value: record["value"] }),
149
+ ...(version !== undefined && { version }),
150
+ });
151
+ }
152
+ return conflicts;
153
+ }
154
+
155
+ /**
156
+ * This function gives the conflicts of an error with the code `VERSION_CONFLICT`: the `conflicts`
157
+ * of a `VersionConflictError`, or the metadata key `conflicts` of an error from the wire (a
158
+ * `ClientError`). It gives undefined for another error. A `VERSION_CONFLICT` error without
159
+ * readable conflicts gives an empty list.
160
+ */
161
+ export function versionConflictsOf(error: unknown): readonly VersionConflict[] | undefined {
162
+ if (typeof error !== "object" || error === null) return undefined;
163
+ if (error instanceof VersionConflictError) return error.conflicts;
164
+ const { code, conflicts, metadata } = error as { code?: unknown; conflicts?: unknown; metadata?: unknown };
165
+ if (code !== "VERSION_CONFLICT") return undefined;
166
+ const fromMetadata = typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>)[VERSION_CONFLICTS_METADATA_KEY] : undefined;
167
+ return conflictsOfData(conflicts) ?? conflictsOfData(fromMetadata) ?? [];
168
+ }
169
+
170
+ /**
171
+ * This function gives a `VersionConflictError` for an error with the code `VERSION_CONFLICT`, with
172
+ * its conflicts and its message. It gives undefined for another error.
173
+ */
174
+ export function asVersionConflictError(error: unknown): VersionConflictError | undefined {
175
+ if (error instanceof VersionConflictError) return error;
176
+ const conflicts = versionConflictsOf(error);
177
+ if (conflicts === undefined) return undefined;
178
+ const message = error instanceof Error ? error.message : undefined;
179
+ return new VersionConflictError(conflicts, message);
180
+ }
@@ -56,10 +56,12 @@ import {
56
56
  type SetBatchInput,
57
57
  type DeleteBatchInput,
58
58
  } from "./schemas.js";
59
- import { DEFAULT_FIND_LIMIT, applyPatch, queryEntries, type StoragePatch, type StorageQueryOptions, type StorageQueryPolicy } from "./query.js";
60
- import type { CollectionStorage, StorageWriteOptions } from "./storage.js";
59
+ import { DEFAULT_FIND_LIMIT, queryEntries, type StorageQueryOptions, type StorageQueryPolicy } from "./query.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
+ import { admitsRow, admittedStorage, type CollectionAdmission } from "./admission.js";
64
+ import { patchItem } from "./patch-in-parts.js";
63
65
 
64
66
  // =============================================================================
65
67
  // Handler Factory
@@ -122,6 +124,13 @@ export interface CollectionProceduresOptions {
122
124
  * @default true, with the topic `collections.<name>` and no values
123
125
  */
124
126
  changeEvents?: boolean | CollectionChangeEventsOptions | undefined;
127
+ /**
128
+ * The rules of the rows (roadmap CLI-41). `admit` examines each row of each read and of each
129
+ * change event with values. `guard` examines each write before the storage changes, and a
130
+ * refused write gets `FORBIDDEN` (reason `write-not-allowed`). Without it, the procedures check
131
+ * only the partition.
132
+ */
133
+ admission?: CollectionAdmission | undefined;
125
134
  }
126
135
 
127
136
  /** The write options of an input with an origin. */
@@ -129,38 +138,17 @@ function writeOptionsOf(input: { origin?: string }): StorageWriteOptions | undef
129
138
  return input.origin === undefined ? undefined : { origin: input.origin };
130
139
  }
131
140
 
132
- /**
133
- * The patches of a storage without `patch()` that run, for each id. A patch of an id waits for the
134
- * patch before it, so two patches of one process keep each field. A write of another writer
135
- * between the read and the write of a patch is lost: give the storage a `patch()` for one step.
136
- */
137
- const PATCHES = new WeakMap<object, Map<string, Promise<unknown>>>();
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
+ }
138
146
 
139
- /** This function patches an item of a storage without `patch()`: a read, a merge and a write. */
140
- function patchInParts(storage: CollectionStorage<unknown>, id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<unknown> {
141
- let running = PATCHES.get(storage);
142
- if (!running) {
143
- running = new Map();
144
- PATCHES.set(storage, running);
145
- }
146
- const queue = running;
147
- const before = queue.get(id) ?? Promise.resolve();
148
- const patch = before.then(async () => {
149
- const current = await storage.get(id);
150
- if (current === undefined) return undefined;
151
- const next = applyPatch(current, fields);
152
- await storage.set(id, next, options);
153
- return next;
154
- });
155
- const settled = patch.then(
156
- () => undefined,
157
- () => undefined,
158
- );
159
- queue.set(id, settled);
160
- void settled.then(() => {
161
- if (queue.get(id) === settled) queue.delete(id);
162
- });
163
- return patch;
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 };
164
152
  }
165
153
 
166
154
  // =============================================================================
@@ -192,17 +180,20 @@ export function createCollectionProcedures(collectionName: string, options: Coll
192
180
  events === false ? undefined : (events !== true && events.topic) || ((name: string, _ctx: ProcedureContext, partition: string | undefined) => collectionChangeTopic(name, partition));
193
181
  const topicOf = (ctx: ProcedureContext, partition: string | undefined): string | undefined => topicRule?.(collectionName, ctx, partition);
194
182
  const withValues = events !== false && events !== true && events.values === true;
195
- /** A write, and then its change event on the bus of the context. */
183
+ const admission = options.admission;
184
+ /** A write, and then its change event on the bus of the context. The chain of the events belongs to the storage, not to its view. */
196
185
  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);
186
+ writeWithChangeEvent(ctx.bus, storage, topicRule ? topicOf(ctx, partition) : undefined, ids, origin, write, withValues, admission?.admit);
198
187
  /**
199
188
  * 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.
189
+ * partitions of the collection and their resolver: the tenant comes from the principal. Each read
190
+ * and each write goes through the view, with the rules of the rows (roadmap CLI-41).
201
191
  */
202
- const storageOf = async (input: PartitionInput, ctx: ProcedureContext): Promise<{ storage: CollectionStorage<unknown>; partition: string | undefined }> => {
192
+ const storageOf = async (input: PartitionInput, ctx: ProcedureContext): Promise<{ storage: CollectionStorage<unknown>; view: CollectionStorage<unknown>; partition: string | undefined }> => {
203
193
  const collection = getCollectionFromPath(ctx.path);
204
194
  const partition = await partitionOfCall(ctx, collection, input.partition);
205
- return { storage: storageOfCall<unknown>(ctx, collection, partition), partition };
195
+ const storage = storageOfCall<unknown>(ctx, collection, partition);
196
+ return { storage, view: admittedStorage(storage, admission, ctx), partition };
206
197
  };
207
198
 
208
199
  return [
@@ -217,8 +208,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
217
208
  tags: ["collections", collectionName, "read"],
218
209
  },
219
210
  handler: async (input: GetInput & PartitionInput, ctx: ProcedureContext) => {
220
- const { storage } = await storageOf(input, ctx);
221
- const value = await storage.get(input.id);
211
+ const { view } = await storageOf(input, ctx);
212
+ const value = await view.get(input.id);
222
213
  if (input.found !== true) return value;
223
214
  // The output with `found` keeps a stored null apart from a missing id, in one call
224
215
  const output: GetFoundOutput = value === undefined ? { found: false } : { found: true, value };
@@ -237,8 +228,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
237
228
  tags: ["collections", collectionName, "write"],
238
229
  },
239
230
  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)));
231
+ const { storage, view, partition } = await storageOf(input, ctx);
232
+ await changing(ctx, partition, storage, [input.id], input.origin, () => view.set(input.id, input.value, itemWriteOptionsOf(input)));
242
233
  },
243
234
  }),
244
235
 
@@ -253,8 +244,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
253
244
  tags: ["collections", collectionName, "write"],
254
245
  },
255
246
  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)));
247
+ const { storage, view, partition } = await storageOf(input, ctx);
248
+ return changing(ctx, partition, storage, [input.id], input.origin, () => view.delete(input.id, itemWriteOptionsOf(input)));
258
249
  },
259
250
  }),
260
251
 
@@ -269,8 +260,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
269
260
  tags: ["collections", collectionName, "read"],
270
261
  },
271
262
  handler: async (input: HasInput & PartitionInput, ctx: ProcedureContext) => {
272
- const { storage } = await storageOf(input, ctx);
273
- return storage.has(input.id);
263
+ const { view } = await storageOf(input, ctx);
264
+ return view.has(input.id);
274
265
  },
275
266
  }),
276
267
 
@@ -285,8 +276,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
285
276
  tags: ["collections", collectionName, "read"],
286
277
  },
287
278
  handler: async (input: GetAllInput & PartitionInput, ctx: ProcedureContext) => {
288
- const { storage } = await storageOf(input, ctx);
289
- return storage.getAll();
279
+ const { view } = await storageOf(input, ctx);
280
+ return view.getAll();
290
281
  },
291
282
  }),
292
283
 
@@ -301,11 +292,11 @@ export function createCollectionProcedures(collectionName: string, options: Coll
301
292
  tags: ["collections", collectionName, "read"],
302
293
  },
303
294
  handler: async (input: EntriesInput & PartitionInput, ctx: ProcedureContext) => {
304
- const { storage } = await storageOf(input, ctx);
305
- if (!storage.getEntries) {
295
+ const { view } = await storageOf(input, ctx);
296
+ if (!view.getEntries) {
306
297
  throw new Error(`The storage of ${collectionName} cannot give the ids of its items`);
307
298
  }
308
- return (await storage.getEntries()).map(([id, value]) => ({ id, value }));
299
+ return (await view.getEntries()).map(([id, value]) => ({ id, value }));
309
300
  },
310
301
  }),
311
302
 
@@ -320,10 +311,10 @@ export function createCollectionProcedures(collectionName: string, options: Coll
320
311
  tags: ["collections", collectionName, "read"],
321
312
  },
322
313
  handler: async (input: IdsInput & PartitionInput, ctx: ProcedureContext) => {
323
- const { storage } = await storageOf(input, ctx);
324
- if (storage.getIds) return storage.getIds();
314
+ const { view } = await storageOf(input, ctx);
315
+ if (view.getIds) return view.getIds();
325
316
  // A storage without getIds() gives its entries: the values stay on the server
326
- if (storage.getEntries) return (await storage.getEntries()).map(([id]) => id);
317
+ if (view.getEntries) return (await view.getEntries()).map(([id]) => id);
327
318
  throw new Error(`The storage of ${collectionName} cannot give the ids of its items`);
328
319
  },
329
320
  }),
@@ -339,9 +330,9 @@ export function createCollectionProcedures(collectionName: string, options: Coll
339
330
  tags: ["collections", collectionName, "read"],
340
331
  },
341
332
  handler: async (input: ChangesInput & PartitionInput, ctx: ProcedureContext): Promise<ChangesOutput> => {
342
- const { storage, partition } = await storageOf(input, ctx);
343
- if (storage.getChangesSince) {
344
- const changes = await storage.getChangesSince(input.since);
333
+ const { storage, view, partition } = await storageOf(input, ctx);
334
+ if (view.getChangesSince) {
335
+ const changes = await view.getChangesSince(input.since);
345
336
  // The chain of the change events starts at this version: the caller then applies the
346
337
  // next event without a read (roadmap CLI-15)
347
338
  if (topicRule) startChangeChain(ctx.bus, storage, topicOf(ctx, partition), changes.version);
@@ -354,8 +345,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
354
345
  }
355
346
  // A storage without a change log gives all its items. The empty version asks for all
356
347
  // items again at the next call.
357
- if (storage.getEntries) {
358
- const entries = await storage.getEntries();
348
+ if (view.getEntries) {
349
+ const entries = await view.getEntries();
359
350
  return { version: "", full: true, changed: entries.map(([id, value]) => ({ id, value })), deleted: [] };
360
351
  }
361
352
  throw new Error(`The storage of ${collectionName} cannot give its changes or the ids of its items`);
@@ -373,16 +364,16 @@ export function createCollectionProcedures(collectionName: string, options: Coll
373
364
  tags: ["collections", collectionName, "read", "query"],
374
365
  },
375
366
  handler: async (input: FindInput & PartitionInput, ctx: ProcedureContext) => {
376
- const { storage } = await storageOf(input, ctx);
367
+ const { view } = await storageOf(input, ctx);
377
368
  const selector = input.selector ?? {};
378
369
  const query = queryOptionsOf(input, queryPolicy);
379
370
  // The storage runs the selector itself when it can: a database does not read each item. It
380
371
  // gets the policy of the host, so it permits what the procedure permits.
381
- if (storage.query) return (await storage.query(selector, query, queryPolicy)).map(([id, value]) => ({ id, value }));
382
- if (!storage.getEntries) {
372
+ if (view.query) return (await view.query(selector, query, queryPolicy)).map(([id, value]) => ({ id, value }));
373
+ if (!view.getEntries) {
383
374
  throw new Error(`The storage of ${collectionName} cannot run a selector or give the ids of its items`);
384
375
  }
385
- return queryEntries(await storage.getEntries(), selector, query, queryPolicy).map(([id, value]) => ({ id, value }));
376
+ return queryEntries(await view.getEntries(), selector, query, queryPolicy).map(([id, value]) => ({ id, value }));
386
377
  },
387
378
  }),
388
379
 
@@ -397,12 +388,12 @@ export function createCollectionProcedures(collectionName: string, options: Coll
397
388
  tags: ["collections", collectionName, "write"],
398
389
  },
399
390
  handler: async (input: PatchInput & PartitionInput, ctx: ProcedureContext) => {
400
- const { storage, partition } = await storageOf(input, ctx);
391
+ const { storage, view, partition } = await storageOf(input, ctx);
401
392
  const write = writeOptionsOf(input);
402
- const value = await changing(ctx, partition, storage, [input.id], input.origin, () =>
403
- storage.patch ? storage.patch(input.id, input.fields, write) : patchInParts(storage, input.id, input.fields, write),
404
- );
405
- const output: GetFoundOutput = value === undefined ? { found: false } : { found: true, value };
393
+ const value = await changing(ctx, partition, storage, [input.id], input.origin, () => patchItem(view, input.id, input.fields, write));
394
+ if (value === undefined) return { found: false } satisfies GetFoundOutput;
395
+ // The new item is a row too: a caller gets it only when the rule of the rows admits it (roadmap CLI-41)
396
+ const output: GetFoundOutput = (await admitsRow(admission, ctx, input.id, value)) ? { found: true, value } : { found: true };
406
397
  return output;
407
398
  },
408
399
  }),
@@ -418,8 +409,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
418
409
  tags: ["collections", collectionName, "read"],
419
410
  },
420
411
  handler: async (input: SizeInput & PartitionInput, ctx: ProcedureContext) => {
421
- const { storage } = await storageOf(input, ctx);
422
- return storage.size();
412
+ const { view } = await storageOf(input, ctx);
413
+ return view.size();
423
414
  },
424
415
  }),
425
416
 
@@ -434,8 +425,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
434
425
  tags: ["collections", collectionName, "write"],
435
426
  },
436
427
  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)));
428
+ const { storage, view, partition } = await storageOf(input, ctx);
429
+ await changing(ctx, partition, storage, "*", input.origin, () => view.clear(writeOptionsOf(input)));
439
430
  },
440
431
  }),
441
432
 
@@ -450,8 +441,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
450
441
  tags: ["collections", collectionName, "read", "batch"],
451
442
  },
452
443
  handler: async (input: GetBatchInput & PartitionInput, ctx: ProcedureContext) => {
453
- const { storage } = await storageOf(input, ctx);
454
- const result = await storage.getBatch(input.ids);
444
+ const { view } = await storageOf(input, ctx);
445
+ const result = await view.getBatch(input.ids);
455
446
  // Convert Map to plain object
456
447
  return Object.fromEntries(result);
457
448
  },
@@ -468,12 +459,12 @@ export function createCollectionProcedures(collectionName: string, options: Coll
468
459
  tags: ["collections", collectionName, "write", "batch"],
469
460
  },
470
461
  handler: async (input: SetBatchInput & PartitionInput, ctx: ProcedureContext) => {
471
- const { storage, partition } = await storageOf(input, ctx);
462
+ const { storage, view, partition } = await storageOf(input, ctx);
472
463
  const items: Array<[string, unknown]> = input.items.map((item) => [
473
464
  item.id,
474
465
  item.value,
475
466
  ]);
476
- await changing(ctx, partition, storage, items.map(([id]) => id), input.origin, () => storage.setBatch(items, writeOptionsOf(input)));
467
+ await changing(ctx, partition, storage, items.map(([id]) => id), input.origin, () => view.setBatch(items, batchWriteOptionsOf(input)));
477
468
  },
478
469
  }),
479
470
 
@@ -488,8 +479,8 @@ export function createCollectionProcedures(collectionName: string, options: Coll
488
479
  tags: ["collections", collectionName, "write", "batch"],
489
480
  },
490
481
  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)));
482
+ const { storage, view, partition } = await storageOf(input, ctx);
483
+ return changing(ctx, partition, storage, input.ids, input.origin, () => view.deleteBatch(input.ids, writeOptionsOf(input)));
493
484
  },
494
485
  }),
495
486
  ];