Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 15 additions & 114 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ 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";
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<NamedBackupSummary[]> {
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<void> = Promise.resolve();
const transactionSnapshotContext = new AsyncLocalStorage<{
snapshot: AccountStorageV3 | null;
Expand All @@ -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<AccountLike, "accountId" | "email" | "refreshToken">
| 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() : "";
Expand Down Expand Up @@ -879,7 +775,12 @@ export function buildNamedBackupPath(name: string): string {
}

export async function getNamedBackups(): Promise<NamedBackupSummary[]> {
return collectNamedBackups(getStoragePath());
return collectNamedBackups(getStoragePath(), {
readDir: fs.readdir,
stat: fs.stat,
loadAccountsFromPath,
logDebug: (message, meta) => log.debug(message, meta),
});
}

export async function restoreAccountsFromBackup(
Expand Down
4 changes: 2 additions & 2 deletions lib/storage/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type AccountLike = {
refreshToken?: string;
};

type AccountIdentityRef = {
export type AccountIdentityRef = {
accountId?: string;
emailKey?: string;
refreshToken?: string;
Expand Down Expand Up @@ -39,7 +39,7 @@ function hashRefreshTokenKey(refreshToken: string): string {
return createHash("sha256").update(refreshToken).digest("hex");
}

function toAccountIdentityRef(
export function toAccountIdentityRef(
account:
| Pick<AccountLike, "accountId" | "email" | "refreshToken">
| null
Expand Down
85 changes: 85 additions & 0 deletions lib/storage/named-backups.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;
}

export async function collectNamedBackups(
storagePath: string,
deps: CollectNamedBackupsDeps,
): Promise<NamedBackupSummary[]> {
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;
}