@procedures/store 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +22 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/api.d.ts +11 -0
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +36 -3
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/hybrid.d.ts +43 -7
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +115 -35
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/synced-registry.d.ts +8 -1
- package/dist/storage/synced-registry.d.ts.map +1 -1
- package/dist/storage/synced-registry.js +25 -4
- package/dist/storage/synced-registry.js.map +1 -1
- package/package.json +3 -3
- package/src/storage/api.ts +35 -3
- package/src/storage/hybrid.ts +108 -32
- package/src/storage/synced-registry.ts +26 -6
package/src/storage/hybrid.ts
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - A sync asks for the changes since a version when the remote has `getChangesSince`.
|
|
17
17
|
* - With `versionOf`, a conflict is a change on both sides, and `conflictResolution` decides.
|
|
18
18
|
* - `pullInterval` keeps the local copy fresh.
|
|
19
|
+
* - Each write takes a copy of the value (`structuredClone`), for the local copy, the queue and the
|
|
20
|
+
* remote. The reads give the copies of the local copy, or of the remote in `remote-only` mode.
|
|
21
|
+
* - A full sync reads the local ids with `getIds()`, so it does not copy each local item.
|
|
22
|
+
* - After `close()`, each operation rejects, and a second `close()` does nothing.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
|
|
@@ -260,7 +264,8 @@ type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string
|
|
|
260
264
|
/**
|
|
261
265
|
* A storage for the local copy of a `HybridStorage` (roadmap R45): a collection storage that gives
|
|
262
266
|
* its entries. A durable one, for example `SqliteStorage` of `@procedures/sqlite`, keeps the local
|
|
263
|
-
* copy after a restart.
|
|
267
|
+
* copy after a restart. With `getIds()`, a full sync reads the local ids without a copy of the
|
|
268
|
+
* values.
|
|
264
269
|
*/
|
|
265
270
|
export type LocalCopyStorage<T> = CollectionStorage<T> & { getEntries(): Promise<Array<[string, T]>> };
|
|
266
271
|
|
|
@@ -336,7 +341,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
336
341
|
private active = 0;
|
|
337
342
|
private idleWaiters: Array<() => void> = [];
|
|
338
343
|
private isOnline = true;
|
|
344
|
+
/** True after `close()`: each operation rejects */
|
|
339
345
|
private closed = false;
|
|
346
|
+
/** The work of the first `close()` */
|
|
347
|
+
private closing: Promise<void> | undefined;
|
|
340
348
|
/** A counter of local writes for each id, so a sync does not restore an older remote value */
|
|
341
349
|
private readonly writeVersions = new Map<string, number>();
|
|
342
350
|
private clearVersion = 0;
|
|
@@ -406,6 +414,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
406
414
|
*/
|
|
407
415
|
async setMode(mode: HybridMode): Promise<void> {
|
|
408
416
|
const target = checkMode(mode);
|
|
417
|
+
if (this.closed) throw this.closedError();
|
|
409
418
|
const previous = this.gate;
|
|
410
419
|
let release!: () => void;
|
|
411
420
|
this.gate = new Promise<void>((resolve) => (release = resolve));
|
|
@@ -461,7 +470,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
461
470
|
const push = this.mode === "write-back" && this.options.syncInterval > 0;
|
|
462
471
|
if (push && !this.syncTimer) {
|
|
463
472
|
this.syncTimer = setInterval(() => {
|
|
464
|
-
this.
|
|
473
|
+
if (this.closed) return;
|
|
474
|
+
this.push().catch((err) => {
|
|
465
475
|
console.error("Periodic sync failed:", err);
|
|
466
476
|
});
|
|
467
477
|
}, this.options.syncInterval);
|
|
@@ -475,7 +485,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
475
485
|
const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
|
|
476
486
|
if (pull && !this.pullTimer) {
|
|
477
487
|
this.pullTimer = setInterval(() => {
|
|
478
|
-
this.
|
|
488
|
+
if (this.closed) return;
|
|
489
|
+
this.pull().catch((err) => {
|
|
479
490
|
console.error("Periodic pull failed:", err);
|
|
480
491
|
});
|
|
481
492
|
}, this.options.pullInterval);
|
|
@@ -540,6 +551,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
540
551
|
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remoteEntries()) : this.local.getEntries()));
|
|
541
552
|
}
|
|
542
553
|
|
|
554
|
+
/**
|
|
555
|
+
* The ids of the items, in the order of the entries: the ids of the local copy, or of the remote
|
|
556
|
+
* in `remote-only` mode. The ids come without a copy of the values when the storage under them
|
|
557
|
+
* has `getIds()`.
|
|
558
|
+
*/
|
|
559
|
+
async getIds(): Promise<string[]> {
|
|
560
|
+
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remoteIds()) : this.localIds()));
|
|
561
|
+
}
|
|
562
|
+
|
|
543
563
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
544
564
|
return this.run(true, () =>
|
|
545
565
|
this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate),
|
|
@@ -577,7 +597,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
577
597
|
// ═══ Write Operations ═══
|
|
578
598
|
//
|
|
579
599
|
|
|
580
|
-
|
|
600
|
+
/**
|
|
601
|
+
* This method sets an item. The storage takes a copy of the value, so a later change of the
|
|
602
|
+
* value does not change the local copy, the queue or the remote write.
|
|
603
|
+
*/
|
|
604
|
+
async set(id: string, written: T): Promise<void> {
|
|
605
|
+
const value = structuredClone(written);
|
|
581
606
|
await this.write("set", { id, value }, [id], async () => {
|
|
582
607
|
this.bump(id);
|
|
583
608
|
await this.local.set(id, value);
|
|
@@ -602,7 +627,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
602
627
|
// ═══ Bulk Operations ═══
|
|
603
628
|
//
|
|
604
629
|
|
|
605
|
-
|
|
630
|
+
/** This method sets several items. The storage takes a copy of each value, as `set` does. */
|
|
631
|
+
async setBatch(written: Array<[string, T]>): Promise<void> {
|
|
632
|
+
const items = written.map(([id, value]): [string, T] => [id, structuredClone(value)]);
|
|
606
633
|
await this.write("setBatch", { items }, items.map(([id]) => id), async () => {
|
|
607
634
|
for (const [id] of items) this.bump(id);
|
|
608
635
|
await this.local.setBatch(items);
|
|
@@ -752,11 +779,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
752
779
|
* - Syncs run one at a time.
|
|
753
780
|
*/
|
|
754
781
|
syncFromRemote(): Promise<void> {
|
|
755
|
-
return this.
|
|
756
|
-
|
|
757
|
-
if (this.loadError) throw this.loadError;
|
|
758
|
-
if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
|
|
759
|
-
});
|
|
782
|
+
if (this.closed) return Promise.reject(this.closedError());
|
|
783
|
+
return this.pull();
|
|
760
784
|
}
|
|
761
785
|
|
|
762
786
|
/**
|
|
@@ -766,11 +790,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
766
790
|
* the call does nothing.
|
|
767
791
|
*/
|
|
768
792
|
syncToRemote(): Promise<void> {
|
|
769
|
-
return this.
|
|
770
|
-
|
|
771
|
-
if (this.loadError) throw this.loadError;
|
|
772
|
-
if (this.mode !== "local-only") await this.pushQueue();
|
|
773
|
-
});
|
|
793
|
+
if (this.closed) return Promise.reject(this.closedError());
|
|
794
|
+
return this.push();
|
|
774
795
|
}
|
|
775
796
|
|
|
776
797
|
/**
|
|
@@ -789,17 +810,52 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
789
810
|
await this.syncFromRemote();
|
|
790
811
|
}
|
|
791
812
|
|
|
813
|
+
/** The pull of `syncFromRemote()`, after the syncs that run. */
|
|
814
|
+
private pull(): Promise<void> {
|
|
815
|
+
return this.serialize(async () => {
|
|
816
|
+
await this.loaded;
|
|
817
|
+
if (this.loadError) throw this.loadError;
|
|
818
|
+
if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** The push of `syncToRemote()`, after the syncs that run. */
|
|
823
|
+
private push(): Promise<void> {
|
|
824
|
+
return this.serialize(async () => {
|
|
825
|
+
await this.loaded;
|
|
826
|
+
if (this.loadError) throw this.loadError;
|
|
827
|
+
if (this.mode !== "local-only") await this.pushQueue();
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** The error of an operation after `close()`. */
|
|
832
|
+
private closedError(): Error {
|
|
833
|
+
return new Error("The HybridStorage is closed");
|
|
834
|
+
}
|
|
835
|
+
|
|
792
836
|
//
|
|
793
837
|
// ═══ Lifecycle & Metadata ═══
|
|
794
838
|
//
|
|
795
839
|
|
|
796
840
|
/**
|
|
797
|
-
* This method
|
|
798
|
-
*
|
|
799
|
-
*
|
|
841
|
+
* This method closes the storage.
|
|
842
|
+
*
|
|
843
|
+
* - It stops the timers, and waits for the operations, the mode switch and the syncs that run.
|
|
844
|
+
* - It sends the queue (not in `local-only` mode). The queue store keeps the operations that
|
|
845
|
+
* the remote did not take.
|
|
846
|
+
* - It closes the remote and the local copy. A durable local copy keeps its items.
|
|
847
|
+
* - After it, each operation rejects. A second `close()` does nothing: it gives the promise of
|
|
848
|
+
* the first one, without its error.
|
|
800
849
|
*/
|
|
801
|
-
|
|
850
|
+
close(): Promise<void> {
|
|
851
|
+
if (this.closing) return this.closing.catch(() => undefined);
|
|
802
852
|
this.closed = true;
|
|
853
|
+
this.closing = this.shutDown();
|
|
854
|
+
return this.closing;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The work of `close()`. */
|
|
858
|
+
private async shutDown(): Promise<void> {
|
|
803
859
|
for (const timer of [this.syncTimer, this.pullTimer]) {
|
|
804
860
|
if (timer) clearInterval(timer);
|
|
805
861
|
}
|
|
@@ -811,14 +867,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
811
867
|
}
|
|
812
868
|
|
|
813
869
|
await this.loaded;
|
|
814
|
-
//
|
|
815
|
-
|
|
870
|
+
// The operations and the switch that started before close() end first
|
|
871
|
+
await this.gate;
|
|
872
|
+
await this.idle();
|
|
873
|
+
// After the syncs that run: send the pending operations
|
|
874
|
+
await this.serialize(async () => {
|
|
875
|
+
if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only") return;
|
|
816
876
|
try {
|
|
817
|
-
await this.
|
|
877
|
+
await this.pushQueue();
|
|
818
878
|
} catch (error) {
|
|
819
879
|
console.error("Failed to sync before close:", error);
|
|
820
880
|
}
|
|
821
|
-
}
|
|
881
|
+
});
|
|
822
882
|
await this.storeChain;
|
|
823
883
|
|
|
824
884
|
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
@@ -869,6 +929,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
869
929
|
* and never during a mode switch.
|
|
870
930
|
*/
|
|
871
931
|
private async run<R>(read: boolean, work: () => Promise<R>): Promise<R> {
|
|
932
|
+
if (this.closed) throw this.closedError();
|
|
872
933
|
await (read ? this.ready : this.loaded);
|
|
873
934
|
for (;;) {
|
|
874
935
|
const gate = this.gate;
|
|
@@ -876,6 +937,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
876
937
|
// No new switch started while this operation waited
|
|
877
938
|
if (this.gate === gate) break;
|
|
878
939
|
}
|
|
940
|
+
// close() started while this operation waited
|
|
941
|
+
if (this.closed) throw this.closedError();
|
|
879
942
|
if (this.loadError) throw this.loadError;
|
|
880
943
|
this.active++;
|
|
881
944
|
try {
|
|
@@ -921,15 +984,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
921
984
|
|
|
922
985
|
/** The first sync: send the loaded queue, then read the remote. */
|
|
923
986
|
private async initialSync(): Promise<void> {
|
|
924
|
-
|
|
987
|
+
// A close() before the first sync: the storage makes no remote call
|
|
988
|
+
if (this.loadError || this.closed) return;
|
|
925
989
|
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
926
990
|
if (this.offlineOps.length > 0) {
|
|
927
|
-
await this.
|
|
991
|
+
await this.push().catch((err) => {
|
|
928
992
|
console.error("Failed to send the queue on init:", err);
|
|
929
993
|
});
|
|
930
994
|
}
|
|
931
|
-
if (this.mode === "write-through" || this.mode === "write-back") {
|
|
932
|
-
await this.
|
|
995
|
+
if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
|
|
996
|
+
await this.pull().catch((err) => {
|
|
933
997
|
console.error("Failed to sync on init:", err);
|
|
934
998
|
});
|
|
935
999
|
}
|
|
@@ -988,10 +1052,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
988
1052
|
remoteIds.add(id);
|
|
989
1053
|
if (await this.pullItem(id, item, changedLocally, conflicts)) queueChanged = true;
|
|
990
1054
|
}
|
|
991
|
-
// The remote deleted these items
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
: changes.deleted;
|
|
1055
|
+
// The remote deleted these items. A full pull reads only the local ids: before, it read each
|
|
1056
|
+
// local entry, and the local copy copied each value for it.
|
|
1057
|
+
const gone = changes.full ? (await this.localIds()).filter((id) => !remoteIds.has(id)) : changes.deleted;
|
|
995
1058
|
for (const id of gone) {
|
|
996
1059
|
if (await this.pullDelete(id, changedLocally, conflicts)) queueChanged = true;
|
|
997
1060
|
}
|
|
@@ -1183,6 +1246,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1183
1246
|
return entries;
|
|
1184
1247
|
}
|
|
1185
1248
|
|
|
1249
|
+
/** The ids of the local copy: from `getIds()` when it has it, without a copy of the values. */
|
|
1250
|
+
private async localIds(): Promise<string[]> {
|
|
1251
|
+
if (this.local.getIds) return this.local.getIds();
|
|
1252
|
+
return (await this.local.getEntries()).map(([id]) => id);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** The ids of the remote: from `getIds()` when it has it, or else from its entries. */
|
|
1256
|
+
private async remoteIds(): Promise<string[]> {
|
|
1257
|
+
if (this.remote.getIds) return this.remote.getIds();
|
|
1258
|
+
return (await this.remoteEntries()).map(([id]) => id);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1186
1261
|
/**
|
|
1187
1262
|
* This method sends the queue. The operations that reached the remote leave the queue. The queue store
|
|
1188
1263
|
* gets the change at the end.
|
|
@@ -1502,7 +1577,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1502
1577
|
const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
|
|
1503
1578
|
this.drainTimer = setTimeout(() => {
|
|
1504
1579
|
this.drainTimer = null;
|
|
1505
|
-
this.
|
|
1580
|
+
if (this.closed) return;
|
|
1581
|
+
this.push().catch(() => this.scheduleDrain());
|
|
1506
1582
|
}, delay);
|
|
1507
1583
|
(this.drainTimer as { unref?: () => void }).unref?.();
|
|
1508
1584
|
}
|
|
@@ -140,6 +140,12 @@ export class SyncedProcedureRegistry {
|
|
|
140
140
|
/** Whether currently connected to remote storage */
|
|
141
141
|
private connected = false;
|
|
142
142
|
|
|
143
|
+
/** True after `close()`: the registry writes nothing to its closed storage */
|
|
144
|
+
private closed = false;
|
|
145
|
+
|
|
146
|
+
/** The sync of the auto-sync timer that runs. `close()` waits for it. */
|
|
147
|
+
private autoSyncRun: Promise<unknown> | undefined;
|
|
148
|
+
|
|
143
149
|
/** Remote endpoint (if connected) */
|
|
144
150
|
private endpoint: string | undefined;
|
|
145
151
|
|
|
@@ -204,7 +210,8 @@ export class SyncedProcedureRegistry {
|
|
|
204
210
|
}
|
|
205
211
|
this.touch(key);
|
|
206
212
|
|
|
207
|
-
|
|
213
|
+
// After close(), the storage is closed: the registry keeps the procedure in memory only
|
|
214
|
+
if (options?.persist === false || this.closed) {
|
|
208
215
|
return;
|
|
209
216
|
}
|
|
210
217
|
this.pendingDeletes.delete(key);
|
|
@@ -231,7 +238,7 @@ export class SyncedProcedureRegistry {
|
|
|
231
238
|
unregister(path: ProcedurePath): boolean {
|
|
232
239
|
const result = this.baseRegistry.unregister(path);
|
|
233
240
|
|
|
234
|
-
if (result) {
|
|
241
|
+
if (result && !this.closed) {
|
|
235
242
|
const key = pathToKey(path);
|
|
236
243
|
this.pendingChanges.delete(key);
|
|
237
244
|
this.touch(key);
|
|
@@ -554,9 +561,16 @@ export class SyncedProcedureRegistry {
|
|
|
554
561
|
const intervalMs = typeof interval === "number" ? interval : toMs(interval, "interval");
|
|
555
562
|
this.stopAutoSync();
|
|
556
563
|
const handle = setInterval(() => {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
564
|
+
// One auto-sync at a time, and none after close()
|
|
565
|
+
if (this.closed || this.autoSyncRun) return;
|
|
566
|
+
const run = this.sync("both")
|
|
567
|
+
.catch((err) => {
|
|
568
|
+
console.error("Auto-sync failed:", err);
|
|
569
|
+
})
|
|
570
|
+
.finally(() => {
|
|
571
|
+
if (this.autoSyncRun === run) this.autoSyncRun = undefined;
|
|
572
|
+
});
|
|
573
|
+
this.autoSyncRun = run;
|
|
560
574
|
}, intervalMs);
|
|
561
575
|
// Don't let the auto-sync timer keep the process alive. See BUGS-2026-07.md (M4).
|
|
562
576
|
handle.unref?.();
|
|
@@ -726,10 +740,16 @@ export class SyncedProcedureRegistry {
|
|
|
726
740
|
// ===========================================================================
|
|
727
741
|
|
|
728
742
|
/**
|
|
729
|
-
*
|
|
743
|
+
* This method closes the registry: it stops the auto-sync, waits for the sync that runs, writes
|
|
744
|
+
* the pending changes, and closes the storage. After it, `register()` and `unregister()` change
|
|
745
|
+
* only the registry in memory, because the storage rejects each operation. A second `close()`
|
|
746
|
+
* does nothing.
|
|
730
747
|
*/
|
|
731
748
|
async close(): Promise<void> {
|
|
749
|
+
if (this.closed) return;
|
|
750
|
+
this.closed = true;
|
|
732
751
|
this.stopAutoSync();
|
|
752
|
+
await this.autoSyncRun;
|
|
733
753
|
|
|
734
754
|
// Flush pending changes before closing
|
|
735
755
|
await this.flushPending();
|