zen-fs-config 0.5.28 → 0.5.30

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
@@ -1187,6 +1187,22 @@ Source file: /app-a/db.json (mtime=1700000000123)
1187
1187
  → Next sync: source.mtimeMs === target.mtimeMs → skip (no spurious copy)
1188
1188
  ```
1189
1189
 
1190
+ ### Backend mtime implementation status
1191
+
1192
+ The `writeFileWithMtime` mechanism (above) only preserves mtime if the target
1193
+ backend actually persists it. Implementation status per backend:
1194
+
1195
+ | Backend | Protocol can set mtime? | Strategy | `write` path writes sidecar? | Status |
1196
+ |---|---|---|---|---|
1197
+ | RemoteStorage (`zen-fs-remotestoragejs`) | No — server `Last-Modified` is the PUT arrival time, client cannot control it | `.mtime` sidecar | **Yes** — `writeFile` writes the sidecar when `preciseMtime` is enabled (default on) | Working |
1198
+ | Gitee (`zen-fs-gitee`) | No — Git has no per-file mtime | `.mtime` sidecar | **Yes** — `write()` / `writeSync()` and `writeFile` / `writeFileSync` persist it; `createSnapshot()` reads it (write fix since 1.2.17, snapshot fix since 1.2.18) | **Fixed in 1.2.18** |
1199
+
1200
+ Consequence of the Gitee gap: files pushed through the normal write path have
1201
+ no `.mtime` sidecar, so `stat()` falls back to the commit time. That commit
1202
+ time never equals the source's real mtime, so the sync engine re-PUTs
1203
+ content-identical files every cycle (empty / "no-diff" commits on Gitee). Fix
1204
+ tracked in `zen-fs-gitee/DESIGN.md` §5.
1205
+
1190
1206
  ## 13. Peer Dependencies
1191
1207
 
1192
1208
  | Package | Role | Version | Required |
package/dist/index.d.mts CHANGED
@@ -170,6 +170,12 @@ interface AppDataBackendDescriptor {
170
170
  /** Optional: reuse account fields from a config-sync backend. */
171
171
  accountBackendId?: string;
172
172
  description?: string;
173
+ /**
174
+ * Optional creation timestamp (ms). Used when deduplicating backends that
175
+ * point to the same endpoint — the OLDEST one is kept. Backends without
176
+ * this field are treated as legacy/oldest (e.g. a fixed `*-primary` id).
177
+ */
178
+ createdAt?: number;
173
179
  }
174
180
  /** Descriptor for a data-sync group referenced by a config-sync group. */
175
181
  interface AppDataGroupDescriptor {
package/dist/index.d.ts CHANGED
@@ -170,6 +170,12 @@ interface AppDataBackendDescriptor {
170
170
  /** Optional: reuse account fields from a config-sync backend. */
171
171
  accountBackendId?: string;
172
172
  description?: string;
173
+ /**
174
+ * Optional creation timestamp (ms). Used when deduplicating backends that
175
+ * point to the same endpoint — the OLDEST one is kept. Backends without
176
+ * this field are treated as legacy/oldest (e.g. a fixed `*-primary` id).
177
+ */
178
+ createdAt?: number;
173
179
  }
174
180
  /** Descriptor for a data-sync group referenced by a config-sync group. */
175
181
  interface AppDataGroupDescriptor {
package/dist/index.js CHANGED
@@ -1041,6 +1041,31 @@ function sortKeysDeep(obj) {
1041
1041
  function backendDedupKey(desc) {
1042
1042
  return `${desc.type}:${stableOptionsKey(desc.options)}`;
1043
1043
  }
1044
+ function backendAgeRank(b) {
1045
+ if (typeof b.createdAt === "number") return b.createdAt;
1046
+ const m = /-(\d{10,})$/.exec(b.id);
1047
+ if (m) return Number(m[1]);
1048
+ return -Infinity;
1049
+ }
1050
+ function dedupeAppDataBackends(backends) {
1051
+ const byKey = /* @__PURE__ */ new Map();
1052
+ const removed = [];
1053
+ for (const b of backends) {
1054
+ const key = backendDedupKey({ id: b.id, type: b.type, options: b.options });
1055
+ const existing = byKey.get(key);
1056
+ if (!existing) {
1057
+ byKey.set(key, b);
1058
+ continue;
1059
+ }
1060
+ if (backendAgeRank(b) < backendAgeRank(existing)) {
1061
+ removed.push(existing);
1062
+ byKey.set(key, b);
1063
+ } else {
1064
+ removed.push(b);
1065
+ }
1066
+ }
1067
+ return { kept: Array.from(byKey.values()), removed };
1068
+ }
1044
1069
  var ConfigRepo = class {
1045
1070
  appId;
1046
1071
  nodeId;
@@ -1471,25 +1496,29 @@ var ConfigRepo = class {
1471
1496
  async updateTombstoneConfirmations() {
1472
1497
  const tombstones = await this.readTombstones();
1473
1498
  if (tombstones.length === 0) return;
1474
- const backendsMeta = await this.getBackends();
1475
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
1499
+ let modified = 0;
1476
1500
  for (const tombstone of tombstones) {
1477
1501
  const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
1502
+ let changed = false;
1478
1503
  for (const replicaId of this.replicaBackends.keys()) {
1479
1504
  if (!tombstone.confirmedBy.includes(replicaId)) {
1480
1505
  tombstone.confirmedBy.push(replicaId);
1506
+ changed = true;
1481
1507
  }
1482
1508
  }
1483
- try {
1484
- await this.cachedFS.writeFile(
1485
- tombstonePath,
1486
- new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
1487
- );
1488
- } catch {
1509
+ if (changed) {
1510
+ try {
1511
+ await this.cachedFS.writeFile(
1512
+ tombstonePath,
1513
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
1514
+ );
1515
+ modified++;
1516
+ } catch {
1517
+ }
1489
1518
  }
1490
1519
  }
1491
- log2.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
1492
- this.invalidateTombstoneCache();
1520
+ log2.log(`[ConfigRepo] updateTombstoneConfirmations: ${modified} tombstone(s) updated`);
1521
+ if (modified > 0) this.invalidateTombstoneCache();
1493
1522
  }
1494
1523
  /**
1495
1524
  * GC: remove tombstones where all backends in backends.json have confirmed.
@@ -1502,8 +1531,7 @@ var ConfigRepo = class {
1502
1531
  async gcTombstones() {
1503
1532
  const tombstones = await this.readTombstones();
1504
1533
  if (tombstones.length === 0) return;
1505
- const backendsMeta = await this.getBackends();
1506
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
1534
+ const allBackendIds = [...this.replicaBackends.keys()];
1507
1535
  for (const tombstone of tombstones) {
1508
1536
  const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
1509
1537
  if (allConfirmed) {
@@ -2359,6 +2387,13 @@ var AppDataGroupImpl = class {
2359
2387
  }
2360
2388
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
2361
2389
  this.fs = createChrootFS(this.localFS, "/");
2390
+ const { kept, removed } = dedupeAppDataBackends(this._backends);
2391
+ if (removed.length > 0) {
2392
+ log2.warn(
2393
+ `[AppDataGroup:${this.groupId}] dedupe: removing ${removed.length} duplicate backend(s) (${removed.map((b) => b.id).join(", ")}) \u2014 keeping ${kept.map((b) => b.id).join(", ")}`
2394
+ );
2395
+ this._backends = kept;
2396
+ }
2362
2397
  for (const desc of this._backends) {
2363
2398
  try {
2364
2399
  const instance = await createBackend({ type: desc.type, options: desc.options });
@@ -2385,6 +2420,13 @@ var AppDataGroupImpl = class {
2385
2420
  log2.warn(`[AppDataGroup:${this.groupId}] Initial sync failed:`, err);
2386
2421
  }
2387
2422
  this.syncEngine.watchAll();
2423
+ if (removed.length > 0) {
2424
+ try {
2425
+ await this.parent.updateAppDataGroupDescriptor(this.groupId, this._backends);
2426
+ } catch (err) {
2427
+ log2.warn(`[AppDataGroup:${this.groupId}] dedupe: failed to persist deduplicated backends:`, err);
2428
+ }
2429
+ }
2388
2430
  }
2389
2431
  getSyncStatuses() {
2390
2432
  return this.syncEngine.getStatusAll();
@@ -2412,7 +2454,7 @@ var AppDataGroupImpl = class {
2412
2454
  },
2413
2455
  "/"
2414
2456
  );
2415
- const desc = { id, type, options, description };
2457
+ const desc = { id, type, options, description, createdAt: Date.now() };
2416
2458
  this.dataBackends.set(id, { instance, syncable, pairId: pair.pairId, desc });
2417
2459
  log2.log(`[AppDataGroup:${this.groupId}] addBackend: ${id} (${type}) connected, pair=${pair.pairId}`);
2418
2460
  try {
package/dist/index.mjs CHANGED
@@ -643,6 +643,31 @@ function sortKeysDeep(obj) {
643
643
  function backendDedupKey(desc) {
644
644
  return `${desc.type}:${stableOptionsKey(desc.options)}`;
645
645
  }
646
+ function backendAgeRank(b) {
647
+ if (typeof b.createdAt === "number") return b.createdAt;
648
+ const m = /-(\d{10,})$/.exec(b.id);
649
+ if (m) return Number(m[1]);
650
+ return -Infinity;
651
+ }
652
+ function dedupeAppDataBackends(backends) {
653
+ const byKey = /* @__PURE__ */ new Map();
654
+ const removed = [];
655
+ for (const b of backends) {
656
+ const key = backendDedupKey({ id: b.id, type: b.type, options: b.options });
657
+ const existing = byKey.get(key);
658
+ if (!existing) {
659
+ byKey.set(key, b);
660
+ continue;
661
+ }
662
+ if (backendAgeRank(b) < backendAgeRank(existing)) {
663
+ removed.push(existing);
664
+ byKey.set(key, b);
665
+ } else {
666
+ removed.push(b);
667
+ }
668
+ }
669
+ return { kept: Array.from(byKey.values()), removed };
670
+ }
646
671
  var ConfigRepo = class {
647
672
  appId;
648
673
  nodeId;
@@ -1073,25 +1098,29 @@ var ConfigRepo = class {
1073
1098
  async updateTombstoneConfirmations() {
1074
1099
  const tombstones = await this.readTombstones();
1075
1100
  if (tombstones.length === 0) return;
1076
- const backendsMeta = await this.getBackends();
1077
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
1101
+ let modified = 0;
1078
1102
  for (const tombstone of tombstones) {
1079
1103
  const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
1104
+ let changed = false;
1080
1105
  for (const replicaId of this.replicaBackends.keys()) {
1081
1106
  if (!tombstone.confirmedBy.includes(replicaId)) {
1082
1107
  tombstone.confirmedBy.push(replicaId);
1108
+ changed = true;
1083
1109
  }
1084
1110
  }
1085
- try {
1086
- await this.cachedFS.writeFile(
1087
- tombstonePath,
1088
- new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
1089
- );
1090
- } catch {
1111
+ if (changed) {
1112
+ try {
1113
+ await this.cachedFS.writeFile(
1114
+ tombstonePath,
1115
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
1116
+ );
1117
+ modified++;
1118
+ } catch {
1119
+ }
1091
1120
  }
1092
1121
  }
1093
- log2.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
1094
- this.invalidateTombstoneCache();
1122
+ log2.log(`[ConfigRepo] updateTombstoneConfirmations: ${modified} tombstone(s) updated`);
1123
+ if (modified > 0) this.invalidateTombstoneCache();
1095
1124
  }
1096
1125
  /**
1097
1126
  * GC: remove tombstones where all backends in backends.json have confirmed.
@@ -1104,8 +1133,7 @@ var ConfigRepo = class {
1104
1133
  async gcTombstones() {
1105
1134
  const tombstones = await this.readTombstones();
1106
1135
  if (tombstones.length === 0) return;
1107
- const backendsMeta = await this.getBackends();
1108
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
1136
+ const allBackendIds = [...this.replicaBackends.keys()];
1109
1137
  for (const tombstone of tombstones) {
1110
1138
  const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
1111
1139
  if (allConfirmed) {
@@ -1961,6 +1989,13 @@ var AppDataGroupImpl = class {
1961
1989
  }
1962
1990
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
1963
1991
  this.fs = createChrootFS(this.localFS, "/");
1992
+ const { kept, removed } = dedupeAppDataBackends(this._backends);
1993
+ if (removed.length > 0) {
1994
+ log2.warn(
1995
+ `[AppDataGroup:${this.groupId}] dedupe: removing ${removed.length} duplicate backend(s) (${removed.map((b) => b.id).join(", ")}) \u2014 keeping ${kept.map((b) => b.id).join(", ")}`
1996
+ );
1997
+ this._backends = kept;
1998
+ }
1964
1999
  for (const desc of this._backends) {
1965
2000
  try {
1966
2001
  const instance = await createBackend({ type: desc.type, options: desc.options });
@@ -1987,6 +2022,13 @@ var AppDataGroupImpl = class {
1987
2022
  log2.warn(`[AppDataGroup:${this.groupId}] Initial sync failed:`, err);
1988
2023
  }
1989
2024
  this.syncEngine.watchAll();
2025
+ if (removed.length > 0) {
2026
+ try {
2027
+ await this.parent.updateAppDataGroupDescriptor(this.groupId, this._backends);
2028
+ } catch (err) {
2029
+ log2.warn(`[AppDataGroup:${this.groupId}] dedupe: failed to persist deduplicated backends:`, err);
2030
+ }
2031
+ }
1990
2032
  }
1991
2033
  getSyncStatuses() {
1992
2034
  return this.syncEngine.getStatusAll();
@@ -2014,7 +2056,7 @@ var AppDataGroupImpl = class {
2014
2056
  },
2015
2057
  "/"
2016
2058
  );
2017
- const desc = { id, type, options, description };
2059
+ const desc = { id, type, options, description, createdAt: Date.now() };
2018
2060
  this.dataBackends.set(id, { instance, syncable, pairId: pair.pairId, desc });
2019
2061
  log2.log(`[AppDataGroup:${this.groupId}] addBackend: ${id} (${type}) connected, pair=${pair.pairId}`);
2020
2062
  try {
@@ -26918,6 +26918,31 @@ var ZenFSConfig = (() => {
26918
26918
  function backendDedupKey(desc) {
26919
26919
  return `${desc.type}:${stableOptionsKey(desc.options)}`;
26920
26920
  }
26921
+ function backendAgeRank(b) {
26922
+ if (typeof b.createdAt === "number") return b.createdAt;
26923
+ const m = /-(\d{10,})$/.exec(b.id);
26924
+ if (m) return Number(m[1]);
26925
+ return -Infinity;
26926
+ }
26927
+ function dedupeAppDataBackends(backends) {
26928
+ const byKey = /* @__PURE__ */ new Map();
26929
+ const removed = [];
26930
+ for (const b of backends) {
26931
+ const key = backendDedupKey({ id: b.id, type: b.type, options: b.options });
26932
+ const existing = byKey.get(key);
26933
+ if (!existing) {
26934
+ byKey.set(key, b);
26935
+ continue;
26936
+ }
26937
+ if (backendAgeRank(b) < backendAgeRank(existing)) {
26938
+ removed.push(existing);
26939
+ byKey.set(key, b);
26940
+ } else {
26941
+ removed.push(b);
26942
+ }
26943
+ }
26944
+ return { kept: Array.from(byKey.values()), removed };
26945
+ }
26921
26946
  var ConfigRepo = class {
26922
26947
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict, pollIntervalMs, cacheOptions) {
26923
26948
  __publicField(this, "appId");
@@ -27348,25 +27373,29 @@ var ZenFSConfig = (() => {
27348
27373
  async updateTombstoneConfirmations() {
27349
27374
  const tombstones = await this.readTombstones();
27350
27375
  if (tombstones.length === 0) return;
27351
- const backendsMeta = await this.getBackends();
27352
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
27376
+ let modified = 0;
27353
27377
  for (const tombstone of tombstones) {
27354
27378
  const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
27379
+ let changed = false;
27355
27380
  for (const replicaId of this.replicaBackends.keys()) {
27356
27381
  if (!tombstone.confirmedBy.includes(replicaId)) {
27357
27382
  tombstone.confirmedBy.push(replicaId);
27383
+ changed = true;
27358
27384
  }
27359
27385
  }
27360
- try {
27361
- await this.cachedFS.writeFile(
27362
- tombstonePath,
27363
- new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
27364
- );
27365
- } catch {
27386
+ if (changed) {
27387
+ try {
27388
+ await this.cachedFS.writeFile(
27389
+ tombstonePath,
27390
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
27391
+ );
27392
+ modified++;
27393
+ } catch {
27394
+ }
27366
27395
  }
27367
27396
  }
27368
- log6.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
27369
- this.invalidateTombstoneCache();
27397
+ log6.log(`[ConfigRepo] updateTombstoneConfirmations: ${modified} tombstone(s) updated`);
27398
+ if (modified > 0) this.invalidateTombstoneCache();
27370
27399
  }
27371
27400
  /**
27372
27401
  * GC: remove tombstones where all backends in backends.json have confirmed.
@@ -27379,8 +27408,7 @@ var ZenFSConfig = (() => {
27379
27408
  async gcTombstones() {
27380
27409
  const tombstones = await this.readTombstones();
27381
27410
  if (tombstones.length === 0) return;
27382
- const backendsMeta = await this.getBackends();
27383
- const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
27411
+ const allBackendIds = [...this.replicaBackends.keys()];
27384
27412
  for (const tombstone of tombstones) {
27385
27413
  const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
27386
27414
  if (allConfirmed) {
@@ -28236,6 +28264,13 @@ var ZenFSConfig = (() => {
28236
28264
  }
28237
28265
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
28238
28266
  this.fs = createChrootFS(this.localFS, "/");
28267
+ const { kept, removed } = dedupeAppDataBackends(this._backends);
28268
+ if (removed.length > 0) {
28269
+ log6.warn(
28270
+ `[AppDataGroup:${this.groupId}] dedupe: removing ${removed.length} duplicate backend(s) (${removed.map((b) => b.id).join(", ")}) \u2014 keeping ${kept.map((b) => b.id).join(", ")}`
28271
+ );
28272
+ this._backends = kept;
28273
+ }
28239
28274
  for (const desc of this._backends) {
28240
28275
  try {
28241
28276
  const instance = await createBackend({ type: desc.type, options: desc.options });
@@ -28262,6 +28297,13 @@ var ZenFSConfig = (() => {
28262
28297
  log6.warn(`[AppDataGroup:${this.groupId}] Initial sync failed:`, err2);
28263
28298
  }
28264
28299
  this.syncEngine.watchAll();
28300
+ if (removed.length > 0) {
28301
+ try {
28302
+ await this.parent.updateAppDataGroupDescriptor(this.groupId, this._backends);
28303
+ } catch (err2) {
28304
+ log6.warn(`[AppDataGroup:${this.groupId}] dedupe: failed to persist deduplicated backends:`, err2);
28305
+ }
28306
+ }
28265
28307
  }
28266
28308
  getSyncStatuses() {
28267
28309
  return this.syncEngine.getStatusAll();
@@ -28289,7 +28331,7 @@ var ZenFSConfig = (() => {
28289
28331
  },
28290
28332
  "/"
28291
28333
  );
28292
- const desc = { id, type, options, description };
28334
+ const desc = { id, type, options, description, createdAt: Date.now() };
28293
28335
  this.dataBackends.set(id, { instance, syncable, pairId: pair.pairId, desc });
28294
28336
  log6.log(`[AppDataGroup:${this.groupId}] addBackend: ${id} (${type}) connected, pair=${pair.pairId}`);
28295
28337
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
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",