diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 72b59e7..b1597cd 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -55,6 +55,7 @@ export { getBacklinks, getSnapshots, listRevisions, + reconstructContent, } from "./revision.ts"; export { createAppSession, diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index d5603d9..f80f237 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -124,6 +124,63 @@ export function listRevisions( return db.query(sql).all(...params) as RevisionHistoryRow[]; } +// Rebuild the post-apply content of a single revision by folding diffs forward +// from the nearest snapshot (or the empty string). Lets the history view render a +// clean word-level diff for revisions older than the snapshot cap, instead of the +// raw dmp patch fallback. Returns null if the revision is unknown or the diff chain +// is corrupt — the caller then falls back to renderDiffInline. +export function reconstructContent( + noteAtUri: string, + revisionAtUri: string, +): string | null { + const db = getDb(); + + const target = db + .query("SELECT id FROM revisions WHERE note_at_uri = ? AND at_uri = ?") + .get(noteAtUri, revisionAtUri) as { id: number } | null; + if (!target) return null; + + // Nearest snapshot at or before R in chain order. Snapshot ids live in a + // separate AUTOINCREMENT sequence from revision ids, so join through + // revision_at_uri to compare positions on the actual chain (id columns are + // not interchangeable across the two tables). + const base = db + .query( + `SELECT s.content AS content, r.id AS rev_id + FROM snapshots s + JOIN revisions r ON r.at_uri = s.revision_at_uri + WHERE s.note_at_uri = ? AND r.id <= ? + ORDER BY r.id DESC + LIMIT 1`, + ) + .get(noteAtUri, target.id) as { content: string; rev_id: number } | null; + + let content = base?.content ?? ""; + const fromId = base?.rev_id ?? 0; + if (fromId === target.id) return content; + + const rows = db + .query( + `SELECT diff FROM revisions + WHERE note_at_uri = ? AND id > ? AND id <= ? + ORDER BY id ASC`, + ) + .all(noteAtUri, fromId, target.id) as { diff: string }[]; + + try { + for (const row of rows) { + content = applyDiff(content, row.diff); + } + } catch (err) { + console.warn( + `[reconstruct] diff chain broken for ${revisionAtUri}: ${String(err)}`, + ); + return null; + } + + return content; +} + export function getSnapshots(noteAtUri: string): SnapshotRow[] { const db = getDb(); return db diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index aed99b2..959360b 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -34,6 +34,7 @@ import { getSidebarNotes, getSnapshots, listRevisions, + reconstructContent, } from "../db/queries/index.ts"; import { hp, hwnp } from "./params.ts"; import { renderNoteResponse } from "./render-note.ts"; @@ -182,17 +183,36 @@ export const noteRoutes = new Elysia() const dids = Array.from(new Set(revisions.map((r) => r.did))); const profiles = getCachedProfilesByDids(dids); - const snapshots = getSnapshots(data.note.at_uri); + const noteAtUri = data.note.at_uri; + const snapshots = getSnapshots(noteAtUri); const snapByUri = new Map( snapshots.map((s) => [s.revision_at_uri, s.content]), ); + // Snapshot if present, else reconstruct by folding the diff chain. + // Memoized so before(R) reuses after(parent) within this page. + const reconstructed = new Map(); + const contentFor = (uri: string): string | null => { + const snap = snapByUri.get(uri); + if (snap !== undefined) return snap; + if (!reconstructed.has(uri)) { + reconstructed.set(uri, reconstructContent(noteAtUri, uri)); + } + return reconstructed.get(uri) ?? null; + }; + const entries: HistoryEntry[] = revisions.map((r) => { const p = profiles.get(r.did); - const after = snapByUri.get(r.at_uri); - const before = r.parent_revision_uri - ? snapByUri.get(r.parent_revision_uri) + let after = contentFor(r.at_uri); + let before = r.parent_revision_uri + ? contentFor(r.parent_revision_uri) : ""; + // Word diff needs both sides; if either is unavailable, drop to the + // stored-patch fallback (view keys on afterContent !== null). + if (after === null || before === null) { + after = null; + before = null; + } return { id: r.id, at_uri: r.at_uri, @@ -203,8 +223,8 @@ export const noteRoutes = new Elysia() diff: r.diff, diff_format: r.diff_format, created_at: r.created_at, - beforeContent: before ?? null, - afterContent: after ?? null, + beforeContent: before, + afterContent: after, }; }); diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index 510d074..e2ff5ae 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import * as TID from "@atcute/tid"; +import { applyDiff } from "../../../../src/lib/diff.ts"; import { getDb } from "../../../../src/server/db/index.ts"; import { getBlobsByCids } from "../../../../src/server/db/queries/blob.ts"; import { @@ -7,6 +8,7 @@ import { createNote, getBacklinks, getSnapshots, + reconstructContent, saveNoteEdit, } from "../../../../src/server/db/queries/index.ts"; import { @@ -29,15 +31,125 @@ beforeAll(() => { cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); cleanupNotes("test", "blob-test-*"); + cleanupNotes("test", "recon-test-*"); }); afterAll(() => { cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); cleanupNotes("test", "blob-test-*"); + cleanupNotes("test", "recon-test-*"); cleanupWikiAndDependents("other-wiki"); }); +// Build a note + edit chain, returning the note uri, the revision uris in order, +// and the content after each revision. +function buildChain( + slug: string, + contents: string[], +): { nUri: string; revUris: string[] } { + const nUri = noteUri(); + const revUris: string[] = []; + const first = revUri(); + revUris.push(first); + createNote( + nUri, + first, + WIKI_AT_URI, + WIKI_SLUG, + slug, + slug, + TEST_DID, + contents[0] ?? "", + ); + for (let i = 1; i < contents.length; i++) { + const r = revUri(); + revUris.push(r); + saveNoteEdit(r, WIKI_AT_URI, slug, contents[i] ?? "", TEST_DID); + } + return { nUri, revUris }; +} + +function currentContent(nUri: string): string { + const row = db + .query("SELECT content FROM current_note WHERE note_at_uri = ?") + .get(nUri) as { content: string } | null; + return row?.content ?? ""; +} + +describe("reconstructContent", () => { + const CHAIN = [ + "# Title\n\nfirst body", + "# Title\n\nfirst body, extended", + "# New Title\n\ncompletely rewritten body\n\nwith more lines", + "# New Title\n\ncompletely rewritten body", + ]; + + test("latest revision matches current_note.content", () => { + const { nUri, revUris } = buildChain("recon-test-latest", CHAIN); + const latest = revUris[revUris.length - 1] ?? ""; + expect(reconstructContent(nUri, latest)).toBe(currentContent(nUri)); + expect(reconstructContent(nUri, latest)).toBe( + CHAIN[CHAIN.length - 1] ?? "", + ); + }); + + test("each revision reconstructs to its post-apply content", () => { + const { nUri, revUris } = buildChain("recon-test-each", CHAIN); + revUris.forEach((uri, i) => { + expect(reconstructContent(nUri, uri)).toBe(CHAIN[i] ?? ""); + }); + }); + + test("walks from empty when no snapshot exists", () => { + const { nUri, revUris } = buildChain("recon-test-nosnap", CHAIN); + db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [nUri]); + expect(getSnapshots(nUri)).toEqual([]); + revUris.forEach((uri, i) => { + expect(reconstructContent(nUri, uri)).toBe(CHAIN[i] ?? ""); + }); + }); + + test("matches a manual fold of the diff chain", () => { + const { nUri, revUris } = buildChain("recon-test-control", CHAIN); + db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [nUri]); + const diffs = db + .query("SELECT diff FROM revisions WHERE note_at_uri = ? ORDER BY id ASC") + .all(nUri) as { diff: string }[]; + let folded = ""; + const expected = diffs.map((d) => { + folded = applyDiff(folded, d.diff); + return folded; + }); + revUris.forEach((uri, i) => { + expect(reconstructContent(nUri, uri)).toBe(expected[i] ?? ""); + }); + }); + + test("unknown revision uri returns null", () => { + const { nUri } = buildChain("recon-test-unknown", ["only one"]); + expect(reconstructContent(nUri, revUri())).toBeNull(); + }); + + test("unknown note uri returns null", () => { + expect(reconstructContent("at://fake/note", revUri())).toBeNull(); + }); + + test("corrupt diff returns null without throwing", () => { + const { nUri, revUris } = buildChain("recon-test-corrupt", CHAIN); + // Drop snapshots so reconstruction must replay the diff chain, then mangle + // a mid-chain diff so applyDiff throws. + db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [nUri]); + db.run("UPDATE revisions SET diff = ? WHERE at_uri = ?", [ + "this is not a valid patch", + revUris[1] ?? "", + ]); + const latest = revUris[revUris.length - 1] ?? ""; + expect(() => reconstructContent(nUri, latest)).not.toThrow(); + expect(reconstructContent(nUri, latest)).toBeNull(); + }); +}); + describe("backlinks", () => { test("createNote populates backlinks from wikilinks", () => { const nUri = noteUri();