@procedures/store 0.7.0 → 0.8.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 +77 -0
- package/README.md +98 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/indexeddb/sync-state-store.d.ts +40 -0
- package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
- package/dist/indexeddb/sync-state-store.js +94 -0
- package/dist/indexeddb/sync-state-store.js.map +1 -0
- package/dist/indexeddb.d.ts +7 -2
- package/dist/indexeddb.d.ts.map +1 -1
- package/dist/indexeddb.js +6 -2
- package/dist/indexeddb.js.map +1 -1
- package/dist/storage/api.d.ts +94 -11
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +177 -24
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/feed.d.ts +133 -0
- package/dist/storage/feed.d.ts.map +1 -0
- package/dist/storage/feed.js +159 -0
- package/dist/storage/feed.js.map +1 -0
- package/dist/storage/file-queue.d.ts.map +1 -1
- package/dist/storage/file-queue.js +2 -1
- package/dist/storage/file-queue.js.map +1 -1
- package/dist/storage/hybrid.d.ts +415 -20
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1405 -196
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +6 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +6 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/sync-state.d.ts +56 -0
- package/dist/storage/sync-state.d.ts.map +1 -0
- package/dist/storage/sync-state.js +58 -0
- package/dist/storage/sync-state.js.map +1 -0
- package/dist/storage/synced-registry.d.ts +22 -4
- package/dist/storage/synced-registry.d.ts.map +1 -1
- package/dist/storage/synced-registry.js +63 -31
- package/dist/storage/synced-registry.js.map +1 -1
- package/dist/storage/types.d.ts +22 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/indexeddb/sync-state-store.ts +101 -0
- package/src/indexeddb.ts +7 -2
- package/src/storage/api.ts +240 -28
- package/src/storage/feed.ts +251 -0
- package/src/storage/file-queue.ts +2 -1
- package/src/storage/hybrid.ts +1526 -203
- package/src/storage/index.ts +17 -0
- package/src/storage/sync-state.ts +89 -0
- package/src/storage/synced-registry.ts +76 -31
- package/src/storage/types.ts +25 -1
package/src/storage/api.ts
CHANGED
|
@@ -7,11 +7,23 @@
|
|
|
7
7
|
* This is the KEY integration between Collections and Universal Client!
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
|
|
10
|
+
import type { CollectionStorage, StorageChanges, StorageMetadata, StorageWriteOptions } from "@procedures/collections";
|
|
11
|
+
import {
|
|
12
|
+
QUERY_OPERATORS,
|
|
13
|
+
patchProblems,
|
|
14
|
+
queryEntries,
|
|
15
|
+
queryOptionsProblems,
|
|
16
|
+
selectorProblems,
|
|
17
|
+
type StoragePatch,
|
|
18
|
+
type StorageQueryOptions,
|
|
19
|
+
type StorageQueryPolicy,
|
|
20
|
+
type StorageSelector,
|
|
21
|
+
} from "@procedures/client/query";
|
|
11
22
|
import type { Client } from "@procedures/client";
|
|
12
23
|
import type { Method } from "@procedures/client";
|
|
13
24
|
import type { CallOptions } from "@procedures/client/client";
|
|
14
|
-
import { optionalMs, type Duration } from "@procedures/client";
|
|
25
|
+
import { optionalMs, realClock, type Clock, type Duration } from "@procedures/client";
|
|
26
|
+
import { backoffDelay, type Backoff } from "@mark1russell7/service/backoff";
|
|
15
27
|
|
|
16
28
|
/**
|
|
17
29
|
* API storage configuration options.
|
|
@@ -38,14 +50,16 @@ export interface ApiStorageOptions {
|
|
|
38
50
|
version?: string;
|
|
39
51
|
|
|
40
52
|
/**
|
|
41
|
-
* Custom operation names (override defaults)
|
|
53
|
+
* Custom operation names (override defaults). `find` is the operation of `query()`.
|
|
42
54
|
*/
|
|
43
55
|
operations?: {
|
|
44
56
|
get?: string;
|
|
45
57
|
getAll?: string;
|
|
46
58
|
entries?: string;
|
|
59
|
+
ids?: string;
|
|
47
60
|
changes?: string;
|
|
48
61
|
find?: string;
|
|
62
|
+
patch?: string;
|
|
49
63
|
has?: string;
|
|
50
64
|
size?: string;
|
|
51
65
|
set?: string;
|
|
@@ -64,17 +78,58 @@ export interface ApiStorageOptions {
|
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
80
|
* Retry a call that fails with a retryable error: true (3 attempts), false (1 attempt), or the
|
|
67
|
-
* number of attempts.
|
|
81
|
+
* number of attempts. Each collection procedure is idempotent, so a retry is safe.
|
|
68
82
|
* @default true
|
|
69
83
|
*/
|
|
70
84
|
retry?: boolean | number;
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
|
-
* The
|
|
87
|
+
* The backoff of the first retry. Each later backoff is `retryFactor` times longer, at most
|
|
88
|
+
* `maxRetryDelay`. The wait is `backoffDelay` of `@mark1russell7/service/backoff`.
|
|
74
89
|
* @default 100 milliseconds
|
|
75
90
|
*/
|
|
76
91
|
retryDelay?: Duration;
|
|
77
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The factor of the backoff from one retry to the next.
|
|
95
|
+
* @default 2
|
|
96
|
+
*/
|
|
97
|
+
retryFactor?: number;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The longest backoff of a retry, before its random part.
|
|
101
|
+
* @default 30 seconds
|
|
102
|
+
*/
|
|
103
|
+
maxRetryDelay?: Duration;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The random part of each wait, from 0 to 1. With 1 (full jitter), the wait is a random time from
|
|
107
|
+
* 0 to the backoff, so the clients that failed together do not retry together. 0 gives the
|
|
108
|
+
* backoff exactly. A server that asks for a wait (`retryAfterMs` of the error) gets at least it.
|
|
109
|
+
* @default 1
|
|
110
|
+
*/
|
|
111
|
+
retryJitter?: number;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The clock of the timeout and of the waits between attempts. A test gives `fakeClock` of
|
|
115
|
+
* `@mark1russell7/service/testing`.
|
|
116
|
+
* @default the clock of the platform (`realClock` of `@procedures/client`)
|
|
117
|
+
*/
|
|
118
|
+
clock?: Clock;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The random numbers of the jitter, from 0 up to 1. A test gives a fixed generator.
|
|
122
|
+
* @default Math.random
|
|
123
|
+
*/
|
|
124
|
+
random?: () => number;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The query policy that `query()` applies before it sends a query, for a call without a policy.
|
|
128
|
+
* The server applies its own policy too. Without it, the storage examines only the form of a
|
|
129
|
+
* selector, and the server decides the rest.
|
|
130
|
+
*/
|
|
131
|
+
queryPolicy?: StorageQueryPolicy;
|
|
132
|
+
|
|
78
133
|
/**
|
|
79
134
|
* AbortSignal for cancelling requests
|
|
80
135
|
*/
|
|
@@ -87,6 +142,18 @@ export interface ApiStorageOptions {
|
|
|
87
142
|
closeClient?: boolean;
|
|
88
143
|
}
|
|
89
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The policy of a query that only the server limits: each operator, and no limit of size. The form
|
|
147
|
+
* of the selector must still be correct.
|
|
148
|
+
*/
|
|
149
|
+
const FORM_ONLY: StorageQueryPolicy = Object.freeze({
|
|
150
|
+
operators: QUERY_OPERATORS,
|
|
151
|
+
maxRegexLength: Number.POSITIVE_INFINITY,
|
|
152
|
+
maxClauses: Number.POSITIVE_INFINITY,
|
|
153
|
+
maxDepth: Number.POSITIVE_INFINITY,
|
|
154
|
+
maxListLength: Number.POSITIVE_INFINITY,
|
|
155
|
+
});
|
|
156
|
+
|
|
90
157
|
/** An error that says whether another attempt can succeed (a ClientError does). */
|
|
91
158
|
function isRetryable(error: unknown): boolean {
|
|
92
159
|
return (
|
|
@@ -96,6 +163,12 @@ function isRetryable(error: unknown): boolean {
|
|
|
96
163
|
);
|
|
97
164
|
}
|
|
98
165
|
|
|
166
|
+
/** The wait that an error asks for before a retry, in milliseconds, or 0. */
|
|
167
|
+
function retryAfterOf(error: unknown): number {
|
|
168
|
+
const value = (error as { retryAfterMs?: unknown } | null)?.retryAfterMs;
|
|
169
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
99
172
|
/** A promise that rejects when the signal aborts first. */
|
|
100
173
|
function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined, describe: () => Error): Promise<T> {
|
|
101
174
|
if (!signal) return promise;
|
|
@@ -116,6 +189,30 @@ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined, d
|
|
|
116
189
|
});
|
|
117
190
|
}
|
|
118
191
|
|
|
192
|
+
/** A wait on the clock. The signal stops the wait and its timer. */
|
|
193
|
+
function wait(clock: Clock, ms: number, signal: AbortSignal | undefined, describe: () => Error): Promise<void> {
|
|
194
|
+
return new Promise<void>((resolve, reject) => {
|
|
195
|
+
if (signal?.aborted) {
|
|
196
|
+
reject(describe());
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const onAbort = (): void => {
|
|
200
|
+
cancel();
|
|
201
|
+
reject(describe());
|
|
202
|
+
};
|
|
203
|
+
const cancel = clock.after(ms, () => {
|
|
204
|
+
signal?.removeEventListener("abort", onAbort);
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when the server does not know a procedure: an older server without `ids`. */
|
|
212
|
+
function isNotFound(error: unknown): boolean {
|
|
213
|
+
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
|
|
214
|
+
}
|
|
215
|
+
|
|
119
216
|
/**
|
|
120
217
|
* True for the output `{ found, value }` of the core procedure `get` with `found: true`: an
|
|
121
218
|
* object with a boolean `found`, and no key other than `found` and `value`.
|
|
@@ -126,6 +223,14 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
|
|
|
126
223
|
return typeof record["found"] === "boolean" && Object.keys(record).every((key) => key === "found" || key === "value");
|
|
127
224
|
}
|
|
128
225
|
|
|
226
|
+
/** The entries of the output `[{ id, value }]` of `entries` or `find`. */
|
|
227
|
+
function entriesOf<T>(output: unknown, label: string): Array<[string, T]> {
|
|
228
|
+
if (!Array.isArray(output) || !output.every((entry) => typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string")) {
|
|
229
|
+
throw new Error(`${label} did not return an array of { id, value }`);
|
|
230
|
+
}
|
|
231
|
+
return (output as Array<{ id: string; value: T }>).map((entry) => [entry.id, entry.value]);
|
|
232
|
+
}
|
|
233
|
+
|
|
129
234
|
/**
|
|
130
235
|
* API storage backed by universal client.
|
|
131
236
|
*
|
|
@@ -134,11 +239,17 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
|
|
|
134
239
|
* - get(id) → `collections.<collection>.get` with `{ id }`
|
|
135
240
|
* - set(id, value) → `collections.<collection>.set` with `{ id, value }`
|
|
136
241
|
* - setBatch(items) → `collections.<collection>.setBatch` with `{ items: [{ id, value }] }`
|
|
242
|
+
* - getIds() → `collections.<collection>.ids` with `{}`, or the entries of an older server
|
|
243
|
+
* - query(selector, options) → `collections.<collection>.find` with `{ selector, sort, skip, limit }`
|
|
244
|
+
* - patch(id, fields) → `collections.<collection>.patch` with `{ id, fields }`
|
|
137
245
|
* - etc.
|
|
138
246
|
*
|
|
139
247
|
* Before, the class expected a `{ data }` envelope and called `<service>.get`: no core
|
|
140
248
|
* procedure matched, and every read was undefined (deep dive CORE-9, DATA-9).
|
|
141
249
|
*
|
|
250
|
+
* The retries wait with `backoffDelay` of `@mark1russell7/service/backoff` on the clock of the
|
|
251
|
+
* options, with full jitter by default. Before, the waits had no jitter, and they used the timers of the platform directly.
|
|
252
|
+
*
|
|
142
253
|
* @example
|
|
143
254
|
* ```typescript
|
|
144
255
|
* // Create API storage
|
|
@@ -151,6 +262,7 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
|
|
|
151
262
|
* // Operations are automatically sent to server
|
|
152
263
|
* await storage.set("123", { name: "John" });
|
|
153
264
|
* const user = await storage.get("123"); // Fetched from server
|
|
265
|
+
* const admins = await storage.query({ role: "admin" }, { sort: { name: 1 }, limit: 50 }); // Runs on the server
|
|
154
266
|
* ```
|
|
155
267
|
*/
|
|
156
268
|
export class ApiStorage<T> implements CollectionStorage<T> {
|
|
@@ -161,19 +273,32 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
161
273
|
operations: Required<NonNullable<ApiStorageOptions["operations"]>>;
|
|
162
274
|
timeout: number;
|
|
163
275
|
attempts: number;
|
|
164
|
-
|
|
276
|
+
backoff: Backoff;
|
|
277
|
+
clock: Clock;
|
|
278
|
+
random: () => number;
|
|
279
|
+
queryPolicy: StorageQueryPolicy;
|
|
165
280
|
signal?: AbortSignal;
|
|
166
281
|
closeClient: boolean;
|
|
167
282
|
/** True when the storage calls the `get` of the core collection procedures */
|
|
168
283
|
askFound: boolean;
|
|
284
|
+
/** True when the writes send their origin: the core collection procedures take it (roadmap CLI-15) */
|
|
285
|
+
sendOrigin: boolean;
|
|
169
286
|
};
|
|
170
287
|
private closed = false;
|
|
288
|
+
/** True when the server does not know the procedure `ids`: `getIds()` reads the entries */
|
|
289
|
+
private idsUnsupported = false;
|
|
290
|
+
/** True when the server does not know the procedure `find`: `query()` reads the entries */
|
|
291
|
+
private findUnsupported = false;
|
|
171
292
|
|
|
172
293
|
constructor(client: Client, options: ApiStorageOptions) {
|
|
173
294
|
const service = options.service ?? (options.collection !== undefined ? `collections.${options.collection}` : undefined);
|
|
174
295
|
if (!service) {
|
|
175
296
|
throw new Error("ApiStorage requires a collection or a service");
|
|
176
297
|
}
|
|
298
|
+
const jitter = options.retryJitter ?? 1;
|
|
299
|
+
if (!(jitter >= 0 && jitter <= 1)) throw new RangeError(`retryJitter must be from 0 to 1, not ${jitter}`);
|
|
300
|
+
const factor = options.retryFactor ?? 2;
|
|
301
|
+
if (!(Number.isFinite(factor) && factor >= 1)) throw new RangeError(`retryFactor must be a number of 1 or more, not ${factor}`);
|
|
177
302
|
this.client = client;
|
|
178
303
|
this.options = {
|
|
179
304
|
service,
|
|
@@ -182,8 +307,10 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
182
307
|
get: "get",
|
|
183
308
|
getAll: "getAll",
|
|
184
309
|
entries: "entries",
|
|
310
|
+
ids: "ids",
|
|
185
311
|
changes: "changes",
|
|
186
312
|
find: "find",
|
|
313
|
+
patch: "patch",
|
|
187
314
|
has: "has",
|
|
188
315
|
size: "size",
|
|
189
316
|
set: "set",
|
|
@@ -197,11 +324,21 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
197
324
|
timeout: optionalMs(options.timeout, "timeout") ?? 30000,
|
|
198
325
|
attempts:
|
|
199
326
|
options.retry === false ? 1 : options.retry === true || options.retry === undefined ? 3 : Math.max(1, options.retry),
|
|
200
|
-
|
|
327
|
+
backoff: {
|
|
328
|
+
base: optionalMs(options.retryDelay, "retryDelay") ?? 100,
|
|
329
|
+
factor,
|
|
330
|
+
cap: optionalMs(options.maxRetryDelay, "maxRetryDelay") ?? 30000,
|
|
331
|
+
jitter: jitter === 0 ? { kind: "none" } : { kind: "subtract", fraction: jitter },
|
|
332
|
+
},
|
|
333
|
+
clock: options.clock ?? realClock,
|
|
334
|
+
random: options.random ?? Math.random,
|
|
335
|
+
queryPolicy: options.queryPolicy ?? FORM_ONLY,
|
|
201
336
|
...(options.signal !== undefined && { signal: options.signal }),
|
|
202
337
|
closeClient: options.closeClient ?? false,
|
|
203
338
|
// A custom service can refuse an unknown input field: it gets the input { id }
|
|
204
339
|
askFound: options.service === undefined && (options.operations?.get ?? "get") === "get",
|
|
340
|
+
// A custom service can refuse an unknown input field: it gets no origin
|
|
341
|
+
sendOrigin: options.service === undefined,
|
|
205
342
|
};
|
|
206
343
|
}
|
|
207
344
|
|
|
@@ -234,11 +371,28 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
234
371
|
|
|
235
372
|
/** Every item with its id, from the `entries` procedure of the collection (roadmap R2). */
|
|
236
373
|
async getEntries(): Promise<Array<[string, T]>> {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
374
|
+
return entriesOf<T>(await this.call<unknown>("entries", {}), `${this.options.service}.entries`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The ids of the items in the order of the entries, from the `ids` procedure of the collection.
|
|
379
|
+
* The values stay on the server. An older server without the procedure answers `NOT_FOUND`.
|
|
380
|
+
* Then this call and each later call read the ids from `getEntries()`.
|
|
381
|
+
*/
|
|
382
|
+
async getIds(): Promise<string[]> {
|
|
383
|
+
if (!this.idsUnsupported) {
|
|
384
|
+
try {
|
|
385
|
+
const ids = await this.call<string[]>("ids", {});
|
|
386
|
+
if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string")) {
|
|
387
|
+
throw new Error(`${this.options.service}.ids did not return an array of ids`);
|
|
388
|
+
}
|
|
389
|
+
return ids;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (!isNotFound(error)) throw error;
|
|
392
|
+
this.idsUnsupported = true;
|
|
393
|
+
}
|
|
240
394
|
}
|
|
241
|
-
return
|
|
395
|
+
return (await this.getEntries()).map(([id]) => id);
|
|
242
396
|
}
|
|
243
397
|
|
|
244
398
|
/**
|
|
@@ -265,12 +419,46 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
265
419
|
};
|
|
266
420
|
}
|
|
267
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The items for which the predicate gives true. A function cannot go over the network, so this
|
|
424
|
+
* method reads all items and filters here. `query()` sends a JSON selector to the server.
|
|
425
|
+
*/
|
|
268
426
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
269
|
-
// A predicate cannot go over the network: read all items, then filter here
|
|
270
427
|
const all = await this.getAll();
|
|
271
428
|
return all.filter(predicate);
|
|
272
429
|
}
|
|
273
430
|
|
|
431
|
+
/**
|
|
432
|
+
* The entries whose items match the selector, from the `find` procedure of the collection: the
|
|
433
|
+
* server runs the selector, the order, the skip and the limit (roadmap CLI-14), under its own
|
|
434
|
+
* query policy. An older server without the procedure answers `NOT_FOUND`. Then this call and
|
|
435
|
+
* each later call read the entries and run the query here, with the same result.
|
|
436
|
+
*
|
|
437
|
+
* @param policy - The policy that the storage applies before it sends the query. The default is
|
|
438
|
+
* the option `queryPolicy`. The server applies its own policy too.
|
|
439
|
+
*/
|
|
440
|
+
async query(selector: StorageSelector, options: StorageQueryOptions = {}, policy?: StorageQueryPolicy): Promise<Array<[string, T]>> {
|
|
441
|
+
// A selector with a problem does not go to the server: the server would refuse it
|
|
442
|
+
const limits = policy ?? this.options.queryPolicy;
|
|
443
|
+
const problems = [...selectorProblems(selector, limits), ...queryOptionsProblems(options, limits)];
|
|
444
|
+
if (problems.length > 0) throw new TypeError(`The query is not correct: ${problems.join(". ")}`);
|
|
445
|
+
if (!this.findUnsupported) {
|
|
446
|
+
const input = {
|
|
447
|
+
selector,
|
|
448
|
+
...(options.sort !== undefined && { sort: options.sort }),
|
|
449
|
+
...(options.skip !== undefined && { skip: options.skip }),
|
|
450
|
+
...(options.limit !== undefined && { limit: options.limit }),
|
|
451
|
+
};
|
|
452
|
+
try {
|
|
453
|
+
return entriesOf<T>(await this.call<unknown>("find", input), `${this.options.service}.find`);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (!isNotFound(error)) throw error;
|
|
456
|
+
this.findUnsupported = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return queryEntries(await this.getEntries(), selector, options, limits);
|
|
460
|
+
}
|
|
461
|
+
|
|
274
462
|
async has(id: string): Promise<boolean> {
|
|
275
463
|
return (await this.call<boolean>("has", { id })) === true;
|
|
276
464
|
}
|
|
@@ -283,29 +471,46 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
283
471
|
// ═══ Write Operations ═══
|
|
284
472
|
//
|
|
285
473
|
|
|
286
|
-
|
|
287
|
-
|
|
474
|
+
/**
|
|
475
|
+
* This method sets an item on the server. The origin of the options goes to the procedure `set`,
|
|
476
|
+
* so the storage of the server and the change event of the write have it (roadmap CLI-15).
|
|
477
|
+
*/
|
|
478
|
+
async set(id: string, value: T, options?: StorageWriteOptions): Promise<void> {
|
|
479
|
+
await this.call("set", { id, value, ...this.originOf(options) });
|
|
288
480
|
}
|
|
289
481
|
|
|
290
|
-
async delete(id: string): Promise<boolean> {
|
|
291
|
-
return (await this.call<boolean>("delete", { id })) === true;
|
|
482
|
+
async delete(id: string, options?: StorageWriteOptions): Promise<boolean> {
|
|
483
|
+
return (await this.call<boolean>("delete", { id, ...this.originOf(options) })) === true;
|
|
292
484
|
}
|
|
293
485
|
|
|
294
|
-
async clear(): Promise<void> {
|
|
295
|
-
await this.call("clear", {});
|
|
486
|
+
async clear(options?: StorageWriteOptions): Promise<void> {
|
|
487
|
+
await this.call("clear", { ...this.originOf(options) });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* This method changes an item on the server in one step: the `patch` procedure of the collection
|
|
492
|
+
* (roadmap CLI-14). The fields are a JSON Merge Patch, or the operations `$set` and `$unset`.
|
|
493
|
+
* It gives the new item, or undefined for a missing id.
|
|
494
|
+
*/
|
|
495
|
+
async patch(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined> {
|
|
496
|
+
const [problem] = patchProblems(fields);
|
|
497
|
+
if (problem !== undefined) throw new TypeError(`The patch of ${id} is not correct: ${problem}`);
|
|
498
|
+
const result = await this.call<unknown>("patch", { id, fields, ...this.originOf(options) });
|
|
499
|
+
if (!isFoundOutput(result)) throw new Error(`${this.options.service}.patch did not return { found, value }`);
|
|
500
|
+
return result.found ? (result.value as T) : undefined;
|
|
296
501
|
}
|
|
297
502
|
|
|
298
503
|
//
|
|
299
504
|
// ═══ Bulk Operations ═══
|
|
300
505
|
//
|
|
301
506
|
|
|
302
|
-
async setBatch(items: Array<[string, T]
|
|
507
|
+
async setBatch(items: Array<[string, T]>, options?: StorageWriteOptions): Promise<void> {
|
|
303
508
|
// The collection procedure takes objects, not tuples
|
|
304
|
-
await this.call("setBatch", { items: items.map(([id, value]) => ({ id, value })) });
|
|
509
|
+
await this.call("setBatch", { items: items.map(([id, value]) => ({ id, value })), ...this.originOf(options) });
|
|
305
510
|
}
|
|
306
511
|
|
|
307
|
-
async deleteBatch(ids: string[]): Promise<number> {
|
|
308
|
-
return Number(await this.call<number>("deleteBatch", { ids }));
|
|
512
|
+
async deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number> {
|
|
513
|
+
return Number(await this.call<number>("deleteBatch", { ids, ...this.originOf(options) }));
|
|
309
514
|
}
|
|
310
515
|
|
|
311
516
|
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
@@ -354,8 +559,15 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
354
559
|
// ═══ Internal Helpers ═══
|
|
355
560
|
//
|
|
356
561
|
|
|
562
|
+
/** The field `origin` of a write input, or no field: a custom service gets no origin. */
|
|
563
|
+
private originOf(options: StorageWriteOptions | undefined): { origin?: string } {
|
|
564
|
+
return this.options.sendOrigin && options?.origin !== undefined ? { origin: options.origin } : {};
|
|
565
|
+
}
|
|
566
|
+
|
|
357
567
|
/**
|
|
358
|
-
* Make an RPC call via the universal client, with the timeout, the signal and the retries.
|
|
568
|
+
* Make an RPC call via the universal client, with the timeout, the signal and the retries. The
|
|
569
|
+
* wait before retry `n` is `backoffDelay(backoff, n)`, or the wait that the error asks for when
|
|
570
|
+
* it is longer.
|
|
359
571
|
*/
|
|
360
572
|
private async call<TRes>(operation: keyof Required<ApiStorageOptions>["operations"], payload: unknown): Promise<TRes> {
|
|
361
573
|
if (this.closed) throw new Error(`The ApiStorage of ${this.options.service} is closed`);
|
|
@@ -373,8 +585,8 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
373
585
|
if (attempt >= this.options.attempts || !isRetryable(error) || this.options.signal?.aborted) {
|
|
374
586
|
throw error;
|
|
375
587
|
}
|
|
376
|
-
const delay = this.options.
|
|
377
|
-
await
|
|
588
|
+
const delay = Math.max(backoffDelay(this.options.backoff, attempt, this.options.random), retryAfterOf(error));
|
|
589
|
+
await wait(this.options.clock, delay, this.options.signal, () => error as Error);
|
|
378
590
|
}
|
|
379
591
|
}
|
|
380
592
|
}
|
|
@@ -384,9 +596,9 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
384
596
|
const signals: AbortSignal[] = [];
|
|
385
597
|
if (this.options.signal) signals.push(this.options.signal);
|
|
386
598
|
const timer = this.options.timeout > 0 ? new AbortController() : undefined;
|
|
387
|
-
let
|
|
599
|
+
let cancelTimeout: (() => void) | undefined;
|
|
388
600
|
if (timer) {
|
|
389
|
-
|
|
601
|
+
cancelTimeout = this.options.clock.after(this.options.timeout, () => timer.abort());
|
|
390
602
|
signals.push(timer.signal);
|
|
391
603
|
}
|
|
392
604
|
const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
@@ -401,7 +613,7 @@ export class ApiStorage<T> implements CollectionStorage<T> {
|
|
|
401
613
|
return untilAborted(call, signal, () =>
|
|
402
614
|
timer?.signal.aborted ? new Error(`${label} timed out after ${this.options.timeout} ms`) : new Error(`${label} was aborted`)
|
|
403
615
|
).finally(() => {
|
|
404
|
-
|
|
616
|
+
cancelTimeout?.();
|
|
405
617
|
});
|
|
406
618
|
}
|
|
407
619
|
}
|