@procedures/store 0.8.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +88 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/sync-state-store.d.ts +40 -0
  5. package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
  6. package/dist/indexeddb/sync-state-store.js +94 -0
  7. package/dist/indexeddb/sync-state-store.js.map +1 -0
  8. package/dist/indexeddb.d.ts +7 -2
  9. package/dist/indexeddb.d.ts.map +1 -1
  10. package/dist/indexeddb.js +6 -2
  11. package/dist/indexeddb.js.map +1 -1
  12. package/dist/storage/api.d.ts +84 -11
  13. package/dist/storage/api.d.ts.map +1 -1
  14. package/dist/storage/api.js +148 -25
  15. package/dist/storage/api.js.map +1 -1
  16. package/dist/storage/feed.d.ts +133 -0
  17. package/dist/storage/feed.d.ts.map +1 -0
  18. package/dist/storage/feed.js +159 -0
  19. package/dist/storage/feed.js.map +1 -0
  20. package/dist/storage/hybrid.d.ts +367 -20
  21. package/dist/storage/hybrid.d.ts.map +1 -1
  22. package/dist/storage/hybrid.js +1295 -206
  23. package/dist/storage/hybrid.js.map +1 -1
  24. package/dist/storage/index.d.ts +6 -2
  25. package/dist/storage/index.d.ts.map +1 -1
  26. package/dist/storage/index.js +6 -1
  27. package/dist/storage/index.js.map +1 -1
  28. package/dist/storage/sync-state.d.ts +56 -0
  29. package/dist/storage/sync-state.d.ts.map +1 -0
  30. package/dist/storage/sync-state.js +58 -0
  31. package/dist/storage/sync-state.js.map +1 -0
  32. package/dist/storage/synced-registry.d.ts +22 -4
  33. package/dist/storage/synced-registry.d.ts.map +1 -1
  34. package/dist/storage/synced-registry.js +63 -31
  35. package/dist/storage/synced-registry.js.map +1 -1
  36. package/dist/storage/types.d.ts +22 -1
  37. package/dist/storage/types.d.ts.map +1 -1
  38. package/package.json +4 -3
  39. package/src/indexeddb/sync-state-store.ts +101 -0
  40. package/src/indexeddb.ts +7 -2
  41. package/src/storage/api.ts +210 -29
  42. package/src/storage/feed.ts +251 -0
  43. package/src/storage/hybrid.ts +1404 -211
  44. package/src/storage/index.ts +17 -0
  45. package/src/storage/sync-state.ts +89 -0
  46. package/src/storage/synced-registry.ts +76 -31
  47. package/src/storage/types.ts +25 -1
@@ -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,7 +50,7 @@ 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;
@@ -47,6 +59,7 @@ export interface ApiStorageOptions {
47
59
  ids?: string;
48
60
  changes?: string;
49
61
  find?: string;
62
+ patch?: string;
50
63
  has?: string;
51
64
  size?: string;
52
65
  set?: string;
@@ -65,17 +78,58 @@ export interface ApiStorageOptions {
65
78
 
66
79
  /**
67
80
  * Retry a call that fails with a retryable error: true (3 attempts), false (1 attempt), or the
68
- * number of attempts.
81
+ * number of attempts. Each collection procedure is idempotent, so a retry is safe.
69
82
  * @default true
70
83
  */
71
84
  retry?: boolean | number;
72
85
 
73
86
  /**
74
- * The first delay between attempts. Each later delay is two times longer.
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`.
75
89
  * @default 100 milliseconds
76
90
  */
77
91
  retryDelay?: Duration;
78
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
+
79
133
  /**
80
134
  * AbortSignal for cancelling requests
81
135
  */
@@ -88,6 +142,18 @@ export interface ApiStorageOptions {
88
142
  closeClient?: boolean;
89
143
  }
90
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
+
91
157
  /** An error that says whether another attempt can succeed (a ClientError does). */
92
158
  function isRetryable(error: unknown): boolean {
93
159
  return (
@@ -97,6 +163,12 @@ function isRetryable(error: unknown): boolean {
97
163
  );
98
164
  }
99
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
+
100
172
  /** A promise that rejects when the signal aborts first. */
101
173
  function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined, describe: () => Error): Promise<T> {
102
174
  if (!signal) return promise;
@@ -117,6 +189,25 @@ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined, d
117
189
  });
118
190
  }
119
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
+
120
211
  /** True when the server does not know a procedure: an older server without `ids`. */
121
212
  function isNotFound(error: unknown): boolean {
122
213
  return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
@@ -132,6 +223,14 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
132
223
  return typeof record["found"] === "boolean" && Object.keys(record).every((key) => key === "found" || key === "value");
133
224
  }
134
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
+
135
234
  /**
136
235
  * API storage backed by universal client.
137
236
  *
@@ -141,11 +240,16 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
141
240
  * - set(id, value) → `collections.<collection>.set` with `{ id, value }`
142
241
  * - setBatch(items) → `collections.<collection>.setBatch` with `{ items: [{ id, value }] }`
143
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 }`
144
245
  * - etc.
145
246
  *
146
247
  * Before, the class expected a `{ data }` envelope and called `<service>.get`: no core
147
248
  * procedure matched, and every read was undefined (deep dive CORE-9, DATA-9).
148
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
+ *
149
253
  * @example
150
254
  * ```typescript
151
255
  * // Create API storage
@@ -158,6 +262,7 @@ function isFoundOutput(value: unknown): value is { found: boolean; value?: unkno
158
262
  * // Operations are automatically sent to server
159
263
  * await storage.set("123", { name: "John" });
160
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
161
266
  * ```
162
267
  */
163
268
  export class ApiStorage<T> implements CollectionStorage<T> {
@@ -168,21 +273,32 @@ export class ApiStorage<T> implements CollectionStorage<T> {
168
273
  operations: Required<NonNullable<ApiStorageOptions["operations"]>>;
169
274
  timeout: number;
170
275
  attempts: number;
171
- retryDelay: number;
276
+ backoff: Backoff;
277
+ clock: Clock;
278
+ random: () => number;
279
+ queryPolicy: StorageQueryPolicy;
172
280
  signal?: AbortSignal;
173
281
  closeClient: boolean;
174
282
  /** True when the storage calls the `get` of the core collection procedures */
175
283
  askFound: boolean;
284
+ /** True when the writes send their origin: the core collection procedures take it (roadmap CLI-15) */
285
+ sendOrigin: boolean;
176
286
  };
177
287
  private closed = false;
178
288
  /** True when the server does not know the procedure `ids`: `getIds()` reads the entries */
179
289
  private idsUnsupported = false;
290
+ /** True when the server does not know the procedure `find`: `query()` reads the entries */
291
+ private findUnsupported = false;
180
292
 
181
293
  constructor(client: Client, options: ApiStorageOptions) {
182
294
  const service = options.service ?? (options.collection !== undefined ? `collections.${options.collection}` : undefined);
183
295
  if (!service) {
184
296
  throw new Error("ApiStorage requires a collection or a service");
185
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}`);
186
302
  this.client = client;
187
303
  this.options = {
188
304
  service,
@@ -194,6 +310,7 @@ export class ApiStorage<T> implements CollectionStorage<T> {
194
310
  ids: "ids",
195
311
  changes: "changes",
196
312
  find: "find",
313
+ patch: "patch",
197
314
  has: "has",
198
315
  size: "size",
199
316
  set: "set",
@@ -207,11 +324,21 @@ export class ApiStorage<T> implements CollectionStorage<T> {
207
324
  timeout: optionalMs(options.timeout, "timeout") ?? 30000,
208
325
  attempts:
209
326
  options.retry === false ? 1 : options.retry === true || options.retry === undefined ? 3 : Math.max(1, options.retry),
210
- retryDelay: optionalMs(options.retryDelay, "retryDelay") ?? 100,
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,
211
336
  ...(options.signal !== undefined && { signal: options.signal }),
212
337
  closeClient: options.closeClient ?? false,
213
338
  // A custom service can refuse an unknown input field: it gets the input { id }
214
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,
215
342
  };
216
343
  }
217
344
 
@@ -244,11 +371,7 @@ export class ApiStorage<T> implements CollectionStorage<T> {
244
371
 
245
372
  /** Every item with its id, from the `entries` procedure of the collection (roadmap R2). */
246
373
  async getEntries(): Promise<Array<[string, T]>> {
247
- const entries = await this.call<Array<{ id: string; value: T }>>("entries", {});
248
- if (!Array.isArray(entries)) {
249
- throw new Error(`${this.options.service}.entries did not return an array`);
250
- }
251
- return entries.map((entry) => [entry.id, entry.value]);
374
+ return entriesOf<T>(await this.call<unknown>("entries", {}), `${this.options.service}.entries`);
252
375
  }
253
376
 
254
377
  /**
@@ -296,12 +419,46 @@ export class ApiStorage<T> implements CollectionStorage<T> {
296
419
  };
297
420
  }
298
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
+ */
299
426
  async find(predicate: (item: T) => boolean): Promise<T[]> {
300
- // A predicate cannot go over the network: read all items, then filter here
301
427
  const all = await this.getAll();
302
428
  return all.filter(predicate);
303
429
  }
304
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
+
305
462
  async has(id: string): Promise<boolean> {
306
463
  return (await this.call<boolean>("has", { id })) === true;
307
464
  }
@@ -314,29 +471,46 @@ export class ApiStorage<T> implements CollectionStorage<T> {
314
471
  // ═══ Write Operations ═══
315
472
  //
316
473
 
317
- async set(id: string, value: T): Promise<void> {
318
- await this.call("set", { id, value });
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) });
319
480
  }
320
481
 
321
- async delete(id: string): Promise<boolean> {
322
- 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;
323
484
  }
324
485
 
325
- async clear(): Promise<void> {
326
- 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;
327
501
  }
328
502
 
329
503
  //
330
504
  // ═══ Bulk Operations ═══
331
505
  //
332
506
 
333
- async setBatch(items: Array<[string, T]>): Promise<void> {
507
+ async setBatch(items: Array<[string, T]>, options?: StorageWriteOptions): Promise<void> {
334
508
  // The collection procedure takes objects, not tuples
335
- 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) });
336
510
  }
337
511
 
338
- async deleteBatch(ids: string[]): Promise<number> {
339
- 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) }));
340
514
  }
341
515
 
342
516
  async getBatch(ids: string[]): Promise<Map<string, T>> {
@@ -385,8 +559,15 @@ export class ApiStorage<T> implements CollectionStorage<T> {
385
559
  // ═══ Internal Helpers ═══
386
560
  //
387
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
+
388
567
  /**
389
- * 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.
390
571
  */
391
572
  private async call<TRes>(operation: keyof Required<ApiStorageOptions>["operations"], payload: unknown): Promise<TRes> {
392
573
  if (this.closed) throw new Error(`The ApiStorage of ${this.options.service} is closed`);
@@ -404,8 +585,8 @@ export class ApiStorage<T> implements CollectionStorage<T> {
404
585
  if (attempt >= this.options.attempts || !isRetryable(error) || this.options.signal?.aborted) {
405
586
  throw error;
406
587
  }
407
- const delay = this.options.retryDelay * 2 ** (attempt - 1);
408
- await untilAborted(new Promise((resolve) => setTimeout(resolve, delay)), this.options.signal, () => error as Error);
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);
409
590
  }
410
591
  }
411
592
  }
@@ -415,9 +596,9 @@ export class ApiStorage<T> implements CollectionStorage<T> {
415
596
  const signals: AbortSignal[] = [];
416
597
  if (this.options.signal) signals.push(this.options.signal);
417
598
  const timer = this.options.timeout > 0 ? new AbortController() : undefined;
418
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
599
+ let cancelTimeout: (() => void) | undefined;
419
600
  if (timer) {
420
- timeoutHandle = setTimeout(() => timer.abort(), this.options.timeout);
601
+ cancelTimeout = this.options.clock.after(this.options.timeout, () => timer.abort());
421
602
  signals.push(timer.signal);
422
603
  }
423
604
  const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
@@ -432,7 +613,7 @@ export class ApiStorage<T> implements CollectionStorage<T> {
432
613
  return untilAborted(call, signal, () =>
433
614
  timer?.signal.aborted ? new Error(`${label} timed out after ${this.options.timeout} ms`) : new Error(`${label} was aborted`)
434
615
  ).finally(() => {
435
- if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
616
+ cancelTimeout?.();
436
617
  });
437
618
  }
438
619
  }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The push feed of the remote changes of a `HybridStorage` (roadmap CLI-21, CLI-15).
3
+ *
4
+ * A `RemoteChangeFeed` pushes the deltas of the remote. When the `since` of a delta is the cursor
5
+ * of the storage, the storage applies the delta without a read. A gap, a lost event or a new
6
+ * subscription makes the storage read the changes since its cursor. Thus the feed replaces most
7
+ * of the periodic pulls, and the pulls stay correct when the feed loses an event.
8
+ *
9
+ * `EventsChangeFeed` is the feed of the change events of the core collection procedures: the topic
10
+ * `collections.<name>` of `events.subscribe` (`documentation/EVENTS.md`). By default, an event has
11
+ * only the ids and the versions: the feed gives it as a hint, and the storage reads the values
12
+ * with a pull, through the checks of a read. The file imports no module of Node, so it runs in a
13
+ * browser.
14
+ */
15
+
16
+ import type { StorageChanges } from "@procedures/collections";
17
+ import {
18
+ collectionChangeTopic,
19
+ subscribeRemote,
20
+ type RemoteEvent,
21
+ type SubscribeOptions,
22
+ type Subscription,
23
+ type SubscriptionError,
24
+ } from "@procedures/client";
25
+ import type { EventTransport } from "@procedures/client/events";
26
+
27
+ /**
28
+ * A delta of the remote that a feed pushes: the changes between two versions of the remote. The
29
+ * changes have the form of `getChangesSince()`.
30
+ */
31
+ export interface RemoteChanges<T> extends StorageChanges<T> {
32
+ /**
33
+ * The version of the remote before the changes. A storage applies the delta without a read only
34
+ * when this version is its cursor. Without it, the storage reads the changes.
35
+ */
36
+ readonly since?: string | undefined;
37
+ /** The origin of the writes of the delta, when one origin made all of them. */
38
+ readonly origin?: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * A hint of the remote (roadmap CLI-15): the ids that changed between two versions, without their
43
+ * values. A value reaches a subscriber only through a read, with the checks of a read.
44
+ */
45
+ export interface RemoteChangeHint {
46
+ /** The version of the remote before the changes, or undefined when the chain of versions starts. */
47
+ readonly since?: string | undefined;
48
+ /** The version of the remote after the changes, or undefined for a remote without a change log. */
49
+ readonly version?: string | undefined;
50
+ /** The ids of the items that changed: the listener reads their values. */
51
+ readonly changed: readonly string[];
52
+ /** The ids that were deleted. A delete needs no value. */
53
+ readonly deleted: readonly string[];
54
+ /** The origin of the writes, when one origin made all of them. */
55
+ readonly origin?: string | undefined;
56
+ }
57
+
58
+ /** The functions that get the pushes of a feed. */
59
+ export interface RemoteChangeListener<T> {
60
+ /** This function gets a delta of the remote with its values. The listener applies it, or reads the changes. */
61
+ onChanges(changes: RemoteChanges<T>): void;
62
+ /**
63
+ * This function gets a hint without values. The listener reads the changes, or applies the
64
+ * deletes when no value changed. Without it, a hint goes to `onGap()`.
65
+ */
66
+ onHint?(hint: RemoteChangeHint): void;
67
+ /**
68
+ * This function tells that the feed can have lost changes: a gap of the events, a lost event, or
69
+ * a new subscription. The listener reads the changes since its cursor.
70
+ */
71
+ onGap(): void;
72
+ /** This function gets the error that stops the feed. The listener then reads the changes only with its pulls. */
73
+ onError?(error: unknown): void;
74
+ }
75
+
76
+ /** A push feed of the changes of a remote storage (roadmap CLI-21). */
77
+ export interface RemoteChangeFeed<T> {
78
+ /**
79
+ * This method starts to push the changes to a listener. Each new subscription calls `onGap()`
80
+ * when it starts, so the listener reads the changes that came before it.
81
+ *
82
+ * @returns The function that stops the pushes. A second use of it does nothing.
83
+ */
84
+ subscribe(listener: RemoteChangeListener<T>): () => void;
85
+ }
86
+
87
+ /**
88
+ * An object that subscribes to the topics of the event bus of a server: a `Client`, or a
89
+ * `WebSocketTransport` of `@procedures/client`.
90
+ */
91
+ export interface EventSubscriber {
92
+ subscribe<E = unknown>(
93
+ topics: string | readonly string[],
94
+ handler: (data: E, event: RemoteEvent<E>) => void | Promise<void>,
95
+ options?: SubscribeOptions,
96
+ ): Subscription;
97
+ }
98
+
99
+ /** The source of the events of an `EventsChangeFeed`: an `EventSubscriber`, or a transport. */
100
+ export type ChangeEventSource = EventSubscriber | EventTransport;
101
+
102
+ /** The options of an `EventsChangeFeed`. */
103
+ export interface EventsChangeFeedOptions {
104
+ /** The collection of the core collection procedures. The topic is `collections.<collection>`. */
105
+ collection?: string | undefined;
106
+ /** The topic of the change events, for example with a partition. It comes before `collection`. */
107
+ topic?: string | undefined;
108
+ /**
109
+ * The options of the subscription: the resubscribe rules, the backoff, the clock and the
110
+ * metadata of the call. The feed gives `onSubscribed` and `onError` itself.
111
+ * @default `{ maxAttempts: Infinity }`: the feed resubscribes until it stops
112
+ */
113
+ subscription?: Omit<SubscribeOptions, "onSubscribed" | "onError"> | undefined;
114
+ /** This function gets the error that ends the subscription, for example `TOPIC_NOT_FOUND`. */
115
+ onError?: ((error: SubscriptionError) => void) | undefined;
116
+ }
117
+
118
+ /** True for a source with `subscribe()`: it subscribes itself. */
119
+ function isSubscriber(source: ChangeEventSource): source is EventSubscriber {
120
+ return typeof (source as Partial<EventSubscriber>).subscribe === "function";
121
+ }
122
+
123
+ /** True for a string, or for no value. */
124
+ const optionalString = (value: unknown): value is string | undefined => value === undefined || typeof value === "string";
125
+
126
+ /** True for a list of ids. */
127
+ const isIds = (value: unknown): value is string[] => Array.isArray(value) && value.every((id) => typeof id === "string");
128
+
129
+ /**
130
+ * The delta or the hint of a change event of the collection procedures, or undefined for an
131
+ * event of another form. The event comes over the wire: the function examines each field.
132
+ *
133
+ * - An event with `since`, `version` and a value for each changed id is a delta: `changes`.
134
+ * - Each other event is a hint: an event without values, an event with withheld values (the
135
+ * filter of the host refused them), or a hint of a storage without a change log.
136
+ */
137
+ function eventOf<T>(data: unknown): { readonly changes: RemoteChanges<T> } | { readonly hint: RemoteChangeHint } | undefined {
138
+ if (typeof data !== "object" || data === null) return undefined;
139
+ const event = data as { since?: unknown; version?: unknown; changed?: unknown; deleted?: unknown; values?: unknown; withheld?: unknown; origin?: unknown };
140
+ const { since, version, origin } = event;
141
+ if (!optionalString(since) || !optionalString(version) || !optionalString(origin)) return undefined;
142
+ if (!isIds(event.changed) || !isIds(event.deleted) || (event.withheld !== undefined && !isIds(event.withheld))) return undefined;
143
+ let values: Array<[string, T]> | undefined;
144
+ if (event.values !== undefined) {
145
+ if (!Array.isArray(event.values)) return undefined;
146
+ values = [];
147
+ for (const value of event.values as unknown[]) {
148
+ const entry = value as { id?: unknown; value?: unknown } | null;
149
+ if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || !("value" in entry)) return undefined;
150
+ values.push([entry.id, entry.value as T]);
151
+ }
152
+ }
153
+ const withOrigin = origin !== undefined ? { origin } : {};
154
+ const valued = new Set(values?.map(([id]) => id));
155
+ const withheld = (event.withheld as string[] | undefined) ?? [];
156
+ const complete = values !== undefined && withheld.length === 0 && (event.changed as string[]).every((id) => valued.has(id));
157
+ if (since !== undefined && version !== undefined && complete) {
158
+ return { changes: { since, version, full: false, changed: values!, deleted: event.deleted as string[], ...withOrigin } };
159
+ }
160
+ return {
161
+ hint: {
162
+ ...(since !== undefined && { since }),
163
+ ...(version !== undefined && { version }),
164
+ changed: event.changed as string[],
165
+ deleted: event.deleted as string[],
166
+ ...withOrigin,
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * The push feed of the change events of the core collection procedures (roadmap CLI-15). It
173
+ * subscribes to the topic `collections.<name>` with `events.subscribe`. The delivery of the events
174
+ * is at most once, so the feed treats them as hints:
175
+ *
176
+ * - An event without values (the default of the procedures) or with withheld values goes to
177
+ * `onHint()`. The storage reads the values with a pull, so each value passes the checks of a
178
+ * read. A hint with only deletes applies without a pull when `since` is the cursor.
179
+ * - An event with the values of each changed id (the option `values` of the procedures) goes to
180
+ * `onChanges()`. The storage applies it without a read when `since` is its cursor.
181
+ * - A gap of the numbers of the events, a lost event (`dropped`), an event of another form, and
182
+ * each confirmation of the subscription (the first one and each resubscribe) call `onGap()`.
183
+ * - The error that ends the subscription goes to `onError` of the options and of the listener.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const users = new HybridStorage(new ApiStorage(client, { collection: "users" }), {
188
+ * versionOf: (user) => user.rev,
189
+ * feed: new EventsChangeFeed(client, { collection: "users" }),
190
+ * });
191
+ * ```
192
+ */
193
+ export class EventsChangeFeed<T> implements RemoteChangeFeed<T> {
194
+ /** The topic of the change events. */
195
+ readonly topic: string;
196
+ private readonly source: ChangeEventSource;
197
+ private readonly subscription: Omit<SubscribeOptions, "onSubscribed" | "onError">;
198
+ private readonly onError: ((error: SubscriptionError) => void) | undefined;
199
+
200
+ /**
201
+ * @param source - A `Client` or a `WebSocketTransport`, or another transport of the client
202
+ * @param options - The collection or the topic, and the options of the subscription
203
+ */
204
+ constructor(source: ChangeEventSource, options: EventsChangeFeedOptions) {
205
+ const topic = options.topic ?? (options.collection !== undefined ? collectionChangeTopic(options.collection) : undefined);
206
+ if (topic === undefined || topic === "") throw new TypeError("EventsChangeFeed needs a collection or a topic");
207
+ this.topic = topic;
208
+ this.source = source;
209
+ this.subscription = { maxAttempts: Number.POSITIVE_INFINITY, ...options.subscription };
210
+ this.onError = options.onError;
211
+ }
212
+
213
+ subscribe(listener: RemoteChangeListener<T>): () => void {
214
+ /** The number of the next event: a resubscribe starts again at 1 */
215
+ let next = 1;
216
+ let stopped = false;
217
+ const handler = (data: unknown, event: RemoteEvent<unknown>): void => {
218
+ if (stopped) return;
219
+ const lost = event.dropped > 0 || event.seq !== next;
220
+ next = event.seq + 1;
221
+ if (lost) listener.onGap();
222
+ const parsed = eventOf<T>(data);
223
+ if (parsed === undefined) listener.onGap();
224
+ else if ("changes" in parsed) listener.onChanges(parsed.changes);
225
+ // A hint: the listener reads the values with a read of its own
226
+ else if (listener.onHint) listener.onHint(parsed.hint);
227
+ else listener.onGap();
228
+ };
229
+ const options: SubscribeOptions = {
230
+ ...this.subscription,
231
+ onSubscribed: () => {
232
+ if (stopped) return;
233
+ next = 1;
234
+ listener.onGap();
235
+ },
236
+ onError: (error) => {
237
+ if (stopped) return;
238
+ this.onError?.(error);
239
+ listener.onError?.(error);
240
+ },
241
+ };
242
+ const unsubscribe = isSubscriber(this.source)
243
+ ? this.source.subscribe<unknown>(this.topic, handler, options)
244
+ : subscribeRemote<unknown>(this.source, this.topic, handler, options);
245
+ return () => {
246
+ if (stopped) return;
247
+ stopped = true;
248
+ unsubscribe();
249
+ };
250
+ }
251
+ }