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 +118 -12
- package/dist/index.js +12 -0
- package/dist/index.mjs +12 -0
- package/package.json +1 -1
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. **
|
|
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
|
|
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
|
|
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:
|
|
889
|
-
│ └─ OneWay:
|
|
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.
|
|
901
|
-
|
|
902
|
-
-
|
|
903
|
-
-
|
|
904
|
-
|
|
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;
|