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 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
- └─ 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. 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.2 Standalone Data-Sync Group (`createDataSyncGroup`)
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
- └─ 5. Return DataSyncGroup handle with direct fs access
929
+ └─ 7. Return DataSyncGroup handle with direct fs access
748
930
  ```
749
931
 
750
- ### 11.3 Unified Entry Point (`connect`)
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();
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.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",