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>
149 lines
4.3 KiB
TypeScript
149 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|