zen-fs-config 0.5.13 → 0.5.14
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/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +177 -64
- package/dist/index.mjs +177 -64
- package/package.json +3 -3
package/dist/index.d.mts
CHANGED
|
@@ -485,6 +485,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
485
485
|
private readonly pollIntervalMs?;
|
|
486
486
|
/** Tombstone cache — avoids redundant reads within a single flush() cycle. */
|
|
487
487
|
private tombstoneCache;
|
|
488
|
+
/**
|
|
489
|
+
* Tracks the background initial sync started by createConfigRepo().
|
|
490
|
+
* `flush()` and `dispose()` will await this if it hasn't completed yet,
|
|
491
|
+
* preventing concurrent syncEngine.syncAll() calls.
|
|
492
|
+
*/
|
|
493
|
+
private initialSyncPromise;
|
|
488
494
|
constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number, cacheOptions?: CacheOptions);
|
|
489
495
|
/** Full path to this node's directory on the primary backend. */
|
|
490
496
|
get nodePath(): string;
|
|
@@ -505,6 +511,13 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
505
511
|
* to all backends instead of being treated as "missing file → re-create".
|
|
506
512
|
*/
|
|
507
513
|
deleteFile(path: string): Promise<void>;
|
|
514
|
+
/**
|
|
515
|
+
* Background sync scheduled after a deleteFile() call.
|
|
516
|
+
* Uses a short debounce to coalesce multiple rapid deletions.
|
|
517
|
+
* If a sync is already in progress, the next poll will pick up the tombstone.
|
|
518
|
+
*/
|
|
519
|
+
private postDeleteSyncTimer?;
|
|
520
|
+
private schedulePostDeleteSync;
|
|
508
521
|
/**
|
|
509
522
|
* Read all tombstones from the primary backend.
|
|
510
523
|
* Results are cached within a flush() cycle to avoid redundant reads.
|
|
@@ -536,6 +549,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
536
549
|
* backend descriptors) that watch()'s initial snapshot would skip.
|
|
537
550
|
*/
|
|
538
551
|
initialSyncAndDedup(): Promise<void>;
|
|
552
|
+
/**
|
|
553
|
+
* Start the initial sync + dedup cycle in the background.
|
|
554
|
+
* `flush()` and `dispose()` will await this promise if it hasn't
|
|
555
|
+
* completed yet, preventing concurrent syncEngine operations.
|
|
556
|
+
*/
|
|
557
|
+
startBackgroundSync(): void;
|
|
539
558
|
/**
|
|
540
559
|
* After sync: mark each tombstone as confirmed by all replica backends.
|
|
541
560
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -485,6 +485,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
485
485
|
private readonly pollIntervalMs?;
|
|
486
486
|
/** Tombstone cache — avoids redundant reads within a single flush() cycle. */
|
|
487
487
|
private tombstoneCache;
|
|
488
|
+
/**
|
|
489
|
+
* Tracks the background initial sync started by createConfigRepo().
|
|
490
|
+
* `flush()` and `dispose()` will await this if it hasn't completed yet,
|
|
491
|
+
* preventing concurrent syncEngine.syncAll() calls.
|
|
492
|
+
*/
|
|
493
|
+
private initialSyncPromise;
|
|
488
494
|
constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>, pollIntervalMs?: number, cacheOptions?: CacheOptions);
|
|
489
495
|
/** Full path to this node's directory on the primary backend. */
|
|
490
496
|
get nodePath(): string;
|
|
@@ -505,6 +511,13 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
505
511
|
* to all backends instead of being treated as "missing file → re-create".
|
|
506
512
|
*/
|
|
507
513
|
deleteFile(path: string): Promise<void>;
|
|
514
|
+
/**
|
|
515
|
+
* Background sync scheduled after a deleteFile() call.
|
|
516
|
+
* Uses a short debounce to coalesce multiple rapid deletions.
|
|
517
|
+
* If a sync is already in progress, the next poll will pick up the tombstone.
|
|
518
|
+
*/
|
|
519
|
+
private postDeleteSyncTimer?;
|
|
520
|
+
private schedulePostDeleteSync;
|
|
508
521
|
/**
|
|
509
522
|
* Read all tombstones from the primary backend.
|
|
510
523
|
* Results are cached within a flush() cycle to avoid redundant reads.
|
|
@@ -536,6 +549,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
536
549
|
* backend descriptors) that watch()'s initial snapshot would skip.
|
|
537
550
|
*/
|
|
538
551
|
initialSyncAndDedup(): Promise<void>;
|
|
552
|
+
/**
|
|
553
|
+
* Start the initial sync + dedup cycle in the background.
|
|
554
|
+
* `flush()` and `dispose()` will await this promise if it hasn't
|
|
555
|
+
* completed yet, preventing concurrent syncEngine operations.
|
|
556
|
+
*/
|
|
557
|
+
startBackgroundSync(): void;
|
|
539
558
|
/**
|
|
540
559
|
* After sync: mark each tombstone as confirmed by all replica backends.
|
|
541
560
|
*/
|
package/dist/index.js
CHANGED
|
@@ -622,6 +622,12 @@ var ConfigRepo = class {
|
|
|
622
622
|
pollIntervalMs;
|
|
623
623
|
/** Tombstone cache — avoids redundant reads within a single flush() cycle. */
|
|
624
624
|
tombstoneCache = null;
|
|
625
|
+
/**
|
|
626
|
+
* Tracks the background initial sync started by createConfigRepo().
|
|
627
|
+
* `flush()` and `dispose()` will await this if it hasn't completed yet,
|
|
628
|
+
* preventing concurrent syncEngine.syncAll() calls.
|
|
629
|
+
*/
|
|
630
|
+
initialSyncPromise = null;
|
|
625
631
|
constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs, cacheOptions) {
|
|
626
632
|
this.appId = appId;
|
|
627
633
|
this.nodeId = nodeId;
|
|
@@ -781,6 +787,10 @@ var ConfigRepo = class {
|
|
|
781
787
|
// -----------------------------------------------------------------------
|
|
782
788
|
async flush() {
|
|
783
789
|
this.assertNotDisposed();
|
|
790
|
+
if (this.initialSyncPromise) {
|
|
791
|
+
await this.initialSyncPromise;
|
|
792
|
+
this.initialSyncPromise = null;
|
|
793
|
+
}
|
|
784
794
|
await this.processTombstones();
|
|
785
795
|
const resultsMap = await this.syncEngine.syncAll();
|
|
786
796
|
this.invalidateTombstoneCache();
|
|
@@ -826,6 +836,25 @@ var ConfigRepo = class {
|
|
|
826
836
|
}
|
|
827
837
|
console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
|
|
828
838
|
this.invalidateTombstoneCache();
|
|
839
|
+
this.schedulePostDeleteSync();
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Background sync scheduled after a deleteFile() call.
|
|
843
|
+
* Uses a short debounce to coalesce multiple rapid deletions.
|
|
844
|
+
* If a sync is already in progress, the next poll will pick up the tombstone.
|
|
845
|
+
*/
|
|
846
|
+
postDeleteSyncTimer;
|
|
847
|
+
schedulePostDeleteSync() {
|
|
848
|
+
if (this.postDeleteSyncTimer) {
|
|
849
|
+
clearTimeout(this.postDeleteSyncTimer);
|
|
850
|
+
}
|
|
851
|
+
this.postDeleteSyncTimer = setTimeout(() => {
|
|
852
|
+
this.postDeleteSyncTimer = void 0;
|
|
853
|
+
if (this.disposed) return;
|
|
854
|
+
this.syncEngine.syncAll().catch((err) => {
|
|
855
|
+
console.warn("[ConfigRepo] post-delete sync failed:", err);
|
|
856
|
+
});
|
|
857
|
+
}, 500);
|
|
829
858
|
}
|
|
830
859
|
/**
|
|
831
860
|
* Read all tombstones from the primary backend.
|
|
@@ -951,6 +980,20 @@ var ConfigRepo = class {
|
|
|
951
980
|
await this.processTombstones();
|
|
952
981
|
this.syncEngine.watchAll();
|
|
953
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Start the initial sync + dedup cycle in the background.
|
|
985
|
+
* `flush()` and `dispose()` will await this promise if it hasn't
|
|
986
|
+
* completed yet, preventing concurrent syncEngine operations.
|
|
987
|
+
*/
|
|
988
|
+
startBackgroundSync() {
|
|
989
|
+
this.initialSyncPromise = this.initialSyncAndDedup().then(() => {
|
|
990
|
+
console.log("[ConfigRepo] Background initial sync complete");
|
|
991
|
+
}).catch((err) => {
|
|
992
|
+
console.error("[ConfigRepo] Background initial sync failed:", err);
|
|
993
|
+
}).finally(() => {
|
|
994
|
+
this.initialSyncPromise = null;
|
|
995
|
+
});
|
|
996
|
+
}
|
|
954
997
|
/**
|
|
955
998
|
* After sync: mark each tombstone as confirmed by all replica backends.
|
|
956
999
|
*/
|
|
@@ -1098,7 +1141,15 @@ var ConfigRepo = class {
|
|
|
1098
1141
|
// -----------------------------------------------------------------------
|
|
1099
1142
|
async dispose() {
|
|
1100
1143
|
if (this.disposed) return;
|
|
1144
|
+
if (this.initialSyncPromise) {
|
|
1145
|
+
await this.initialSyncPromise;
|
|
1146
|
+
this.initialSyncPromise = null;
|
|
1147
|
+
}
|
|
1101
1148
|
this.disposed = true;
|
|
1149
|
+
if (this.postDeleteSyncTimer) {
|
|
1150
|
+
clearTimeout(this.postDeleteSyncTimer);
|
|
1151
|
+
this.postDeleteSyncTimer = void 0;
|
|
1152
|
+
}
|
|
1102
1153
|
this.syncEngine.dispose();
|
|
1103
1154
|
for (const [_id, replica] of this.replicaBackends) {
|
|
1104
1155
|
if (replica.instance?.dispose) {
|
|
@@ -1139,7 +1190,24 @@ var ConfigRepo = class {
|
|
|
1139
1190
|
{
|
|
1140
1191
|
direction: import_zen_fs_sync.SyncDirection.BiDirectional,
|
|
1141
1192
|
conflictStrategy: "source-wins",
|
|
1142
|
-
pollIntervalMs
|
|
1193
|
+
pollIntervalMs,
|
|
1194
|
+
preSyncHook: async () => {
|
|
1195
|
+
try {
|
|
1196
|
+
this.invalidateTombstoneCache();
|
|
1197
|
+
await this.processTombstones();
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
postSyncHook: async () => {
|
|
1203
|
+
try {
|
|
1204
|
+
this.invalidateTombstoneCache();
|
|
1205
|
+
await this.processTombstones();
|
|
1206
|
+
await this.updateTombstoneConfirmations();
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1143
1211
|
},
|
|
1144
1212
|
"/"
|
|
1145
1213
|
);
|
|
@@ -1192,13 +1260,19 @@ var ConfigRepo = class {
|
|
|
1192
1260
|
const appDir = `/${this.appId}`;
|
|
1193
1261
|
try {
|
|
1194
1262
|
const files = await this.walkDir(appDir);
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1263
|
+
const readResults = await Promise.all(
|
|
1264
|
+
files.map(async (filePath) => {
|
|
1265
|
+
try {
|
|
1266
|
+
const raw = await this.cachedFS.readFile(filePath);
|
|
1267
|
+
const data = this.serializer.deserialize(toUint8Array(raw), filePath);
|
|
1268
|
+
return { filePath, data };
|
|
1269
|
+
} catch {
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
})
|
|
1273
|
+
);
|
|
1274
|
+
for (const item of readResults) {
|
|
1275
|
+
if (item) this.configCache.set(item.filePath, item.data);
|
|
1202
1276
|
}
|
|
1203
1277
|
} catch {
|
|
1204
1278
|
}
|
|
@@ -1285,17 +1359,23 @@ var ConfigRepo = class {
|
|
|
1285
1359
|
const current = stack.pop();
|
|
1286
1360
|
try {
|
|
1287
1361
|
const entries = await this.cachedFS.readdir(current);
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
results.push(fullPath);
|
|
1362
|
+
const statResults = await Promise.all(
|
|
1363
|
+
entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
|
|
1364
|
+
const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
|
|
1365
|
+
try {
|
|
1366
|
+
const stat = await this.cachedFS.stat(fullPath);
|
|
1367
|
+
return { fullPath, stat };
|
|
1368
|
+
} catch {
|
|
1369
|
+
return null;
|
|
1297
1370
|
}
|
|
1298
|
-
}
|
|
1371
|
+
})
|
|
1372
|
+
);
|
|
1373
|
+
for (const item of statResults) {
|
|
1374
|
+
if (!item) continue;
|
|
1375
|
+
if (item.stat.mode !== void 0 && (item.stat.mode & 16384) === 16384) {
|
|
1376
|
+
stack.push(item.fullPath);
|
|
1377
|
+
} else {
|
|
1378
|
+
results.push(item.fullPath);
|
|
1299
1379
|
}
|
|
1300
1380
|
}
|
|
1301
1381
|
} catch {
|
|
@@ -1336,29 +1416,38 @@ var ConfigRepo = class {
|
|
|
1336
1416
|
async readAllBackendDescriptors() {
|
|
1337
1417
|
try {
|
|
1338
1418
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1339
|
-
const
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1419
|
+
const jsonEntries = entries.filter((e) => e.endsWith(".json"));
|
|
1420
|
+
const readResults = await Promise.all(
|
|
1421
|
+
jsonEntries.map(async (entry) => {
|
|
1422
|
+
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
1423
|
+
try {
|
|
1424
|
+
const raw = await this.cachedFS.readFile(filePath);
|
|
1425
|
+
const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
|
|
1426
|
+
if (desc.id && desc.type) {
|
|
1427
|
+
let mtime = 0;
|
|
1428
|
+
try {
|
|
1429
|
+
const stat = await this.cachedFS.stat(filePath);
|
|
1430
|
+
mtime = stat.mtimeMs ?? 0;
|
|
1431
|
+
} catch {
|
|
1432
|
+
}
|
|
1433
|
+
return { kind: "ok", desc, mtime };
|
|
1434
|
+
} else {
|
|
1435
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1436
|
+
return { kind: "corrupt", filePath };
|
|
1353
1437
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
corruptFiles.push(filePath);
|
|
1438
|
+
} catch (parseErr) {
|
|
1439
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1440
|
+
return { kind: "corrupt", filePath };
|
|
1358
1441
|
}
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1442
|
+
})
|
|
1443
|
+
);
|
|
1444
|
+
const items = [];
|
|
1445
|
+
const corruptFiles = [];
|
|
1446
|
+
for (const result of readResults) {
|
|
1447
|
+
if (result.kind === "ok") {
|
|
1448
|
+
items.push({ desc: result.desc, mtime: result.mtime });
|
|
1449
|
+
} else {
|
|
1450
|
+
corruptFiles.push(result.filePath);
|
|
1362
1451
|
}
|
|
1363
1452
|
}
|
|
1364
1453
|
for (const corruptPath of corruptFiles) {
|
|
@@ -1509,7 +1598,24 @@ var ConfigRepo = class {
|
|
|
1509
1598
|
syncable,
|
|
1510
1599
|
{
|
|
1511
1600
|
direction: import_zen_fs_sync.SyncDirection.BiDirectional,
|
|
1512
|
-
conflictStrategy: "source-wins"
|
|
1601
|
+
conflictStrategy: "source-wins",
|
|
1602
|
+
preSyncHook: async () => {
|
|
1603
|
+
try {
|
|
1604
|
+
this.invalidateTombstoneCache();
|
|
1605
|
+
await this.processTombstones();
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1610
|
+
postSyncHook: async () => {
|
|
1611
|
+
try {
|
|
1612
|
+
this.invalidateTombstoneCache();
|
|
1613
|
+
await this.processTombstones();
|
|
1614
|
+
await this.updateTombstoneConfirmations();
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1513
1619
|
},
|
|
1514
1620
|
"/"
|
|
1515
1621
|
);
|
|
@@ -1528,26 +1634,33 @@ var ConfigRepo = class {
|
|
|
1528
1634
|
throw new Error("Cannot remove the local IndexedDB primary backend");
|
|
1529
1635
|
}
|
|
1530
1636
|
const replica = this.replicaBackends.get(id);
|
|
1531
|
-
if (!replica) {
|
|
1532
|
-
throw new Error(`Backend "${id}" is not a registered replica`);
|
|
1533
|
-
}
|
|
1534
1637
|
const descPath = this.backendFilePath(id);
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1638
|
+
if (replica) {
|
|
1639
|
+
try {
|
|
1640
|
+
await replica.instance.unlink(descPath);
|
|
1641
|
+
} catch {
|
|
1642
|
+
}
|
|
1643
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1644
|
+
try {
|
|
1645
|
+
await this.deleteFile(descPath);
|
|
1646
|
+
} catch {
|
|
1647
|
+
await this.removeBackendDescriptor(id);
|
|
1648
|
+
}
|
|
1649
|
+
this.syncEngine.removePair(replica.pairId);
|
|
1650
|
+
console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
|
|
1651
|
+
this.replicaBackends.delete(id);
|
|
1652
|
+
if (replica.instance?.dispose) {
|
|
1653
|
+
await replica.instance.dispose();
|
|
1654
|
+
}
|
|
1655
|
+
} else {
|
|
1656
|
+
console.log(`[ConfigRepo] removeBackend: "${id}" not in replicaBackends, cleaning up descriptor only`);
|
|
1657
|
+
try {
|
|
1658
|
+
await this.deleteFile(descPath);
|
|
1659
|
+
} catch {
|
|
1660
|
+
await this.removeBackendDescriptor(id);
|
|
1661
|
+
}
|
|
1550
1662
|
}
|
|
1663
|
+
this.schedulePostDeleteSync();
|
|
1551
1664
|
console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
|
|
1552
1665
|
}
|
|
1553
1666
|
// -----------------------------------------------------------------------
|
|
@@ -1907,16 +2020,16 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1907
2020
|
}
|
|
1908
2021
|
console.log(`[createConfigRepo] Migration complete`);
|
|
1909
2022
|
}
|
|
2023
|
+
let allBackends = await tempRepo.readAllBackendDescriptors();
|
|
1910
2024
|
if (options.backendInfo) {
|
|
1911
2025
|
const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
|
|
1912
|
-
const
|
|
1913
|
-
const hasReplica = allBackends2.some((b) => b.id === replicaId);
|
|
2026
|
+
const hasReplica = allBackends.some((b) => b.id === replicaId);
|
|
1914
2027
|
const newKey = backendDedupKey({
|
|
1915
2028
|
id: replicaId,
|
|
1916
2029
|
type: options.backendInfo.type,
|
|
1917
2030
|
options: options.backendInfo.options
|
|
1918
2031
|
});
|
|
1919
|
-
const dupConfig =
|
|
2032
|
+
const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
|
|
1920
2033
|
if (!hasReplica && !dupConfig) {
|
|
1921
2034
|
await tempRepo.writeBackendDescriptor({
|
|
1922
2035
|
id: replicaId,
|
|
@@ -1924,13 +2037,13 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1924
2037
|
options: options.backendInfo.options
|
|
1925
2038
|
});
|
|
1926
2039
|
console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
|
|
2040
|
+
allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
|
|
1927
2041
|
} else if (dupConfig) {
|
|
1928
2042
|
console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
|
|
1929
2043
|
} else {
|
|
1930
2044
|
console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
|
|
1931
2045
|
}
|
|
1932
2046
|
}
|
|
1933
|
-
const allBackends = await tempRepo.readAllBackendDescriptors();
|
|
1934
2047
|
console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
|
|
1935
2048
|
let nodeId = options.nodeId;
|
|
1936
2049
|
if (!nodeId) {
|
|
@@ -1951,8 +2064,8 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1951
2064
|
await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
|
|
1952
2065
|
await repo.load();
|
|
1953
2066
|
if (repo.replicaCount > 0) {
|
|
1954
|
-
console.log("[createConfigRepo]
|
|
1955
|
-
|
|
2067
|
+
console.log("[createConfigRepo] Starting background initial sync + dedup...");
|
|
2068
|
+
repo.startBackgroundSync();
|
|
1956
2069
|
}
|
|
1957
2070
|
repo.syncMetaToReplicas().catch((err) => {
|
|
1958
2071
|
console.error("[createConfigRepo] background syncMetaToReplicas failed:", err);
|
package/dist/index.mjs
CHANGED
|
@@ -532,6 +532,12 @@ var ConfigRepo = class {
|
|
|
532
532
|
pollIntervalMs;
|
|
533
533
|
/** Tombstone cache — avoids redundant reads within a single flush() cycle. */
|
|
534
534
|
tombstoneCache = null;
|
|
535
|
+
/**
|
|
536
|
+
* Tracks the background initial sync started by createConfigRepo().
|
|
537
|
+
* `flush()` and `dispose()` will await this if it hasn't completed yet,
|
|
538
|
+
* preventing concurrent syncEngine.syncAll() calls.
|
|
539
|
+
*/
|
|
540
|
+
initialSyncPromise = null;
|
|
535
541
|
constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs, cacheOptions) {
|
|
536
542
|
this.appId = appId;
|
|
537
543
|
this.nodeId = nodeId;
|
|
@@ -691,6 +697,10 @@ var ConfigRepo = class {
|
|
|
691
697
|
// -----------------------------------------------------------------------
|
|
692
698
|
async flush() {
|
|
693
699
|
this.assertNotDisposed();
|
|
700
|
+
if (this.initialSyncPromise) {
|
|
701
|
+
await this.initialSyncPromise;
|
|
702
|
+
this.initialSyncPromise = null;
|
|
703
|
+
}
|
|
694
704
|
await this.processTombstones();
|
|
695
705
|
const resultsMap = await this.syncEngine.syncAll();
|
|
696
706
|
this.invalidateTombstoneCache();
|
|
@@ -736,6 +746,25 @@ var ConfigRepo = class {
|
|
|
736
746
|
}
|
|
737
747
|
console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
|
|
738
748
|
this.invalidateTombstoneCache();
|
|
749
|
+
this.schedulePostDeleteSync();
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Background sync scheduled after a deleteFile() call.
|
|
753
|
+
* Uses a short debounce to coalesce multiple rapid deletions.
|
|
754
|
+
* If a sync is already in progress, the next poll will pick up the tombstone.
|
|
755
|
+
*/
|
|
756
|
+
postDeleteSyncTimer;
|
|
757
|
+
schedulePostDeleteSync() {
|
|
758
|
+
if (this.postDeleteSyncTimer) {
|
|
759
|
+
clearTimeout(this.postDeleteSyncTimer);
|
|
760
|
+
}
|
|
761
|
+
this.postDeleteSyncTimer = setTimeout(() => {
|
|
762
|
+
this.postDeleteSyncTimer = void 0;
|
|
763
|
+
if (this.disposed) return;
|
|
764
|
+
this.syncEngine.syncAll().catch((err) => {
|
|
765
|
+
console.warn("[ConfigRepo] post-delete sync failed:", err);
|
|
766
|
+
});
|
|
767
|
+
}, 500);
|
|
739
768
|
}
|
|
740
769
|
/**
|
|
741
770
|
* Read all tombstones from the primary backend.
|
|
@@ -861,6 +890,20 @@ var ConfigRepo = class {
|
|
|
861
890
|
await this.processTombstones();
|
|
862
891
|
this.syncEngine.watchAll();
|
|
863
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* Start the initial sync + dedup cycle in the background.
|
|
895
|
+
* `flush()` and `dispose()` will await this promise if it hasn't
|
|
896
|
+
* completed yet, preventing concurrent syncEngine operations.
|
|
897
|
+
*/
|
|
898
|
+
startBackgroundSync() {
|
|
899
|
+
this.initialSyncPromise = this.initialSyncAndDedup().then(() => {
|
|
900
|
+
console.log("[ConfigRepo] Background initial sync complete");
|
|
901
|
+
}).catch((err) => {
|
|
902
|
+
console.error("[ConfigRepo] Background initial sync failed:", err);
|
|
903
|
+
}).finally(() => {
|
|
904
|
+
this.initialSyncPromise = null;
|
|
905
|
+
});
|
|
906
|
+
}
|
|
864
907
|
/**
|
|
865
908
|
* After sync: mark each tombstone as confirmed by all replica backends.
|
|
866
909
|
*/
|
|
@@ -1008,7 +1051,15 @@ var ConfigRepo = class {
|
|
|
1008
1051
|
// -----------------------------------------------------------------------
|
|
1009
1052
|
async dispose() {
|
|
1010
1053
|
if (this.disposed) return;
|
|
1054
|
+
if (this.initialSyncPromise) {
|
|
1055
|
+
await this.initialSyncPromise;
|
|
1056
|
+
this.initialSyncPromise = null;
|
|
1057
|
+
}
|
|
1011
1058
|
this.disposed = true;
|
|
1059
|
+
if (this.postDeleteSyncTimer) {
|
|
1060
|
+
clearTimeout(this.postDeleteSyncTimer);
|
|
1061
|
+
this.postDeleteSyncTimer = void 0;
|
|
1062
|
+
}
|
|
1012
1063
|
this.syncEngine.dispose();
|
|
1013
1064
|
for (const [_id, replica] of this.replicaBackends) {
|
|
1014
1065
|
if (replica.instance?.dispose) {
|
|
@@ -1049,7 +1100,24 @@ var ConfigRepo = class {
|
|
|
1049
1100
|
{
|
|
1050
1101
|
direction: SyncDirection.BiDirectional,
|
|
1051
1102
|
conflictStrategy: "source-wins",
|
|
1052
|
-
pollIntervalMs
|
|
1103
|
+
pollIntervalMs,
|
|
1104
|
+
preSyncHook: async () => {
|
|
1105
|
+
try {
|
|
1106
|
+
this.invalidateTombstoneCache();
|
|
1107
|
+
await this.processTombstones();
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
postSyncHook: async () => {
|
|
1113
|
+
try {
|
|
1114
|
+
this.invalidateTombstoneCache();
|
|
1115
|
+
await this.processTombstones();
|
|
1116
|
+
await this.updateTombstoneConfirmations();
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1053
1121
|
},
|
|
1054
1122
|
"/"
|
|
1055
1123
|
);
|
|
@@ -1102,13 +1170,19 @@ var ConfigRepo = class {
|
|
|
1102
1170
|
const appDir = `/${this.appId}`;
|
|
1103
1171
|
try {
|
|
1104
1172
|
const files = await this.walkDir(appDir);
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1173
|
+
const readResults = await Promise.all(
|
|
1174
|
+
files.map(async (filePath) => {
|
|
1175
|
+
try {
|
|
1176
|
+
const raw = await this.cachedFS.readFile(filePath);
|
|
1177
|
+
const data = this.serializer.deserialize(toUint8Array(raw), filePath);
|
|
1178
|
+
return { filePath, data };
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
})
|
|
1183
|
+
);
|
|
1184
|
+
for (const item of readResults) {
|
|
1185
|
+
if (item) this.configCache.set(item.filePath, item.data);
|
|
1112
1186
|
}
|
|
1113
1187
|
} catch {
|
|
1114
1188
|
}
|
|
@@ -1195,17 +1269,23 @@ var ConfigRepo = class {
|
|
|
1195
1269
|
const current = stack.pop();
|
|
1196
1270
|
try {
|
|
1197
1271
|
const entries = await this.cachedFS.readdir(current);
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
results.push(fullPath);
|
|
1272
|
+
const statResults = await Promise.all(
|
|
1273
|
+
entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
|
|
1274
|
+
const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
|
|
1275
|
+
try {
|
|
1276
|
+
const stat = await this.cachedFS.stat(fullPath);
|
|
1277
|
+
return { fullPath, stat };
|
|
1278
|
+
} catch {
|
|
1279
|
+
return null;
|
|
1207
1280
|
}
|
|
1208
|
-
}
|
|
1281
|
+
})
|
|
1282
|
+
);
|
|
1283
|
+
for (const item of statResults) {
|
|
1284
|
+
if (!item) continue;
|
|
1285
|
+
if (item.stat.mode !== void 0 && (item.stat.mode & 16384) === 16384) {
|
|
1286
|
+
stack.push(item.fullPath);
|
|
1287
|
+
} else {
|
|
1288
|
+
results.push(item.fullPath);
|
|
1209
1289
|
}
|
|
1210
1290
|
}
|
|
1211
1291
|
} catch {
|
|
@@ -1246,29 +1326,38 @@ var ConfigRepo = class {
|
|
|
1246
1326
|
async readAllBackendDescriptors() {
|
|
1247
1327
|
try {
|
|
1248
1328
|
const entries = await this.cachedFS.readdir(BACKENDS_DIR);
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1329
|
+
const jsonEntries = entries.filter((e) => e.endsWith(".json"));
|
|
1330
|
+
const readResults = await Promise.all(
|
|
1331
|
+
jsonEntries.map(async (entry) => {
|
|
1332
|
+
const filePath = `${BACKENDS_DIR}/${entry}`;
|
|
1333
|
+
try {
|
|
1334
|
+
const raw = await this.cachedFS.readFile(filePath);
|
|
1335
|
+
const desc = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
|
|
1336
|
+
if (desc.id && desc.type) {
|
|
1337
|
+
let mtime = 0;
|
|
1338
|
+
try {
|
|
1339
|
+
const stat = await this.cachedFS.stat(filePath);
|
|
1340
|
+
mtime = stat.mtimeMs ?? 0;
|
|
1341
|
+
} catch {
|
|
1342
|
+
}
|
|
1343
|
+
return { kind: "ok", desc, mtime };
|
|
1344
|
+
} else {
|
|
1345
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} is missing id/type fields, marking for cleanup`);
|
|
1346
|
+
return { kind: "corrupt", filePath };
|
|
1263
1347
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
corruptFiles.push(filePath);
|
|
1348
|
+
} catch (parseErr) {
|
|
1349
|
+
console.warn(`[ConfigRepo] Backend descriptor ${entry} has corrupted JSON: ${parseErr}. Marking for cleanup.`);
|
|
1350
|
+
return { kind: "corrupt", filePath };
|
|
1268
1351
|
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1352
|
+
})
|
|
1353
|
+
);
|
|
1354
|
+
const items = [];
|
|
1355
|
+
const corruptFiles = [];
|
|
1356
|
+
for (const result of readResults) {
|
|
1357
|
+
if (result.kind === "ok") {
|
|
1358
|
+
items.push({ desc: result.desc, mtime: result.mtime });
|
|
1359
|
+
} else {
|
|
1360
|
+
corruptFiles.push(result.filePath);
|
|
1272
1361
|
}
|
|
1273
1362
|
}
|
|
1274
1363
|
for (const corruptPath of corruptFiles) {
|
|
@@ -1419,7 +1508,24 @@ var ConfigRepo = class {
|
|
|
1419
1508
|
syncable,
|
|
1420
1509
|
{
|
|
1421
1510
|
direction: SyncDirection.BiDirectional,
|
|
1422
|
-
conflictStrategy: "source-wins"
|
|
1511
|
+
conflictStrategy: "source-wins",
|
|
1512
|
+
preSyncHook: async () => {
|
|
1513
|
+
try {
|
|
1514
|
+
this.invalidateTombstoneCache();
|
|
1515
|
+
await this.processTombstones();
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
console.warn("[ConfigRepo] preSyncHook processTombstones failed:", err);
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
postSyncHook: async () => {
|
|
1521
|
+
try {
|
|
1522
|
+
this.invalidateTombstoneCache();
|
|
1523
|
+
await this.processTombstones();
|
|
1524
|
+
await this.updateTombstoneConfirmations();
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
console.warn("[ConfigRepo] postSyncHook tombstone processing failed:", err);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1423
1529
|
},
|
|
1424
1530
|
"/"
|
|
1425
1531
|
);
|
|
@@ -1438,26 +1544,33 @@ var ConfigRepo = class {
|
|
|
1438
1544
|
throw new Error("Cannot remove the local IndexedDB primary backend");
|
|
1439
1545
|
}
|
|
1440
1546
|
const replica = this.replicaBackends.get(id);
|
|
1441
|
-
if (!replica) {
|
|
1442
|
-
throw new Error(`Backend "${id}" is not a registered replica`);
|
|
1443
|
-
}
|
|
1444
1547
|
const descPath = this.backendFilePath(id);
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1548
|
+
if (replica) {
|
|
1549
|
+
try {
|
|
1550
|
+
await replica.instance.unlink(descPath);
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1554
|
+
try {
|
|
1555
|
+
await this.deleteFile(descPath);
|
|
1556
|
+
} catch {
|
|
1557
|
+
await this.removeBackendDescriptor(id);
|
|
1558
|
+
}
|
|
1559
|
+
this.syncEngine.removePair(replica.pairId);
|
|
1560
|
+
console.log(`[ConfigRepo] removeBackend: sync pair ${replica.pairId} removed`);
|
|
1561
|
+
this.replicaBackends.delete(id);
|
|
1562
|
+
if (replica.instance?.dispose) {
|
|
1563
|
+
await replica.instance.dispose();
|
|
1564
|
+
}
|
|
1565
|
+
} else {
|
|
1566
|
+
console.log(`[ConfigRepo] removeBackend: "${id}" not in replicaBackends, cleaning up descriptor only`);
|
|
1567
|
+
try {
|
|
1568
|
+
await this.deleteFile(descPath);
|
|
1569
|
+
} catch {
|
|
1570
|
+
await this.removeBackendDescriptor(id);
|
|
1571
|
+
}
|
|
1460
1572
|
}
|
|
1573
|
+
this.schedulePostDeleteSync();
|
|
1461
1574
|
console.log(`[ConfigRepo] removeBackend: ${id} removed (tombstone written, remote cleaned)`);
|
|
1462
1575
|
}
|
|
1463
1576
|
// -----------------------------------------------------------------------
|
|
@@ -1817,16 +1930,16 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1817
1930
|
}
|
|
1818
1931
|
console.log(`[createConfigRepo] Migration complete`);
|
|
1819
1932
|
}
|
|
1933
|
+
let allBackends = await tempRepo.readAllBackendDescriptors();
|
|
1820
1934
|
if (options.backendInfo) {
|
|
1821
1935
|
const replicaId = options.primaryBackendId || `${options.backendInfo.type}-replica`;
|
|
1822
|
-
const
|
|
1823
|
-
const hasReplica = allBackends2.some((b) => b.id === replicaId);
|
|
1936
|
+
const hasReplica = allBackends.some((b) => b.id === replicaId);
|
|
1824
1937
|
const newKey = backendDedupKey({
|
|
1825
1938
|
id: replicaId,
|
|
1826
1939
|
type: options.backendInfo.type,
|
|
1827
1940
|
options: options.backendInfo.options
|
|
1828
1941
|
});
|
|
1829
|
-
const dupConfig =
|
|
1942
|
+
const dupConfig = allBackends.find((b) => backendDedupKey(b) === newKey);
|
|
1830
1943
|
if (!hasReplica && !dupConfig) {
|
|
1831
1944
|
await tempRepo.writeBackendDescriptor({
|
|
1832
1945
|
id: replicaId,
|
|
@@ -1834,13 +1947,13 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1834
1947
|
options: options.backendInfo.options
|
|
1835
1948
|
});
|
|
1836
1949
|
console.log(`[createConfigRepo] Added replica backend: ${replicaId} (${options.backendInfo.type})`);
|
|
1950
|
+
allBackends = [...allBackends, { id: replicaId, type: options.backendInfo.type, options: options.backendInfo.options }];
|
|
1837
1951
|
} else if (dupConfig) {
|
|
1838
1952
|
console.log(`[createConfigRepo] Replica with same config already registered as "${dupConfig.id}", skipping`);
|
|
1839
1953
|
} else {
|
|
1840
1954
|
console.log(`[createConfigRepo] Replica ${replicaId} already registered`);
|
|
1841
1955
|
}
|
|
1842
1956
|
}
|
|
1843
|
-
const allBackends = await tempRepo.readAllBackendDescriptors();
|
|
1844
1957
|
console.log(`[createConfigRepo] Replica backends: ${allBackends.map((b) => b.id).join(", ") || "(none)"}`);
|
|
1845
1958
|
let nodeId = options.nodeId;
|
|
1846
1959
|
if (!nodeId) {
|
|
@@ -1861,8 +1974,8 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1861
1974
|
await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
|
|
1862
1975
|
await repo.load();
|
|
1863
1976
|
if (repo.replicaCount > 0) {
|
|
1864
|
-
console.log("[createConfigRepo]
|
|
1865
|
-
|
|
1977
|
+
console.log("[createConfigRepo] Starting background initial sync + dedup...");
|
|
1978
|
+
repo.startBackgroundSync();
|
|
1866
1979
|
}
|
|
1867
1980
|
repo.syncMetaToReplicas().catch((err) => {
|
|
1868
1981
|
console.error("[createConfigRepo] background syncMetaToReplicas failed:", err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-fs-config",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@zenfs/core": ">=2.3.0",
|
|
45
45
|
"@zenfs/dom": ">=1.0.0",
|
|
46
46
|
"zen-fs-cache": ">=1.0.0",
|
|
47
|
-
"zen-fs-sync": ">=0.
|
|
47
|
+
"zen-fs-sync": ">=0.4.6"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@zenfs/dom": {
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"typescript": "^5.9.3",
|
|
62
62
|
"vitest": "^1.6.1",
|
|
63
63
|
"zen-fs-cache": "^1.0.1",
|
|
64
|
-
"zen-fs-sync": "^0.4.
|
|
64
|
+
"zen-fs-sync": "^0.4.6"
|
|
65
65
|
}
|
|
66
66
|
}
|