zen-fs-config 0.5.32 → 0.5.33

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
@@ -57,7 +57,7 @@ declare function isMtimeSidecar(fileName: string): boolean;
57
57
  * Delete every `.mtime` sidecar stored in a local primary backend.
58
58
  *
59
59
  * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`,
60
- * because sidecars live next to their data file and are themselves dotfiles.
60
+ * because sidecars live next to their data file as `<name>.mtime` files.
61
61
  * Unreadable entries are skipped; failures on individual files are collected
62
62
  * in `failed` instead of aborting the walk.
63
63
  */
@@ -229,6 +229,18 @@ interface ConfigRepoOptions {
229
229
  * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
230
  */
231
231
  purgeMtimeSidecars?: boolean;
232
+ /**
233
+ * Migrate legacy `.x.version` sidecars to the new `<name>.version` naming on
234
+ * startup (rename locally, delete the remote residual).
235
+ *
236
+ * Older builds stored version sidecars as hidden dotfiles (`.db.json.version`).
237
+ * They are now `<name>.version`. This one-time sweep renames existing legacy
238
+ * sidecars (preserving version history) and removes the old copy from every
239
+ * replica so sync does not pull it back.
240
+ *
241
+ * Default: `true`. Pass `false` to skip the startup sweep.
242
+ */
243
+ migrateVersionSidecars?: boolean;
232
244
  }
233
245
  /** Type of sync group. */
234
246
  type SyncGroupType = 'config-sync' | 'data-sync';
@@ -585,6 +597,71 @@ declare function getAccountFields(type: string): string[];
585
597
  declare function mergeAccountFields(targetType: string, sourceOptions: Record<string, unknown>, targetOptions: Record<string, unknown>): Record<string, unknown>;
586
598
  declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
587
599
 
600
+ /**
601
+ * zen-fs-config — Legacy Version Sidecar Migration
602
+ *
603
+ * Version sidecars used to be stored as hidden dotfiles:
604
+ *
605
+ * config file: /app-a/db.json
606
+ * version file: /app-a/.db.json.version (OLD — starts with `.`)
607
+ *
608
+ * They are now stored as `<name>.version` (no leading dot):
609
+ *
610
+ * version file: /app-a/db.json.version (NEW)
611
+ *
612
+ * This module migrates existing legacy sidecars to the new naming at startup,
613
+ * preserving version history (the file content is byte-identical after rename).
614
+ *
615
+ * Ambiguity safety: a legacy path `.x.version` could in theory belong to either
616
+ * a dotfile config `.x` (whose version is unchanged by the rename) or a
617
+ * non-dot config `x` (old naming). We resolve it by probing the directory:
618
+ * - `.x` exists → it's the dotfile config's version → NEW path == legacy → skip
619
+ * - `x` exists → rename legacy `.x.version` → `x.version`
620
+ * - neither → orphan (config gone) → delete
621
+ *
622
+ * We also delete the remote residual legacy path from every replica so the
623
+ * sync engine does not pull the old sidecar back (zen-fs-sync syncs `.version`
624
+ * files on both ends).
625
+ */
626
+ /** Minimal async FS surface needed to read/rename/delete on the local primary. */
627
+ interface MigrateLocalFS {
628
+ readdir(path: string): Promise<string[]>;
629
+ stat(path: string): Promise<{
630
+ mode?: number;
631
+ }>;
632
+ readFile(path: string, encoding?: string): Promise<Uint8Array | string>;
633
+ writeFile(path: string, data: Uint8Array): Promise<void>;
634
+ unlink(path: string): Promise<void>;
635
+ }
636
+ /** Minimal async FS surface needed to delete remote residual legacy sidecars. */
637
+ interface MigrateReplicaFS {
638
+ unlink(path: string): Promise<void>;
639
+ }
640
+ interface VersionMigrationOptions {
641
+ /** Root to scan. Default: `/` (the whole local primary). */
642
+ root?: string;
643
+ /** Report what would change without touching anything. */
644
+ dryRun?: boolean;
645
+ /** Replica backends whose legacy sidecars should be deleted. */
646
+ replicas?: MigrateReplicaFS[];
647
+ }
648
+ interface VersionMigrationResult {
649
+ /** Local legacy sidecars renamed to the new naming. */
650
+ renamed: string[];
651
+ /** Local orphan legacy sidecars deleted (no owning config). */
652
+ deleted: string[];
653
+ /** Remote residual legacy sidecars deleted (best-effort). */
654
+ remoteDeleted: string[];
655
+ /** Paths that failed to migrate. */
656
+ failed: string[];
657
+ }
658
+ /**
659
+ * Walk `fs` from `options.root` and migrate every legacy `.x.version` /
660
+ * `.x.version.mtime` sidecar to the new `<name>.version` / `<name>.version.mtime`
661
+ * naming. Best-effort: failures are collected in `failed`, never thrown.
662
+ */
663
+ declare function migrateVersionSidecars(fs: MigrateLocalFS, options?: VersionMigrationOptions): Promise<VersionMigrationResult>;
664
+
588
665
  /**
589
666
  * zen-fs-config — ConfigRepo Implementation
590
667
  *
@@ -690,6 +767,15 @@ declare class ConfigRepo implements IConfigRepo {
690
767
  * @param options.dryRun List the sidecars without deleting them.
691
768
  */
692
769
  purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
770
+ /**
771
+ * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
772
+ * `<name>.version` naming. Renames local sidecars (preserving content/history)
773
+ * and deletes the residual legacy copy from every replica backend so sync
774
+ * does not pull it back. Best-effort: failures are logged, never thrown.
775
+ *
776
+ * Runs once at startup (see createConfigRepo). Safe to call again.
777
+ */
778
+ migrateLegacyVersionSidecars(): Promise<VersionMigrationResult>;
693
779
  /**
694
780
  * Perform a full sync + dedup cycle without the watch snapshot cache.
695
781
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -876,24 +962,35 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
876
962
  /**
877
963
  * zen-fs-config — Sidecar Version File Management
878
964
  *
879
- * Each config file has a companion .version file for version-based change
880
- * detection and conflict resolution.
965
+ * Each config file has a companion `.version` sidecar file for version-based
966
+ * change detection and conflict resolution. The sidecar is named `<name>.version`
967
+ * (NOT `.name.version`) so it is not a hidden dotfile.
881
968
  *
882
969
  * Config file: /app-a/db.json
883
- * Version file: /app-a/.db.json.version
970
+ * Version file: /app-a/db.json.version
884
971
  */
885
972
 
886
973
  /**
887
974
  * Compute the sidecar version file path from a config file path.
888
975
  *
889
- * /app-a/db.json → /app-a/.db.json.version
890
- * /shared/flags.json → /shared/.flags.json.version
891
- * /nodes/s1/env.json → /nodes/s1/.env.json.version
976
+ * /app-a/db.json → /app-a/db.json.version
977
+ * /shared/flags.json → /shared/flags.json.version
978
+ * /nodes/s1/env.json → /nodes/s1/env.json.version
892
979
  *
893
980
  * Returns null for files that are already version sidecars (.version files),
894
- * to prevent creating version-of-version files (e.g. ..db.json.version.version).
981
+ * to prevent creating version-of-version files (e.g. db.json.version.version).
895
982
  */
896
983
  declare function versionPathFor(configFilePath: string): string | null;
984
+ /**
985
+ * Compute the *legacy* (pre-dotfile-removal) version sidecar path for a config
986
+ * file. Returns the old `.${name}.version` form for non-dot configs, or `null`
987
+ * for dotfile configs (whose version path is unchanged) and for version files
988
+ * themselves.
989
+ *
990
+ * Used as a read fallback so existing history in `.db.json.version` is still
991
+ * found after the naming scheme changed to `db.json.version`.
992
+ */
993
+ declare function legacyVersionPathFor(configFilePath: string): string | null;
897
994
  /**
898
995
  * Compute SHA-256 hash of a Uint8Array.
899
996
  * Returns "sha256:" prefix + hex digest.
@@ -923,4 +1020,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
923
1020
  */
924
1021
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
925
1022
 
926
- export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
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 };
package/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ declare function isMtimeSidecar(fileName: string): boolean;
57
57
  * Delete every `.mtime` sidecar stored in a local primary backend.
58
58
  *
59
59
  * Walks the whole tree from `options.root` — including dotfiles and `/.meta/`,
60
- * because sidecars live next to their data file and are themselves dotfiles.
60
+ * because sidecars live next to their data file as `<name>.mtime` files.
61
61
  * Unreadable entries are skipped; failures on individual files are collected
62
62
  * in `failed` instead of aborting the walk.
63
63
  */
@@ -229,6 +229,18 @@ interface ConfigRepoOptions {
229
229
  * Default: `true`. Pass `false` to skip the (local-only) startup sweep.
230
230
  */
231
231
  purgeMtimeSidecars?: boolean;
232
+ /**
233
+ * Migrate legacy `.x.version` sidecars to the new `<name>.version` naming on
234
+ * startup (rename locally, delete the remote residual).
235
+ *
236
+ * Older builds stored version sidecars as hidden dotfiles (`.db.json.version`).
237
+ * They are now `<name>.version`. This one-time sweep renames existing legacy
238
+ * sidecars (preserving version history) and removes the old copy from every
239
+ * replica so sync does not pull it back.
240
+ *
241
+ * Default: `true`. Pass `false` to skip the startup sweep.
242
+ */
243
+ migrateVersionSidecars?: boolean;
232
244
  }
233
245
  /** Type of sync group. */
234
246
  type SyncGroupType = 'config-sync' | 'data-sync';
@@ -585,6 +597,71 @@ declare function getAccountFields(type: string): string[];
585
597
  declare function mergeAccountFields(targetType: string, sourceOptions: Record<string, unknown>, targetOptions: Record<string, unknown>): Record<string, unknown>;
586
598
  declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
587
599
 
600
+ /**
601
+ * zen-fs-config — Legacy Version Sidecar Migration
602
+ *
603
+ * Version sidecars used to be stored as hidden dotfiles:
604
+ *
605
+ * config file: /app-a/db.json
606
+ * version file: /app-a/.db.json.version (OLD — starts with `.`)
607
+ *
608
+ * They are now stored as `<name>.version` (no leading dot):
609
+ *
610
+ * version file: /app-a/db.json.version (NEW)
611
+ *
612
+ * This module migrates existing legacy sidecars to the new naming at startup,
613
+ * preserving version history (the file content is byte-identical after rename).
614
+ *
615
+ * Ambiguity safety: a legacy path `.x.version` could in theory belong to either
616
+ * a dotfile config `.x` (whose version is unchanged by the rename) or a
617
+ * non-dot config `x` (old naming). We resolve it by probing the directory:
618
+ * - `.x` exists → it's the dotfile config's version → NEW path == legacy → skip
619
+ * - `x` exists → rename legacy `.x.version` → `x.version`
620
+ * - neither → orphan (config gone) → delete
621
+ *
622
+ * We also delete the remote residual legacy path from every replica so the
623
+ * sync engine does not pull the old sidecar back (zen-fs-sync syncs `.version`
624
+ * files on both ends).
625
+ */
626
+ /** Minimal async FS surface needed to read/rename/delete on the local primary. */
627
+ interface MigrateLocalFS {
628
+ readdir(path: string): Promise<string[]>;
629
+ stat(path: string): Promise<{
630
+ mode?: number;
631
+ }>;
632
+ readFile(path: string, encoding?: string): Promise<Uint8Array | string>;
633
+ writeFile(path: string, data: Uint8Array): Promise<void>;
634
+ unlink(path: string): Promise<void>;
635
+ }
636
+ /** Minimal async FS surface needed to delete remote residual legacy sidecars. */
637
+ interface MigrateReplicaFS {
638
+ unlink(path: string): Promise<void>;
639
+ }
640
+ interface VersionMigrationOptions {
641
+ /** Root to scan. Default: `/` (the whole local primary). */
642
+ root?: string;
643
+ /** Report what would change without touching anything. */
644
+ dryRun?: boolean;
645
+ /** Replica backends whose legacy sidecars should be deleted. */
646
+ replicas?: MigrateReplicaFS[];
647
+ }
648
+ interface VersionMigrationResult {
649
+ /** Local legacy sidecars renamed to the new naming. */
650
+ renamed: string[];
651
+ /** Local orphan legacy sidecars deleted (no owning config). */
652
+ deleted: string[];
653
+ /** Remote residual legacy sidecars deleted (best-effort). */
654
+ remoteDeleted: string[];
655
+ /** Paths that failed to migrate. */
656
+ failed: string[];
657
+ }
658
+ /**
659
+ * Walk `fs` from `options.root` and migrate every legacy `.x.version` /
660
+ * `.x.version.mtime` sidecar to the new `<name>.version` / `<name>.version.mtime`
661
+ * naming. Best-effort: failures are collected in `failed`, never thrown.
662
+ */
663
+ declare function migrateVersionSidecars(fs: MigrateLocalFS, options?: VersionMigrationOptions): Promise<VersionMigrationResult>;
664
+
588
665
  /**
589
666
  * zen-fs-config — ConfigRepo Implementation
590
667
  *
@@ -690,6 +767,15 @@ declare class ConfigRepo implements IConfigRepo {
690
767
  * @param options.dryRun List the sidecars without deleting them.
691
768
  */
692
769
  purgeMtimeSidecars(options?: PurgeMtimeOptions): Promise<MtimePurgeResult>;
770
+ /**
771
+ * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
772
+ * `<name>.version` naming. Renames local sidecars (preserving content/history)
773
+ * and deletes the residual legacy copy from every replica backend so sync
774
+ * does not pull it back. Best-effort: failures are logged, never thrown.
775
+ *
776
+ * Runs once at startup (see createConfigRepo). Safe to call again.
777
+ */
778
+ migrateLegacyVersionSidecars(): Promise<VersionMigrationResult>;
693
779
  /**
694
780
  * Perform a full sync + dedup cycle without the watch snapshot cache.
695
781
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -876,24 +962,35 @@ declare function connect(appId: string, options?: ConnectOptions): Promise<Conne
876
962
  /**
877
963
  * zen-fs-config — Sidecar Version File Management
878
964
  *
879
- * Each config file has a companion .version file for version-based change
880
- * detection and conflict resolution.
965
+ * Each config file has a companion `.version` sidecar file for version-based
966
+ * change detection and conflict resolution. The sidecar is named `<name>.version`
967
+ * (NOT `.name.version`) so it is not a hidden dotfile.
881
968
  *
882
969
  * Config file: /app-a/db.json
883
- * Version file: /app-a/.db.json.version
970
+ * Version file: /app-a/db.json.version
884
971
  */
885
972
 
886
973
  /**
887
974
  * Compute the sidecar version file path from a config file path.
888
975
  *
889
- * /app-a/db.json → /app-a/.db.json.version
890
- * /shared/flags.json → /shared/.flags.json.version
891
- * /nodes/s1/env.json → /nodes/s1/.env.json.version
976
+ * /app-a/db.json → /app-a/db.json.version
977
+ * /shared/flags.json → /shared/flags.json.version
978
+ * /nodes/s1/env.json → /nodes/s1/env.json.version
892
979
  *
893
980
  * Returns null for files that are already version sidecars (.version files),
894
- * to prevent creating version-of-version files (e.g. ..db.json.version.version).
981
+ * to prevent creating version-of-version files (e.g. db.json.version.version).
895
982
  */
896
983
  declare function versionPathFor(configFilePath: string): string | null;
984
+ /**
985
+ * Compute the *legacy* (pre-dotfile-removal) version sidecar path for a config
986
+ * file. Returns the old `.${name}.version` form for non-dot configs, or `null`
987
+ * for dotfile configs (whose version path is unchanged) and for version files
988
+ * themselves.
989
+ *
990
+ * Used as a read fallback so existing history in `.db.json.version` is still
991
+ * found after the naming scheme changed to `db.json.version`.
992
+ */
993
+ declare function legacyVersionPathFor(configFilePath: string): string | null;
897
994
  /**
898
995
  * Compute SHA-256 hash of a Uint8Array.
899
996
  * Returns "sha256:" prefix + hex digest.
@@ -923,4 +1020,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
923
1020
  */
924
1021
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
925
1022
 
926
- export { type AppDataBackendDescriptor, type AppDataGroup, type AppDataGroupDescriptor, type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendMetadata, type BackendParamDef, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type ConnectOptions, type ConnectResult, FolderStore, type IConfigRepo, LOCAL_IDB_BACKEND_ID, type MtimePurgeResult, type PurgeMtimeOptions, type PurgeableFS, type SyncGroupType, type TombstoneMeta, type VersionMeta, configKeyToFilePath, connect, createBackend, createConfigRepo, createSerializerChain, getAccountFields, getBackendMetadata, getExtension, hasBackend, incrementVersion, isBrowserEnv, isMtimeSidecar, listBackendMetadata, listBackends, localPrimaryType, mergeAccountFields, purgeMtimeSidecars, readVersion, registerBackend, registerFolderBackend, resolveLocalPrimary, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
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 };
package/dist/index.js CHANGED
@@ -419,10 +419,12 @@ __export(index_exports, {
419
419
  incrementVersion: () => incrementVersion,
420
420
  isBrowserEnv: () => isBrowserEnv,
421
421
  isMtimeSidecar: () => isMtimeSidecar,
422
+ legacyVersionPathFor: () => legacyVersionPathFor,
422
423
  listBackendMetadata: () => listBackendMetadata,
423
424
  listBackends: () => listBackends,
424
425
  localPrimaryType: () => localPrimaryType,
425
426
  mergeAccountFields: () => mergeAccountFields,
427
+ migrateVersionSidecars: () => migrateVersionSidecars,
426
428
  purgeMtimeSidecars: () => purgeMtimeSidecars,
427
429
  readVersion: () => readVersion,
428
430
  registerBackend: () => registerBackend,
@@ -828,9 +830,30 @@ function versionPathFor(configFilePath) {
828
830
  if (fileName.endsWith(".version")) {
829
831
  return null;
830
832
  }
831
- const versionFileName = fileName.startsWith(".") ? `${fileName}.version` : `.${fileName}.version`;
833
+ const versionFileName = `${fileName}.version`;
832
834
  return dir ? `${dir}/${versionFileName}` : versionFileName;
833
835
  }
836
+ function legacyVersionPathFor(configFilePath) {
837
+ const lastSlash = configFilePath.lastIndexOf("/");
838
+ const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
839
+ const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
840
+ if (fileName.endsWith(".version")) return null;
841
+ if (fileName.startsWith(".")) return null;
842
+ const legacyFileName = `.${fileName}.version`;
843
+ return dir ? `${dir}/${legacyFileName}` : legacyFileName;
844
+ }
845
+ async function readVersionForConfig(fs, configFilePath) {
846
+ const newPath = versionPathFor(configFilePath);
847
+ if (newPath) {
848
+ const v = await readVersion(fs, newPath);
849
+ if (v) return v;
850
+ }
851
+ const legacyPath = legacyVersionPathFor(configFilePath);
852
+ if (legacyPath) {
853
+ return readVersion(fs, legacyPath);
854
+ }
855
+ return null;
856
+ }
834
857
  function sha256PureJS(data) {
835
858
  const K = new Uint32Array([
836
859
  1116352408,
@@ -994,7 +1017,7 @@ async function writeVersion(fs, versionFilePath, meta) {
994
1017
  }
995
1018
  async function incrementVersion(fs, configFilePath, newContent, author) {
996
1019
  const vPath = versionPathFor(configFilePath);
997
- const prev = vPath ? await readVersion(fs, vPath) : null;
1020
+ const prev = vPath ? await readVersionForConfig(fs, configFilePath) : null;
998
1021
  const hash = await sha256(newContent);
999
1022
  if (prev && prev.hash === hash) {
1000
1023
  return prev;
@@ -1009,7 +1032,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
1009
1032
  async function verifyOrRepairVersion(fs, configFilePath, author) {
1010
1033
  const vPath = versionPathFor(configFilePath);
1011
1034
  if (!vPath) return null;
1012
- const existing = await readVersion(fs, vPath);
1035
+ const existing = await readVersionForConfig(fs, configFilePath);
1013
1036
  if (!existing) return null;
1014
1037
  try {
1015
1038
  const content = await fs.readFile(configFilePath);
@@ -1094,6 +1117,131 @@ async function purgeMtimeSidecars(fs, options = {}) {
1094
1117
  return result;
1095
1118
  }
1096
1119
 
1120
+ // src/version-migration.ts
1121
+ var S_IFDIR2 = 16384;
1122
+ function isDir(stat) {
1123
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR2) === S_IFDIR2;
1124
+ }
1125
+ function joinPath2(dir, entry) {
1126
+ if (dir === "/" || dir === "") return `/${entry}`;
1127
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
1128
+ }
1129
+ async function existsPath(fs, p) {
1130
+ try {
1131
+ await fs.stat(p);
1132
+ return true;
1133
+ } catch {
1134
+ return false;
1135
+ }
1136
+ }
1137
+ function configNameFromLegacy(entry) {
1138
+ const noMtime = entry.endsWith(".version.mtime") ? entry.slice(0, -".version.mtime".length) : entry.slice(0, -".version".length);
1139
+ return noMtime.slice(1);
1140
+ }
1141
+ async function resolveTarget(fs, dir, entry) {
1142
+ const configName = configNameFromLegacy(entry);
1143
+ const configPath = joinPath2(dir, configName);
1144
+ const dotConfigPath = joinPath2(dir, `.${configName}`);
1145
+ if (await existsPath(fs, dotConfigPath)) {
1146
+ return { action: "skip" };
1147
+ }
1148
+ if (await existsPath(fs, configPath)) {
1149
+ return { action: "rename", newPath: versionPathFor(configPath) ?? `${configName}.version` };
1150
+ }
1151
+ return { action: "delete" };
1152
+ }
1153
+ async function migrateVersionSidecars(fs, options = {}) {
1154
+ const root = options.root && options.root !== "" ? options.root : "/";
1155
+ const dryRun = options.dryRun ?? false;
1156
+ const replicas = options.replicas ?? [];
1157
+ const result = {
1158
+ renamed: [],
1159
+ deleted: [],
1160
+ remoteDeleted: [],
1161
+ failed: []
1162
+ };
1163
+ const stack = [root];
1164
+ while (stack.length > 0) {
1165
+ const dir = stack.pop();
1166
+ let entries;
1167
+ try {
1168
+ entries = await fs.readdir(dir);
1169
+ } catch {
1170
+ continue;
1171
+ }
1172
+ for (const entry of entries) {
1173
+ const full = joinPath2(dir, entry);
1174
+ let stat;
1175
+ try {
1176
+ stat = await fs.stat(full);
1177
+ } catch {
1178
+ continue;
1179
+ }
1180
+ if (isDir(stat)) {
1181
+ stack.push(full);
1182
+ continue;
1183
+ }
1184
+ const isLegacyVersion = entry.startsWith(".") && (entry.endsWith(".version") || entry.endsWith(".version.mtime"));
1185
+ if (!isLegacyVersion) continue;
1186
+ const legacyVerPath = entry.endsWith(".version.mtime") ? full.slice(0, -".mtime".length) : full;
1187
+ const target = await resolveTarget(fs, dir, entry);
1188
+ if (target.action === "skip") continue;
1189
+ const legacyMtimePath = `${legacyVerPath}.mtime`;
1190
+ const newMtimePath = target.newPath ? `${target.newPath}.mtime` : null;
1191
+ if (dryRun) {
1192
+ if (target.action === "rename") result.renamed.push(full);
1193
+ else result.deleted.push(full);
1194
+ continue;
1195
+ }
1196
+ try {
1197
+ if (target.action === "rename") {
1198
+ if (await existsPath(fs, target.newPath)) {
1199
+ await fs.unlink(legacyVerPath);
1200
+ result.deleted.push(full);
1201
+ } else {
1202
+ const content = await fs.readFile(legacyVerPath);
1203
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
1204
+ await fs.writeFile(target.newPath, bytes);
1205
+ await fs.unlink(legacyVerPath);
1206
+ result.renamed.push(full);
1207
+ }
1208
+ if (newMtimePath && await existsPath(fs, legacyMtimePath)) {
1209
+ try {
1210
+ const mc = await fs.readFile(legacyMtimePath);
1211
+ const mb = typeof mc === "string" ? new TextEncoder().encode(mc) : mc;
1212
+ await fs.writeFile(newMtimePath, mb);
1213
+ await fs.unlink(legacyMtimePath);
1214
+ } catch {
1215
+ }
1216
+ }
1217
+ } else {
1218
+ await fs.unlink(legacyVerPath);
1219
+ result.deleted.push(full);
1220
+ if (await existsPath(fs, legacyMtimePath)) {
1221
+ try {
1222
+ await fs.unlink(legacyMtimePath);
1223
+ } catch {
1224
+ }
1225
+ }
1226
+ }
1227
+ } catch (e) {
1228
+ result.failed.push(full);
1229
+ continue;
1230
+ }
1231
+ for (const rep of replicas) {
1232
+ for (const rp of [legacyVerPath, legacyMtimePath]) {
1233
+ try {
1234
+ await rep.unlink(rp);
1235
+ result.remoteDeleted.push(rp);
1236
+ } catch {
1237
+ }
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ return result;
1243
+ }
1244
+
1097
1245
  // src/config-repo.ts
1098
1246
  var log2 = (0, import_localstorage_logger2.createLogger)("zen-fs-config:config-repo");
1099
1247
  var META_DIR = "/.meta";
@@ -1576,6 +1724,42 @@ var ConfigRepo = class {
1576
1724
  }
1577
1725
  return result;
1578
1726
  }
1727
+ /**
1728
+ * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
1729
+ * `<name>.version` naming. Renames local sidecars (preserving content/history)
1730
+ * and deletes the residual legacy copy from every replica backend so sync
1731
+ * does not pull it back. Best-effort: failures are logged, never thrown.
1732
+ *
1733
+ * Runs once at startup (see createConfigRepo). Safe to call again.
1734
+ */
1735
+ async migrateLegacyVersionSidecars() {
1736
+ this.assertNotDisposed();
1737
+ const replicas = [...this.replicaBackends.values()].map((r) => ({
1738
+ unlink: (p) => r.syncable.unlink(p)
1739
+ }));
1740
+ const result = await migrateVersionSidecars(this.cachedFS, { replicas });
1741
+ if (result.renamed.length > 0) {
1742
+ log2.log(
1743
+ `[ConfigRepo] migrated ${result.renamed.length} legacy version sidecar(s) to new naming`,
1744
+ result.renamed
1745
+ );
1746
+ }
1747
+ if (result.deleted.length > 0) {
1748
+ log2.log(
1749
+ `[ConfigRepo] removed ${result.deleted.length} orphaned legacy version sidecar(s)`,
1750
+ result.deleted
1751
+ );
1752
+ }
1753
+ if (result.remoteDeleted.length > 0) {
1754
+ log2.log(
1755
+ `[ConfigRepo] removed ${result.remoteDeleted.length} remote residual legacy version sidecar(s)`
1756
+ );
1757
+ }
1758
+ if (result.failed.length > 0) {
1759
+ log2.warn(`[ConfigRepo] failed to migrate ${result.failed.length} legacy version sidecar(s):`, result.failed);
1760
+ }
1761
+ return result;
1762
+ }
1579
1763
  /**
1580
1764
  * Perform a full sync + dedup cycle without the watch snapshot cache.
1581
1765
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -1981,7 +2165,9 @@ var ConfigRepo = class {
1981
2165
  try {
1982
2166
  const entries = await this.cachedFS.readdir(current);
1983
2167
  const statResults = await Promise.all(
1984
- entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
2168
+ entries.filter(
2169
+ (entry) => !entry.startsWith(".") && !entry.endsWith(".version") && !entry.endsWith(".mtime")
2170
+ ).map(async (entry) => {
1985
2171
  const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1986
2172
  try {
1987
2173
  const stat = await this.cachedFS.stat(fullPath);
@@ -2747,6 +2933,16 @@ async function createConfigRepo(appId, options = {}) {
2747
2933
  cacheOptions
2748
2934
  );
2749
2935
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
2936
+ if (options.migrateVersionSidecars !== false) {
2937
+ try {
2938
+ const mig = await repo.migrateLegacyVersionSidecars();
2939
+ if (mig.renamed.length === 0 && mig.deleted.length === 0 && mig.remoteDeleted.length === 0) {
2940
+ log2.log("[createConfigRepo] no legacy version sidecars to migrate");
2941
+ }
2942
+ } catch (err) {
2943
+ log2.warn(`[createConfigRepo] version sidecar migration failed:`, err?.message ?? err);
2944
+ }
2945
+ }
2750
2946
  await repo.load();
2751
2947
  if (repo.replicaCount > 0) {
2752
2948
  log2.log("[createConfigRepo] Starting background initial sync + dedup...");
@@ -2882,10 +3078,12 @@ init_backend_registry();
2882
3078
  incrementVersion,
2883
3079
  isBrowserEnv,
2884
3080
  isMtimeSidecar,
3081
+ legacyVersionPathFor,
2885
3082
  listBackendMetadata,
2886
3083
  listBackends,
2887
3084
  localPrimaryType,
2888
3085
  mergeAccountFields,
3086
+ migrateVersionSidecars,
2889
3087
  purgeMtimeSidecars,
2890
3088
  readVersion,
2891
3089
  registerBackend,
package/dist/index.mjs CHANGED
@@ -401,9 +401,30 @@ function versionPathFor(configFilePath) {
401
401
  if (fileName.endsWith(".version")) {
402
402
  return null;
403
403
  }
404
- const versionFileName = fileName.startsWith(".") ? `${fileName}.version` : `.${fileName}.version`;
404
+ const versionFileName = `${fileName}.version`;
405
405
  return dir ? `${dir}/${versionFileName}` : versionFileName;
406
406
  }
407
+ function legacyVersionPathFor(configFilePath) {
408
+ const lastSlash = configFilePath.lastIndexOf("/");
409
+ const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
410
+ const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
411
+ if (fileName.endsWith(".version")) return null;
412
+ if (fileName.startsWith(".")) return null;
413
+ const legacyFileName = `.${fileName}.version`;
414
+ return dir ? `${dir}/${legacyFileName}` : legacyFileName;
415
+ }
416
+ async function readVersionForConfig(fs, configFilePath) {
417
+ const newPath = versionPathFor(configFilePath);
418
+ if (newPath) {
419
+ const v = await readVersion(fs, newPath);
420
+ if (v) return v;
421
+ }
422
+ const legacyPath = legacyVersionPathFor(configFilePath);
423
+ if (legacyPath) {
424
+ return readVersion(fs, legacyPath);
425
+ }
426
+ return null;
427
+ }
407
428
  function sha256PureJS(data) {
408
429
  const K = new Uint32Array([
409
430
  1116352408,
@@ -567,7 +588,7 @@ async function writeVersion(fs, versionFilePath, meta) {
567
588
  }
568
589
  async function incrementVersion(fs, configFilePath, newContent, author) {
569
590
  const vPath = versionPathFor(configFilePath);
570
- const prev = vPath ? await readVersion(fs, vPath) : null;
591
+ const prev = vPath ? await readVersionForConfig(fs, configFilePath) : null;
571
592
  const hash = await sha256(newContent);
572
593
  if (prev && prev.hash === hash) {
573
594
  return prev;
@@ -582,7 +603,7 @@ async function incrementVersion(fs, configFilePath, newContent, author) {
582
603
  async function verifyOrRepairVersion(fs, configFilePath, author) {
583
604
  const vPath = versionPathFor(configFilePath);
584
605
  if (!vPath) return null;
585
- const existing = await readVersion(fs, vPath);
606
+ const existing = await readVersionForConfig(fs, configFilePath);
586
607
  if (!existing) return null;
587
608
  try {
588
609
  const content = await fs.readFile(configFilePath);
@@ -667,6 +688,131 @@ async function purgeMtimeSidecars(fs, options = {}) {
667
688
  return result;
668
689
  }
669
690
 
691
+ // src/version-migration.ts
692
+ var S_IFDIR2 = 16384;
693
+ function isDir(stat) {
694
+ return typeof stat.mode === "number" && (stat.mode & S_IFDIR2) === S_IFDIR2;
695
+ }
696
+ function joinPath2(dir, entry) {
697
+ if (dir === "/" || dir === "") return `/${entry}`;
698
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
699
+ }
700
+ async function existsPath(fs, p) {
701
+ try {
702
+ await fs.stat(p);
703
+ return true;
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+ function configNameFromLegacy(entry) {
709
+ const noMtime = entry.endsWith(".version.mtime") ? entry.slice(0, -".version.mtime".length) : entry.slice(0, -".version".length);
710
+ return noMtime.slice(1);
711
+ }
712
+ async function resolveTarget(fs, dir, entry) {
713
+ const configName = configNameFromLegacy(entry);
714
+ const configPath = joinPath2(dir, configName);
715
+ const dotConfigPath = joinPath2(dir, `.${configName}`);
716
+ if (await existsPath(fs, dotConfigPath)) {
717
+ return { action: "skip" };
718
+ }
719
+ if (await existsPath(fs, configPath)) {
720
+ return { action: "rename", newPath: versionPathFor(configPath) ?? `${configName}.version` };
721
+ }
722
+ return { action: "delete" };
723
+ }
724
+ async function migrateVersionSidecars(fs, options = {}) {
725
+ const root = options.root && options.root !== "" ? options.root : "/";
726
+ const dryRun = options.dryRun ?? false;
727
+ const replicas = options.replicas ?? [];
728
+ const result = {
729
+ renamed: [],
730
+ deleted: [],
731
+ remoteDeleted: [],
732
+ failed: []
733
+ };
734
+ const stack = [root];
735
+ while (stack.length > 0) {
736
+ const dir = stack.pop();
737
+ let entries;
738
+ try {
739
+ entries = await fs.readdir(dir);
740
+ } catch {
741
+ continue;
742
+ }
743
+ for (const entry of entries) {
744
+ const full = joinPath2(dir, entry);
745
+ let stat;
746
+ try {
747
+ stat = await fs.stat(full);
748
+ } catch {
749
+ continue;
750
+ }
751
+ if (isDir(stat)) {
752
+ stack.push(full);
753
+ continue;
754
+ }
755
+ const isLegacyVersion = entry.startsWith(".") && (entry.endsWith(".version") || entry.endsWith(".version.mtime"));
756
+ if (!isLegacyVersion) continue;
757
+ const legacyVerPath = entry.endsWith(".version.mtime") ? full.slice(0, -".mtime".length) : full;
758
+ const target = await resolveTarget(fs, dir, entry);
759
+ if (target.action === "skip") continue;
760
+ const legacyMtimePath = `${legacyVerPath}.mtime`;
761
+ const newMtimePath = target.newPath ? `${target.newPath}.mtime` : null;
762
+ if (dryRun) {
763
+ if (target.action === "rename") result.renamed.push(full);
764
+ else result.deleted.push(full);
765
+ continue;
766
+ }
767
+ try {
768
+ if (target.action === "rename") {
769
+ if (await existsPath(fs, target.newPath)) {
770
+ await fs.unlink(legacyVerPath);
771
+ result.deleted.push(full);
772
+ } else {
773
+ const content = await fs.readFile(legacyVerPath);
774
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
775
+ await fs.writeFile(target.newPath, bytes);
776
+ await fs.unlink(legacyVerPath);
777
+ result.renamed.push(full);
778
+ }
779
+ if (newMtimePath && await existsPath(fs, legacyMtimePath)) {
780
+ try {
781
+ const mc = await fs.readFile(legacyMtimePath);
782
+ const mb = typeof mc === "string" ? new TextEncoder().encode(mc) : mc;
783
+ await fs.writeFile(newMtimePath, mb);
784
+ await fs.unlink(legacyMtimePath);
785
+ } catch {
786
+ }
787
+ }
788
+ } else {
789
+ await fs.unlink(legacyVerPath);
790
+ result.deleted.push(full);
791
+ if (await existsPath(fs, legacyMtimePath)) {
792
+ try {
793
+ await fs.unlink(legacyMtimePath);
794
+ } catch {
795
+ }
796
+ }
797
+ }
798
+ } catch (e) {
799
+ result.failed.push(full);
800
+ continue;
801
+ }
802
+ for (const rep of replicas) {
803
+ for (const rp of [legacyVerPath, legacyMtimePath]) {
804
+ try {
805
+ await rep.unlink(rp);
806
+ result.remoteDeleted.push(rp);
807
+ } catch {
808
+ }
809
+ }
810
+ }
811
+ }
812
+ }
813
+ return result;
814
+ }
815
+
670
816
  // src/config-repo.ts
671
817
  var log2 = createLogger2("zen-fs-config:config-repo");
672
818
  var META_DIR = "/.meta";
@@ -1149,6 +1295,42 @@ var ConfigRepo = class {
1149
1295
  }
1150
1296
  return result;
1151
1297
  }
1298
+ /**
1299
+ * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
1300
+ * `<name>.version` naming. Renames local sidecars (preserving content/history)
1301
+ * and deletes the residual legacy copy from every replica backend so sync
1302
+ * does not pull it back. Best-effort: failures are logged, never thrown.
1303
+ *
1304
+ * Runs once at startup (see createConfigRepo). Safe to call again.
1305
+ */
1306
+ async migrateLegacyVersionSidecars() {
1307
+ this.assertNotDisposed();
1308
+ const replicas = [...this.replicaBackends.values()].map((r) => ({
1309
+ unlink: (p) => r.syncable.unlink(p)
1310
+ }));
1311
+ const result = await migrateVersionSidecars(this.cachedFS, { replicas });
1312
+ if (result.renamed.length > 0) {
1313
+ log2.log(
1314
+ `[ConfigRepo] migrated ${result.renamed.length} legacy version sidecar(s) to new naming`,
1315
+ result.renamed
1316
+ );
1317
+ }
1318
+ if (result.deleted.length > 0) {
1319
+ log2.log(
1320
+ `[ConfigRepo] removed ${result.deleted.length} orphaned legacy version sidecar(s)`,
1321
+ result.deleted
1322
+ );
1323
+ }
1324
+ if (result.remoteDeleted.length > 0) {
1325
+ log2.log(
1326
+ `[ConfigRepo] removed ${result.remoteDeleted.length} remote residual legacy version sidecar(s)`
1327
+ );
1328
+ }
1329
+ if (result.failed.length > 0) {
1330
+ log2.warn(`[ConfigRepo] failed to migrate ${result.failed.length} legacy version sidecar(s):`, result.failed);
1331
+ }
1332
+ return result;
1333
+ }
1152
1334
  /**
1153
1335
  * Perform a full sync + dedup cycle without the watch snapshot cache.
1154
1336
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -1554,7 +1736,9 @@ var ConfigRepo = class {
1554
1736
  try {
1555
1737
  const entries = await this.cachedFS.readdir(current);
1556
1738
  const statResults = await Promise.all(
1557
- entries.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
1739
+ entries.filter(
1740
+ (entry) => !entry.startsWith(".") && !entry.endsWith(".version") && !entry.endsWith(".mtime")
1741
+ ).map(async (entry) => {
1558
1742
  const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
1559
1743
  try {
1560
1744
  const stat = await this.cachedFS.stat(fullPath);
@@ -2320,6 +2504,16 @@ async function createConfigRepo(appId, options = {}) {
2320
2504
  cacheOptions
2321
2505
  );
2322
2506
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
2507
+ if (options.migrateVersionSidecars !== false) {
2508
+ try {
2509
+ const mig = await repo.migrateLegacyVersionSidecars();
2510
+ if (mig.renamed.length === 0 && mig.deleted.length === 0 && mig.remoteDeleted.length === 0) {
2511
+ log2.log("[createConfigRepo] no legacy version sidecars to migrate");
2512
+ }
2513
+ } catch (err) {
2514
+ log2.warn(`[createConfigRepo] version sidecar migration failed:`, err?.message ?? err);
2515
+ }
2516
+ }
2323
2517
  await repo.load();
2324
2518
  if (repo.replicaCount > 0) {
2325
2519
  log2.log("[createConfigRepo] Starting background initial sync + dedup...");
@@ -2450,10 +2644,12 @@ export {
2450
2644
  incrementVersion,
2451
2645
  isBrowserEnv,
2452
2646
  isMtimeSidecar,
2647
+ legacyVersionPathFor,
2453
2648
  listBackendMetadata,
2454
2649
  listBackends,
2455
2650
  localPrimaryType,
2456
2651
  mergeAccountFields,
2652
+ migrateVersionSidecars,
2457
2653
  purgeMtimeSidecars,
2458
2654
  readVersion,
2459
2655
  registerBackend,
@@ -5275,7 +5275,7 @@ var ZenFSConfig = (() => {
5275
5275
  * @param path The path to remove from the file system.
5276
5276
  * @param isDir Does the path belong to a directory, or a file?
5277
5277
  */
5278
- async remove(path, isDir) {
5278
+ async remove(path, isDir2) {
5279
5279
  const env_25 = { stack: [], error: void 0, hasError: false };
5280
5280
  try {
5281
5281
  const tx = __addDisposableResource(env_25, this.transaction(), true);
@@ -5285,7 +5285,7 @@ var ZenFSConfig = (() => {
5285
5285
  const ino = listing[fileName];
5286
5286
  const inode = new Inode(await tx.get(ino) ?? _throw(withErrno("ENOENT")));
5287
5287
  delete listing[fileName];
5288
- if (!isDir && isDirectory2(inode))
5288
+ if (!isDir2 && isDirectory2(inode))
5289
5289
  throw withErrno("EISDIR");
5290
5290
  await tx.set(parentNode.data, encodeDirListing(listing));
5291
5291
  if (inode.nlink > 1) {
@@ -5311,7 +5311,7 @@ var ZenFSConfig = (() => {
5311
5311
  * @param path The path to remove from the file system.
5312
5312
  * @param isDir Does the path belong to a directory, or a file?
5313
5313
  */
5314
- removeSync(path, isDir) {
5314
+ removeSync(path, isDir2) {
5315
5315
  const env_26 = { stack: [], error: void 0, hasError: false };
5316
5316
  try {
5317
5317
  const tx = __addDisposableResource(env_26, this.transaction(), false);
@@ -5320,7 +5320,7 @@ var ZenFSConfig = (() => {
5320
5320
  throw withErrno("ENOENT");
5321
5321
  const inode = new Inode(tx.getSync(ino) ?? _throw(withErrno("ENOENT")));
5322
5322
  delete listing[fileName];
5323
- if (!isDir && isDirectory2(inode))
5323
+ if (!isDir2 && isDirectory2(inode))
5324
5324
  throw withErrno("EISDIR");
5325
5325
  tx.setSync(parentNode.data, encodeDirListing(listing));
5326
5326
  if (inode.nlink > 1) {
@@ -19499,12 +19499,12 @@ var ZenFSConfig = (() => {
19499
19499
  const inode = this.index.get(path);
19500
19500
  if (!inode)
19501
19501
  throw withErrno("ENOENT");
19502
- const isDir = (inode.mode & S_IFMT) == S_IFDIR;
19503
- if (!isDir && !isUnlink)
19502
+ const isDir2 = (inode.mode & S_IFMT) == S_IFDIR;
19503
+ if (!isDir2 && !isUnlink)
19504
19504
  throw withErrno("ENOTDIR");
19505
- if (isDir && isUnlink)
19505
+ if (isDir2 && isUnlink)
19506
19506
  throw withErrno("EISDIR");
19507
- if (!isDir)
19507
+ if (!isDir2)
19508
19508
  this.index.delete(path);
19509
19509
  }
19510
19510
  async unlink(path) {
@@ -23805,17 +23805,17 @@ var ZenFSConfig = (() => {
23805
23805
  const inode = this.index.get(path);
23806
23806
  if (!inode)
23807
23807
  throw withErrno("ENOENT");
23808
- const isDir = (inode.mode & constants_exports.S_IFMT) == constants_exports.S_IFDIR;
23808
+ const isDir2 = (inode.mode & constants_exports.S_IFMT) == constants_exports.S_IFDIR;
23809
23809
  let handle;
23810
23810
  try {
23811
- handle = await this.get(isDir ? "directory" : "file", path);
23811
+ handle = await this.get(isDir2 ? "directory" : "file", path);
23812
23812
  } catch {
23813
23813
  const parent = await this.get("directory", dirname(path));
23814
- handle = await parent[isDir ? "getDirectoryHandle" : "getFileHandle"](basename(path), { create: true }).catch((ex) => _throw(convertException(ex, path)));
23814
+ handle = await parent[isDir2 ? "getDirectoryHandle" : "getFileHandle"](basename(path), { create: true }).catch((ex) => _throw(convertException(ex, path)));
23815
23815
  if (!this.disableHandleCache)
23816
23816
  this._handles.set(path, handle);
23817
23817
  }
23818
- if (isDir)
23818
+ if (isDir2)
23819
23819
  return;
23820
23820
  if (isKind(handle, "directory")) {
23821
23821
  log_exports.crit(withErrno("EIO", "Mismatch in entry kind on write"));
@@ -25112,10 +25112,12 @@ var ZenFSConfig = (() => {
25112
25112
  incrementVersion: () => incrementVersion,
25113
25113
  isBrowserEnv: () => isBrowserEnv,
25114
25114
  isMtimeSidecar: () => isMtimeSidecar,
25115
+ legacyVersionPathFor: () => legacyVersionPathFor,
25115
25116
  listBackendMetadata: () => listBackendMetadata,
25116
25117
  listBackends: () => listBackends,
25117
25118
  localPrimaryType: () => localPrimaryType,
25118
25119
  mergeAccountFields: () => mergeAccountFields,
25120
+ migrateVersionSidecars: () => migrateVersionSidecars,
25119
25121
  purgeMtimeSidecars: () => purgeMtimeSidecars,
25120
25122
  readVersion: () => readVersion,
25121
25123
  registerBackend: () => registerBackend,
@@ -26705,9 +26707,30 @@ var ZenFSConfig = (() => {
26705
26707
  if (fileName.endsWith(".version")) {
26706
26708
  return null;
26707
26709
  }
26708
- const versionFileName = fileName.startsWith(".") ? `${fileName}.version` : `.${fileName}.version`;
26710
+ const versionFileName = `${fileName}.version`;
26709
26711
  return dir ? `${dir}/${versionFileName}` : versionFileName;
26710
26712
  }
26713
+ function legacyVersionPathFor(configFilePath) {
26714
+ const lastSlash = configFilePath.lastIndexOf("/");
26715
+ const dir = lastSlash >= 0 ? configFilePath.slice(0, lastSlash) : "";
26716
+ const fileName = lastSlash >= 0 ? configFilePath.slice(lastSlash + 1) : configFilePath;
26717
+ if (fileName.endsWith(".version")) return null;
26718
+ if (fileName.startsWith(".")) return null;
26719
+ const legacyFileName = `.${fileName}.version`;
26720
+ return dir ? `${dir}/${legacyFileName}` : legacyFileName;
26721
+ }
26722
+ async function readVersionForConfig(fs, configFilePath) {
26723
+ const newPath = versionPathFor(configFilePath);
26724
+ if (newPath) {
26725
+ const v = await readVersion(fs, newPath);
26726
+ if (v) return v;
26727
+ }
26728
+ const legacyPath = legacyVersionPathFor(configFilePath);
26729
+ if (legacyPath) {
26730
+ return readVersion(fs, legacyPath);
26731
+ }
26732
+ return null;
26733
+ }
26711
26734
  function sha256PureJS(data) {
26712
26735
  const K = new Uint32Array([
26713
26736
  1116352408,
@@ -26871,7 +26894,7 @@ var ZenFSConfig = (() => {
26871
26894
  }
26872
26895
  async function incrementVersion(fs, configFilePath, newContent, author) {
26873
26896
  const vPath = versionPathFor(configFilePath);
26874
- const prev = vPath ? await readVersion(fs, vPath) : null;
26897
+ const prev = vPath ? await readVersionForConfig(fs, configFilePath) : null;
26875
26898
  const hash = await sha256(newContent);
26876
26899
  if (prev && prev.hash === hash) {
26877
26900
  return prev;
@@ -26886,7 +26909,7 @@ var ZenFSConfig = (() => {
26886
26909
  async function verifyOrRepairVersion(fs, configFilePath, author) {
26887
26910
  const vPath = versionPathFor(configFilePath);
26888
26911
  if (!vPath) return null;
26889
- const existing = await readVersion(fs, vPath);
26912
+ const existing = await readVersionForConfig(fs, configFilePath);
26890
26913
  if (!existing) return null;
26891
26914
  try {
26892
26915
  const content = await fs.readFile(configFilePath);
@@ -26971,6 +26994,131 @@ var ZenFSConfig = (() => {
26971
26994
  return result;
26972
26995
  }
26973
26996
 
26997
+ // src/version-migration.ts
26998
+ var S_IFDIR3 = 16384;
26999
+ function isDir(stat5) {
27000
+ return typeof stat5.mode === "number" && (stat5.mode & S_IFDIR3) === S_IFDIR3;
27001
+ }
27002
+ function joinPath2(dir, entry) {
27003
+ if (dir === "/" || dir === "") return `/${entry}`;
27004
+ return `${dir.replace(/\/+$/, "")}/${entry}`;
27005
+ }
27006
+ async function existsPath(fs, p) {
27007
+ try {
27008
+ await fs.stat(p);
27009
+ return true;
27010
+ } catch {
27011
+ return false;
27012
+ }
27013
+ }
27014
+ function configNameFromLegacy(entry) {
27015
+ const noMtime = entry.endsWith(".version.mtime") ? entry.slice(0, -".version.mtime".length) : entry.slice(0, -".version".length);
27016
+ return noMtime.slice(1);
27017
+ }
27018
+ async function resolveTarget(fs, dir, entry) {
27019
+ const configName = configNameFromLegacy(entry);
27020
+ const configPath = joinPath2(dir, configName);
27021
+ const dotConfigPath = joinPath2(dir, `.${configName}`);
27022
+ if (await existsPath(fs, dotConfigPath)) {
27023
+ return { action: "skip" };
27024
+ }
27025
+ if (await existsPath(fs, configPath)) {
27026
+ return { action: "rename", newPath: versionPathFor(configPath) ?? `${configName}.version` };
27027
+ }
27028
+ return { action: "delete" };
27029
+ }
27030
+ async function migrateVersionSidecars(fs, options = {}) {
27031
+ const root = options.root && options.root !== "" ? options.root : "/";
27032
+ const dryRun = options.dryRun ?? false;
27033
+ const replicas = options.replicas ?? [];
27034
+ const result = {
27035
+ renamed: [],
27036
+ deleted: [],
27037
+ remoteDeleted: [],
27038
+ failed: []
27039
+ };
27040
+ const stack = [root];
27041
+ while (stack.length > 0) {
27042
+ const dir = stack.pop();
27043
+ let entries2;
27044
+ try {
27045
+ entries2 = await fs.readdir(dir);
27046
+ } catch {
27047
+ continue;
27048
+ }
27049
+ for (const entry of entries2) {
27050
+ const full = joinPath2(dir, entry);
27051
+ let stat5;
27052
+ try {
27053
+ stat5 = await fs.stat(full);
27054
+ } catch {
27055
+ continue;
27056
+ }
27057
+ if (isDir(stat5)) {
27058
+ stack.push(full);
27059
+ continue;
27060
+ }
27061
+ const isLegacyVersion = entry.startsWith(".") && (entry.endsWith(".version") || entry.endsWith(".version.mtime"));
27062
+ if (!isLegacyVersion) continue;
27063
+ const legacyVerPath = entry.endsWith(".version.mtime") ? full.slice(0, -".mtime".length) : full;
27064
+ const target = await resolveTarget(fs, dir, entry);
27065
+ if (target.action === "skip") continue;
27066
+ const legacyMtimePath = `${legacyVerPath}.mtime`;
27067
+ const newMtimePath = target.newPath ? `${target.newPath}.mtime` : null;
27068
+ if (dryRun) {
27069
+ if (target.action === "rename") result.renamed.push(full);
27070
+ else result.deleted.push(full);
27071
+ continue;
27072
+ }
27073
+ try {
27074
+ if (target.action === "rename") {
27075
+ if (await existsPath(fs, target.newPath)) {
27076
+ await fs.unlink(legacyVerPath);
27077
+ result.deleted.push(full);
27078
+ } else {
27079
+ const content = await fs.readFile(legacyVerPath);
27080
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
27081
+ await fs.writeFile(target.newPath, bytes);
27082
+ await fs.unlink(legacyVerPath);
27083
+ result.renamed.push(full);
27084
+ }
27085
+ if (newMtimePath && await existsPath(fs, legacyMtimePath)) {
27086
+ try {
27087
+ const mc = await fs.readFile(legacyMtimePath);
27088
+ const mb = typeof mc === "string" ? new TextEncoder().encode(mc) : mc;
27089
+ await fs.writeFile(newMtimePath, mb);
27090
+ await fs.unlink(legacyMtimePath);
27091
+ } catch {
27092
+ }
27093
+ }
27094
+ } else {
27095
+ await fs.unlink(legacyVerPath);
27096
+ result.deleted.push(full);
27097
+ if (await existsPath(fs, legacyMtimePath)) {
27098
+ try {
27099
+ await fs.unlink(legacyMtimePath);
27100
+ } catch {
27101
+ }
27102
+ }
27103
+ }
27104
+ } catch (e) {
27105
+ result.failed.push(full);
27106
+ continue;
27107
+ }
27108
+ for (const rep of replicas) {
27109
+ for (const rp of [legacyVerPath, legacyMtimePath]) {
27110
+ try {
27111
+ await rep.unlink(rp);
27112
+ result.remoteDeleted.push(rp);
27113
+ } catch {
27114
+ }
27115
+ }
27116
+ }
27117
+ }
27118
+ }
27119
+ return result;
27120
+ }
27121
+
26974
27122
  // src/config-repo.ts
26975
27123
  var log6 = createLogger2("zen-fs-config:config-repo");
26976
27124
  var META_DIR = "/.meta";
@@ -27453,6 +27601,42 @@ var ZenFSConfig = (() => {
27453
27601
  }
27454
27602
  return result;
27455
27603
  }
27604
+ /**
27605
+ * Migrate legacy `.x.version` sidecars (old dotfile naming) to the new
27606
+ * `<name>.version` naming. Renames local sidecars (preserving content/history)
27607
+ * and deletes the residual legacy copy from every replica backend so sync
27608
+ * does not pull it back. Best-effort: failures are logged, never thrown.
27609
+ *
27610
+ * Runs once at startup (see createConfigRepo). Safe to call again.
27611
+ */
27612
+ async migrateLegacyVersionSidecars() {
27613
+ this.assertNotDisposed();
27614
+ const replicas = [...this.replicaBackends.values()].map((r) => ({
27615
+ unlink: (p) => r.syncable.unlink(p)
27616
+ }));
27617
+ const result = await migrateVersionSidecars(this.cachedFS, { replicas });
27618
+ if (result.renamed.length > 0) {
27619
+ log6.log(
27620
+ `[ConfigRepo] migrated ${result.renamed.length} legacy version sidecar(s) to new naming`,
27621
+ result.renamed
27622
+ );
27623
+ }
27624
+ if (result.deleted.length > 0) {
27625
+ log6.log(
27626
+ `[ConfigRepo] removed ${result.deleted.length} orphaned legacy version sidecar(s)`,
27627
+ result.deleted
27628
+ );
27629
+ }
27630
+ if (result.remoteDeleted.length > 0) {
27631
+ log6.log(
27632
+ `[ConfigRepo] removed ${result.remoteDeleted.length} remote residual legacy version sidecar(s)`
27633
+ );
27634
+ }
27635
+ if (result.failed.length > 0) {
27636
+ log6.warn(`[ConfigRepo] failed to migrate ${result.failed.length} legacy version sidecar(s):`, result.failed);
27637
+ }
27638
+ return result;
27639
+ }
27456
27640
  /**
27457
27641
  * Perform a full sync + dedup cycle without the watch snapshot cache.
27458
27642
  * Used by createConfigRepo to pull remote-only files (like duplicate
@@ -27858,7 +28042,9 @@ var ZenFSConfig = (() => {
27858
28042
  try {
27859
28043
  const entries2 = await this.cachedFS.readdir(current);
27860
28044
  const statResults = await Promise.all(
27861
- entries2.filter((entry) => !entry.startsWith(".")).map(async (entry) => {
28045
+ entries2.filter(
28046
+ (entry) => !entry.startsWith(".") && !entry.endsWith(".version") && !entry.endsWith(".mtime")
28047
+ ).map(async (entry) => {
27862
28048
  const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
27863
28049
  try {
27864
28050
  const stat5 = await this.cachedFS.stat(fullPath);
@@ -28624,6 +28810,16 @@ var ZenFSConfig = (() => {
28624
28810
  cacheOptions
28625
28811
  );
28626
28812
  await repo.setupSync(allBackends, LOCAL_IDB_BACKEND_ID, options.syncPollIntervalMs);
28813
+ if (options.migrateVersionSidecars !== false) {
28814
+ try {
28815
+ const mig = await repo.migrateLegacyVersionSidecars();
28816
+ if (mig.renamed.length === 0 && mig.deleted.length === 0 && mig.remoteDeleted.length === 0) {
28817
+ log6.log("[createConfigRepo] no legacy version sidecars to migrate");
28818
+ }
28819
+ } catch (err2) {
28820
+ log6.warn(`[createConfigRepo] version sidecar migration failed:`, err2?.message ?? err2);
28821
+ }
28822
+ }
28627
28823
  await repo.load();
28628
28824
  if (repo.replicaCount > 0) {
28629
28825
  log6.log("[createConfigRepo] Starting background initial sync + dedup...");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.5.32",
3
+ "version": "0.5.33",
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",