zen-fs-config 0.5.33 → 0.5.34

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
@@ -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
@@ -418,6 +418,7 @@ __export(index_exports, {
418
418
  hasBackend: () => hasBackend,
419
419
  incrementVersion: () => incrementVersion,
420
420
  isBrowserEnv: () => isBrowserEnv,
421
+ isKeepFile: () => isKeepFile,
421
422
  isMtimeSidecar: () => isMtimeSidecar,
422
423
  legacyVersionPathFor: () => legacyVersionPathFor,
423
424
  listBackendMetadata: () => listBackendMetadata,
@@ -425,6 +426,7 @@ __export(index_exports, {
425
426
  localPrimaryType: () => localPrimaryType,
426
427
  mergeAccountFields: () => mergeAccountFields,
427
428
  migrateVersionSidecars: () => migrateVersionSidecars,
429
+ purgeKeepFiles: () => purgeKeepFiles,
428
430
  purgeMtimeSidecars: () => purgeMtimeSidecars,
429
431
  readVersion: () => readVersion,
430
432
  registerBackend: () => registerBackend,
@@ -1117,15 +1119,79 @@ async function purgeMtimeSidecars(fs, options = {}) {
1117
1119
  return result;
1118
1120
  }
1119
1121
 
1120
- // src/version-migration.ts
1122
+ // src/keep-cleanup.ts
1123
+ function isKeepFile(fileName) {
1124
+ return fileName === ".keep";
1125
+ }
1121
1126
  var S_IFDIR2 = 16384;
1122
- function isDir(stat) {
1127
+ var DEFAULT_PROTECTED_DIRS = ["/.meta"];
1128
+ function isDirectory2(stat) {
1123
1129
  return typeof stat.mode === "number" && (stat.mode & S_IFDIR2) === S_IFDIR2;
1124
1130
  }
1125
1131
  function joinPath2(dir, entry) {
1126
1132
  if (dir === "/" || dir === "") return `/${entry}`;
1127
1133
  return `${dir.replace(/\/+$/, "")}/${entry}`;
1128
1134
  }
1135
+ function isProtected(fullPath, protectedDirs) {
1136
+ return protectedDirs.some((d) => {
1137
+ const dir = d.endsWith("/") ? d.slice(0, -1) : d;
1138
+ return fullPath === dir || fullPath.startsWith(dir + "/");
1139
+ });
1140
+ }
1141
+ async function purgeKeepFiles(fs, options = {}) {
1142
+ const root = options.root && options.root !== "" ? options.root : "/";
1143
+ const dryRun = options.dryRun ?? false;
1144
+ const protectedDirs = options.protectedDirs ?? DEFAULT_PROTECTED_DIRS;
1145
+ const result = { scanned: 0, removed: [], failed: [] };
1146
+ const stack = [root];
1147
+ while (stack.length > 0) {
1148
+ const dir = stack.pop();
1149
+ if (isProtected(dir, protectedDirs)) continue;
1150
+ let entries;
1151
+ try {
1152
+ entries = await fs.readdir(dir);
1153
+ } catch {
1154
+ continue;
1155
+ }
1156
+ for (const entry of entries) {
1157
+ const fullPath = joinPath2(dir, entry);
1158
+ if (isProtected(fullPath, protectedDirs)) continue;
1159
+ let stat;
1160
+ try {
1161
+ stat = await fs.stat(fullPath);
1162
+ } catch {
1163
+ continue;
1164
+ }
1165
+ if (isDirectory2(stat)) {
1166
+ stack.push(fullPath);
1167
+ continue;
1168
+ }
1169
+ result.scanned++;
1170
+ if (!isKeepFile(entry)) continue;
1171
+ if (dryRun) {
1172
+ result.removed.push(fullPath);
1173
+ continue;
1174
+ }
1175
+ try {
1176
+ await fs.unlink(fullPath);
1177
+ result.removed.push(fullPath);
1178
+ } catch {
1179
+ result.failed.push(fullPath);
1180
+ }
1181
+ }
1182
+ }
1183
+ return result;
1184
+ }
1185
+
1186
+ // src/version-migration.ts
1187
+ var S_IFDIR3 = 16384;
1188
+ function isDir(stat) {
1189
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR3) === S_IFDIR3;
1190
+ }
1191
+ function joinPath3(dir, entry) {
1192
+ if (dir === "/" || dir === "") return `/${entry}`;
1193
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
1194
+ }
1129
1195
  async function existsPath(fs, p) {
1130
1196
  try {
1131
1197
  await fs.stat(p);
@@ -1140,8 +1206,8 @@ function configNameFromLegacy(entry) {
1140
1206
  }
1141
1207
  async function resolveTarget(fs, dir, entry) {
1142
1208
  const configName = configNameFromLegacy(entry);
1143
- const configPath = joinPath2(dir, configName);
1144
- const dotConfigPath = joinPath2(dir, `.${configName}`);
1209
+ const configPath = joinPath3(dir, configName);
1210
+ const dotConfigPath = joinPath3(dir, `.${configName}`);
1145
1211
  if (await existsPath(fs, dotConfigPath)) {
1146
1212
  return { action: "skip" };
1147
1213
  }
@@ -1170,7 +1236,7 @@ async function migrateVersionSidecars(fs, options = {}) {
1170
1236
  continue;
1171
1237
  }
1172
1238
  for (const entry of entries) {
1173
- const full = joinPath2(dir, entry);
1239
+ const full = joinPath3(dir, entry);
1174
1240
  let stat;
1175
1241
  try {
1176
1242
  stat = await fs.stat(full);
@@ -1724,6 +1790,32 @@ var ConfigRepo = class {
1724
1790
  }
1725
1791
  return result;
1726
1792
  }
1793
+ /**
1794
+ * Delete leaked `.keep` placeholders from the local primary backend.
1795
+ *
1796
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
1797
+ * directory alive with an internal `.keep` placeholder. Older builds copied
1798
+ * those backend-internal files into the local primary, where sync ignores
1799
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
1800
+ * protected and never removed.
1801
+ *
1802
+ * @param options.root Scan only this subtree (default `/`).
1803
+ * @param options.dryRun List the placeholders without deleting them.
1804
+ */
1805
+ async purgeKeepFiles(options) {
1806
+ this.assertNotDisposed();
1807
+ const result = await purgeKeepFiles(this.cachedFS, options);
1808
+ if (result.removed.length > 0) {
1809
+ log2.log(
1810
+ `[ConfigRepo] purged ${result.removed.length} .keep placeholder(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
1811
+ result.removed
1812
+ );
1813
+ }
1814
+ if (result.failed.length > 0) {
1815
+ log2.warn(`[ConfigRepo] failed to purge ${result.failed.length} .keep placeholder(s):`, result.failed);
1816
+ }
1817
+ return result;
1818
+ }
1727
1819
  /**
1728
1820
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
1729
1821
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -2694,6 +2786,17 @@ var AppDataGroupImpl = class {
2694
2786
  } catch (err) {
2695
2787
  log2.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err?.message ?? err);
2696
2788
  }
2789
+ try {
2790
+ const purgedKeep = await purgeKeepFiles(this.localFS);
2791
+ if (purgedKeep.removed.length > 0) {
2792
+ log2.warn(
2793
+ `[AppDataGroup:${this.groupId}] purged ${purgedKeep.removed.length} leaked .keep placeholder(s):`,
2794
+ purgedKeep.removed
2795
+ );
2796
+ }
2797
+ } catch (err) {
2798
+ log2.warn(`[AppDataGroup:${this.groupId}] .keep purge failed:`, err?.message ?? err);
2799
+ }
2697
2800
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
2698
2801
  this.fs = createChrootFS(this.localFS, "/");
2699
2802
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -2833,6 +2936,19 @@ async function createConfigRepo(appId, options = {}) {
2833
2936
  log2.warn(`[createConfigRepo] .mtime purge failed:`, err?.message ?? err);
2834
2937
  }
2835
2938
  }
2939
+ if (options.purgeKeepFiles !== false) {
2940
+ try {
2941
+ const purged = await purgeKeepFiles(cachedFS);
2942
+ if (purged.removed.length > 0) {
2943
+ log2.warn(
2944
+ `[createConfigRepo] purged ${purged.removed.length} leaked .keep placeholder(s) from local primary:`,
2945
+ purged.removed
2946
+ );
2947
+ }
2948
+ } catch (err) {
2949
+ log2.warn(`[createConfigRepo] .keep purge failed:`, err?.message ?? err);
2950
+ }
2951
+ }
2836
2952
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
2837
2953
  try {
2838
2954
  await primaryInstance.mkdir(META_DIR);
@@ -3077,6 +3193,7 @@ init_backend_registry();
3077
3193
  hasBackend,
3078
3194
  incrementVersion,
3079
3195
  isBrowserEnv,
3196
+ isKeepFile,
3080
3197
  isMtimeSidecar,
3081
3198
  legacyVersionPathFor,
3082
3199
  listBackendMetadata,
@@ -3084,6 +3201,7 @@ init_backend_registry();
3084
3201
  localPrimaryType,
3085
3202
  mergeAccountFields,
3086
3203
  migrateVersionSidecars,
3204
+ purgeKeepFiles,
3087
3205
  purgeMtimeSidecars,
3088
3206
  readVersion,
3089
3207
  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)
@@ -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,
@@ -25111,6 +25111,7 @@ var ZenFSConfig = (() => {
25111
25111
  hasBackend: () => hasBackend,
25112
25112
  incrementVersion: () => incrementVersion,
25113
25113
  isBrowserEnv: () => isBrowserEnv,
25114
+ isKeepFile: () => isKeepFile,
25114
25115
  isMtimeSidecar: () => isMtimeSidecar,
25115
25116
  legacyVersionPathFor: () => legacyVersionPathFor,
25116
25117
  listBackendMetadata: () => listBackendMetadata,
@@ -25118,6 +25119,7 @@ var ZenFSConfig = (() => {
25118
25119
  localPrimaryType: () => localPrimaryType,
25119
25120
  mergeAccountFields: () => mergeAccountFields,
25120
25121
  migrateVersionSidecars: () => migrateVersionSidecars,
25122
+ purgeKeepFiles: () => purgeKeepFiles,
25121
25123
  purgeMtimeSidecars: () => purgeMtimeSidecars,
25122
25124
  readVersion: () => readVersion,
25123
25125
  registerBackend: () => registerBackend,
@@ -26994,15 +26996,79 @@ var ZenFSConfig = (() => {
26994
26996
  return result;
26995
26997
  }
26996
26998
 
26997
- // src/version-migration.ts
26999
+ // src/keep-cleanup.ts
27000
+ function isKeepFile(fileName) {
27001
+ return fileName === ".keep";
27002
+ }
26998
27003
  var S_IFDIR3 = 16384;
26999
- function isDir(stat5) {
27004
+ var DEFAULT_PROTECTED_DIRS = ["/.meta"];
27005
+ function isDirectory4(stat5) {
27000
27006
  return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR3) === S_IFDIR3;
27001
27007
  }
27002
27008
  function joinPath2(dir, entry) {
27003
27009
  if (dir === "/" || dir === "") return `/${entry}`;
27004
27010
  return `${dir.replace(/\/+$/, "")}/${entry}`;
27005
27011
  }
27012
+ function isProtected(fullPath, protectedDirs) {
27013
+ return protectedDirs.some((d) => {
27014
+ const dir = d.endsWith("/") ? d.slice(0, -1) : d;
27015
+ return fullPath === dir || fullPath.startsWith(dir + "/");
27016
+ });
27017
+ }
27018
+ async function purgeKeepFiles(fs, options = {}) {
27019
+ const root = options.root && options.root !== "" ? options.root : "/";
27020
+ const dryRun = options.dryRun ?? false;
27021
+ const protectedDirs = options.protectedDirs ?? DEFAULT_PROTECTED_DIRS;
27022
+ const result = { scanned: 0, removed: [], failed: [] };
27023
+ const stack = [root];
27024
+ while (stack.length > 0) {
27025
+ const dir = stack.pop();
27026
+ if (isProtected(dir, protectedDirs)) continue;
27027
+ let entries2;
27028
+ try {
27029
+ entries2 = await fs.readdir(dir);
27030
+ } catch {
27031
+ continue;
27032
+ }
27033
+ for (const entry of entries2) {
27034
+ const fullPath = joinPath2(dir, entry);
27035
+ if (isProtected(fullPath, protectedDirs)) continue;
27036
+ let stat5;
27037
+ try {
27038
+ stat5 = await fs.stat(fullPath);
27039
+ } catch {
27040
+ continue;
27041
+ }
27042
+ if (isDirectory4(stat5)) {
27043
+ stack.push(fullPath);
27044
+ continue;
27045
+ }
27046
+ result.scanned++;
27047
+ if (!isKeepFile(entry)) continue;
27048
+ if (dryRun) {
27049
+ result.removed.push(fullPath);
27050
+ continue;
27051
+ }
27052
+ try {
27053
+ await fs.unlink(fullPath);
27054
+ result.removed.push(fullPath);
27055
+ } catch {
27056
+ result.failed.push(fullPath);
27057
+ }
27058
+ }
27059
+ }
27060
+ return result;
27061
+ }
27062
+
27063
+ // src/version-migration.ts
27064
+ var S_IFDIR4 = 16384;
27065
+ function isDir(stat5) {
27066
+ return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR4) === S_IFDIR4;
27067
+ }
27068
+ function joinPath3(dir, entry) {
27069
+ if (dir === "/" || dir === "") return `/${entry}`;
27070
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
27071
+ }
27006
27072
  async function existsPath(fs, p) {
27007
27073
  try {
27008
27074
  await fs.stat(p);
@@ -27017,8 +27083,8 @@ var ZenFSConfig = (() => {
27017
27083
  }
27018
27084
  async function resolveTarget(fs, dir, entry) {
27019
27085
  const configName = configNameFromLegacy(entry);
27020
- const configPath = joinPath2(dir, configName);
27021
- const dotConfigPath = joinPath2(dir, `.${configName}`);
27086
+ const configPath = joinPath3(dir, configName);
27087
+ const dotConfigPath = joinPath3(dir, `.${configName}`);
27022
27088
  if (await existsPath(fs, dotConfigPath)) {
27023
27089
  return { action: "skip" };
27024
27090
  }
@@ -27047,7 +27113,7 @@ var ZenFSConfig = (() => {
27047
27113
  continue;
27048
27114
  }
27049
27115
  for (const entry of entries2) {
27050
- const full = joinPath2(dir, entry);
27116
+ const full = joinPath3(dir, entry);
27051
27117
  let stat5;
27052
27118
  try {
27053
27119
  stat5 = await fs.stat(full);
@@ -27601,6 +27667,32 @@ var ZenFSConfig = (() => {
27601
27667
  }
27602
27668
  return result;
27603
27669
  }
27670
+ /**
27671
+ * Delete leaked `.keep` placeholders from the local primary backend.
27672
+ *
27673
+ * Backends that cannot store empty directories (Gitee, RemoteStorage…) keep a
27674
+ * directory alive with an internal `.keep` placeholder. Older builds copied
27675
+ * those backend-internal files into the local primary, where sync ignores
27676
+ * them — so they linger forever. `/.meta/backends/.keep` (intentional) is
27677
+ * protected and never removed.
27678
+ *
27679
+ * @param options.root Scan only this subtree (default `/`).
27680
+ * @param options.dryRun List the placeholders without deleting them.
27681
+ */
27682
+ async purgeKeepFiles(options) {
27683
+ this.assertNotDisposed();
27684
+ const result = await purgeKeepFiles(this.cachedFS, options);
27685
+ if (result.removed.length > 0) {
27686
+ log6.log(
27687
+ `[ConfigRepo] purged ${result.removed.length} .keep placeholder(s) from local primary` + (options?.dryRun ? " (dry run)" : ""),
27688
+ result.removed
27689
+ );
27690
+ }
27691
+ if (result.failed.length > 0) {
27692
+ log6.warn(`[ConfigRepo] failed to purge ${result.failed.length} .keep placeholder(s):`, result.failed);
27693
+ }
27694
+ return result;
27695
+ }
27604
27696
  /**
27605
27697
  * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
27606
27698
  * `<name>.version` naming. Renames local sidecars (preserving content/history)
@@ -28571,6 +28663,17 @@ var ZenFSConfig = (() => {
28571
28663
  } catch (err2) {
28572
28664
  log6.warn(`[AppDataGroup:${this.groupId}] .mtime purge failed:`, err2?.message ?? err2);
28573
28665
  }
28666
+ try {
28667
+ const purgedKeep = await purgeKeepFiles(this.localFS);
28668
+ if (purgedKeep.removed.length > 0) {
28669
+ log6.warn(
28670
+ `[AppDataGroup:${this.groupId}] purged ${purgedKeep.removed.length} leaked .keep placeholder(s):`,
28671
+ purgedKeep.removed
28672
+ );
28673
+ }
28674
+ } catch (err2) {
28675
+ log6.warn(`[AppDataGroup:${this.groupId}] .keep purge failed:`, err2?.message ?? err2);
28676
+ }
28574
28677
  const localSyncable = backendToSyncableFS(this.localFS, `local(${this.groupId})`);
28575
28678
  this.fs = createChrootFS(this.localFS, "/");
28576
28679
  const { kept, removed } = dedupeAppDataBackends(this._backends);
@@ -28710,6 +28813,19 @@ var ZenFSConfig = (() => {
28710
28813
  log6.warn(`[createConfigRepo] .mtime purge failed:`, err2?.message ?? err2);
28711
28814
  }
28712
28815
  }
28816
+ if (options.purgeKeepFiles !== false) {
28817
+ try {
28818
+ const purged = await purgeKeepFiles(cachedFS);
28819
+ if (purged.removed.length > 0) {
28820
+ log6.warn(
28821
+ `[createConfigRepo] purged ${purged.removed.length} leaked .keep placeholder(s) from local primary:`,
28822
+ purged.removed
28823
+ );
28824
+ }
28825
+ } catch (err2) {
28826
+ log6.warn(`[createConfigRepo] .keep purge failed:`, err2?.message ?? err2);
28827
+ }
28828
+ }
28713
28829
  const cacheOptions = options.cache === false ? void 0 : options.cache ?? {};
28714
28830
  try {
28715
28831
  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.34",
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",