zen-fs-config 0.5.4 → 0.5.5

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
package/dist/index.js CHANGED
@@ -274,6 +274,12 @@ function backendToSyncableFS(backend, name) {
274
274
  if (typeof backend.shouldSync === "function") {
275
275
  syncable.shouldSync = () => backend.shouldSync();
276
276
  }
277
+ if (typeof backend.createSnapshot === "function") {
278
+ syncable.createSnapshot = (root, filter) => backend.createSnapshot(root, filter);
279
+ }
280
+ if (typeof backend.writeFileWithMtime === "function") {
281
+ syncable.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
282
+ }
277
283
  if (typeof backend.checkForUpdates === "function") {
278
284
  syncable.checkForUpdates = () => backend.checkForUpdates();
279
285
  }
@@ -404,6 +410,12 @@ async function wrapZenFSFileSystem(config) {
404
410
  backend.onChange = (callback) => {
405
411
  changeCallback = callback;
406
412
  };
413
+ if (typeof isolatedFS.createSnapshot === "function") {
414
+ backend.createSnapshot = (root, filter) => isolatedFS.createSnapshot(root, filter);
415
+ }
416
+ if (typeof isolatedFS.writeFileWithMtime === "function") {
417
+ backend.writeFileWithMtime = (path, data, mtimeMs) => isolatedFS.writeFileWithMtime(path, data, mtimeMs);
418
+ }
407
419
  return backend;
408
420
  }
409
421
  var inMemoryCounter = 0;
package/dist/index.mjs CHANGED
@@ -217,6 +217,12 @@ function backendToSyncableFS(backend, name) {
217
217
  if (typeof backend.shouldSync === "function") {
218
218
  syncable.shouldSync = () => backend.shouldSync();
219
219
  }
220
+ if (typeof backend.createSnapshot === "function") {
221
+ syncable.createSnapshot = (root, filter) => backend.createSnapshot(root, filter);
222
+ }
223
+ if (typeof backend.writeFileWithMtime === "function") {
224
+ syncable.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
225
+ }
220
226
  if (typeof backend.checkForUpdates === "function") {
221
227
  syncable.checkForUpdates = () => backend.checkForUpdates();
222
228
  }
@@ -347,6 +353,12 @@ async function wrapZenFSFileSystem(config) {
347
353
  backend.onChange = (callback) => {
348
354
  changeCallback = callback;
349
355
  };
356
+ if (typeof isolatedFS.createSnapshot === "function") {
357
+ backend.createSnapshot = (root, filter) => isolatedFS.createSnapshot(root, filter);
358
+ }
359
+ if (typeof isolatedFS.writeFileWithMtime === "function") {
360
+ backend.writeFileWithMtime = (path, data, mtimeMs) => isolatedFS.writeFileWithMtime(path, data, mtimeMs);
361
+ }
350
362
  return backend;
351
363
  }
352
364
  var inMemoryCounter = 0;
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.5",
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",