@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.
@@ -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.syncToRemote().catch((err) => {
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.syncFromRemote().catch((err) => {
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
- async set(id: string, value: T): Promise<void> {
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
- async setBatch(items: Array<[string, T]>): Promise<void> {
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.serialize(async () => {
756
- await this.loaded;
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.serialize(async () => {
770
- await this.loaded;
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 stops the timers, sends the queue (not in `local-only` mode), and closes the remote and the
798
- * local copy. The queue store keeps the operations that the remote did not take. A durable local
799
- * copy keeps its items.
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
- async close(): Promise<void> {
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
- // Sync pending operations before closing
815
- if (this.offlineOps.length > 0 && this.mode !== "local-only") {
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.syncToRemote();
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
- if (this.loadError) return;
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.syncToRemote().catch((err) => {
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.syncFromRemote().catch((err) => {
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
- const gone = changes.full
985
- ? (await this.local.getEntries()).map(([id]) => id).filter((id) => !remoteIds.has(id))
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.syncToRemote().catch(() => this.scheduleDrain());
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
- if (options?.persist === false) {
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
- void this.sync("both").catch((err) => {
558
- console.error("Auto-sync failed:", err);
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
- * Close the synced registry and cleanup resources.
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();