zen-fs-config 0.5.5 → 0.5.6
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 +61 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +67 -48
- package/dist/index.mjs +67 -48
- package/package.json +1 -1
package/DESIGN.md
CHANGED
|
@@ -1147,6 +1147,67 @@ Application
|
|
|
1147
1147
|
→ Dispose temporary SyncPair
|
|
1148
1148
|
```
|
|
1149
1149
|
|
|
1150
|
+
### Mtime Preservation During Sync
|
|
1151
|
+
|
|
1152
|
+
**Problem**: When the sync engine copies a file from source to target, it calls `writeFile(path, data)`. The target backend sets its own mtime (typically `Date.now()`), losing the source file's original mtime. This causes the next sync cycle to detect a "modified" file (source mtime ≠ target mtime), triggering unnecessary copies on every sync.
|
|
1153
|
+
|
|
1154
|
+
**Solution**: An optional `writeFileWithMtime` method on the `SyncableFS` interface, with automatic fallback to `writeFile` when not implemented:
|
|
1155
|
+
|
|
1156
|
+
```typescript
|
|
1157
|
+
interface SyncableFS {
|
|
1158
|
+
// ... existing methods ...
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Optional: write file with precise mtime.
|
|
1162
|
+
* If implemented, the sync engine uses this instead of writeFile,
|
|
1163
|
+
* passing the source file's mtime so the target can preserve it.
|
|
1164
|
+
* Backends that don't support precise mtime should not implement this —
|
|
1165
|
+
* the sync engine falls back to plain writeFile.
|
|
1166
|
+
*/
|
|
1167
|
+
writeFileWithMtime?(path: string, data: string | Uint8Array, mtime: number): Promise<void>;
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
**Sync engine (`zen-fs-sync`)**: A central helper function handles the fallback:
|
|
1172
|
+
|
|
1173
|
+
```javascript
|
|
1174
|
+
async function writeFileWithMtimeFallback(fs, path, data, mtimeMs) {
|
|
1175
|
+
if (mtimeMs !== undefined && typeof fs.writeFileWithMtime === "function") {
|
|
1176
|
+
await fs.writeFileWithMtime(path, data, mtimeMs);
|
|
1177
|
+
} else {
|
|
1178
|
+
await fs.writeFile(path, data);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
This helper is used in `copyFile()`, `syncOneWay()`, and `writeFileBoth()`. The source file's mtime is obtained via `stat()` before writing, then passed through to the target.
|
|
1184
|
+
|
|
1185
|
+
**Adapters (`zen-fs-config`)**: All three `SyncableFS` adapters implement `writeFileWithMtime`:
|
|
1186
|
+
|
|
1187
|
+
| Adapter | Implementation |
|
|
1188
|
+
|---|---|
|
|
1189
|
+
| `backendToSyncableFS` | Passes `{ mtime }` as options to `backend.writeFile()` — the backend's `writeFile` calls `touch()` with the provided mtime |
|
|
1190
|
+
| `zenfsPromisesToSyncableFS` | Calls `promises.writeFile()` then `promises.utimes()` as a fallback (some VFS backends don't support mtime in writeFile) |
|
|
1191
|
+
| `cachedFSToSyncableFS` | Passes `{ mtime }` as options to `cached.writeFile()` — mtime flows through to the underlying backend |
|
|
1192
|
+
|
|
1193
|
+
**RemoteStorage backend (`zen-fs-remotestoragejs`)**: `writeFileWithMtime` delegates to `writeFile(path, data, { mtime })`, which writes the `.mtime` sidecar file to preserve millisecond-precision mtime (see RemoteStorage DESIGN.md §2 for details).
|
|
1194
|
+
|
|
1195
|
+
**Data flow**:
|
|
1196
|
+
```
|
|
1197
|
+
Source file: /app-a/db.json (mtime=1700000000123)
|
|
1198
|
+
│
|
|
1199
|
+
├─ sync engine: stat("/app-a/db.json") → mtimeMs=1700000000123
|
|
1200
|
+
├─ sync engine: readFile("/app-a/db.json") → data
|
|
1201
|
+
├─ sync engine: writeFileWithMtimeFallback(target, "/app-a/db.json", data, 1700000000123)
|
|
1202
|
+
│ ├─ target has writeFileWithMtime? → YES → target.writeFileWithMtime(path, data, 1700000000123)
|
|
1203
|
+
│ │ → backend.writeFile(path, data, { mtime: 1700000000123 })
|
|
1204
|
+
│ │ → touch(path, { mtimeMs: 1700000000123 })
|
|
1205
|
+
│ └─ target has writeFileWithMtime? → NO → target.writeFile(path, data) [fallback]
|
|
1206
|
+
│
|
|
1207
|
+
└─ Target file: /app-a/db.json (mtime=1700000000123) ← preserved!
|
|
1208
|
+
→ Next sync: source.mtimeMs === target.mtimeMs → skip (no spurious copy)
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1150
1211
|
## 13. Peer Dependencies
|
|
1151
1212
|
|
|
1152
1213
|
| Package | Role | Version | Required |
|
package/dist/index.d.mts
CHANGED
|
@@ -522,6 +522,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
522
522
|
readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
|
|
523
523
|
dispose(): Promise<void>;
|
|
524
524
|
setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
|
|
525
|
+
/** Write version sidecar for a config file (no-op for .version files). */
|
|
526
|
+
private writeVersionSidecar;
|
|
527
|
+
/** Delete version sidecar on a backend (no-op for .version files). */
|
|
528
|
+
private unlinkVersionSidecar;
|
|
529
|
+
/** Read version sidecar (returns null for .version files). */
|
|
530
|
+
private readVersionSidecar;
|
|
525
531
|
private persistConfig;
|
|
526
532
|
private reloadConfigCache;
|
|
527
533
|
private handleConflict;
|
|
@@ -683,8 +689,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
|
|
|
683
689
|
* /app-a/db.json → /app-a/.db.json.version
|
|
684
690
|
* /shared/flags.json → /shared/.flags.json.version
|
|
685
691
|
* /nodes/s1/env.json → /nodes/s1/.env.json.version
|
|
692
|
+
*
|
|
693
|
+
* Returns null for files that are already version sidecars (.version files),
|
|
694
|
+
* to prevent creating version-of-version files (e.g. ..db.json.version.version).
|
|
686
695
|
*/
|
|
687
|
-
declare function versionPathFor(configFilePath: string): string;
|
|
696
|
+
declare function versionPathFor(configFilePath: string): string | null;
|
|
688
697
|
/**
|
|
689
698
|
* Compute SHA-256 hash of a Uint8Array.
|
|
690
699
|
* Returns "sha256:" prefix + hex digest.
|
package/dist/index.d.ts
CHANGED
|
@@ -522,6 +522,12 @@ declare class ConfigRepo implements IConfigRepo {
|
|
|
522
522
|
readConflictBackup(conflictId: string, fileType: 'source' | 'target' | 'resolved'): Promise<string>;
|
|
523
523
|
dispose(): Promise<void>;
|
|
524
524
|
setupSync(backends: BackendDescriptor[], primaryBackendId: string, pollIntervalMs?: number): Promise<void>;
|
|
525
|
+
/** Write version sidecar for a config file (no-op for .version files). */
|
|
526
|
+
private writeVersionSidecar;
|
|
527
|
+
/** Delete version sidecar on a backend (no-op for .version files). */
|
|
528
|
+
private unlinkVersionSidecar;
|
|
529
|
+
/** Read version sidecar (returns null for .version files). */
|
|
530
|
+
private readVersionSidecar;
|
|
525
531
|
private persistConfig;
|
|
526
532
|
private reloadConfigCache;
|
|
527
533
|
private handleConflict;
|
|
@@ -683,8 +689,11 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
|
|
|
683
689
|
* /app-a/db.json → /app-a/.db.json.version
|
|
684
690
|
* /shared/flags.json → /shared/.flags.json.version
|
|
685
691
|
* /nodes/s1/env.json → /nodes/s1/.env.json.version
|
|
692
|
+
*
|
|
693
|
+
* Returns null for files that are already version sidecars (.version files),
|
|
694
|
+
* to prevent creating version-of-version files (e.g. ..db.json.version.version).
|
|
686
695
|
*/
|
|
687
|
-
declare function versionPathFor(configFilePath: string): string;
|
|
696
|
+
declare function versionPathFor(configFilePath: string): string | null;
|
|
688
697
|
/**
|
|
689
698
|
* Compute SHA-256 hash of a Uint8Array.
|
|
690
699
|
* Returns "sha256:" prefix + hex digest.
|
package/dist/index.js
CHANGED
|
@@ -250,6 +250,9 @@ function backendToSyncableFS(backend, name) {
|
|
|
250
250
|
async writeFile(path, data) {
|
|
251
251
|
return backend.writeFile(path, data);
|
|
252
252
|
},
|
|
253
|
+
async writeFileWithMtime(path, data, mtime) {
|
|
254
|
+
return backend.writeFile(path, data, { mtime });
|
|
255
|
+
},
|
|
253
256
|
async unlink(path) {
|
|
254
257
|
return backend.unlink(path);
|
|
255
258
|
},
|
|
@@ -373,7 +376,7 @@ async function wrapZenFSFileSystem(config) {
|
|
|
373
376
|
}
|
|
374
377
|
await isolatedFS.write(path, bytes, 0);
|
|
375
378
|
try {
|
|
376
|
-
await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
|
|
379
|
+
await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
|
|
377
380
|
} catch {
|
|
378
381
|
}
|
|
379
382
|
notifyChange();
|
|
@@ -454,6 +457,9 @@ function versionPathFor(configFilePath) {
|
|
|
454
457
|
const lastSlash = configFilePath.lastIndexOf("/");
|
|
455
458
|
const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
|
|
456
459
|
const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
|
|
460
|
+
if (fileName.endsWith(".version")) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
457
463
|
const versionFileName = `.${fileName}.version`;
|
|
458
464
|
return dir ? `${dir}/${versionFileName}` : versionFileName;
|
|
459
465
|
}
|
|
@@ -490,7 +496,7 @@ async function writeVersion(fs, versionFilePath, meta) {
|
|
|
490
496
|
}
|
|
491
497
|
async function incrementVersion(fs, configFilePath, newContent, author) {
|
|
492
498
|
const vPath = versionPathFor(configFilePath);
|
|
493
|
-
const prev = await readVersion(fs, vPath);
|
|
499
|
+
const prev = vPath ? await readVersion(fs, vPath) : null;
|
|
494
500
|
const hash = await sha256(newContent);
|
|
495
501
|
return {
|
|
496
502
|
version: (prev?.version ?? 0) + 1,
|
|
@@ -501,6 +507,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
|
|
|
501
507
|
}
|
|
502
508
|
async function verifyOrRepairVersion(fs, configFilePath, author) {
|
|
503
509
|
const vPath = versionPathFor(configFilePath);
|
|
510
|
+
if (!vPath) return null;
|
|
504
511
|
const existing = await readVersion(fs, vPath);
|
|
505
512
|
if (!existing) return null;
|
|
506
513
|
try {
|
|
@@ -772,9 +779,11 @@ var ConfigRepo = class {
|
|
|
772
779
|
} catch {
|
|
773
780
|
}
|
|
774
781
|
const versionPath = versionPathFor(normalizedPath);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
782
|
+
if (versionPath) {
|
|
783
|
+
try {
|
|
784
|
+
await this.cachedFS.unlink(versionPath);
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
778
787
|
}
|
|
779
788
|
console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
|
|
780
789
|
}
|
|
@@ -808,22 +817,27 @@ var ConfigRepo = class {
|
|
|
808
817
|
if (tombstones.length === 0) return;
|
|
809
818
|
console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
|
|
810
819
|
for (const tombstone of tombstones) {
|
|
820
|
+
const tVersionPath = versionPathFor(tombstone.path);
|
|
811
821
|
try {
|
|
812
822
|
await this.cachedFS.unlink(tombstone.path);
|
|
813
823
|
} catch {
|
|
814
824
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
825
|
+
if (tVersionPath) {
|
|
826
|
+
try {
|
|
827
|
+
await this.cachedFS.unlink(tVersionPath);
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
818
830
|
}
|
|
819
831
|
for (const [replicaId, replica] of this.replicaBackends) {
|
|
820
832
|
try {
|
|
821
833
|
await replica.instance.unlink(tombstone.path);
|
|
822
834
|
} catch {
|
|
823
835
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
836
|
+
if (tVersionPath) {
|
|
837
|
+
try {
|
|
838
|
+
await replica.instance.unlink(tVersionPath);
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
827
841
|
}
|
|
828
842
|
console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
|
|
829
843
|
}
|
|
@@ -932,7 +946,7 @@ var ConfigRepo = class {
|
|
|
932
946
|
bytes,
|
|
933
947
|
author
|
|
934
948
|
);
|
|
935
|
-
await
|
|
949
|
+
await this.writeVersionSidecar(configPath, version);
|
|
936
950
|
const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
|
|
937
951
|
const resolvedBackupPath = `${conflictDir}/resolved`;
|
|
938
952
|
const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
|
|
@@ -1033,13 +1047,34 @@ var ConfigRepo = class {
|
|
|
1033
1047
|
// -----------------------------------------------------------------------
|
|
1034
1048
|
// Internal — Persistence
|
|
1035
1049
|
// -----------------------------------------------------------------------
|
|
1050
|
+
/** Write version sidecar for a config file (no-op for .version files). */
|
|
1051
|
+
async writeVersionSidecar(configPath, version) {
|
|
1052
|
+
const vPath = versionPathFor(configPath);
|
|
1053
|
+
if (!vPath) return;
|
|
1054
|
+
await this.ensureDir(vPath);
|
|
1055
|
+
await writeVersion(this.fullFS, vPath, version);
|
|
1056
|
+
}
|
|
1057
|
+
/** Delete version sidecar on a backend (no-op for .version files). */
|
|
1058
|
+
async unlinkVersionSidecar(fs, configPath) {
|
|
1059
|
+
const vPath = versionPathFor(configPath);
|
|
1060
|
+
if (!vPath) return;
|
|
1061
|
+
try {
|
|
1062
|
+
await fs.unlink(vPath);
|
|
1063
|
+
} catch {
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/** Read version sidecar (returns null for .version files). */
|
|
1067
|
+
async readVersionSidecar(configPath) {
|
|
1068
|
+
const vPath = versionPathFor(configPath);
|
|
1069
|
+
if (!vPath) return null;
|
|
1070
|
+
return readVersion(this.fullFS, vPath);
|
|
1071
|
+
}
|
|
1036
1072
|
async persistConfig(fullPath, bytes) {
|
|
1037
1073
|
await this.ensureDir(fullPath);
|
|
1038
1074
|
await this.cachedFS.writeFile(fullPath, bytes);
|
|
1039
1075
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1040
1076
|
const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
|
|
1041
|
-
await this.
|
|
1042
|
-
await writeVersion(this.fullFS, versionPathFor(fullPath), version);
|
|
1077
|
+
await this.writeVersionSidecar(fullPath, version);
|
|
1043
1078
|
}
|
|
1044
1079
|
async reloadConfigCache() {
|
|
1045
1080
|
const appDir = `/${this.appId}`;
|
|
@@ -1077,7 +1112,7 @@ var ConfigRepo = class {
|
|
|
1077
1112
|
);
|
|
1078
1113
|
let sourceVersion = 0;
|
|
1079
1114
|
try {
|
|
1080
|
-
const srcVer = await
|
|
1115
|
+
const srcVer = await this.readVersionSidecar(conflict.path);
|
|
1081
1116
|
if (srcVer) sourceVersion = srcVer.version;
|
|
1082
1117
|
} catch {
|
|
1083
1118
|
}
|
|
@@ -1162,8 +1197,7 @@ var ConfigRepo = class {
|
|
|
1162
1197
|
await this.cachedFS.writeFile(path, bytes);
|
|
1163
1198
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1164
1199
|
const version = await incrementVersion(this.fullFS, path, bytes, author);
|
|
1165
|
-
await this.
|
|
1166
|
-
await writeVersion(this.fullFS, versionPathFor(path), version);
|
|
1200
|
+
await this.writeVersionSidecar(path, version);
|
|
1167
1201
|
}
|
|
1168
1202
|
async readMetaFile(path) {
|
|
1169
1203
|
try {
|
|
@@ -1221,10 +1255,7 @@ var ConfigRepo = class {
|
|
|
1221
1255
|
await replica.instance.unlink(corruptPath);
|
|
1222
1256
|
} catch {
|
|
1223
1257
|
}
|
|
1224
|
-
|
|
1225
|
-
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1226
|
-
} catch {
|
|
1227
|
-
}
|
|
1258
|
+
await this.unlinkVersionSidecar(replica.instance, corruptPath);
|
|
1228
1259
|
}
|
|
1229
1260
|
try {
|
|
1230
1261
|
await this.deleteFile(corruptPath);
|
|
@@ -1233,10 +1264,7 @@ var ConfigRepo = class {
|
|
|
1233
1264
|
await this.cachedFS.unlink(corruptPath);
|
|
1234
1265
|
} catch {
|
|
1235
1266
|
}
|
|
1236
|
-
|
|
1237
|
-
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1238
|
-
} catch {
|
|
1239
|
-
}
|
|
1267
|
+
await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
|
|
1240
1268
|
}
|
|
1241
1269
|
}
|
|
1242
1270
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -1266,10 +1294,7 @@ var ConfigRepo = class {
|
|
|
1266
1294
|
await replica.instance.unlink(descPath);
|
|
1267
1295
|
} catch {
|
|
1268
1296
|
}
|
|
1269
|
-
|
|
1270
|
-
await replica.instance.unlink(versionPathFor(descPath));
|
|
1271
|
-
} catch {
|
|
1272
|
-
}
|
|
1297
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1273
1298
|
}
|
|
1274
1299
|
try {
|
|
1275
1300
|
await this.deleteFile(descPath);
|
|
@@ -1291,8 +1316,7 @@ var ConfigRepo = class {
|
|
|
1291
1316
|
await this.cachedFS.writeFile(path, bytes);
|
|
1292
1317
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1293
1318
|
const version = await incrementVersion(this.fullFS, path, bytes, author);
|
|
1294
|
-
await this.
|
|
1295
|
-
await writeVersion(this.fullFS, versionPathFor(path), version);
|
|
1319
|
+
await this.writeVersionSidecar(path, version);
|
|
1296
1320
|
}
|
|
1297
1321
|
/** Remove a single backend descriptor file + its version sidecar */
|
|
1298
1322
|
async removeBackendDescriptor(id) {
|
|
@@ -1301,10 +1325,7 @@ var ConfigRepo = class {
|
|
|
1301
1325
|
await this.cachedFS.unlink(path);
|
|
1302
1326
|
} catch {
|
|
1303
1327
|
}
|
|
1304
|
-
|
|
1305
|
-
await this.cachedFS.unlink(versionPathFor(path));
|
|
1306
|
-
} catch {
|
|
1307
|
-
}
|
|
1328
|
+
await this.unlinkVersionSidecar(this.cachedFS, path);
|
|
1308
1329
|
}
|
|
1309
1330
|
// -----------------------------------------------------------------------
|
|
1310
1331
|
// IConfigRepo — Meta file access (no chroot)
|
|
@@ -1403,10 +1424,7 @@ var ConfigRepo = class {
|
|
|
1403
1424
|
await replica.instance.unlink(descPath);
|
|
1404
1425
|
} catch {
|
|
1405
1426
|
}
|
|
1406
|
-
|
|
1407
|
-
await replica.instance.unlink(versionPathFor(descPath));
|
|
1408
|
-
} catch {
|
|
1409
|
-
}
|
|
1427
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1410
1428
|
try {
|
|
1411
1429
|
await this.deleteFile(descPath);
|
|
1412
1430
|
} catch {
|
|
@@ -1502,8 +1520,7 @@ var ConfigRepo = class {
|
|
|
1502
1520
|
await this.cachedFS.writeFile(descPath, bytes);
|
|
1503
1521
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1504
1522
|
const version = await incrementVersion(this.fullFS, descPath, bytes, author);
|
|
1505
|
-
await this.
|
|
1506
|
-
await writeVersion(this.fullFS, versionPathFor(descPath), version);
|
|
1523
|
+
await this.writeVersionSidecar(descPath, version);
|
|
1507
1524
|
this.appDataGroups.set(id, group);
|
|
1508
1525
|
console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
|
|
1509
1526
|
return group;
|
|
@@ -1563,10 +1580,7 @@ var ConfigRepo = class {
|
|
|
1563
1580
|
await this.cachedFS.unlink(descPath);
|
|
1564
1581
|
} catch {
|
|
1565
1582
|
}
|
|
1566
|
-
|
|
1567
|
-
await this.cachedFS.unlink(versionPathFor(descPath));
|
|
1568
|
-
} catch {
|
|
1569
|
-
}
|
|
1583
|
+
await this.unlinkVersionSidecar(this.cachedFS, descPath);
|
|
1570
1584
|
console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
|
|
1571
1585
|
}
|
|
1572
1586
|
async listAccountBackends() {
|
|
@@ -1764,9 +1778,14 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1764
1778
|
await cachedFS.unlink(BACKENDS_FILE);
|
|
1765
1779
|
} catch {
|
|
1766
1780
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1781
|
+
{
|
|
1782
|
+
const vPath = versionPathFor(BACKENDS_FILE);
|
|
1783
|
+
if (vPath) {
|
|
1784
|
+
try {
|
|
1785
|
+
await cachedFS.unlink(vPath);
|
|
1786
|
+
} catch {
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1770
1789
|
}
|
|
1771
1790
|
console.log(`[createConfigRepo] Migration complete`);
|
|
1772
1791
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -193,6 +193,9 @@ function backendToSyncableFS(backend, name) {
|
|
|
193
193
|
async writeFile(path, data) {
|
|
194
194
|
return backend.writeFile(path, data);
|
|
195
195
|
},
|
|
196
|
+
async writeFileWithMtime(path, data, mtime) {
|
|
197
|
+
return backend.writeFile(path, data, { mtime });
|
|
198
|
+
},
|
|
196
199
|
async unlink(path) {
|
|
197
200
|
return backend.unlink(path);
|
|
198
201
|
},
|
|
@@ -316,7 +319,7 @@ async function wrapZenFSFileSystem(config) {
|
|
|
316
319
|
}
|
|
317
320
|
await isolatedFS.write(path, bytes, 0);
|
|
318
321
|
try {
|
|
319
|
-
await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: Date.now() });
|
|
322
|
+
await isolatedFS.touch(path, { size: bytes.byteLength, mtimeMs: _options?.mtime ?? Date.now() });
|
|
320
323
|
} catch {
|
|
321
324
|
}
|
|
322
325
|
notifyChange();
|
|
@@ -397,6 +400,9 @@ function versionPathFor(configFilePath) {
|
|
|
397
400
|
const lastSlash = configFilePath.lastIndexOf("/");
|
|
398
401
|
const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
|
|
399
402
|
const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
|
|
403
|
+
if (fileName.endsWith(".version")) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
400
406
|
const versionFileName = `.${fileName}.version`;
|
|
401
407
|
return dir ? `${dir}/${versionFileName}` : versionFileName;
|
|
402
408
|
}
|
|
@@ -433,7 +439,7 @@ async function writeVersion(fs, versionFilePath, meta) {
|
|
|
433
439
|
}
|
|
434
440
|
async function incrementVersion(fs, configFilePath, newContent, author) {
|
|
435
441
|
const vPath = versionPathFor(configFilePath);
|
|
436
|
-
const prev = await readVersion(fs, vPath);
|
|
442
|
+
const prev = vPath ? await readVersion(fs, vPath) : null;
|
|
437
443
|
const hash = await sha256(newContent);
|
|
438
444
|
return {
|
|
439
445
|
version: (prev?.version ?? 0) + 1,
|
|
@@ -444,6 +450,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
|
|
|
444
450
|
}
|
|
445
451
|
async function verifyOrRepairVersion(fs, configFilePath, author) {
|
|
446
452
|
const vPath = versionPathFor(configFilePath);
|
|
453
|
+
if (!vPath) return null;
|
|
447
454
|
const existing = await readVersion(fs, vPath);
|
|
448
455
|
if (!existing) return null;
|
|
449
456
|
try {
|
|
@@ -715,9 +722,11 @@ var ConfigRepo = class {
|
|
|
715
722
|
} catch {
|
|
716
723
|
}
|
|
717
724
|
const versionPath = versionPathFor(normalizedPath);
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
725
|
+
if (versionPath) {
|
|
726
|
+
try {
|
|
727
|
+
await this.cachedFS.unlink(versionPath);
|
|
728
|
+
} catch {
|
|
729
|
+
}
|
|
721
730
|
}
|
|
722
731
|
console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
|
|
723
732
|
}
|
|
@@ -751,22 +760,27 @@ var ConfigRepo = class {
|
|
|
751
760
|
if (tombstones.length === 0) return;
|
|
752
761
|
console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
|
|
753
762
|
for (const tombstone of tombstones) {
|
|
763
|
+
const tVersionPath = versionPathFor(tombstone.path);
|
|
754
764
|
try {
|
|
755
765
|
await this.cachedFS.unlink(tombstone.path);
|
|
756
766
|
} catch {
|
|
757
767
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
768
|
+
if (tVersionPath) {
|
|
769
|
+
try {
|
|
770
|
+
await this.cachedFS.unlink(tVersionPath);
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
761
773
|
}
|
|
762
774
|
for (const [replicaId, replica] of this.replicaBackends) {
|
|
763
775
|
try {
|
|
764
776
|
await replica.instance.unlink(tombstone.path);
|
|
765
777
|
} catch {
|
|
766
778
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
779
|
+
if (tVersionPath) {
|
|
780
|
+
try {
|
|
781
|
+
await replica.instance.unlink(tVersionPath);
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
770
784
|
}
|
|
771
785
|
console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
|
|
772
786
|
}
|
|
@@ -875,7 +889,7 @@ var ConfigRepo = class {
|
|
|
875
889
|
bytes,
|
|
876
890
|
author
|
|
877
891
|
);
|
|
878
|
-
await
|
|
892
|
+
await this.writeVersionSidecar(configPath, version);
|
|
879
893
|
const conflictDir = metaPath.substring(0, metaPath.lastIndexOf("/"));
|
|
880
894
|
const resolvedBackupPath = `${conflictDir}/resolved`;
|
|
881
895
|
const resolvedBytes = typeof mergedContent === "string" ? new TextEncoder().encode(mergedContent) : new TextEncoder().encode(JSON.stringify(mergedContent, null, 2));
|
|
@@ -976,13 +990,34 @@ var ConfigRepo = class {
|
|
|
976
990
|
// -----------------------------------------------------------------------
|
|
977
991
|
// Internal — Persistence
|
|
978
992
|
// -----------------------------------------------------------------------
|
|
993
|
+
/** Write version sidecar for a config file (no-op for .version files). */
|
|
994
|
+
async writeVersionSidecar(configPath, version) {
|
|
995
|
+
const vPath = versionPathFor(configPath);
|
|
996
|
+
if (!vPath) return;
|
|
997
|
+
await this.ensureDir(vPath);
|
|
998
|
+
await writeVersion(this.fullFS, vPath, version);
|
|
999
|
+
}
|
|
1000
|
+
/** Delete version sidecar on a backend (no-op for .version files). */
|
|
1001
|
+
async unlinkVersionSidecar(fs, configPath) {
|
|
1002
|
+
const vPath = versionPathFor(configPath);
|
|
1003
|
+
if (!vPath) return;
|
|
1004
|
+
try {
|
|
1005
|
+
await fs.unlink(vPath);
|
|
1006
|
+
} catch {
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/** Read version sidecar (returns null for .version files). */
|
|
1010
|
+
async readVersionSidecar(configPath) {
|
|
1011
|
+
const vPath = versionPathFor(configPath);
|
|
1012
|
+
if (!vPath) return null;
|
|
1013
|
+
return readVersion(this.fullFS, vPath);
|
|
1014
|
+
}
|
|
979
1015
|
async persistConfig(fullPath, bytes) {
|
|
980
1016
|
await this.ensureDir(fullPath);
|
|
981
1017
|
await this.cachedFS.writeFile(fullPath, bytes);
|
|
982
1018
|
const author = `${this.appId}/${this.nodeId}`;
|
|
983
1019
|
const version = await incrementVersion(this.fullFS, fullPath, bytes, author);
|
|
984
|
-
await this.
|
|
985
|
-
await writeVersion(this.fullFS, versionPathFor(fullPath), version);
|
|
1020
|
+
await this.writeVersionSidecar(fullPath, version);
|
|
986
1021
|
}
|
|
987
1022
|
async reloadConfigCache() {
|
|
988
1023
|
const appDir = `/${this.appId}`;
|
|
@@ -1020,7 +1055,7 @@ var ConfigRepo = class {
|
|
|
1020
1055
|
);
|
|
1021
1056
|
let sourceVersion = 0;
|
|
1022
1057
|
try {
|
|
1023
|
-
const srcVer = await
|
|
1058
|
+
const srcVer = await this.readVersionSidecar(conflict.path);
|
|
1024
1059
|
if (srcVer) sourceVersion = srcVer.version;
|
|
1025
1060
|
} catch {
|
|
1026
1061
|
}
|
|
@@ -1105,8 +1140,7 @@ var ConfigRepo = class {
|
|
|
1105
1140
|
await this.cachedFS.writeFile(path, bytes);
|
|
1106
1141
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1107
1142
|
const version = await incrementVersion(this.fullFS, path, bytes, author);
|
|
1108
|
-
await this.
|
|
1109
|
-
await writeVersion(this.fullFS, versionPathFor(path), version);
|
|
1143
|
+
await this.writeVersionSidecar(path, version);
|
|
1110
1144
|
}
|
|
1111
1145
|
async readMetaFile(path) {
|
|
1112
1146
|
try {
|
|
@@ -1164,10 +1198,7 @@ var ConfigRepo = class {
|
|
|
1164
1198
|
await replica.instance.unlink(corruptPath);
|
|
1165
1199
|
} catch {
|
|
1166
1200
|
}
|
|
1167
|
-
|
|
1168
|
-
await replica.instance.unlink(versionPathFor(corruptPath));
|
|
1169
|
-
} catch {
|
|
1170
|
-
}
|
|
1201
|
+
await this.unlinkVersionSidecar(replica.instance, corruptPath);
|
|
1171
1202
|
}
|
|
1172
1203
|
try {
|
|
1173
1204
|
await this.deleteFile(corruptPath);
|
|
@@ -1176,10 +1207,7 @@ var ConfigRepo = class {
|
|
|
1176
1207
|
await this.cachedFS.unlink(corruptPath);
|
|
1177
1208
|
} catch {
|
|
1178
1209
|
}
|
|
1179
|
-
|
|
1180
|
-
await this.cachedFS.unlink(versionPathFor(corruptPath));
|
|
1181
|
-
} catch {
|
|
1182
|
-
}
|
|
1210
|
+
await this.unlinkVersionSidecar(this.cachedFS, corruptPath);
|
|
1183
1211
|
}
|
|
1184
1212
|
}
|
|
1185
1213
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -1209,10 +1237,7 @@ var ConfigRepo = class {
|
|
|
1209
1237
|
await replica.instance.unlink(descPath);
|
|
1210
1238
|
} catch {
|
|
1211
1239
|
}
|
|
1212
|
-
|
|
1213
|
-
await replica.instance.unlink(versionPathFor(descPath));
|
|
1214
|
-
} catch {
|
|
1215
|
-
}
|
|
1240
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1216
1241
|
}
|
|
1217
1242
|
try {
|
|
1218
1243
|
await this.deleteFile(descPath);
|
|
@@ -1234,8 +1259,7 @@ var ConfigRepo = class {
|
|
|
1234
1259
|
await this.cachedFS.writeFile(path, bytes);
|
|
1235
1260
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1236
1261
|
const version = await incrementVersion(this.fullFS, path, bytes, author);
|
|
1237
|
-
await this.
|
|
1238
|
-
await writeVersion(this.fullFS, versionPathFor(path), version);
|
|
1262
|
+
await this.writeVersionSidecar(path, version);
|
|
1239
1263
|
}
|
|
1240
1264
|
/** Remove a single backend descriptor file + its version sidecar */
|
|
1241
1265
|
async removeBackendDescriptor(id) {
|
|
@@ -1244,10 +1268,7 @@ var ConfigRepo = class {
|
|
|
1244
1268
|
await this.cachedFS.unlink(path);
|
|
1245
1269
|
} catch {
|
|
1246
1270
|
}
|
|
1247
|
-
|
|
1248
|
-
await this.cachedFS.unlink(versionPathFor(path));
|
|
1249
|
-
} catch {
|
|
1250
|
-
}
|
|
1271
|
+
await this.unlinkVersionSidecar(this.cachedFS, path);
|
|
1251
1272
|
}
|
|
1252
1273
|
// -----------------------------------------------------------------------
|
|
1253
1274
|
// IConfigRepo — Meta file access (no chroot)
|
|
@@ -1346,10 +1367,7 @@ var ConfigRepo = class {
|
|
|
1346
1367
|
await replica.instance.unlink(descPath);
|
|
1347
1368
|
} catch {
|
|
1348
1369
|
}
|
|
1349
|
-
|
|
1350
|
-
await replica.instance.unlink(versionPathFor(descPath));
|
|
1351
|
-
} catch {
|
|
1352
|
-
}
|
|
1370
|
+
await this.unlinkVersionSidecar(replica.instance, descPath);
|
|
1353
1371
|
try {
|
|
1354
1372
|
await this.deleteFile(descPath);
|
|
1355
1373
|
} catch {
|
|
@@ -1445,8 +1463,7 @@ var ConfigRepo = class {
|
|
|
1445
1463
|
await this.cachedFS.writeFile(descPath, bytes);
|
|
1446
1464
|
const author = `${this.appId}/${this.nodeId}`;
|
|
1447
1465
|
const version = await incrementVersion(this.fullFS, descPath, bytes, author);
|
|
1448
|
-
await this.
|
|
1449
|
-
await writeVersion(this.fullFS, versionPathFor(descPath), version);
|
|
1466
|
+
await this.writeVersionSidecar(descPath, version);
|
|
1450
1467
|
this.appDataGroups.set(id, group);
|
|
1451
1468
|
console.log(`[ConfigRepo] createAppDataGroup: "${id}" created`);
|
|
1452
1469
|
return group;
|
|
@@ -1506,10 +1523,7 @@ var ConfigRepo = class {
|
|
|
1506
1523
|
await this.cachedFS.unlink(descPath);
|
|
1507
1524
|
} catch {
|
|
1508
1525
|
}
|
|
1509
|
-
|
|
1510
|
-
await this.cachedFS.unlink(versionPathFor(descPath));
|
|
1511
|
-
} catch {
|
|
1512
|
-
}
|
|
1526
|
+
await this.unlinkVersionSidecar(this.cachedFS, descPath);
|
|
1513
1527
|
console.log(`[ConfigRepo] removeAppDataGroup: "${id}" removed`);
|
|
1514
1528
|
}
|
|
1515
1529
|
async listAccountBackends() {
|
|
@@ -1707,9 +1721,14 @@ async function createConfigRepo(appId, options = {}) {
|
|
|
1707
1721
|
await cachedFS.unlink(BACKENDS_FILE);
|
|
1708
1722
|
} catch {
|
|
1709
1723
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1724
|
+
{
|
|
1725
|
+
const vPath = versionPathFor(BACKENDS_FILE);
|
|
1726
|
+
if (vPath) {
|
|
1727
|
+
try {
|
|
1728
|
+
await cachedFS.unlink(vPath);
|
|
1729
|
+
} catch {
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1713
1732
|
}
|
|
1714
1733
|
console.log(`[createConfigRepo] Migration complete`);
|
|
1715
1734
|
}
|