fix(web): bind editor saves to the opened doc and real PATCH

Drop test-only ETag helpers. App now calls saveControlledBlockEdit,
which PATCHes once on the bound editor docId or fail-closes. Resume
hydrate after a blocked first load, and refuse silent doc switches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 20:00:47 +00:00
co-authored by Cursor
parent 1b04d962d1
commit 9b43bd6a73
10 changed files with 544 additions and 99 deletions
@@ -29,6 +29,9 @@ severity: high
| 保存 | `handleSaveBlockEdit` 按 `updated.id` 在 `docAST.rootBlock.children` 查找;`idx === -1` 时 `setEditingBlock(null)` 后 return | 关窗、不发 PATCH、无提示 |
| hydration 触发 | `useEffect([session, sessionValidated, activeDocId, deliverables])` 在 lease/工作流同步改 `deliverables` 时重跑 | fill 窗口打出新 GET |
| 迟到响应 | hydrate `.then` 无 generation / 编辑门闩 | 弹窗打开后或 PATCH 成功后仍可覆盖编辑基线(即使 ID 已对齐,也会回滚新正文) |
| 会话绑定 | 只按块 ID 保存;`activeDocId` 可被 canonical/session/任务流改写,不经 `handleSelectDoc` | 可能对另一份文档发 PATCH,或块 ID 碰巧对上 |
| 关窗续跑 | 编辑中拒绝/跳过的首次 hydrate,effect 依赖不再变化 | 关窗后可能永久停在 loading skeleton |
| 切文档 | `handleSelectDoc` 在编辑中静默 `closeBlockEditor` | 草稿被丢;modal 虽 `fixed inset:0 zIndex 100` 挡住 SlimTabBar,但 chat/drawer 等仍会调 `handleSelectDoc` |
`deliverables` 被 5s lease 同步、任务完成后 `syncControlledDocsAfterTask`、工作流列表合并(清 cache)以及 governance 事件改写。任一变化都会在编辑期间再拉正文并换块身份。
@@ -52,4 +55,6 @@ severity: high
## 开放问题
- 本轮只做前端门闩与单测/构建验证;**未跑真实 UI-06**,不得标 E2E 通过或任务 `done`
- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束,可接受)
- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束;缺 map 时仍随机,门闩已消费)
- **首次 hydrate 在 `editor_open` 被拒后会卡 skeleton**:可达(骨架块可双击,或清 cache 后编辑中跳过 GET)。关窗时 `takeHydrationResume()` 递增 `hydrateResumeKey` 重跑 effect。
- modal 不能硬保证挡死所有 `handleSelectDoc`;编辑中切文档 fail-closed,不静默关窗。
@@ -19,8 +19,9 @@ mutex: document-revision-editor-hydration-race
- `web/src/lib/controlledDocHydrate.ts`:`fetchRevisionState` 保留 `head_block_map`,hydrate 后 `applyHeadBlockMap`
- `web/src/lib/editorHydrationGate.ts` 与单测(迟到响应不得覆盖 PATCH 成功正文)
- `web/src/lib/blockSavePolicy.ts` 与单测(stale-id fail-closed)
- `web/src/App.tsx`:初始/切换/重试 hydration、打开/关闭编辑器、保存
- `web/src/lib/blockSavePolicy.ts` 与单测(stale-id / doc 绑定 / 切文档 fail-closed)
- `web/src/lib/saveControlledBlockEdit.ts`:App 真实调用的 PATCH 编排(可注入 patch API)
- `web/src/App.tsx`:`editorSession`、关窗续跑、切文档 fail-closed
- audit / remediation / CURRENT 认领
## 非目标
@@ -36,8 +37,9 @@ mutex: document-revision-editor-hydration-race
2. [x] 定位全部会在弹窗打开期间替换 AST 的路径(effect / 选文档 / 重试;lease 改 deliverables 触发 effect)
3. [x] hydrate 对齐 `head_block_map` + generation token / 编辑门闩 + stale-id fail-closed
4. [x] 单测:同 map ID 稳定、缺 map 仍随机、deferred hydrate、stale id、迟到 hydrate、PATCH ETag、409
5. [x] `web` npm test / build(tsx 相关 25 项 + 全量 `npm test` + `npm run build` 通过)
6. [ ] 真实 UI-06 重跑(本轮不做)
5. [x] `web` npm test / build(首轮)
6. [x] 独立审查补强:真实 PATCH 编排测试、editorDocId 绑定、关窗续跑、切文档 fail-closed
7. [ ] 真实 UI-06 重跑(本轮不做)
## 验收标准
@@ -55,5 +57,5 @@ mutex: document-revision-editor-hydration-race
- mutex:`document-revision-editor-hydration-race`(新 mutex,未占用 `document-revision-save-selector` 等 in_progress 行)
- 基线:`dev@4c465d2`
- 验证:`npx tsx --test` 覆盖 gate / save policy / hydrate / etag 共 25 pass;全量 `npm test` + `npm run build` 通过
- 验证:补强后全量 `npm test` + `npm run build` 通过;`saveControlledBlockEdit` 断言一次 PATCH
- **未跑真实 UI-06,不得标 done**
@@ -35,12 +35,18 @@ updated: 2026-09-13
1. **身份对齐**:`hydrateControlledDocumentContent` 保留完整 `RevisionState`(含 `head_block_map`),`buildControlledDocumentAST` 后 `applyHeadBlockMap`。同一 head 的重复 hydrate 不再因 `Math.random` 换 ID。
2. **generation / 编辑门闩**:双击立刻 `setEditorOpen(true)`;编辑中 `beginHydration` 返回 `null`;已在途 token 经 `decideHydrationApply` 丢弃。PATCH 成功先 `markSaved()` 再关窗,迟到 hydrate **即使 ID 已对齐**也不得回滚新正文。
3. **stale-id fail-closed**:`resolveSaveTargetBlock` **只按 id**;找不到则提示并留窗,绝不按正文改另一块、绝不静默关窗。
4. **真实 PATCH 编排**:`saveControlledBlockEdit` 被 `App.tsx` 调用;依赖注入 `patchDocumentContent`。成功路径恰好一次 PATCH(绑定 `editorDocId`、hydrate ETag、`client_block_map`),采用响应 ETag。409/网络错误不关窗。
5. **编辑会话绑 docId**:`editorSession = { docId, block }`。`activeDocId` 被 canonical/session 改写后保存 fail-closed,无 PATCH。
6. **关窗续跑**:`editor_open` 跳过/丢弃的 hydrate 置 `blockedNeedsResume`;安全关闭后 `hydrateResumeKey` 重跑 effect,避免永久 loading skeleton。
7. **切文档**:modal `fixed inset:0 z-index 100` 挡住 SlimTabBar,但 chat/drawer 仍可调 `handleSelectDoc`。编辑中切换 fail-closed 并提示,不静默关窗。
## 验收标准
- [x] 纯函数门闩覆盖 UI-06 时序(hydrate 在 fill 后 resolve 不覆盖)
- [x] stale block id 保存不静默关闭、不假成功、不改同内容另一块
- [x] PATCH 成功后迟到 hydration 不回滚;409 保留草稿
- [x] `saveControlledBlockEdit` 成功路径断言恰好一次 PATCH(docId/ETag/body/map);doc 漂移/409/网络不关窗
- [x] 关窗后续跑被阻塞的 hydrate;编辑中切文档 fail-closed
- [ ] 真实 UI-06 E2E(本轮不做,不得勾选)
## 相关链接
+38 -62
View File
@@ -22,10 +22,7 @@ import {
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
import { listProjectWorkflowDocuments } from './api/projectDocuments';
import { patchDocumentContent } from './api/documentRevision';
import { resolvePatchEtag } from './lib/revisionEtag';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch';
import {
documentRevisionInactiveHint,
revisionToolbarMountMode,
@@ -74,9 +71,10 @@ import {
type HydrationToken,
} from './lib/editorHydrationGate';
import {
interpretBlockSaveError,
resolveSaveTargetBlock,
decideActiveDocSwitch,
type BlockEditorSession,
} from './lib/blockSavePolicy';
import { saveControlledBlockEdit } from './lib/saveControlledBlockEdit';
import { DocumentLineage } from './types/projectWorkflow';
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
@@ -730,16 +728,19 @@ export default function App() {
const [docRevRefreshKey, setDocRevRefreshKey] = useState(0);
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
const editorHydrationGateRef = useRef(createEditorHydrationGate());
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
const [hydrateResumeKey, setHydrateResumeKey] = useState(0);
const [editorSession, setEditorSession] = useState<BlockEditorSession | null>(null);
const openBlockEditor = useCallback((block: DocumentBlock) => {
editorHydrationGateRef.current.setEditorOpen(true);
setEditingBlock(block);
}, []);
setEditorSession({ docId: activeDocId, block });
}, [activeDocId]);
const closeBlockEditor = useCallback(() => {
const resume = editorHydrationGateRef.current.takeHydrationResume();
editorHydrationGateRef.current.setEditorOpen(false);
setEditingBlock(null);
setEditorSession(null);
if (resume) setHydrateResumeKey((k) => k + 1);
}, []);
useEffect(() => {
@@ -1227,7 +1228,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument]);
}, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument, hydrateResumeKey]);
const retryControlledDocLoad = useCallback(() => {
const target = deliverables.find((d) => d.docId === activeDocId);
@@ -1296,8 +1297,13 @@ export default function App() {
// 切换文档
const handleSelectDoc = (id: string) => {
if (id !== activeDocId && editorHydrationGateRef.current.isEditorOpen()) {
closeBlockEditor();
const switchDecision = decideActiveDocSwitch({
editorDocId: editorSession?.docId,
nextDocId: id,
});
if (!switchDecision.allow) {
setDocRevConflict(switchDecision.message);
return;
}
editorHydrationGateRef.current.setActiveDocId(id);
setActiveDocId(id);
@@ -3369,22 +3375,8 @@ export default function App() {
]);
};
// 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
// 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
const children = docAST.rootBlock.children || [];
const targetBlock = resolveSaveTargetBlock(children, updated);
if (!targetBlock.ok) {
setDocRevConflict(targetBlock.message);
return;
}
const idx = targetBlock.index;
const nextRoot = {
...docAST.rootBlock,
children: children.map((c, i) => (i === idx ? updated : { ...c })),
};
recomputeNumbering(nextRoot);
const nextAST = { ...docAST, rootBlock: nextRoot };
const target = deliverables.find((d) => d.docId === activeDocId);
if (
target &&
@@ -3395,40 +3387,24 @@ export default function App() {
closeBlockEditor();
return;
}
try {
const etag = resolvePatchEtag({
revisionEtag: nextAST.revisionEtag ?? docAST.revisionEtag,
governanceMode: nextAST.governanceMode ?? docAST.governanceMode,
});
const markdown = blockASTToMarkdown(nextAST);
const idempotencyKey =
(typeof crypto !== 'undefined' && 'randomUUID' in crypto && crypto.randomUUID()) ||
`edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const res = await patchDocumentContent(
activeDocId,
{
markdown,
edit_session_id: `ui-${currentUser.id}`,
client_block_map: astToClientBlockMap(nextAST),
},
{ etag, idempotencyKey },
);
const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map);
if (res.state?.etag) {
aligned.revisionEtag = res.state.etag;
}
editorHydrationGateRef.current.markSaved();
setDocAST(aligned);
setDocCache((prev) => ({ ...prev, [activeDocId]: aligned }));
closeBlockEditor();
setDocRevConflict(null);
setDocRevOverlay(res.manual_overlay || null);
setDocRevRefreshKey((k) => k + 1);
} catch (e) {
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
const ui = interpretBlockSaveError(err);
setDocRevConflict(ui.conflict);
const result = await saveControlledBlockEdit({
editorDocId: editorSession?.docId,
activeDocId,
ast: docAST,
updated,
actorUserId: currentUser.id,
});
if (!result.ok) {
setDocRevConflict(result.message);
return;
}
editorHydrationGateRef.current.markSaved();
setDocAST(result.ast);
setDocCache((prev) => ({ ...prev, [activeDocId]: result.ast }));
closeBlockEditor();
setDocRevConflict(null);
setDocRevOverlay(result.overlay);
setDocRevRefreshKey((k) => k + 1);
};
const handleInsertBlock = (index: number, position: 'above' | 'below') => {
@@ -4320,9 +4296,9 @@ export default function App() {
)}
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
{editingBlock && (
{editorSession && (
<BlockEditorModal
block={editingBlock}
block={editorSession.block}
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
onSave={handleSaveBlockEdit}
onClose={closeBlockEditor}
+43 -16
View File
@@ -1,13 +1,14 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolvePatchEtag } from './revisionEtag';
import {
DOC_SWITCH_WHILE_EDITING_MESSAGE,
EDITOR_DOC_MISMATCH_MESSAGE,
HEAD_CONFLICT_SAVE_MESSAGE,
STALE_BLOCK_SAVE_MESSAGE,
decideActiveDocSwitch,
decideEditorDocBinding,
interpretBlockSaveError,
nextRevisionEtagAfterPatch,
resolveSaveTargetBlock,
shouldCloseEditorAfterSave,
} from './blockSavePolicy';
describe('resolveSaveTargetBlock', () => {
@@ -21,7 +22,6 @@ describe('resolveSaveTargetBlock', () => {
assert.equal(decision.keepEditor, true);
assert.equal(decision.reason, 'block_not_found');
assert.equal(decision.message, STALE_BLOCK_SAVE_MESSAGE);
assert.equal(shouldCloseEditorAfterSave('block_not_found'), false);
});
it('never retargets another block that happens to share the same content', () => {
@@ -36,25 +36,52 @@ describe('resolveSaveTargetBlock', () => {
});
});
describe('block save PATCH / 409 policy', () => {
it('successful save still uses the hydrate ETag baseline and adopts the response ETag', () => {
const etag = resolvePatchEtag({
revisionEtag: '1:0',
governanceMode: 'controlled',
describe('editor session document binding', () => {
it('fail-closes when activeDocId drifts away from the opened editor doc', () => {
const decision = decideEditorDocBinding({
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_research',
});
assert.equal(etag, '1:0');
assert.equal(
nextRevisionEtagAfterPatch({ previousEtag: '1:0', responseEtag: '1:1' }),
'1:1',
);
assert.equal(shouldCloseEditorAfterSave('success'), true);
assert.equal(decision.ok, false);
if (decision.ok) return;
assert.equal(decision.keepEditor, true);
assert.equal(decision.reason, 'doc_mismatch');
assert.equal(decision.message, EDITOR_DOC_MISMATCH_MESSAGE);
});
it('accepts a save only when the editor is still bound to the active doc', () => {
assert.deepEqual(
decideEditorDocBinding({
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
}),
{ ok: true },
);
});
it('blocks switching documents while the editor is open', () => {
const blocked = decideActiveDocSwitch({
editorDocId: 'doc_a',
nextDocId: 'doc_b',
});
assert.equal(blocked.allow, false);
if (blocked.allow) return;
assert.equal(blocked.keepEditor, true);
assert.equal(blocked.message, DOC_SWITCH_WHILE_EDITING_MESSAGE);
assert.deepEqual(decideActiveDocSwitch({ editorDocId: 'doc_a', nextDocId: 'doc_a' }), {
allow: true,
});
assert.deepEqual(decideActiveDocSwitch({ editorDocId: null, nextDocId: 'doc_b' }), {
allow: true,
});
});
});
describe('interpretBlockSaveError', () => {
it('409 keeps the draft in the editor', () => {
const ui = interpretBlockSaveError({ code: 'DOCUMENT_HEAD_CONFLICT' });
assert.equal(ui.keepEditor, true);
assert.equal(ui.conflict, HEAD_CONFLICT_SAVE_MESSAGE);
assert.equal(shouldCloseEditorAfterSave('conflict'), false);
});
it('other PATCH failures also keep the editor', () => {
+43 -13
View File
@@ -1,13 +1,30 @@
import type { DocumentBlock } from '../types/block';
export const STALE_BLOCK_SAVE_MESSAGE =
'保存失败:当前正文块身份已随后台刷新失效,编辑器仍保留你的修改。请勿关闭以免丢稿;刷新对照后重新打开该块再保存。';
export const HEAD_CONFLICT_SAVE_MESSAGE =
'保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。';
export const EDITOR_DOC_MISMATCH_MESSAGE =
'保存失败:当前编辑会话不属于正在查看的文档。编辑器仍保留你的修改,请先回到原文档再保存。';
export const DOC_SWITCH_WHILE_EDITING_MESSAGE =
'文档切换已取消:编辑器仍保留未保存修改。请先保存或关闭编辑器,再切换文档。';
export type BlockEditorSession = {
docId: string;
block: DocumentBlock;
};
export type SaveTargetDecision =
| { ok: true; index: number }
| { ok: false; keepEditor: true; reason: 'block_not_found'; message: string };
export type DocSwitchDecision =
| { allow: true }
| { allow: false; keepEditor: true; message: string };
/**
* Resolve the PATCH target strictly by block id.
* Matching another node by identical markdown would write the wrong slot.
@@ -28,6 +45,32 @@ export function resolveSaveTargetBlock(
return { ok: true, index };
}
export function decideEditorDocBinding(input: {
editorDocId?: string | null;
activeDocId: string;
}): { ok: true } | { ok: false; keepEditor: true; reason: 'doc_mismatch'; message: string } {
if (!input.editorDocId || input.editorDocId !== input.activeDocId) {
return {
ok: false,
keepEditor: true,
reason: 'doc_mismatch',
message: EDITOR_DOC_MISMATCH_MESSAGE,
};
}
return { ok: true };
}
/** SlimTabBar is usually under the modal, but handleSelectDoc also comes from chat/drawer/effects. */
export function decideActiveDocSwitch(input: {
editorDocId?: string | null;
nextDocId: string;
}): DocSwitchDecision {
if (input.editorDocId && input.editorDocId !== input.nextDocId) {
return { allow: false, keepEditor: true, message: DOC_SWITCH_WHILE_EDITING_MESSAGE };
}
return { allow: true };
}
export function interpretBlockSaveError(err: { code?: string; message?: string }): {
keepEditor: true;
conflict: string;
@@ -40,16 +83,3 @@ export function interpretBlockSaveError(err: { code?: string; message?: string }
conflict: `保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`,
};
}
export function nextRevisionEtagAfterPatch(input: {
previousEtag?: string;
responseEtag?: string;
}): string | undefined {
return input.responseEtag || input.previousEtag;
}
export function shouldCloseEditorAfterSave(
kind: 'success' | 'conflict' | 'block_not_found',
): boolean {
return kind === 'success';
}
+25
View File
@@ -103,6 +103,31 @@ describe('createEditorHydrationGate', () => {
assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' });
});
it('asks for a hydrate resume after editor_open blocked the first apply', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
const token = gate.beginHydration('doc_a');
assert.ok(token);
assert.equal(gate.takeHydrationResume(), false);
gate.setEditorOpen(true);
assert.equal(gate.decide(token).reason, 'editor_open');
gate.setEditorOpen(false);
assert.equal(gate.takeHydrationResume(), true);
assert.equal(gate.takeHydrationResume(), false);
const retry = gate.beginHydration('doc_a');
assert.ok(retry);
assert.deepEqual(gate.decide(retry), { apply: true, reason: 'ok' });
});
it('asks for a hydrate resume when beginHydration is skipped while editing', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
gate.setEditorOpen(true);
assert.equal(gate.beginHydration('doc_a'), null);
gate.setEditorOpen(false);
assert.equal(gate.takeHydrationResume(), true);
});
it('late hydrate after a successful PATCH does not roll back the new baseline', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
+21 -2
View File
@@ -53,6 +53,12 @@ export type EditorHydrationGate = {
beginHydration: (docId: string) => HydrationToken | null;
markSaved: () => void;
decide: (token: HydrationToken) => HydrationApplyDecision;
/**
* True when a hydrate was skipped or discarded because the editor was open.
* Consuming the flag lets the caller re-run hydrate after a safe close,
* so a first-load skeleton is not left forever (effect deps may not change).
*/
takeHydrationResume: () => boolean;
};
export function createEditorHydrationGate(): EditorHydrationGate {
@@ -60,6 +66,7 @@ export function createEditorHydrationGate(): EditorHydrationGate {
let saveEpoch = 0;
let editorOpen = false;
let activeDocId = '';
let blockedNeedsResume = false;
return {
setEditorOpen(open) {
@@ -72,7 +79,10 @@ export function createEditorHydrationGate(): EditorHydrationGate {
activeDocId = id;
},
beginHydration(docId) {
if (editorOpen) return null;
if (editorOpen) {
blockedNeedsResume = true;
return null;
}
hydrationGen += 1;
return { gen: hydrationGen, saveEpoch, docId };
},
@@ -80,13 +90,22 @@ export function createEditorHydrationGate(): EditorHydrationGate {
saveEpoch += 1;
},
decide(token) {
return decideHydrationApply({
const decision = decideHydrationApply({
token,
currentGen: hydrationGen,
currentSaveEpoch: saveEpoch,
activeDocId,
editorOpen,
});
if (decision.reason === 'editor_open') {
blockedNeedsResume = true;
}
return decision;
},
takeHydrationResume() {
if (!blockedNeedsResume) return false;
blockedNeedsResume = false;
return true;
},
};
}
+207
View File
@@ -0,0 +1,207 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { DocumentAST, DocumentBlock } from '../types/block';
import type { DiffResult, RevisionState } from '../features/documentRevision/types';
import {
EDITOR_DOC_MISMATCH_MESSAGE,
HEAD_CONFLICT_SAVE_MESSAGE,
STALE_BLOCK_SAVE_MESSAGE,
} from './blockSavePolicy';
import { saveControlledBlockEdit, type PatchDocumentContent } from './saveControlledBlockEdit';
function sampleAst(): DocumentAST {
return {
docId: 'doc_proj6140_intake',
title: '用户初始需求收集表',
taskKey: 'intake',
version: '1',
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-13',
revisionEtag: '1:0',
governanceMode: 'controlled',
rootBlock: {
id: 'root',
type: 'document',
content: '',
children: [
{ id: 'srv_heading', type: 'heading', level: 1, content: '标题' },
{ id: 'srv_para', type: 'paragraph', content: '正文' },
],
},
};
}
const updated: DocumentBlock = {
id: 'srv_para',
type: 'paragraph',
content: '正文【已修订】',
};
function revisionState(etag: string): RevisionState {
return {
doc_id: 'doc_proj6140_intake',
current_version_no: 1,
version_status: 'open',
baseline_snapshot_id: 1,
head_snapshot_id: 1,
head_revision_no: 1,
head_sha256: 'abc',
manual_change_count: 1,
capabilities: [],
etag,
head_block_map: [
{ id: 'srv_heading', type: 'heading', start_offset: 0, end_offset: 2, order: 0 },
{ id: 'srv_para', type: 'paragraph', start_offset: 3, end_offset: 8, order: 1 },
],
};
}
const emptyOverlay: DiffResult = {
summary: { inserts: 0, deletes: 0, modifies: 0, moves: 0 },
hunks: [],
};
describe('saveControlledBlockEdit', () => {
it('successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map', async () => {
const calls: Array<{
docId: string;
body: { markdown?: string; edit_session_id?: string; client_block_map?: { id: string }[] };
opts: { etag: string; idempotencyKey: string };
}> = [];
const patch: PatchDocumentContent = async (docId, body, opts) => {
calls.push({ docId, body, opts });
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
};
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
idempotencyKey: 'idem-ui06',
},
{ patchDocumentContent: patch },
);
assert.equal(calls.length, 1);
assert.equal(calls[0].docId, 'doc_proj6140_intake');
assert.equal(calls[0].opts.etag, '1:0');
assert.equal(calls[0].opts.idempotencyKey, 'idem-ui06');
assert.equal(calls[0].body.edit_session_id, 'ui-7');
assert.match(calls[0].body.markdown || '', /正文【已修订】/);
assert.deepEqual(
(calls[0].body.client_block_map || []).map((n) => n.id),
['srv_heading', 'srv_para'],
);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.closeEditor, true);
assert.equal(result.markSaved, true);
assert.equal(result.ast.revisionEtag, '1:1');
assert.equal(result.request.etag, '1:0');
assert.equal(result.request.docId, 'doc_proj6140_intake');
});
it('does not PATCH when editorDocId and activeDocId diverge', async () => {
let calls = 0;
const patch: PatchDocumentContent = async () => {
calls += 1;
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
};
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_research',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{ patchDocumentContent: patch },
);
assert.equal(calls, 0);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'doc_mismatch');
assert.equal(result.message, EDITOR_DOC_MISMATCH_MESSAGE);
});
it('does not PATCH on stale block id and keeps the draft', async () => {
let calls = 0;
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated: { ...updated, id: 'blk_from_modal' },
actorUserId: 7,
},
{
patchDocumentContent: async () => {
calls += 1;
return { state: revisionState('1:1'), manual_overlay: emptyOverlay };
},
},
);
assert.equal(calls, 0);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.message, STALE_BLOCK_SAVE_MESSAGE);
});
it('409 does not close the editor or drop the draft', async () => {
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{
patchDocumentContent: async () => {
const err = new Error('conflict') as Error & { code?: string };
err.code = 'DOCUMENT_HEAD_CONFLICT';
throw err;
},
},
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'head_conflict');
assert.equal(result.message, HEAD_CONFLICT_SAVE_MESSAGE);
});
it('network errors do not close the editor or drop the draft', async () => {
const result = await saveControlledBlockEdit(
{
editorDocId: 'doc_proj6140_intake',
activeDocId: 'doc_proj6140_intake',
ast: sampleAst(),
updated,
actorUserId: 7,
},
{
patchDocumentContent: async () => {
throw new Error('network down');
},
},
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.closeEditor, false);
assert.equal(result.keepEditor, true);
assert.equal(result.reason, 'request_failed');
assert.match(result.message, /network down/);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { patchDocumentContent } from '../api/documentRevision';
import { blockASTToMarkdown } from '../engine/blockToMarkdown';
import { recomputeNumbering } from '../engine/autoNumber';
import { applyHeadBlockMap, astToClientBlockMap } from '../features/documentRevision/overlayMatch';
import type { DiffResult, PatchContentBody, RevisionState } from '../features/documentRevision/types';
import type { DocumentAST, DocumentBlock } from '../types/block';
import { resolvePatchEtag } from './revisionEtag';
import {
decideEditorDocBinding,
interpretBlockSaveError,
resolveSaveTargetBlock,
} from './blockSavePolicy';
export type PatchDocumentContent = (
docId: string,
body: PatchContentBody,
opts: { etag: string; idempotencyKey: string },
) => Promise<{ state: RevisionState; manual_overlay: DiffResult; conflict?: unknown }>;
export type SaveControlledBlockEditInput = {
editorDocId?: string | null;
activeDocId: string;
ast: DocumentAST;
updated: DocumentBlock;
actorUserId: number | string;
idempotencyKey?: string;
};
export type SaveControlledBlockEditSuccess = {
ok: true;
closeEditor: true;
markSaved: true;
ast: DocumentAST;
overlay: DiffResult | null;
request: {
docId: string;
etag: string;
body: PatchContentBody;
idempotencyKey: string;
};
};
export type SaveControlledBlockEditFailure = {
ok: false;
closeEditor: false;
keepEditor: true;
reason: 'doc_mismatch' | 'block_not_found' | 'head_conflict' | 'request_failed' | 'etag_missing';
message: string;
};
export type SaveControlledBlockEditResult =
| SaveControlledBlockEditSuccess
| SaveControlledBlockEditFailure;
function newIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export async function saveControlledBlockEdit(
input: SaveControlledBlockEditInput,
deps?: { patchDocumentContent?: PatchDocumentContent },
): Promise<SaveControlledBlockEditResult> {
const bound = decideEditorDocBinding({
editorDocId: input.editorDocId,
activeDocId: input.activeDocId,
});
if (!bound.ok) {
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'doc_mismatch',
message: bound.message,
};
}
const children = input.ast.rootBlock.children || [];
const target = resolveSaveTargetBlock(children, input.updated);
if (!target.ok) {
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'block_not_found',
message: target.message,
};
}
const nextRoot = {
...input.ast.rootBlock,
children: children.map((child, i) => (i === target.index ? input.updated : { ...child })),
};
recomputeNumbering(nextRoot);
const nextAST: DocumentAST = { ...input.ast, rootBlock: nextRoot };
let etag: string;
try {
etag = resolvePatchEtag({
revisionEtag: nextAST.revisionEtag ?? input.ast.revisionEtag,
governanceMode: nextAST.governanceMode ?? input.ast.governanceMode,
});
} catch (e) {
const err = e as Error;
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'etag_missing',
message: interpretBlockSaveError(err).conflict,
};
}
const body: PatchContentBody = {
markdown: blockASTToMarkdown(nextAST),
edit_session_id: `ui-${input.actorUserId}`,
client_block_map: astToClientBlockMap(nextAST),
};
const idempotencyKey = input.idempotencyKey || newIdempotencyKey();
const patch = deps?.patchDocumentContent ?? patchDocumentContent;
try {
const res = await patch(input.activeDocId, body, { etag, idempotencyKey });
const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map);
if (res.state?.etag) {
aligned.revisionEtag = res.state.etag;
}
return {
ok: true,
closeEditor: true,
markSaved: true,
ast: aligned,
overlay: res.manual_overlay || null,
request: { docId: input.activeDocId, etag, body, idempotencyKey },
};
} catch (e) {
const err = e as Error & { code?: string };
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: err.code === 'DOCUMENT_HEAD_CONFLICT' ? 'head_conflict' : 'request_failed',
message: interpretBlockSaveError(err).conflict,
};
}
}