diff --git a/server/portal-mindspace-page-core-routes.mjs b/server/portal-mindspace-page-core-routes.mjs index 8b12811..40a0f04 100644 --- a/server/portal-mindspace-page-core-routes.mjs +++ b/server/portal-mindspace-page-core-routes.mjs @@ -12,6 +12,56 @@ function assertRouter(api) { } } +const DEFAULT_PAGE_LIST_SYNC_INTERVAL_MS = 30_000; + +export function createMindSpacePageListSyncScheduler({ + syncUserGeneratedPages, + logger = console, + minIntervalMs = DEFAULT_PAGE_LIST_SYNC_INTERVAL_MS, + now = () => Date.now(), +} = {}) { + if (typeof syncUserGeneratedPages !== 'function') { + throw new Error('createMindSpacePageListSyncScheduler requires syncUserGeneratedPages'); + } + const inFlightByUser = new Map(); + const lastStartedAtByUser = new Map(); + + return function schedulePageListSync(userId) { + if (!userId) return null; + const inFlight = inFlightByUser.get(userId); + if (inFlight) return inFlight; + + const currentTime = now(); + const lastStartedAt = lastStartedAtByUser.get(userId); + if ( + Number.isFinite(minIntervalMs) && + minIntervalMs > 0 && + Number.isFinite(lastStartedAt) && + currentTime - lastStartedAt < minIntervalMs + ) { + return null; + } + + lastStartedAtByUser.set(userId, currentTime); + const task = Promise.resolve() + .then(() => syncUserGeneratedPages(userId)) + .catch((error) => { + logger?.warn?.( + `[MindSpace] background page list sync failed for user ${userId}:`, + error instanceof Error ? error.message : error, + ); + return null; + }) + .finally(() => { + if (inFlightByUser.get(userId) === task) { + inFlightByUser.delete(userId); + } + }); + inFlightByUser.set(userId, task); + return task; + }; +} + export function attachPortalMindSpacePageCoreRoutes( api, { @@ -28,6 +78,8 @@ export function attachPortalMindSpacePageCoreRoutes( sendData, sendError, handleMindSpaceError, + logger = console, + pageListSyncIntervalMs = DEFAULT_PAGE_LIST_SYNC_INTERVAL_MS, } = {}, ) { assertRouter(api); @@ -43,6 +95,11 @@ export function attachPortalMindSpacePageCoreRoutes( 'attachPortalMindSpacePageCoreRoutes requires page route dependencies', ); } + const schedulePageListSync = createMindSpacePageListSyncScheduler({ + syncUserGeneratedPages, + logger, + minIntervalMs: pageListSyncIntervalMs, + }); api.post('/mindspace/v1/pages', async (req, res) => { const pages = getMindSpacePages(); @@ -72,7 +129,9 @@ export function attachPortalMindSpacePageCoreRoutes( return res.status(503).json({ message: 'MindSpace 未启用' }); } try { - await syncUserGeneratedPages(req.currentUser.id); + // Keep remote/public HTML discovery alive, but never put the full + // workspace delivery pass on the latency path for listing pages. + schedulePageListSync(req.currentUser.id); const limit = Number.parseInt(String(req.query.limit ?? ''), 10); const offset = Number.parseInt(String(req.query.offset ?? ''), 10); const result = await pages.listPages(req.currentUser.id, { diff --git a/server/portal-mindspace-page-core-routes.test.mjs b/server/portal-mindspace-page-core-routes.test.mjs index bd03ac6..4b2e313 100644 --- a/server/portal-mindspace-page-core-routes.test.mjs +++ b/server/portal-mindspace-page-core-routes.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { attachPortalMindSpacePageCoreRoutes } from './portal-mindspace-page-core-routes.mjs'; +import { + attachPortalMindSpacePageCoreRoutes, + createMindSpacePageListSyncScheduler, +} from './portal-mindspace-page-core-routes.mjs'; function createRouterRecorder() { const routes = new Map(); @@ -179,6 +182,82 @@ test('create and list preserve payload mapping, sync, and pagination envelope', }); }); +test('list pages responds without waiting for background workspace sync', async () => { + let syncStarted = 0; + let resolveSync; + const received = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async listPages(userId, input) { + received.push({ userId, input }); + return { + items: [{ id: 'page-1' }], + total: 1, + limit: 6, + offset: 0, + hasMore: false, + }; + }, + }), + syncUserGeneratedPages() { + syncStarted += 1; + return new Promise((resolve) => { + resolveSync = resolve; + }); + }, + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + const listRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages')( + createRequest({ query: { limit: '6', offset: '0' } }), + listRes, + ); + + assert.equal(syncStarted, 1); + assert.equal(received.length, 1); + assert.deepEqual(listRes.body.data, [{ id: 'page-1' }]); + + resolveSync?.({ created: 0, updated: 0, skipped: 0 }); +}); + +test('page list background sync coalesces in-flight and throttled requests', async () => { + let currentTime = 1_000; + const calls = []; + const resolvers = []; + const scheduler = createMindSpacePageListSyncScheduler({ + now: () => currentTime, + minIntervalMs: 30_000, + syncUserGeneratedPages(userId) { + calls.push(userId); + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }, + }); + + const first = scheduler('user-1'); + const second = scheduler('user-1'); + assert.equal(first, second); + await Promise.resolve(); + assert.deepEqual(calls, ['user-1']); + + resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 }); + await first; + + currentTime += 1_000; + assert.equal(scheduler('user-1'), null); + assert.deepEqual(calls, ['user-1']); + + currentTime += 30_000; + const third = scheduler('user-1'); + await Promise.resolve(); + assert.deepEqual(calls, ['user-1', 'user-1']); + resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 }); + await third; +}); + test('get and update preserve parallel lookup and version mapping', async () => { const api = createRouterRecorder(); const setup = createDependencies({