From d0feb4b56565b81589b68cc3443287ec71d36f71 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Wed, 11 Mar 2026 22:01:56 -0700 Subject: [PATCH 1/2] fix: guard replaceDocumentProjection against overwriting newer agent edits Add optional `expectedRevision` parameter to `replaceDocumentProjection`. When provided, appends `AND revision = ?` to the WHERE clause so the Yjs persist pipeline cannot silently overwrite content written by an agent HTTP edit that bumped the revision after the snapshot was taken. Three callsites in collab.ts that have the captured `row.revision` available now pass it; one callsite in `materializeProjection` that lacks row access is left as-is with a TODO comment. Co-Authored-By: Claude Sonnet 4.6 --- server/collab.ts | 6 ++++-- server/db.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/server/collab.ts b/server/collab.ts index 66c8b8e..aa8dc2c 100644 --- a/server/collab.ts +++ b/server/collab.ts @@ -1407,6 +1407,7 @@ function materializeProjection( const marks = mergePreservedActionMarks(slug, encodeMarksMap(marksMap)); const yStateVersion = getLatestYStateVersion(slug); if (options?.bumpRevision === false) { + // TODO: pass expectedRevision once materializeProjection has access to the captured revision const replaced = replaceDocumentProjection(slug, markdownText, marks, yStateVersion); if (!replaced) { throw new Error(`[collab] replaceDocumentProjection returned 0 rows for ${slug}`); @@ -1673,7 +1674,7 @@ async function repairProjectionFromFragment( const projectionNeedsHeal = projectedRow?.projection_health !== 'healthy' || projectedRow?.projection_y_state_version !== yStateVersion; if (projectionNeedsHeal) { - const replaced = replaceDocumentProjection(slug, row.markdown, marks, yStateVersion); + const replaced = replaceDocumentProjection(slug, row.markdown, marks, yStateVersion, row.revision); if (!replaced) { recordProjectionRepair('failure', reasons.join('|') || 'y_state_version_sync_no_rows'); return 'retry'; @@ -1914,7 +1915,7 @@ function persistCanonicalYjsBaseline( const snapshot = Y.encodeStateAsUpdate(ydoc); if (snapshot.byteLength > 0) { saveYSnapshot(slug, 1, snapshot); - replaceDocumentProjection(slug, markdown, marks, 1); + replaceDocumentProjection(slug, markdown, marks, 1, row.revision); } const updated = getDocumentBySlug(slug); @@ -1980,6 +1981,7 @@ export async function ensureCanonicalYjsBaselineForDocument(slug: string): Promi stripEphemeralCollabSpans(row.markdown ?? ''), parseStoredMarks(row.marks), persistedYStateVersion, + row.revision, ); } } diff --git a/server/db.ts b/server/db.ts index e511b23..9ee5f05 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1391,25 +1391,31 @@ export function replaceDocumentProjection( markdown: string, marks: Record, yStateVersion?: number, + expectedRevision?: number, ): boolean { assertWritesAllowed('replaceDocumentProjection'); + const revisionClause = expectedRevision !== undefined ? ' AND revision = ?' : ''; if (yStateVersion !== undefined) { + const params: unknown[] = [markdown, JSON.stringify(marks), yStateVersion, slug]; + if (expectedRevision !== undefined) params.push(expectedRevision); const result = getDb().prepare(` UPDATE documents SET markdown = ?, marks = ?, y_state_version = ? - WHERE slug = ? AND share_state IN ('ACTIVE', 'PAUSED') - `).run(markdown, JSON.stringify(marks), yStateVersion, slug); + WHERE slug = ? AND share_state IN ('ACTIVE', 'PAUSED')${revisionClause} + `).run(...params); if (result.changes > 0) { const updated = getDocumentBySlug(slug); if (updated) upsertDocumentProjectionRow(slug, updated.markdown, updated.marks, updated.revision, updated.y_state_version, updated.updated_at); } return result.changes > 0; } + const params: unknown[] = [markdown, JSON.stringify(marks), slug]; + if (expectedRevision !== undefined) params.push(expectedRevision); const result = getDb().prepare(` UPDATE documents SET markdown = ?, marks = ? - WHERE slug = ? AND share_state IN ('ACTIVE', 'PAUSED') - `).run(markdown, JSON.stringify(marks), slug); + WHERE slug = ? AND share_state IN ('ACTIVE', 'PAUSED')${revisionClause} + `).run(...params); if (result.changes > 0) { const updated = getDocumentBySlug(slug); if (updated) upsertDocumentProjectionRow(slug, updated.markdown, updated.marks, updated.revision, updated.y_state_version, updated.updated_at); From 20b833ac0c4e7a3470cadb333a42f1e38a41ab11 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Wed, 11 Mar 2026 22:05:33 -0700 Subject: [PATCH 2/2] test: add regression test for replaceDocumentProjection revision guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers three cases: - expectedRevision matches current revision → update goes through - agent edit bumps revision before Yjs persist fires → stale expectedRevision makes the write a no-op, preserving the agent's content - no expectedRevision → backward-compat unconditional UPDATE Co-Authored-By: Claude Sonnet 4.6 --- ...-replace-projection-revision-guard.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/tests/collab-replace-projection-revision-guard.test.ts diff --git a/src/tests/collab-replace-projection-revision-guard.test.ts b/src/tests/collab-replace-projection-revision-guard.test.ts new file mode 100644 index 0000000..1c4e5b3 --- /dev/null +++ b/src/tests/collab-replace-projection-revision-guard.test.ts @@ -0,0 +1,89 @@ +import { unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +async function run(): Promise { + const dbName = `proof-replace-projection-revision-guard-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + const dbPath = path.join(os.tmpdir(), dbName); + process.env.DATABASE_PATH = dbPath; + + const db = await import('../../server/db.ts'); + + const markdownOriginal = '# Doc\n\nOriginal content.'; + const markdownYjsProjection = '# Doc\n\nYjs-derived content (from stale snapshot).'; + const markdownAgentEdit = '# Doc\n\nAgent edit content (should be preserved).'; + + try { + // ── Case 1: expectedRevision matches → update goes through ────────────── + const slug1 = `revision-guard-match-${Math.random().toString(36).slice(2, 10)}`; + db.createDocument(slug1, markdownOriginal, {}, 'revision guard test'); + const row1Before = db.getDocumentBySlug(slug1); + assert(row1Before?.revision === 1, 'Expected initial revision to be 1'); + + const replaced1 = db.replaceDocumentProjection(slug1, markdownYjsProjection, {}, undefined, 1); + assert(replaced1, 'Expected replaceDocumentProjection to succeed when expectedRevision matches'); + const row1After = db.getDocumentBySlug(slug1); + assert( + (row1After?.markdown ?? '').includes('Yjs-derived'), + `Expected Yjs projection to be written when revision matches. markdown=${row1After?.markdown}`, + ); + + // ── Case 2: agent edit bumps revision, stale expectedRevision → no-op ─── + const slug2 = `revision-guard-stale-${Math.random().toString(36).slice(2, 10)}`; + db.createDocument(slug2, markdownOriginal, {}, 'revision guard stale test'); + const row2AtSnapshot = db.getDocumentBySlug(slug2); + assert(row2AtSnapshot?.revision === 1, 'Expected initial revision to be 1'); + + // Simulate agent HTTP edit: bumps revision 1 → 2 + const agentUpdated = db.updateDocument(slug2, markdownAgentEdit); + assert(agentUpdated, 'Expected agent updateDocument to succeed'); + const row2AfterAgent = db.getDocumentBySlug(slug2); + assert(row2AfterAgent?.revision === 2, `Expected revision to be 2 after agent edit, got ${row2AfterAgent?.revision}`); + + // Yjs persist fires with stale snapshot revision (1) → should be a no-op + const replaced2 = db.replaceDocumentProjection(slug2, markdownYjsProjection, {}, undefined, 1); + assert(!replaced2, 'Expected replaceDocumentProjection to be a no-op when expectedRevision is stale'); + const row2Final = db.getDocumentBySlug(slug2); + assert( + (row2Final?.markdown ?? '').includes('Agent edit'), + `Expected agent content to be preserved after stale Yjs projection attempt. markdown=${row2Final?.markdown}`, + ); + assert( + !(row2Final?.markdown ?? '').includes('Yjs-derived'), + `Expected Yjs-derived content NOT to overwrite agent edit. markdown=${row2Final?.markdown}`, + ); + assert(row2Final?.revision === 2, `Expected revision to remain 2 after no-op. got ${row2Final?.revision}`); + + // ── Case 3: no expectedRevision → backward-compat unconditional UPDATE ── + const slug3 = `revision-guard-compat-${Math.random().toString(36).slice(2, 10)}`; + db.createDocument(slug3, markdownOriginal, {}, 'revision guard compat test'); + db.updateDocument(slug3, markdownAgentEdit); // revision → 2 + + const replaced3 = db.replaceDocumentProjection(slug3, markdownYjsProjection, {}); + assert(replaced3, 'Expected unconditional UPDATE when expectedRevision is omitted (backward-compat)'); + const row3Final = db.getDocumentBySlug(slug3); + assert( + (row3Final?.markdown ?? '').includes('Yjs-derived'), + `Expected Yjs projection to overwrite when no expectedRevision. markdown=${row3Final?.markdown}`, + ); + + console.log('✓ replaceDocumentProjection revision guard: stale Yjs projection cannot overwrite a newer agent edit'); + } finally { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${dbPath}${suffix}`); + } catch { + // ignore cleanup errors + } + } + } +} + +run().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +});