feat(storage): run startup persistence migrations

This commit is contained in:
2026-07-10 17:42:06 +08:00
parent b05adb1bb2
commit 8b40720821
7 changed files with 230 additions and 45 deletions
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory";
import { StorageKeys } from "@/data/storage/storage_keys";
import { runStorageMigrations } from "@/data/storage/storage_migration";
import { SpAsyncUtil } from "@/utils";
describe("runStorageMigrations", () => {
beforeEach(() => {
SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
});
it("moves legacy facebook id into asid", async () => {
await SpAsyncUtil.setString("facebook_id", "asid-legacy");
const result = await runStorageMigrations();
expect(result.success).toBe(true);
await expectString(StorageKeys.asid, "asid-legacy");
await expectString("facebook_id", null);
});
it("does not overwrite existing asid", async () => {
await SpAsyncUtil.setString(StorageKeys.asid, "asid-current");
await SpAsyncUtil.setString("facebook_id", "asid-legacy");
const result = await runStorageMigrations();
expect(result.success).toBe(true);
await expectString(StorageKeys.asid, "asid-current");
await expectString("facebook_id", null);
});
it("migrates legacy pwa dialog markers and removes old keys", async () => {
await SpAsyncUtil.setString("last_pwa_dialog_shown", "2026-01-01");
const result = await runStorageMigrations();
expect(result.success).toBe(true);
const shown = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown);
expect(shown).toEqual({ success: true, data: true });
await expectString("last_pwa_dialog_shown", null);
});
it("records migration version and skips repeated migrations", async () => {
await SpAsyncUtil.setString("facebook_id", "asid-legacy");
await expectSuccess(runStorageMigrations());
await SpAsyncUtil.setString("facebook_id", "asid-new-legacy");
await expectSuccess(runStorageMigrations());
await expectString(StorageKeys.asid, "asid-legacy");
await expectString("facebook_id", "asid-new-legacy");
const version = await SpAsyncUtil.getInt(StorageKeys.storageMigrationVersion);
expect(version).toEqual({ success: true, data: 1 });
});
});
async function expectString(
key: string,
expected: string | null,
): Promise<void> {
const value = await SpAsyncUtil.getString(key);
expect(value).toEqual({ success: true, data: expected });
}
async function expectSuccess(
resultPromise: Promise<{ success: boolean }>,
): Promise<void> {
const result = await resultPromise;
expect(result.success).toBe(true);
}
-12
View File
@@ -15,11 +15,6 @@
import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "../storage_keys";
const LEGACY_PWA_DIALOG_SHOWN_KEYS = [
"cozsweet:lastPwaDialogShown",
"last_pwa_dialog_shown",
];
export class AppStorage {
// ---- PWA install dialog ----
@@ -27,13 +22,6 @@ export class AppStorage {
const shownResult = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown);
if (!shownResult.success) return shownResult;
if (shownResult.data === true) return Result.ok(false);
for (const key of LEGACY_PWA_DIALOG_SHOWN_KEYS) {
const legacyResult = await SpAsyncUtil.getString(key);
if (!legacyResult.success) return legacyResult;
if (legacyResult.data !== null) return Result.ok(false);
}
return Result.ok(true);
}
+4 -28
View File
@@ -92,29 +92,23 @@ export class AuthStorage implements IAuthStorage {
// ---- ASID / PSID ----
async getAsid(): Promise<ResultT<string | null>> {
return getStringWithLegacyFallback(
StorageKeys.asid,
StorageKeys.legacyFacebookId,
);
return SpAsyncUtil.getString(StorageKeys.asid);
}
setAsid(id: string): Promise<ResultT<void>> {
return SpAsyncUtil.setString(StorageKeys.asid, id);
}
clearAsid(): Promise<ResultT<void>> {
return removeKeys(StorageKeys.asid, StorageKeys.legacyFacebookId);
return SpAsyncUtil.remove(StorageKeys.asid);
}
async getPsid(): Promise<ResultT<string | null>> {
return getStringWithLegacyFallback(
StorageKeys.psid,
StorageKeys.legacyFacebookAppId,
);
return SpAsyncUtil.getString(StorageKeys.psid);
}
setPsid(id: string): Promise<ResultT<void>> {
return SpAsyncUtil.setString(StorageKeys.psid, id);
}
clearPsid(): Promise<ResultT<void>> {
return removeKeys(StorageKeys.psid, StorageKeys.legacyFacebookAppId);
return SpAsyncUtil.remove(StorageKeys.psid);
}
// ---- bulk clear ----
@@ -146,24 +140,6 @@ export class AuthStorage implements IAuthStorage {
}
}
async function getStringWithLegacyFallback(
key: string,
legacyKey: string,
): Promise<ResultT<string | null>> {
const value = await SpAsyncUtil.getString(key);
if (!value.success) return value;
if (value.data !== null && value.data.length > 0) return value;
return SpAsyncUtil.getString(legacyKey);
}
async function removeKeys(...keys: string[]): Promise<ResultT<void>> {
for (const key of keys) {
const result = await SpAsyncUtil.remove(key);
if (!result.success) return result;
}
return Result.ok(undefined);
}
function isLoginStatus(value: string): value is LoginStatusT {
return Object.values(LoginStatus).includes(value as LoginStatusT);
}
+1
View File
@@ -7,6 +7,7 @@
*/
export * from "@/utils/result";
export * from "./storage_keys";
export * from "./storage_migration";
export * from "./app/app_storage";
// NOTE: top-level `auth_storage.ts` is intentionally not re-exported here —
// it conflicts with `auth/auth_storage.ts` (both define `AuthStorage`).
+1 -2
View File
@@ -15,8 +15,7 @@ export const StorageKeys = {
refreshToken: "refresh_token",
asid: "asid",
psid: "psid",
legacyFacebookId: "facebook_id",
legacyFacebookAppId: "facebook_app_id",
storageMigrationVersion: "storage_migration_version",
// user
user: "user",
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "./storage_keys";
const CURRENT_STORAGE_MIGRATION_VERSION = 1;
const LEGACY_FACEBOOK_ID_KEY = "facebook_id";
const LEGACY_PWA_DIALOG_SHOWN_KEYS = [
"last_pwa_dialog_shown",
"cozsweet:lastPwaDialogShown",
];
/**
* Runs app startup migrations for persistent key-value storage.
*
* Migrations are intentionally idempotent: copying only fills missing new keys,
* and legacy keys are removed after each attempt.
*/
export async function runStorageMigrations(): Promise<ResultT<void>> {
const versionResult = await SpAsyncUtil.getInt(
StorageKeys.storageMigrationVersion,
);
if (!versionResult.success) return versionResult;
if ((versionResult.data ?? 0) >= CURRENT_STORAGE_MIGRATION_VERSION) {
return Result.ok(undefined);
}
const migrationResult = await migrateToV1();
if (!migrationResult.success) return migrationResult;
return SpAsyncUtil.setInt(
StorageKeys.storageMigrationVersion,
CURRENT_STORAGE_MIGRATION_VERSION,
);
}
async function migrateToV1(): Promise<ResultT<void>> {
const asidResult = await migrateStringKey({
legacyKey: LEGACY_FACEBOOK_ID_KEY,
newKey: StorageKeys.asid,
});
if (!asidResult.success) return asidResult;
return migratePwaDialogShown();
}
async function migrateStringKey(input: {
legacyKey: string;
newKey: string;
}): Promise<ResultT<void>> {
const current = await SpAsyncUtil.getString(input.newKey);
if (!current.success) return current;
const legacy = await readLegacyString(input.legacyKey);
if (!legacy.success) return legacy;
if (!current.data && legacy.data) {
const writeResult = await SpAsyncUtil.setString(input.newKey, legacy.data);
if (!writeResult.success) return writeResult;
}
return removeLegacyKey(input.legacyKey);
}
async function migratePwaDialogShown(): Promise<ResultT<void>> {
const current = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown);
if (!current.success) return current;
let wasShown = current.data === true;
for (const legacyKey of LEGACY_PWA_DIALOG_SHOWN_KEYS) {
const legacy = await readLegacyString(legacyKey);
if (!legacy.success) return legacy;
wasShown = wasShown || legacy.data !== null;
}
if (wasShown && current.data !== true) {
const writeResult = await SpAsyncUtil.setBool(
StorageKeys.pwaDialogShown,
true,
);
if (!writeResult.success) return writeResult;
}
for (const legacyKey of LEGACY_PWA_DIALOG_SHOWN_KEYS) {
const removeResult = await removeLegacyKey(legacyKey);
if (!removeResult.success) return removeResult;
}
return Result.ok(undefined);
}
async function readLegacyString(key: string): Promise<ResultT<string | null>> {
const storageResult = await SpAsyncUtil.getString(key);
if (!storageResult.success) return storageResult;
if (storageResult.data) return storageResult;
const browserValue = readBrowserLocalStorage(key);
if (browserValue !== null && browserValue.length > 0) {
return Result.ok(browserValue);
}
return Result.ok(null);
}
async function removeLegacyKey(key: string): Promise<ResultT<void>> {
const removeResult = await SpAsyncUtil.remove(key);
if (!removeResult.success) return removeResult;
removeBrowserLocalStorage(key);
return Result.ok(undefined);
}
function readBrowserLocalStorage(key: string): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function removeBrowserLocalStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// Best-effort cleanup only.
}
}
+22 -3
View File
@@ -5,11 +5,12 @@
* 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。
*
* 与 <OAuthSessionSync /> 平级:
* - AuthStatusChecker: 启动时一次性 init恢复登录态、绑定 ASID/PSID、尝试 PSID 直登
* - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、绑定 ASID/PSID、尝试 PSID 直登
* - OAuthSessionSync: 持续监听 NextAuth session"别人在写"
*/
import { useEffect } from "react";
import { runStorageMigrations } from "@/data/storage";
import { useAuthDispatch } from "./auth-context";
import { Logger } from "@/utils";
@@ -19,8 +20,26 @@ export function AuthStatusChecker() {
const dispatch = useAuthDispatch();
useEffect(() => {
log.debug("[auth-init] mount → dispatch AuthInit");
dispatch({ type: "AuthInit" });
let cancelled = false;
async function runStartupAuthInit() {
const migrationResult = await runStorageMigrations();
if (!migrationResult.success) {
log.warn(
{ err: migrationResult.error.message },
"[auth-init] storage migration failed; continuing AuthInit",
);
}
if (cancelled) return;
log.debug("[auth-init] mount → dispatch AuthInit");
dispatch({ type: "AuthInit" });
}
void runStartupAuthInit();
return () => {
cancelled = true;
};
}, [dispatch]);
// 无 UI