fix(data): 增加全局保存失败保护

This commit is contained in:
Script Generator
2026-07-03 19:36:18 +08:00
parent 516a5f1b63
commit b4a3c990b4
17 changed files with 727 additions and 132 deletions

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { saveWithOptimisticRollback } from './optimistic-persistence';
test('rolls back optimistic state when the save fails and state is unchanged', async () => {
const previous = [{ id: 'old' }];
const optimistic = [{ id: 'new' }];
let current = optimistic;
let rolledBack = false;
await assert.rejects(
() => saveWithOptimisticRollback({
save: async () => {
throw new Error('save failed');
},
expected: optimistic,
getCurrent: () => current,
rollback: () => {
rolledBack = true;
current = previous;
},
}),
/save failed/,
);
assert.equal(rolledBack, true);
assert.equal(current, previous);
});
test('does not roll back a newer optimistic state when an older save fails', async () => {
const optimistic = [{ id: 'new' }];
const newer = [{ id: 'newer' }];
let current = newer;
let rolledBack = false;
await assert.rejects(
() => saveWithOptimisticRollback({
save: async () => {
throw new Error('save failed');
},
expected: optimistic,
getCurrent: () => current,
rollback: () => {
rolledBack = true;
},
}),
/save failed/,
);
assert.equal(rolledBack, false);
assert.equal(current, newer);
});

View File

@@ -0,0 +1,28 @@
type OptimisticRollbackOptions<T> = {
save: () => Promise<void>;
expected: T;
getCurrent: () => T;
rollback: () => void;
};
export async function saveWithOptimisticRollback<T>({
save,
expected,
getCurrent,
rollback,
}: OptimisticRollbackOptions<T>): Promise<void> {
try {
await save();
} catch (error) {
if (Object.is(getCurrent(), expected)) {
rollback();
}
throw error;
}
}
export function scheduleSaveWithOptimisticRollback<T>(
options: OptimisticRollbackOptions<T>,
): void {
void saveWithOptimisticRollback(options).catch(() => undefined);
}

View File

@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
test('server data save failures are surfaced through a global browser event', () => {
const source = readFileSync(join(process.cwd(), 'lib/server-data.ts'), 'utf8');
assert.match(source, /export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';/);
assert.match(source, /window\.dispatchEvent\(\s*new CustomEvent\(SERVER_DATA_SAVE_ERROR_EVENT/);
});
test('layout shell renders the global server data save error banner', () => {
const source = readFileSync(join(process.cwd(), 'components/layout/LayoutShell.tsx'), 'utf8');
assert.match(source, /import \{ ServerDataSaveErrorBanner \} from '@\/components\/ServerDataSaveErrorBanner';/);
assert.match(source, /<ServerDataSaveErrorBanner \/>/);
});

View File

@@ -1,7 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { loadServerData, saveServerData, ServerDataConflictError } from './server-data';
import { __resetApiAvailabilityForTests } from './api';
import {
loadServerData,
saveServerData,
ServerDataConflictError,
SERVER_DATA_SAVE_ERROR_EVENT,
type ServerDataSaveErrorDetail,
} from './server-data';
type MockResponse = {
status: number;
@@ -172,3 +179,55 @@ test('server data reuses a fresh cached load for repeated AppData reads', async
globalThis.fetch = originalFetch;
}
});
test('server data save failures dispatch a browser-visible failure event', async () => {
const originalFetch = globalThis.fetch;
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const events: Array<CustomEvent<ServerDataSaveErrorDetail>> = [];
__resetApiAvailabilityForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: (event: Event) => {
events.push(event as CustomEvent<ServerDataSaveErrorDetail>);
return true;
},
},
});
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/config/ai')) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/requirements') && init?.method === 'PUT') {
return new Response(JSON.stringify({ message: 'offline' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
}) as typeof fetch;
try {
await assert.rejects(() => saveServerData('requirements', []), /offline/);
assert.equal(events.length, 1);
assert.equal(events[0].type, SERVER_DATA_SAVE_ERROR_EVENT);
assert.deepEqual(events[0].detail, {
key: 'requirements',
message: 'offline',
});
} finally {
globalThis.fetch = originalFetch;
__resetApiAvailabilityForTests();
if (originalWindowDescriptor) {
Object.defineProperty(globalThis, 'window', originalWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});

View File

@@ -37,6 +37,12 @@ const serverDataCache = new Map<ServerDataKey, {
}>();
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
export const SERVER_DATA_CACHE_MS = 30_000;
export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';
export type ServerDataSaveErrorDetail = {
key: ServerDataKey;
message: string;
};
type LoadServerDataOptions = {
force?: boolean;
@@ -54,6 +60,24 @@ export class ServerDataConflictError<T = unknown> extends Error {
}
}
function getServerDataSaveErrorMessage(error: unknown) {
if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试';
if (error instanceof Error && error.message) return error.message;
return '保存失败,请检查网络或 API 服务';
}
function notifyServerDataSaveFailure(key: ServerDataKey, error: unknown) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
detail: {
key,
message: getServerDataSaveErrorMessage(error),
},
}),
);
}
export async function loadServerData<T>(
key: ServerDataKey,
options: LoadServerDataOptions = {},
@@ -107,12 +131,15 @@ export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<v
error.status === 409 &&
isServerDataConflictBody<T>(error.body, key)
) {
throw new ServerDataConflictError(
const conflict = new ServerDataConflictError(
error.body.key,
error.body.currentValue,
error.body.currentVersion,
);
notifyServerDataSaveFailure(key, conflict);
throw conflict;
}
notifyServerDataSaveFailure(key, error);
throw error;
}
}