zen-fs-config 0.5.3 → 0.5.4
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 +197 -17
- package/dist/index.js +30 -0
- package/dist/index.mjs +30 -0
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -704,31 +704,206 @@ 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. **Merges** both snapshots into one map (`source ∪ target`)
|
|
752
|
+
4. Caches this merged snapshot as `sourceSnapshots`
|
|
753
|
+
|
|
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**.
|
|
755
|
+
|
|
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.
|
|
757
|
+
|
|
758
|
+
**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
|
+
|
|
760
|
+
This pattern is applied in three places:
|
|
761
|
+
- `createConfigRepo()` → `initialSyncAndDedup()` (sync → dedup → watch)
|
|
762
|
+
- `addBackend()` → `syncMetaToReplicas()` then `watch()` (sync → watch)
|
|
763
|
+
- `AppDataGroupImpl.connect()` → `syncAll()` then `watchAll()` (sync → watch)
|
|
764
|
+
|
|
765
|
+
### 11.3 `flush()` — Manual Sync Trigger
|
|
766
|
+
|
|
767
|
+
```
|
|
768
|
+
flush()
|
|
769
|
+
│
|
|
770
|
+
├─ 1. processTombstones()
|
|
771
|
+
│ For each tombstone in /.meta/.deleted/:
|
|
772
|
+
│ ├─ Delete the actual file on primary (in case re-created)
|
|
773
|
+
│ ├─ Delete the actual file on ALL replicas
|
|
774
|
+
│ └─ Delete version sidecars on all replicas
|
|
775
|
+
│
|
|
776
|
+
├─ 2. syncAll()
|
|
777
|
+
│ For each SyncPair (IndexedDB ↔ replica):
|
|
778
|
+
│ ├─ Build current snapshots of both sides
|
|
779
|
+
│ ├─ Compare with cached snapshot (if any)
|
|
780
|
+
│ ├─ Detect changes: Created / Modified / Deleted
|
|
781
|
+
│ ├─ Resolve conflicts (source-wins strategy)
|
|
782
|
+
│ └─ Copy files in both directions as needed
|
|
783
|
+
│
|
|
784
|
+
├─ 3. readAllBackendDescriptors() — post-sync dedup
|
|
785
|
+
│ Sync may have pulled duplicate backend descriptors from remote.
|
|
786
|
+
│ Re-run dedup to catch and remove them.
|
|
787
|
+
│ ├─ Delete dup files on ALL replicas directly
|
|
788
|
+
│ └─ Create tombstones for deduped descriptors
|
|
789
|
+
│
|
|
790
|
+
├─ 4. processTombstones() — process any new tombstones from step 3
|
|
791
|
+
│
|
|
792
|
+
├─ 5. updateTombstoneConfirmations()
|
|
793
|
+
│ Mark each tombstone as confirmed by all replica backends
|
|
794
|
+
│
|
|
795
|
+
├─ 6. gcTombstones()
|
|
796
|
+
│ Remove tombstones confirmed by ALL backends in the topology
|
|
797
|
+
│
|
|
798
|
+
└─ Return SyncResult[] (one per sync pair)
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### 11.4 Tombstone-Based Deletion Propagation
|
|
802
|
+
|
|
803
|
+
When a file is deleted via `deleteFile(path)`:
|
|
804
|
+
|
|
805
|
+
```
|
|
806
|
+
deleteFile('/.meta/backends/old-backend.json')
|
|
807
|
+
│
|
|
808
|
+
├─ 1. Write tombstone: /.meta/.deleted/++meta__backends__old-backend++json.json
|
|
809
|
+
│ { path, deletedAt, deletedBy, confirmedBy: [primaryBackendId] }
|
|
810
|
+
│
|
|
811
|
+
├─ 2. Delete the actual file on primary (IndexedDB)
|
|
812
|
+
│
|
|
813
|
+
└─ 3. Delete version sidecar (.old-backend.json.version) on primary
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
On the next `processTombstones()` (called by `flush()` or `initialSyncAndDedup()`):
|
|
817
|
+
|
|
818
|
+
```
|
|
819
|
+
For each tombstone:
|
|
820
|
+
├─ Delete file on primary (in case sync re-created it)
|
|
821
|
+
├─ Delete file on ALL replicas
|
|
822
|
+
├─ Delete version sidecar on ALL replicas
|
|
823
|
+
└─ Tombstone file itself is synced to replicas via syncAll()
|
|
824
|
+
→ Late-joining replicas see the tombstone and delete the file
|
|
729
825
|
```
|
|
730
826
|
|
|
731
|
-
|
|
827
|
+
**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.
|
|
828
|
+
|
|
829
|
+
### 11.5 Backend Deduplication
|
|
830
|
+
|
|
831
|
+
When `readAllBackendDescriptors()` detects two backends with the same `type` + `options` (using stable key ordering) but different IDs:
|
|
832
|
+
|
|
833
|
+
```
|
|
834
|
+
Detected: rs-1 and rs-2 have identical type + options
|
|
835
|
+
│
|
|
836
|
+
├─ 1. Keep the one with the earliest mtime (created first)
|
|
837
|
+
│
|
|
838
|
+
├─ 2. For each duplicate:
|
|
839
|
+
│ ├─ Delete descriptor file on ALL replicas directly
|
|
840
|
+
│ │ (prevents sync from pulling it back)
|
|
841
|
+
│ ├─ Delete version sidecar on ALL replicas
|
|
842
|
+
│ └─ Create tombstone + delete local file
|
|
843
|
+
│
|
|
844
|
+
└─ 3. Return deduplicated list (duplicates removed)
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
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.
|
|
848
|
+
|
|
849
|
+
### 11.6 Dynamic Backend Management
|
|
850
|
+
|
|
851
|
+
**`addBackend(id, type, options)`**:
|
|
852
|
+
|
|
853
|
+
```
|
|
854
|
+
├─ 1. Dedup check: reject if same type+options already registered
|
|
855
|
+
├─ 2. Create backend instance
|
|
856
|
+
├─ 3. Write descriptor to .meta/backends/{id}.json
|
|
857
|
+
├─ 4. Create SyncPair (IndexedDB ↔ new replica, bi-directional)
|
|
858
|
+
├─ 5. syncMetaToReplicas() — full sync FIRST (pull + push)
|
|
859
|
+
└─ 6. watch(pairId) — start monitoring AFTER sync completes
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
**`removeBackend(id)`**:
|
|
863
|
+
|
|
864
|
+
```
|
|
865
|
+
├─ 1. Delete descriptor file on the remote backend DIRECTLY
|
|
866
|
+
│ (must happen before removing sync pair — otherwise can't reach remote)
|
|
867
|
+
├─ 2. Delete version sidecar on remote
|
|
868
|
+
├─ 3. Create tombstone + delete local descriptor file
|
|
869
|
+
├─ 4. Remove sync pair (stops watching + disposes)
|
|
870
|
+
├─ 5. Remove from replicaBackends map
|
|
871
|
+
├─ 6. Dispose backend instance
|
|
872
|
+
├─ 7. processTombstones() — propagate deletion to remaining replicas
|
|
873
|
+
└─ 8. flush() — sync + GC tombstones
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### 11.7 Watch Mode (Auto-Sync)
|
|
877
|
+
|
|
878
|
+
After initialization, each SyncPair runs in **watch mode** with hybrid change detection:
|
|
879
|
+
|
|
880
|
+
```
|
|
881
|
+
watch() triggers:
|
|
882
|
+
│
|
|
883
|
+
├─ 1. Register onChange callbacks (if backend supports it)
|
|
884
|
+
│ Local backends (IndexedDB) push change notifications
|
|
885
|
+
│ → triggers debounced sync (default 300ms)
|
|
886
|
+
│
|
|
887
|
+
├─ 2. buildInitialSnapshots()
|
|
888
|
+
│ ├─ BiDirectional: merge source + target snapshots
|
|
889
|
+
│ └─ OneWay: snapshot source only
|
|
890
|
+
│
|
|
891
|
+
└─ 3. Start poll timers (if backend supports shouldSync)
|
|
892
|
+
├─ Remote backends poll shouldSync() every pollIntervalMs (default 30min)
|
|
893
|
+
└─ Fallback: if no onChange and no shouldSync, poll every interval
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
**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
|
+
|
|
898
|
+
**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
|
|
905
|
+
|
|
906
|
+
### 11.8 Standalone Data-Sync Group (`createDataSyncGroup`)
|
|
732
907
|
|
|
733
908
|
```
|
|
734
909
|
createDataSyncGroup('my-app', options?)
|
|
@@ -743,11 +918,16 @@ createDataSyncGroup('my-app', options?)
|
|
|
743
918
|
├─ 3. Create IndexedDB as local primary (for offline access)
|
|
744
919
|
│
|
|
745
920
|
├─ 4. Setup sync: IndexedDB ↔ each data backend (bi-directional)
|
|
921
|
+
│ NOTE: Does NOT watch yet — sync first
|
|
922
|
+
│
|
|
923
|
+
├─ 5. syncAll() — pull data from remote backends
|
|
924
|
+
│
|
|
925
|
+
├─ 6. watchAll() — start monitoring AFTER sync completes
|
|
746
926
|
│
|
|
747
|
-
└─
|
|
927
|
+
└─ 7. Return DataSyncGroup handle with direct fs access
|
|
748
928
|
```
|
|
749
929
|
|
|
750
|
-
### 11.
|
|
930
|
+
### 11.9 Unified Entry Point (`connect`)
|
|
751
931
|
|
|
752
932
|
`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
933
|
|
package/dist/index.js
CHANGED
|
@@ -1179,6 +1179,7 @@ var ConfigRepo = class {
|
|
|
1179
1179
|
try {
|
|
1180
1180
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1181
1181
|
const items = [];
|
|
1182
|
+
const corruptFiles = [];
|
|
1182
1183
|
for (const entry of entries) {
|
|
1183
1184
|
if (!entry.endsWith(".json")) continue;
|
|
1184
1185
|
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
@@ -1193,8 +1194,37 @@ var ConfigRepo = class {
|
|
|
1193
1194
|
} catch {
|
|
1194
1195
|
}
|
|
1195
1196
|
items.push({ desc, mtime });
|
|
1197
|
+
} else {
|
|
1198
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1199
|
+
corruptFiles.push(filePath);
|
|
1200
|
+
}
|
|
1201
|
+
} catch (parseErr) {
|
|
1202
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1203
|
+
corruptFiles.push(filePath);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const corruptPath of corruptFiles) {
|
|
1207
|
+
for (const [, replica] of this.replicaBackends) {
|
|
1208
|
+
try {
|
|
1209
|
+
await replica.instance.unlink(corruptPath);
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
try {
|
|
1213
|
+
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1214
|
+
} catch {
|
|
1196
1215
|
}
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
await this.deleteFile(corruptPath);
|
|
1197
1219
|
} catch {
|
|
1220
|
+
try {
|
|
1221
|
+
await this.cachedFS.unlink(corruptPath);
|
|
1222
|
+
} catch {
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1226
|
+
} catch {
|
|
1227
|
+
}
|
|
1198
1228
|
}
|
|
1199
1229
|
}
|
|
1200
1230
|
const seen = /* @__PURE__ */ new Map();
|
package/dist/index.mjs
CHANGED
|
@@ -1122,6 +1122,7 @@ var ConfigRepo = class {
|
|
|
1122
1122
|
try {
|
|
1123
1123
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1124
1124
|
const items = [];
|
|
1125
|
+
const corruptFiles = [];
|
|
1125
1126
|
for (const entry of entries) {
|
|
1126
1127
|
if (!entry.endsWith(".json")) continue;
|
|
1127
1128
|
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
@@ -1136,8 +1137,37 @@ var ConfigRepo = class {
|
|
|
1136
1137
|
} catch {
|
|
1137
1138
|
}
|
|
1138
1139
|
items.push({ desc, mtime });
|
|
1140
|
+
} else {
|
|
1141
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1142
|
+
corruptFiles.push(filePath);
|
|
1143
|
+
}
|
|
1144
|
+
} catch (parseErr) {
|
|
1145
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1146
|
+
corruptFiles.push(filePath);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
for (const corruptPath of corruptFiles) {
|
|
1150
|
+
for (const [, replica] of this.replicaBackends) {
|
|
1151
|
+
try {
|
|
1152
|
+
await replica.instance.unlink(corruptPath);
|
|
1153
|
+
} catch {
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1157
|
+
} catch {
|
|
1139
1158
|
}
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
await this.deleteFile(corruptPath);
|
|
1140
1162
|
} catch {
|
|
1163
|
+
try {
|
|
1164
|
+
await this.cachedFS.unlink(corruptPath);
|
|
1165
|
+
} catch {
|
|
1166
|
+
}
|
|
1167
|
+
try {
|
|
1168
|
+
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1169
|
+
} catch {
|
|
1170
|
+
}
|
|
1141
1171
|
}
|
|
1142
1172
|
}
|
|
1143
1173
|
const seen = /* @__PURE__ */ new Map();
|