zen-fs-config 0.5.12 → 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.
@@ -876,7 +905,9 @@ var ConfigRepo = class {
876
905
  for (const tombstone of tombstones) {
877
906
  const tVersionPath = versionPathFor(tombstone.path);
878
907
  const existedOnPrimary = await this.safeExists(this.cachedFS, tombstone.path);
879
- console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on primary \u2192 ${existedOnPrimary ? "EXISTS" : "not found"}`);
908
+ if (existedOnPrimary) {
909
+ console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on primary \u2192 EXISTS`);
910
+ }
880
911
  if (existedOnPrimary) {
881
912
  try {
882
913
  await this.cachedFS.unlink(tombstone.path);
@@ -892,8 +923,8 @@ var ConfigRepo = class {
892
923
  }
893
924
  for (const [replicaId, replica] of this.replicaBackends) {
894
925
  const existed = await this.safeExists(replica.instance, tombstone.path);
895
- console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on ${replicaId} \u2192 ${existed ? "EXISTS" : "not found"}`);
896
926
  if (existed) {
927
+ console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on ${replicaId} \u2192 EXISTS`);
897
928
  try {
898
929
  await replica.instance.unlink(tombstone.path);
899
930
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
@@ -949,6 +980,20 @@ var ConfigRepo = class {
949
980
  await this.processTombstones();
950
981
  this.syncEngine.watchAll();
951
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
+ }
952
997
  /**
953
998
  * After sync: mark each tombstone as confirmed by all replica backends.
954
999
  */
@@ -1096,7 +1141,15 @@ var ConfigRepo = class {
1096
1141
  // -----------------------------------------------------------------------
1097
1142
  async dispose() {
1098
1143
  if (this.disposed) return;
1144
+ if (this.initialSyncPromise) {
1145
+ await this.initialSyncPromise;
1146
+ this.initialSyncPromise = null;
1147
+ }
1099
1148
  this.disposed = true;
1149
+ if (this.postDeleteSyncTimer) {
1150
+ clearTimeout(this.postDeleteSyncTimer);
1151
+ this.postDeleteSyncTimer = void 0;
1152
+ }
1100
1153
  this.syncEngine.dispose();
1101
1154
  for (const [_id, replica] of this.replicaBackends) {
1102
1155
  if (replica.instance?.dispose) {
@@ -1137,7 +1190,24 @@ var ConfigRepo = class {
1137
1190
  {
1138
1191
  direction: import_zen_fs_sync.SyncDirection.BiDirectional,
1139
1192
  conflictStrategy: "source-wins",
1140
- 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
+ }
1141
1211
  },
1142
1212
  "/"
1143
1213
  );
@@ -1190,13 +1260,19 @@ var ConfigRepo = class {
1190
1260
  const appDir = `/${this.appId}`;
1191
1261
  try {
1192
1262
  const files = await this.walkDir(appDir);
1193
- for (const filePath of files) {
1194
- try {
1195
- const raw = await this.cachedFS.readFile(filePath);
1196
- const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1197
- this.configCache.set(filePath, data);
1198
- } catch {
1199
- }
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);
1200
1276
  }
1201
1277
  } catch {
1202
1278
  }
@@ -1283,17 +1359,23 @@ var ConfigRepo = class {
1283
1359
  const current = stack.pop();
1284
1360
  try {
1285
1361
  const entries = await this.cachedFS.readdir(current);
1286
- for (const entry of entries) {
1287
- if (entry.startsWith(".")) continue;
1288
- const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1289
- try {
1290
- const stat = await this.cachedFS.stat(fullPath);
1291
- if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
1292
- stack.push(fullPath);
1293
- } else {
1294
- 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;
1295
1370
  }
1296
- } 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);
1297
1379
  }
1298
1380
  }
1299
1381
  } catch {
@@ -1334,29 +1416,38 @@ var ConfigRepo = class {
1334
1416
  async readAllBackendDescriptors() {
1335
1417
  try {
1336
1418
  const entries = await this.cachedFS.readdir(BACKENDS_DIR);
1337
- const items = [];
1338
- const corruptFiles = [];
1339
- for (const entry of entries) {
1340
- if (!entry.endsWith(".json")) continue;
1341
- const filePath = `${BACKENDS_DIR}/${entry}`;
1342
- try {
1343
- const raw = await this.cachedFS.readFile(filePath);
1344
- const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1345
- if (desc.id && desc.type) {
1346
- let mtime = 0;
1347
- try {
1348
- const stat = await this.cachedFS.stat(filePath);
1349
- mtime = stat.mtimeMs ?? 0;
1350
- } 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 };
1351
1437
  }
1352
- items.push({ desc, mtime });
1353
- } else {
1354
- console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1355
- 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 };
1356
1441
  }
1357
- } catch (parseErr) {
1358
- console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1359
- 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);
1360
1451
  }
1361
1452
  }
1362
1453
  for (const corruptPath of corruptFiles) {
@@ -1507,7 +1598,24 @@ var ConfigRepo = class {
1507
1598
  syncable,
1508
1599
  {
1509
1600
  direction: import_zen_fs_sync.SyncDirection.BiDirectional,
1510
- 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
+ }
1511
1619
  },
1512
1620
  "/"
1513
1621
  );
@@ -1526,26 +1634,33 @@ var ConfigRepo = class {
1526
1634
  throw new Error("Cannot remove the local IndexedDB primary backend");
1527
1635
  }
1528
1636
  const replica = this.replicaBackends.get(id);
1529
- if (!replica) {
1530
- throw new Error(`Backend "${id}" is not a registered replica`);
1531
- }
1532
1637
  const descPath = this.backendFilePath(id);
1533
- try {
1534
- await replica.instance.unlink(descPath);
1535
- } catch {
1536
- }
1537
- await this.unlinkVersionSidecar(replica.instance, descPath);
1538
- try {
1539
- await this.deleteFile(descPath);
1540
- } catch {
1541
- await this.removeBackendDescriptor(id);
1542
- }
1543
- this.syncEngine.removePair(replica.pairId);
1544
- console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1545
- this.replicaBackends.delete(id);
1546
- if (replica.instance?.dispose) {
1547
- 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
+ }
1548
1662
  }
1663
+ this.schedulePostDeleteSync();
1549
1664
  console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
1550
1665
  }
1551
1666
  // -----------------------------------------------------------------------
@@ -1905,16 +2020,16 @@ async function createConfigRepo(appId, options = {}) {
1905
2020
  }
1906
2021
  console.log(`[createConfigRepo] Migration complete`);
1907
2022
  }
2023
+ let allBackends = await tempRepo.readAllBackendDescriptors();
1908
2024
  if (options.backendInfo) {
1909
2025
  const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
1910
- const allBackends2 = await tempRepo.readAllBackendDescriptors();
1911
- const hasReplica = allBackends2.some((b) => b.id === replicaId);
2026
+ const hasReplica = allBackends.some((b) => b.id === replicaId);
1912
2027
  const newKey = backendDedupKey({
1913
2028
  id: replicaId,
1914
2029
  type: options.backendInfo.type,
1915
2030
  options: options.backendInfo.options
1916
2031
  });
1917
- const dupConfig = allBackends2.find((b) => backendDedupKey(b) === newKey);
2032
+ const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
1918
2033
  if (!hasReplica && !dupConfig) {
1919
2034
  await tempRepo.writeBackendDescriptor({
1920
2035
  id: replicaId,
@@ -1922,13 +2037,13 @@ async function createConfigRepo(appId, options = {}) {
1922
2037
  options: options.backendInfo.options
1923
2038
  });
1924
2039
  console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
2040
+ allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
1925
2041
  } else if (dupConfig) {
1926
2042
  console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
1927
2043
  } else {
1928
2044
  console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
1929
2045
  }
1930
2046
  }
1931
- const allBackends = await tempRepo.readAllBackendDescriptors();
1932
2047
  console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
1933
2048
  let nodeId = options.nodeId;
1934
2049
  if (!nodeId) {
@@ -1949,8 +2064,8 @@ async function createConfigRepo(appId, options = {}) {
1949
2064
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
1950
2065
  await repo.load();
1951
2066
  if (repo.replicaCount > 0) {
1952
- console.log("[createConfigRepo] Initial sync + dedup cycle...");
1953
- await repo.initialSyncAndDedup();
2067
+ console.log("[createConfigRepo] Starting background initial sync + dedup...");
2068
+ repo.startBackgroundSync();
1954
2069
  }
1955
2070
  repo.syncMetaToReplicas().catch((err) => {
1956
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.
@@ -786,7 +815,9 @@ var ConfigRepo = class {
786
815
  for (const tombstone of tombstones) {
787
816
  const tVersionPath = versionPathFor(tombstone.path);
788
817
  const existedOnPrimary = await this.safeExists(this.cachedFS, tombstone.path);
789
- console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on primary \u2192 ${existedOnPrimary ? "EXISTS" : "not found"}`);
818
+ if (existedOnPrimary) {
819
+ console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on primary \u2192 EXISTS`);
820
+ }
790
821
  if (existedOnPrimary) {
791
822
  try {
792
823
  await this.cachedFS.unlink(tombstone.path);
@@ -802,8 +833,8 @@ var ConfigRepo = class {
802
833
  }
803
834
  for (const [replicaId, replica] of this.replicaBackends) {
804
835
  const existed = await this.safeExists(replica.instance, tombstone.path);
805
- console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on ${replicaId} \u2192 ${existed ? "EXISTS" : "not found"}`);
806
836
  if (existed) {
837
+ console.log(`[ConfigRepo] tombstone check: ${tombstone.path} on ${replicaId} \u2192 EXISTS`);
807
838
  try {
808
839
  await replica.instance.unlink(tombstone.path);
809
840
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
@@ -859,6 +890,20 @@ var ConfigRepo = class {
859
890
  await this.processTombstones();
860
891
  this.syncEngine.watchAll();
861
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
+ }
862
907
  /**
863
908
  * After sync: mark each tombstone as confirmed by all replica backends.
864
909
  */
@@ -1006,7 +1051,15 @@ var ConfigRepo = class {
1006
1051
  // -----------------------------------------------------------------------
1007
1052
  async dispose() {
1008
1053
  if (this.disposed) return;
1054
+ if (this.initialSyncPromise) {
1055
+ await this.initialSyncPromise;
1056
+ this.initialSyncPromise = null;
1057
+ }
1009
1058
  this.disposed = true;
1059
+ if (this.postDeleteSyncTimer) {
1060
+ clearTimeout(this.postDeleteSyncTimer);
1061
+ this.postDeleteSyncTimer = void 0;
1062
+ }
1010
1063
  this.syncEngine.dispose();
1011
1064
  for (const [_id, replica] of this.replicaBackends) {
1012
1065
  if (replica.instance?.dispose) {
@@ -1047,7 +1100,24 @@ var ConfigRepo = class {
1047
1100
  {
1048
1101
  direction: SyncDirection.BiDirectional,
1049
1102
  conflictStrategy: "source-wins",
1050
- 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
+ }
1051
1121
  },
1052
1122
  "/"
1053
1123
  );
@@ -1100,13 +1170,19 @@ var ConfigRepo = class {
1100
1170
  const appDir = `/${this.appId}`;
1101
1171
  try {
1102
1172
  const files = await this.walkDir(appDir);
1103
- for (const filePath of files) {
1104
- try {
1105
- const raw = await this.cachedFS.readFile(filePath);
1106
- const data = this.serializer.deserialize(toUint8Array(raw), filePath);
1107
- this.configCache.set(filePath, data);
1108
- } catch {
1109
- }
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);
1110
1186
  }
1111
1187
  } catch {
1112
1188
  }
@@ -1193,17 +1269,23 @@ var ConfigRepo = class {
1193
1269
  const current = stack.pop();
1194
1270
  try {
1195
1271
  const entries = await this.cachedFS.readdir(current);
1196
- for (const entry of entries) {
1197
- if (entry.startsWith(".")) continue;
1198
- const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1199
- try {
1200
- const stat = await this.cachedFS.stat(fullPath);
1201
- if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
1202
- stack.push(fullPath);
1203
- } else {
1204
- 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;
1205
1280
  }
1206
- } 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);
1207
1289
  }
1208
1290
  }
1209
1291
  } catch {
@@ -1244,29 +1326,38 @@ var ConfigRepo = class {
1244
1326
  async readAllBackendDescriptors() {
1245
1327
  try {
1246
1328
  const entries = await this.cachedFS.readdir(BACKENDS_DIR);
1247
- const items = [];
1248
- const corruptFiles = [];
1249
- for (const entry of entries) {
1250
- if (!entry.endsWith(".json")) continue;
1251
- const filePath = `${BACKENDS_DIR}/${entry}`;
1252
- try {
1253
- const raw = await this.cachedFS.readFile(filePath);
1254
- const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
1255
- if (desc.id && desc.type) {
1256
- let mtime = 0;
1257
- try {
1258
- const stat = await this.cachedFS.stat(filePath);
1259
- mtime = stat.mtimeMs ?? 0;
1260
- } 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 };
1261
1347
  }
1262
- items.push({ desc, mtime });
1263
- } else {
1264
- console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
1265
- 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 };
1266
1351
  }
1267
- } catch (parseErr) {
1268
- console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
1269
- 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);
1270
1361
  }
1271
1362
  }
1272
1363
  for (const corruptPath of corruptFiles) {
@@ -1417,7 +1508,24 @@ var ConfigRepo = class {
1417
1508
  syncable,
1418
1509
  {
1419
1510
  direction: SyncDirection.BiDirectional,
1420
- 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
+ }
1421
1529
  },
1422
1530
  "/"
1423
1531
  );
@@ -1436,26 +1544,33 @@ var ConfigRepo = class {
1436
1544
  throw new Error("Cannot remove the local IndexedDB primary backend");
1437
1545
  }
1438
1546
  const replica = this.replicaBackends.get(id);
1439
- if (!replica) {
1440
- throw new Error(`Backend "${id}" is not a registered replica`);
1441
- }
1442
1547
  const descPath = this.backendFilePath(id);
1443
- try {
1444
- await replica.instance.unlink(descPath);
1445
- } catch {
1446
- }
1447
- await this.unlinkVersionSidecar(replica.instance, descPath);
1448
- try {
1449
- await this.deleteFile(descPath);
1450
- } catch {
1451
- await this.removeBackendDescriptor(id);
1452
- }
1453
- this.syncEngine.removePair(replica.pairId);
1454
- console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
1455
- this.replicaBackends.delete(id);
1456
- if (replica.instance?.dispose) {
1457
- 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
+ }
1458
1572
  }
1573
+ this.schedulePostDeleteSync();
1459
1574
  console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
1460
1575
  }
1461
1576
  // -----------------------------------------------------------------------
@@ -1815,16 +1930,16 @@ async function createConfigRepo(appId, options = {}) {
1815
1930
  }
1816
1931
  console.log(`[createConfigRepo] Migration complete`);
1817
1932
  }
1933
+ let allBackends = await tempRepo.readAllBackendDescriptors();
1818
1934
  if (options.backendInfo) {
1819
1935
  const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
1820
- const allBackends2 = await tempRepo.readAllBackendDescriptors();
1821
- const hasReplica = allBackends2.some((b) => b.id === replicaId);
1936
+ const hasReplica = allBackends.some((b) => b.id === replicaId);
1822
1937
  const newKey = backendDedupKey({
1823
1938
  id: replicaId,
1824
1939
  type: options.backendInfo.type,
1825
1940
  options: options.backendInfo.options
1826
1941
  });
1827
- const dupConfig = allBackends2.find((b) => backendDedupKey(b) === newKey);
1942
+ const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
1828
1943
  if (!hasReplica && !dupConfig) {
1829
1944
  await tempRepo.writeBackendDescriptor({
1830
1945
  id: replicaId,
@@ -1832,13 +1947,13 @@ async function createConfigRepo(appId, options = {}) {
1832
1947
  options: options.backendInfo.options
1833
1948
  });
1834
1949
  console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
1950
+ allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
1835
1951
  } else if (dupConfig) {
1836
1952
  console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
1837
1953
  } else {
1838
1954
  console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
1839
1955
  }
1840
1956
  }
1841
- const allBackends = await tempRepo.readAllBackendDescriptors();
1842
1957
  console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
1843
1958
  let nodeId = options.nodeId;
1844
1959
  if (!nodeId) {
@@ -1859,8 +1974,8 @@ async function createConfigRepo(appId, options = {}) {
1859
1974
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
1860
1975
  await repo.load();
1861
1976
  if (repo.replicaCount > 0) {
1862
- console.log("[createConfigRepo] Initial sync + dedup cycle...");
1863
- await repo.initialSyncAndDedup();
1977
+ console.log("[createConfigRepo] Starting background initial sync + dedup...");
1978
+ repo.startBackgroundSync();
1864
1979
  }
1865
1980
  repo.syncMetaToReplicas().catch((err) => {
1866
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.12",
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
  }