zen-fs-config 0.5.5 → 0.5.7

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/DESIGN.md CHANGED
@@ -1147,6 +1147,67 @@ Application
1147
1147
  → Dispose temporary SyncPair
1148
1148
  ```
1149
1149
 
1150
+ ### Mtime Preservation During Sync
1151
+
1152
+ **Problem**: When the sync engine copies a file from source to target, it calls `writeFile(path, data)`. The target backend sets its own mtime (typically `Date.now()`), losing the source file's original mtime. This causes the next sync cycle to detect a "modified" file (source mtime ≠ target mtime), triggering unnecessary copies on every sync.
1153
+
1154
+ **Solution**: An optional `writeFileWithMtime` method on the `SyncableFS` interface, with automatic fallback to `writeFile` when not implemented:
1155
+
1156
+ ```typescript
1157
+ interface SyncableFS {
1158
+ // ... existing methods ...
1159
+
1160
+ /**
1161
+ * Optional: write file with precise mtime.
1162
+ * If implemented, the sync engine uses this instead of writeFile,
1163
+ * passing the source file's mtime so the target can preserve it.
1164
+ * Backends that don't support precise mtime should not implement this —
1165
+ * the sync engine falls back to plain writeFile.
1166
+ */
1167
+ writeFileWithMtime?(path: string, data: string | Uint8Array, mtime: number): Promise<void>;
1168
+ }
1169
+ ```
1170
+
1171
+ **Sync engine (`zen-fs-sync`)**: A central helper function handles the fallback:
1172
+
1173
+ ```javascript
1174
+ async function writeFileWithMtimeFallback(fs, path, data, mtimeMs) {
1175
+ if (mtimeMs !== undefined && typeof fs.writeFileWithMtime === "function") {
1176
+ await fs.writeFileWithMtime(path, data, mtimeMs);
1177
+ } else {
1178
+ await fs.writeFile(path, data);
1179
+ }
1180
+ }
1181
+ ```
1182
+
1183
+ This helper is used in `copyFile()`, `syncOneWay()`, and `writeFileBoth()`. The source file's mtime is obtained via `stat()` before writing, then passed through to the target.
1184
+
1185
+ **Adapters (`zen-fs-config`)**: All three `SyncableFS` adapters implement `writeFileWithMtime`:
1186
+
1187
+ | Adapter | Implementation |
1188
+ |---|---|
1189
+ | `backendToSyncableFS` | Passes `{ mtime }` as options to `backend.writeFile()` — the backend's `writeFile` calls `touch()` with the provided mtime |
1190
+ | `zenfsPromisesToSyncableFS` | Calls `promises.writeFile()` then `promises.utimes()` as a fallback (some VFS backends don't support mtime in writeFile) |
1191
+ | `cachedFSToSyncableFS` | Passes `{ mtime }` as options to `cached.writeFile()` — mtime flows through to the underlying backend |
1192
+
1193
+ **RemoteStorage backend (`zen-fs-remotestoragejs`)**: `writeFileWithMtime` delegates to `writeFile(path, data, { mtime })`, which writes the `.mtime` sidecar file to preserve millisecond-precision mtime (see RemoteStorage DESIGN.md §2 for details).
1194
+
1195
+ **Data flow**:
1196
+ ```
1197
+ Source file: /app-a/db.json (mtime=1700000000123)
1198
+
1199
+ ├─ sync engine: stat("/app-a/db.json") → mtimeMs=1700000000123
1200
+ ├─ sync engine: readFile("/app-a/db.json") → data
1201
+ ├─ sync engine: writeFileWithMtimeFallback(target, "/app-a/db.json", data, 1700000000123)
1202
+ │ ├─ target has writeFileWithMtime? → YES → target.writeFileWithMtime(path, data, 1700000000123)
1203
+ │ │ → backend.writeFile(path, data, { mtime: 1700000000123 })
1204
+ │ │ → touch(path, { mtimeMs: 1700000000123 })
1205
+ │ └─ target has writeFileWithMtime? → NO → target.writeFile(path, data) [fallback]
1206
+
1207
+ └─ Target file: /app-a/db.json (mtime=1700000000123) ← preserved!
1208
+ → Next sync: source.mtimeMs === target.mtimeMs → skip (no spurious copy)
1209
+ ```
1210
+
1150
1211
  ## 13. Peer Dependencies
1151
1212
 
1152
1213
  | Package | Role | Version | Required |
package/dist/index.d.mts CHANGED
@@ -462,6 +462,8 @@ declare class ConfigRepo implements IConfigRepo {
462
462
  private configCache;
463
463
  private readonly primaryBackendId;
464
464
  private readonly pollIntervalMs?;
465
+ /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
466
+ private tombstoneCache;
465
467
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number);
466
468
  /** Full path to this node's directory on the primary backend. */
467
469
  get nodePath(): string;
@@ -484,8 +486,11 @@ declare class ConfigRepo implements IConfigRepo {
484
486
  deleteFile(path: string): Promise<void>;
485
487
  /**
486
488
  * Read all tombstones from the primary backend.
489
+ * Results are cached within a flush() cycle to avoid redundant reads.
487
490
  */
488
491
  private readTombstones;
492
+ /** Invalidate the tombstone cache — call after tombstones are modified. */
493
+ private invalidateTombstoneCache;
489
494
  /**
490
495
  * Before sync: for each tombstone, delete the actual file on all replicas.
491
496
  * This prevents bi-directional sync from copying the file back.
@@ -522,6 +527,12 @@ declare class ConfigRepo implements IConfigRepo {
522
527
  readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
523
528
  dispose(): Promise<void>;
524
529
  setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
530
+ /** Write version sidecar for a config file (no-op for .version files). */
531
+ private writeVersionSidecar;
532
+ /** Delete version sidecar on a backend (no-op for .version files). */
533
+ private unlinkVersionSidecar;
534
+ /** Read version sidecar (returns null for .version files). */
535
+ private readVersionSidecar;
525
536
  private persistConfig;
526
537
  private reloadConfigCache;
527
538
  private handleConflict;
@@ -683,8 +694,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
683
694
  * /app-a/db.json → /app-a/.db.json.version
684
695
  * /shared/flags.json → /shared/.flags.json.version
685
696
  * /nodes/s1/env.json → /nodes/s1/.env.json.version
697
+ *
698
+ * Returns null for files that are already version sidecars (.version files),
699
+ * to prevent creating version-of-version files (e.g. ..db.json.version.version).
686
700
  */
687
- declare function versionPathFor(configFilePath: string): string;
701
+ declare function versionPathFor(configFilePath: string): string | null;
688
702
  /**
689
703
  * Compute SHA-256 hash of a Uint8Array.
690
704
  * Returns "sha256:" prefix + hex digest.
package/dist/index.d.ts CHANGED
@@ -462,6 +462,8 @@ declare class ConfigRepo implements IConfigRepo {
462
462
  private configCache;
463
463
  private readonly primaryBackendId;
464
464
  private readonly pollIntervalMs?;
465
+ /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
466
+ private tombstoneCache;
465
467
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number);
466
468
  /** Full path to this node's directory on the primary backend. */
467
469
  get nodePath(): string;
@@ -484,8 +486,11 @@ declare class ConfigRepo implements IConfigRepo {
484
486
  deleteFile(path: string): Promise<void>;
485
487
  /**
486
488
  * Read all tombstones from the primary backend.
489
+ * Results are cached within a flush() cycle to avoid redundant reads.
487
490
  */
488
491
  private readTombstones;
492
+ /** Invalidate the tombstone cache — call after tombstones are modified. */
493
+ private invalidateTombstoneCache;
489
494
  /**
490
495
  * Before sync: for each tombstone, delete the actual file on all replicas.
491
496
  * This prevents bi-directional sync from copying the file back.
@@ -522,6 +527,12 @@ declare class ConfigRepo implements IConfigRepo {
522
527
  readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
523
528
  dispose(): Promise<void>;
524
529
  setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
530
+ /** Write version sidecar for a config file (no-op for .version files). */
531
+ private writeVersionSidecar;
532
+ /** Delete version sidecar on a backend (no-op for .version files). */
533
+ private unlinkVersionSidecar;
534
+ /** Read version sidecar (returns null for .version files). */
535
+ private readVersionSidecar;
525
536
  private persistConfig;
526
537
  private reloadConfigCache;
527
538
  private handleConflict;
@@ -683,8 +694,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
683
694
  * /app-a/db.json → /app-a/.db.json.version
684
695
  * /shared/flags.json → /shared/.flags.json.version
685
696
  * /nodes/s1/env.json → /nodes/s1/.env.json.version
697
+ *
698
+ * Returns null for files that are already version sidecars (.version files),
699
+ * to prevent creating version-of-version files (e.g. ..db.json.version.version).
686
700
  */
687
- declare function versionPathFor(configFilePath: string): string;
701
+ declare function versionPathFor(configFilePath: string): string | null;
688
702
  /**
689
703
  * Compute SHA-256 hash of a Uint8Array.
690
704
  * Returns "sha256:" prefix + hex digest.
package/dist/index.js CHANGED
@@ -250,6 +250,9 @@ function backendToSyncableFS(backend, name) {
250
250
  async writeFile(path, data) {
251
251
  return backend.writeFile(path, data);
252
252
  },
253
+ async writeFileWithMtime(path, data, mtime) {
254
+ return backend.writeFile(path, data, { mtime });
255
+ },
253
256
  async unlink(path) {
254
257
  return backend.unlink(path);
255
258
  },
@@ -373,7 +376,7 @@ async function wrapZenFSFileSystem(config) {
373
376
  }
374
377
  await isolatedFS.write(path, bytes, 0);
375
378
  try {
376
- await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
379
+ await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
377
380
  } catch {
378
381
  }
379
382
  notifyChange();
@@ -454,6 +457,9 @@ function versionPathFor(configFilePath) {
454
457
  const lastSlash = configFilePath.lastIndexOf("/");
455
458
  const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
456
459
  const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
460
+ if (fileName.endsWith(".version")) {
461
+ return null;
462
+ }
457
463
  const versionFileName = `.${fileName}.version`;
458
464
  return dir ? `${dir}/${versionFileName}` : versionFileName;
459
465
  }
@@ -490,7 +496,7 @@ async function writeVersion(fs, versionFilePath, meta) {
490
496
  }
491
497
  async function incrementVersion(fs, configFilePath, newContent, author) {
492
498
  const vPath = versionPathFor(configFilePath);
493
- const prev = await readVersion(fs, vPath);
499
+ const prev = vPath ? await readVersion(fs, vPath) : null;
494
500
  const hash = await sha256(newContent);
495
501
  return {
496
502
  version: (prev?.version ?? 0) + 1,
@@ -501,6 +507,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
501
507
  }
502
508
  async function verifyOrRepairVersion(fs, configFilePath, author) {
503
509
  const vPath = versionPathFor(configFilePath);
510
+ if (!vPath) return null;
504
511
  const existing = await readVersion(fs, vPath);
505
512
  if (!existing) return null;
506
513
  try {
@@ -579,6 +586,8 @@ var ConfigRepo = class {
579
586
  configCache = /* @__PURE__ */ new Map();
580
587
  primaryBackendId;
581
588
  pollIntervalMs;
589
+ /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
590
+ tombstoneCache = null;
582
591
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs) {
583
592
  this.appId = appId;
584
593
  this.nodeId = nodeId;
@@ -739,10 +748,12 @@ var ConfigRepo = class {
739
748
  this.assertNotDisposed();
740
749
  await this.processTombstones();
741
750
  const resultsMap = await this.syncEngine.syncAll();
751
+ this.invalidateTombstoneCache();
742
752
  await this.readAllBackendDescriptors();
743
753
  await this.processTombstones();
744
754
  await this.updateTombstoneConfirmations();
745
755
  await this.gcTombstones();
756
+ this.invalidateTombstoneCache();
746
757
  return Array.from(resultsMap.values());
747
758
  }
748
759
  // -----------------------------------------------------------------------
@@ -772,16 +783,23 @@ var ConfigRepo = class {
772
783
  } catch {
773
784
  }
774
785
  const versionPath = versionPathFor(normalizedPath);
775
- try {
776
- await this.cachedFS.unlink(versionPath);
777
- } catch {
786
+ if (versionPath) {
787
+ try {
788
+ await this.cachedFS.unlink(versionPath);
789
+ } catch {
790
+ }
778
791
  }
779
792
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
793
+ this.invalidateTombstoneCache();
780
794
  }
781
795
  /**
782
796
  * Read all tombstones from the primary backend.
797
+ * Results are cached within a flush() cycle to avoid redundant reads.
783
798
  */
784
799
  async readTombstones() {
800
+ if (this.tombstoneCache !== null) {
801
+ return this.tombstoneCache;
802
+ }
785
803
  try {
786
804
  const entries = await this.cachedFS.readdir(DELETIONS_DIR);
787
805
  const tombstones = [];
@@ -794,11 +812,17 @@ var ConfigRepo = class {
794
812
  } catch {
795
813
  }
796
814
  }
815
+ this.tombstoneCache = tombstones;
797
816
  return tombstones;
798
817
  } catch {
818
+ this.tombstoneCache = [];
799
819
  return [];
800
820
  }
801
821
  }
822
+ /** Invalidate the tombstone cache — call after tombstones are modified. */
823
+ invalidateTombstoneCache() {
824
+ this.tombstoneCache = null;
825
+ }
802
826
  /**
803
827
  * Before sync: for each tombstone, delete the actual file on all replicas.
804
828
  * This prevents bi-directional sync from copying the file back.
@@ -808,22 +832,27 @@ var ConfigRepo = class {
808
832
  if (tombstones.length === 0) return;
809
833
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
810
834
  for (const tombstone of tombstones) {
835
+ const tVersionPath = versionPathFor(tombstone.path);
811
836
  try {
812
837
  await this.cachedFS.unlink(tombstone.path);
813
838
  } catch {
814
839
  }
815
- try {
816
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
817
- } catch {
840
+ if (tVersionPath) {
841
+ try {
842
+ await this.cachedFS.unlink(tVersionPath);
843
+ } catch {
844
+ }
818
845
  }
819
846
  for (const [replicaId, replica] of this.replicaBackends) {
820
847
  try {
821
848
  await replica.instance.unlink(tombstone.path);
822
849
  } catch {
823
850
  }
824
- try {
825
- await replica.instance.unlink(versionPathFor(tombstone.path));
826
- } catch {
851
+ if (tVersionPath) {
852
+ try {
853
+ await replica.instance.unlink(tVersionPath);
854
+ } catch {
855
+ }
827
856
  }
828
857
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
829
858
  }
@@ -869,6 +898,7 @@ var ConfigRepo = class {
869
898
  }
870
899
  }
871
900
  console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
901
+ this.invalidateTombstoneCache();
872
902
  }
873
903
  /**
874
904
  * GC: remove tombstones where all backends in backends.json have confirmed.
@@ -932,7 +962,7 @@ var ConfigRepo = class {
932
962
  bytes,
933
963
  author
934
964
  );
935
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
965
+ await this.writeVersionSidecar(configPath, version);
936
966
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
937
967
  const resolvedBackupPath = `${conflictDir}/resolved`;
938
968
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -1033,13 +1063,34 @@ var ConfigRepo = class {
1033
1063
  // -----------------------------------------------------------------------
1034
1064
  // Internal — Persistence
1035
1065
  // -----------------------------------------------------------------------
1066
+ /** Write version sidecar for a config file (no-op for .version files). */
1067
+ async writeVersionSidecar(configPath, version) {
1068
+ const vPath = versionPathFor(configPath);
1069
+ if (!vPath) return;
1070
+ await this.ensureDir(vPath);
1071
+ await writeVersion(this.fullFS, vPath, version);
1072
+ }
1073
+ /** Delete version sidecar on a backend (no-op for .version files). */
1074
+ async unlinkVersionSidecar(fs, configPath) {
1075
+ const vPath = versionPathFor(configPath);
1076
+ if (!vPath) return;
1077
+ try {
1078
+ await fs.unlink(vPath);
1079
+ } catch {
1080
+ }
1081
+ }
1082
+ /** Read version sidecar (returns null for .version files). */
1083
+ async readVersionSidecar(configPath) {
1084
+ const vPath = versionPathFor(configPath);
1085
+ if (!vPath) return null;
1086
+ return readVersion(this.fullFS, vPath);
1087
+ }
1036
1088
  async persistConfig(fullPath, bytes) {
1037
1089
  await this.ensureDir(fullPath);
1038
1090
  await this.cachedFS.writeFile(fullPath, bytes);
1039
1091
  const author = `${this.appId}/${this.nodeId}`;
1040
1092
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
1041
- await this.ensureDir(versionPathFor(fullPath));
1042
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1093
+ await this.writeVersionSidecar(fullPath, version);
1043
1094
  }
1044
1095
  async reloadConfigCache() {
1045
1096
  const appDir = `/${this.appId}`;
@@ -1077,7 +1128,7 @@ var ConfigRepo = class {
1077
1128
  );
1078
1129
  let sourceVersion = 0;
1079
1130
  try {
1080
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1131
+ const srcVer = await this.readVersionSidecar(conflict.path);
1081
1132
  if (srcVer) sourceVersion = srcVer.version;
1082
1133
  } catch {
1083
1134
  }
@@ -1162,8 +1213,7 @@ var ConfigRepo = class {
1162
1213
  await this.cachedFS.writeFile(path, bytes);
1163
1214
  const author = `${this.appId}/${this.nodeId}`;
1164
1215
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1165
- await this.ensureDir(versionPathFor(path));
1166
- await writeVersion(this.fullFS, versionPathFor(path), version);
1216
+ await this.writeVersionSidecar(path, version);
1167
1217
  }
1168
1218
  async readMetaFile(path) {
1169
1219
  try {
@@ -1221,10 +1271,7 @@ var ConfigRepo = class {
1221
1271
  await replica.instance.unlink(corruptPath);
1222
1272
  } catch {
1223
1273
  }
1224
- try {
1225
- await replica.instance.unlink(versionPathFor(corruptPath));
1226
- } catch {
1227
- }
1274
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1228
1275
  }
1229
1276
  try {
1230
1277
  await this.deleteFile(corruptPath);
@@ -1233,10 +1280,7 @@ var ConfigRepo = class {
1233
1280
  await this.cachedFS.unlink(corruptPath);
1234
1281
  } catch {
1235
1282
  }
1236
- try {
1237
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1238
- } catch {
1239
- }
1283
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1240
1284
  }
1241
1285
  }
1242
1286
  const seen = /* @__PURE__ */ new Map();
@@ -1266,10 +1310,7 @@ var ConfigRepo = class {
1266
1310
  await replica.instance.unlink(descPath);
1267
1311
  } catch {
1268
1312
  }
1269
- try {
1270
- await replica.instance.unlink(versionPathFor(descPath));
1271
- } catch {
1272
- }
1313
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1273
1314
  }
1274
1315
  try {
1275
1316
  await this.deleteFile(descPath);
@@ -1291,8 +1332,7 @@ var ConfigRepo = class {
1291
1332
  await this.cachedFS.writeFile(path, bytes);
1292
1333
  const author = `${this.appId}/${this.nodeId}`;
1293
1334
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1294
- await this.ensureDir(versionPathFor(path));
1295
- await writeVersion(this.fullFS, versionPathFor(path), version);
1335
+ await this.writeVersionSidecar(path, version);
1296
1336
  }
1297
1337
  /** Remove a single backend descriptor file + its version sidecar */
1298
1338
  async removeBackendDescriptor(id) {
@@ -1301,10 +1341,7 @@ var ConfigRepo = class {
1301
1341
  await this.cachedFS.unlink(path);
1302
1342
  } catch {
1303
1343
  }
1304
- try {
1305
- await this.cachedFS.unlink(versionPathFor(path));
1306
- } catch {
1307
- }
1344
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1308
1345
  }
1309
1346
  // -----------------------------------------------------------------------
1310
1347
  // IConfigRepo — Meta file access (no chroot)
@@ -1403,10 +1440,7 @@ var ConfigRepo = class {
1403
1440
  await replica.instance.unlink(descPath);
1404
1441
  } catch {
1405
1442
  }
1406
- try {
1407
- await replica.instance.unlink(versionPathFor(descPath));
1408
- } catch {
1409
- }
1443
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1410
1444
  try {
1411
1445
  await this.deleteFile(descPath);
1412
1446
  } catch {
@@ -1502,8 +1536,7 @@ var ConfigRepo = class {
1502
1536
  await this.cachedFS.writeFile(descPath, bytes);
1503
1537
  const author = `${this.appId}/${this.nodeId}`;
1504
1538
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1505
- await this.ensureDir(versionPathFor(descPath));
1506
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1539
+ await this.writeVersionSidecar(descPath, version);
1507
1540
  this.appDataGroups.set(id, group);
1508
1541
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1509
1542
  return group;
@@ -1563,10 +1596,7 @@ var ConfigRepo = class {
1563
1596
  await this.cachedFS.unlink(descPath);
1564
1597
  } catch {
1565
1598
  }
1566
- try {
1567
- await this.cachedFS.unlink(versionPathFor(descPath));
1568
- } catch {
1569
- }
1599
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1570
1600
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1571
1601
  }
1572
1602
  async listAccountBackends() {
@@ -1764,9 +1794,14 @@ async function createConfigRepo(appId, options = {}) {
1764
1794
  await cachedFS.unlink(BACKENDS_FILE);
1765
1795
  } catch {
1766
1796
  }
1767
- try {
1768
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1769
- } catch {
1797
+ {
1798
+ const vPath = versionPathFor(BACKENDS_FILE);
1799
+ if (vPath) {
1800
+ try {
1801
+ await cachedFS.unlink(vPath);
1802
+ } catch {
1803
+ }
1804
+ }
1770
1805
  }
1771
1806
  console.log(`[createConfigRepo] Migration complete`);
1772
1807
  }
package/dist/index.mjs CHANGED
@@ -193,6 +193,9 @@ function backendToSyncableFS(backend, name) {
193
193
  async writeFile(path, data) {
194
194
  return backend.writeFile(path, data);
195
195
  },
196
+ async writeFileWithMtime(path, data, mtime) {
197
+ return backend.writeFile(path, data, { mtime });
198
+ },
196
199
  async unlink(path) {
197
200
  return backend.unlink(path);
198
201
  },
@@ -316,7 +319,7 @@ async function wrapZenFSFileSystem(config) {
316
319
  }
317
320
  await isolatedFS.write(path, bytes, 0);
318
321
  try {
319
- await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
322
+ await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
320
323
  } catch {
321
324
  }
322
325
  notifyChange();
@@ -397,6 +400,9 @@ function versionPathFor(configFilePath) {
397
400
  const lastSlash = configFilePath.lastIndexOf("/");
398
401
  const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
399
402
  const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
403
+ if (fileName.endsWith(".version")) {
404
+ return null;
405
+ }
400
406
  const versionFileName = `.${fileName}.version`;
401
407
  return dir ? `${dir}/${versionFileName}` : versionFileName;
402
408
  }
@@ -433,7 +439,7 @@ async function writeVersion(fs, versionFilePath, meta) {
433
439
  }
434
440
  async function incrementVersion(fs, configFilePath, newContent, author) {
435
441
  const vPath = versionPathFor(configFilePath);
436
- const prev = await readVersion(fs, vPath);
442
+ const prev = vPath ? await readVersion(fs, vPath) : null;
437
443
  const hash = await sha256(newContent);
438
444
  return {
439
445
  version: (prev?.version ?? 0) + 1,
@@ -444,6 +450,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
444
450
  }
445
451
  async function verifyOrRepairVersion(fs, configFilePath, author) {
446
452
  const vPath = versionPathFor(configFilePath);
453
+ if (!vPath) return null;
447
454
  const existing = await readVersion(fs, vPath);
448
455
  if (!existing) return null;
449
456
  try {
@@ -522,6 +529,8 @@ var ConfigRepo = class {
522
529
  configCache = /* @__PURE__ */ new Map();
523
530
  primaryBackendId;
524
531
  pollIntervalMs;
532
+ /** Tombstone cache — avoids redundant reads within a single flush() cycle. */
533
+ tombstoneCache = null;
525
534
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs) {
526
535
  this.appId = appId;
527
536
  this.nodeId = nodeId;
@@ -682,10 +691,12 @@ var ConfigRepo = class {
682
691
  this.assertNotDisposed();
683
692
  await this.processTombstones();
684
693
  const resultsMap = await this.syncEngine.syncAll();
694
+ this.invalidateTombstoneCache();
685
695
  await this.readAllBackendDescriptors();
686
696
  await this.processTombstones();
687
697
  await this.updateTombstoneConfirmations();
688
698
  await this.gcTombstones();
699
+ this.invalidateTombstoneCache();
689
700
  return Array.from(resultsMap.values());
690
701
  }
691
702
  // -----------------------------------------------------------------------
@@ -715,16 +726,23 @@ var ConfigRepo = class {
715
726
  } catch {
716
727
  }
717
728
  const versionPath = versionPathFor(normalizedPath);
718
- try {
719
- await this.cachedFS.unlink(versionPath);
720
- } catch {
729
+ if (versionPath) {
730
+ try {
731
+ await this.cachedFS.unlink(versionPath);
732
+ } catch {
733
+ }
721
734
  }
722
735
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
736
+ this.invalidateTombstoneCache();
723
737
  }
724
738
  /**
725
739
  * Read all tombstones from the primary backend.
740
+ * Results are cached within a flush() cycle to avoid redundant reads.
726
741
  */
727
742
  async readTombstones() {
743
+ if (this.tombstoneCache !== null) {
744
+ return this.tombstoneCache;
745
+ }
728
746
  try {
729
747
  const entries = await this.cachedFS.readdir(DELETIONS_DIR);
730
748
  const tombstones = [];
@@ -737,11 +755,17 @@ var ConfigRepo = class {
737
755
  } catch {
738
756
  }
739
757
  }
758
+ this.tombstoneCache = tombstones;
740
759
  return tombstones;
741
760
  } catch {
761
+ this.tombstoneCache = [];
742
762
  return [];
743
763
  }
744
764
  }
765
+ /** Invalidate the tombstone cache — call after tombstones are modified. */
766
+ invalidateTombstoneCache() {
767
+ this.tombstoneCache = null;
768
+ }
745
769
  /**
746
770
  * Before sync: for each tombstone, delete the actual file on all replicas.
747
771
  * This prevents bi-directional sync from copying the file back.
@@ -751,22 +775,27 @@ var ConfigRepo = class {
751
775
  if (tombstones.length === 0) return;
752
776
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
753
777
  for (const tombstone of tombstones) {
778
+ const tVersionPath = versionPathFor(tombstone.path);
754
779
  try {
755
780
  await this.cachedFS.unlink(tombstone.path);
756
781
  } catch {
757
782
  }
758
- try {
759
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
760
- } catch {
783
+ if (tVersionPath) {
784
+ try {
785
+ await this.cachedFS.unlink(tVersionPath);
786
+ } catch {
787
+ }
761
788
  }
762
789
  for (const [replicaId, replica] of this.replicaBackends) {
763
790
  try {
764
791
  await replica.instance.unlink(tombstone.path);
765
792
  } catch {
766
793
  }
767
- try {
768
- await replica.instance.unlink(versionPathFor(tombstone.path));
769
- } catch {
794
+ if (tVersionPath) {
795
+ try {
796
+ await replica.instance.unlink(tVersionPath);
797
+ } catch {
798
+ }
770
799
  }
771
800
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
772
801
  }
@@ -812,6 +841,7 @@ var ConfigRepo = class {
812
841
  }
813
842
  }
814
843
  console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
844
+ this.invalidateTombstoneCache();
815
845
  }
816
846
  /**
817
847
  * GC: remove tombstones where all backends in backends.json have confirmed.
@@ -875,7 +905,7 @@ var ConfigRepo = class {
875
905
  bytes,
876
906
  author
877
907
  );
878
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
908
+ await this.writeVersionSidecar(configPath, version);
879
909
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
880
910
  const resolvedBackupPath = `${conflictDir}/resolved`;
881
911
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -976,13 +1006,34 @@ var ConfigRepo = class {
976
1006
  // -----------------------------------------------------------------------
977
1007
  // Internal — Persistence
978
1008
  // -----------------------------------------------------------------------
1009
+ /** Write version sidecar for a config file (no-op for .version files). */
1010
+ async writeVersionSidecar(configPath, version) {
1011
+ const vPath = versionPathFor(configPath);
1012
+ if (!vPath) return;
1013
+ await this.ensureDir(vPath);
1014
+ await writeVersion(this.fullFS, vPath, version);
1015
+ }
1016
+ /** Delete version sidecar on a backend (no-op for .version files). */
1017
+ async unlinkVersionSidecar(fs, configPath) {
1018
+ const vPath = versionPathFor(configPath);
1019
+ if (!vPath) return;
1020
+ try {
1021
+ await fs.unlink(vPath);
1022
+ } catch {
1023
+ }
1024
+ }
1025
+ /** Read version sidecar (returns null for .version files). */
1026
+ async readVersionSidecar(configPath) {
1027
+ const vPath = versionPathFor(configPath);
1028
+ if (!vPath) return null;
1029
+ return readVersion(this.fullFS, vPath);
1030
+ }
979
1031
  async persistConfig(fullPath, bytes) {
980
1032
  await this.ensureDir(fullPath);
981
1033
  await this.cachedFS.writeFile(fullPath, bytes);
982
1034
  const author = `${this.appId}/${this.nodeId}`;
983
1035
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
984
- await this.ensureDir(versionPathFor(fullPath));
985
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1036
+ await this.writeVersionSidecar(fullPath, version);
986
1037
  }
987
1038
  async reloadConfigCache() {
988
1039
  const appDir = `/${this.appId}`;
@@ -1020,7 +1071,7 @@ var ConfigRepo = class {
1020
1071
  );
1021
1072
  let sourceVersion = 0;
1022
1073
  try {
1023
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1074
+ const srcVer = await this.readVersionSidecar(conflict.path);
1024
1075
  if (srcVer) sourceVersion = srcVer.version;
1025
1076
  } catch {
1026
1077
  }
@@ -1105,8 +1156,7 @@ var ConfigRepo = class {
1105
1156
  await this.cachedFS.writeFile(path, bytes);
1106
1157
  const author = `${this.appId}/${this.nodeId}`;
1107
1158
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1108
- await this.ensureDir(versionPathFor(path));
1109
- await writeVersion(this.fullFS, versionPathFor(path), version);
1159
+ await this.writeVersionSidecar(path, version);
1110
1160
  }
1111
1161
  async readMetaFile(path) {
1112
1162
  try {
@@ -1164,10 +1214,7 @@ var ConfigRepo = class {
1164
1214
  await replica.instance.unlink(corruptPath);
1165
1215
  } catch {
1166
1216
  }
1167
- try {
1168
- await replica.instance.unlink(versionPathFor(corruptPath));
1169
- } catch {
1170
- }
1217
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1171
1218
  }
1172
1219
  try {
1173
1220
  await this.deleteFile(corruptPath);
@@ -1176,10 +1223,7 @@ var ConfigRepo = class {
1176
1223
  await this.cachedFS.unlink(corruptPath);
1177
1224
  } catch {
1178
1225
  }
1179
- try {
1180
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1181
- } catch {
1182
- }
1226
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1183
1227
  }
1184
1228
  }
1185
1229
  const seen = /* @__PURE__ */ new Map();
@@ -1209,10 +1253,7 @@ var ConfigRepo = class {
1209
1253
  await replica.instance.unlink(descPath);
1210
1254
  } catch {
1211
1255
  }
1212
- try {
1213
- await replica.instance.unlink(versionPathFor(descPath));
1214
- } catch {
1215
- }
1256
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1216
1257
  }
1217
1258
  try {
1218
1259
  await this.deleteFile(descPath);
@@ -1234,8 +1275,7 @@ var ConfigRepo = class {
1234
1275
  await this.cachedFS.writeFile(path, bytes);
1235
1276
  const author = `${this.appId}/${this.nodeId}`;
1236
1277
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1237
- await this.ensureDir(versionPathFor(path));
1238
- await writeVersion(this.fullFS, versionPathFor(path), version);
1278
+ await this.writeVersionSidecar(path, version);
1239
1279
  }
1240
1280
  /** Remove a single backend descriptor file + its version sidecar */
1241
1281
  async removeBackendDescriptor(id) {
@@ -1244,10 +1284,7 @@ var ConfigRepo = class {
1244
1284
  await this.cachedFS.unlink(path);
1245
1285
  } catch {
1246
1286
  }
1247
- try {
1248
- await this.cachedFS.unlink(versionPathFor(path));
1249
- } catch {
1250
- }
1287
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1251
1288
  }
1252
1289
  // -----------------------------------------------------------------------
1253
1290
  // IConfigRepo — Meta file access (no chroot)
@@ -1346,10 +1383,7 @@ var ConfigRepo = class {
1346
1383
  await replica.instance.unlink(descPath);
1347
1384
  } catch {
1348
1385
  }
1349
- try {
1350
- await replica.instance.unlink(versionPathFor(descPath));
1351
- } catch {
1352
- }
1386
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1353
1387
  try {
1354
1388
  await this.deleteFile(descPath);
1355
1389
  } catch {
@@ -1445,8 +1479,7 @@ var ConfigRepo = class {
1445
1479
  await this.cachedFS.writeFile(descPath, bytes);
1446
1480
  const author = `${this.appId}/${this.nodeId}`;
1447
1481
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1448
- await this.ensureDir(versionPathFor(descPath));
1449
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1482
+ await this.writeVersionSidecar(descPath, version);
1450
1483
  this.appDataGroups.set(id, group);
1451
1484
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1452
1485
  return group;
@@ -1506,10 +1539,7 @@ var ConfigRepo = class {
1506
1539
  await this.cachedFS.unlink(descPath);
1507
1540
  } catch {
1508
1541
  }
1509
- try {
1510
- await this.cachedFS.unlink(versionPathFor(descPath));
1511
- } catch {
1512
- }
1542
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1513
1543
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1514
1544
  }
1515
1545
  async listAccountBackends() {
@@ -1707,9 +1737,14 @@ async function createConfigRepo(appId, options = {}) {
1707
1737
  await cachedFS.unlink(BACKENDS_FILE);
1708
1738
  } catch {
1709
1739
  }
1710
- try {
1711
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1712
- } catch {
1740
+ {
1741
+ const vPath = versionPathFor(BACKENDS_FILE);
1742
+ if (vPath) {
1743
+ try {
1744
+ await cachedFS.unlink(vPath);
1745
+ } catch {
1746
+ }
1747
+ }
1713
1748
  }
1714
1749
  console.log(`[createConfigRepo] Migration complete`);
1715
1750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
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",