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 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
- └─ Write descriptor to .meta/backends/{replicaId}.json (if not exists)
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 > localStorage > auto-generated)
715
+ ├─ 7. Determine nodeId (explicit parameter > auto-generated)
712
716
 
713
- ├─ 8. Create ConfigRepo with primary = 'local-idb'
717
+ ├─ 8. Create final ConfigRepo instance (primary = 'local-idb')
714
718
 
715
- ├─ 9. setupSync: for each backend (except local-idb):
716
- │ ├─ Create backend instance
717
- │ ├─ Create SyncPair(IndexedDB, replica, bi-directional)
718
- └─ syncEngine.watch(pairId)
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 app data group references from .meta/app-data-groups/{appId}/
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. syncMetaToReplicas: push .meta/ changes to all replicas (background)
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
- └─ 12. Load config cache from IndexedDB
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
- ### 11.2 Standalone Data-Sync Group (`createDataSyncGroup`)
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
- └─ 5. Return DataSyncGroup handle with direct fs access
927
+ └─ 7. Return DataSyncGroup handle with direct fs access
748
928
  ```
749
929
 
750
- ### 11.3 Unified Entry Point (`connect`)
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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",