From f1e2fb0b993467df9a0383b0ba81d7cf64512b59 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 7 Jul 2026 18:22:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(data):=20=E4=B8=B2=E8=A1=8C=E5=8C=96=20AppD?= =?UTF-8?q?ata=20=E5=90=8C=20key=20=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/server-data.test.ts | 81 ++++++++++++++++++++++++++++++++ apps/web/lib/server-data.ts | 16 ++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/server-data.test.ts b/apps/web/lib/server-data.test.ts index 21f35b0..f23ab49 100644 --- a/apps/web/lib/server-data.test.ts +++ b/apps/web/lib/server-data.test.ts @@ -142,6 +142,87 @@ test('server data shares an in-flight load for the same AppData key', async () = } }); +test('server data serializes same-key saves so later writes use the latest AppData version', async () => { + const originalFetch = globalThis.fetch; + const saveCalls: Array<{ value: unknown; version?: string | null }> = []; + let releaseFirstSave!: () => void; + let markFirstSaveStarted!: () => void; + const firstSaveStarted = new Promise((resolve) => { + markFirstSaveStarted = resolve; + }); + const firstSaveCanFinish = new Promise((resolve) => { + releaseFirstSave = resolve; + }); + __resetApiAvailabilityForTests(); + + 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 || init.method === 'GET')) { + return new Response(JSON.stringify({ + key: 'requirements', + value: { requirements: [] }, + version: 'requirements-version-1', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/data/requirements') && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + saveCalls.push(body); + if (body.value.marker === 'first') { + markFirstSaveStarted(); + await firstSaveCanFinish; + return new Response(JSON.stringify({ + key: 'requirements', + value: body.value, + version: 'requirements-version-2', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ + key: 'requirements', + value: body.value, + version: 'requirements-version-3', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); + }) as typeof fetch; + + try { + await loadServerData('requirements', { force: true }); + + const firstSave = saveServerData('requirements', { marker: 'first' }); + await firstSaveStarted; + const secondSave = saveServerData('requirements', { marker: 'second' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(saveCalls.length, 1); + + releaseFirstSave(); + await Promise.all([firstSave, secondSave]); + + assert.deepEqual(saveCalls, [ + { value: { marker: 'first' }, version: 'requirements-version-1' }, + { value: { marker: 'second' }, version: 'requirements-version-2' }, + ]); + } finally { + globalThis.fetch = originalFetch; + __resetApiAvailabilityForTests(); + } +}); + test('server data reuses a fresh cached load for repeated AppData reads', async () => { const originalFetch = globalThis.fetch; let dataRequests = 0; diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts index 647a6b1..80b7cdf 100644 --- a/apps/web/lib/server-data.ts +++ b/apps/web/lib/server-data.ts @@ -36,6 +36,7 @@ const serverDataCache = new Map(); const serverDataLoadPromises = new Map>(); +const serverDataSaveQueues = new Map>(); export const SERVER_DATA_CACHE_MS = 30_000; export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error'; @@ -110,7 +111,20 @@ export async function loadServerData( return promise; } -export async function saveServerData(key: ServerDataKey, value: T): Promise { +export function saveServerData(key: ServerDataKey, value: T): Promise { + const previousSave = serverDataSaveQueues.get(key) ?? Promise.resolve(); + const save = previousSave.then(() => putServerData(key, value)); + const queueTail = save.catch(() => undefined); + serverDataSaveQueues.set(key, queueTail); + void queueTail.then(() => { + if (serverDataSaveQueues.get(key) === queueTail) { + serverDataSaveQueues.delete(key); + } + }); + return save; +} + +async function putServerData(key: ServerDataKey, value: T): Promise { const payload: { value: T; version?: string | null } = { value }; if (serverDataVersions.has(key)) { payload.version = serverDataVersions.get(key) ?? null;