Skip to content

Commit 445afe6

Browse files
committed
fix: remap stale transaction selections
1 parent a1f39c8 commit 445afe6

2 files changed

Lines changed: 173 additions & 20 deletions

File tree

lib/storage.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1942,7 +1942,7 @@ function setTransactionSnapshotRevision(
19421942
value: revision,
19431943
writable: true,
19441944
configurable: true,
1945-
enumerable: true,
1945+
enumerable: false,
19461946
});
19471947
return storage;
19481948
}
@@ -1965,11 +1965,44 @@ function mergeStaleTransactionSnapshot(
19651965
latest: AccountStorageV3,
19661966
stale: AccountStorageV3,
19671967
): AccountStorageV3 {
1968+
const mergedAccounts = deduplicateAccounts([...latest.accounts, ...stale.accounts]);
1969+
const staleActiveIndex = clampIndex(stale.activeIndex, stale.accounts.length);
1970+
const staleActiveRef = extractActiveAccountRef(stale.accounts, staleActiveIndex);
1971+
const activeIndex = resolveAccountSelectionIndex(
1972+
mergedAccounts,
1973+
{
1974+
accountId: staleActiveRef.accountId,
1975+
email: staleActiveRef.emailKey,
1976+
refreshToken: staleActiveRef.refreshToken,
1977+
},
1978+
staleActiveIndex,
1979+
);
1980+
const activeIndexByFamily: Partial<Record<ModelFamily, number>> = {};
1981+
for (const family of MODEL_FAMILIES) {
1982+
const rawFamilyIndex = stale.activeIndexByFamily?.[family];
1983+
const staleFamilyIndex =
1984+
typeof rawFamilyIndex === "number" && Number.isFinite(rawFamilyIndex)
1985+
? clampIndex(rawFamilyIndex, stale.accounts.length)
1986+
: staleActiveIndex;
1987+
const staleFamilyRef = extractActiveAccountRef(
1988+
stale.accounts,
1989+
staleFamilyIndex,
1990+
);
1991+
activeIndexByFamily[family] = resolveAccountSelectionIndex(
1992+
mergedAccounts,
1993+
{
1994+
accountId: staleFamilyRef.accountId,
1995+
email: staleFamilyRef.emailKey,
1996+
refreshToken: staleFamilyRef.refreshToken,
1997+
},
1998+
staleFamilyIndex,
1999+
);
2000+
}
19682001
const merged = normalizeAccountStorage({
19692002
version: 3,
1970-
accounts: deduplicateAccounts([...latest.accounts, ...stale.accounts]),
1971-
activeIndex: stale.activeIndex,
1972-
activeIndexByFamily: stale.activeIndexByFamily,
2003+
accounts: mergedAccounts,
2004+
activeIndex,
2005+
activeIndexByFamily,
19732006
});
19742007
return cloneAccountStorageForPersistence(merged ?? stale);
19752008
}
@@ -2542,8 +2575,8 @@ export async function importAccounts(
25422575
// Nested imports write immediately to avoid re-entering the storage
25432576
// mutex. Later persist() calls compare the payload's snapshot revision
25442577
// against the live transaction state so stale payloads keep these
2545-
// imported accounts, while payloads built from getCurrent() can still
2546-
// intentionally remove them.
2578+
// imported accounts, while handlers that mutate the live getCurrent()
2579+
// snapshot in place can still intentionally remove them.
25472580
return applyImport(transactionState.snapshot, async (storage) => {
25482581
const nextStorage = cloneAccountStorageForPersistence(storage);
25492582
transactionState.revision += 1;

test/storage.test.ts

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,8 @@ describe("storage", () => {
718718

719719
const stalePersistPayload = {
720720
...current,
721+
activeIndex: 1,
722+
activeIndexByFamily: { codex: 1 },
721723
accounts: [
722724
...current.accounts,
723725
{
@@ -751,6 +753,18 @@ describe("storage", () => {
751753
}),
752754
]),
753755
);
756+
expect(loaded?.accounts[loaded.activeIndex]).toEqual(
757+
expect.objectContaining({
758+
accountId: "acct-appended",
759+
refreshToken: "refresh-appended",
760+
}),
761+
);
762+
expect(loaded?.accounts[loaded.activeIndexByFamily?.codex ?? -1]).toEqual(
763+
expect.objectContaining({
764+
accountId: "acct-appended",
765+
refreshToken: "refresh-appended",
766+
}),
767+
);
754768
});
755769

756770
it("allows a live transaction payload to intentionally remove imported accounts", async () => {
@@ -798,13 +812,10 @@ describe("storage", () => {
798812
if (!liveCurrent) {
799813
throw new Error("expected live transaction snapshot");
800814
}
801-
802-
await persist({
803-
...liveCurrent,
804-
accounts: liveCurrent.accounts.filter(
805-
(account) => account.accountId !== "acct-imported",
806-
),
807-
});
815+
liveCurrent.accounts = liveCurrent.accounts.filter(
816+
(account) => account.accountId !== "acct-imported",
817+
);
818+
await persist(liveCurrent);
808819
});
809820

810821
const loaded = await loadAccounts();
@@ -859,13 +870,10 @@ describe("storage", () => {
859870
if (!liveCurrent) {
860871
throw new Error("expected live transaction snapshot");
861872
}
862-
863-
await persist({
864-
...liveCurrent,
865-
accounts: liveCurrent.accounts.filter(
866-
(account) => account.accountId !== "acct-imported",
867-
),
868-
});
873+
liveCurrent.accounts = liveCurrent.accounts.filter(
874+
(account) => account.accountId !== "acct-imported",
875+
);
876+
await persist(liveCurrent);
869877
});
870878

871879
const loaded = await loadAccounts();
@@ -999,6 +1007,118 @@ describe("storage", () => {
9991007
);
10001008
});
10011009

1010+
it("getCurrent exposes the live snapshot after persist inside a flagged transaction", async () => {
1011+
const now = Date.now();
1012+
let liveSnapshot: Awaited<ReturnType<typeof loadAccounts>> = null;
1013+
1014+
await saveAccounts({
1015+
version: 3,
1016+
activeIndex: 0,
1017+
activeIndexByFamily: { codex: 0 },
1018+
accounts: [
1019+
{
1020+
accountId: "acct-existing",
1021+
email: "existing@example.com",
1022+
refreshToken: "refresh-existing",
1023+
addedAt: now - 1_000,
1024+
lastUsed: now - 1_000,
1025+
},
1026+
],
1027+
});
1028+
1029+
await withAccountAndFlaggedStorageTransaction(
1030+
async (current, persist, getCurrent) => {
1031+
if (!current) {
1032+
throw new Error("expected existing account storage");
1033+
}
1034+
expect(getCurrent()).toBe(current);
1035+
1036+
await persist(
1037+
{
1038+
...current,
1039+
accounts: [
1040+
...current.accounts,
1041+
{
1042+
accountId: "acct-added",
1043+
email: "added@example.com",
1044+
refreshToken: "refresh-added",
1045+
addedAt: now,
1046+
lastUsed: now,
1047+
},
1048+
],
1049+
},
1050+
{
1051+
version: 1,
1052+
accounts: [],
1053+
},
1054+
);
1055+
1056+
liveSnapshot = getCurrent();
1057+
},
1058+
);
1059+
1060+
expect(liveSnapshot?.accounts).toEqual(
1061+
expect.arrayContaining([
1062+
expect.objectContaining({
1063+
accountId: "acct-existing",
1064+
refreshToken: "refresh-existing",
1065+
}),
1066+
expect.objectContaining({
1067+
accountId: "acct-added",
1068+
refreshToken: "refresh-added",
1069+
}),
1070+
]),
1071+
);
1072+
});
1073+
1074+
it("getCurrent starts null and becomes live after persist inside a flagged transaction", async () => {
1075+
const storagePath = getStoragePath();
1076+
const now = Date.now();
1077+
let liveSnapshot: Awaited<ReturnType<typeof loadAccounts>> = null;
1078+
1079+
await fs.mkdir(dirname(storagePath), { recursive: true });
1080+
await fs.writeFile(storagePath, "{invalid-json", "utf-8");
1081+
1082+
await withAccountAndFlaggedStorageTransaction(
1083+
async (current, persist, getCurrent) => {
1084+
expect(current).toBeNull();
1085+
expect(getCurrent()).toBeNull();
1086+
1087+
await persist(
1088+
{
1089+
version: 3,
1090+
activeIndex: 0,
1091+
activeIndexByFamily: { codex: 0 },
1092+
accounts: [
1093+
{
1094+
accountId: "acct-created",
1095+
email: "created@example.com",
1096+
refreshToken: "refresh-created",
1097+
addedAt: now,
1098+
lastUsed: now,
1099+
},
1100+
],
1101+
},
1102+
{
1103+
version: 1,
1104+
accounts: [],
1105+
},
1106+
);
1107+
1108+
liveSnapshot = getCurrent();
1109+
},
1110+
);
1111+
1112+
expect(liveSnapshot?.accounts).toEqual(
1113+
expect.arrayContaining([
1114+
expect.objectContaining({
1115+
accountId: "acct-created",
1116+
refreshToken: "refresh-created",
1117+
}),
1118+
]),
1119+
);
1120+
});
1121+
10021122
it("should preserve distinct shared-accountId imports when the imported row has no email", async () => {
10031123
const existing = {
10041124
version: 3,

0 commit comments

Comments
 (0)