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); 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); +});