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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user