diff --git a/lib/storage.ts b/lib/storage.ts index 1440ff57..a9e50f7f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -12,8 +12,11 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; - import { formatStorageErrorHint } from "./storage/error-hints.js"; +import { + collectNamedBackups, + type NamedBackupSummary, +} from "./storage/named-backups.js"; export { StorageError } from "./errors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; @@ -21,8 +24,8 @@ export { getAccountIdentityKey, normalizeEmailKey, } from "./storage/identity.js"; +export type { NamedBackupSummary } from "./storage/named-backups.js"; -import { normalizeEmailKey } from "./storage/identity.js"; import { ACCOUNTS_BACKUP_SUFFIX, ACCOUNTS_WAL_SUFFIX, @@ -34,6 +37,10 @@ import { getIntentionalResetMarkerPath, RESET_MARKER_SUFFIX, } from "./storage/file-paths.js"; +import { + type AccountIdentityRef, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -138,82 +145,6 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; -export interface NamedBackupSummary { - path: string; - fileName: string; - accountCount: number; - mtimeMs: number; -} - -async function collectNamedBackups( - storagePath: string, -): Promise { - const backupRoot = getNamedBackupRoot(storagePath); - let entries: Array<{ isFile(): boolean; name: string }>; - try { - entries = await fs.readdir(backupRoot, { - withFileTypes: true, - encoding: "utf8", - }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return []; - throw error; - } - - const candidates: NamedBackupSummary[] = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const candidatePath = join(backupRoot, entry.name); - try { - const statsBefore = await fs.stat(candidatePath); - const { normalized } = await loadAccountsFromPath(candidatePath); - if (!normalized || normalized.accounts.length === 0) continue; - const statsAfter = await fs.stat(candidatePath).catch(() => null); - if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { - log.debug( - "backup file changed between stat and load, mtime may be stale", - { - candidatePath, - fileName: entry.name, - beforeMtimeMs: statsBefore.mtimeMs, - afterMtimeMs: statsAfter.mtimeMs, - }, - ); - } - candidates.push({ - path: candidatePath, - fileName: entry.name, - accountCount: normalized.accounts.length, - mtimeMs: statsBefore.mtimeMs, - }); - } catch (error) { - log.debug( - "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", - { - candidatePath, - fileName: entry.name, - error: - error instanceof Error - ? { - message: error.message, - stack: error.stack, - } - : String(error), - }, - ); - } - } - - candidates.sort((left, right) => { - const mtimeDelta = right.mtimeMs - left.mtimeMs; - if (mtimeDelta !== 0) return mtimeDelta; - return left.fileName.localeCompare(right.fileName); - }); - return candidates; -} - let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; @@ -240,41 +171,6 @@ type AccountLike = { lastUsed?: number; }; -type AccountIdentityRef = { - accountId?: string; - emailKey?: string; - refreshToken?: string; -}; - -function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { - if (!accountId) return undefined; - const trimmed = accountId.trim(); - return trimmed || undefined; -} - -function normalizeRefreshTokenKey( - refreshToken: string | undefined, -): string | undefined { - if (!refreshToken) return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -function toAccountIdentityRef( - account: - | Pick - | null - | undefined, -): AccountIdentityRef { - return { - accountId: normalizeAccountIdKey(account?.accountId), - emailKey: normalizeEmailKey(account?.email), - refreshToken: normalizeRefreshTokenKey(account?.refreshToken), - }; -} - function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; @@ -879,7 +775,12 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath()); + return collectNamedBackups(getStoragePath(), { + readDir: fs.readdir, + stat: fs.stat, + loadAccountsFromPath, + logDebug: (message, meta) => log.debug(message, meta), + }); } export async function restoreAccountsFromBackup( diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index d6ab4b70..af5d64d2 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -6,7 +6,7 @@ type AccountLike = { refreshToken?: string; }; -type AccountIdentityRef = { +export type AccountIdentityRef = { accountId?: string; emailKey?: string; refreshToken?: string; @@ -39,7 +39,7 @@ function hashRefreshTokenKey(refreshToken: string): string { return createHash("sha256").update(refreshToken).digest("hex"); } -function toAccountIdentityRef( +export function toAccountIdentityRef( account: | Pick | null diff --git a/lib/storage/named-backups.ts b/lib/storage/named-backups.ts new file mode 100644 index 00000000..a14a30c2 --- /dev/null +++ b/lib/storage/named-backups.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; +import { getNamedBackupRoot } from "../named-backup-export.js"; + +export interface NamedBackupSummary { + path: string; + fileName: string; + accountCount: number; + mtimeMs: number; +} + +export interface CollectNamedBackupsDeps { + readDir: typeof import("node:fs").promises.readdir; + stat: typeof import("node:fs").promises.stat; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug?: (message: string, meta: Record) => void; +} + +export async function collectNamedBackups( + storagePath: string, + deps: CollectNamedBackupsDeps, +): Promise { + const backupRoot = getNamedBackupRoot(storagePath); + let entries: Array<{ isFile(): boolean; name: string }>; + try { + entries = await deps.readDir(backupRoot, { + withFileTypes: true, + encoding: "utf8", + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return []; + throw error; + } + + const candidates: NamedBackupSummary[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const candidatePath = join(backupRoot, entry.name); + try { + const statsBefore = await deps.stat(candidatePath); + const { normalized } = await deps.loadAccountsFromPath(candidatePath); + if (!normalized || normalized.accounts.length === 0) continue; + const statsAfter = await deps.stat(candidatePath).catch(() => null); + if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { + deps.logDebug?.( + "backup file changed between stat and load, mtime may be stale", + { + candidatePath, + fileName: entry.name, + beforeMtimeMs: statsBefore.mtimeMs, + afterMtimeMs: statsAfter.mtimeMs, + }, + ); + } + candidates.push({ + path: candidatePath, + fileName: entry.name, + accountCount: normalized.accounts.length, + mtimeMs: statsBefore.mtimeMs, + }); + } catch (error) { + deps.logDebug?.( + "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", + { + candidatePath, + fileName: entry.name, + error: + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error), + }, + ); + } + } + + candidates.sort((left, right) => { + const mtimeDelta = right.mtimeMs - left.mtimeMs; + if (mtimeDelta !== 0) return mtimeDelta; + return left.fileName.localeCompare(right.fileName); + }); + return candidates; +}