zen-fs-config 0.5.33 → 0.5.36

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.
@@ -9,12 +9,23 @@ function wrapWithCache(backend, backendId, options) {
9
9
  } else {
10
10
  store = new MemoryCacheStore();
11
11
  }
12
+ try {
13
+ const purge = store.purgeKeepFiles?.();
14
+ if (purge && typeof purge.catch === "function") {
15
+ purge.catch(() => {
16
+ });
17
+ }
18
+ } catch {
19
+ }
12
20
  const wrapped = new CachedFileSystem(backend, store, {
13
21
  ttlMs: options.ttlMs ?? 0
14
22
  });
15
23
  if (typeof backend.shouldSync === "function") {
16
24
  wrapped.shouldSync = (...args) => backend.shouldSync(...args);
17
25
  }
26
+ if (typeof backend.writeFileWithMtime === "function") {
27
+ wrapped.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
28
+ }
18
29
  return wrapped;
19
30
  }
20
31
  export {
package/dist/index.d.mts CHANGED
@@ -25,7 +25,7 @@ import { SyncMapStore, SyncMapTransaction } from '@zenfs/core';
25
25
  * deleting them would cost network calls and lose mtime precision.
26
26
  */
27
27
  /** Minimal async FS surface needed for the purge. */
28
- interface PurgeableFS {
28
+ interface PurgeableFS$1 {
29
29
  readdir(path: string): Promise<string[]>;
30
30
  stat(path: string): Promise<{
31
31
  mode?: number;
@@ -61,7 +61,74 @@ declare function isMtimeSidecar(fileName: string): boolean;
61
61
  * Unreadable entries are skipped; failures on individual files are collected
62
62
  * in `failed` instead of aborting the walk.
63
63
  */
64
- declare function purgeMtimeSidecars(fs: PurgeableFS, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
64
+ declare function purgeMtimeSidecars(fs: PurgeableFS$1, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
65
+
66
+ /**
67
+ * zen-fs-config — `.keep` placeholder cleanup (local primary only)
68
+ *
69
+ * Backends that cannot store empty directories (Git, RemoteStorage…) keep a
70
+ * directory alive with an internal `.keep` placeholder — exactly like the
71
+ * `.mtime` sidecar, this is backend-internal metadata that MUST be hidden from
72
+ * callers (see zen-fs-sync/docs/SyncableFS.md §1/§2).
73
+ *
74
+ * Older builds leaked `.keep` files into the local primary (IndexedDB on
75
+ * browser, Folder on Node), where they linger forever: the sync engine now
76
+ * skips `.keep` paths on both sides, so nothing ever removes them, and every
77
+ * walk re-creates confusion about which directories are "real".
78
+ *
79
+ * This module walks the local primary and deletes those leaked `.keep` files.
80
+ * It never touches replica (remote) backends: their placeholders are live
81
+ * metadata that keep real (empty) directories alive, and deleting them would
82
+ * lose directory structure.
83
+ *
84
+ * The intentional `.keep` under `/.meta/` (used to keep the backends directory
85
+ * alive) is protected and never deleted — see `protectedDirs`.
86
+ */
87
+ /** Minimal async FS surface needed for the purge. */
88
+ interface PurgeableFS {
89
+ readdir(path: string): Promise<string[]>;
90
+ stat(path: string): Promise<{
91
+ mode?: number;
92
+ }>;
93
+ unlink(path: string): Promise<void>;
94
+ }
95
+ interface PurgeKeepOptions {
96
+ /** Root to scan. Default: `/` (the whole local primary). */
97
+ root?: string;
98
+ /** Report what would be deleted without unlinking anything. */
99
+ dryRun?: boolean;
100
+ /**
101
+ * Directories (and their subtrees) whose `.keep` files are NEVER deleted.
102
+ * Defaults to `['/.meta']` to protect the intentional backends placeholder
103
+ * that keeps `/.meta/backends` alive.
104
+ */
105
+ protectedDirs?: string[];
106
+ }
107
+ interface KeepPurgeResult {
108
+ /** Files visited during the walk (`.keep` included). */
109
+ scanned: number;
110
+ /** Placeholders deleted — or, in dryRun, that would be deleted. */
111
+ removed: string[];
112
+ /** Placeholders that could not be deleted (unlink failed). */
113
+ failed: string[];
114
+ }
115
+ /**
116
+ * True when a file name is the internal `.keep` directory placeholder.
117
+ *
118
+ * Matches exactly `.keep` (the file name), which is the only form produced by
119
+ * the backends — never a suffix match, so `foo.keep` is left untouched.
120
+ */
121
+ declare function isKeepFile(fileName: string): boolean;
122
+ /**
123
+ * Delete every leaked `.keep` placeholder stored in a local primary backend.
124
+ *
125
+ * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`
126
+ * entries — but skips any directory listed in `protectedDirs` (default
127
+ * `/.meta`) so the intentional backends placeholder survives. Unreadable
128
+ * entries are skipped; failures on individual files are collected in `failed`
129
+ * instead of aborting the walk.
130
+ */
131
+ declare function purgeKeepFiles(fs: PurgeableFS, options?: PurgeKeepOptions): Promise<KeepPurgeResult>;
65
132
 
66
133
  /** A single backend in the topology. */
67
134
  interface BackendDescriptor {
@@ -229,6 +296,18 @@ interface ConfigRepoOptions {
229
296
  * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
297
  */
231
298
  purgeMtimeSidecars?: boolean;
299
+ /**
300
+ * Delete leaked `.keep` placeholders from the local primary on startup.
301
+ *
302
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
303
+ * directory alive with an internal `.keep` placeholder. Older builds copied
304
+ * those backend-internal files into the local primary, where sync ignores
305
+ * them — so they linger forever. The intentional `/.meta/backends/.keep` is
306
+ * always protected.
307
+ *
308
+ * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
309
+ */
310
+ purgeKeepFiles?: boolean;
232
311
  /**
233
312
  * Migrate legacy `.x.version` sidecars to the new `<name>.version` naming on
234
313
  * startup (rename locally, delete the remote residual).
@@ -433,6 +512,17 @@ interface IConfigRepo {
433
512
  root?: string;
434
513
  dryRun?: boolean;
435
514
  }): Promise<MtimePurgeResult>;
515
+ /**
516
+ * Delete leaked `.keep` placeholders from the local primary backend.
517
+ *
518
+ * Runs automatically on `createConfigRepo()` unless
519
+ * `options.purgeKeepFiles === false`. Call it manually to clean a
520
+ * long-lived store, or with `{ dryRun: true }` to only list them.
521
+ */
522
+ purgeKeepFiles(options?: {
523
+ root?: string;
524
+ dryRun?: boolean;
525
+ }): Promise<KeepPurgeResult>;
436
526
  /**
437
527
  * Create a data-sync group for this app.
438
528
  * Each backend can optionally reference a config-sync backend's account
@@ -767,6 +857,19 @@ declare class ConfigRepo implements IConfigRepo {
767
857
  * @param options.dryRun List the sidecars without deleting them.
768
858
  */
769
859
  purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
860
+ /**
861
+ * Delete leaked `.keep` placeholders from the local primary backend.
862
+ *
863
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
864
+ * directory alive with an internal `.keep` placeholder. Older builds copied
865
+ * those backend-internal files into the local primary, where sync ignores
866
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
867
+ * protected and never removed.
868
+ *
869
+ * @param options.root Scan only this subtree (default `/`).
870
+ * @param options.dryRun List the placeholders without deleting them.
871
+ */
872
+ purgeKeepFiles(options?: PurgeKeepOptions): Promise<KeepPurgeResult>;
770
873
  /**
771
874
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
772
875
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -1020,4 +1123,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
1020
1123
  */
1021
1124
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
1022
1125
 
1023
- 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 MigrateLocalFS, type MigrateReplicaFS, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, type VersionMigrationOptions, type VersionMigrationResult, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, legacyVersionPathFor, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, migrateVersionSidecars, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
1126
+ 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, type KeepPurgeResult, LOCAL_IDB_BACKEND_ID, type MigrateLocalFS, type MigrateReplicaFS, type MtimePurgeResult, type PurgeKeepOptions, type PurgeMtimeOptions, type PurgeableFS$1 as PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, type VersionMigrationOptions, type VersionMigrationResult, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isKeepFile, isMtimeSidecar, legacyVersionPathFor, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, migrateVersionSidecars, purgeKeepFiles, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ import { SyncMapStore, SyncMapTransaction } from '@zenfs/core';
25
25
  * deleting them would cost network calls and lose mtime precision.
26
26
  */
27
27
  /** Minimal async FS surface needed for the purge. */
28
- interface PurgeableFS {
28
+ interface PurgeableFS$1 {
29
29
  readdir(path: string): Promise<string[]>;
30
30
  stat(path: string): Promise<{
31
31
  mode?: number;
@@ -61,7 +61,74 @@ declare function isMtimeSidecar(fileName: string): boolean;
61
61
  * Unreadable entries are skipped; failures on individual files are collected
62
62
  * in `failed` instead of aborting the walk.
63
63
  */
64
- declare function purgeMtimeSidecars(fs: PurgeableFS, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
64
+ declare function purgeMtimeSidecars(fs: PurgeableFS$1, options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
65
+
66
+ /**
67
+ * zen-fs-config — `.keep` placeholder cleanup (local primary only)
68
+ *
69
+ * Backends that cannot store empty directories (Git, RemoteStorage…) keep a
70
+ * directory alive with an internal `.keep` placeholder — exactly like the
71
+ * `.mtime` sidecar, this is backend-internal metadata that MUST be hidden from
72
+ * callers (see zen-fs-sync/docs/SyncableFS.md §1/§2).
73
+ *
74
+ * Older builds leaked `.keep` files into the local primary (IndexedDB on
75
+ * browser, Folder on Node), where they linger forever: the sync engine now
76
+ * skips `.keep` paths on both sides, so nothing ever removes them, and every
77
+ * walk re-creates confusion about which directories are "real".
78
+ *
79
+ * This module walks the local primary and deletes those leaked `.keep` files.
80
+ * It never touches replica (remote) backends: their placeholders are live
81
+ * metadata that keep real (empty) directories alive, and deleting them would
82
+ * lose directory structure.
83
+ *
84
+ * The intentional `.keep` under `/.meta/` (used to keep the backends directory
85
+ * alive) is protected and never deleted — see `protectedDirs`.
86
+ */
87
+ /** Minimal async FS surface needed for the purge. */
88
+ interface PurgeableFS {
89
+ readdir(path: string): Promise<string[]>;
90
+ stat(path: string): Promise<{
91
+ mode?: number;
92
+ }>;
93
+ unlink(path: string): Promise<void>;
94
+ }
95
+ interface PurgeKeepOptions {
96
+ /** Root to scan. Default: `/` (the whole local primary). */
97
+ root?: string;
98
+ /** Report what would be deleted without unlinking anything. */
99
+ dryRun?: boolean;
100
+ /**
101
+ * Directories (and their subtrees) whose `.keep` files are NEVER deleted.
102
+ * Defaults to `['/.meta']` to protect the intentional backends placeholder
103
+ * that keeps `/.meta/backends` alive.
104
+ */
105
+ protectedDirs?: string[];
106
+ }
107
+ interface KeepPurgeResult {
108
+ /** Files visited during the walk (`.keep` included). */
109
+ scanned: number;
110
+ /** Placeholders deleted — or, in dryRun, that would be deleted. */
111
+ removed: string[];
112
+ /** Placeholders that could not be deleted (unlink failed). */
113
+ failed: string[];
114
+ }
115
+ /**
116
+ * True when a file name is the internal `.keep` directory placeholder.
117
+ *
118
+ * Matches exactly `.keep` (the file name), which is the only form produced by
119
+ * the backends — never a suffix match, so `foo.keep` is left untouched.
120
+ */
121
+ declare function isKeepFile(fileName: string): boolean;
122
+ /**
123
+ * Delete every leaked `.keep` placeholder stored in a local primary backend.
124
+ *
125
+ * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`
126
+ * entries — but skips any directory listed in `protectedDirs` (default
127
+ * `/.meta`) so the intentional backends placeholder survives. Unreadable
128
+ * entries are skipped; failures on individual files are collected in `failed`
129
+ * instead of aborting the walk.
130
+ */
131
+ declare function purgeKeepFiles(fs: PurgeableFS, options?: PurgeKeepOptions): Promise<KeepPurgeResult>;
65
132
 
66
133
  /** A single backend in the topology. */
67
134
  interface BackendDescriptor {
@@ -229,6 +296,18 @@ interface ConfigRepoOptions {
229
296
  * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
297
  */
231
298
  purgeMtimeSidecars?: boolean;
299
+ /**
300
+ * Delete leaked `.keep` placeholders from the local primary on startup.
301
+ *
302
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
303
+ * directory alive with an internal `.keep` placeholder. Older builds copied
304
+ * those backend-internal files into the local primary, where sync ignores
305
+ * them — so they linger forever. The intentional `/.meta/backends/.keep` is
306
+ * always protected.
307
+ *
308
+ * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
309
+ */
310
+ purgeKeepFiles?: boolean;
232
311
  /**
233
312
  * Migrate legacy `.x.version` sidecars to the new `<name>.version` naming on
234
313
  * startup (rename locally, delete the remote residual).
@@ -433,6 +512,17 @@ interface IConfigRepo {
433
512
  root?: string;
434
513
  dryRun?: boolean;
435
514
  }): Promise<MtimePurgeResult>;
515
+ /**
516
+ * Delete leaked `.keep` placeholders from the local primary backend.
517
+ *
518
+ * Runs automatically on `createConfigRepo()` unless
519
+ * `options.purgeKeepFiles === false`. Call it manually to clean a
520
+ * long-lived store, or with `{ dryRun: true }` to only list them.
521
+ */
522
+ purgeKeepFiles(options?: {
523
+ root?: string;
524
+ dryRun?: boolean;
525
+ }): Promise<KeepPurgeResult>;
436
526
  /**
437
527
  * Create a data-sync group for this app.
438
528
  * Each backend can optionally reference a config-sync backend's account
@@ -767,6 +857,19 @@ declare class ConfigRepo implements IConfigRepo {
767
857
  * @param options.dryRun List the sidecars without deleting them.
768
858
  */
769
859
  purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
860
+ /**
861
+ * Delete leaked `.keep` placeholders from the local primary backend.
862
+ *
863
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
864
+ * directory alive with an internal `.keep` placeholder. Older builds copied
865
+ * those backend-internal files into the local primary, where sync ignores
866
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
867
+ * protected and never removed.
868
+ *
869
+ * @param options.root Scan only this subtree (default `/`).
870
+ * @param options.dryRun List the placeholders without deleting them.
871
+ */
872
+ purgeKeepFiles(options?: PurgeKeepOptions): Promise<KeepPurgeResult>;
770
873
  /**
771
874
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
772
875
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -1020,4 +1123,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
1020
1123
  */
1021
1124
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
1022
1125
 
1023
- 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 MigrateLocalFS, type MigrateReplicaFS, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, type VersionMigrationOptions, type VersionMigrationResult, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, legacyVersionPathFor, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, migrateVersionSidecars, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
1126
+ 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, type KeepPurgeResult, LOCAL_IDB_BACKEND_ID, type MigrateLocalFS, type MigrateReplicaFS, type MtimePurgeResult, type PurgeKeepOptions, type PurgeMtimeOptions, type PurgeableFS$1 as PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, type VersionMigrationOptions, type VersionMigrationResult, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isKeepFile, isMtimeSidecar, legacyVersionPathFor, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, migrateVersionSidecars, purgeKeepFiles, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.js CHANGED
@@ -385,12 +385,23 @@ function wrapWithCache(backend, backendId, options) {
385
385
  } else {
386
386
  store = new import_zen_fs_cache.MemoryCacheStore();
387
387
  }
388
+ try {
389
+ const purge = store.purgeKeepFiles?.();
390
+ if (purge && typeof purge.catch === "function") {
391
+ purge.catch(() => {
392
+ });
393
+ }
394
+ } catch {
395
+ }
388
396
  const wrapped = new import_zen_fs_cache.CachedFileSystem(backend, store, {
389
397
  ttlMs: options.ttlMs ?? 0
390
398
  });
391
399
  if (typeof backend.shouldSync === "function") {
392
400
  wrapped.shouldSync = (...args) => backend.shouldSync(...args);
393
401
  }
402
+ if (typeof backend.writeFileWithMtime === "function") {
403
+ wrapped.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
404
+ }
394
405
  return wrapped;
395
406
  }
396
407
  var import_zen_fs_cache;
@@ -418,6 +429,7 @@ __export(index_exports, {
418
429
  hasBackend: () => hasBackend,
419
430
  incrementVersion: () => incrementVersion,
420
431
  isBrowserEnv: () => isBrowserEnv,
432
+ isKeepFile: () => isKeepFile,
421
433
  isMtimeSidecar: () => isMtimeSidecar,
422
434
  legacyVersionPathFor: () => legacyVersionPathFor,
423
435
  listBackendMetadata: () => listBackendMetadata,
@@ -425,6 +437,7 @@ __export(index_exports, {
425
437
  localPrimaryType: () => localPrimaryType,
426
438
  mergeAccountFields: () => mergeAccountFields,
427
439
  migrateVersionSidecars: () => migrateVersionSidecars,
440
+ purgeKeepFiles: () => purgeKeepFiles,
428
441
  purgeMtimeSidecars: () => purgeMtimeSidecars,
429
442
  readVersion: () => readVersion,
430
443
  registerBackend: () => registerBackend,
@@ -1117,15 +1130,79 @@ async function purgeMtimeSidecars(fs, options = {}) {
1117
1130
  return result;
1118
1131
  }
1119
1132
 
1120
- // src/version-migration.ts
1133
+ // src/keep-cleanup.ts
1134
+ function isKeepFile(fileName) {
1135
+ return fileName === ".keep";
1136
+ }
1121
1137
  var S_IFDIR2 = 16384;
1122
- function isDir(stat) {
1138
+ var DEFAULT_PROTECTED_DIRS = ["/.meta"];
1139
+ function isDirectory2(stat) {
1123
1140
  return typeof stat.mode === "number" && (stat.mode & S_IFDIR2) === S_IFDIR2;
1124
1141
  }
1125
1142
  function joinPath2(dir, entry) {
1126
1143
  if (dir === "/" || dir === "") return `/${entry}`;
1127
1144
  return `${dir.replace(/\/+$/, "")}/${entry}`;
1128
1145
  }
1146
+ function isProtected(fullPath, protectedDirs) {
1147
+ return protectedDirs.some((d) => {
1148
+ const dir = d.endsWith("/") ? d.slice(0, -1) : d;
1149
+ return fullPath === dir || fullPath.startsWith(dir + "/");
1150
+ });
1151
+ }
1152
+ async function purgeKeepFiles(fs, options = {}) {
1153
+ const root = options.root && options.root !== "" ? options.root : "/";
1154
+ const dryRun = options.dryRun ?? false;
1155
+ const protectedDirs = options.protectedDirs ?? DEFAULT_PROTECTED_DIRS;
1156
+ const result = { scanned: 0, removed: [], failed: [] };
1157
+ const stack = [root];
1158
+ while (stack.length > 0) {
1159
+ const dir = stack.pop();
1160
+ if (isProtected(dir, protectedDirs)) continue;
1161
+ let entries;
1162
+ try {
1163
+ entries = await fs.readdir(dir);
1164
+ } catch {
1165
+ continue;
1166
+ }
1167
+ for (const entry of entries) {
1168
+ const fullPath = joinPath2(dir, entry);
1169
+ if (isProtected(fullPath, protectedDirs)) continue;
1170
+ let stat;
1171
+ try {
1172
+ stat = await fs.stat(fullPath);
1173
+ } catch {
1174
+ continue;
1175
+ }
1176
+ if (isDirectory2(stat)) {
1177
+ stack.push(fullPath);
1178
+ continue;
1179
+ }
1180
+ result.scanned++;
1181
+ if (!isKeepFile(entry)) continue;
1182
+ if (dryRun) {
1183
+ result.removed.push(fullPath);
1184
+ continue;
1185
+ }
1186
+ try {
1187
+ await fs.unlink(fullPath);
1188
+ result.removed.push(fullPath);
1189
+ } catch {
1190
+ result.failed.push(fullPath);
1191
+ }
1192
+ }
1193
+ }
1194
+ return result;
1195
+ }
1196
+
1197
+ // src/version-migration.ts
1198
+ var S_IFDIR3 = 16384;
1199
+ function isDir(stat) {
1200
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR3) === S_IFDIR3;
1201
+ }
1202
+ function joinPath3(dir, entry) {
1203
+ if (dir === "/" || dir === "") return `/${entry}`;
1204
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
1205
+ }
1129
1206
  async function existsPath(fs, p) {
1130
1207
  try {
1131
1208
  await fs.stat(p);
@@ -1140,8 +1217,8 @@ function configNameFromLegacy(entry) {
1140
1217
  }
1141
1218
  async function resolveTarget(fs, dir, entry) {
1142
1219
  const configName = configNameFromLegacy(entry);
1143
- const configPath = joinPath2(dir, configName);
1144
- const dotConfigPath = joinPath2(dir, `.${configName}`);
1220
+ const configPath = joinPath3(dir, configName);
1221
+ const dotConfigPath = joinPath3(dir, `.${configName}`);
1145
1222
  if (await existsPath(fs, dotConfigPath)) {
1146
1223
  return { action: "skip" };
1147
1224
  }
@@ -1170,7 +1247,7 @@ async function migrateVersionSidecars(fs, options = {}) {
1170
1247
  continue;
1171
1248
  }
1172
1249
  for (const entry of entries) {
1173
- const full = joinPath2(dir, entry);
1250
+ const full = joinPath3(dir, entry);
1174
1251
  let stat;
1175
1252
  try {
1176
1253
  stat = await fs.stat(full);
@@ -1724,6 +1801,32 @@ var ConfigRepo = class {
1724
1801
  }
1725
1802
  return result;
1726
1803
  }
1804
+ /**
1805
+ * Delete leaked `.keep` placeholders from the local primary backend.
1806
+ *
1807
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
1808
+ * directory alive with an internal `.keep` placeholder. Older builds copied
1809
+ * those backend-internal files into the local primary, where sync ignores
1810
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
1811
+ * protected and never removed.
1812
+ *
1813
+ * @param options.root Scan only this subtree (default `/`).
1814
+ * @param options.dryRun List the placeholders without deleting them.
1815
+ */
1816
+ async purgeKeepFiles(options) {
1817
+ this.assertNotDisposed();
1818
+ const result = await purgeKeepFiles(this.cachedFS, options);
1819
+ if (result.removed.length > 0) {
1820
+ log2.log(
1821
+ `[ConfigRepo] purged ${result.removed.length} .keep placeholder(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
1822
+ result.removed
1823
+ );
1824
+ }
1825
+ if (result.failed.length > 0) {
1826
+ log2.warn(`[ConfigRepo] failed to purge ${result.failed.length} .keep placeholder(s):`, result.failed);
1827
+ }
1828
+ return result;
1829
+ }
1727
1830
  /**
1728
1831
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
1729
1832
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -2694,6 +2797,17 @@ var AppDataGroupImpl = class {
2694
2797
  } catch (err) {
2695
2798
  log2.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err?.message ?? err);
2696
2799
  }
2800
+ try {
2801
+ const purgedKeep = await purgeKeepFiles(this.localFS);
2802
+ if (purgedKeep.removed.length > 0) {
2803
+ log2.warn(
2804
+ `[AppDataGroup:${this.groupId}] purged ${purgedKeep.removed.length} leaked .keep placeholder(s):`,
2805
+ purgedKeep.removed
2806
+ );
2807
+ }
2808
+ } catch (err) {
2809
+ log2.warn(`[AppDataGroup:${this.groupId}] .keep purge failed:`, err?.message ?? err);
2810
+ }
2697
2811
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
2698
2812
  this.fs = createChrootFS(this.localFS, "/");
2699
2813
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -2833,6 +2947,19 @@ async function createConfigRepo(appId, options = {}) {
2833
2947
  log2.warn(`[createConfigRepo] .mtime purge failed:`, err?.message ?? err);
2834
2948
  }
2835
2949
  }
2950
+ if (options.purgeKeepFiles !== false) {
2951
+ try {
2952
+ const purged = await purgeKeepFiles(cachedFS);
2953
+ if (purged.removed.length > 0) {
2954
+ log2.warn(
2955
+ `[createConfigRepo] purged ${purged.removed.length} leaked .keep placeholder(s) from local primary:`,
2956
+ purged.removed
2957
+ );
2958
+ }
2959
+ } catch (err) {
2960
+ log2.warn(`[createConfigRepo] .keep purge failed:`, err?.message ?? err);
2961
+ }
2962
+ }
2836
2963
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
2837
2964
  try {
2838
2965
  await primaryInstance.mkdir(META_DIR);
@@ -3077,6 +3204,7 @@ init_backend_registry();
3077
3204
  hasBackend,
3078
3205
  incrementVersion,
3079
3206
  isBrowserEnv,
3207
+ isKeepFile,
3080
3208
  isMtimeSidecar,
3081
3209
  legacyVersionPathFor,
3082
3210
  listBackendMetadata,
@@ -3084,6 +3212,7 @@ init_backend_registry();
3084
3212
  localPrimaryType,
3085
3213
  mergeAccountFields,
3086
3214
  migrateVersionSidecars,
3215
+ purgeKeepFiles,
3087
3216
  purgeMtimeSidecars,
3088
3217
  readVersion,
3089
3218
  registerBackend,
package/dist/index.mjs CHANGED
@@ -688,15 +688,79 @@ async function purgeMtimeSidecars(fs, options = {}) {
688
688
  return result;
689
689
  }
690
690
 
691
- // src/version-migration.ts
691
+ // src/keep-cleanup.ts
692
+ function isKeepFile(fileName) {
693
+ return fileName === ".keep";
694
+ }
692
695
  var S_IFDIR2 = 16384;
693
- function isDir(stat) {
696
+ var DEFAULT_PROTECTED_DIRS = ["/.meta"];
697
+ function isDirectory2(stat) {
694
698
  return typeof stat.mode === "number" && (stat.mode & S_IFDIR2) === S_IFDIR2;
695
699
  }
696
700
  function joinPath2(dir, entry) {
697
701
  if (dir === "/" || dir === "") return `/${entry}`;
698
702
  return `${dir.replace(/\/+$/, "")}/${entry}`;
699
703
  }
704
+ function isProtected(fullPath, protectedDirs) {
705
+ return protectedDirs.some((d) => {
706
+ const dir = d.endsWith("/") ? d.slice(0, -1) : d;
707
+ return fullPath === dir || fullPath.startsWith(dir + "/");
708
+ });
709
+ }
710
+ async function purgeKeepFiles(fs, options = {}) {
711
+ const root = options.root && options.root !== "" ? options.root : "/";
712
+ const dryRun = options.dryRun ?? false;
713
+ const protectedDirs = options.protectedDirs ?? DEFAULT_PROTECTED_DIRS;
714
+ const result = { scanned: 0, removed: [], failed: [] };
715
+ const stack = [root];
716
+ while (stack.length > 0) {
717
+ const dir = stack.pop();
718
+ if (isProtected(dir, protectedDirs)) continue;
719
+ let entries;
720
+ try {
721
+ entries = await fs.readdir(dir);
722
+ } catch {
723
+ continue;
724
+ }
725
+ for (const entry of entries) {
726
+ const fullPath = joinPath2(dir, entry);
727
+ if (isProtected(fullPath, protectedDirs)) continue;
728
+ let stat;
729
+ try {
730
+ stat = await fs.stat(fullPath);
731
+ } catch {
732
+ continue;
733
+ }
734
+ if (isDirectory2(stat)) {
735
+ stack.push(fullPath);
736
+ continue;
737
+ }
738
+ result.scanned++;
739
+ if (!isKeepFile(entry)) continue;
740
+ if (dryRun) {
741
+ result.removed.push(fullPath);
742
+ continue;
743
+ }
744
+ try {
745
+ await fs.unlink(fullPath);
746
+ result.removed.push(fullPath);
747
+ } catch {
748
+ result.failed.push(fullPath);
749
+ }
750
+ }
751
+ }
752
+ return result;
753
+ }
754
+
755
+ // src/version-migration.ts
756
+ var S_IFDIR3 = 16384;
757
+ function isDir(stat) {
758
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR3) === S_IFDIR3;
759
+ }
760
+ function joinPath3(dir, entry) {
761
+ if (dir === "/" || dir === "") return `/${entry}`;
762
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
763
+ }
700
764
  async function existsPath(fs, p) {
701
765
  try {
702
766
  await fs.stat(p);
@@ -711,8 +775,8 @@ function configNameFromLegacy(entry) {
711
775
  }
712
776
  async function resolveTarget(fs, dir, entry) {
713
777
  const configName = configNameFromLegacy(entry);
714
- const configPath = joinPath2(dir, configName);
715
- const dotConfigPath = joinPath2(dir, `.${configName}`);
778
+ const configPath = joinPath3(dir, configName);
779
+ const dotConfigPath = joinPath3(dir, `.${configName}`);
716
780
  if (await existsPath(fs, dotConfigPath)) {
717
781
  return { action: "skip" };
718
782
  }
@@ -741,7 +805,7 @@ async function migrateVersionSidecars(fs, options = {}) {
741
805
  continue;
742
806
  }
743
807
  for (const entry of entries) {
744
- const full = joinPath2(dir, entry);
808
+ const full = joinPath3(dir, entry);
745
809
  let stat;
746
810
  try {
747
811
  stat = await fs.stat(full);
@@ -1295,6 +1359,32 @@ var ConfigRepo = class {
1295
1359
  }
1296
1360
  return result;
1297
1361
  }
1362
+ /**
1363
+ * Delete leaked `.keep` placeholders from the local primary backend.
1364
+ *
1365
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
1366
+ * directory alive with an internal `.keep` placeholder. Older builds copied
1367
+ * those backend-internal files into the local primary, where sync ignores
1368
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
1369
+ * protected and never removed.
1370
+ *
1371
+ * @param options.root Scan only this subtree (default `/`).
1372
+ * @param options.dryRun List the placeholders without deleting them.
1373
+ */
1374
+ async purgeKeepFiles(options) {
1375
+ this.assertNotDisposed();
1376
+ const result = await purgeKeepFiles(this.cachedFS, options);
1377
+ if (result.removed.length > 0) {
1378
+ log2.log(
1379
+ `[ConfigRepo] purged ${result.removed.length} .keep placeholder(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
1380
+ result.removed
1381
+ );
1382
+ }
1383
+ if (result.failed.length > 0) {
1384
+ log2.warn(`[ConfigRepo] failed to purge ${result.failed.length} .keep placeholder(s):`, result.failed);
1385
+ }
1386
+ return result;
1387
+ }
1298
1388
  /**
1299
1389
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
1300
1390
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -1545,7 +1635,7 @@ var ConfigRepo = class {
1545
1635
  const instance = await createBackend(desc);
1546
1636
  let fsInstance = instance;
1547
1637
  if (this.cacheOptions) {
1548
- const { wrapWithCache } = await import("./cache-wrapper-XQCPZJCD.mjs");
1638
+ const { wrapWithCache } = await import("./cache-wrapper-S5HRLRQX.mjs");
1549
1639
  fsInstance = wrapWithCache(instance, desc.id, this.cacheOptions);
1550
1640
  log2.log(`[ConfigRepo] Replica ${desc.id} wrapped with CachedFileSystem (store=${this.cacheOptions.storeType ?? "IdbCacheStore"})`);
1551
1641
  }
@@ -2265,6 +2355,17 @@ var AppDataGroupImpl = class {
2265
2355
  } catch (err) {
2266
2356
  log2.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err?.message ?? err);
2267
2357
  }
2358
+ try {
2359
+ const purgedKeep = await purgeKeepFiles(this.localFS);
2360
+ if (purgedKeep.removed.length > 0) {
2361
+ log2.warn(
2362
+ `[AppDataGroup:${this.groupId}] purged ${purgedKeep.removed.length} leaked .keep placeholder(s):`,
2363
+ purgedKeep.removed
2364
+ );
2365
+ }
2366
+ } catch (err) {
2367
+ log2.warn(`[AppDataGroup:${this.groupId}] .keep purge failed:`, err?.message ?? err);
2368
+ }
2268
2369
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
2269
2370
  this.fs = createChrootFS(this.localFS, "/");
2270
2371
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -2404,6 +2505,19 @@ async function createConfigRepo(appId, options = {}) {
2404
2505
  log2.warn(`[createConfigRepo] .mtime purge failed:`, err?.message ?? err);
2405
2506
  }
2406
2507
  }
2508
+ if (options.purgeKeepFiles !== false) {
2509
+ try {
2510
+ const purged = await purgeKeepFiles(cachedFS);
2511
+ if (purged.removed.length > 0) {
2512
+ log2.warn(
2513
+ `[createConfigRepo] purged ${purged.removed.length} leaked .keep placeholder(s) from local primary:`,
2514
+ purged.removed
2515
+ );
2516
+ }
2517
+ } catch (err) {
2518
+ log2.warn(`[createConfigRepo] .keep purge failed:`, err?.message ?? err);
2519
+ }
2520
+ }
2407
2521
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
2408
2522
  try {
2409
2523
  await primaryInstance.mkdir(META_DIR);
@@ -2643,6 +2757,7 @@ export {
2643
2757
  hasBackend,
2644
2758
  incrementVersion,
2645
2759
  isBrowserEnv,
2760
+ isKeepFile,
2646
2761
  isMtimeSidecar,
2647
2762
  legacyVersionPathFor,
2648
2763
  listBackendMetadata,
@@ -2650,6 +2765,7 @@ export {
2650
2765
  localPrimaryType,
2651
2766
  mergeAccountFields,
2652
2767
  migrateVersionSidecars,
2768
+ purgeKeepFiles,
2653
2769
  purgeMtimeSidecars,
2654
2770
  readVersion,
2655
2771
  registerBackend,
@@ -24886,10 +24886,35 @@ var ZenFSConfig = (() => {
24886
24886
  }
24887
24887
  async exists(path) {
24888
24888
  try {
24889
+ const innerType = this.inner?.constructor?.name ?? typeof this.inner;
24890
+ const hasStat = typeof this.inner?.stat === "function";
24891
+ const hasExists = typeof this.inner?.exists === "function";
24892
+ const backendName = this.inner?.backendName ?? this.inner?.constructor?.name ?? "unknown";
24893
+ console.log(`[CACHE-EXISTS] exists(${path}): inner=${innerType} backend=${backendName} hasStat=${hasStat} hasExists=${hasExists}`);
24894
+ const statKey = `stat:${path}`;
24895
+ const cachedStat = await this.store.get(statKey);
24896
+ if (cachedStat) {
24897
+ const age = Date.now() - cachedStat.cachedAt;
24898
+ console.log(`[CACHE-EXISTS] exists(${path}): cached stat entry found, age=${age}ms, ttlMs=${this.ttlMs}`);
24899
+ } else {
24900
+ console.log(`[CACHE-EXISTS] exists(${path}): no cached stat entry`);
24901
+ }
24902
+ if (hasExists) {
24903
+ console.log(`[CACHE-EXISTS] exists(${path}): \u2192 calling inner.exists() [backend=${backendName}]`);
24904
+ const result = await this.inner.exists(path);
24905
+ console.log(`[CACHE-EXISTS] exists(${path}): \u2190 inner.exists() [backend=${backendName}] \u2192 ${result}`);
24906
+ return result;
24907
+ }
24908
+ console.log(`[CACHE-EXISTS] exists(${path}): no inner.exists(), falling back to inner.stat() [backend=${backendName}]`);
24889
24909
  await this.inner.stat(path);
24910
+ console.log(`[CACHE-EXISTS] exists(${path}): \u2190 inner.stat() [backend=${backendName}] \u2192 OK (exists)`);
24890
24911
  return true;
24891
24912
  } catch (err2) {
24892
- if (this.isNotFound(err2)) return false;
24913
+ if (this.isNotFound(err2)) {
24914
+ console.log(`[CACHE-EXISTS] exists(${path}): \u2190 inner.stat() \u2192 NotFound`);
24915
+ return false;
24916
+ }
24917
+ console.log(`[CACHE-EXISTS] exists(${path}): \u2190 inner.stat() \u2192 ERROR: ${err2}`);
24893
24918
  throw err2;
24894
24919
  }
24895
24920
  }
@@ -24922,7 +24947,10 @@ var ZenFSConfig = (() => {
24922
24947
  await this.invalidateMeta(path);
24923
24948
  }
24924
24949
  async unlink(path) {
24950
+ const backendName = this.inner?.backendName ?? this.inner?.constructor?.name ?? "unknown";
24951
+ console.log(`[CACHE-UNLINK] unlink(${path}): calling inner.unlink() [backend=${backendName}]`);
24925
24952
  await this.inner.unlink(path);
24953
+ console.log(`[CACHE-UNLINK] unlink(${path}): inner.unlink() OK [backend=${backendName}], invalidating cache`);
24926
24954
  await this.invalidate(path);
24927
24955
  }
24928
24956
  async mkdir(path, options) {
@@ -24981,6 +25009,12 @@ var ZenFSConfig = (() => {
24981
25009
  async clear() {
24982
25010
  this.store.clear();
24983
25011
  }
25012
+ async purgeKeepFiles() {
25013
+ for (const key of Array.from(this.store.keys())) {
25014
+ const base = key === "/" ? "" : key.split("/").pop() ?? "";
25015
+ if (base === ".keep") this.store.delete(key);
25016
+ }
25017
+ }
24984
25018
  };
24985
25019
  IdbCacheStore = class {
24986
25020
  constructor(prefix = "zen-fs-cache:", dbName = "zen-fs-cache", storeName = "cache") {
@@ -25061,6 +25095,28 @@ var ZenFSConfig = (() => {
25061
25095
  } catch {
25062
25096
  }
25063
25097
  }
25098
+ async purgeKeepFiles() {
25099
+ try {
25100
+ const db = await this.open();
25101
+ await new Promise((resolve4, reject) => {
25102
+ const tx = db.transaction(this.storeName, "readwrite");
25103
+ const store = tx.objectStore(this.storeName);
25104
+ const req = store.getAllKeys();
25105
+ req.onsuccess = () => {
25106
+ const keys = req.result ?? [];
25107
+ for (const raw of keys) {
25108
+ const path = raw.startsWith(this.prefix) ? raw.slice(this.prefix.length) : raw;
25109
+ const base = path === "/" ? "" : path.split("/").pop() ?? "";
25110
+ if (base === ".keep") store.delete(raw);
25111
+ }
25112
+ };
25113
+ req.onerror = () => reject(req.error);
25114
+ tx.oncomplete = () => resolve4();
25115
+ tx.onerror = () => reject(tx.error);
25116
+ });
25117
+ } catch {
25118
+ }
25119
+ }
25064
25120
  };
25065
25121
  }
25066
25122
  });
@@ -25079,12 +25135,23 @@ var ZenFSConfig = (() => {
25079
25135
  } else {
25080
25136
  store = new MemoryCacheStore();
25081
25137
  }
25138
+ try {
25139
+ const purge = store.purgeKeepFiles?.();
25140
+ if (purge && typeof purge.catch === "function") {
25141
+ purge.catch(() => {
25142
+ });
25143
+ }
25144
+ } catch {
25145
+ }
25082
25146
  const wrapped = new CachedFileSystem(backend, store, {
25083
25147
  ttlMs: options.ttlMs ?? 0
25084
25148
  });
25085
25149
  if (typeof backend.shouldSync === "function") {
25086
25150
  wrapped.shouldSync = (...args) => backend.shouldSync(...args);
25087
25151
  }
25152
+ if (typeof backend.writeFileWithMtime === "function") {
25153
+ wrapped.writeFileWithMtime = (path, data, mtimeMs) => backend.writeFileWithMtime(path, data, mtimeMs);
25154
+ }
25088
25155
  return wrapped;
25089
25156
  }
25090
25157
  var init_cache_wrapper = __esm({
@@ -25111,6 +25178,7 @@ var ZenFSConfig = (() => {
25111
25178
  hasBackend: () => hasBackend,
25112
25179
  incrementVersion: () => incrementVersion,
25113
25180
  isBrowserEnv: () => isBrowserEnv,
25181
+ isKeepFile: () => isKeepFile,
25114
25182
  isMtimeSidecar: () => isMtimeSidecar,
25115
25183
  legacyVersionPathFor: () => legacyVersionPathFor,
25116
25184
  listBackendMetadata: () => listBackendMetadata,
@@ -25118,6 +25186,7 @@ var ZenFSConfig = (() => {
25118
25186
  localPrimaryType: () => localPrimaryType,
25119
25187
  mergeAccountFields: () => mergeAccountFields,
25120
25188
  migrateVersionSidecars: () => migrateVersionSidecars,
25189
+ purgeKeepFiles: () => purgeKeepFiles,
25121
25190
  purgeMtimeSidecars: () => purgeMtimeSidecars,
25122
25191
  readVersion: () => readVersion,
25123
25192
  registerBackend: () => registerBackend,
@@ -26994,15 +27063,79 @@ var ZenFSConfig = (() => {
26994
27063
  return result;
26995
27064
  }
26996
27065
 
26997
- // src/version-migration.ts
27066
+ // src/keep-cleanup.ts
27067
+ function isKeepFile(fileName) {
27068
+ return fileName === ".keep";
27069
+ }
26998
27070
  var S_IFDIR3 = 16384;
26999
- function isDir(stat5) {
27071
+ var DEFAULT_PROTECTED_DIRS = ["/.meta"];
27072
+ function isDirectory4(stat5) {
27000
27073
  return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR3) === S_IFDIR3;
27001
27074
  }
27002
27075
  function joinPath2(dir, entry) {
27003
27076
  if (dir === "/" || dir === "") return `/${entry}`;
27004
27077
  return `${dir.replace(/\/+$/, "")}/${entry}`;
27005
27078
  }
27079
+ function isProtected(fullPath, protectedDirs) {
27080
+ return protectedDirs.some((d) => {
27081
+ const dir = d.endsWith("/") ? d.slice(0, -1) : d;
27082
+ return fullPath === dir || fullPath.startsWith(dir + "/");
27083
+ });
27084
+ }
27085
+ async function purgeKeepFiles(fs, options = {}) {
27086
+ const root = options.root && options.root !== "" ? options.root : "/";
27087
+ const dryRun = options.dryRun ?? false;
27088
+ const protectedDirs = options.protectedDirs ?? DEFAULT_PROTECTED_DIRS;
27089
+ const result = { scanned: 0, removed: [], failed: [] };
27090
+ const stack = [root];
27091
+ while (stack.length > 0) {
27092
+ const dir = stack.pop();
27093
+ if (isProtected(dir, protectedDirs)) continue;
27094
+ let entries2;
27095
+ try {
27096
+ entries2 = await fs.readdir(dir);
27097
+ } catch {
27098
+ continue;
27099
+ }
27100
+ for (const entry of entries2) {
27101
+ const fullPath = joinPath2(dir, entry);
27102
+ if (isProtected(fullPath, protectedDirs)) continue;
27103
+ let stat5;
27104
+ try {
27105
+ stat5 = await fs.stat(fullPath);
27106
+ } catch {
27107
+ continue;
27108
+ }
27109
+ if (isDirectory4(stat5)) {
27110
+ stack.push(fullPath);
27111
+ continue;
27112
+ }
27113
+ result.scanned++;
27114
+ if (!isKeepFile(entry)) continue;
27115
+ if (dryRun) {
27116
+ result.removed.push(fullPath);
27117
+ continue;
27118
+ }
27119
+ try {
27120
+ await fs.unlink(fullPath);
27121
+ result.removed.push(fullPath);
27122
+ } catch {
27123
+ result.failed.push(fullPath);
27124
+ }
27125
+ }
27126
+ }
27127
+ return result;
27128
+ }
27129
+
27130
+ // src/version-migration.ts
27131
+ var S_IFDIR4 = 16384;
27132
+ function isDir(stat5) {
27133
+ return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR4) === S_IFDIR4;
27134
+ }
27135
+ function joinPath3(dir, entry) {
27136
+ if (dir === "/" || dir === "") return `/${entry}`;
27137
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
27138
+ }
27006
27139
  async function existsPath(fs, p) {
27007
27140
  try {
27008
27141
  await fs.stat(p);
@@ -27017,8 +27150,8 @@ var ZenFSConfig = (() => {
27017
27150
  }
27018
27151
  async function resolveTarget(fs, dir, entry) {
27019
27152
  const configName = configNameFromLegacy(entry);
27020
- const configPath = joinPath2(dir, configName);
27021
- const dotConfigPath = joinPath2(dir, `.${configName}`);
27153
+ const configPath = joinPath3(dir, configName);
27154
+ const dotConfigPath = joinPath3(dir, `.${configName}`);
27022
27155
  if (await existsPath(fs, dotConfigPath)) {
27023
27156
  return { action: "skip" };
27024
27157
  }
@@ -27047,7 +27180,7 @@ var ZenFSConfig = (() => {
27047
27180
  continue;
27048
27181
  }
27049
27182
  for (const entry of entries2) {
27050
- const full = joinPath2(dir, entry);
27183
+ const full = joinPath3(dir, entry);
27051
27184
  let stat5;
27052
27185
  try {
27053
27186
  stat5 = await fs.stat(full);
@@ -27601,6 +27734,32 @@ var ZenFSConfig = (() => {
27601
27734
  }
27602
27735
  return result;
27603
27736
  }
27737
+ /**
27738
+ * Delete leaked `.keep` placeholders from the local primary backend.
27739
+ *
27740
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
27741
+ * directory alive with an internal `.keep` placeholder. Older builds copied
27742
+ * those backend-internal files into the local primary, where sync ignores
27743
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
27744
+ * protected and never removed.
27745
+ *
27746
+ * @param options.root Scan only this subtree (default `/`).
27747
+ * @param options.dryRun List the placeholders without deleting them.
27748
+ */
27749
+ async purgeKeepFiles(options) {
27750
+ this.assertNotDisposed();
27751
+ const result = await purgeKeepFiles(this.cachedFS, options);
27752
+ if (result.removed.length > 0) {
27753
+ log6.log(
27754
+ `[ConfigRepo] purged ${result.removed.length} .keep placeholder(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
27755
+ result.removed
27756
+ );
27757
+ }
27758
+ if (result.failed.length > 0) {
27759
+ log6.warn(`[ConfigRepo] failed to purge ${result.failed.length} .keep placeholder(s):`, result.failed);
27760
+ }
27761
+ return result;
27762
+ }
27604
27763
  /**
27605
27764
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
27606
27765
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -28571,6 +28730,17 @@ var ZenFSConfig = (() => {
28571
28730
  } catch (err2) {
28572
28731
  log6.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err2?.message ?? err2);
28573
28732
  }
28733
+ try {
28734
+ const purgedKeep = await purgeKeepFiles(this.localFS);
28735
+ if (purgedKeep.removed.length > 0) {
28736
+ log6.warn(
28737
+ `[AppDataGroup:${this.groupId}] purged ${purgedKeep.removed.length} leaked .keep placeholder(s):`,
28738
+ purgedKeep.removed
28739
+ );
28740
+ }
28741
+ } catch (err2) {
28742
+ log6.warn(`[AppDataGroup:${this.groupId}] .keep purge failed:`, err2?.message ?? err2);
28743
+ }
28574
28744
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
28575
28745
  this.fs = createChrootFS(this.localFS, "/");
28576
28746
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -28710,6 +28880,19 @@ var ZenFSConfig = (() => {
28710
28880
  log6.warn(`[createConfigRepo] .mtime purge failed:`, err2?.message ?? err2);
28711
28881
  }
28712
28882
  }
28883
+ if (options.purgeKeepFiles !== false) {
28884
+ try {
28885
+ const purged = await purgeKeepFiles(cachedFS);
28886
+ if (purged.removed.length > 0) {
28887
+ log6.warn(
28888
+ `[createConfigRepo] purged ${purged.removed.length} leaked .keep placeholder(s) from local primary:`,
28889
+ purged.removed
28890
+ );
28891
+ }
28892
+ } catch (err2) {
28893
+ log6.warn(`[createConfigRepo] .keep purge failed:`, err2?.message ?? err2);
28894
+ }
28895
+ }
28713
28896
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
28714
28897
  try {
28715
28898
  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.33",
3
+ "version": "0.5.36",
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",
@@ -80,7 +80,7 @@
80
80
  "tsup": "^8.5.1",
81
81
  "typescript": "^5.9.3",
82
82
  "vitest": "^1.6.1",
83
- "zen-fs-cache": "^1.0.1",
83
+ "zen-fs-cache": "^1.0.6",
84
84
  "zen-fs-sync": "^0.4.9"
85
85
  }
86
86
  }