@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.
- package/CHANGELOG.md +47 -0
- package/README.md +88 -0
- 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 +84 -11
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +148 -25
- 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/hybrid.d.ts +367 -20
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1295 -206
- 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 +210 -29
- package/src/storage/feed.ts +251 -0
- package/src/storage/hybrid.ts +1404 -211
- 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/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type {
|
|
|
16
16
|
SyncConflict,
|
|
17
17
|
SyncResult,
|
|
18
18
|
SyncStatus,
|
|
19
|
+
SyncError,
|
|
19
20
|
SyncedRegistryOptions,
|
|
20
21
|
ProcedureStorageConfig,
|
|
21
22
|
} from "./types.js";
|
|
@@ -44,8 +45,24 @@ export {
|
|
|
44
45
|
QueueStoreError,
|
|
45
46
|
ConflictError,
|
|
46
47
|
ModeSwitchError,
|
|
48
|
+
SyncStateError,
|
|
47
49
|
} from "./hybrid.js";
|
|
48
50
|
export type { HybridStorageOptions, HybridMode, LocalCopyStorage } from "./hybrid.js";
|
|
51
|
+
// The push feed of the remote changes (roadmap CLI-21, CLI-15)
|
|
52
|
+
export { EventsChangeFeed } from "./feed.js";
|
|
53
|
+
export type {
|
|
54
|
+
ChangeEventSource,
|
|
55
|
+
EventSubscriber,
|
|
56
|
+
EventsChangeFeedOptions,
|
|
57
|
+
RemoteChangeFeed,
|
|
58
|
+
RemoteChangeHint,
|
|
59
|
+
RemoteChangeListener,
|
|
60
|
+
RemoteChanges,
|
|
61
|
+
} from "./feed.js";
|
|
62
|
+
// The sync state of HybridStorage: the cursor and the base versions (roadmap CLI-21).
|
|
63
|
+
// IndexedDbSyncStateStore is in "@procedures/store/indexeddb".
|
|
64
|
+
export { MemorySyncStateStore } from "./sync-state.js";
|
|
65
|
+
export type { SyncState, SyncStateChange, SyncStateStore } from "./sync-state.js";
|
|
49
66
|
// The queue of HybridStorage and its store (roadmap R28). FileQueueStore is in
|
|
50
67
|
// "@procedures/store/file-queue": it uses node:fs. IndexedDbQueueStore is in
|
|
51
68
|
// "@procedures/store/indexeddb": it uses the IndexedDB of a browser (roadmap R45).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync state of a `HybridStorage` and the store that keeps it (roadmap CLI-21).
|
|
3
|
+
*
|
|
4
|
+
* The sync state is the cursor (the remote version of the last pull) and the base versions (the
|
|
5
|
+
* last known remote version of each id). A store keeps them after a reload. A new storage with the
|
|
6
|
+
* same store then asks the remote only for the changes since the cursor, and it finds the conflicts
|
|
7
|
+
* of the writes after the reload.
|
|
8
|
+
*
|
|
9
|
+
* - `MemorySyncStateStore` keeps the state in memory. A new storage that gets the same instance
|
|
10
|
+
* loads the state, so the tests can simulate a reload.
|
|
11
|
+
* - `IndexedDbSyncStateStore` keeps the state in IndexedDB, for a browser. It is in
|
|
12
|
+
* `@procedures/store/indexeddb`.
|
|
13
|
+
*
|
|
14
|
+
* The state belongs to its local copy. Give a store only with a durable local copy, and give each
|
|
15
|
+
* storage its own store.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The sync state of a `HybridStorage`. Each field is JSON. */
|
|
19
|
+
export interface SyncState {
|
|
20
|
+
/** The remote version of the last pull, or undefined: the next pull reads all items. */
|
|
21
|
+
readonly cursor?: string | undefined;
|
|
22
|
+
/** The last known remote version of each id. `null`: the remote has no item. */
|
|
23
|
+
readonly bases: ReadonlyArray<readonly [string, string | null]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A change of the sync state, from a `HybridStorage` to its store. */
|
|
27
|
+
export interface SyncStateChange extends SyncState {
|
|
28
|
+
/** True: the change replaces the whole state. False: it changes the cursor and the ids that it names. */
|
|
29
|
+
readonly reset: boolean;
|
|
30
|
+
/** The ids whose base is not known any more. Empty when `reset` is true. */
|
|
31
|
+
readonly forgotten: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The store of the sync state of a `HybridStorage` (roadmap CLI-21). The storage calls the methods
|
|
36
|
+
* one at a time, in the order of the changes.
|
|
37
|
+
*/
|
|
38
|
+
export interface SyncStateStore {
|
|
39
|
+
/** This method gives the state that the store holds, or undefined when it holds none. */
|
|
40
|
+
load(): Promise<SyncState | undefined>;
|
|
41
|
+
/** This method writes a change of the state. */
|
|
42
|
+
write(change: SyncStateChange): Promise<void>;
|
|
43
|
+
/** This method removes the state. */
|
|
44
|
+
clear(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A sync state store in memory. Its state lives as long as the instance. */
|
|
48
|
+
export class MemorySyncStateStore implements SyncStateStore {
|
|
49
|
+
private cursor: string | undefined;
|
|
50
|
+
private readonly bases = new Map<string, string | null>();
|
|
51
|
+
private written = false;
|
|
52
|
+
|
|
53
|
+
load(): Promise<SyncState | undefined> {
|
|
54
|
+
if (!this.written) return Promise.resolve(undefined);
|
|
55
|
+
return Promise.resolve({ ...(this.cursor !== undefined && { cursor: this.cursor }), bases: [...this.bases] });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
write(change: SyncStateChange): Promise<void> {
|
|
59
|
+
if (change.reset) this.bases.clear();
|
|
60
|
+
this.cursor = change.cursor;
|
|
61
|
+
for (const [id, version] of change.bases) this.bases.set(id, version);
|
|
62
|
+
for (const id of change.forgotten) this.bases.delete(id);
|
|
63
|
+
this.written = true;
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
clear(): Promise<void> {
|
|
68
|
+
this.cursor = undefined;
|
|
69
|
+
this.bases.clear();
|
|
70
|
+
this.written = false;
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The number of base versions that the store holds. */
|
|
75
|
+
get size(): number {
|
|
76
|
+
return this.bases.size;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when a value has the shape of a sync state. A loaded store can hold anything. */
|
|
81
|
+
export function isSyncState(value: unknown): value is SyncState {
|
|
82
|
+
if (typeof value !== "object" || value === null) return false;
|
|
83
|
+
const { cursor, bases } = value as { cursor?: unknown; bases?: unknown };
|
|
84
|
+
return (
|
|
85
|
+
(cursor === undefined || typeof cursor === "string") &&
|
|
86
|
+
Array.isArray(bases) &&
|
|
87
|
+
bases.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && (entry[1] === null || typeof entry[1] === "string"))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* Enables procedure persistence and sync across backends.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { StorageListeners, type CollectionStorage } from "@procedures/collections";
|
|
9
9
|
import { ProcedureRegistry } from "@procedures/client";
|
|
10
10
|
import type { AnyProcedure, ProcedurePath, RegistrationOptions } from "@procedures/client";
|
|
11
11
|
import { pathToKey } from "@procedures/client";
|
|
12
12
|
import type {
|
|
13
13
|
SerializedProcedure,
|
|
14
14
|
SyncedRegistryOptions,
|
|
15
|
+
SyncError,
|
|
15
16
|
SyncResult,
|
|
16
17
|
SyncStatus,
|
|
17
18
|
SyncDirection,
|
|
@@ -21,7 +22,7 @@ import type {
|
|
|
21
22
|
} from "./types.js";
|
|
22
23
|
import { ProcedureStorageAdapter } from "./adapter.js";
|
|
23
24
|
import { serializeProcedure, deserializeProcedure, getSerializedKey } from "./serialization.js";
|
|
24
|
-
import { optionalMs, toMs, type Duration } from "@procedures/client";
|
|
25
|
+
import { optionalMs, realClock, toMs, type Cancel, type Clock, type Duration } from "@procedures/client";
|
|
25
26
|
import type { HybridMode } from "./hybrid.js";
|
|
26
27
|
|
|
27
28
|
/** A storage with modes: `HybridStorage` (roadmap R28). */
|
|
@@ -155,8 +156,17 @@ export class SyncedProcedureRegistry {
|
|
|
155
156
|
/** Whether currently syncing */
|
|
156
157
|
private syncing = false;
|
|
157
158
|
|
|
158
|
-
/**
|
|
159
|
-
private
|
|
159
|
+
/** The clock of the auto-sync */
|
|
160
|
+
private readonly clock: Clock;
|
|
161
|
+
|
|
162
|
+
/** The auto-sync that runs: the timer of its next sync */
|
|
163
|
+
private autoSync: { cancel: Cancel | undefined } | undefined;
|
|
164
|
+
|
|
165
|
+
/** The failures of the auto-sync and of the write-through writes that a later success did not clear */
|
|
166
|
+
private readonly failures = new Map<SyncError["operation"], SyncError>();
|
|
167
|
+
|
|
168
|
+
/** The listeners of `onStatus()` */
|
|
169
|
+
private readonly statusListeners = new StorageListeners<SyncStatus>();
|
|
160
170
|
|
|
161
171
|
constructor(
|
|
162
172
|
baseRegistry: ProcedureRegistry,
|
|
@@ -175,6 +185,7 @@ export class SyncedProcedureRegistry {
|
|
|
175
185
|
handlerLoader: options.handlerLoader,
|
|
176
186
|
syncOnInit: options.syncOnInit ?? false,
|
|
177
187
|
};
|
|
188
|
+
this.clock = options.clock ?? realClock;
|
|
178
189
|
|
|
179
190
|
// The storage procedures of this registry find this wrapper
|
|
180
191
|
STORES.set(baseRegistry, this);
|
|
@@ -216,11 +227,12 @@ export class SyncedProcedureRegistry {
|
|
|
216
227
|
}
|
|
217
228
|
this.pendingDeletes.delete(key);
|
|
218
229
|
if (this.options.writeStrategy === "write-through") {
|
|
219
|
-
// Write-through: persist now. flushWrites() waits for these writes.
|
|
230
|
+
// Write-through: persist now. flushWrites() waits for these writes. A failure goes to the status.
|
|
220
231
|
this.track(
|
|
221
|
-
this.storeRecords([registered]).
|
|
222
|
-
|
|
223
|
-
|
|
232
|
+
this.storeRecords([registered]).then(
|
|
233
|
+
() => this.succeed("push"),
|
|
234
|
+
(error: unknown) => this.fail("push", new Error(`Failed to persist procedure ${key}`, { cause: error })),
|
|
235
|
+
)
|
|
224
236
|
);
|
|
225
237
|
} else {
|
|
226
238
|
// Write-back: queue for later sync
|
|
@@ -244,9 +256,10 @@ export class SyncedProcedureRegistry {
|
|
|
244
256
|
this.touch(key);
|
|
245
257
|
if (this.options.writeStrategy === "write-through") {
|
|
246
258
|
this.track(
|
|
247
|
-
this.adapter.remove(path).
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
this.adapter.remove(path).then(
|
|
260
|
+
() => this.succeed("push"),
|
|
261
|
+
(error: unknown) => this.fail("push", new Error(`Failed to remove procedure ${key} from storage`, { cause: error })),
|
|
262
|
+
)
|
|
250
263
|
);
|
|
251
264
|
} else {
|
|
252
265
|
this.pendingDeletes.set(key, path);
|
|
@@ -509,9 +522,11 @@ export class SyncedProcedureRegistry {
|
|
|
509
522
|
}
|
|
510
523
|
|
|
511
524
|
/**
|
|
512
|
-
* Get current sync status.
|
|
525
|
+
* Get current sync status. `error` is the newest failure of the auto-sync or of a write-through
|
|
526
|
+
* write that a later success did not clear.
|
|
513
527
|
*/
|
|
514
528
|
getStatus(): SyncStatus {
|
|
529
|
+
const error = [...this.failures.values()].at(-1);
|
|
515
530
|
return {
|
|
516
531
|
connected: this.connected,
|
|
517
532
|
endpoint: this.endpoint,
|
|
@@ -519,9 +534,32 @@ export class SyncedProcedureRegistry {
|
|
|
519
534
|
pendingChanges: this.pendingChanges.size + this.pendingDeletes.size,
|
|
520
535
|
syncing: this.syncing,
|
|
521
536
|
mode: this.getMode(),
|
|
537
|
+
...(error !== undefined && { error }),
|
|
522
538
|
};
|
|
523
539
|
}
|
|
524
540
|
|
|
541
|
+
/**
|
|
542
|
+
* This method sends the status to a listener after each failure and each success of the auto-sync
|
|
543
|
+
* and of the write-through writes (roadmap CLI-13). Before, the failures went to the console.
|
|
544
|
+
*
|
|
545
|
+
* @returns The function that stops the status. A second use of it does nothing.
|
|
546
|
+
*/
|
|
547
|
+
onStatus(listener: (status: SyncStatus) => void): () => void {
|
|
548
|
+
return this.statusListeners.add(listener);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** This method keeps a failure for the status, and sends the status. */
|
|
552
|
+
private fail(operation: SyncError["operation"], error: unknown): void {
|
|
553
|
+
this.failures.delete(operation);
|
|
554
|
+
this.failures.set(operation, { operation, error });
|
|
555
|
+
this.statusListeners.emit(this.getStatus());
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** This method clears the failure of an operation after a success, and sends the status. */
|
|
559
|
+
private succeed(operation: SyncError["operation"]): void {
|
|
560
|
+
if (this.failures.delete(operation)) this.statusListeners.emit(this.getStatus());
|
|
561
|
+
}
|
|
562
|
+
|
|
525
563
|
// ===========================================================================
|
|
526
564
|
// The mode of the storage (roadmap R28)
|
|
527
565
|
// ===========================================================================
|
|
@@ -560,31 +598,38 @@ export class SyncedProcedureRegistry {
|
|
|
560
598
|
// A number is the internal form of the option, in milliseconds
|
|
561
599
|
const intervalMs = typeof interval === "number" ? interval : toMs(interval, "interval");
|
|
562
600
|
this.stopAutoSync();
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
.
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
601
|
+
const loop: { cancel: Cancel | undefined } = { cancel: undefined };
|
|
602
|
+
this.autoSync = loop;
|
|
603
|
+
// The next sync waits for the interval after the end of the last one: one auto-sync at a time
|
|
604
|
+
const arm = (): void => {
|
|
605
|
+
const cancel = this.clock.after(intervalMs, () => {
|
|
606
|
+
loop.cancel = undefined;
|
|
607
|
+
// None after close() or stopAutoSync()
|
|
608
|
+
if (this.closed || this.autoSync !== loop) return;
|
|
609
|
+
const run = this.sync("both")
|
|
610
|
+
.then(
|
|
611
|
+
() => this.succeed("sync"),
|
|
612
|
+
(error: unknown) => this.fail("sync", error),
|
|
613
|
+
)
|
|
614
|
+
.finally(() => {
|
|
615
|
+
if (this.autoSyncRun === run) this.autoSyncRun = undefined;
|
|
616
|
+
if (!this.closed && this.autoSync === loop) arm();
|
|
617
|
+
});
|
|
618
|
+
this.autoSyncRun = run;
|
|
619
|
+
});
|
|
620
|
+
// Don't let the auto-sync timer keep the process alive. See BUGS-2026-07.md (M4).
|
|
621
|
+
cancel.unref?.();
|
|
622
|
+
loop.cancel = cancel;
|
|
623
|
+
};
|
|
624
|
+
arm();
|
|
578
625
|
}
|
|
579
626
|
|
|
580
627
|
/**
|
|
581
628
|
* Stop auto-sync.
|
|
582
629
|
*/
|
|
583
630
|
stopAutoSync(): void {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
this.syncIntervalHandle = undefined;
|
|
587
|
-
}
|
|
631
|
+
this.autoSync?.cancel?.();
|
|
632
|
+
this.autoSync = undefined;
|
|
588
633
|
}
|
|
589
634
|
|
|
590
635
|
// ===========================================================================
|
package/src/storage/types.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ProcedurePath, ProcedureMetadata } from "@procedures/client";
|
|
9
|
-
import type { Duration } from "@procedures/client";
|
|
9
|
+
import type { Clock, Duration } from "@procedures/client";
|
|
10
10
|
|
|
11
11
|
// =============================================================================
|
|
12
12
|
// Serialized Procedure
|
|
@@ -140,6 +140,23 @@ export interface SyncStatus {
|
|
|
140
140
|
|
|
141
141
|
/** The mode of a hybrid storage (roadmap R28), or undefined for a storage without modes */
|
|
142
142
|
mode?: "local-only" | "write-through" | "write-back" | "remote-only" | undefined;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The newest failure of the sync that a later success of the same work did not clear (roadmap
|
|
146
|
+
* CLI-13), or undefined. Before, the failures went to the console.
|
|
147
|
+
*/
|
|
148
|
+
error?: SyncError | undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A failure of a sync, for its status. */
|
|
152
|
+
export interface SyncError {
|
|
153
|
+
/**
|
|
154
|
+
* The work that failed: the load of the queue, a push of the queue or of the records, a pull, a
|
|
155
|
+
* sync of both directions, or the queue store or the sync state store.
|
|
156
|
+
*/
|
|
157
|
+
readonly operation: "load" | "push" | "pull" | "sync" | "store";
|
|
158
|
+
/** The error of the work. */
|
|
159
|
+
readonly error: unknown;
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
// =============================================================================
|
|
@@ -159,6 +176,13 @@ export interface SyncedRegistryOptions {
|
|
|
159
176
|
/** Auto-sync interval, for example `minutes(1)` (0 to disable) */
|
|
160
177
|
syncInterval?: Duration | undefined;
|
|
161
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The clock of the auto-sync. A test gives `fakeClock` of `@mark1russell7/service/testing`. The
|
|
181
|
+
* timer does not keep a Node process alive.
|
|
182
|
+
* @default the clock of the platform (`realClock` of `@procedures/client`)
|
|
183
|
+
*/
|
|
184
|
+
clock?: Clock | undefined;
|
|
185
|
+
|
|
162
186
|
/** Handler loader for restoring handlers from references */
|
|
163
187
|
handlerLoader?: HandlerLoader | undefined;
|
|
164
188
|
|