Skip to content
186 changes: 140 additions & 46 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,13 +709,17 @@ function getLegacyFlaggedAccountsPath(): string {
);
}

async function migrateLegacyProjectStorageIfNeeded(
persist: (storage: AccountStorageV3) => Promise<void> = saveAccounts,
): Promise<AccountStorageV3 | null> {
async function migrateLegacyProjectStorageIfNeeded(options?: {
persist?: (storage: AccountStorageV3) => Promise<void>;
commit?: boolean;
}): Promise<AccountStorageV3 | null> {
const persist = options?.persist ?? saveAccounts;
const commit = options?.commit ?? true;
const state = getStoragePathState();
if (!state.currentStoragePath) {
return null;
}
const currentStoragePath = state.currentStoragePath;

const candidatePaths = [
state.currentLegacyWorktreeStoragePath,
Expand All @@ -740,10 +744,8 @@ async function migrateLegacyProjectStorageIfNeeded(
return null;
}

let targetStorage = await loadNormalizedStorageFromPath(
state.currentStoragePath,
"current account storage",
{
const loadCurrentStorageForMigration = async (): Promise<AccountStorageV3 | null> =>
loadNormalizedStorageFromPath(currentStoragePath, "current account storage", {
loadAccountsFromPath: (path) =>
loadAccountsFromPath(path, {
normalizeAccountStorage,
Expand All @@ -752,11 +754,50 @@ async function migrateLegacyProjectStorageIfNeeded(
logWarn: (message, details) => {
log.warn(message, details);
},
},
);
});
const readLiveCurrentStorageIfExportMode = async (): Promise<{
exists: boolean;
storage: AccountStorageV3 | null;
}> => {
if (commit || !existsSync(currentStoragePath)) {
return { exists: false, storage: null };
}
try {
const { normalized, schemaErrors } = await loadAccountsFromPath(
currentStoragePath,
{
normalizeAccountStorage,
isRecord,
},
);
if (schemaErrors.length > 0) {
log.warn("current account storage schema validation warnings", {
path: currentStoragePath,
errors: schemaErrors.slice(0, 5),
});
}
return {
exists: true,
storage: normalized,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { exists: false, storage: null };
}
throw error;
}
};

let targetStorage = await loadCurrentStorageForMigration();
let migrated = false;

for (const legacyPath of existingCandidatePaths) {
const liveCurrentStorageBeforeMerge =
await readLiveCurrentStorageIfExportMode();
if (liveCurrentStorageBeforeMerge.exists) {
return liveCurrentStorageBeforeMerge.storage;
}

const legacyStorage = await loadNormalizedStorageFromPath(
legacyPath,
"legacy account storage",
Expand All @@ -775,56 +816,68 @@ async function migrateLegacyProjectStorageIfNeeded(
continue;
}

const liveCurrentStorageAfterLegacyRead =
await readLiveCurrentStorageIfExportMode();
if (liveCurrentStorageAfterLegacyRead.exists) {
return liveCurrentStorageAfterLegacyRead.storage;
}

const mergedStorage = mergeStorageForMigration(
targetStorage,
legacyStorage,
normalizeAccountStorage,
);
const fallbackStorage = targetStorage ?? legacyStorage;

try {
await persist(mergedStorage);
targetStorage = mergedStorage;
migrated = true;
} catch (error) {
targetStorage = fallbackStorage;
log.warn("Failed to persist migrated account storage", {
if (commit) {
try {
await persist(mergedStorage);
targetStorage = mergedStorage;
migrated = true;
} catch (error) {
targetStorage = fallbackStorage;
log.warn("Failed to persist migrated account storage", {
from: legacyPath,
to: currentStoragePath,
error: String(error),
});
continue;
}

try {
await fs.unlink(legacyPath);
log.info("Removed legacy account storage file after migration", {
path: legacyPath,
});
} catch (unlinkError) {
const code = (unlinkError as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.warn(
"Failed to remove legacy account storage file after migration",
{
path: legacyPath,
error: String(unlinkError),
},
);
}
}

log.info("Migrated legacy project account storage", {
from: legacyPath,
to: state.currentStoragePath,
error: String(error),
to: currentStoragePath,
accounts: mergedStorage.accounts.length,
});
continue;
}

try {
await fs.unlink(legacyPath);
log.info("Removed legacy account storage file after migration", {
path: legacyPath,
});
} catch (unlinkError) {
const code = (unlinkError as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.warn(
"Failed to remove legacy account storage file after migration",
{
path: legacyPath,
error: String(unlinkError),
},
);
}
}

log.info("Migrated legacy project account storage", {
from: legacyPath,
to: state.currentStoragePath,
accounts: mergedStorage.accounts.length,
});
targetStorage = mergedStorage;
migrated = true;
}

if (migrated) {
return targetStorage;
}
if (targetStorage && !existsSync(state.currentStoragePath)) {
if (targetStorage && !existsSync(currentStoragePath)) {
return targetStorage;
}
return null;
Expand Down Expand Up @@ -1263,7 +1316,7 @@ async function loadAccountsInternal(
const resetMarkerPath = getIntentionalResetMarkerPath(path);
await cleanupStaleRotatingBackupArtifacts(path);
const migratedLegacyStorage = persistMigration
? await migrateLegacyProjectStorageIfNeeded(persistMigration)
? await migrateLegacyProjectStorageIfNeeded({ persist: persistMigration })
: null;

try {
Expand Down Expand Up @@ -1432,6 +1485,48 @@ async function loadAccountsInternal(
}
}

async function loadAccountsForExport(): Promise<AccountStorageV3 | null> {
// Export reuses this helper from both paths in `exportAccounts()`. Keep the
// read side effect free so export never clears a reset marker or races with
// concurrent writers while normalizing legacy storage for the snapshot.
const path = getStoragePath();
const resetMarkerPath = getIntentionalResetMarkerPath(path);

if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}

try {
const { normalized, schemaErrors } = await loadAccountsFromPath(path, {
normalizeAccountStorage,
isRecord,
});
if (schemaErrors.length > 0) {
log.warn("Account storage schema validation warnings", {
errors: schemaErrors.slice(0, 5),
});
}
if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
return normalized;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
if (code === "ENOENT") {
const migratedLegacyStorage =
await migrateLegacyProjectStorageIfNeeded({ commit: false });
if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
return migratedLegacyStorage;
}
throw error;
}
}

async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
const path = getStoragePath();
const resetMarkerPath = getIntentionalResetMarkerPath(path);
Expand Down Expand Up @@ -1788,9 +1883,8 @@ export async function exportAccounts(
force,
currentStoragePath,
transactionState: getTransactionSnapshotState(),
loadAccountsInternal: () => loadAccountsInternal(saveAccountsUnlocked),
readCurrentStorage: () =>
withAccountStorageTransaction((current) => Promise.resolve(current)),
readCurrentStorageUnlocked: () => loadAccountsForExport(),
readCurrentStorage: () => withStorageLock(() => loadAccountsForExport()),
exportAccountsToFile,
beforeCommit,
logInfo: (message, details) => {
Expand Down
4 changes: 2 additions & 2 deletions lib/storage/account-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function exportAccountsSnapshot(params: {
snapshot: AccountStorageV3 | null;
}
| undefined;
loadAccountsInternal: () => Promise<AccountStorageV3 | null>;
readCurrentStorageUnlocked: () => Promise<AccountStorageV3 | null>;
readCurrentStorage: () => Promise<AccountStorageV3 | null>;
exportAccountsToFile: (args: {
resolvedPath: string;
Expand All @@ -28,7 +28,7 @@ export async function exportAccountsSnapshot(params: {
params.transactionState.storagePath === params.currentStoragePath
? params.transactionState.snapshot
: params.transactionState?.active
? await params.loadAccountsInternal()
? await params.readCurrentStorageUnlocked()
: await params.readCurrentStorage();

await params.exportAccountsToFile({
Expand Down
93 changes: 88 additions & 5 deletions test/account-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,111 @@ import {
} from "../lib/storage/account-port.js";

describe("account port helpers", () => {
it("exports transaction snapshot when active", async () => {
it("exports transaction snapshot when active for the current storage path", async () => {
const exportAccountsToFile = vi.fn(async () => undefined);
const snapshot = {
version: 3 as const,
accounts: [{ refreshToken: "snapshot-token" }],
activeIndex: 0,
activeIndexByFamily: {},
};
const readCurrentStorageUnlocked = vi.fn();
const readCurrentStorage = vi.fn();

await exportAccountsSnapshot({
resolvedPath: "/tmp/out.json",
force: true,
currentStoragePath: "/tmp/accounts.json",
transactionState: {
active: true,
storagePath: "/tmp/accounts.json",
snapshot,
},
readCurrentStorageUnlocked,
readCurrentStorage,
exportAccountsToFile,
logInfo: vi.fn(),
});
expect(readCurrentStorageUnlocked).not.toHaveBeenCalled();
expect(readCurrentStorage).not.toHaveBeenCalled();
expect(exportAccountsToFile).toHaveBeenCalledWith(
expect.objectContaining({
storage: snapshot,
}),
);
});

it("reads current storage without reusing a stale transaction snapshot from another path", async () => {
const exportAccountsToFile = vi.fn(async () => undefined);
const readCurrentStorageUnlocked = vi.fn(async () => ({
version: 3 as const,
accounts: [{ refreshToken: "live-token" }],
activeIndex: 0,
activeIndexByFamily: {},
}));
const readCurrentStorage = vi.fn();

await exportAccountsSnapshot({
resolvedPath: "/tmp/out.json",
force: true,
currentStoragePath: "/tmp/accounts.json",
transactionState: {
active: true,
storagePath: "/tmp/other.json",
snapshot: {
version: 3,
accounts: [],
accounts: [{ refreshToken: "stale-token" }],
activeIndex: 0,
activeIndexByFamily: {},
},
},
loadAccountsInternal: vi.fn(),
readCurrentStorage: vi.fn(),
readCurrentStorageUnlocked,
readCurrentStorage,
exportAccountsToFile,
logInfo: vi.fn(),
});
expect(exportAccountsToFile).toHaveBeenCalled();

expect(readCurrentStorageUnlocked).toHaveBeenCalledTimes(1);
expect(readCurrentStorage).not.toHaveBeenCalled();
expect(exportAccountsToFile).toHaveBeenCalledWith(
expect.objectContaining({
storage: expect.objectContaining({
accounts: [{ refreshToken: "live-token" }],
}),
}),
);
});

it("reads current storage via the locked reader when no transaction is active", async () => {
const exportAccountsToFile = vi.fn(async () => undefined);
const readCurrentStorageUnlocked = vi.fn();
const readCurrentStorage = vi.fn(async () => ({
version: 3 as const,
accounts: [{ refreshToken: "locked-read-token" }],
activeIndex: 0,
activeIndexByFamily: {},
}));

await exportAccountsSnapshot({
resolvedPath: "/tmp/out.json",
force: true,
currentStoragePath: "/tmp/accounts.json",
transactionState: undefined,
readCurrentStorageUnlocked,
readCurrentStorage,
exportAccountsToFile,
logInfo: vi.fn(),
});

expect(readCurrentStorageUnlocked).not.toHaveBeenCalled();
expect(readCurrentStorage).toHaveBeenCalledTimes(1);
expect(exportAccountsToFile).toHaveBeenCalledWith(
expect.objectContaining({
storage: expect.objectContaining({
accounts: [{ refreshToken: "locked-read-token" }],
}),
}),
);
});

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