refactor(web): sync server documents from one hook
Keep freeform append, workflow reconcile, and lease polling together so a failed list cannot wipe local docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,7 +46,7 @@ updated: 2026-09-19
|
||||
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
|
||||
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
|
||||
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。工作台主体仍在 `App.tsx` |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。自由文档、工作流对账和编辑租约轮询在 `useServerDocumentSync`。工作台主体仍在 `App.tsx` |
|
||||
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
|
||||
|
||||
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
|
||||
|
||||
+9
-131
@@ -12,7 +12,6 @@ import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActi
|
||||
import {
|
||||
createFreeformDoc,
|
||||
saveMessageToPrivate,
|
||||
listFreeformDocs,
|
||||
fetchFreeformDoc,
|
||||
shareFreeformDoc,
|
||||
unshareFreeformDoc,
|
||||
@@ -87,7 +86,6 @@ import {
|
||||
planControlledContentInvalidation,
|
||||
type ControlledInvalidationPlan,
|
||||
} from './lib/controlledContentInvalidation';
|
||||
import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync';
|
||||
import {
|
||||
createEditorHydrationGate,
|
||||
} from './lib/editorHydrationGate';
|
||||
@@ -196,6 +194,7 @@ import {
|
||||
import { useChatWindow } from './hooks/useChatWindow';
|
||||
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
|
||||
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
|
||||
import { useServerDocumentSync } from './hooks/useServerDocumentSync';
|
||||
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
|
||||
import { useTurnRuntime } from './hooks/useTurnRuntime';
|
||||
import { useTaskRunStream } from './hooks/useTaskRunStream';
|
||||
@@ -1188,140 +1187,19 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||||
let active = true;
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const [privateSummaries, sharedSummaries] = await Promise.all([
|
||||
listFreeformDocs('my_private', currentProjectId).catch(() => []),
|
||||
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
|
||||
]);
|
||||
if (!active) return;
|
||||
const allSummaries = [...privateSummaries, ...sharedSummaries];
|
||||
if (allSummaries.length === 0) return;
|
||||
|
||||
setDeliverables((prev) => {
|
||||
let updated = [...prev];
|
||||
for (const s of allSummaries) {
|
||||
const exists = updated.find((d) => d.docId === s.doc_id);
|
||||
const category = s.space === 'my_private' ? 'private' : 'shared';
|
||||
if (!exists) {
|
||||
updated.unshift({
|
||||
docId: s.doc_id,
|
||||
projectId: s.project_id || currentProjectId,
|
||||
taskKey: 'freeform_doc',
|
||||
title: s.title,
|
||||
stage: 'V0.1',
|
||||
category,
|
||||
creatorId: s.owner_user_id || currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
|
||||
governanceMode: 'freeform',
|
||||
space: s.space,
|
||||
});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('获取服务端自由文档列表异常:', e);
|
||||
}
|
||||
};
|
||||
fetchDocs();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session, sessionValidated, currentProjectId, currentUser.id]);
|
||||
|
||||
// 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return;
|
||||
let active = true;
|
||||
const syncWorkflowDocs = async () => {
|
||||
try {
|
||||
// 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。
|
||||
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId);
|
||||
if (!active) return;
|
||||
setDeliverables((prev) =>
|
||||
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
|
||||
);
|
||||
applyControlledInvalidationPlan(
|
||||
planControlledContentInvalidation({
|
||||
reason: 'list_sync',
|
||||
candidateIds: controlledHydrateCandidateIds(
|
||||
deliverablesRef.current,
|
||||
currentProjectId,
|
||||
),
|
||||
activeDocId: activeDocIdRef.current,
|
||||
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[deliverables] 工作流受控文档同步失败:', e);
|
||||
}
|
||||
};
|
||||
void syncWorkflowDocs();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
useServerDocumentSync({
|
||||
session,
|
||||
sessionValidated,
|
||||
currentProjectId,
|
||||
currentUser.id,
|
||||
currentUser.name,
|
||||
currentUser,
|
||||
workflowDocsEpoch,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
applyControlledInvalidationPlan,
|
||||
]);
|
||||
|
||||
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
|
||||
let cancelled = false;
|
||||
const syncLeases = async () => {
|
||||
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
|
||||
if (cancelled || docs.length === 0) return;
|
||||
const leases = new Map(
|
||||
docs.map((doc) => [
|
||||
doc.doc_id,
|
||||
doc.lease_holder_type
|
||||
? {
|
||||
holderType: doc.lease_holder_type,
|
||||
holderId: doc.lease_holder_id,
|
||||
expiresAt: doc.lease_expires_at,
|
||||
heartbeatAt: doc.lease_heartbeat_at,
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
);
|
||||
setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId));
|
||||
setDocCache((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
leases.forEach((editLease, docId) => {
|
||||
if (!next[docId]) return;
|
||||
if (sameLease(next[docId].editLease, editLease)) return;
|
||||
next[docId] = { ...next[docId], editLease };
|
||||
changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
const timer = window.setInterval(() => void syncLeases(), 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [session, sessionValidated, currentProjectId]);
|
||||
});
|
||||
|
||||
// 模态弹窗与侧栏
|
||||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('server document list sync lives in one hook', () => {
|
||||
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(new URL('./useServerDocumentSync.ts', import.meta.url), 'utf8');
|
||||
assert.equal((app.match(/useServerDocumentSync\(/g) || []).length, 1);
|
||||
assert.equal(app.includes('获取服务端自由文档列表异常'), false);
|
||||
assert.equal(app.includes('工作流受控文档同步失败'), false);
|
||||
assert.equal(hook.includes('listFreeformDocs'), true);
|
||||
assert.equal(hook.includes('mergeWorkflowDocumentsFromServer'), true);
|
||||
assert.equal(hook.includes('applyLeaseUpdates'), true);
|
||||
assert.equal(hook.includes('失败不得调用 merge'), true);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { listFreeformDocs } from '../api/freeformDoc';
|
||||
import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync';
|
||||
import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync';
|
||||
import {
|
||||
controlledHydrateCandidateIds,
|
||||
planControlledContentInvalidation,
|
||||
type ControlledInvalidationPlan,
|
||||
} from '../lib/controlledContentInvalidation';
|
||||
import {
|
||||
createEditorHydrationGate,
|
||||
} from '../lib/editorHydrationGate';
|
||||
import type { LoginResult } from '../types/auth';
|
||||
import type { DocumentAST } from '../types/block';
|
||||
import type { UserProfile } from '../types/docgov';
|
||||
import type { DeliverableItem } from '../types/workbench';
|
||||
|
||||
type Gate = ReturnType<typeof createEditorHydrationGate>;
|
||||
|
||||
export interface ServerDocumentSyncInput {
|
||||
session: LoginResult | null;
|
||||
sessionValidated: boolean | null;
|
||||
currentProjectId: number;
|
||||
currentUser: UserProfile;
|
||||
workflowDocsEpoch: number;
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||||
deliverablesRef: MutableRefObject<DeliverableItem[]>;
|
||||
activeDocIdRef: MutableRefObject<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void;
|
||||
}
|
||||
|
||||
export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
|
||||
const {
|
||||
session,
|
||||
sessionValidated,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
workflowDocsEpoch,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
applyControlledInvalidationPlan,
|
||||
} = input;
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||||
let active = true;
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const [privateSummaries, sharedSummaries] = await Promise.all([
|
||||
listFreeformDocs('my_private', currentProjectId).catch(() => []),
|
||||
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
|
||||
]);
|
||||
if (!active) return;
|
||||
const allSummaries = [...privateSummaries, ...sharedSummaries];
|
||||
if (allSummaries.length === 0) return;
|
||||
|
||||
setDeliverables((prev) => {
|
||||
let updated = [...prev];
|
||||
for (const s of allSummaries) {
|
||||
const exists = updated.find((d) => d.docId === s.doc_id);
|
||||
const category = s.space === 'my_private' ? 'private' : 'shared';
|
||||
if (!exists) {
|
||||
updated.unshift({
|
||||
docId: s.doc_id,
|
||||
projectId: s.project_id || currentProjectId,
|
||||
taskKey: 'freeform_doc',
|
||||
title: s.title,
|
||||
stage: 'V0.1',
|
||||
category,
|
||||
creatorId: s.owner_user_id || currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
|
||||
governanceMode: 'freeform',
|
||||
space: s.space,
|
||||
});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('获取服务端自由文档列表异常:', e);
|
||||
}
|
||||
};
|
||||
fetchDocs();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session, sessionValidated, currentProjectId, currentUser.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return;
|
||||
let active = true;
|
||||
const syncWorkflowDocs = async () => {
|
||||
try {
|
||||
// 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。
|
||||
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId);
|
||||
if (!active) return;
|
||||
setDeliverables((prev) =>
|
||||
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
|
||||
);
|
||||
applyControlledInvalidationPlan(
|
||||
planControlledContentInvalidation({
|
||||
reason: 'list_sync',
|
||||
candidateIds: controlledHydrateCandidateIds(
|
||||
deliverablesRef.current,
|
||||
currentProjectId,
|
||||
),
|
||||
activeDocId: activeDocIdRef.current,
|
||||
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[deliverables] 工作流受控文档同步失败:', e);
|
||||
}
|
||||
};
|
||||
void syncWorkflowDocs();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
session,
|
||||
sessionValidated,
|
||||
currentProjectId,
|
||||
currentUser.id,
|
||||
currentUser.name,
|
||||
workflowDocsEpoch,
|
||||
applyControlledInvalidationPlan,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
|
||||
let cancelled = false;
|
||||
const syncLeases = async () => {
|
||||
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
|
||||
if (cancelled || docs.length === 0) return;
|
||||
const leases = new Map(
|
||||
docs.map((doc) => [
|
||||
doc.doc_id,
|
||||
doc.lease_holder_type
|
||||
? {
|
||||
holderType: doc.lease_holder_type,
|
||||
holderId: doc.lease_holder_id,
|
||||
expiresAt: doc.lease_expires_at,
|
||||
heartbeatAt: doc.lease_heartbeat_at,
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
);
|
||||
setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId));
|
||||
setDocCache((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
leases.forEach((editLease, docId) => {
|
||||
if (!next[docId]) return;
|
||||
if (sameLease(next[docId].editLease, editLease)) return;
|
||||
next[docId] = { ...next[docId], editLease };
|
||||
changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
const timer = window.setInterval(() => void syncLeases(), 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [session, sessionValidated, currentProjectId]);
|
||||
}
|
||||
Reference in New Issue
Block a user