zen-fs-config 0.5.3 → 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 +303 -17
- package/dist/index.js +42 -0
- package/dist/index.mjs +42 -0
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -704,31 +704,208 @@ createConfigRepo('my-app', options?)
|
|
|
704
704
|
│
|
|
705
705
|
├─ 5. If options.backendInfo provided:
|
|
706
706
|
│ ├─ Generate replica ID (options.primaryBackendId or auto)
|
|
707
|
-
│
|
|
707
|
+
│ ├─ Dedup check: same type + options (stable key) already registered?
|
|
708
|
+
│ └─ Write descriptor to .meta/backends/{replicaId}.json (if not duplicate)
|
|
708
709
|
│
|
|
709
710
|
├─ 6. Read all backend descriptors from .meta/backends/
|
|
711
|
+
│ └─ Dedup: remove duplicates (same type + options, different ID)
|
|
712
|
+
│ ├─ Delete duplicate files on ALL replicas directly
|
|
713
|
+
│ └─ Create tombstone + delete local file
|
|
710
714
|
│
|
|
711
|
-
├─ 7. Determine nodeId (explicit
|
|
715
|
+
├─ 7. Determine nodeId (explicit parameter > auto-generated)
|
|
712
716
|
│
|
|
713
|
-
├─ 8. Create ConfigRepo
|
|
717
|
+
├─ 8. Create final ConfigRepo instance (primary = 'local-idb')
|
|
714
718
|
│
|
|
715
|
-
├─ 9. setupSync: for each backend
|
|
716
|
-
│ ├─ Create backend instance
|
|
717
|
-
│ ├─ Create SyncPair(IndexedDB
|
|
718
|
-
│
|
|
719
|
+
├─ 9. setupSync: for each replica backend:
|
|
720
|
+
│ ├─ Create backend instance (e.g., Gitee, RemoteStorage)
|
|
721
|
+
│ ├─ Create SyncPair(IndexedDB ↔ replica, bi-directional)
|
|
722
|
+
│ ├─ Register conflict handler
|
|
723
|
+
│ └─ NOTE: Does NOT call watch() yet (see §11.4 for why)
|
|
719
724
|
│
|
|
720
|
-
├─ 10. Load
|
|
721
|
-
│ For each referenced data-sync group:
|
|
722
|
-
│ ├─ Create data-sync backends (merge account fields if accountBackendId set)
|
|
723
|
-
│ ├─ Create SyncPair for each data backend
|
|
724
|
-
│ └─ syncEngine.watch(pairId)
|
|
725
|
+
├─ 10. Load config cache from IndexedDB (fast, local-only)
|
|
725
726
|
│
|
|
726
|
-
├─ 11.
|
|
727
|
+
├─ 11. initialSyncAndDedup() — only if replicas exist:
|
|
728
|
+
│ ├─ unwatchAll() — safety: clear any stale snapshots
|
|
729
|
+
│ ├─ syncAll() — full bidirectional sync (no cached snapshot
|
|
730
|
+
│ │ → every file is compared, remote-only files
|
|
731
|
+
│ │ are pulled to local)
|
|
732
|
+
│ ├─ readAllBackendDescriptors() — dedup duplicates pulled from remote
|
|
733
|
+
│ │ ├─ Delete dup files on ALL replicas directly
|
|
734
|
+
│ │ └─ Create tombstones for deduped descriptors
|
|
735
|
+
│ ├─ processTombstones() — delete deduped files on all replicas
|
|
736
|
+
│ └─ watchAll() — start monitoring for future changes
|
|
737
|
+
│ (snapshots now reflect the fully synced state)
|
|
727
738
|
│
|
|
728
|
-
|
|
739
|
+
├─ 12. syncMetaToReplicas() — background push of .meta/ changes
|
|
740
|
+
│ (watchers already running, this just speeds up initial propagation)
|
|
741
|
+
│
|
|
742
|
+
└─ 13. Return ConfigRepo instance
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### 11.2 Why "Sync Before Watch" (Critical Design Decision)
|
|
746
|
+
|
|
747
|
+
The sync engine (`zen-fs-sync`) uses **snapshot-based change detection**. When `watch()` is called on a SyncPair, it triggers `buildInitialSnapshots()` which:
|
|
748
|
+
|
|
749
|
+
1. Builds a snapshot of the source (IndexedDB) — walks all files, records `path`, `size`, `mtimeMs`
|
|
750
|
+
2. Builds a snapshot of the target (remote backend) — same process
|
|
751
|
+
3. Caches **separate** snapshots: `prevSrcSnap` (source) and `prevTgtSnap` (target)
|
|
752
|
+
|
|
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**.
|
|
754
|
+
|
|
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.
|
|
756
|
+
|
|
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.
|
|
758
|
+
|
|
759
|
+
This pattern is applied in three places:
|
|
760
|
+
- `createConfigRepo()` → `initialSyncAndDedup()` (sync → dedup → watch)
|
|
761
|
+
- `addBackend()` → `syncMetaToReplicas()` then `watch()` (sync → watch)
|
|
762
|
+
- `AppDataGroupImpl.connect()` → `syncAll()` then `watchAll()` (sync → watch)
|
|
763
|
+
|
|
764
|
+
### 11.3 `flush()` — Manual Sync Trigger
|
|
765
|
+
|
|
766
|
+
```
|
|
767
|
+
flush()
|
|
768
|
+
│
|
|
769
|
+
├─ 1. processTombstones()
|
|
770
|
+
│ For each tombstone in /.meta/.deleted/:
|
|
771
|
+
│ ├─ Delete the actual file on primary (in case re-created)
|
|
772
|
+
│ ├─ Delete the actual file on ALL replicas
|
|
773
|
+
│ └─ Delete version sidecars on all replicas
|
|
774
|
+
│
|
|
775
|
+
├─ 2. syncAll()
|
|
776
|
+
│ For each SyncPair (IndexedDB ↔ replica):
|
|
777
|
+
│ ├─ Build current snapshots of both sides
|
|
778
|
+
│ ├─ Compare with cached snapshot (if any)
|
|
779
|
+
│ ├─ Detect changes: Created / Modified / Deleted
|
|
780
|
+
│ ├─ Resolve conflicts (source-wins strategy)
|
|
781
|
+
│ └─ Copy files in both directions as needed
|
|
782
|
+
│
|
|
783
|
+
├─ 3. readAllBackendDescriptors() — post-sync dedup
|
|
784
|
+
│ Sync may have pulled duplicate backend descriptors from remote.
|
|
785
|
+
│ Re-run dedup to catch and remove them.
|
|
786
|
+
│ ├─ Delete dup files on ALL replicas directly
|
|
787
|
+
│ └─ Create tombstones for deduped descriptors
|
|
788
|
+
│
|
|
789
|
+
├─ 4. processTombstones() — process any new tombstones from step 3
|
|
790
|
+
│
|
|
791
|
+
├─ 5. updateTombstoneConfirmations()
|
|
792
|
+
│ Mark each tombstone as confirmed by all replica backends
|
|
793
|
+
│
|
|
794
|
+
├─ 6. gcTombstones()
|
|
795
|
+
│ Remove tombstones confirmed by ALL backends in the topology
|
|
796
|
+
│
|
|
797
|
+
└─ Return SyncResult[] (one per sync pair)
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### 11.4 Tombstone-Based Deletion Propagation
|
|
801
|
+
|
|
802
|
+
When a file is deleted via `deleteFile(path)`:
|
|
803
|
+
|
|
804
|
+
```
|
|
805
|
+
deleteFile('/.meta/backends/old-backend.json')
|
|
806
|
+
│
|
|
807
|
+
├─ 1. Write tombstone: /.meta/.deleted/++meta__backends__old-backend++json.json
|
|
808
|
+
│ { path, deletedAt, deletedBy, confirmedBy: [primaryBackendId] }
|
|
809
|
+
│
|
|
810
|
+
├─ 2. Delete the actual file on primary (IndexedDB)
|
|
811
|
+
│
|
|
812
|
+
└─ 3. Delete version sidecar (.old-backend.json.version) on primary
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
On the next `processTombstones()` (called by `flush()` or `initialSyncAndDedup()`):
|
|
816
|
+
|
|
817
|
+
```
|
|
818
|
+
For each tombstone:
|
|
819
|
+
├─ Delete file on primary (in case sync re-created it)
|
|
820
|
+
├─ Delete file on ALL replicas
|
|
821
|
+
├─ Delete version sidecar on ALL replicas
|
|
822
|
+
└─ Tombstone file itself is synced to replicas via syncAll()
|
|
823
|
+
→ Late-joining replicas see the tombstone and delete the file
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**Why tombstones?** Without them, bi-directional sync treats a deleted local file as "missing → needs to be copied from remote". The tombstone explicitly signals "this file was intentionally deleted" so all replicas honor the deletion. Tombstones are garbage-collected after all backends confirm receipt.
|
|
827
|
+
|
|
828
|
+
### 11.5 Backend Deduplication
|
|
829
|
+
|
|
830
|
+
When `readAllBackendDescriptors()` detects two backends with the same `type` + `options` (using stable key ordering) but different IDs:
|
|
831
|
+
|
|
832
|
+
```
|
|
833
|
+
Detected: rs-1 and rs-2 have identical type + options
|
|
834
|
+
│
|
|
835
|
+
├─ 1. Keep the one with the earliest mtime (created first)
|
|
836
|
+
│
|
|
837
|
+
├─ 2. For each duplicate:
|
|
838
|
+
│ ├─ Delete descriptor file on ALL replicas directly
|
|
839
|
+
│ │ (prevents sync from pulling it back)
|
|
840
|
+
│ ├─ Delete version sidecar on ALL replicas
|
|
841
|
+
│ └─ Create tombstone + delete local file
|
|
842
|
+
│
|
|
843
|
+
└─ 3. Return deduplicated list (duplicates removed)
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
The stable key function (`backendDedupKey`) sorts object keys recursively, so `{ token: 'a', owner: 'b' }` and `{ owner: 'b', token: 'a' }` produce the same key and are correctly detected as duplicates.
|
|
847
|
+
|
|
848
|
+
### 11.6 Dynamic Backend Management
|
|
849
|
+
|
|
850
|
+
**`addBackend(id, type, options)`**:
|
|
851
|
+
|
|
852
|
+
```
|
|
853
|
+
├─ 1. Dedup check: reject if same type+options already registered
|
|
854
|
+
├─ 2. Create backend instance
|
|
855
|
+
├─ 3. Write descriptor to .meta/backends/{id}.json
|
|
856
|
+
├─ 4. Create SyncPair (IndexedDB ↔ new replica, bi-directional)
|
|
857
|
+
├─ 5. syncMetaToReplicas() — full sync FIRST (pull + push)
|
|
858
|
+
└─ 6. watch(pairId) — start monitoring AFTER sync completes
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
**`removeBackend(id)`**:
|
|
862
|
+
|
|
863
|
+
```
|
|
864
|
+
├─ 1. Delete descriptor file on the remote backend DIRECTLY
|
|
865
|
+
│ (must happen before removing sync pair — otherwise can't reach remote)
|
|
866
|
+
├─ 2. Delete version sidecar on remote
|
|
867
|
+
├─ 3. Create tombstone + delete local descriptor file
|
|
868
|
+
├─ 4. Remove sync pair (stops watching + disposes)
|
|
869
|
+
├─ 5. Remove from replicaBackends map
|
|
870
|
+
├─ 6. Dispose backend instance
|
|
871
|
+
├─ 7. processTombstones() — propagate deletion to remaining replicas
|
|
872
|
+
└─ 8. flush() — sync + GC tombstones
|
|
729
873
|
```
|
|
730
874
|
|
|
731
|
-
### 11.
|
|
875
|
+
### 11.7 Watch Mode (Auto-Sync)
|
|
876
|
+
|
|
877
|
+
After initialization, each SyncPair runs in **watch mode** with hybrid change detection:
|
|
878
|
+
|
|
879
|
+
```
|
|
880
|
+
watch() triggers:
|
|
881
|
+
│
|
|
882
|
+
├─ 1. Register onChange callbacks (if backend supports it)
|
|
883
|
+
│ Local backends (IndexedDB) push change notifications
|
|
884
|
+
│ → triggers debounced sync (default 300ms)
|
|
885
|
+
│
|
|
886
|
+
├─ 2. buildInitialSnapshots()
|
|
887
|
+
│ ├─ BiDirectional: cache separate source and target snapshots
|
|
888
|
+
│ └─ OneWay: cache source snapshot only
|
|
889
|
+
│
|
|
890
|
+
└─ 3. Start poll timers (if backend supports shouldSync)
|
|
891
|
+
├─ Remote backends poll shouldSync() every pollIntervalMs (default 30min)
|
|
892
|
+
└─ Fallback: if no onChange and no shouldSync, poll every interval
|
|
893
|
+
```
|
|
894
|
+
|
|
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.
|
|
896
|
+
|
|
897
|
+
**Snapshot comparison** in `syncBidirectional()`:
|
|
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).
|
|
907
|
+
|
|
908
|
+
### 11.8 Standalone Data-Sync Group (`createDataSyncGroup`)
|
|
732
909
|
|
|
733
910
|
```
|
|
734
911
|
createDataSyncGroup('my-app', options?)
|
|
@@ -743,11 +920,16 @@ createDataSyncGroup('my-app', options?)
|
|
|
743
920
|
├─ 3. Create IndexedDB as local primary (for offline access)
|
|
744
921
|
│
|
|
745
922
|
├─ 4. Setup sync: IndexedDB ↔ each data backend (bi-directional)
|
|
923
|
+
│ NOTE: Does NOT watch yet — sync first
|
|
924
|
+
│
|
|
925
|
+
├─ 5. syncAll() — pull data from remote backends
|
|
926
|
+
│
|
|
927
|
+
├─ 6. watchAll() — start monitoring AFTER sync completes
|
|
746
928
|
│
|
|
747
|
-
└─
|
|
929
|
+
└─ 7. Return DataSyncGroup handle with direct fs access
|
|
748
930
|
```
|
|
749
931
|
|
|
750
|
-
### 11.
|
|
932
|
+
### 11.9 Unified Entry Point (`connect`)
|
|
751
933
|
|
|
752
934
|
`createConfigRepo` and `createDataSyncGroup` are lower-level factory functions. The recommended entry point is `connect`, which auto-detects the group type and dispatches to the appropriate factory:
|
|
753
935
|
|
|
@@ -815,6 +997,110 @@ interface ConnectResult {
|
|
|
815
997
|
|
|
816
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).
|
|
817
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
|
+
|
|
818
1104
|
## 12. Data Flow
|
|
819
1105
|
|
|
820
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;
|
|
@@ -1179,6 +1191,7 @@ var ConfigRepo = class {
|
|
|
1179
1191
|
try {
|
|
1180
1192
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1181
1193
|
const items = [];
|
|
1194
|
+
const corruptFiles = [];
|
|
1182
1195
|
for (const entry of entries) {
|
|
1183
1196
|
if (!entry.endsWith(".json")) continue;
|
|
1184
1197
|
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
@@ -1193,8 +1206,37 @@ var ConfigRepo = class {
|
|
|
1193
1206
|
} catch {
|
|
1194
1207
|
}
|
|
1195
1208
|
items.push({ desc, mtime });
|
|
1209
|
+
} else {
|
|
1210
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1211
|
+
corruptFiles.push(filePath);
|
|
1196
1212
|
}
|
|
1213
|
+
} catch (parseErr) {
|
|
1214
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1215
|
+
corruptFiles.push(filePath);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
for (const corruptPath of corruptFiles) {
|
|
1219
|
+
for (const [, replica] of this.replicaBackends) {
|
|
1220
|
+
try {
|
|
1221
|
+
await replica.instance.unlink(corruptPath);
|
|
1222
|
+
} catch {
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1226
|
+
} catch {
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
await this.deleteFile(corruptPath);
|
|
1197
1231
|
} catch {
|
|
1232
|
+
try {
|
|
1233
|
+
await this.cachedFS.unlink(corruptPath);
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1238
|
+
} catch {
|
|
1239
|
+
}
|
|
1198
1240
|
}
|
|
1199
1241
|
}
|
|
1200
1242
|
const seen = /* @__PURE__ */ new Map();
|
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;
|
|
@@ -1122,6 +1134,7 @@ var ConfigRepo = class {
|
|
|
1122
1134
|
try {
|
|
1123
1135
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1124
1136
|
const items = [];
|
|
1137
|
+
const corruptFiles = [];
|
|
1125
1138
|
for (const entry of entries) {
|
|
1126
1139
|
if (!entry.endsWith(".json")) continue;
|
|
1127
1140
|
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
@@ -1136,8 +1149,37 @@ var ConfigRepo = class {
|
|
|
1136
1149
|
} catch {
|
|
1137
1150
|
}
|
|
1138
1151
|
items.push({ desc, mtime });
|
|
1152
|
+
} else {
|
|
1153
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1154
|
+
corruptFiles.push(filePath);
|
|
1139
1155
|
}
|
|
1156
|
+
} catch (parseErr) {
|
|
1157
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1158
|
+
corruptFiles.push(filePath);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
for (const corruptPath of corruptFiles) {
|
|
1162
|
+
for (const [, replica] of this.replicaBackends) {
|
|
1163
|
+
try {
|
|
1164
|
+
await replica.instance.unlink(corruptPath);
|
|
1165
|
+
} catch {
|
|
1166
|
+
}
|
|
1167
|
+
try {
|
|
1168
|
+
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1169
|
+
} catch {
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
await this.deleteFile(corruptPath);
|
|
1140
1174
|
} catch {
|
|
1175
|
+
try {
|
|
1176
|
+
await this.cachedFS.unlink(corruptPath);
|
|
1177
|
+
} catch {
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1181
|
+
} catch {
|
|
1182
|
+
}
|
|
1141
1183
|
}
|
|
1142
1184
|
}
|
|
1143
1185
|
const seen = /* @__PURE__ */ new Map();
|