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 +98 -1
- package/dist/index.d.ts +98 -1
- package/dist/index.js +111 -0
- package/dist/index.mjs +109 -0
- package/dist/zen-fs-config.js +109 -0
- package/package.json +1 -1
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,
|
package/dist/zen-fs-config.js
CHANGED
|
@@ -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);
|