Skip to content

Commit c350e4a

Browse files
committed
Merge PR #259: refactor: extract storage record utils
# Conflicts: # lib/storage.ts
2 parents 734eba3 + 2ede713 commit c350e4a

8 files changed

Lines changed: 242 additions & 148 deletions

File tree

lib/storage.ts

Lines changed: 42 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createHash } from "node:crypto";
21
import { existsSync, promises as fs } from "node:fs";
32
import { basename, dirname, join } from "node:path";
43
import { ACCOUNT_LIMITS } from "./constants.js";
@@ -11,14 +10,24 @@ import {
1110
} from "./named-backup-export.js";
1211
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
1312
import { clearAccountStorageArtifacts } from "./storage/account-clear.js";
13+
import {
14+
collectDistinctIdentityValues,
15+
findNewestMatchingIndex as findNewestMatchingIndexByRef,
16+
selectNewestAccount,
17+
} from "./storage/account-match-utils.js";
1418
import { cloneAccountStorageForPersistence } from "./storage/account-persistence.js";
19+
import {
20+
describeAccountSnapshot as describeAccountSnapshotWithDeps,
21+
statSnapshot as statSnapshotWithDeps,
22+
} from "./storage/account-snapshot.js";
1523
import { buildBackupMetadata } from "./storage/backup-metadata-builder.js";
1624
import {
1725
type BackupMetadataSection,
1826
type BackupSnapshotKind,
1927
type BackupSnapshotMetadata,
2028
buildMetadataSection,
2129
} from "./storage/backup-metadata.js";
30+
import { isCacheLikeBackupArtifactName } from "./storage/cache-artifacts.js";
2231
import {
2332
ACCOUNTS_BACKUP_SUFFIX,
2433
ACCOUNTS_WAL_SUFFIX,
@@ -30,12 +39,14 @@ import {
3039
} from "./storage/backup-paths.js";
3140
import { restoreAccountsFromBackupPath } from "./storage/backup-restore.js";
3241
import { looksLikeSyntheticFixtureStorage } from "./storage/fixture-guards.js";
42+
import { loadFlaggedAccountsFromFile } from "./storage/flagged-storage-file.js";
3343
import { normalizeFlaggedStorage } from "./storage/flagged-storage.js";
3444
import {
3545
clearFlaggedAccountsOnDisk,
3646
loadFlaggedAccountsState,
3747
saveFlaggedAccountsUnlockedToDisk,
3848
} from "./storage/flagged-storage-io.js";
49+
import { computeSha256 } from "./storage/hash.js";
3950
import {
4051
exportAccountsToFile,
4152
mergeImportedAccounts,
@@ -85,7 +96,12 @@ import {
8596
loadNormalizedStorageFromPath,
8697
mergeStorageForMigration,
8798
} from "./storage/project-migration.js";
99+
import { clampIndex, isRecord } from "./storage/record-utils.js";
88100
import { buildRestoreAssessment } from "./storage/restore-assessment.js";
101+
import {
102+
createEmptyStorageWithRestoreMetadata as createEmptyStorageWithMetadata,
103+
withRestoreMetadata,
104+
} from "./storage/restore-metadata.js";
89105
import {
90106
loadAccountsFromPath,
91107
parseAndNormalizeStorage,
@@ -130,11 +146,6 @@ export interface FlaggedAccountStorageV1 {
130146

131147
type RestoreReason = "empty-storage" | "intentional-reset" | "missing-storage";
132148

133-
type AccountStorageWithMetadata = AccountStorageV3 & {
134-
restoreEligible?: boolean;
135-
restoreReason?: RestoreReason;
136-
};
137-
138149
export type BackupMetadata = {
139150
accounts: BackupMetadataSection;
140151
flaggedAccounts: BackupMetadataSection;
@@ -418,105 +429,32 @@ async function cleanupStaleRotatingBackupArtifacts(
418429
}
419430
}
420431

421-
function computeSha256(value: string): string {
422-
return createHash("sha256").update(value).digest("hex");
423-
}
424-
425-
function createEmptyStorageWithMetadata(
426-
restoreEligible: boolean,
427-
restoreReason: RestoreReason,
428-
): AccountStorageWithMetadata {
429-
return {
430-
version: 3,
431-
accounts: [],
432-
activeIndex: 0,
433-
activeIndexByFamily: {},
434-
restoreEligible,
435-
restoreReason,
436-
};
437-
}
438-
439-
function withRestoreMetadata(
440-
storage: AccountStorageV3,
441-
restoreEligible: boolean,
442-
restoreReason: RestoreReason,
443-
): AccountStorageWithMetadata {
444-
return {
445-
...storage,
446-
restoreEligible,
447-
restoreReason,
448-
};
449-
}
450-
451-
function isCacheLikeBackupArtifactName(entryName: string): boolean {
452-
return entryName.toLowerCase().includes(".cache");
453-
}
454-
455432
async function statSnapshot(path: string): Promise<{
456433
exists: boolean;
457434
bytes?: number;
458435
mtimeMs?: number;
459436
}> {
460-
try {
461-
const stats = await fs.stat(path);
462-
return { exists: true, bytes: stats.size, mtimeMs: stats.mtimeMs };
463-
} catch (error) {
464-
const code = (error as NodeJS.ErrnoException).code;
465-
if (code !== "ENOENT") {
466-
log.warn("Failed to stat backup candidate", {
467-
path,
468-
error: String(error),
469-
});
470-
}
471-
return { exists: false };
472-
}
437+
return statSnapshotWithDeps(path, {
438+
stat: fs.stat,
439+
logWarn: (message, meta) => log.warn(message, meta),
440+
});
473441
}
474442

475443
async function describeAccountSnapshot(
476444
path: string,
477445
kind: BackupSnapshotKind,
478446
index?: number,
479447
): Promise<BackupSnapshotMetadata> {
480-
const stats = await statSnapshot(path);
481-
if (!stats.exists) {
482-
return { kind, path, index, exists: false, valid: false };
483-
}
484-
try {
485-
const { normalized, schemaErrors, storedVersion } =
486-
await loadAccountsFromPath(path, {
448+
return describeAccountSnapshotWithDeps(path, kind, {
449+
index,
450+
statSnapshot,
451+
loadAccountsFromPath: (targetPath) =>
452+
loadAccountsFromPath(targetPath, {
487453
normalizeAccountStorage,
488454
isRecord,
489-
});
490-
return {
491-
kind,
492-
path,
493-
index,
494-
exists: true,
495-
valid: !!normalized,
496-
bytes: stats.bytes,
497-
mtimeMs: stats.mtimeMs,
498-
version: typeof storedVersion === "number" ? storedVersion : undefined,
499-
accountCount: normalized?.accounts.length,
500-
schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined,
501-
};
502-
} catch (error) {
503-
const code = (error as NodeJS.ErrnoException).code;
504-
if (code !== "ENOENT") {
505-
log.warn("Failed to inspect account snapshot", {
506-
path,
507-
error: String(error),
508-
});
509-
}
510-
return {
511-
kind,
512-
path,
513-
index,
514-
exists: true,
515-
valid: false,
516-
bytes: stats.bytes,
517-
mtimeMs: stats.mtimeMs,
518-
};
519-
}
455+
}),
456+
logWarn: (message, meta) => log.warn(message, meta),
457+
});
520458
}
521459

522460
async function describeAccountsWalSnapshot(
@@ -587,11 +525,13 @@ async function describeAccountsWalSnapshot(
587525
async function loadFlaggedAccountsFromPath(
588526
path: string,
589527
): Promise<FlaggedAccountStorageV1> {
590-
const content = await fs.readFile(path, "utf-8");
591-
const data = JSON.parse(content) as unknown;
592-
return normalizeFlaggedStorage(data, {
593-
isRecord,
594-
now: () => Date.now(),
528+
return loadFlaggedAccountsFromFile(path, {
529+
readFile: fs.readFile,
530+
normalizeFlaggedStorage: (data) =>
531+
normalizeFlaggedStorage(data, {
532+
isRecord,
533+
now: () => Date.now(),
534+
}),
595535
});
596536
}
597537

@@ -896,57 +836,20 @@ async function migrateLegacyProjectStorageIfNeeded(
896836
return null;
897837
}
898838

899-
function selectNewestAccount<T extends AccountLike>(
900-
current: T | undefined,
901-
candidate: T,
902-
): T {
903-
if (!current) return candidate;
904-
const currentLastUsed = current.lastUsed || 0;
905-
const candidateLastUsed = candidate.lastUsed || 0;
906-
if (candidateLastUsed > currentLastUsed) return candidate;
907-
if (candidateLastUsed < currentLastUsed) return current;
908-
const currentAddedAt = current.addedAt || 0;
909-
const candidateAddedAt = candidate.addedAt || 0;
910-
return candidateAddedAt >= currentAddedAt ? candidate : current;
911-
}
912-
913839
type AccountMatchOptions = {
914840
allowUniqueAccountIdFallbackWithoutEmail?: boolean;
915841
};
916842

917-
function collectDistinctIdentityValues(
918-
values: Array<string | undefined>,
919-
): Set<string> {
920-
const distinct = new Set<string>();
921-
for (const value of values) {
922-
if (value) distinct.add(value);
923-
}
924-
return distinct;
925-
}
926-
927843
function findNewestMatchingIndex<T extends AccountLike>(
928844
accounts: readonly T[],
929845
predicate: (ref: AccountIdentityRef) => boolean,
930846
): number | undefined {
931-
let matchIndex: number | undefined;
932-
let match: T | undefined;
933-
for (let i = 0; i < accounts.length; i += 1) {
934-
const account = accounts[i];
935-
if (!account) continue;
936-
const ref = toAccountIdentityRef(account);
937-
if (!predicate(ref)) continue;
938-
if (matchIndex === undefined) {
939-
matchIndex = i;
940-
match = account;
941-
continue;
942-
}
943-
const newest = selectNewestAccount(match, account);
944-
if (newest === account) {
945-
matchIndex = i;
946-
match = account;
947-
}
948-
}
949-
return matchIndex;
847+
return findNewestMatchingIndexByRef(
848+
accounts,
849+
(account) => toAccountIdentityRef(account),
850+
predicate,
851+
selectNewestAccount,
852+
);
950853
}
951854

952855
function findCompositeAccountMatchIndex<T extends AccountLike>(
@@ -1165,15 +1068,6 @@ export function deduplicateAccountsByEmail<
11651068
return deduplicateAccountsByIdentity(accounts);
11661069
}
11671070

1168-
function isRecord(value: unknown): value is Record<string, unknown> {
1169-
return !!value && typeof value === "object" && !Array.isArray(value);
1170-
}
1171-
1172-
function clampIndex(index: number, length: number): number {
1173-
if (length <= 0) return 0;
1174-
return Math.max(0, Math.min(index, length - 1));
1175-
}
1176-
11771071
function extractActiveAccountRef(
11781072
accounts: unknown[],
11791073
activeIndex: number,

lib/storage/account-match-utils.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
type AccountLike = {
2+
addedAt?: number;
3+
lastUsed?: number;
4+
};
5+
6+
export function selectNewestAccount<T extends AccountLike>(
7+
current: T | undefined,
8+
candidate: T,
9+
): T {
10+
if (!current) return candidate;
11+
const currentLastUsed = current.lastUsed || 0;
12+
const candidateLastUsed = candidate.lastUsed || 0;
13+
if (candidateLastUsed > currentLastUsed) return candidate;
14+
if (candidateLastUsed < currentLastUsed) return current;
15+
const currentAddedAt = current.addedAt || 0;
16+
const candidateAddedAt = candidate.addedAt || 0;
17+
return candidateAddedAt >= currentAddedAt ? candidate : current;
18+
}
19+
20+
export function collectDistinctIdentityValues(
21+
values: Array<string | undefined>,
22+
): Set<string> {
23+
const distinct = new Set<string>();
24+
for (const value of values) {
25+
if (value) distinct.add(value);
26+
}
27+
return distinct;
28+
}
29+
30+
export function findNewestMatchingIndex<T, TRef>(
31+
accounts: readonly T[],
32+
toRef: (account: T) => TRef,
33+
predicate: (ref: TRef) => boolean,
34+
selectNewest: (current: T | undefined, candidate: T) => T,
35+
): number | undefined {
36+
let matchIndex: number | undefined;
37+
let match: T | undefined;
38+
for (let i = 0; i < accounts.length; i += 1) {
39+
const account = accounts[i];
40+
if (!account) continue;
41+
const ref = toRef(account);
42+
if (!predicate(ref)) continue;
43+
if (matchIndex === undefined) {
44+
matchIndex = i;
45+
match = account;
46+
continue;
47+
}
48+
const newest = selectNewest(match, account);
49+
if (newest === account) {
50+
matchIndex = i;
51+
match = account;
52+
}
53+
}
54+
return matchIndex;
55+
}

0 commit comments

Comments
 (0)