From 1306c25c587fff0f664761f8e75e3e932a661d95 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 10:17:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(T13842):=20previewDocUpdateThroughSections?= =?UTF-8?q?=20=E2=80=94=20read-only=20docs=20projection=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the exact projection inside its transaction and rolls it back, so docs-update can write the fetch-visible row from the merged text before the projection commits (review-2253 on #2338, option a). No leases are acquired; conflict version uids name rolled-back rows. Co-Authored-By: Claude Opus 5.5 --- .../docs/__tests__/section-write-path.test.ts | 49 ++++++++++++- packages/core/src/docs/section-write-path.ts | 70 ++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/packages/core/src/docs/__tests__/section-write-path.test.ts b/packages/core/src/docs/__tests__/section-write-path.test.ts index fc742d7b4d..76de016d68 100644 --- a/packages/core/src/docs/__tests__/section-write-path.test.ts +++ b/packages/core/src/docs/__tests__/section-write-path.test.ts @@ -33,7 +33,11 @@ import { updateDocBySlug } from '../docs-update.js'; import { buildDocsFetchResult } from '../fetch-result.js'; import { listSectionLeases, SectionLeaseManager } from '../section-leases.js'; import { sectionIds, sectionStateText, splitDocIntoSections } from '../section-merge.js'; -import { projectDocUpdateThroughSections, releaseSectionLeases } from '../section-write-path.js'; +import { + previewDocUpdateThroughSections, + projectDocUpdateThroughSections, + releaseSectionLeases, +} from '../section-write-path.js'; let tempDir: string; @@ -1012,6 +1016,49 @@ describe('projectDocUpdateThroughSections (T13355)', () => { ).toBe(true); }); + it('T13842: previewDocUpdateThroughSections returns the merged text the projection lands, and writes nothing', async () => { + await projectDocUpdateThroughSections(tempDir, { + slug: 'plan-doc', + authorAgentId: 'agent-seed', + content: DOC_V1, + hlc: hlcAt(1000), + }); + const a = await projectDocUpdateThroughSections(tempDir, { + slug: 'plan-doc', + authorAgentId: 'agent-a', + content: DOC_V2, + hlc: hlcAt(2000), + }); + const staleB = { + slug: 'plan-doc', + authorAgentId: 'agent-b', + sessionId: 'ses-preview', + content: DOC_V1.replace('beta body', 'beta body\n\nB beta line.'), + baseSections: splitDocIntoSections(DOC_V1), + hlc: hlcAt(3000), + }; + const { getConduitDocHead, listConduitAttachmentVersions, readSectionCrdtState } = await import( + '../../store/conduit-doc-history.js' + ); + const betaBefore = await readSectionCrdtState(tempDir, a.docUid, 'h-beta'); + + const preview = await previewDocUpdateThroughSections(tempDir, staleB); + expect(preview.wouldAdvance).toBe(true); + expect(preview.conflicts).toEqual([]); + expect(preview.mergedText).toContain('an appended line from agent B.'); + expect(preview.mergedText).toContain('B beta line.'); + // Nothing landed: same versions, same head, same section state, no lease. + expect(await listConduitAttachmentVersions(tempDir, a.docUid)).toHaveLength(2); + expect((await getConduitDocHead(tempDir, a.docUid))?.headVersionUid).toBe(a.version.uid); + expect((await readSectionCrdtState(tempDir, a.docUid, 'h-beta'))?.snapshot).toEqual( + betaBefore?.snapshot, + ); + expect(await listSectionLeases(tempDir, 'plan-doc')).toEqual([]); + + const real = await projectDocUpdateThroughSections(tempDir, staleB); + expect(real.mergedText).toBe(preview.mergedText); + }); + it('T13604: updateDocBySlug threads section conflicts into the result envelope and emits a W_DOC_SECTION_CONFLICT warning', async () => { // Seed the legacy row so updateDocBySlug finds the slug. const v0 = '# Plan\n\nv0 body'; diff --git a/packages/core/src/docs/section-write-path.ts b/packages/core/src/docs/section-write-path.ts index 27b6f7c470..ef4fc1cd07 100644 --- a/packages/core/src/docs/section-write-path.ts +++ b/packages/core/src/docs/section-write-path.ts @@ -325,6 +325,68 @@ function writeMergedBlob(projectRoot: string, storageKey: string, text: string): export async function projectDocUpdateThroughSections( projectRoot: string, input: ProjectDocUpdateInput, +): Promise { + return projectDocUpdate(projectRoot, input, false); +} + +/** Outcome of {@link previewDocUpdateThroughSections}. */ +export interface DocUpdatePreview { + /** The merged text the projection would land. */ + readonly mergedText: string; + /** Whether the head would advance to that text (max-HLC rule). */ + readonly wouldAdvance: boolean; + /** + * The section conflicts the projection would record. Their version uids + * name rolled-back rows: use `sectionId` only. + */ + readonly conflicts: readonly SectionConflict[]; +} + +/** Carries a preview's result out of its transaction, forcing the rollback. */ +class PreviewRollback extends Error { + constructor(readonly result: ProjectDocUpdateResult) { + super('docs projection preview rolled back by design'); + } +} + +/** + * Preview a docs update without committing it (T13842): runs the exact + * projection — merge, adopt, version append, head upsert — inside its + * transaction and rolls it back, so a caller can write the fetch-visible row + * from the merged text BEFORE the projection commits. Acquires no leases. + * Another writer can still land between the preview and the real + * projection; callers compare the committed `mergedText` with the preview. + * A content-addressed blob may remain as a harmless orphan. + * + * @param projectRoot - Absolute path to the CLEO project root. + * @param input - The update facts, exactly as for {@link projectDocUpdateThroughSections}. + * @returns The merged text, whether the head would advance, and the conflicts. + */ +export async function previewDocUpdateThroughSections( + projectRoot: string, + input: ProjectDocUpdateInput, +): Promise { + const result = await projectDocUpdate(projectRoot, input, true); + return { + mergedText: result.mergedText, + wouldAdvance: result.head.advanced, + conflicts: result.conflicts, + }; +} + +/** + * The projection behind {@link projectDocUpdateThroughSections} and + * {@link previewDocUpdateThroughSections}. + * + * @param projectRoot - Absolute path to the CLEO project root. + * @param input - The update facts. + * @param preview - Roll the transaction back and skip leases. + * @returns The merge outcome. + */ +async function projectDocUpdate( + projectRoot: string, + input: ProjectDocUpdateInput, + preview: boolean, ): Promise { const existingUid = await resolveConduitDocUid(projectRoot, input.slug); const contentBuffer = Buffer.from(input.content, 'utf-8'); @@ -336,7 +398,7 @@ export async function projectDocUpdateThroughSections( input.sessionId ?? (await resolveBoundSession(projectRoot))?.session.id ?? null; const leaseNotices: SectionLeaseNotice[] = []; const acquiredThisRun: string[] = []; - if (boundSessionId !== null) { + if (boundSessionId !== null && !preview) { const leaseManager = new SectionLeaseManager(projectRoot, { sessionId: boundSessionId, holder: input.authorAgentId, @@ -545,7 +607,7 @@ export async function projectDocUpdateThroughSections( } await rebuildConduitAttachmentContributors(projectRoot, docUid); - return { + const result: ProjectDocUpdateResult = { docUid, adopted, mergedText, @@ -556,8 +618,12 @@ export async function projectDocUpdateThroughSections( conflicts, leaseNotices, }; + // @sync-invariant none:input-shape a preview rolls its own transaction back by design; nothing is written + if (preview) throw new PreviewRollback(result); + return result; }); } catch (err) { + if (err instanceof PreviewRollback) return err.result; // The projection rolled back: release the leases acquired for it, so a // failed edit leaves no stale claim. (Renewed pre-existing leases stay — // the session still holds them.)