fix(data): 增加全局保存失败保护
This commit is contained in:
53
apps/web/lib/optimistic-persistence.test.ts
Normal file
53
apps/web/lib/optimistic-persistence.test.ts
Normal 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);
|
||||
});
|
||||
28
apps/web/lib/optimistic-persistence.ts
Normal file
28
apps/web/lib/optimistic-persistence.ts
Normal 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);
|
||||
}
|
||||
18
apps/web/lib/server-data-save-error-source.test.ts
Normal file
18
apps/web/lib/server-data-save-error-source.test.ts
Normal 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 \/>/);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user