Skip to content

Commit 2e86612

Browse files
committed
Merge PR #294: refactor: extract named backups list facade
2 parents 08cbb3d + c64566f commit 2e86612

22 files changed

Lines changed: 1051 additions & 241 deletions

lib/storage.ts

Lines changed: 195 additions & 241 deletions
Large diffs are not rendered by default.

lib/storage/account-clear-entry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export async function clearAccountsEntry(params: {
2+
path: string;
3+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
4+
resetMarkerPath: string;
5+
walPath: string;
6+
getBackupPaths: () => Promise<string[]>;
7+
clearAccountStorageArtifacts: (args: {
8+
path: string;
9+
resetMarkerPath: string;
10+
walPath: string;
11+
backupPaths: string[];
12+
logError: (message: string, details: Record<string, unknown>) => void;
13+
}) => Promise<void>;
14+
logError: (message: string, details: Record<string, unknown>) => void;
15+
}): Promise<void> {
16+
return params.withStorageLock(async () => {
17+
await params.clearAccountStorageArtifacts({
18+
path: params.path,
19+
resetMarkerPath: params.resetMarkerPath,
20+
walPath: params.walPath,
21+
backupPaths: await params.getBackupPaths(),
22+
logError: params.logError,
23+
});
24+
});
25+
}

lib/storage/account-port.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { AccountStorageV3, FlaggedAccountStorageV1 } from "../storage.js";
2+
3+
export async function exportAccountsSnapshot(params: {
4+
resolvedPath: string;
5+
force: boolean;
6+
currentStoragePath: string;
7+
transactionState:
8+
| {
9+
active: boolean;
10+
storagePath: string;
11+
snapshot: AccountStorageV3 | null;
12+
}
13+
| undefined;
14+
loadAccountsInternal: () => Promise<AccountStorageV3 | null>;
15+
readCurrentStorage: () => Promise<AccountStorageV3 | null>;
16+
exportAccountsToFile: (args: {
17+
resolvedPath: string;
18+
force: boolean;
19+
storage: AccountStorageV3 | null;
20+
beforeCommit?: (resolvedPath: string) => Promise<void> | void;
21+
logInfo: (message: string, details: Record<string, unknown>) => void;
22+
}) => Promise<void>;
23+
beforeCommit?: (resolvedPath: string) => Promise<void> | void;
24+
logInfo: (message: string, details: Record<string, unknown>) => void;
25+
}): Promise<void> {
26+
const storage =
27+
params.transactionState?.active &&
28+
params.transactionState.storagePath === params.currentStoragePath
29+
? params.transactionState.snapshot
30+
: params.transactionState?.active
31+
? await params.loadAccountsInternal()
32+
: await params.readCurrentStorage();
33+
34+
await params.exportAccountsToFile({
35+
resolvedPath: params.resolvedPath,
36+
force: params.force,
37+
storage,
38+
beforeCommit: params.beforeCommit,
39+
logInfo: params.logInfo,
40+
});
41+
}
42+
43+
export async function importAccountsSnapshot(params: {
44+
resolvedPath: string;
45+
readImportFile: (args: {
46+
resolvedPath: string;
47+
normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null;
48+
}) => Promise<AccountStorageV3>;
49+
normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null;
50+
withAccountStorageTransaction: <T>(
51+
handler: (
52+
current: AccountStorageV3 | null,
53+
persist: (storage: AccountStorageV3) => Promise<void>,
54+
) => Promise<T>,
55+
) => Promise<T>;
56+
mergeImportedAccounts: (args: {
57+
existing: AccountStorageV3 | null;
58+
imported: AccountStorageV3;
59+
maxAccounts: number;
60+
deduplicateAccounts: (
61+
accounts: AccountStorageV3["accounts"],
62+
) => AccountStorageV3["accounts"];
63+
}) => {
64+
newStorage: AccountStorageV3;
65+
imported: number;
66+
total: number;
67+
skipped: number;
68+
};
69+
maxAccounts: number;
70+
deduplicateAccounts: (
71+
accounts: AccountStorageV3["accounts"],
72+
) => AccountStorageV3["accounts"];
73+
logInfo: (message: string, details: Record<string, unknown>) => void;
74+
}): Promise<{ imported: number; total: number; skipped: number }> {
75+
const normalized = await params.readImportFile({
76+
resolvedPath: params.resolvedPath,
77+
normalizeAccountStorage: params.normalizeAccountStorage,
78+
});
79+
80+
const result = await params.withAccountStorageTransaction(
81+
async (existing, persist) => {
82+
const merged = params.mergeImportedAccounts({
83+
existing,
84+
imported: normalized,
85+
maxAccounts: params.maxAccounts,
86+
deduplicateAccounts: params.deduplicateAccounts,
87+
});
88+
await persist(merged.newStorage);
89+
return {
90+
imported: merged.imported,
91+
total: merged.total,
92+
skipped: merged.skipped,
93+
};
94+
},
95+
);
96+
97+
params.logInfo("Imported accounts", {
98+
path: params.resolvedPath,
99+
imported: result.imported,
100+
skipped: result.skipped,
101+
total: result.total,
102+
});
103+
return result;
104+
}
105+
106+
export async function saveFlaggedAccountsEntry(params: {
107+
storage: FlaggedAccountStorageV1;
108+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
109+
saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise<void>;
110+
}): Promise<void> {
111+
return params.withStorageLock(async () => {
112+
await params.saveUnlocked(params.storage);
113+
});
114+
}
115+
116+
export async function clearFlaggedAccountsEntry(params: {
117+
path: string;
118+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
119+
markerPath: string;
120+
getBackupPaths: () => Promise<string[]>;
121+
clearFlaggedAccountsOnDisk: (args: {
122+
path: string;
123+
markerPath: string;
124+
backupPaths: string[];
125+
logError: (message: string, details: Record<string, unknown>) => void;
126+
}) => Promise<void>;
127+
logError: (message: string, details: Record<string, unknown>) => void;
128+
}): Promise<void> {
129+
return params.withStorageLock(async () => {
130+
await params.clearFlaggedAccountsOnDisk({
131+
path: params.path,
132+
markerPath: params.markerPath,
133+
backupPaths: await params.getBackupPaths(),
134+
logError: params.logError,
135+
});
136+
});
137+
}

lib/storage/account-save-entry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { AccountStorageV3 } from "../storage.js";
2+
3+
export async function saveAccountsEntry(params: {
4+
storage: AccountStorageV3;
5+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
6+
saveUnlocked: (storage: AccountStorageV3) => Promise<void>;
7+
}): Promise<void> {
8+
return params.withStorageLock(async () => {
9+
await params.saveUnlocked(params.storage);
10+
});
11+
}

lib/storage/account-save.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { AccountStorageV3 } from "../storage.js";
2+
3+
export async function saveAccountsToDisk(
4+
storage: AccountStorageV3,
5+
params: {
6+
path: string;
7+
resetMarkerPath: string;
8+
walPath: string;
9+
storageBackupEnabled: boolean;
10+
ensureDirectory: () => Promise<void>;
11+
ensureGitignore: () => Promise<void>;
12+
looksLikeSyntheticFixtureStorage: (
13+
storage: AccountStorageV3 | null,
14+
) => boolean;
15+
loadExistingStorage: () => Promise<AccountStorageV3 | null>;
16+
createSyntheticFixtureError: () => Error;
17+
createRotatingAccountsBackup: (path: string) => Promise<void>;
18+
computeSha256: (value: string) => string;
19+
writeJournal: (content: string, path: string) => Promise<void>;
20+
writeTemp: (tempPath: string, content: string) => Promise<void>;
21+
statTemp: (tempPath: string) => Promise<{ size: number }>;
22+
renameTempToPath: (tempPath: string) => Promise<void>;
23+
cleanupResetMarker: () => Promise<void>;
24+
cleanupWal: () => Promise<void>;
25+
cleanupTemp: (tempPath: string) => Promise<void>;
26+
onSaved: () => void;
27+
logWarn: (message: string, details: Record<string, unknown>) => void;
28+
logError: (message: string, details: Record<string, unknown>) => void;
29+
createStorageError: (error: unknown) => Error;
30+
backupPath: string;
31+
createTempPath: () => string;
32+
},
33+
): Promise<void> {
34+
const tempPath = params.createTempPath();
35+
try {
36+
await params.ensureDirectory();
37+
await params.ensureGitignore();
38+
39+
if (params.looksLikeSyntheticFixtureStorage(storage)) {
40+
try {
41+
const existing = await params.loadExistingStorage();
42+
if (
43+
existing &&
44+
existing.accounts.length > 0 &&
45+
!params.looksLikeSyntheticFixtureStorage(existing)
46+
) {
47+
throw params.createSyntheticFixtureError();
48+
}
49+
} catch (error) {
50+
if (error instanceof Error && error.message.includes("synthetic")) {
51+
throw error;
52+
}
53+
}
54+
}
55+
56+
if (params.storageBackupEnabled) {
57+
try {
58+
await params.createRotatingAccountsBackup(params.path);
59+
} catch (backupError) {
60+
params.logWarn("Failed to create account storage backup", {
61+
path: params.path,
62+
backupPath: params.backupPath,
63+
error: String(backupError),
64+
});
65+
}
66+
}
67+
68+
const content = JSON.stringify(storage, null, 2);
69+
await params.writeJournal(content, params.path);
70+
await params.writeTemp(tempPath, content);
71+
72+
const stats = await params.statTemp(tempPath);
73+
if (stats.size === 0) {
74+
throw Object.assign(new Error("File written but size is 0"), {
75+
code: "EEMPTY",
76+
});
77+
}
78+
79+
await params.renameTempToPath(tempPath);
80+
await params.cleanupResetMarker();
81+
params.onSaved();
82+
await params.cleanupWal();
83+
} catch (error) {
84+
await params.cleanupTemp(tempPath);
85+
params.logError("Failed to save accounts", {
86+
path: params.path,
87+
error: String(error),
88+
});
89+
throw params.createStorageError(error);
90+
}
91+
}

lib/storage/flagged-entry.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FlaggedAccountStorageV1 } from "../storage.js";
2+
3+
export async function saveFlaggedAccountsEntry(params: {
4+
storage: FlaggedAccountStorageV1;
5+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
6+
saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise<void>;
7+
}): Promise<void> {
8+
return params.withStorageLock(async () => {
9+
await params.saveUnlocked(params.storage);
10+
});
11+
}
12+
13+
export async function clearFlaggedAccountsEntry(params: {
14+
path: string;
15+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
16+
markerPath: string;
17+
getBackupPaths: () => Promise<string[]>;
18+
clearFlaggedAccountsOnDisk: (args: {
19+
path: string;
20+
markerPath: string;
21+
backupPaths: string[];
22+
logError: (message: string, details: Record<string, unknown>) => void;
23+
}) => Promise<void>;
24+
logError: (message: string, details: Record<string, unknown>) => void;
25+
}): Promise<void> {
26+
return params.withStorageLock(async () => {
27+
await params.clearFlaggedAccountsOnDisk({
28+
path: params.path,
29+
markerPath: params.markerPath,
30+
backupPaths: await params.getBackupPaths(),
31+
logError: params.logError,
32+
});
33+
});
34+
}

lib/storage/flagged-load-entry.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { FlaggedAccountStorageV1 } from "../storage.js";
2+
3+
export async function loadFlaggedAccountsEntry(params: {
4+
getFlaggedAccountsPath: () => string;
5+
getLegacyFlaggedAccountsPath: () => string;
6+
getIntentionalResetMarkerPath: (path: string) => string;
7+
normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1;
8+
saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise<void>;
9+
loadFlaggedAccountsState: (args: {
10+
path: string;
11+
legacyPath: string;
12+
resetMarkerPath: string;
13+
normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1;
14+
saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise<void>;
15+
logError: (message: string, details: Record<string, unknown>) => void;
16+
logInfo: (message: string, details: Record<string, unknown>) => void;
17+
}) => Promise<FlaggedAccountStorageV1>;
18+
logError: (message: string, details: Record<string, unknown>) => void;
19+
logInfo: (message: string, details: Record<string, unknown>) => void;
20+
}): Promise<FlaggedAccountStorageV1> {
21+
const path = params.getFlaggedAccountsPath();
22+
return params.loadFlaggedAccountsState({
23+
path,
24+
legacyPath: params.getLegacyFlaggedAccountsPath(),
25+
resetMarkerPath: params.getIntentionalResetMarkerPath(path),
26+
normalizeFlaggedStorage: params.normalizeFlaggedStorage,
27+
saveFlaggedAccounts: params.saveFlaggedAccounts,
28+
logError: params.logError,
29+
logInfo: params.logInfo,
30+
});
31+
}

lib/storage/flagged-save-entry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { FlaggedAccountStorageV1 } from "../storage.js";
2+
3+
export async function saveFlaggedAccountsEntry(params: {
4+
storage: FlaggedAccountStorageV1;
5+
withStorageLock: <T>(fn: () => Promise<T>) => Promise<T>;
6+
saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise<void>;
7+
}): Promise<void> {
8+
return params.withStorageLock(async () => {
9+
await params.saveUnlocked(params.storage);
10+
});
11+
}

lib/storage/gitignore.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { existsSync, promises as fs } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
4+
export async function ensureCodexGitignoreEntry(params: {
5+
storagePath: string;
6+
currentProjectRoot: string | null;
7+
logDebug: (message: string, details: Record<string, unknown>) => void;
8+
logWarn: (message: string, details: Record<string, unknown>) => void;
9+
}): Promise<void> {
10+
const configDir = dirname(params.storagePath);
11+
const inferredProjectRoot = dirname(configDir);
12+
const candidateRoots = [
13+
params.currentProjectRoot,
14+
inferredProjectRoot,
15+
].filter(
16+
(root): root is string => typeof root === "string" && root.length > 0,
17+
);
18+
const projectRoot = candidateRoots.find((root) =>
19+
existsSync(join(root, ".git")),
20+
);
21+
if (!projectRoot) return;
22+
23+
const gitignorePath = join(projectRoot, ".gitignore");
24+
try {
25+
let content = "";
26+
if (existsSync(gitignorePath)) {
27+
content = await fs.readFile(gitignorePath, "utf-8");
28+
const lines = content.split("\n").map((line) => line.trim());
29+
if (
30+
lines.includes(".codex") ||
31+
lines.includes(".codex/") ||
32+
lines.includes("/.codex") ||
33+
lines.includes("/.codex/")
34+
) {
35+
return;
36+
}
37+
}
38+
39+
const newContent =
40+
content.endsWith("\n") || content === "" ? content : `${content}\n`;
41+
await fs.writeFile(gitignorePath, `${newContent}.codex/\n`, "utf-8");
42+
params.logDebug("Added .codex to .gitignore", { path: gitignorePath });
43+
} catch (error) {
44+
params.logWarn("Failed to update .gitignore", { error: String(error) });
45+
}
46+
}

0 commit comments

Comments
 (0)