diff --git a/scripts/heal-table-projection-wedge.ts b/scripts/heal-table-projection-wedge.ts new file mode 100644 index 00000000..74e2795c --- /dev/null +++ b/scripts/heal-table-projection-wedge.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env tsx +// One-time heal for documents wedged by the table-projection bug. +// +// Documents created before the write-time markdown normalization fix store raw +// canonical markdown that never matches the collab fragment's serialization +// (the Milkdown GFM serializer pads table columns and injects `:---` alignment +// markers). Such docs are stuck on readSource=yjs_fallback / mutationReady=false: +// agent mutations return 409 PROJECTION_STALE and browser edits are dropped. +// +// This script rewrites each affected doc's canonical markdown to the fragment +// serialization so the projection converges. It is idempotent — already-canonical +// docs are skipped — so it is safe to re-run. +// +// Usage (run with the server stopped to avoid write contention): +// DATABASE_PATH=$HOME/.proof/proof-share.db npx tsx scripts/heal-table-projection-wedge.ts +// DATABASE_PATH=... npx tsx scripts/heal-table-projection-wedge.ts --dry-run + +const dryRun = process.argv.includes('--dry-run'); + +async function main(): Promise { + const db = await import('../server/db.ts'); + const collab = await import('../server/collab.ts'); + + const docs = db.listActiveDocuments(); + console.log(`[heal] scanning ${docs.length} active document(s)${dryRun ? ' (dry run)' : ''}`); + + let healed = 0; + let skipped = 0; + let failed = 0; + + for (const doc of docs) { + try { + if (dryRun) { + // In dry-run, compute the normalized form without writing. Mirror the + // real heal's gate exactly, including stripEphemeralCollabSpans, so the + // count doesn't mislead the operator. + const current = collab.stripEphemeralCollabSpans(doc.markdown ?? ''); + if (current.trim().length === 0) { skipped += 1; continue; } + const normalized = await collab.deriveCanonicalMarkdownForStorage(current); + // Match healCanonicalMarkdownForCollabFragment's gate: only structural + // divergence wedges; trailing-whitespace-only diffs are skipped. + if (normalized.trimEnd() !== current.trimEnd()) { + healed += 1; + console.log(`[heal] WOULD heal ${doc.slug} (${current.length} -> ${normalized.length} chars)`); + } else { + skipped += 1; + } + continue; + } + + const result = await collab.healCanonicalMarkdownForCollabFragment(doc.slug); + if (result.healed) { + healed += 1; + console.log(`[heal] healed ${doc.slug} (${result.before} -> ${result.after} chars)`); + } else { + skipped += 1; + } + } catch (error) { + failed += 1; + console.error(`[heal] FAILED ${doc.slug}:`, error instanceof Error ? error.message : String(error)); + } + } + + console.log(`[heal] done — ${healed} ${dryRun ? 'would be ' : ''}healed, ${skipped} already canonical, ${failed} failed`); + await collab.stopCollabRuntime(); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/server/canonical-document.ts b/server/canonical-document.ts index 3069c8c5..1c45c948 100644 --- a/server/canonical-document.ts +++ b/server/canonical-document.ts @@ -26,6 +26,7 @@ import { import { maybeFastQuarantineProjectionPathology, cloneAuthoritativeDocState, + deriveCanonicalMarkdownFromProseMirrorDoc, detectPathologicalProjectionRepeat, evaluateProjectionSafety, getCanonicalReadableDocument, @@ -980,9 +981,20 @@ export async function mutateCanonicalDocument(args: CanonicalMutationArgs): Prom const nextMarksBase = hasExplicitNextMarks ? nextMarks : authoritativeMarks; const authoredMarks = extractAuthoredMarksFromDoc(parsedNext.doc as ProseMirrorNode, parser.schema as Schema); const effectiveNextMarks = synchronizeAuthoredMarks(nextMarksBase, authoredMarks); + // Store canonical markdown in the collab fragment's serialization (the same + // fixed point POST /documents uses) so the projection stays fresh after an + // edit. A plain parse->serialize (serializedNextMarkdown) is NOT that fixed + // point — it reformats GFM tables differently from the fragment round trip, so + // storing it re-wedges the doc (readSource=yjs_fallback) on the next read. + // The rich-snapshot branch keeps its raw HTML-preserving form (it intentionally + // holds content the fragment cannot represent). Fall back to the parse->serialize + // form if the fragment derivation fails. + const fragmentCanonicalMarkdown = shouldPreserveRichMarkdownSnapshot(sanitizedMarkdown) + ? null + : await deriveCanonicalMarkdownFromProseMirrorDoc(parsedNext.doc as ProseMirrorNode); const authoritativeNextMarkdown = shouldPreserveRichMarkdownSnapshot(sanitizedMarkdown) ? normalizeStoredMarkdownSnapshot(sanitizedMarkdown) - : serializedNextMarkdown; + : (fragmentCanonicalMarkdown ?? serializedNextMarkdown); try { if (liveRequired && currentMutationBase.source !== 'live_yjs') { diff --git a/server/collab.ts b/server/collab.ts index 721ffe1c..aec87e84 100644 --- a/server/collab.ts +++ b/server/collab.ts @@ -4487,6 +4487,111 @@ async function deriveMarkdownProjectionFromFragment(doc: Y.Doc): Promise derive pipeline the load path uses. A plain +// parse -> serialize is NOT equivalent: the fragment round trip +// (prosemirrorToYXmlFragment / yXmlFragmentToProseMirrorRootNode) reformats GFM +// tables (column padding + explicit `:---` alignment markers), and only the +// fragment-derived form is a stable fixed point. Returns the input unchanged if +// the parser is unavailable — the load-time reconcile heals it later. +export async function deriveCanonicalMarkdownForStorage(markdown: string): Promise { + const input = markdown ?? ''; + if (input.trim().length === 0) return input; + const ydoc = new Y.Doc(); + try { + await seedFragmentFromLegacyMarkdown(ydoc, input); + const derived = await deriveMarkdownProjectionFromFragment(ydoc); + return derived ?? input; + } catch (error) { + console.warn('[collab] failed to normalize canonical markdown for storage; storing raw', { + error: summarizeParseError(error), + }); + return input; + } finally { + ydoc.destroy(); + } +} + +// Derive the canonical (fragment-fixed-point) markdown for a ProseMirror document +// that is about to be written into a collab fragment via prosemirrorToYXmlFragment. +// Mutation write paths (PUT / rewrite / agent edits) build the fragment from a +// parsed ProseMirror doc and must store the SAME serialization the fragment will +// later derive, or the doc re-wedges on the next read exactly like a raw-markdown +// create did. Building the fragment from the identical doc guarantees the match. +// Returns null on failure so callers can fall back to their existing value. +export async function deriveCanonicalMarkdownFromProseMirrorDoc( + doc: ProseMirrorNode, +): Promise { + const ydoc = new Y.Doc(); + try { + prosemirrorToYXmlFragment(doc, ydoc.getXmlFragment('prosemirror') as any); + return await deriveMarkdownProjectionFromFragment(ydoc); + } catch (error) { + console.warn('[collab] failed to derive canonical markdown from ProseMirror doc', { + error: summarizeParseError(error), + }); + return null; + } finally { + ydoc.destroy(); + } +} + +// Heal an EXISTING document whose stored canonical markdown predates the +// write-time normalization above (e.g. a doc created with a raw GFM table before +// this fix). Rewrites `documents.markdown` to the collab fragment's serialization +// and refreshes the projection so it converges to fresh. Idempotent: a no-op when +// the canonical markdown already matches the fragment serialization. +export async function healCanonicalMarkdownForCollabFragment( + slug: string, +): Promise<{ healed: boolean; reason: string; before?: number; after?: number }> { + const row = getDocumentBySlug(slug); + if (!row) return { healed: false, reason: 'missing_doc' }; + if (row.share_state === 'DELETED') return { healed: false, reason: 'deleted' }; + const current = stripEphemeralCollabSpans(row.markdown ?? ''); + if (current.trim().length === 0) return { healed: false, reason: 'empty' }; + + // Only heal seed-only wedged documents. A doc with persisted incremental Yjs + // updates may hold live edits not yet compacted into the canonical row; healing + // re-seeds the collab room from the canonical row and clears persisted Yjs + // state, which would drop those edits. Docs that receive edits are kept + // consistent by write-path normalization instead, so skip them here. + if (getYUpdatesAfter(slug, 0).length > 0) { + return { healed: false, reason: 'has_persisted_edits' }; + } + + const normalized = await deriveCanonicalMarkdownForStorage(current); + if (normalized === current) return { healed: false, reason: 'already_canonical' }; + // Only structural divergence wedges a projection — the read/snapshot path + // tolerates trailing-whitespace differences (a bare trailing newline never + // wedges). Skip cosmetic-only diffs so the heal doesn't churn healthy docs. + if (normalized.trimEnd() === current.trimEnd()) return { healed: false, reason: 'cosmetic_only' }; + + const marks = parseStoredMarks(row.marks); + const yStateVersion = getLatestYStateVersion(slug); + const now = new Date().toISOString(); + const db = getDb(); + const tx = db.transaction(() => { + db.prepare( + "UPDATE documents SET markdown = ?, updated_at = ? WHERE slug = ? AND share_state IN ('ACTIVE', 'PAUSED')", + ).run(normalized, now, slug); + replaceDocumentProjection(slug, normalized, marks, yStateVersion, { + health: 'healthy', + healthReason: null, + }); + }); + tx(); + // Safe because the guard above ensures there are no persisted incremental + // updates: clearing lets the next load re-seed the room from the healed + // canonical row so the fragment, markdown mirror, and projection all agree. + invalidateCollabDocument(slug); + return { healed: true, reason: 'normalized', before: current.length, after: normalized.length }; +} + let canonicalSyncPostApplyFailureForTests: string | null = null; let canonicalSyncForcedRefusalForTests: CanonicalCollabSyncFailureReason | null = null; let canonicalSyncParseFailureForTests = false; diff --git a/server/routes.ts b/server/routes.ts index 80971cb3..41056358 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -6,6 +6,7 @@ import { applyAgentPresenceToLoadedCollab, applyCanonicalDocumentToCollab, buildCollabSession, + deriveCanonicalMarkdownForStorage, getCanonicalReadableDocumentSync, getCollabRuntime, invalidateCollabDocument, @@ -784,7 +785,7 @@ function deriveShareCapabilities(role: ShareRole, shareState: string): { } // Create a shared document -apiRoutes.post('/documents', (req: Request, res: Response) => { +apiRoutes.post('/documents', async (req: Request, res: Response) => { const legacyCreateMode = resolveLegacyCreateMode(getPublicBaseUrl(req)); if (legacyCreateMode === 'disabled') { recordLegacyCreateRouteTelemetry(req, legacyCreateMode, 'blocked_disabled'); @@ -826,7 +827,12 @@ apiRoutes.post('/documents', (req: Request, res: Response) => { const slug = generateSlug(); const ownerSecret = randomUUID(); const normalizedMarks = canonicalizeStoredMarks(marks ?? {}); - const doc = createDocument(slug, sanitizedMarkdown, normalizedMarks, title, ownerId, ownerSecret); + // Store canonical markdown in the collab fragment's serialization so the + // projection derived on first load matches byte-for-byte. Without this, docs + // containing GFM tables wedge on `readSource=yjs_fallback` / `mutationReady=false` + // because the fragment re-serializes tables (column padding + `:---` markers). + const canonicalMarkdown = await deriveCanonicalMarkdownForStorage(sanitizedMarkdown); + const doc = createDocument(slug, canonicalMarkdown, normalizedMarks, title, ownerId, ownerSecret); const defaultAccess = createDocumentAccessToken(slug, 'editor'); const links = buildShareLink(req, doc.slug); const shareUrlWithToken = withShareToken(links.shareUrl, defaultAccess.secret); @@ -1078,7 +1084,11 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< const slug = generateSlug(); const ownerSecret = randomUUID(); - const doc = createDocument(slug, sanitizedMarkdown, marks, title, ownerId, ownerSecret); + // Normalize to the collab fragment's serialization so structural markdown + // (GFM tables, list-then-heading) doesn't wedge the projection. Same rationale + // as POST /documents. + const canonicalMarkdown = await deriveCanonicalMarkdownForStorage(sanitizedMarkdown); + const doc = createDocument(slug, canonicalMarkdown, marks, title, ownerId, ownerSecret); const access = createDocumentAccessToken(slug, requestedRole); const links = buildShareLink(req, doc.slug); const shareUrlWithToken = withShareToken(links.shareUrl, access.secret); diff --git a/src/tests/agent-edit-live-viewer-regression.test.ts b/src/tests/agent-edit-live-viewer-regression.test.ts index 28b7cb3e..1b84612d 100644 --- a/src/tests/agent-edit-live-viewer-regression.test.ts +++ b/src/tests/agent-edit-live-viewer-regression.test.ts @@ -179,6 +179,19 @@ async function run(): Promise { }); const created = await mustJson(createRes, 'create'); + // Capture the canonical markdown the server actually stored. Creation + // normalizes markdown into the collab fragment's serialization, so the stored + // canonical may differ cosmetically from the raw POST body (e.g. a canonical + // blank line before a heading). The blocked-edit assertion below checks this + // stored canonical is left unchanged, not that it equals the raw input. + const canonicalAfterCreateRes = await fetch(`${httpBase}/api/documents/${created.slug}`, { + headers: { + ...CLIENT_HEADERS, + 'x-share-token': created.ownerSecret, + }, + }); + const canonicalAfterCreate = (await mustJson(canonicalAfterCreateRes, 'read-after-create')).markdown; + const collabSessionRes = await fetch(`${httpBase}/api/documents/${created.slug}/collab-session`, { headers: { ...CLIENT_HEADERS, @@ -271,7 +284,7 @@ async function run(): Promise { `Expected /edit/v2 guidance, got ${String(edit.recommendedEndpoint)}`, ); - const expectedFinal = `${DOC_PREFIX}${currentSection}${DOC_SUFFIX}`; + const expectedFinal = canonicalAfterCreate; const readRes = await fetch(`${httpBase}/api/documents/${created.slug}`, { headers: { ...CLIENT_HEADERS, diff --git a/src/tests/collab-table-projection-freshness-regression.test.ts b/src/tests/collab-table-projection-freshness-regression.test.ts new file mode 100644 index 00000000..53bb68e7 --- /dev/null +++ b/src/tests/collab-table-projection-freshness-regression.test.ts @@ -0,0 +1,118 @@ +// Regression: a document containing a GFM markdown table must not wedge its +// projection. Before the fix, seeding the Yjs fragment from a table doc produced +// markdown that differed from the stored canonical (the Milkdown GFM serializer +// pads columns and injects `:---` alignment markers), so the projection was +// permanently stale — readSource=yjs_fallback, mutationReady=false, and every +// agent mutation returned 409 PROJECTION_STALE while browser edits were dropped. +// +// The fix normalizes canonical markdown at write time so the stored canonical +// equals what the fragment serializes back to. This test asserts the doc stays +// projection-fresh after seeding, and that the seed→derive round trip is byte +// exact against the stored canonical. + +import { unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +async function run(): Promise { + const dbName = `proof-collab-table-freshness-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + const dbPath = path.join(os.tmpdir(), dbName); + const previousDbPath = process.env.DATABASE_PATH; + process.env.DATABASE_PATH = dbPath; + + const db = await import('../../server/db.ts'); + const collab = await import('../../server/collab.ts'); + const { getHeadlessMilkdownParser, serializeMarkdown } = await import('../../server/milkdown-headless.ts'); + + const slug = `table-fresh-${Math.random().toString(36).slice(2, 10)}`; + const markdown = [ + '# Table Doc', + '', + 'Intro paragraph.', + '', + '| Name | Role | Notes |', + '| --- | --- | --- |', + '| Alice | Engineer | Works on collab |', + '| Bob | Designer | Owns the editor UI |', + '', + ].join('\n'); + + try { + // The create route stores canonical markdown in the collab fragment's + // serialization (deriveCanonicalMarkdownForStorage). Mirror that here so the + // test exercises the shipped normalization, not raw storage. + const canonicalMarkdown = await collab.deriveCanonicalMarkdownForStorage(markdown); + db.createDocument(slug, canonicalMarkdown, {}, 'table projection freshness'); + + // Seed the canonical Yjs fragment (this is what a first read / browser + // connection triggers). + const handle = await collab.loadCanonicalYDoc(slug); + assert(Boolean(handle), 'Expected canonical Yjs handle'); + + // The stored canonical markdown after creation. With the fix it is the + // serializer-canonical form; without a table it is unchanged. + const stored = db.getDocumentBySlug(slug); + assert(Boolean(stored), 'Expected document row after canonical load'); + const storedMarkdown = stored?.markdown ?? ''; + + // The seed -> derive round trip must be byte-exact against the STORED + // canonical, or the projection freshness check can never converge. + const parser = await getHeadlessMilkdownParser(); + const root = yXmlFragmentToProseMirrorRootNode( + handle!.ydoc.getXmlFragment('prosemirror') as any, + parser.schema as any, + ); + const serialized = await serializeMarkdown(root as any); + assert( + serialized === storedMarkdown, + `Fragment must round-trip to the stored canonical markdown.\nStored:\n${JSON.stringify(storedMarkdown)}\n\nSerialized:\n${JSON.stringify(serialized)}`, + ); + + // The read surface must serve the projection and stay writable — otherwise + // the doc is wedged (yjs_fallback / mutation_ready=false / 409 PROJECTION_STALE). + const readable = collab.getCanonicalReadableDocumentSync(slug, 'snapshot'); + assert(Boolean(readable), 'Expected a readable document'); + assert( + (readable as any).read_source === 'projection' && (readable as any).mutation_ready === true, + `Table document must not wedge. read_source=${(readable as any)?.read_source} mutation_ready=${(readable as any)?.mutation_ready}`, + ); + + // The table's semantic content must survive normalization. + assert(storedMarkdown.includes('Alice') && storedMarkdown.includes('Bob'), 'Table content must be preserved'); + + // Pin the corrected VALUE, not just self-consistency: the stored canonical + // must be the fragment fixed point, which serializes tables with explicit + // `:--` alignment markers. Raw input (`---`) or a plain parse->serialize + // (also `---`) would wedge; only the fragment form has the colon markers. + assert( + /\|\s*:--/.test(storedMarkdown), + `Stored canonical must be the fragment form (\`:--\` alignment markers), got:\n${JSON.stringify(storedMarkdown)}`, + ); + + console.log('✓ table document stays projection-fresh and round-trips to stored canonical'); + } finally { + if (previousDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDbPath; + } + await collab.stopCollabRuntime(); + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${dbPath}${suffix}`); + } catch { + // ignore cleanup errors + } + } + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/src/tests/collab-table-projection-heal-regression.test.ts b/src/tests/collab-table-projection-heal-regression.test.ts new file mode 100644 index 00000000..7fe3cb7a --- /dev/null +++ b/src/tests/collab-table-projection-heal-regression.test.ts @@ -0,0 +1,121 @@ +// Regression: healCanonicalMarkdownForCollabFragment un-wedges an EXISTING doc +// that was created (before the write-time fix) with a raw GFM table. Such a doc +// stores raw canonical markdown that never matches the fragment serialization +// (the Milkdown GFM serializer pads columns and injects `:---` markers), so its +// projection is permanently stale and every agent mutation returns 409 +// PROJECTION_STALE. The heal rewrites canonical to the fragment serialization so +// the two converge. +// +// The wedge condition is: markdown derived from the seeded Yjs fragment != the +// stored canonical markdown. That is exactly what the projection-repair and +// snapshot paths compare, so this test asserts on that derived form. + +import { unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +async function run(): Promise { + const dbName = `proof-collab-table-heal-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + const dbPath = path.join(os.tmpdir(), dbName); + const previousDbPath = process.env.DATABASE_PATH; + process.env.DATABASE_PATH = dbPath; + + const db = await import('../../server/db.ts'); + const collab = await import('../../server/collab.ts'); + const { getHeadlessMilkdownParser, serializeMarkdown } = await import('../../server/milkdown-headless.ts'); + + async function fragmentDerivedMarkdown(): Promise { + const handle = await collab.loadCanonicalYDoc(slug); + const parser = await getHeadlessMilkdownParser(); + const root = yXmlFragmentToProseMirrorRootNode( + handle!.ydoc.getXmlFragment('prosemirror') as any, + parser.schema as any, + ); + return serializeMarkdown(root as any); + } + + const slug = `table-heal-${Math.random().toString(36).slice(2, 10)}`; + // Raw, un-normalized table markdown — how a pre-fix doc was stored. + const rawMarkdown = [ + '# Heal Doc', + '', + '| A | B |', + '| --- | --- |', + '| one | two |', + '| three | four |', + '', + ].join('\n'); + + try { + // Simulate a pre-fix document: raw markdown stored directly (bypassing the + // create route's normalization). + db.createDocument(slug, rawMarkdown, {}, 'table projection heal'); + + // Pre-heal: the fragment serialization diverges from stored canonical — this + // is the wedge (projection can never converge; agent mutations 409). + const derivedBefore = await fragmentDerivedMarkdown(); + const canonicalBefore = db.getDocumentBySlug(slug)?.markdown ?? ''; + assert( + derivedBefore !== canonicalBefore, + 'Precondition: raw-table doc should be wedged (fragment serialization != canonical)', + ); + + // Heal it. + const result = await collab.healCanonicalMarkdownForCollabFragment(slug); + assert(result.healed, `Expected heal to run, got reason=${result.reason}`); + + // Post-heal: canonical now equals the fragment serialization — converged. + const derivedAfter = await fragmentDerivedMarkdown(); + const canonicalAfter = db.getDocumentBySlug(slug)?.markdown ?? ''; + assert( + derivedAfter === canonicalAfter, + `Post-heal: fragment serialization must equal canonical.\nCanonical:\n${JSON.stringify(canonicalAfter)}\n\nDerived:\n${JSON.stringify(derivedAfter)}`, + ); + + // Content preserved. + assert( + canonicalAfter.includes('one') && canonicalAfter.includes('four'), + 'Table content must be preserved after heal', + ); + + // The heal updates canonical + projection but leaves the Yjs `markdown` text + // mirror as-seeded. Confirm the sync read path does not re-wedge on a + // canonical-vs-mirror mismatch (loaded_doc_ahead). + const readable = collab.getCanonicalReadableDocumentSync(slug, 'snapshot'); + assert(Boolean(readable), 'Expected a readable document post-heal'); + assert( + (readable as any).read_source === 'projection' && (readable as any).mutation_ready === true, + `Post-heal sync read must be fresh + writable, not re-wedged. read_source=${(readable as any)?.read_source} mutation_ready=${(readable as any)?.mutation_ready}`, + ); + + // Idempotent: a second heal is a no-op. + const second = await collab.healCanonicalMarkdownForCollabFragment(slug); + assert(!second.healed && second.reason === 'already_canonical', `Second heal should be a no-op, got ${JSON.stringify(second)}`); + + console.log('✓ heal un-wedges an existing raw-table doc and is idempotent'); + } finally { + if (previousDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDbPath; + } + await collab.stopCollabRuntime(); + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${dbPath}${suffix}`); + } catch { + // ignore cleanup errors + } + } + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/src/tests/collab-table-projection-route-regression.test.ts b/src/tests/collab-table-projection-route-regression.test.ts new file mode 100644 index 00000000..f693a4a4 --- /dev/null +++ b/src/tests/collab-table-projection-route-regression.test.ts @@ -0,0 +1,81 @@ +// Regression: POST /documents (and the direct share-markdown route) must +// normalize canonical markdown to the collab fragment form so a table doc does +// not wedge. This exercises the actual HTTP route — the freshness unit test +// bypasses it by calling deriveCanonicalMarkdownForStorage directly, so without +// this test, deleting the route's normalization call would pass CI. + +import { unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express from 'express'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +async function run(): Promise { + const dbName = `proof-collab-table-route-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + const dbPath = path.join(os.tmpdir(), dbName); + const previousDbPath = process.env.DATABASE_PATH; + process.env.DATABASE_PATH = dbPath; + + const [{ apiRoutes }, collab, db] = await Promise.all([ + import('../../server/routes.ts'), + import('../../server/collab.ts'), + import('../../server/db.ts'), + ]); + + const app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api', apiRoutes); + const server = createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const { port } = server.address() as AddressInfo; + const base = `http://127.0.0.1:${port}`; + + const rawTable = '# Route Doc\n\n| Name | Role |\n| --- | --- |\n| Alice | Eng |\n| Bob | Design |\n'; + + try { + const createRes = await fetch(`${base}/api/documents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Route Doc', markdown: rawTable }), + }); + assert(createRes.status === 200 || createRes.status === 201, `Create failed: HTTP ${createRes.status}`); + const created = await createRes.json() as { slug: string }; + + // The route must have normalized the stored canonical to the fragment form + // (`:--` alignment markers), not stored the raw `---` table the client + // POSTed — otherwise the doc wedges on first load. + const stored = db.getDocumentBySlug(created.slug)?.markdown ?? ''; + assert( + /\|\s*:--/.test(stored), + `POST /documents must normalize the table to fragment form. Stored:\n${JSON.stringify(stored)}`, + ); + // The re-derived fragment must equal the stored canonical (no wedge). + const readable = collab.getCanonicalReadableDocumentSync(created.slug, 'snapshot'); + assert( + (readable as any)?.read_source === 'projection' && (readable as any)?.mutation_ready === true, + `Route-created table doc must not wedge. read_source=${(readable as any)?.read_source} mutation_ready=${(readable as any)?.mutation_ready}`, + ); + // Content preserved. + assert(stored.includes('Alice') && stored.includes('Bob'), 'Table content must survive'); + + console.log('✓ POST /documents normalizes table markdown so the doc does not wedge'); + } finally { + server.close(); + if (previousDbPath === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = previousDbPath; + await collab.stopCollabRuntime(); + for (const suffix of ['', '-wal', '-shm']) { + try { unlinkSync(`${dbPath}${suffix}`); } catch { /* ignore */ } + } + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +});