zen-fs-config 0.5.4 → 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
@@ -748,12 +748,11 @@ The sync engine (`zen-fs-sync`) uses **snapshot-based change detection**. When `
748
748
 
749
749
  1. Builds a snapshot of the source (IndexedDB) — walks all files, records `path`, `size`, `mtimeMs`
750
750
  2. Builds a snapshot of the target (remote backend) — same process
751
- 3. **Merges** both snapshots into one map (`source target`)
752
- 4. Caches this merged snapshot as `sourceSnapshots`
751
+ 3. Caches **separate** snapshots: `prevSrcSnap` (source) and `prevTgtSnap` (target)
753
752
 
754
- On the next `syncAll()`, `syncBidirectional()` compares the current merged snapshot with the cached one. If all paths, sizes, and mtimes match → **"unchanged" → skip sync entirely**.
753
+ On the next `syncAll()`, `syncBidirectional()` compares each side's current snapshot with its own previous snapshot independently. If both sides are unchanged → **"unchanged" → skip sync entirely**.
755
754
 
756
- **The problem**: `buildInitialSnapshots()` only *reads* file metadata — it does NOT copy any files. So if the remote has files that the local doesn't (e.g., duplicate backend descriptors written by another node), the merged snapshot includes them from the remote side. The subsequent sync sees "the merged snapshot already has this file" and skips — the file is never actually copied to local, and local-only dedup logic never runs.
755
+ **The problem**: `buildInitialSnapshots()` only *reads* file metadata — it does NOT copy any files. So if the remote has files that the local doesn't (e.g., duplicate backend descriptors written by another node), the cached snapshots reflect the un-synced state. The subsequent sync sees "snapshots already match this state" and skips — the file is never actually copied to local, and local-only dedup logic never runs.
757
756
 
758
757
  **The fix**: Always perform a full `syncAll()` **before** `watch()`. With no cached snapshot, `syncBidirectional()` does a complete comparison and copies all missing files. After sync completes, `watch()` builds snapshots from the now-consistent state.
759
758
 
@@ -885,8 +884,8 @@ watch() triggers:
885
884
  │ → triggers debounced sync (default 300ms)
886
885
 
887
886
  ├─ 2. buildInitialSnapshots()
888
- │ ├─ BiDirectional: merge source + target snapshots
889
- │ └─ OneWay: snapshot source only
887
+ │ ├─ BiDirectional: cache separate source and target snapshots
888
+ │ └─ OneWay: cache source snapshot only
890
889
 
891
890
  └─ 3. Start poll timers (if backend supports shouldSync)
892
891
  ├─ Remote backends poll shouldSync() every pollIntervalMs (default 30min)
@@ -896,12 +895,15 @@ watch() triggers:
896
895
  **State guard**: If `unwatch()` is called during `buildInitialSnapshots()` (which is async), the snapshots are discarded — they won't be cached. This prevents stale snapshots from causing sync skips.
897
896
 
898
897
  **Snapshot comparison** in `syncBidirectional()`:
899
- 1. Build current snapshots of both sides
900
- 2. Merge into `currentMerged = source target`
901
- 3. Compare with cached `sourceSnapshots`:
902
- - If all paths + mtimes + sizes match → "unchanged" → skip
903
- - Otherwise proceed with full diff and file operations
904
- 4. Cache `currentMerged` for next comparison
898
+ 1. Build current snapshots of both sides (via `getSnapshot()`)
899
+ 2. Compare each side independently against its own previous snapshot:
900
+ - `srcChanged = !snapshotsEqual(prevSrcSnap, currentSrcSnap)`
901
+ - `tgtChanged = !snapshotsEqual(prevTgtSnap, currentTgtSnap)`
902
+ - If neither changed and both previous snapshots exist → skip sync entirely
903
+ 3. Cache current snapshots as `prevSrcSnap` and `prevTgtSnap` for next comparison
904
+ 4. If either side changed, proceed with full diff and file operations
905
+
906
+ **Key difference from previous design**: The old approach merged source and target snapshots into a single map (`source ∪ target`), which lost information about which filesystem a file belonged to. The new approach keeps them separate, enabling precise per-side change detection and bidirectional deletion propagation (see §11.10).
905
907
 
906
908
  ### 11.8 Standalone Data-Sync Group (`createDataSyncGroup`)
907
909
 
@@ -995,6 +997,110 @@ interface ConnectResult {
995
997
 
996
998
  **Offline / zero-parameter mode**: When no `backendInfo` is provided, `connect` defaults to `config-sync` and creates an IndexedDB-only repo (same as `createConfigRepo` with no options).
997
999
 
1000
+ ### 11.10 Snapshot Optimization Design
1001
+
1002
+ This section describes three interrelated optimizations to the sync engine's snapshot mechanism.
1003
+
1004
+ #### 11.10.1 FS-Provided `createSnapshot()`
1005
+
1006
+ The `SyncableFS` interface now includes an optional `createSnapshot()` method:
1007
+
1008
+ ```typescript
1009
+ interface SyncableFS {
1010
+ // ... existing methods ...
1011
+
1012
+ /**
1013
+ * Optional: Build a filesystem snapshot.
1014
+ * Returns a map of relative path → {size, mtimeMs} for all files under root.
1015
+ * Returns null if the filesystem is unreachable.
1016
+ *
1017
+ * Backends that can provide a more efficient snapshot than the generic
1018
+ * walkFiles+stat approach should implement this method.
1019
+ */
1020
+ createSnapshot?(root: string, filter?: SyncFilter): Promise<Map<string, FileSnapshot> | null>;
1021
+ }
1022
+ ```
1023
+
1024
+ The sync engine's `getSnapshot()` helper dispatches to the FS-provided method when available, falling back to the generic `buildSnapshot()` (walkFiles + stat) otherwise:
1025
+
1026
+ ```typescript
1027
+ private async getSnapshot(fs: SyncableFS): Promise<Map<string, FileSnapshot> | null> {
1028
+ if (fs.createSnapshot) {
1029
+ return fs.createSnapshot(this.root, this.options.filter);
1030
+ }
1031
+ return buildSnapshot(fs, this.root, this.options.filter);
1032
+ }
1033
+ ```
1034
+
1035
+ **Optimization examples**:
1036
+ - **Gitee/GitHub**: Use Git tree API to fetch all file metadata in a single request instead of walking files one by one
1037
+ - **IndexedDB**: Use `getAll()` for batch querying instead of individual `stat()` calls
1038
+ - **InMemory**: Directly iterate the internal Map (no async I/O overhead)
1039
+
1040
+ Backends that do not implement `createSnapshot()` are fully supported — the generic fallback produces identical results.
1041
+
1042
+ #### 11.10.2 Separate Source and Target Snapshots
1043
+
1044
+ **Previous design** (merged snapshots):
1045
+ - `buildInitialSnapshots()` merged source and target into a single map: `sourceSnapshots = new Map([...srcSnap, ...tgtSnap])`
1046
+ - `syncBidirectional()` compared `currentMerged` with the cached merged snapshot
1047
+ - **Problem**: The merged map lost which filesystem a file belonged to. A file present on target but not source could be "new on target" or "deleted from source" — the merged snapshot couldn't distinguish.
1048
+
1049
+ **New design** (separate snapshots):
1050
+ - `buildInitialSnapshots()` caches two independent maps: `prevSrcSnap` and `prevTgtSnap`
1051
+ - `syncBidirectional()` compares each side independently:
1052
+ ```
1053
+ srcChanged = !snapshotsEqual(prevSrcSnap, currentSrcSnap)
1054
+ tgtChanged = !snapshotsEqual(prevTgtSnap, currentTgtSnap)
1055
+ if (!srcChanged && !tgtChanged && prevSrcSnap && prevTgtSnap) → skip sync
1056
+ ```
1057
+ - Each side's change is detected independently, preserving file-location information
1058
+
1059
+ #### 11.10.3 Bidirectional Deletion Propagation
1060
+
1061
+ When a file exists on one side but not the other, the sync engine uses previous snapshots to distinguish "created" from "deleted":
1062
+
1063
+ ```
1064
+ File on target, not on source:
1065
+ ├─ Was it on source in the previous snapshot (prevSrcSnap)?
1066
+ │ ├─ Yes → file was deleted from source → delete from target too (propagate deletion)
1067
+ │ └─ No → file was created on target → copy to source
1068
+
1069
+ File on source, not on target:
1070
+ ├─ Was it on target in the previous snapshot (prevTgtSnap)?
1071
+ │ ├─ Yes → file was deleted from target → delete from source too (propagate deletion)
1072
+ │ └─ No → file was created on source → copy to target
1073
+ ```
1074
+
1075
+ **Without this mechanism**, deleting a file on one side would cause the sync engine to see "the other side still has it → copy it back", effectively undoing the deletion.
1076
+
1077
+ **Relationship with tombstones**: The tombstone mechanism (§11.4) and bidirectional deletion propagation operate at different layers and complement each other:
1078
+
1079
+ | Mechanism | Layer | Trigger | How It Works |
1080
+ |-----------|-------|---------|--------------|
1081
+ | Tombstone | `zen-fs-config` (application) | Application calls `deleteFile()` | Writes a `.meta/.deleted/` marker, physically deletes file on all replicas **before** sync runs |
1082
+ | Deletion propagation | `zen-fs-sync` (engine) | Sync detects one-side-only file | Compares with previous snapshot to determine if file was created or deleted |
1083
+
1084
+ Tombstones handle application-initiated deletions (the common case). Deletion propagation handles deletions that bypass the tombstone flow — e.g., external modifications on the remote backend, or files removed by other sync mechanisms.
1085
+
1086
+ #### 11.10.4 `shouldSync()` vs Snapshot Comparison
1087
+
1088
+ These two mechanisms are complementary, not interchangeable:
1089
+
1090
+ | Mechanism | Purpose | Cost | When Used |
1091
+ |-----------|---------|------|-----------|
1092
+ | `shouldSync()` | Fast "has anything changed?" boolean | O(1) for remote (ETag/commit check) | `onRemotePoll()` — decide whether to trigger sync at all |
1093
+ | Snapshot comparison | "What exactly changed?" detail | O(n) filesystem traversal | `syncBidirectional()` — decide what to copy/delete |
1094
+
1095
+ `shouldSync()` is **not** part of the snapshot comparison because:
1096
+ 1. `shouldSync()` updates its internal baseline after each call — calling it again during sync would return stale results
1097
+ 2. `shouldSync()` returning false doesn't guarantee the snapshot is unchanged — it means the FS's own change detection says nothing changed, which could miss edge cases
1098
+ 3. `shouldSync()` returning true doesn't tell us **which** files changed — snapshots are still needed for that
1099
+
1100
+ The two-level optimization works as follows:
1101
+ 1. **Level 1**: `shouldSync()` in remote poll → if false, skip sync trigger entirely (saves the O(n) snapshot build)
1102
+ 2. **Level 2**: Separate snapshot comparison in sync → if both sides unchanged, skip file operations (saves I/O)
1103
+
998
1104
  ## 12. Data Flow
999
1105
 
1000
1106
  ### Read Path
@@ -1041,6 +1147,67 @@ Application
1041
1147
  → Dispose temporary SyncPair
1042
1148
  ```
1043
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
+
1044
1211
  ## 13. Peer Dependencies
1045
1212
 
1046
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
  },
@@ -274,6 +277,12 @@ function backendToSyncableFS(backend, name) {
274
277
  if (typeof backend.shouldSync === "function") {
275
278
  syncable.shouldSync = () => backend.shouldSync();
276
279
  }
280
+ if (typeof backend.createSnapshot === "function") {
281
+ syncable.createSnapshot = (root, filter) => backend.createSnapshot(root, filter);
282
+ }
283
+ if (typeof backend.writeFileWithMtime === "function") {
284
+ syncable.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
285
+ }
277
286
  if (typeof backend.checkForUpdates === "function") {
278
287
  syncable.checkForUpdates = () => backend.checkForUpdates();
279
288
  }
@@ -367,7 +376,7 @@ async function wrapZenFSFileSystem(config) {
367
376
  }
368
377
  await isolatedFS.write(path, bytes, 0);
369
378
  try {
370
- await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
379
+ await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
371
380
  } catch {
372
381
  }
373
382
  notifyChange();
@@ -404,6 +413,12 @@ async function wrapZenFSFileSystem(config) {
404
413
  backend.onChange = (callback) => {
405
414
  changeCallback = callback;
406
415
  };
416
+ if (typeof isolatedFS.createSnapshot === "function") {
417
+ backend.createSnapshot = (root, filter) => isolatedFS.createSnapshot(root, filter);
418
+ }
419
+ if (typeof isolatedFS.writeFileWithMtime === "function") {
420
+ backend.writeFileWithMtime = (path, data, mtimeMs) => isolatedFS.writeFileWithMtime(path, data, mtimeMs);
421
+ }
407
422
  return backend;
408
423
  }
409
424
  var inMemoryCounter = 0;
@@ -442,6 +457,9 @@ function versionPathFor(configFilePath) {
442
457
  const lastSlash = configFilePath.lastIndexOf("/");
443
458
  const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
444
459
  const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
460
+ if (fileName.endsWith(".version")) {
461
+ return null;
462
+ }
445
463
  const versionFileName = `.${fileName}.version`;
446
464
  return dir ? `${dir}/${versionFileName}` : versionFileName;
447
465
  }
@@ -478,7 +496,7 @@ async function writeVersion(fs, versionFilePath, meta) {
478
496
  }
479
497
  async function incrementVersion(fs, configFilePath, newContent, author) {
480
498
  const vPath = versionPathFor(configFilePath);
481
- const prev = await readVersion(fs, vPath);
499
+ const prev = vPath ? await readVersion(fs, vPath) : null;
482
500
  const hash = await sha256(newContent);
483
501
  return {
484
502
  version: (prev?.version ?? 0) + 1,
@@ -489,6 +507,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
489
507
  }
490
508
  async function verifyOrRepairVersion(fs, configFilePath, author) {
491
509
  const vPath = versionPathFor(configFilePath);
510
+ if (!vPath) return null;
492
511
  const existing = await readVersion(fs, vPath);
493
512
  if (!existing) return null;
494
513
  try {
@@ -760,9 +779,11 @@ var ConfigRepo = class {
760
779
  } catch {
761
780
  }
762
781
  const versionPath = versionPathFor(normalizedPath);
763
- try {
764
- await this.cachedFS.unlink(versionPath);
765
- } catch {
782
+ if (versionPath) {
783
+ try {
784
+ await this.cachedFS.unlink(versionPath);
785
+ } catch {
786
+ }
766
787
  }
767
788
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
768
789
  }
@@ -796,22 +817,27 @@ var ConfigRepo = class {
796
817
  if (tombstones.length === 0) return;
797
818
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
798
819
  for (const tombstone of tombstones) {
820
+ const tVersionPath = versionPathFor(tombstone.path);
799
821
  try {
800
822
  await this.cachedFS.unlink(tombstone.path);
801
823
  } catch {
802
824
  }
803
- try {
804
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
805
- } catch {
825
+ if (tVersionPath) {
826
+ try {
827
+ await this.cachedFS.unlink(tVersionPath);
828
+ } catch {
829
+ }
806
830
  }
807
831
  for (const [replicaId, replica] of this.replicaBackends) {
808
832
  try {
809
833
  await replica.instance.unlink(tombstone.path);
810
834
  } catch {
811
835
  }
812
- try {
813
- await replica.instance.unlink(versionPathFor(tombstone.path));
814
- } catch {
836
+ if (tVersionPath) {
837
+ try {
838
+ await replica.instance.unlink(tVersionPath);
839
+ } catch {
840
+ }
815
841
  }
816
842
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
817
843
  }
@@ -920,7 +946,7 @@ var ConfigRepo = class {
920
946
  bytes,
921
947
  author
922
948
  );
923
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
949
+ await this.writeVersionSidecar(configPath, version);
924
950
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
925
951
  const resolvedBackupPath = `${conflictDir}/resolved`;
926
952
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -1021,13 +1047,34 @@ var ConfigRepo = class {
1021
1047
  // -----------------------------------------------------------------------
1022
1048
  // Internal — Persistence
1023
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
+ }
1024
1072
  async persistConfig(fullPath, bytes) {
1025
1073
  await this.ensureDir(fullPath);
1026
1074
  await this.cachedFS.writeFile(fullPath, bytes);
1027
1075
  const author = `${this.appId}/${this.nodeId}`;
1028
1076
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
1029
- await this.ensureDir(versionPathFor(fullPath));
1030
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1077
+ await this.writeVersionSidecar(fullPath, version);
1031
1078
  }
1032
1079
  async reloadConfigCache() {
1033
1080
  const appDir = `/${this.appId}`;
@@ -1065,7 +1112,7 @@ var ConfigRepo = class {
1065
1112
  );
1066
1113
  let sourceVersion = 0;
1067
1114
  try {
1068
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1115
+ const srcVer = await this.readVersionSidecar(conflict.path);
1069
1116
  if (srcVer) sourceVersion = srcVer.version;
1070
1117
  } catch {
1071
1118
  }
@@ -1150,8 +1197,7 @@ var ConfigRepo = class {
1150
1197
  await this.cachedFS.writeFile(path, bytes);
1151
1198
  const author = `${this.appId}/${this.nodeId}`;
1152
1199
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1153
- await this.ensureDir(versionPathFor(path));
1154
- await writeVersion(this.fullFS, versionPathFor(path), version);
1200
+ await this.writeVersionSidecar(path, version);
1155
1201
  }
1156
1202
  async readMetaFile(path) {
1157
1203
  try {
@@ -1209,10 +1255,7 @@ var ConfigRepo = class {
1209
1255
  await replica.instance.unlink(corruptPath);
1210
1256
  } catch {
1211
1257
  }
1212
- try {
1213
- await replica.instance.unlink(versionPathFor(corruptPath));
1214
- } catch {
1215
- }
1258
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1216
1259
  }
1217
1260
  try {
1218
1261
  await this.deleteFile(corruptPath);
@@ -1221,10 +1264,7 @@ var ConfigRepo = class {
1221
1264
  await this.cachedFS.unlink(corruptPath);
1222
1265
  } catch {
1223
1266
  }
1224
- try {
1225
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1226
- } catch {
1227
- }
1267
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1228
1268
  }
1229
1269
  }
1230
1270
  const seen = /* @__PURE__ */ new Map();
@@ -1254,10 +1294,7 @@ var ConfigRepo = class {
1254
1294
  await replica.instance.unlink(descPath);
1255
1295
  } catch {
1256
1296
  }
1257
- try {
1258
- await replica.instance.unlink(versionPathFor(descPath));
1259
- } catch {
1260
- }
1297
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1261
1298
  }
1262
1299
  try {
1263
1300
  await this.deleteFile(descPath);
@@ -1279,8 +1316,7 @@ var ConfigRepo = class {
1279
1316
  await this.cachedFS.writeFile(path, bytes);
1280
1317
  const author = `${this.appId}/${this.nodeId}`;
1281
1318
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1282
- await this.ensureDir(versionPathFor(path));
1283
- await writeVersion(this.fullFS, versionPathFor(path), version);
1319
+ await this.writeVersionSidecar(path, version);
1284
1320
  }
1285
1321
  /** Remove a single backend descriptor file + its version sidecar */
1286
1322
  async removeBackendDescriptor(id) {
@@ -1289,10 +1325,7 @@ var ConfigRepo = class {
1289
1325
  await this.cachedFS.unlink(path);
1290
1326
  } catch {
1291
1327
  }
1292
- try {
1293
- await this.cachedFS.unlink(versionPathFor(path));
1294
- } catch {
1295
- }
1328
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1296
1329
  }
1297
1330
  // -----------------------------------------------------------------------
1298
1331
  // IConfigRepo — Meta file access (no chroot)
@@ -1391,10 +1424,7 @@ var ConfigRepo = class {
1391
1424
  await replica.instance.unlink(descPath);
1392
1425
  } catch {
1393
1426
  }
1394
- try {
1395
- await replica.instance.unlink(versionPathFor(descPath));
1396
- } catch {
1397
- }
1427
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1398
1428
  try {
1399
1429
  await this.deleteFile(descPath);
1400
1430
  } catch {
@@ -1490,8 +1520,7 @@ var ConfigRepo = class {
1490
1520
  await this.cachedFS.writeFile(descPath, bytes);
1491
1521
  const author = `${this.appId}/${this.nodeId}`;
1492
1522
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1493
- await this.ensureDir(versionPathFor(descPath));
1494
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1523
+ await this.writeVersionSidecar(descPath, version);
1495
1524
  this.appDataGroups.set(id, group);
1496
1525
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1497
1526
  return group;
@@ -1551,10 +1580,7 @@ var ConfigRepo = class {
1551
1580
  await this.cachedFS.unlink(descPath);
1552
1581
  } catch {
1553
1582
  }
1554
- try {
1555
- await this.cachedFS.unlink(versionPathFor(descPath));
1556
- } catch {
1557
- }
1583
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1558
1584
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1559
1585
  }
1560
1586
  async listAccountBackends() {
@@ -1752,9 +1778,14 @@ async function createConfigRepo(appId, options = {}) {
1752
1778
  await cachedFS.unlink(BACKENDS_FILE);
1753
1779
  } catch {
1754
1780
  }
1755
- try {
1756
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1757
- } catch {
1781
+ {
1782
+ const vPath = versionPathFor(BACKENDS_FILE);
1783
+ if (vPath) {
1784
+ try {
1785
+ await cachedFS.unlink(vPath);
1786
+ } catch {
1787
+ }
1788
+ }
1758
1789
  }
1759
1790
  console.log(`[createConfigRepo] Migration complete`);
1760
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
  },
@@ -217,6 +220,12 @@ function backendToSyncableFS(backend, name) {
217
220
  if (typeof backend.shouldSync === "function") {
218
221
  syncable.shouldSync = () => backend.shouldSync();
219
222
  }
223
+ if (typeof backend.createSnapshot === "function") {
224
+ syncable.createSnapshot = (root, filter) => backend.createSnapshot(root, filter);
225
+ }
226
+ if (typeof backend.writeFileWithMtime === "function") {
227
+ syncable.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
228
+ }
220
229
  if (typeof backend.checkForUpdates === "function") {
221
230
  syncable.checkForUpdates = () => backend.checkForUpdates();
222
231
  }
@@ -310,7 +319,7 @@ async function wrapZenFSFileSystem(config) {
310
319
  }
311
320
  await isolatedFS.write(path, bytes, 0);
312
321
  try {
313
- await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
322
+ await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
314
323
  } catch {
315
324
  }
316
325
  notifyChange();
@@ -347,6 +356,12 @@ async function wrapZenFSFileSystem(config) {
347
356
  backend.onChange = (callback) => {
348
357
  changeCallback = callback;
349
358
  };
359
+ if (typeof isolatedFS.createSnapshot === "function") {
360
+ backend.createSnapshot = (root, filter) => isolatedFS.createSnapshot(root, filter);
361
+ }
362
+ if (typeof isolatedFS.writeFileWithMtime === "function") {
363
+ backend.writeFileWithMtime = (path, data, mtimeMs) => isolatedFS.writeFileWithMtime(path, data, mtimeMs);
364
+ }
350
365
  return backend;
351
366
  }
352
367
  var inMemoryCounter = 0;
@@ -385,6 +400,9 @@ function versionPathFor(configFilePath) {
385
400
  const lastSlash = configFilePath.lastIndexOf("/");
386
401
  const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
387
402
  const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
403
+ if (fileName.endsWith(".version")) {
404
+ return null;
405
+ }
388
406
  const versionFileName = `.${fileName}.version`;
389
407
  return dir ? `${dir}/${versionFileName}` : versionFileName;
390
408
  }
@@ -421,7 +439,7 @@ async function writeVersion(fs, versionFilePath, meta) {
421
439
  }
422
440
  async function incrementVersion(fs, configFilePath, newContent, author) {
423
441
  const vPath = versionPathFor(configFilePath);
424
- const prev = await readVersion(fs, vPath);
442
+ const prev = vPath ? await readVersion(fs, vPath) : null;
425
443
  const hash = await sha256(newContent);
426
444
  return {
427
445
  version: (prev?.version ?? 0) + 1,
@@ -432,6 +450,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
432
450
  }
433
451
  async function verifyOrRepairVersion(fs, configFilePath, author) {
434
452
  const vPath = versionPathFor(configFilePath);
453
+ if (!vPath) return null;
435
454
  const existing = await readVersion(fs, vPath);
436
455
  if (!existing) return null;
437
456
  try {
@@ -703,9 +722,11 @@ var ConfigRepo = class {
703
722
  } catch {
704
723
  }
705
724
  const versionPath = versionPathFor(normalizedPath);
706
- try {
707
- await this.cachedFS.unlink(versionPath);
708
- } catch {
725
+ if (versionPath) {
726
+ try {
727
+ await this.cachedFS.unlink(versionPath);
728
+ } catch {
729
+ }
709
730
  }
710
731
  console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
711
732
  }
@@ -739,22 +760,27 @@ var ConfigRepo = class {
739
760
  if (tombstones.length === 0) return;
740
761
  console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
741
762
  for (const tombstone of tombstones) {
763
+ const tVersionPath = versionPathFor(tombstone.path);
742
764
  try {
743
765
  await this.cachedFS.unlink(tombstone.path);
744
766
  } catch {
745
767
  }
746
- try {
747
- await this.cachedFS.unlink(versionPathFor(tombstone.path));
748
- } catch {
768
+ if (tVersionPath) {
769
+ try {
770
+ await this.cachedFS.unlink(tVersionPath);
771
+ } catch {
772
+ }
749
773
  }
750
774
  for (const [replicaId, replica] of this.replicaBackends) {
751
775
  try {
752
776
  await replica.instance.unlink(tombstone.path);
753
777
  } catch {
754
778
  }
755
- try {
756
- await replica.instance.unlink(versionPathFor(tombstone.path));
757
- } catch {
779
+ if (tVersionPath) {
780
+ try {
781
+ await replica.instance.unlink(tVersionPath);
782
+ } catch {
783
+ }
758
784
  }
759
785
  console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
760
786
  }
@@ -863,7 +889,7 @@ var ConfigRepo = class {
863
889
  bytes,
864
890
  author
865
891
  );
866
- await writeVersion(this.fullFS, versionPathFor(configPath), version);
892
+ await this.writeVersionSidecar(configPath, version);
867
893
  const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
868
894
  const resolvedBackupPath = `${conflictDir}/resolved`;
869
895
  const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
@@ -964,13 +990,34 @@ var ConfigRepo = class {
964
990
  // -----------------------------------------------------------------------
965
991
  // Internal — Persistence
966
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
+ }
967
1015
  async persistConfig(fullPath, bytes) {
968
1016
  await this.ensureDir(fullPath);
969
1017
  await this.cachedFS.writeFile(fullPath, bytes);
970
1018
  const author = `${this.appId}/${this.nodeId}`;
971
1019
  const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
972
- await this.ensureDir(versionPathFor(fullPath));
973
- await writeVersion(this.fullFS, versionPathFor(fullPath), version);
1020
+ await this.writeVersionSidecar(fullPath, version);
974
1021
  }
975
1022
  async reloadConfigCache() {
976
1023
  const appDir = `/${this.appId}`;
@@ -1008,7 +1055,7 @@ var ConfigRepo = class {
1008
1055
  );
1009
1056
  let sourceVersion = 0;
1010
1057
  try {
1011
- const srcVer = await readVersion(this.fullFS, versionPathFor(conflict.path));
1058
+ const srcVer = await this.readVersionSidecar(conflict.path);
1012
1059
  if (srcVer) sourceVersion = srcVer.version;
1013
1060
  } catch {
1014
1061
  }
@@ -1093,8 +1140,7 @@ var ConfigRepo = class {
1093
1140
  await this.cachedFS.writeFile(path, bytes);
1094
1141
  const author = `${this.appId}/${this.nodeId}`;
1095
1142
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1096
- await this.ensureDir(versionPathFor(path));
1097
- await writeVersion(this.fullFS, versionPathFor(path), version);
1143
+ await this.writeVersionSidecar(path, version);
1098
1144
  }
1099
1145
  async readMetaFile(path) {
1100
1146
  try {
@@ -1152,10 +1198,7 @@ var ConfigRepo = class {
1152
1198
  await replica.instance.unlink(corruptPath);
1153
1199
  } catch {
1154
1200
  }
1155
- try {
1156
- await replica.instance.unlink(versionPathFor(corruptPath));
1157
- } catch {
1158
- }
1201
+ await this.unlinkVersionSidecar(replica.instance, corruptPath);
1159
1202
  }
1160
1203
  try {
1161
1204
  await this.deleteFile(corruptPath);
@@ -1164,10 +1207,7 @@ var ConfigRepo = class {
1164
1207
  await this.cachedFS.unlink(corruptPath);
1165
1208
  } catch {
1166
1209
  }
1167
- try {
1168
- await this.cachedFS.unlink(versionPathFor(corruptPath));
1169
- } catch {
1170
- }
1210
+ await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
1171
1211
  }
1172
1212
  }
1173
1213
  const seen = /* @__PURE__ */ new Map();
@@ -1197,10 +1237,7 @@ var ConfigRepo = class {
1197
1237
  await replica.instance.unlink(descPath);
1198
1238
  } catch {
1199
1239
  }
1200
- try {
1201
- await replica.instance.unlink(versionPathFor(descPath));
1202
- } catch {
1203
- }
1240
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1204
1241
  }
1205
1242
  try {
1206
1243
  await this.deleteFile(descPath);
@@ -1222,8 +1259,7 @@ var ConfigRepo = class {
1222
1259
  await this.cachedFS.writeFile(path, bytes);
1223
1260
  const author = `${this.appId}/${this.nodeId}`;
1224
1261
  const version = await incrementVersion(this.fullFS, path, bytes, author);
1225
- await this.ensureDir(versionPathFor(path));
1226
- await writeVersion(this.fullFS, versionPathFor(path), version);
1262
+ await this.writeVersionSidecar(path, version);
1227
1263
  }
1228
1264
  /** Remove a single backend descriptor file + its version sidecar */
1229
1265
  async removeBackendDescriptor(id) {
@@ -1232,10 +1268,7 @@ var ConfigRepo = class {
1232
1268
  await this.cachedFS.unlink(path);
1233
1269
  } catch {
1234
1270
  }
1235
- try {
1236
- await this.cachedFS.unlink(versionPathFor(path));
1237
- } catch {
1238
- }
1271
+ await this.unlinkVersionSidecar(this.cachedFS, path);
1239
1272
  }
1240
1273
  // -----------------------------------------------------------------------
1241
1274
  // IConfigRepo — Meta file access (no chroot)
@@ -1334,10 +1367,7 @@ var ConfigRepo = class {
1334
1367
  await replica.instance.unlink(descPath);
1335
1368
  } catch {
1336
1369
  }
1337
- try {
1338
- await replica.instance.unlink(versionPathFor(descPath));
1339
- } catch {
1340
- }
1370
+ await this.unlinkVersionSidecar(replica.instance, descPath);
1341
1371
  try {
1342
1372
  await this.deleteFile(descPath);
1343
1373
  } catch {
@@ -1433,8 +1463,7 @@ var ConfigRepo = class {
1433
1463
  await this.cachedFS.writeFile(descPath, bytes);
1434
1464
  const author = `${this.appId}/${this.nodeId}`;
1435
1465
  const version = await incrementVersion(this.fullFS, descPath, bytes, author);
1436
- await this.ensureDir(versionPathFor(descPath));
1437
- await writeVersion(this.fullFS, versionPathFor(descPath), version);
1466
+ await this.writeVersionSidecar(descPath, version);
1438
1467
  this.appDataGroups.set(id, group);
1439
1468
  console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
1440
1469
  return group;
@@ -1494,10 +1523,7 @@ var ConfigRepo = class {
1494
1523
  await this.cachedFS.unlink(descPath);
1495
1524
  } catch {
1496
1525
  }
1497
- try {
1498
- await this.cachedFS.unlink(versionPathFor(descPath));
1499
- } catch {
1500
- }
1526
+ await this.unlinkVersionSidecar(this.cachedFS, descPath);
1501
1527
  console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
1502
1528
  }
1503
1529
  async listAccountBackends() {
@@ -1695,9 +1721,14 @@ async function createConfigRepo(appId, options = {}) {
1695
1721
  await cachedFS.unlink(BACKENDS_FILE);
1696
1722
  } catch {
1697
1723
  }
1698
- try {
1699
- await cachedFS.unlink(versionPathFor(BACKENDS_FILE));
1700
- } catch {
1724
+ {
1725
+ const vPath = versionPathFor(BACKENDS_FILE);
1726
+ if (vPath) {
1727
+ try {
1728
+ await cachedFS.unlink(vPath);
1729
+ } catch {
1730
+ }
1731
+ }
1701
1732
  }
1702
1733
  console.log(`[createConfigRepo] Migration complete`);
1703
1734
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.4",
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",