feat(storage): run startup persistence migrations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user