zen-fs-config 0.5.13 → 0.5.14

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/dist/index.d.mts CHANGED
@@ -485,6 +485,12 @@ declare class ConfigRepo implements IConfigRepo {
485
485
  private readonly pollIntervalMs?;
486
486
  /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
487
487
  private tombstoneCache;
488
+ /**
489
+ * Tracks the background initial sync started by createConfigRepo().
490
+ * `flush()` and `dispose()` will await this if it hasn't completed yet,
491
+ * preventing concurrent syncEngine.syncAll() calls.
492
+ */
493
+ private initialSyncPromise;
488
494
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number, cacheOptions?: CacheOptions);
489
495
  /** Full path to this node's directory on the primary backend. */
490
496
  get nodePath(): string;
@@ -505,6 +511,13 @@ declare class ConfigRepo implements IConfigRepo {
505
511
  * to all backends instead of being treated as "missing file → re-create".
506
512
  */
507
513
  deleteFile(path: string): Promise<void>;
514
+ /**
515
+ * Background sync scheduled after a deleteFile() call.
516
+ * Uses a short debounce to coalesce multiple rapid deletions.
517
+ * If a sync is already in progress, the next poll will pick up the tombstone.
518
+ */
519
+ private postDeleteSyncTimer?;
520
+ private schedulePostDeleteSync;
508
521
  /**
509
522
  * Read all tombstones from the primary backend.
510
523
  * Results are cached within a flush() cycle to avoid redundant reads.
@@ -536,6 +549,12 @@ declare class ConfigRepo implements IConfigRepo {
536
549
  * backend descriptors) that watch()'s initial snapshot would skip.
537
550
  */
538
551
  initialSyncAndDedup(): Promise<void>;
552
+ /**
553
+ * Start the initial sync + dedup cycle in the background.
554
+ * `flush()` and `dispose()` will await this promise if it hasn't
555
+ * completed yet, preventing concurrent syncEngine operations.
556
+ */
557
+ startBackgroundSync(): void;
539
558
  /**
540
559
  * After sync: mark each tombstone as confirmed by all replica backends.
541
560
  */
package/dist/index.d.ts CHANGED
@@ -485,6 +485,12 @@ declare class ConfigRepo implements IConfigRepo {
485
485
  private readonly pollIntervalMs?;
486
486
  /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
487
487
  private tombstoneCache;
488
+ /**
489
+ * Tracks the background initial sync started by createConfigRepo().
490
+ * `flush()` and `dispose()` will await this if it hasn't completed yet,
491
+ * preventing concurrent syncEngine.syncAll() calls.
492
+ */
493
+ private initialSyncPromise;
488
494
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number, cacheOptions?: CacheOptions);
489
495
  /** Full path to this node's directory on the primary backend. */
490
496
  get nodePath(): string;
@@ -505,6 +511,13 @@ declare class ConfigRepo implements IConfigRepo {
505
511
  * to all backends instead of being treated as "missing file → re-create".
506
512
  */
507
513
  deleteFile(path: string): Promise<void>;
514
+ /**
515
+ * Background sync scheduled after a deleteFile() call.
516
+ * Uses a short debounce to coalesce multiple rapid deletions.
517
+ * If a sync is already in progress, the next poll will pick up the tombstone.
518
+ */
519
+ private postDeleteSyncTimer?;
520
+ private schedulePostDeleteSync;
508
521
  /**
509
522
  * Read all tombstones from the primary backend.
510
523
  * Results are cached within a flush() cycle to avoid redundant reads.
@@ -536,6 +549,12 @@ declare class ConfigRepo implements IConfigRepo {
536
549
  * backend descriptors) that watch()'s initial snapshot would skip.
537
550
  */
538
551
  initialSyncAndDedup(): Promise<void>;
552
+ /**
553
+ * Start the initial sync + dedup cycle in the background.
554
+ * `flush()` and `dispose()` will await this promise if it hasn't
555
+ * completed yet, preventing concurrent syncEngine operations.
556
+ */
557
+ startBackgroundSync(): void;
539
558
  /**
540
559
  * After sync: mark each tombstone as confirmed by all replica backends.
541
560
  */
package/dist/index.js CHANGED
@@ -622,6 +622,12 @@ var ConfigRepo = class {
622
622
  pollIntervalMs;
623
623
  /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
624
624
  tombstoneCache = null;
625
+ /**
626
+ * Tracks the background initial sync started by createConfigRepo().
627
+ * `flush()` and `dispose()` will await this if it hasn't completed yet,
628
+ * preventing concurrent syncEngine.syncAll() calls.
629
+ */
630
+ initialSyncPromise = null;
625
631
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs, cacheOptions) {
626
632
  this.appId = appId;
627
633
  this.nodeId = nodeId;
@@ -781,6 +787,10 @@ var ConfigRepo = class {
781
787
  // -----------------------------------------------------------------------
782
788
  async flush() {
783
789
  this.assertNotDisposed();
790
+ if (this.initialSyncPromise) {
791
+ await this.initialSyncPromise;
792
+ this.initialSyncPromise = null;
793
+ }
784
794
  await this.processTombstones();
785
795
  const resultsMap = await this.syncEngine.syncAll();
786
796
  this.invalidateTombstoneCache();
@@ -826,6 +836,25 @@ var ConfigRepo = class {
826
836
  }
827
837
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
828
838
  this.invalidateTombstoneCache();
839
+ this.schedulePostDeleteSync();
840
+ }
841
+ /**
842
+ * Background sync scheduled after a deleteFile() call.
843
+ * Uses a short debounce to coalesce multiple rapid deletions.
844
+ * If a sync is already in progress, the next poll will pick up the tombstone.
845
+ */
846
+ postDeleteSyncTimer;
847
+ schedulePostDeleteSync() {
848
+ if (this.postDeleteSyncTimer) {
849
+ clearTimeout(this.postDeleteSyncTimer);
850
+ }
851
+ this.postDeleteSyncTimer = setTimeout(() => {
852
+ this.postDeleteSyncTimer = void 0;
853
+ if (this.disposed) return;
854
+ this.syncEngine.syncAll().catch((err) => {
855
+ console.warn("[ConfigRepo] post-delete sync failed:", err);
856
+ });
857
+ }, 500);
829
858
  }
830
859
  /**
831
860
  * Read all tombstones from the primary backend.
@@ -951,6 +980,20 @@ var ConfigRepo = class {
951
980
  await this.processTombstones();
952
981
  this.syncEngine.watchAll();
953
982
  }
983
+ /**
984
+ * Start the initial sync + dedup cycle in the background.
985
+ * `flush()` and `dispose()` will await this promise if it hasn't
986
+ * completed yet, preventing concurrent syncEngine operations.
987
+ */
988
+ startBackgroundSync() {
989
+ this.initialSyncPromise = this.initialSyncAndDedup().then(() => {
990
+ console.log("[ConfigRepo] Background initial sync complete");
991
+ }).catch((err) => {
992
+ console.error("[ConfigRepo] Background initial sync failed:", err);
993
+ }).finally(() => {
994
+ this.initialSyncPromise = null;
995
+ });
996
+ }
954
997
  /**
955
998
  * After sync: mark each tombstone as confirmed by all replica backends.
956
999
  */
@@ -1098,7 +1141,15 @@ var ConfigRepo = class {
1098
1141
  // -----------------------------------------------------------------------
1099
1142
  async dispose() {
1100
1143
  if (this.disposed) return;
1144
+ if (this.initialSyncPromise) {
1145
+ await this.initialSyncPromise;
1146
+ this.initialSyncPromise = null;
1147
+ }
1101
1148
  this.disposed = true;
1149
+ if (this.postDeleteSyncTimer) {
1150
+ clearTimeout(this.postDeleteSyncTimer);
1151
+ this.postDeleteSyncTimer = void 0;
1152
+ }
1102
1153
  this.syncEngine.dispose();
1103
1154
  for (const [_id, replica] of this.replicaBackends) {
1104
1155
  if (replica.instance?.dispose) {
@@ -1139,7 +1190,24 @@ var ConfigRepo = class {
1139
1190
  {
1140
1191
  direction: import_zen_fs_sync.SyncDirection.BiDirectional,
1141
1192
  conflictStrategy: "source-wins",
1142
- pollIntervalMs
1193
+ pollIntervalMs,
1194
+ preSyncHook: async () => {
1195
+ try {
1196
+ this.invalidateTombstoneCache();
1197
+ await this.processTombstones();
1198
+ } catch (err) {
1199
+ console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
1200
+ }
1201
+ },
1202
+ postSyncHook: async () => {
1203
+ try {
1204
+ this.invalidateTombstoneCache();
1205
+ await this.processTombstones();
1206
+ await this.updateTombstoneConfirmations();
1207
+ } catch (err) {
1208
+ console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
1209
+ }
1210
+ }
1143
1211
  },
1144
1212
  "/"
1145
1213
  );
@@ -1192,13 +1260,19 @@ var ConfigRepo = class {
1192
1260
  const appDir = `/${this.appId}`;
1193
1261
  try {
1194
1262
  const files = await this.walkDir(appDir);
1195
- for (const filePath of files) {
1196
- try {
1197
- const raw = await this.cachedFS.readFile(filePath);
1198
- const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1199
- this.configCache.set(filePath, data);
1200
- } catch {
1201
- }
1263
+ const readResults = await Promise.all(
1264
+ files.map(async (filePath) => {
1265
+ try {
1266
+ const raw = await this.cachedFS.readFile(filePath);
1267
+ const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1268
+ return { filePath, data };
1269
+ } catch {
1270
+ return null;
1271
+ }
1272
+ })
1273
+ );
1274
+ for (const item of readResults) {
1275
+ if (item) this.configCache.set(item.filePath, item.data);
1202
1276
  }
1203
1277
  } catch {
1204
1278
  }
@@ -1285,17 +1359,23 @@ var ConfigRepo = class {
1285
1359
  const current = stack.pop();
1286
1360
  try {
1287
1361
  const entries = await this.cachedFS.readdir(current);
1288
- for (const entry of entries) {
1289
- if (entry.startsWith(".")) continue;
1290
- const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1291
- try {
1292
- const stat = await this.cachedFS.stat(fullPath);
1293
- if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
1294
- stack.push(fullPath);
1295
- } else {
1296
- results.push(fullPath);
1362
+ const statResults = await Promise.all(
1363
+ entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
1364
+ const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1365
+ try {
1366
+ const stat = await this.cachedFS.stat(fullPath);
1367
+ return { fullPath, stat };
1368
+ } catch {
1369
+ return null;
1297
1370
  }
1298
- } catch {
1371
+ })
1372
+ );
1373
+ for (const item of statResults) {
1374
+ if (!item) continue;
1375
+ if (item.stat.mode !== void 0 && (item.stat.mode & 16384) === 16384) {
1376
+ stack.push(item.fullPath);
1377
+ } else {
1378
+ results.push(item.fullPath);
1299
1379
  }
1300
1380
  }
1301
1381
  } catch {
@@ -1336,29 +1416,38 @@ var ConfigRepo = class {
1336
1416
  async readAllBackendDescriptors() {
1337
1417
  try {
1338
1418
  const entries = await this.cachedFS.readdir(BACKENDS_DIR);
1339
- const items = [];
1340
- const corruptFiles = [];
1341
- for (const entry of entries) {
1342
- if (!entry.endsWith(".json")) continue;
1343
- const filePath = `${BACKENDS_DIR}/${entry}`;
1344
- try {
1345
- const raw = await this.cachedFS.readFile(filePath);
1346
- const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1347
- if (desc.id && desc.type) {
1348
- let mtime = 0;
1349
- try {
1350
- const stat = await this.cachedFS.stat(filePath);
1351
- mtime = stat.mtimeMs ?? 0;
1352
- } catch {
1419
+ const jsonEntries = entries.filter((e) => e.endsWith(".json"));
1420
+ const readResults = await Promise.all(
1421
+ jsonEntries.map(async (entry) => {
1422
+ const filePath = `${BACKENDS_DIR}/${entry}`;
1423
+ try {
1424
+ const raw = await this.cachedFS.readFile(filePath);
1425
+ const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1426
+ if (desc.id && desc.type) {
1427
+ let mtime = 0;
1428
+ try {
1429
+ const stat = await this.cachedFS.stat(filePath);
1430
+ mtime = stat.mtimeMs ?? 0;
1431
+ } catch {
1432
+ }
1433
+ return { kind: "ok", desc, mtime };
1434
+ } else {
1435
+ console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1436
+ return { kind: "corrupt", filePath };
1353
1437
  }
1354
- items.push({ desc, mtime });
1355
- } else {
1356
- console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1357
- corruptFiles.push(filePath);
1438
+ } catch (parseErr) {
1439
+ console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1440
+ return { kind: "corrupt", filePath };
1358
1441
  }
1359
- } catch (parseErr) {
1360
- console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1361
- corruptFiles.push(filePath);
1442
+ })
1443
+ );
1444
+ const items = [];
1445
+ const corruptFiles = [];
1446
+ for (const result of readResults) {
1447
+ if (result.kind === "ok") {
1448
+ items.push({ desc: result.desc, mtime: result.mtime });
1449
+ } else {
1450
+ corruptFiles.push(result.filePath);
1362
1451
  }
1363
1452
  }
1364
1453
  for (const corruptPath of corruptFiles) {
@@ -1509,7 +1598,24 @@ var ConfigRepo = class {
1509
1598
  syncable,
1510
1599
  {
1511
1600
  direction: import_zen_fs_sync.SyncDirection.BiDirectional,
1512
- conflictStrategy: "source-wins"
1601
+ conflictStrategy: "source-wins",
1602
+ preSyncHook: async () => {
1603
+ try {
1604
+ this.invalidateTombstoneCache();
1605
+ await this.processTombstones();
1606
+ } catch (err) {
1607
+ console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
1608
+ }
1609
+ },
1610
+ postSyncHook: async () => {
1611
+ try {
1612
+ this.invalidateTombstoneCache();
1613
+ await this.processTombstones();
1614
+ await this.updateTombstoneConfirmations();
1615
+ } catch (err) {
1616
+ console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
1617
+ }
1618
+ }
1513
1619
  },
1514
1620
  "/"
1515
1621
  );
@@ -1528,26 +1634,33 @@ var ConfigRepo = class {
1528
1634
  throw new Error("Cannot remove the local IndexedDB primary backend");
1529
1635
  }
1530
1636
  const replica = this.replicaBackends.get(id);
1531
- if (!replica) {
1532
- throw new Error(`Backend "${id}" is not a registered replica`);
1533
- }
1534
1637
  const descPath = this.backendFilePath(id);
1535
- try {
1536
- await replica.instance.unlink(descPath);
1537
- } catch {
1538
- }
1539
- await this.unlinkVersionSidecar(replica.instance, descPath);
1540
- try {
1541
- await this.deleteFile(descPath);
1542
- } catch {
1543
- await this.removeBackendDescriptor(id);
1544
- }
1545
- this.syncEngine.removePair(replica.pairId);
1546
- console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1547
- this.replicaBackends.delete(id);
1548
- if (replica.instance?.dispose) {
1549
- await replica.instance.dispose();
1638
+ if (replica) {
1639
+ try {
1640
+ await replica.instance.unlink(descPath);
1641
+ } catch {
1642
+ }
1643
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1644
+ try {
1645
+ await this.deleteFile(descPath);
1646
+ } catch {
1647
+ await this.removeBackendDescriptor(id);
1648
+ }
1649
+ this.syncEngine.removePair(replica.pairId);
1650
+ console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1651
+ this.replicaBackends.delete(id);
1652
+ if (replica.instance?.dispose) {
1653
+ await replica.instance.dispose();
1654
+ }
1655
+ } else {
1656
+ console.log(`[ConfigRepo] removeBackend: "${id}" not in replicaBackends, cleaning up descriptor only`);
1657
+ try {
1658
+ await this.deleteFile(descPath);
1659
+ } catch {
1660
+ await this.removeBackendDescriptor(id);
1661
+ }
1550
1662
  }
1663
+ this.schedulePostDeleteSync();
1551
1664
  console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
1552
1665
  }
1553
1666
  // -----------------------------------------------------------------------
@@ -1907,16 +2020,16 @@ async function createConfigRepo(appId, options = {}) {
1907
2020
  }
1908
2021
  console.log(`[createConfigRepo] Migration complete`);
1909
2022
  }
2023
+ let allBackends = await tempRepo.readAllBackendDescriptors();
1910
2024
  if (options.backendInfo) {
1911
2025
  const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
1912
- const allBackends2 = await tempRepo.readAllBackendDescriptors();
1913
- const hasReplica = allBackends2.some((b) => b.id === replicaId);
2026
+ const hasReplica = allBackends.some((b) => b.id === replicaId);
1914
2027
  const newKey = backendDedupKey({
1915
2028
  id: replicaId,
1916
2029
  type: options.backendInfo.type,
1917
2030
  options: options.backendInfo.options
1918
2031
  });
1919
- const dupConfig = allBackends2.find((b) => backendDedupKey(b) === newKey);
2032
+ const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
1920
2033
  if (!hasReplica && !dupConfig) {
1921
2034
  await tempRepo.writeBackendDescriptor({
1922
2035
  id: replicaId,
@@ -1924,13 +2037,13 @@ async function createConfigRepo(appId, options = {}) {
1924
2037
  options: options.backendInfo.options
1925
2038
  });
1926
2039
  console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
2040
+ allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
1927
2041
  } else if (dupConfig) {
1928
2042
  console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
1929
2043
  } else {
1930
2044
  console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
1931
2045
  }
1932
2046
  }
1933
- const allBackends = await tempRepo.readAllBackendDescriptors();
1934
2047
  console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
1935
2048
  let nodeId = options.nodeId;
1936
2049
  if (!nodeId) {
@@ -1951,8 +2064,8 @@ async function createConfigRepo(appId, options = {}) {
1951
2064
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
1952
2065
  await repo.load();
1953
2066
  if (repo.replicaCount > 0) {
1954
- console.log("[createConfigRepo] Initial sync + dedup cycle...");
1955
- await repo.initialSyncAndDedup();
2067
+ console.log("[createConfigRepo] Starting background initial sync + dedup...");
2068
+ repo.startBackgroundSync();
1956
2069
  }
1957
2070
  repo.syncMetaToReplicas().catch((err) => {
1958
2071
  console.error("[createConfigRepo] background syncMetaToReplicas failed:", err);
package/dist/index.mjs CHANGED
@@ -532,6 +532,12 @@ var ConfigRepo = class {
532
532
  pollIntervalMs;
533
533
  /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
534
534
  tombstoneCache = null;
535
+ /**
536
+ * Tracks the background initial sync started by createConfigRepo().
537
+ * `flush()` and `dispose()` will await this if it hasn't completed yet,
538
+ * preventing concurrent syncEngine.syncAll() calls.
539
+ */
540
+ initialSyncPromise = null;
535
541
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs, cacheOptions) {
536
542
  this.appId = appId;
537
543
  this.nodeId = nodeId;
@@ -691,6 +697,10 @@ var ConfigRepo = class {
691
697
  // -----------------------------------------------------------------------
692
698
  async flush() {
693
699
  this.assertNotDisposed();
700
+ if (this.initialSyncPromise) {
701
+ await this.initialSyncPromise;
702
+ this.initialSyncPromise = null;
703
+ }
694
704
  await this.processTombstones();
695
705
  const resultsMap = await this.syncEngine.syncAll();
696
706
  this.invalidateTombstoneCache();
@@ -736,6 +746,25 @@ var ConfigRepo = class {
736
746
  }
737
747
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
738
748
  this.invalidateTombstoneCache();
749
+ this.schedulePostDeleteSync();
750
+ }
751
+ /**
752
+ * Background sync scheduled after a deleteFile() call.
753
+ * Uses a short debounce to coalesce multiple rapid deletions.
754
+ * If a sync is already in progress, the next poll will pick up the tombstone.
755
+ */
756
+ postDeleteSyncTimer;
757
+ schedulePostDeleteSync() {
758
+ if (this.postDeleteSyncTimer) {
759
+ clearTimeout(this.postDeleteSyncTimer);
760
+ }
761
+ this.postDeleteSyncTimer = setTimeout(() => {
762
+ this.postDeleteSyncTimer = void 0;
763
+ if (this.disposed) return;
764
+ this.syncEngine.syncAll().catch((err) => {
765
+ console.warn("[ConfigRepo] post-delete sync failed:", err);
766
+ });
767
+ }, 500);
739
768
  }
740
769
  /**
741
770
  * Read all tombstones from the primary backend.
@@ -861,6 +890,20 @@ var ConfigRepo = class {
861
890
  await this.processTombstones();
862
891
  this.syncEngine.watchAll();
863
892
  }
893
+ /**
894
+ * Start the initial sync + dedup cycle in the background.
895
+ * `flush()` and `dispose()` will await this promise if it hasn't
896
+ * completed yet, preventing concurrent syncEngine operations.
897
+ */
898
+ startBackgroundSync() {
899
+ this.initialSyncPromise = this.initialSyncAndDedup().then(() => {
900
+ console.log("[ConfigRepo] Background initial sync complete");
901
+ }).catch((err) => {
902
+ console.error("[ConfigRepo] Background initial sync failed:", err);
903
+ }).finally(() => {
904
+ this.initialSyncPromise = null;
905
+ });
906
+ }
864
907
  /**
865
908
  * After sync: mark each tombstone as confirmed by all replica backends.
866
909
  */
@@ -1008,7 +1051,15 @@ var ConfigRepo = class {
1008
1051
  // -----------------------------------------------------------------------
1009
1052
  async dispose() {
1010
1053
  if (this.disposed) return;
1054
+ if (this.initialSyncPromise) {
1055
+ await this.initialSyncPromise;
1056
+ this.initialSyncPromise = null;
1057
+ }
1011
1058
  this.disposed = true;
1059
+ if (this.postDeleteSyncTimer) {
1060
+ clearTimeout(this.postDeleteSyncTimer);
1061
+ this.postDeleteSyncTimer = void 0;
1062
+ }
1012
1063
  this.syncEngine.dispose();
1013
1064
  for (const [_id, replica] of this.replicaBackends) {
1014
1065
  if (replica.instance?.dispose) {
@@ -1049,7 +1100,24 @@ var ConfigRepo = class {
1049
1100
  {
1050
1101
  direction: SyncDirection.BiDirectional,
1051
1102
  conflictStrategy: "source-wins",
1052
- pollIntervalMs
1103
+ pollIntervalMs,
1104
+ preSyncHook: async () => {
1105
+ try {
1106
+ this.invalidateTombstoneCache();
1107
+ await this.processTombstones();
1108
+ } catch (err) {
1109
+ console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
1110
+ }
1111
+ },
1112
+ postSyncHook: async () => {
1113
+ try {
1114
+ this.invalidateTombstoneCache();
1115
+ await this.processTombstones();
1116
+ await this.updateTombstoneConfirmations();
1117
+ } catch (err) {
1118
+ console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
1119
+ }
1120
+ }
1053
1121
  },
1054
1122
  "/"
1055
1123
  );
@@ -1102,13 +1170,19 @@ var ConfigRepo = class {
1102
1170
  const appDir = `/${this.appId}`;
1103
1171
  try {
1104
1172
  const files = await this.walkDir(appDir);
1105
- for (const filePath of files) {
1106
- try {
1107
- const raw = await this.cachedFS.readFile(filePath);
1108
- const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1109
- this.configCache.set(filePath, data);
1110
- } catch {
1111
- }
1173
+ const readResults = await Promise.all(
1174
+ files.map(async (filePath) => {
1175
+ try {
1176
+ const raw = await this.cachedFS.readFile(filePath);
1177
+ const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1178
+ return { filePath, data };
1179
+ } catch {
1180
+ return null;
1181
+ }
1182
+ })
1183
+ );
1184
+ for (const item of readResults) {
1185
+ if (item) this.configCache.set(item.filePath, item.data);
1112
1186
  }
1113
1187
  } catch {
1114
1188
  }
@@ -1195,17 +1269,23 @@ var ConfigRepo = class {
1195
1269
  const current = stack.pop();
1196
1270
  try {
1197
1271
  const entries = await this.cachedFS.readdir(current);
1198
- for (const entry of entries) {
1199
- if (entry.startsWith(".")) continue;
1200
- const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1201
- try {
1202
- const stat = await this.cachedFS.stat(fullPath);
1203
- if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
1204
- stack.push(fullPath);
1205
- } else {
1206
- results.push(fullPath);
1272
+ const statResults = await Promise.all(
1273
+ entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
1274
+ const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1275
+ try {
1276
+ const stat = await this.cachedFS.stat(fullPath);
1277
+ return { fullPath, stat };
1278
+ } catch {
1279
+ return null;
1207
1280
  }
1208
- } catch {
1281
+ })
1282
+ );
1283
+ for (const item of statResults) {
1284
+ if (!item) continue;
1285
+ if (item.stat.mode !== void 0 && (item.stat.mode & 16384) === 16384) {
1286
+ stack.push(item.fullPath);
1287
+ } else {
1288
+ results.push(item.fullPath);
1209
1289
  }
1210
1290
  }
1211
1291
  } catch {
@@ -1246,29 +1326,38 @@ var ConfigRepo = class {
1246
1326
  async readAllBackendDescriptors() {
1247
1327
  try {
1248
1328
  const entries = await this.cachedFS.readdir(BACKENDS_DIR);
1249
- const items = [];
1250
- const corruptFiles = [];
1251
- for (const entry of entries) {
1252
- if (!entry.endsWith(".json")) continue;
1253
- const filePath = `${BACKENDS_DIR}/${entry}`;
1254
- try {
1255
- const raw = await this.cachedFS.readFile(filePath);
1256
- const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1257
- if (desc.id && desc.type) {
1258
- let mtime = 0;
1259
- try {
1260
- const stat = await this.cachedFS.stat(filePath);
1261
- mtime = stat.mtimeMs ?? 0;
1262
- } catch {
1329
+ const jsonEntries = entries.filter((e) => e.endsWith(".json"));
1330
+ const readResults = await Promise.all(
1331
+ jsonEntries.map(async (entry) => {
1332
+ const filePath = `${BACKENDS_DIR}/${entry}`;
1333
+ try {
1334
+ const raw = await this.cachedFS.readFile(filePath);
1335
+ const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1336
+ if (desc.id && desc.type) {
1337
+ let mtime = 0;
1338
+ try {
1339
+ const stat = await this.cachedFS.stat(filePath);
1340
+ mtime = stat.mtimeMs ?? 0;
1341
+ } catch {
1342
+ }
1343
+ return { kind: "ok", desc, mtime };
1344
+ } else {
1345
+ console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1346
+ return { kind: "corrupt", filePath };
1263
1347
  }
1264
- items.push({ desc, mtime });
1265
- } else {
1266
- console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1267
- corruptFiles.push(filePath);
1348
+ } catch (parseErr) {
1349
+ console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1350
+ return { kind: "corrupt", filePath };
1268
1351
  }
1269
- } catch (parseErr) {
1270
- console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1271
- corruptFiles.push(filePath);
1352
+ })
1353
+ );
1354
+ const items = [];
1355
+ const corruptFiles = [];
1356
+ for (const result of readResults) {
1357
+ if (result.kind === "ok") {
1358
+ items.push({ desc: result.desc, mtime: result.mtime });
1359
+ } else {
1360
+ corruptFiles.push(result.filePath);
1272
1361
  }
1273
1362
  }
1274
1363
  for (const corruptPath of corruptFiles) {
@@ -1419,7 +1508,24 @@ var ConfigRepo = class {
1419
1508
  syncable,
1420
1509
  {
1421
1510
  direction: SyncDirection.BiDirectional,
1422
- conflictStrategy: "source-wins"
1511
+ conflictStrategy: "source-wins",
1512
+ preSyncHook: async () => {
1513
+ try {
1514
+ this.invalidateTombstoneCache();
1515
+ await this.processTombstones();
1516
+ } catch (err) {
1517
+ console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
1518
+ }
1519
+ },
1520
+ postSyncHook: async () => {
1521
+ try {
1522
+ this.invalidateTombstoneCache();
1523
+ await this.processTombstones();
1524
+ await this.updateTombstoneConfirmations();
1525
+ } catch (err) {
1526
+ console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
1527
+ }
1528
+ }
1423
1529
  },
1424
1530
  "/"
1425
1531
  );
@@ -1438,26 +1544,33 @@ var ConfigRepo = class {
1438
1544
  throw new Error("Cannot remove the local IndexedDB primary backend");
1439
1545
  }
1440
1546
  const replica = this.replicaBackends.get(id);
1441
- if (!replica) {
1442
- throw new Error(`Backend "${id}" is not a registered replica`);
1443
- }
1444
1547
  const descPath = this.backendFilePath(id);
1445
- try {
1446
- await replica.instance.unlink(descPath);
1447
- } catch {
1448
- }
1449
- await this.unlinkVersionSidecar(replica.instance, descPath);
1450
- try {
1451
- await this.deleteFile(descPath);
1452
- } catch {
1453
- await this.removeBackendDescriptor(id);
1454
- }
1455
- this.syncEngine.removePair(replica.pairId);
1456
- console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1457
- this.replicaBackends.delete(id);
1458
- if (replica.instance?.dispose) {
1459
- await replica.instance.dispose();
1548
+ if (replica) {
1549
+ try {
1550
+ await replica.instance.unlink(descPath);
1551
+ } catch {
1552
+ }
1553
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1554
+ try {
1555
+ await this.deleteFile(descPath);
1556
+ } catch {
1557
+ await this.removeBackendDescriptor(id);
1558
+ }
1559
+ this.syncEngine.removePair(replica.pairId);
1560
+ console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1561
+ this.replicaBackends.delete(id);
1562
+ if (replica.instance?.dispose) {
1563
+ await replica.instance.dispose();
1564
+ }
1565
+ } else {
1566
+ console.log(`[ConfigRepo] removeBackend: "${id}" not in replicaBackends, cleaning up descriptor only`);
1567
+ try {
1568
+ await this.deleteFile(descPath);
1569
+ } catch {
1570
+ await this.removeBackendDescriptor(id);
1571
+ }
1460
1572
  }
1573
+ this.schedulePostDeleteSync();
1461
1574
  console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
1462
1575
  }
1463
1576
  // -----------------------------------------------------------------------
@@ -1817,16 +1930,16 @@ async function createConfigRepo(appId, options = {}) {
1817
1930
  }
1818
1931
  console.log(`[createConfigRepo] Migration complete`);
1819
1932
  }
1933
+ let allBackends = await tempRepo.readAllBackendDescriptors();
1820
1934
  if (options.backendInfo) {
1821
1935
  const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
1822
- const allBackends2 = await tempRepo.readAllBackendDescriptors();
1823
- const hasReplica = allBackends2.some((b) => b.id === replicaId);
1936
+ const hasReplica = allBackends.some((b) => b.id === replicaId);
1824
1937
  const newKey = backendDedupKey({
1825
1938
  id: replicaId,
1826
1939
  type: options.backendInfo.type,
1827
1940
  options: options.backendInfo.options
1828
1941
  });
1829
- const dupConfig = allBackends2.find((b) => backendDedupKey(b) === newKey);
1942
+ const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
1830
1943
  if (!hasReplica && !dupConfig) {
1831
1944
  await tempRepo.writeBackendDescriptor({
1832
1945
  id: replicaId,
@@ -1834,13 +1947,13 @@ async function createConfigRepo(appId, options = {}) {
1834
1947
  options: options.backendInfo.options
1835
1948
  });
1836
1949
  console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
1950
+ allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
1837
1951
  } else if (dupConfig) {
1838
1952
  console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
1839
1953
  } else {
1840
1954
  console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
1841
1955
  }
1842
1956
  }
1843
- const allBackends = await tempRepo.readAllBackendDescriptors();
1844
1957
  console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
1845
1958
  let nodeId = options.nodeId;
1846
1959
  if (!nodeId) {
@@ -1861,8 +1974,8 @@ async function createConfigRepo(appId, options = {}) {
1861
1974
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
1862
1975
  await repo.load();
1863
1976
  if (repo.replicaCount > 0) {
1864
- console.log("[createConfigRepo] Initial sync + dedup cycle...");
1865
- await repo.initialSyncAndDedup();
1977
+ console.log("[createConfigRepo] Starting background initial sync + dedup...");
1978
+ repo.startBackgroundSync();
1866
1979
  }
1867
1980
  repo.syncMetaToReplicas().catch((err) => {
1868
1981
  console.error("[createConfigRepo] background syncMetaToReplicas failed:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "Distributed config management library built on ZenFS, zen-fs-cache, and zen-fs-sync",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -44,7 +44,7 @@
44
44
  "@zenfs/core": ">=2.3.0",
45
45
  "@zenfs/dom": ">=1.0.0",
46
46
  "zen-fs-cache": ">=1.0.0",
47
- "zen-fs-sync": ">=0.1.0"
47
+ "zen-fs-sync": ">=0.4.6"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@zenfs/dom": {
@@ -61,6 +61,6 @@
61
61
  "typescript": "^5.9.3",
62
62
  "vitest": "^1.6.1",
63
63
  "zen-fs-cache": "^1.0.1",
64
- "zen-fs-sync": "^0.4.3"
64
+ "zen-fs-sync": "^0.4.6"
65
65
  }
66
66
  }