Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions server/collab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1980,6 +1981,7 @@ export async function ensureCanonicalYjsBaselineForDocument(slug: string): Promi
stripEphemeralCollabSpans(row.markdown ?? ''),
parseStoredMarks(row.marks),
persistedYStateVersion,
row.revision,
);
}
}
Expand Down
14 changes: 10 additions & 4 deletions server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,25 +1391,31 @@ export function replaceDocumentProjection(
markdown: string,
marks: Record<string, unknown>,
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);
Expand Down
89 changes: 89 additions & 0 deletions src/tests/collab-replace-projection-revision-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});