@procedures/store 0.4.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)
@@ -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
- if (this.loadError) return;
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.syncToRemote().catch((err) => {
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.syncFromRemote().catch((err) => {
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
- const gone = changes.full
993
- ? (await this.local.getEntries()).map(([id]) => id).filter((id) => !remoteIds.has(id))
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.syncToRemote().catch(() => this.scheduleDrain());
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
- 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();