zen-fs-config 0.5.31 → 0.5.32

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 CHANGED
@@ -4,6 +4,65 @@ import * as node_fs from 'node:fs';
4
4
  import * as node_path from 'node:path';
5
5
  import { SyncMapStore, SyncMapTransaction } from '@zenfs/core';
6
6
 
7
+ /**
8
+ * zen-fs-config — mtime sidecar cleanup
9
+ *
10
+ * Backends that cannot store a precise mtime natively (RemoteStorage, Gitee,
11
+ * …) persist it in a `.mtime` sidecar next to each file
12
+ * (`data.json` → `.data.json.mtime`). A sidecar is backend-internal metadata:
13
+ * it belongs to the backend that produced it and must never be handed out by
14
+ * `readdir()`/`createSnapshot()`.
15
+ *
16
+ * Older builds did leak them, and once a sidecar lands in the local primary
17
+ * (IndexedDB on browser, Folder on Node) it stays there forever — zen-fs-sync
18
+ * deliberately skips `.mtime` paths on both sides, so nothing ever removes
19
+ * them, and every walk logs:
20
+ *
21
+ * [zen-fs-sync] mtime sidecar leaked from backend "local-idb" …
22
+ *
23
+ * This module walks the local primary and deletes those leaked files. It never
24
+ * touches replica (remote) backends: their sidecars are live metadata, and
25
+ * deleting them would cost network calls and lose mtime precision.
26
+ */
27
+ /** Minimal async FS surface needed for the purge. */
28
+ interface PurgeableFS {
29
+ readdir(path: string): Promise<string[]>;
30
+ stat(path: string): Promise<{
31
+ mode?: number;
32
+ }>;
33
+ unlink(path: string): Promise<void>;
34
+ }
35
+ interface PurgeMtimeOptions {
36
+ /** Root to scan. Default: `/` (the whole local primary). */
37
+ root?: string;
38
+ /** Report what would be deleted without unlinking anything. */
39
+ dryRun?: boolean;
40
+ }
41
+ interface MtimePurgeResult {
42
+ /** Files visited during the walk (sidecars included). */
43
+ scanned: number;
44
+ /** Sidecars deleted — or, in dryRun, that would be deleted. */
45
+ removed: string[];
46
+ /** Sidecars that could not be deleted (unlink failed). */
47
+ failed: string[];
48
+ }
49
+ /**
50
+ * True when a file name is an mtime sidecar.
51
+ *
52
+ * Uses the same rule as zen-fs-sync's walker (`path.endsWith('.mtime')`), so
53
+ * the pathological nested form (`.foo.mtime.mtime`) is covered too.
54
+ */
55
+ declare function isMtimeSidecar(fileName: string): boolean;
56
+ /**
57
+ * Delete every `.mtime` sidecar stored in a local primary backend.
58
+ *
59
+ * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`,
60
+ * because sidecars live next to their data file and are themselves dotfiles.
61
+ * Unreadable entries are skipped; failures on individual files are collected
62
+ * in `failed` instead of aborting the walk.
63
+ */
64
+ declare function purgeMtimeSidecars(fs: PurgeableFS, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
65
+
7
66
  /** A single backend in the topology. */
8
67
  interface BackendDescriptor {
9
68
  /** Unique identifier within this config repo (e.g., "local-idb"). */
@@ -159,6 +218,17 @@ interface ConfigRepoOptions {
159
218
  * Default: 1800000 (30 minutes).
160
219
  */
161
220
  syncPollIntervalMs?: number;
221
+ /**
222
+ * Delete leaked `.mtime` sidecars from the local primary on startup.
223
+ *
224
+ * Backends that keep a precise mtime out-of-band (RemoteStorage, Gitee…)
225
+ * write a `.mtime` file next to each data file. Older builds copied those
226
+ * backend-internal files into the local primary, where sync ignores them —
227
+ * so they linger forever and warn on every walk.
228
+ *
229
+ * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
+ */
231
+ purgeMtimeSidecars?: boolean;
162
232
  }
163
233
  /** Type of sync group. */
164
234
  type SyncGroupType = 'config-sync' | 'data-sync';
@@ -340,6 +410,17 @@ interface IConfigRepo {
340
410
  * Called automatically by createConfigRepo() after setupSync().
341
411
  */
342
412
  syncMetaToReplicas(): Promise<void>;
413
+ /**
414
+ * Delete leaked `.mtime` sidecars from the local primary backend.
415
+ *
416
+ * Runs automatically on `createConfigRepo()` unless
417
+ * `options.purgeMtimeSidecars === false`. Call it manually to clean a
418
+ * long-lived store, or with `{ dryRun: true }` to only list them.
419
+ */
420
+ purgeMtimeSidecars(options?: {
421
+ root?: string;
422
+ dryRun?: boolean;
423
+ }): Promise<MtimePurgeResult>;
343
424
  /**
344
425
  * Create a data-sync group for this app.
345
426
  * Each backend can optionally reference a config-sync backend's account
@@ -593,6 +674,22 @@ declare class ConfigRepo implements IConfigRepo {
593
674
  private safeExists;
594
675
  /** Public wrapper for processTombstones — used by createConfigRepo. */
595
676
  processTombstonesPublic(): Promise<void>;
677
+ /**
678
+ * Delete leaked `.mtime` sidecar files from the local primary backend
679
+ * (IndexedDB on browser, Folder on Node).
680
+ *
681
+ * Sidecars are produced by backends that keep a precise mtime out-of-band
682
+ * (RemoteStorage, Gitee…). Once one is copied into the local primary it is
683
+ * never removed: zen-fs-sync skips `.mtime` paths on both sides, so it is
684
+ * invisible to sync, and every walk warns about the leak.
685
+ *
686
+ * Only the local primary is scanned — replica sidecars are live metadata of
687
+ * the backend that owns them and must be left alone.
688
+ *
689
+ * @param options.root Scan only this subtree (default `/`).
690
+ * @param options.dryRun List the sidecars without deleting them.
691
+ */
692
+ purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
596
693
  /**
597
694
  * Perform a full sync + dedup cycle without the watch snapshot cache.
598
695
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -826,4 +923,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
826
923
  */
827
924
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
828
925
 
829
- export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
926
+ export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,65 @@ import * as node_fs from 'node:fs';
4
4
  import * as node_path from 'node:path';
5
5
  import { SyncMapStore, SyncMapTransaction } from '@zenfs/core';
6
6
 
7
+ /**
8
+ * zen-fs-config — mtime sidecar cleanup
9
+ *
10
+ * Backends that cannot store a precise mtime natively (RemoteStorage, Gitee,
11
+ * …) persist it in a `.mtime` sidecar next to each file
12
+ * (`data.json` → `.data.json.mtime`). A sidecar is backend-internal metadata:
13
+ * it belongs to the backend that produced it and must never be handed out by
14
+ * `readdir()`/`createSnapshot()`.
15
+ *
16
+ * Older builds did leak them, and once a sidecar lands in the local primary
17
+ * (IndexedDB on browser, Folder on Node) it stays there forever — zen-fs-sync
18
+ * deliberately skips `.mtime` paths on both sides, so nothing ever removes
19
+ * them, and every walk logs:
20
+ *
21
+ * [zen-fs-sync] mtime sidecar leaked from backend "local-idb" …
22
+ *
23
+ * This module walks the local primary and deletes those leaked files. It never
24
+ * touches replica (remote) backends: their sidecars are live metadata, and
25
+ * deleting them would cost network calls and lose mtime precision.
26
+ */
27
+ /** Minimal async FS surface needed for the purge. */
28
+ interface PurgeableFS {
29
+ readdir(path: string): Promise<string[]>;
30
+ stat(path: string): Promise<{
31
+ mode?: number;
32
+ }>;
33
+ unlink(path: string): Promise<void>;
34
+ }
35
+ interface PurgeMtimeOptions {
36
+ /** Root to scan. Default: `/` (the whole local primary). */
37
+ root?: string;
38
+ /** Report what would be deleted without unlinking anything. */
39
+ dryRun?: boolean;
40
+ }
41
+ interface MtimePurgeResult {
42
+ /** Files visited during the walk (sidecars included). */
43
+ scanned: number;
44
+ /** Sidecars deleted — or, in dryRun, that would be deleted. */
45
+ removed: string[];
46
+ /** Sidecars that could not be deleted (unlink failed). */
47
+ failed: string[];
48
+ }
49
+ /**
50
+ * True when a file name is an mtime sidecar.
51
+ *
52
+ * Uses the same rule as zen-fs-sync's walker (`path.endsWith('.mtime')`), so
53
+ * the pathological nested form (`.foo.mtime.mtime`) is covered too.
54
+ */
55
+ declare function isMtimeSidecar(fileName: string): boolean;
56
+ /**
57
+ * Delete every `.mtime` sidecar stored in a local primary backend.
58
+ *
59
+ * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`,
60
+ * because sidecars live next to their data file and are themselves dotfiles.
61
+ * Unreadable entries are skipped; failures on individual files are collected
62
+ * in `failed` instead of aborting the walk.
63
+ */
64
+ declare function purgeMtimeSidecars(fs: PurgeableFS, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
65
+
7
66
  /** A single backend in the topology. */
8
67
  interface BackendDescriptor {
9
68
  /** Unique identifier within this config repo (e.g., "local-idb"). */
@@ -159,6 +218,17 @@ interface ConfigRepoOptions {
159
218
  * Default: 1800000 (30 minutes).
160
219
  */
161
220
  syncPollIntervalMs?: number;
221
+ /**
222
+ * Delete leaked `.mtime` sidecars from the local primary on startup.
223
+ *
224
+ * Backends that keep a precise mtime out-of-band (RemoteStorage, Gitee…)
225
+ * write a `.mtime` file next to each data file. Older builds copied those
226
+ * backend-internal files into the local primary, where sync ignores them —
227
+ * so they linger forever and warn on every walk.
228
+ *
229
+ * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
+ */
231
+ purgeMtimeSidecars?: boolean;
162
232
  }
163
233
  /** Type of sync group. */
164
234
  type SyncGroupType = 'config-sync' | 'data-sync';
@@ -340,6 +410,17 @@ interface IConfigRepo {
340
410
  * Called automatically by createConfigRepo() after setupSync().
341
411
  */
342
412
  syncMetaToReplicas(): Promise<void>;
413
+ /**
414
+ * Delete leaked `.mtime` sidecars from the local primary backend.
415
+ *
416
+ * Runs automatically on `createConfigRepo()` unless
417
+ * `options.purgeMtimeSidecars === false`. Call it manually to clean a
418
+ * long-lived store, or with `{ dryRun: true }` to only list them.
419
+ */
420
+ purgeMtimeSidecars(options?: {
421
+ root?: string;
422
+ dryRun?: boolean;
423
+ }): Promise<MtimePurgeResult>;
343
424
  /**
344
425
  * Create a data-sync group for this app.
345
426
  * Each backend can optionally reference a config-sync backend's account
@@ -593,6 +674,22 @@ declare class ConfigRepo implements IConfigRepo {
593
674
  private safeExists;
594
675
  /** Public wrapper for processTombstones — used by createConfigRepo. */
595
676
  processTombstonesPublic(): Promise<void>;
677
+ /**
678
+ * Delete leaked `.mtime` sidecar files from the local primary backend
679
+ * (IndexedDB on browser, Folder on Node).
680
+ *
681
+ * Sidecars are produced by backends that keep a precise mtime out-of-band
682
+ * (RemoteStorage, Gitee…). Once one is copied into the local primary it is
683
+ * never removed: zen-fs-sync skips `.mtime` paths on both sides, so it is
684
+ * invisible to sync, and every walk warns about the leak.
685
+ *
686
+ * Only the local primary is scanned — replica sidecars are live metadata of
687
+ * the backend that owns them and must be left alone.
688
+ *
689
+ * @param options.root Scan only this subtree (default `/`).
690
+ * @param options.dryRun List the sidecars without deleting them.
691
+ */
692
+ purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
596
693
  /**
597
694
  * Perform a full sync + dedup cycle without the watch snapshot cache.
598
695
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -826,4 +923,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
826
923
  */
827
924
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
828
925
 
829
- export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
926
+ export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.js CHANGED
@@ -418,10 +418,12 @@ __export(index_exports, {
418
418
  hasBackend: () => hasBackend,
419
419
  incrementVersion: () => incrementVersion,
420
420
  isBrowserEnv: () => isBrowserEnv,
421
+ isMtimeSidecar: () => isMtimeSidecar,
421
422
  listBackendMetadata: () => listBackendMetadata,
422
423
  listBackends: () => listBackends,
423
424
  localPrimaryType: () => localPrimaryType,
424
425
  mergeAccountFields: () => mergeAccountFields,
426
+ purgeMtimeSidecars: () => purgeMtimeSidecars,
425
427
  readVersion: () => readVersion,
426
428
  registerBackend: () => registerBackend,
427
429
  registerFolderBackend: () => registerFolderBackend,
@@ -1038,6 +1040,60 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
1038
1040
  }
1039
1041
  }
1040
1042
 
1043
+ // src/mtime-cleanup.ts
1044
+ function isMtimeSidecar(fileName) {
1045
+ return fileName.endsWith(".mtime");
1046
+ }
1047
+ var S_IFDIR = 16384;
1048
+ function isDirectory(stat) {
1049
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR) === S_IFDIR;
1050
+ }
1051
+ function joinPath(dir, entry) {
1052
+ if (dir === "/" || dir === "") return `/${entry}`;
1053
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
1054
+ }
1055
+ async function purgeMtimeSidecars(fs, options = {}) {
1056
+ const root = options.root && options.root !== "" ? options.root : "/";
1057
+ const dryRun = options.dryRun ?? false;
1058
+ const result = { scanned: 0, removed: [], failed: [] };
1059
+ const stack = [root];
1060
+ while (stack.length > 0) {
1061
+ const dir = stack.pop();
1062
+ let entries;
1063
+ try {
1064
+ entries = await fs.readdir(dir);
1065
+ } catch {
1066
+ continue;
1067
+ }
1068
+ for (const entry of entries) {
1069
+ const fullPath = joinPath(dir, entry);
1070
+ let stat;
1071
+ try {
1072
+ stat = await fs.stat(fullPath);
1073
+ } catch {
1074
+ continue;
1075
+ }
1076
+ if (isDirectory(stat)) {
1077
+ stack.push(fullPath);
1078
+ continue;
1079
+ }
1080
+ result.scanned++;
1081
+ if (!isMtimeSidecar(entry)) continue;
1082
+ if (dryRun) {
1083
+ result.removed.push(fullPath);
1084
+ continue;
1085
+ }
1086
+ try {
1087
+ await fs.unlink(fullPath);
1088
+ result.removed.push(fullPath);
1089
+ } catch {
1090
+ result.failed.push(fullPath);
1091
+ }
1092
+ }
1093
+ }
1094
+ return result;
1095
+ }
1096
+
1041
1097
  // src/config-repo.ts
1042
1098
  var log2 = (0, import_localstorage_logger2.createLogger)("zen-fs-config:config-repo");
1043
1099
  var META_DIR = "/.meta";
@@ -1491,6 +1547,35 @@ var ConfigRepo = class {
1491
1547
  async processTombstonesPublic() {
1492
1548
  await this.processTombstones();
1493
1549
  }
1550
+ /**
1551
+ * Delete leaked `.mtime` sidecar files from the local primary backend
1552
+ * (IndexedDB on browser, Folder on Node).
1553
+ *
1554
+ * Sidecars are produced by backends that keep a precise mtime out-of-band
1555
+ * (RemoteStorage, Gitee…). Once one is copied into the local primary it is
1556
+ * never removed: zen-fs-sync skips `.mtime` paths on both sides, so it is
1557
+ * invisible to sync, and every walk warns about the leak.
1558
+ *
1559
+ * Only the local primary is scanned — replica sidecars are live metadata of
1560
+ * the backend that owns them and must be left alone.
1561
+ *
1562
+ * @param options.root Scan only this subtree (default `/`).
1563
+ * @param options.dryRun List the sidecars without deleting them.
1564
+ */
1565
+ async purgeMtimeSidecars(options) {
1566
+ this.assertNotDisposed();
1567
+ const result = await purgeMtimeSidecars(this.cachedFS, options);
1568
+ if (result.removed.length > 0) {
1569
+ log2.log(
1570
+ `[ConfigRepo] purged ${result.removed.length} .mtime sidecar(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
1571
+ result.removed
1572
+ );
1573
+ }
1574
+ if (result.failed.length > 0) {
1575
+ log2.warn(`[ConfigRepo] failed to purge ${result.failed.length} .mtime sidecar(s):`, result.failed);
1576
+ }
1577
+ return result;
1578
+ }
1494
1579
  /**
1495
1580
  * Perform a full sync + dedup cycle without the watch snapshot cache.
1496
1581
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -2412,6 +2497,17 @@ var AppDataGroupImpl = class {
2412
2497
  } catch {
2413
2498
  throw new Error(`Failed to create local primary for data group "${this.groupId}"`);
2414
2499
  }
2500
+ try {
2501
+ const purged = await purgeMtimeSidecars(this.localFS);
2502
+ if (purged.removed.length > 0) {
2503
+ log2.warn(
2504
+ `[AppDataGroup:${this.groupId}] purged ${purged.removed.length} leaked .mtime sidecar(s):`,
2505
+ purged.removed
2506
+ );
2507
+ }
2508
+ } catch (err) {
2509
+ log2.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err?.message ?? err);
2510
+ }
2415
2511
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
2416
2512
  this.fs = createChrootFS(this.localFS, "/");
2417
2513
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -2538,6 +2634,19 @@ async function createConfigRepo(appId, options = {}) {
2538
2634
  log2.log(`[createConfigRepo] Creating local primary (type: ${localPrimary.type})...`);
2539
2635
  const primaryInstance = await createBackend(localPrimary);
2540
2636
  const cachedFS = primaryInstance;
2637
+ if (options.purgeMtimeSidecars !== false) {
2638
+ try {
2639
+ const purged = await purgeMtimeSidecars(cachedFS);
2640
+ if (purged.removed.length > 0) {
2641
+ log2.warn(
2642
+ `[createConfigRepo] purged ${purged.removed.length} leaked .mtime sidecar(s) from local primary:`,
2643
+ purged.removed
2644
+ );
2645
+ }
2646
+ } catch (err) {
2647
+ log2.warn(`[createConfigRepo] .mtime purge failed:`, err?.message ?? err);
2648
+ }
2649
+ }
2541
2650
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
2542
2651
  try {
2543
2652
  await primaryInstance.mkdir(META_DIR);
@@ -2772,10 +2881,12 @@ init_backend_registry();
2772
2881
  hasBackend,
2773
2882
  incrementVersion,
2774
2883
  isBrowserEnv,
2884
+ isMtimeSidecar,
2775
2885
  listBackendMetadata,
2776
2886
  listBackends,
2777
2887
  localPrimaryType,
2778
2888
  mergeAccountFields,
2889
+ purgeMtimeSidecars,
2779
2890
  readVersion,
2780
2891
  registerBackend,
2781
2892
  registerFolderBackend,
package/dist/index.mjs CHANGED
@@ -613,6 +613,60 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
613
613
  }
614
614
  }
615
615
 
616
+ // src/mtime-cleanup.ts
617
+ function isMtimeSidecar(fileName) {
618
+ return fileName.endsWith(".mtime");
619
+ }
620
+ var S_IFDIR = 16384;
621
+ function isDirectory(stat) {
622
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR) === S_IFDIR;
623
+ }
624
+ function joinPath(dir, entry) {
625
+ if (dir === "/" || dir === "") return `/${entry}`;
626
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
627
+ }
628
+ async function purgeMtimeSidecars(fs, options = {}) {
629
+ const root = options.root && options.root !== "" ? options.root : "/";
630
+ const dryRun = options.dryRun ?? false;
631
+ const result = { scanned: 0, removed: [], failed: [] };
632
+ const stack = [root];
633
+ while (stack.length > 0) {
634
+ const dir = stack.pop();
635
+ let entries;
636
+ try {
637
+ entries = await fs.readdir(dir);
638
+ } catch {
639
+ continue;
640
+ }
641
+ for (const entry of entries) {
642
+ const fullPath = joinPath(dir, entry);
643
+ let stat;
644
+ try {
645
+ stat = await fs.stat(fullPath);
646
+ } catch {
647
+ continue;
648
+ }
649
+ if (isDirectory(stat)) {
650
+ stack.push(fullPath);
651
+ continue;
652
+ }
653
+ result.scanned++;
654
+ if (!isMtimeSidecar(entry)) continue;
655
+ if (dryRun) {
656
+ result.removed.push(fullPath);
657
+ continue;
658
+ }
659
+ try {
660
+ await fs.unlink(fullPath);
661
+ result.removed.push(fullPath);
662
+ } catch {
663
+ result.failed.push(fullPath);
664
+ }
665
+ }
666
+ }
667
+ return result;
668
+ }
669
+
616
670
  // src/config-repo.ts
617
671
  var log2 = createLogger2("zen-fs-config:config-repo");
618
672
  var META_DIR = "/.meta";
@@ -1066,6 +1120,35 @@ var ConfigRepo = class {
1066
1120
  async processTombstonesPublic() {
1067
1121
  await this.processTombstones();
1068
1122
  }
1123
+ /**
1124
+ * Delete leaked `.mtime` sidecar files from the local primary backend
1125
+ * (IndexedDB on browser, Folder on Node).
1126
+ *
1127
+ * Sidecars are produced by backends that keep a precise mtime out-of-band
1128
+ * (RemoteStorage, Gitee…). Once one is copied into the local primary it is
1129
+ * never removed: zen-fs-sync skips `.mtime` paths on both sides, so it is
1130
+ * invisible to sync, and every walk warns about the leak.
1131
+ *
1132
+ * Only the local primary is scanned — replica sidecars are live metadata of
1133
+ * the backend that owns them and must be left alone.
1134
+ *
1135
+ * @param options.root Scan only this subtree (default `/`).
1136
+ * @param options.dryRun List the sidecars without deleting them.
1137
+ */
1138
+ async purgeMtimeSidecars(options) {
1139
+ this.assertNotDisposed();
1140
+ const result = await purgeMtimeSidecars(this.cachedFS, options);
1141
+ if (result.removed.length > 0) {
1142
+ log2.log(
1143
+ `[ConfigRepo] purged ${result.removed.length} .mtime sidecar(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
1144
+ result.removed
1145
+ );
1146
+ }
1147
+ if (result.failed.length > 0) {
1148
+ log2.warn(`[ConfigRepo] failed to purge ${result.failed.length} .mtime sidecar(s):`, result.failed);
1149
+ }
1150
+ return result;
1151
+ }
1069
1152
  /**
1070
1153
  * Perform a full sync + dedup cycle without the watch snapshot cache.
1071
1154
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -1987,6 +2070,17 @@ var AppDataGroupImpl = class {
1987
2070
  } catch {
1988
2071
  throw new Error(`Failed to create local primary for data group "${this.groupId}"`);
1989
2072
  }
2073
+ try {
2074
+ const purged = await purgeMtimeSidecars(this.localFS);
2075
+ if (purged.removed.length > 0) {
2076
+ log2.warn(
2077
+ `[AppDataGroup:${this.groupId}] purged ${purged.removed.length} leaked .mtime sidecar(s):`,
2078
+ purged.removed
2079
+ );
2080
+ }
2081
+ } catch (err) {
2082
+ log2.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err?.message ?? err);
2083
+ }
1990
2084
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
1991
2085
  this.fs = createChrootFS(this.localFS, "/");
1992
2086
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -2113,6 +2207,19 @@ async function createConfigRepo(appId, options = {}) {
2113
2207
  log2.log(`[createConfigRepo] Creating local primary (type: ${localPrimary.type})...`);
2114
2208
  const primaryInstance = await createBackend(localPrimary);
2115
2209
  const cachedFS = primaryInstance;
2210
+ if (options.purgeMtimeSidecars !== false) {
2211
+ try {
2212
+ const purged = await purgeMtimeSidecars(cachedFS);
2213
+ if (purged.removed.length > 0) {
2214
+ log2.warn(
2215
+ `[createConfigRepo] purged ${purged.removed.length} leaked .mtime sidecar(s) from local primary:`,
2216
+ purged.removed
2217
+ );
2218
+ }
2219
+ } catch (err) {
2220
+ log2.warn(`[createConfigRepo] .mtime purge failed:`, err?.message ?? err);
2221
+ }
2222
+ }
2116
2223
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
2117
2224
  try {
2118
2225
  await primaryInstance.mkdir(META_DIR);
@@ -2342,10 +2449,12 @@ export {
2342
2449
  hasBackend,
2343
2450
  incrementVersion,
2344
2451
  isBrowserEnv,
2452
+ isMtimeSidecar,
2345
2453
  listBackendMetadata,
2346
2454
  listBackends,
2347
2455
  localPrimaryType,
2348
2456
  mergeAccountFields,
2457
+ purgeMtimeSidecars,
2349
2458
  readVersion,
2350
2459
  registerBackend,
2351
2460
  registerFolderBackend,
@@ -25111,10 +25111,12 @@ var ZenFSConfig = (() => {
25111
25111
  hasBackend: () => hasBackend,
25112
25112
  incrementVersion: () => incrementVersion,
25113
25113
  isBrowserEnv: () => isBrowserEnv,
25114
+ isMtimeSidecar: () => isMtimeSidecar,
25114
25115
  listBackendMetadata: () => listBackendMetadata,
25115
25116
  listBackends: () => listBackends,
25116
25117
  localPrimaryType: () => localPrimaryType,
25117
25118
  mergeAccountFields: () => mergeAccountFields,
25119
+ purgeMtimeSidecars: () => purgeMtimeSidecars,
25118
25120
  readVersion: () => readVersion,
25119
25121
  registerBackend: () => registerBackend,
25120
25122
  registerFolderBackend: () => registerFolderBackend,
@@ -26915,6 +26917,60 @@ var ZenFSConfig = (() => {
26915
26917
  }
26916
26918
  }
26917
26919
 
26920
+ // src/mtime-cleanup.ts
26921
+ function isMtimeSidecar(fileName) {
26922
+ return fileName.endsWith(".mtime");
26923
+ }
26924
+ var S_IFDIR2 = 16384;
26925
+ function isDirectory3(stat5) {
26926
+ return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR2) === S_IFDIR2;
26927
+ }
26928
+ function joinPath(dir, entry) {
26929
+ if (dir === "/" || dir === "") return `/${entry}`;
26930
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
26931
+ }
26932
+ async function purgeMtimeSidecars(fs, options = {}) {
26933
+ const root = options.root && options.root !== "" ? options.root : "/";
26934
+ const dryRun = options.dryRun ?? false;
26935
+ const result = { scanned: 0, removed: [], failed: [] };
26936
+ const stack = [root];
26937
+ while (stack.length > 0) {
26938
+ const dir = stack.pop();
26939
+ let entries2;
26940
+ try {
26941
+ entries2 = await fs.readdir(dir);
26942
+ } catch {
26943
+ continue;
26944
+ }
26945
+ for (const entry of entries2) {
26946
+ const fullPath = joinPath(dir, entry);
26947
+ let stat5;
26948
+ try {
26949
+ stat5 = await fs.stat(fullPath);
26950
+ } catch {
26951
+ continue;
26952
+ }
26953
+ if (isDirectory3(stat5)) {
26954
+ stack.push(fullPath);
26955
+ continue;
26956
+ }
26957
+ result.scanned++;
26958
+ if (!isMtimeSidecar(entry)) continue;
26959
+ if (dryRun) {
26960
+ result.removed.push(fullPath);
26961
+ continue;
26962
+ }
26963
+ try {
26964
+ await fs.unlink(fullPath);
26965
+ result.removed.push(fullPath);
26966
+ } catch {
26967
+ result.failed.push(fullPath);
26968
+ }
26969
+ }
26970
+ }
26971
+ return result;
26972
+ }
26973
+
26918
26974
  // src/config-repo.ts
26919
26975
  var log6 = createLogger2("zen-fs-config:config-repo");
26920
26976
  var META_DIR = "/.meta";
@@ -27368,6 +27424,35 @@ var ZenFSConfig = (() => {
27368
27424
  async processTombstonesPublic() {
27369
27425
  await this.processTombstones();
27370
27426
  }
27427
+ /**
27428
+ * Delete leaked `.mtime` sidecar files from the local primary backend
27429
+ * (IndexedDB on browser, Folder on Node).
27430
+ *
27431
+ * Sidecars are produced by backends that keep a precise mtime out-of-band
27432
+ * (RemoteStorage, Gitee…). Once one is copied into the local primary it is
27433
+ * never removed: zen-fs-sync skips `.mtime` paths on both sides, so it is
27434
+ * invisible to sync, and every walk warns about the leak.
27435
+ *
27436
+ * Only the local primary is scanned — replica sidecars are live metadata of
27437
+ * the backend that owns them and must be left alone.
27438
+ *
27439
+ * @param options.root Scan only this subtree (default `/`).
27440
+ * @param options.dryRun List the sidecars without deleting them.
27441
+ */
27442
+ async purgeMtimeSidecars(options) {
27443
+ this.assertNotDisposed();
27444
+ const result = await purgeMtimeSidecars(this.cachedFS, options);
27445
+ if (result.removed.length > 0) {
27446
+ log6.log(
27447
+ `[ConfigRepo] purged ${result.removed.length} .mtime sidecar(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
27448
+ result.removed
27449
+ );
27450
+ }
27451
+ if (result.failed.length > 0) {
27452
+ log6.warn(`[ConfigRepo] failed to purge ${result.failed.length} .mtime sidecar(s):`, result.failed);
27453
+ }
27454
+ return result;
27455
+ }
27371
27456
  /**
27372
27457
  * Perform a full sync + dedup cycle without the watch snapshot cache.
27373
27458
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -28289,6 +28374,17 @@ var ZenFSConfig = (() => {
28289
28374
  } catch {
28290
28375
  throw new Error(`Failed to create local primary for data group "${this.groupId}"`);
28291
28376
  }
28377
+ try {
28378
+ const purged = await purgeMtimeSidecars(this.localFS);
28379
+ if (purged.removed.length > 0) {
28380
+ log6.warn(
28381
+ `[AppDataGroup:${this.groupId}] purged ${purged.removed.length} leaked .mtime sidecar(s):`,
28382
+ purged.removed
28383
+ );
28384
+ }
28385
+ } catch (err2) {
28386
+ log6.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err2?.message ?? err2);
28387
+ }
28292
28388
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
28293
28389
  this.fs = createChrootFS(this.localFS, "/");
28294
28390
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -28415,6 +28511,19 @@ var ZenFSConfig = (() => {
28415
28511
  log6.log(`[createConfigRepo] Creating local primary (type: ${localPrimary.type})...`);
28416
28512
  const primaryInstance = await createBackend(localPrimary);
28417
28513
  const cachedFS = primaryInstance;
28514
+ if (options.purgeMtimeSidecars !== false) {
28515
+ try {
28516
+ const purged = await purgeMtimeSidecars(cachedFS);
28517
+ if (purged.removed.length > 0) {
28518
+ log6.warn(
28519
+ `[createConfigRepo] purged ${purged.removed.length} leaked .mtime sidecar(s) from local primary:`,
28520
+ purged.removed
28521
+ );
28522
+ }
28523
+ } catch (err2) {
28524
+ log6.warn(`[createConfigRepo] .mtime purge failed:`, err2?.message ?? err2);
28525
+ }
28526
+ }
28418
28527
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
28419
28528
  try {
28420
28529
  await primaryInstance.mkdir(META_DIR);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.31",
3
+ "version": "0.5.32",
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",