@procedures/store 0.3.0 → 0.5.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 +52 -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 +49 -7
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +124 -36
- 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 +117 -33
- 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)
|
|
@@ -829,15 +889,23 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
829
889
|
}
|
|
830
890
|
}
|
|
831
891
|
|
|
892
|
+
/**
|
|
893
|
+
* The metadata of the storage. `size` is the result of `size()`: the number of items of the
|
|
894
|
+
* storage, also in `remote-only` mode. `stats.localSize` is the number of items of the local
|
|
895
|
+
* copy. Before, `size` was the size of the local copy, which `remote-only` keeps empty (found by
|
|
896
|
+
* the contract suite of `@procedures/collections/testing`).
|
|
897
|
+
*/
|
|
832
898
|
async getMetadata(): Promise<StorageMetadata> {
|
|
899
|
+
const currentSize = await this.size();
|
|
900
|
+
const localSize = await this.local.size();
|
|
833
901
|
const totalOps = this.stats.cacheHits + this.stats.cacheMisses;
|
|
834
902
|
const hitRate = totalOps > 0 ? (this.stats.cacheHits / totalOps) * 100 : 0;
|
|
835
|
-
const currentSize = await this.local.size();
|
|
836
903
|
|
|
837
904
|
return {
|
|
838
905
|
type: "hybrid",
|
|
839
906
|
size: currentSize,
|
|
840
907
|
stats: {
|
|
908
|
+
localSize,
|
|
841
909
|
hitRate: Math.round(hitRate * 100) / 100,
|
|
842
910
|
cacheHits: this.stats.cacheHits,
|
|
843
911
|
cacheMisses: this.stats.cacheMisses,
|
|
@@ -861,6 +929,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
861
929
|
* and never during a mode switch.
|
|
862
930
|
*/
|
|
863
931
|
private async run<R>(read: boolean, work: () => Promise<R>): Promise<R> {
|
|
932
|
+
if (this.closed) throw this.closedError();
|
|
864
933
|
await (read ? this.ready : this.loaded);
|
|
865
934
|
for (;;) {
|
|
866
935
|
const gate = this.gate;
|
|
@@ -868,6 +937,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
868
937
|
// No new switch started while this operation waited
|
|
869
938
|
if (this.gate === gate) break;
|
|
870
939
|
}
|
|
940
|
+
// close() started while this operation waited
|
|
941
|
+
if (this.closed) throw this.closedError();
|
|
871
942
|
if (this.loadError) throw this.loadError;
|
|
872
943
|
this.active++;
|
|
873
944
|
try {
|
|
@@ -913,15 +984,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
913
984
|
|
|
914
985
|
/** The first sync: send the loaded queue, then read the remote. */
|
|
915
986
|
private async initialSync(): Promise<void> {
|
|
916
|
-
|
|
987
|
+
// A close() before the first sync: the storage makes no remote call
|
|
988
|
+
if (this.loadError || this.closed) return;
|
|
917
989
|
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
918
990
|
if (this.offlineOps.length > 0) {
|
|
919
|
-
await this.
|
|
991
|
+
await this.push().catch((err) => {
|
|
920
992
|
console.error("Failed to send the queue on init:", err);
|
|
921
993
|
});
|
|
922
994
|
}
|
|
923
|
-
if (this.mode === "write-through" || this.mode === "write-back") {
|
|
924
|
-
await this.
|
|
995
|
+
if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
|
|
996
|
+
await this.pull().catch((err) => {
|
|
925
997
|
console.error("Failed to sync on init:", err);
|
|
926
998
|
});
|
|
927
999
|
}
|
|
@@ -980,10 +1052,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
980
1052
|
remoteIds.add(id);
|
|
981
1053
|
if (await this.pullItem(id, item, changedLocally, conflicts)) queueChanged = true;
|
|
982
1054
|
}
|
|
983
|
-
// The remote deleted these items
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
: 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;
|
|
987
1058
|
for (const id of gone) {
|
|
988
1059
|
if (await this.pullDelete(id, changedLocally, conflicts)) queueChanged = true;
|
|
989
1060
|
}
|
|
@@ -1175,6 +1246,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1175
1246
|
return entries;
|
|
1176
1247
|
}
|
|
1177
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
|
+
|
|
1178
1261
|
/**
|
|
1179
1262
|
* This method sends the queue. The operations that reached the remote leave the queue. The queue store
|
|
1180
1263
|
* gets the change at the end.
|
|
@@ -1494,7 +1577,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1494
1577
|
const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
|
|
1495
1578
|
this.drainTimer = setTimeout(() => {
|
|
1496
1579
|
this.drainTimer = null;
|
|
1497
|
-
this.
|
|
1580
|
+
if (this.closed) return;
|
|
1581
|
+
this.push().catch(() => this.scheduleDrain());
|
|
1498
1582
|
}, delay);
|
|
1499
1583
|
(this.drainTimer as { unref?: () => void }).unref?.();
|
|
1500
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();
|