zen-fs-config 0.5.5 → 0.5.6

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
@@ -522,6 +522,12 @@ declare class ConfigRepo implements IConfigRepo {
522
522
  readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
523
523
  dispose(): Promise<void>;
524
524
  setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
525
+ /** Write version sidecar for a config file (no-op for .version files). */
526
+ private writeVersionSidecar;
527
+ /** Delete version sidecar on a backend (no-op for .version files). */
528
+ private unlinkVersionSidecar;
529
+ /** Read version sidecar (returns null for .version files). */
530
+ private readVersionSidecar;
525
531
  private persistConfig;
526
532
  private reloadConfigCache;
527
533
  private handleConflict;
@@ -683,8 +689,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
683
689
  * /app-a/db.json → /app-a/.db.json.version
684
690
  * /shared/flags.json → /shared/.flags.json.version
685
691
  * /nodes/s1/env.json → /nodes/s1/.env.json.version
692
+ *
693
+ * Returns null for files that are already version sidecars (.version files),
694
+ * to prevent creating version-of-version files (e.g. ..db.json.version.version).
686
695
  */
687
- declare function versionPathFor(configFilePath: string): string;
696
+ declare function versionPathFor(configFilePath: string): string | null;
688
697
  /**
689
698
  * Compute SHA-256 hash of a Uint8Array.
690
699
  * Returns "sha256:" prefix + hex digest.
package/dist/index.d.ts CHANGED
@@ -522,6 +522,12 @@ declare class ConfigRepo implements IConfigRepo {
522
522
  readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
523
523
  dispose(): Promise<void>;
524
524
  setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
525
+ /** Write version sidecar for a config file (no-op for .version files). */
526
+ private writeVersionSidecar;
527
+ /** Delete version sidecar on a backend (no-op for .version files). */
528
+ private unlinkVersionSidecar;
529
+ /** Read version sidecar (returns null for .version files). */
530
+ private readVersionSidecar;
525
531
  private persistConfig;
526
532
  private reloadConfigCache;
527
533
  private handleConflict;
@@ -683,8 +689,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
683
689
  * /app-a/db.json → /app-a/.db.json.version
684
690
  * /shared/flags.json → /shared/.flags.json.version
685
691
  * /nodes/s1/env.json → /nodes/s1/.env.json.version
692
+ *
693
+ * Returns null for files that are already version sidecars (.version files),
694
+ * to prevent creating version-of-version files (e.g. ..db.json.version.version).
686
695
  */
687
- declare function versionPathFor(configFilePath: string): string;
696
+ declare function versionPathFor(configFilePath: string): string | null;
688
697
  /**
689
698
  * Compute SHA-256 hash of a Uint8Array.
690
699
  * 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 {
@@ -772,9 +779,11 @@ var ConfigRepo = class {
772
779
  } catch {
773
780
  }
774
781
  const versionPath = versionPathFor(normalizedPath);
775
- try {
776
- await this.cachedFS.unlink(versionPath);
777
- } catch {
782
+ if (versionPath) {
783
+ try {
784
+ await this.cachedFS.unlink(versionPath);
785
+ } catch {
786
+ }
778
787
  }
779
788
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
780
789
  }
@@ -808,22 +817,27 @@ var ConfigRepo = class {
808
817
  if (tombstones.length === 0) return;
809
818
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
810
819
  for (const tombstone of tombstones) {
820
+ const tVersionPath = versionPathFor(tombstone.path);
811
821
  try {
812
822
  await this.cachedFS.unlink(tombstone.path);
813
823
  } catch {
814
824
  }
815
- try {
816
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
817
- } catch {
825
+ if (tVersionPath) {
826
+ try {
827
+ await this.cachedFS.unlink(tVersionPath);
828
+ } catch {
829
+ }
818
830
  }
819
831
  for (const [replicaId, replica] of this.replicaBackends) {
820
832
  try {
821
833
  await replica.instance.unlink(tombstone.path);
822
834
  } catch {
823
835
  }
824
- try {
825
- await replica.instance.unlink(versionPathFor(tombstone.path));
826
- } catch {
836
+ if (tVersionPath) {
837
+ try {
838
+ await replica.instance.unlink(tVersionPath);
839
+ } catch {
840
+ }
827
841
  }
828
842
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
829
843
  }
@@ -932,7 +946,7 @@ var ConfigRepo = class {
932
946
  bytes,
933
947
  author
934
948
  );
935
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
949
+ await this.writeVersionSidecar(configPath, version);
936
950
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
937
951
  const resolvedBackupPath = `${conflictDir}/resolved`;
938
952
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -1033,13 +1047,34 @@ var ConfigRepo = class {
1033
1047
  // -----------------------------------------------------------------------
1034
1048
  // Internal — Persistence
1035
1049
  // -----------------------------------------------------------------------
1050
+ /** Write version sidecar for a config file (no-op for .version files). */
1051
+ async writeVersionSidecar(configPath, version) {
1052
+ const vPath = versionPathFor(configPath);
1053
+ if (!vPath) return;
1054
+ await this.ensureDir(vPath);
1055
+ await writeVersion(this.fullFS, vPath, version);
1056
+ }
1057
+ /** Delete version sidecar on a backend (no-op for .version files). */
1058
+ async unlinkVersionSidecar(fs, configPath) {
1059
+ const vPath = versionPathFor(configPath);
1060
+ if (!vPath) return;
1061
+ try {
1062
+ await fs.unlink(vPath);
1063
+ } catch {
1064
+ }
1065
+ }
1066
+ /** Read version sidecar (returns null for .version files). */
1067
+ async readVersionSidecar(configPath) {
1068
+ const vPath = versionPathFor(configPath);
1069
+ if (!vPath) return null;
1070
+ return readVersion(this.fullFS, vPath);
1071
+ }
1036
1072
  async persistConfig(fullPath, bytes) {
1037
1073
  await this.ensureDir(fullPath);
1038
1074
  await this.cachedFS.writeFile(fullPath, bytes);
1039
1075
  const author = `${this.appId}/${this.nodeId}`;
1040
1076
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
1041
- await this.ensureDir(versionPathFor(fullPath));
1042
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1077
+ await this.writeVersionSidecar(fullPath, version);
1043
1078
  }
1044
1079
  async reloadConfigCache() {
1045
1080
  const appDir = `/${this.appId}`;
@@ -1077,7 +1112,7 @@ var ConfigRepo = class {
1077
1112
  );
1078
1113
  let sourceVersion = 0;
1079
1114
  try {
1080
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1115
+ const srcVer = await this.readVersionSidecar(conflict.path);
1081
1116
  if (srcVer) sourceVersion = srcVer.version;
1082
1117
  } catch {
1083
1118
  }
@@ -1162,8 +1197,7 @@ var ConfigRepo = class {
1162
1197
  await this.cachedFS.writeFile(path, bytes);
1163
1198
  const author = `${this.appId}/${this.nodeId}`;
1164
1199
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1165
- await this.ensureDir(versionPathFor(path));
1166
- await writeVersion(this.fullFS, versionPathFor(path), version);
1200
+ await this.writeVersionSidecar(path, version);
1167
1201
  }
1168
1202
  async readMetaFile(path) {
1169
1203
  try {
@@ -1221,10 +1255,7 @@ var ConfigRepo = class {
1221
1255
  await replica.instance.unlink(corruptPath);
1222
1256
  } catch {
1223
1257
  }
1224
- try {
1225
- await replica.instance.unlink(versionPathFor(corruptPath));
1226
- } catch {
1227
- }
1258
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1228
1259
  }
1229
1260
  try {
1230
1261
  await this.deleteFile(corruptPath);
@@ -1233,10 +1264,7 @@ var ConfigRepo = class {
1233
1264
  await this.cachedFS.unlink(corruptPath);
1234
1265
  } catch {
1235
1266
  }
1236
- try {
1237
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1238
- } catch {
1239
- }
1267
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1240
1268
  }
1241
1269
  }
1242
1270
  const seen = /* @__PURE__ */ new Map();
@@ -1266,10 +1294,7 @@ var ConfigRepo = class {
1266
1294
  await replica.instance.unlink(descPath);
1267
1295
  } catch {
1268
1296
  }
1269
- try {
1270
- await replica.instance.unlink(versionPathFor(descPath));
1271
- } catch {
1272
- }
1297
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1273
1298
  }
1274
1299
  try {
1275
1300
  await this.deleteFile(descPath);
@@ -1291,8 +1316,7 @@ var ConfigRepo = class {
1291
1316
  await this.cachedFS.writeFile(path, bytes);
1292
1317
  const author = `${this.appId}/${this.nodeId}`;
1293
1318
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1294
- await this.ensureDir(versionPathFor(path));
1295
- await writeVersion(this.fullFS, versionPathFor(path), version);
1319
+ await this.writeVersionSidecar(path, version);
1296
1320
  }
1297
1321
  /** Remove a single backend descriptor file + its version sidecar */
1298
1322
  async removeBackendDescriptor(id) {
@@ -1301,10 +1325,7 @@ var ConfigRepo = class {
1301
1325
  await this.cachedFS.unlink(path);
1302
1326
  } catch {
1303
1327
  }
1304
- try {
1305
- await this.cachedFS.unlink(versionPathFor(path));
1306
- } catch {
1307
- }
1328
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1308
1329
  }
1309
1330
  // -----------------------------------------------------------------------
1310
1331
  // IConfigRepo — Meta file access (no chroot)
@@ -1403,10 +1424,7 @@ var ConfigRepo = class {
1403
1424
  await replica.instance.unlink(descPath);
1404
1425
  } catch {
1405
1426
  }
1406
- try {
1407
- await replica.instance.unlink(versionPathFor(descPath));
1408
- } catch {
1409
- }
1427
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1410
1428
  try {
1411
1429
  await this.deleteFile(descPath);
1412
1430
  } catch {
@@ -1502,8 +1520,7 @@ var ConfigRepo = class {
1502
1520
  await this.cachedFS.writeFile(descPath, bytes);
1503
1521
  const author = `${this.appId}/${this.nodeId}`;
1504
1522
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1505
- await this.ensureDir(versionPathFor(descPath));
1506
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1523
+ await this.writeVersionSidecar(descPath, version);
1507
1524
  this.appDataGroups.set(id, group);
1508
1525
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1509
1526
  return group;
@@ -1563,10 +1580,7 @@ var ConfigRepo = class {
1563
1580
  await this.cachedFS.unlink(descPath);
1564
1581
  } catch {
1565
1582
  }
1566
- try {
1567
- await this.cachedFS.unlink(versionPathFor(descPath));
1568
- } catch {
1569
- }
1583
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1570
1584
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1571
1585
  }
1572
1586
  async listAccountBackends() {
@@ -1764,9 +1778,14 @@ async function createConfigRepo(appId, options = {}) {
1764
1778
  await cachedFS.unlink(BACKENDS_FILE);
1765
1779
  } catch {
1766
1780
  }
1767
- try {
1768
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1769
- } catch {
1781
+ {
1782
+ const vPath = versionPathFor(BACKENDS_FILE);
1783
+ if (vPath) {
1784
+ try {
1785
+ await cachedFS.unlink(vPath);
1786
+ } catch {
1787
+ }
1788
+ }
1770
1789
  }
1771
1790
  console.log(`[createConfigRepo] Migration complete`);
1772
1791
  }
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 {
@@ -715,9 +722,11 @@ var ConfigRepo = class {
715
722
  } catch {
716
723
  }
717
724
  const versionPath = versionPathFor(normalizedPath);
718
- try {
719
- await this.cachedFS.unlink(versionPath);
720
- } catch {
725
+ if (versionPath) {
726
+ try {
727
+ await this.cachedFS.unlink(versionPath);
728
+ } catch {
729
+ }
721
730
  }
722
731
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
723
732
  }
@@ -751,22 +760,27 @@ var ConfigRepo = class {
751
760
  if (tombstones.length === 0) return;
752
761
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
753
762
  for (const tombstone of tombstones) {
763
+ const tVersionPath = versionPathFor(tombstone.path);
754
764
  try {
755
765
  await this.cachedFS.unlink(tombstone.path);
756
766
  } catch {
757
767
  }
758
- try {
759
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
760
- } catch {
768
+ if (tVersionPath) {
769
+ try {
770
+ await this.cachedFS.unlink(tVersionPath);
771
+ } catch {
772
+ }
761
773
  }
762
774
  for (const [replicaId, replica] of this.replicaBackends) {
763
775
  try {
764
776
  await replica.instance.unlink(tombstone.path);
765
777
  } catch {
766
778
  }
767
- try {
768
- await replica.instance.unlink(versionPathFor(tombstone.path));
769
- } catch {
779
+ if (tVersionPath) {
780
+ try {
781
+ await replica.instance.unlink(tVersionPath);
782
+ } catch {
783
+ }
770
784
  }
771
785
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
772
786
  }
@@ -875,7 +889,7 @@ var ConfigRepo = class {
875
889
  bytes,
876
890
  author
877
891
  );
878
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
892
+ await this.writeVersionSidecar(configPath, version);
879
893
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
880
894
  const resolvedBackupPath = `${conflictDir}/resolved`;
881
895
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -976,13 +990,34 @@ var ConfigRepo = class {
976
990
  // -----------------------------------------------------------------------
977
991
  // Internal — Persistence
978
992
  // -----------------------------------------------------------------------
993
+ /** Write version sidecar for a config file (no-op for .version files). */
994
+ async writeVersionSidecar(configPath, version) {
995
+ const vPath = versionPathFor(configPath);
996
+ if (!vPath) return;
997
+ await this.ensureDir(vPath);
998
+ await writeVersion(this.fullFS, vPath, version);
999
+ }
1000
+ /** Delete version sidecar on a backend (no-op for .version files). */
1001
+ async unlinkVersionSidecar(fs, configPath) {
1002
+ const vPath = versionPathFor(configPath);
1003
+ if (!vPath) return;
1004
+ try {
1005
+ await fs.unlink(vPath);
1006
+ } catch {
1007
+ }
1008
+ }
1009
+ /** Read version sidecar (returns null for .version files). */
1010
+ async readVersionSidecar(configPath) {
1011
+ const vPath = versionPathFor(configPath);
1012
+ if (!vPath) return null;
1013
+ return readVersion(this.fullFS, vPath);
1014
+ }
979
1015
  async persistConfig(fullPath, bytes) {
980
1016
  await this.ensureDir(fullPath);
981
1017
  await this.cachedFS.writeFile(fullPath, bytes);
982
1018
  const author = `${this.appId}/${this.nodeId}`;
983
1019
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
984
- await this.ensureDir(versionPathFor(fullPath));
985
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1020
+ await this.writeVersionSidecar(fullPath, version);
986
1021
  }
987
1022
  async reloadConfigCache() {
988
1023
  const appDir = `/${this.appId}`;
@@ -1020,7 +1055,7 @@ var ConfigRepo = class {
1020
1055
  );
1021
1056
  let sourceVersion = 0;
1022
1057
  try {
1023
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1058
+ const srcVer = await this.readVersionSidecar(conflict.path);
1024
1059
  if (srcVer) sourceVersion = srcVer.version;
1025
1060
  } catch {
1026
1061
  }
@@ -1105,8 +1140,7 @@ var ConfigRepo = class {
1105
1140
  await this.cachedFS.writeFile(path, bytes);
1106
1141
  const author = `${this.appId}/${this.nodeId}`;
1107
1142
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1108
- await this.ensureDir(versionPathFor(path));
1109
- await writeVersion(this.fullFS, versionPathFor(path), version);
1143
+ await this.writeVersionSidecar(path, version);
1110
1144
  }
1111
1145
  async readMetaFile(path) {
1112
1146
  try {
@@ -1164,10 +1198,7 @@ var ConfigRepo = class {
1164
1198
  await replica.instance.unlink(corruptPath);
1165
1199
  } catch {
1166
1200
  }
1167
- try {
1168
- await replica.instance.unlink(versionPathFor(corruptPath));
1169
- } catch {
1170
- }
1201
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1171
1202
  }
1172
1203
  try {
1173
1204
  await this.deleteFile(corruptPath);
@@ -1176,10 +1207,7 @@ var ConfigRepo = class {
1176
1207
  await this.cachedFS.unlink(corruptPath);
1177
1208
  } catch {
1178
1209
  }
1179
- try {
1180
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1181
- } catch {
1182
- }
1210
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1183
1211
  }
1184
1212
  }
1185
1213
  const seen = /* @__PURE__ */ new Map();
@@ -1209,10 +1237,7 @@ var ConfigRepo = class {
1209
1237
  await replica.instance.unlink(descPath);
1210
1238
  } catch {
1211
1239
  }
1212
- try {
1213
- await replica.instance.unlink(versionPathFor(descPath));
1214
- } catch {
1215
- }
1240
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1216
1241
  }
1217
1242
  try {
1218
1243
  await this.deleteFile(descPath);
@@ -1234,8 +1259,7 @@ var ConfigRepo = class {
1234
1259
  await this.cachedFS.writeFile(path, bytes);
1235
1260
  const author = `${this.appId}/${this.nodeId}`;
1236
1261
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1237
- await this.ensureDir(versionPathFor(path));
1238
- await writeVersion(this.fullFS, versionPathFor(path), version);
1262
+ await this.writeVersionSidecar(path, version);
1239
1263
  }
1240
1264
  /** Remove a single backend descriptor file + its version sidecar */
1241
1265
  async removeBackendDescriptor(id) {
@@ -1244,10 +1268,7 @@ var ConfigRepo = class {
1244
1268
  await this.cachedFS.unlink(path);
1245
1269
  } catch {
1246
1270
  }
1247
- try {
1248
- await this.cachedFS.unlink(versionPathFor(path));
1249
- } catch {
1250
- }
1271
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1251
1272
  }
1252
1273
  // -----------------------------------------------------------------------
1253
1274
  // IConfigRepo — Meta file access (no chroot)
@@ -1346,10 +1367,7 @@ var ConfigRepo = class {
1346
1367
  await replica.instance.unlink(descPath);
1347
1368
  } catch {
1348
1369
  }
1349
- try {
1350
- await replica.instance.unlink(versionPathFor(descPath));
1351
- } catch {
1352
- }
1370
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1353
1371
  try {
1354
1372
  await this.deleteFile(descPath);
1355
1373
  } catch {
@@ -1445,8 +1463,7 @@ var ConfigRepo = class {
1445
1463
  await this.cachedFS.writeFile(descPath, bytes);
1446
1464
  const author = `${this.appId}/${this.nodeId}`;
1447
1465
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1448
- await this.ensureDir(versionPathFor(descPath));
1449
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1466
+ await this.writeVersionSidecar(descPath, version);
1450
1467
  this.appDataGroups.set(id, group);
1451
1468
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1452
1469
  return group;
@@ -1506,10 +1523,7 @@ var ConfigRepo = class {
1506
1523
  await this.cachedFS.unlink(descPath);
1507
1524
  } catch {
1508
1525
  }
1509
- try {
1510
- await this.cachedFS.unlink(versionPathFor(descPath));
1511
- } catch {
1512
- }
1526
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1513
1527
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1514
1528
  }
1515
1529
  async listAccountBackends() {
@@ -1707,9 +1721,14 @@ async function createConfigRepo(appId, options = {}) {
1707
1721
  await cachedFS.unlink(BACKENDS_FILE);
1708
1722
  } catch {
1709
1723
  }
1710
- try {
1711
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1712
- } catch {
1724
+ {
1725
+ const vPath = versionPathFor(BACKENDS_FILE);
1726
+ if (vPath) {
1727
+ try {
1728
+ await cachedFS.unlink(vPath);
1729
+ } catch {
1730
+ }
1731
+ }
1713
1732
  }
1714
1733
  console.log(`[createConfigRepo] Migration complete`);
1715
1734
  }
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.6",
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",