feat(appdata): 冻结兼容写入入口
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
loadServerData,
|
||||
saveServerData,
|
||||
ServerDataConflictError,
|
||||
ServerDataWriteFrozenError,
|
||||
SERVER_DATA_SAVE_ERROR_EVENT,
|
||||
type ServerDataSaveErrorDetail,
|
||||
} from './server-data';
|
||||
@@ -312,3 +313,64 @@ test('server data save failures dispatch a browser-visible failure event', async
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('server data frozen writes surface the replacement domain API path', 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/dev-tasks') && init?.method === 'PUT') {
|
||||
return new Response(JSON.stringify({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'dev-tasks',
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||
}), {
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => saveServerData('dev-tasks', []),
|
||||
(error: unknown) => error instanceof ServerDataWriteFrozenError &&
|
||||
error.key === 'dev-tasks' &&
|
||||
error.replacement === '/api/v1/versions/:versionId/dev-tasks',
|
||||
);
|
||||
assert.equal(events.length, 1);
|
||||
assert.deepEqual(events[0].detail, {
|
||||
key: 'dev-tasks',
|
||||
message: 'AppData 写入已冻结,请改用 /api/v1/versions/:versionId/dev-tasks',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
__resetApiAvailabilityForTests();
|
||||
if (originalWindowDescriptor) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindowDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +29,14 @@ type ServerDataConflictBody<T> = {
|
||||
currentVersion: string | null;
|
||||
};
|
||||
|
||||
type ServerDataWriteFrozenBody = {
|
||||
code: 'APP_DATA_WRITE_FROZEN';
|
||||
key: ServerDataKey;
|
||||
state: 'write_frozen' | 'read_only_archive';
|
||||
replacement: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const serverDataVersions = new Map<ServerDataKey, string | null>();
|
||||
const serverDataCache = new Map<ServerDataKey, {
|
||||
value: unknown | null;
|
||||
@@ -61,8 +69,21 @@ export class ServerDataConflictError<T = unknown> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerDataWriteFrozenError extends Error {
|
||||
constructor(
|
||||
public readonly key: ServerDataKey,
|
||||
public readonly state: 'write_frozen' | 'read_only_archive',
|
||||
public readonly replacement: string,
|
||||
public readonly note?: string,
|
||||
) {
|
||||
super(`AppData 写入已冻结,请改用 ${replacement}`);
|
||||
this.name = 'ServerDataWriteFrozenError';
|
||||
}
|
||||
}
|
||||
|
||||
function getServerDataSaveErrorMessage(error: unknown) {
|
||||
if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试';
|
||||
if (error instanceof ServerDataWriteFrozenError) return error.message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '保存失败,请检查网络或 API 服务';
|
||||
}
|
||||
@@ -153,6 +174,20 @@ async function putServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
||||
notifyServerDataSaveFailure(key, conflict);
|
||||
throw conflict;
|
||||
}
|
||||
if (
|
||||
error instanceof ApiRequestError &&
|
||||
error.status === 409 &&
|
||||
isServerDataWriteFrozenBody(error.body, key)
|
||||
) {
|
||||
const frozen = new ServerDataWriteFrozenError(
|
||||
error.body.key,
|
||||
error.body.state,
|
||||
error.body.replacement,
|
||||
error.body.note,
|
||||
);
|
||||
notifyServerDataSaveFailure(key, frozen);
|
||||
throw frozen;
|
||||
}
|
||||
notifyServerDataSaveFailure(key, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -173,3 +208,18 @@ function isServerDataConflictBody<T>(
|
||||
: true)
|
||||
);
|
||||
}
|
||||
|
||||
function isServerDataWriteFrozenBody(
|
||||
body: unknown,
|
||||
key: ServerDataKey,
|
||||
): body is ServerDataWriteFrozenBody {
|
||||
return (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
(body as { code?: unknown }).code === 'APP_DATA_WRITE_FROZEN' &&
|
||||
(body as { key?: unknown }).key === key &&
|
||||
((body as { state?: unknown }).state === 'write_frozen' ||
|
||||
(body as { state?: unknown }).state === 'read_only_archive') &&
|
||||
typeof (body as { replacement?: unknown }).replacement === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user