Skip to content

Commit cdb4a37

Browse files
committed
fix(storage): fail export when current pool is empty
1 parent 76e5216 commit cdb4a37

4 files changed

Lines changed: 113 additions & 11 deletions

File tree

lib/storage.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,58 @@ async function loadAccountsInternal(
14321432
}
14331433
}
14341434

1435+
async function loadAccountsForExport(): Promise<AccountStorageV3 | null> {
1436+
const path = getStoragePath();
1437+
const resetMarkerPath = getIntentionalResetMarkerPath(path);
1438+
const migratedLegacyStorage =
1439+
await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked);
1440+
1441+
if (existsSync(resetMarkerPath)) {
1442+
return createEmptyStorageWithMetadata(false, "intentional-reset");
1443+
}
1444+
1445+
try {
1446+
const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(
1447+
path,
1448+
{
1449+
normalizeAccountStorage,
1450+
isRecord,
1451+
},
1452+
);
1453+
if (schemaErrors.length > 0) {
1454+
log.warn("Account storage schema validation warnings", {
1455+
errors: schemaErrors.slice(0, 5),
1456+
});
1457+
}
1458+
if (normalized && storedVersion !== normalized.version) {
1459+
log.info("Migrating account storage to v3", {
1460+
from: storedVersion,
1461+
to: normalized.version,
1462+
});
1463+
try {
1464+
await saveAccountsUnlocked(normalized);
1465+
} catch (saveError) {
1466+
log.warn("Failed to persist migrated storage", {
1467+
error: String(saveError),
1468+
});
1469+
}
1470+
}
1471+
if (existsSync(resetMarkerPath)) {
1472+
return createEmptyStorageWithMetadata(false, "intentional-reset");
1473+
}
1474+
return normalized;
1475+
} catch (error) {
1476+
const code = (error as NodeJS.ErrnoException).code;
1477+
if (existsSync(resetMarkerPath)) {
1478+
return createEmptyStorageWithMetadata(false, "intentional-reset");
1479+
}
1480+
if (code === "ENOENT") {
1481+
return migratedLegacyStorage;
1482+
}
1483+
throw error;
1484+
}
1485+
}
1486+
14351487
async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
14361488
const path = getStoragePath();
14371489
const resetMarkerPath = getIntentionalResetMarkerPath(path);
@@ -1788,9 +1840,8 @@ export async function exportAccounts(
17881840
force,
17891841
currentStoragePath,
17901842
transactionState: getTransactionSnapshotState(),
1791-
loadAccountsInternal: () => loadAccountsInternal(saveAccountsUnlocked),
1792-
readCurrentStorage: () =>
1793-
withAccountStorageTransaction((current) => Promise.resolve(current)),
1843+
readCurrentStorageUnlocked: loadAccountsForExport,
1844+
readCurrentStorage: () => withStorageLock(loadAccountsForExport),
17941845
exportAccountsToFile,
17951846
beforeCommit,
17961847
logInfo: (message, details) => {

lib/storage/account-port.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function exportAccountsSnapshot(params: {
1111
snapshot: AccountStorageV3 | null;
1212
}
1313
| undefined;
14-
loadAccountsInternal: () => Promise<AccountStorageV3 | null>;
14+
readCurrentStorageUnlocked: () => Promise<AccountStorageV3 | null>;
1515
readCurrentStorage: () => Promise<AccountStorageV3 | null>;
1616
exportAccountsToFile: (args: {
1717
resolvedPath: string;
@@ -28,7 +28,7 @@ export async function exportAccountsSnapshot(params: {
2828
params.transactionState.storagePath === params.currentStoragePath
2929
? params.transactionState.snapshot
3030
: params.transactionState?.active
31-
? await params.loadAccountsInternal()
31+
? await params.readCurrentStorageUnlocked()
3232
: await params.readCurrentStorage();
3333

3434
await params.exportAccountsToFile({

test/account-port.test.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,79 @@ import {
55
} from "../lib/storage/account-port.js";
66

77
describe("account port helpers", () => {
8-
it("exports transaction snapshot when active", async () => {
8+
it("exports transaction snapshot when active for the current storage path", async () => {
99
const exportAccountsToFile = vi.fn(async () => undefined);
10+
const snapshot = {
11+
version: 3 as const,
12+
accounts: [{ refreshToken: "snapshot-token" }],
13+
activeIndex: 0,
14+
activeIndexByFamily: {},
15+
};
16+
const readCurrentStorageUnlocked = vi.fn();
17+
const readCurrentStorage = vi.fn();
18+
1019
await exportAccountsSnapshot({
1120
resolvedPath: "/tmp/out.json",
1221
force: true,
1322
currentStoragePath: "/tmp/accounts.json",
1423
transactionState: {
1524
active: true,
1625
storagePath: "/tmp/accounts.json",
26+
snapshot,
27+
},
28+
readCurrentStorageUnlocked,
29+
readCurrentStorage,
30+
exportAccountsToFile,
31+
logInfo: vi.fn(),
32+
});
33+
expect(readCurrentStorageUnlocked).not.toHaveBeenCalled();
34+
expect(readCurrentStorage).not.toHaveBeenCalled();
35+
expect(exportAccountsToFile).toHaveBeenCalledWith(
36+
expect.objectContaining({
37+
storage: snapshot,
38+
}),
39+
);
40+
});
41+
42+
it("reads current storage without reusing a stale transaction snapshot from another path", async () => {
43+
const exportAccountsToFile = vi.fn(async () => undefined);
44+
const readCurrentStorageUnlocked = vi.fn(async () => ({
45+
version: 3 as const,
46+
accounts: [{ refreshToken: "live-token" }],
47+
activeIndex: 0,
48+
activeIndexByFamily: {},
49+
}));
50+
const readCurrentStorage = vi.fn();
51+
52+
await exportAccountsSnapshot({
53+
resolvedPath: "/tmp/out.json",
54+
force: true,
55+
currentStoragePath: "/tmp/accounts.json",
56+
transactionState: {
57+
active: true,
58+
storagePath: "/tmp/other.json",
1759
snapshot: {
1860
version: 3,
19-
accounts: [],
61+
accounts: [{ refreshToken: "stale-token" }],
2062
activeIndex: 0,
2163
activeIndexByFamily: {},
2264
},
2365
},
24-
loadAccountsInternal: vi.fn(),
25-
readCurrentStorage: vi.fn(),
66+
readCurrentStorageUnlocked,
67+
readCurrentStorage,
2668
exportAccountsToFile,
2769
logInfo: vi.fn(),
2870
});
29-
expect(exportAccountsToFile).toHaveBeenCalled();
71+
72+
expect(readCurrentStorageUnlocked).toHaveBeenCalledTimes(1);
73+
expect(readCurrentStorage).not.toHaveBeenCalled();
74+
expect(exportAccountsToFile).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
storage: expect.objectContaining({
77+
accounts: [{ refreshToken: "live-token" }],
78+
}),
79+
}),
80+
);
3081
});
3182

3283
it("imports through transaction helper and logs result", async () => {

test/experimental-sync-target-entry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe("experimental sync target entry", () => {
6767

6868
expect(capturedReadJson).toBeDefined();
6969
expect(readFileWithRetry).toHaveBeenCalledWith("C:\\state.json", {
70-
retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]),
70+
retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN"]),
7171
maxAttempts: 4,
7272
sleep,
7373
});

0 commit comments

Comments
 (0)