diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index 6ab546a..3064d9a 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite"; import * as TID from "@atcute/tid"; import { getDevAccounts } from "../../atproto/env.ts"; import { createDiff } from "../../lib/diff.ts"; +import { LIMITS } from "../../lib/limits.ts"; interface SeedNote { slug: string; @@ -64,6 +65,79 @@ function seedWiki( } } +// A note whose revision count exceeds the snapshot cap, so the oldest entries in +// its history view fall back to on-demand diff reconstruction (the snapshots have +// been evicted, exactly as in production). Mirrors the appendRevision + capSnapshots +// flow with raw SQL to stay consistent with the rest of the seed. +function seedNoteWithHistory( + db: Database, + owner: { did: string }, + wikiSlug: string, + note: { slug: string; title: string; versions: string[] }, +): void { + const wikiAtUri = `at://${owner.did}/wiki.lichen.wiki/${wikiSlug}`; + const noteAtUri = `at://${owner.did}/wiki.lichen.note/${TID.now()}`; + db.run( + "INSERT INTO notes (slug, wiki_at_uri, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?, ?)", + [note.slug, wikiAtUri, wikiSlug, note.title, owner.did, noteAtUri], + ); + + let parent: string | null = null; + let prev = ""; + let latest = ""; + for (const [i, content] of note.versions.entries()) { + const revAtUri = `at://${owner.did}/wiki.lichen.noteRevision/${TID.now()}`; + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + noteAtUri, + owner.did, + revAtUri, + parent, + createDiff(prev, content), + i === 0 ? "Initial content" : `Edit ${i}`, + ], + ); + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [noteAtUri, revAtUri, content], + ); + parent = revAtUri; + prev = content; + latest = revAtUri; + } + + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now'))`, + [noteAtUri, prev, latest], + ); + // Keep only the most recent snapshots, as capSnapshots does on every ingest. + db.run( + `DELETE FROM snapshots WHERE note_at_uri = ? AND id NOT IN ( + SELECT id FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC LIMIT ? + )`, + [noteAtUri, noteAtUri, LIMITS.snapshotCap], + ); +} + +// Builds a multi-version changelog: each edit appends one line, so old revisions +// render as clean single-line insertions once reconstructed. +function changelogVersions(): string[] { + let body = `# Changelog + +A note with a long edit history, used to exercise diff reconstruction for +revisions older than the snapshot cap. +`; + const versions = [body]; + for (let i = 1; i <= 30; i++) { + body += `\n- Entry ${i}: documented change number ${i}.`; + versions.push(body); + } + return versions; +} + export function seedIfEmpty(db: Database): void { const count = db.query("SELECT COUNT(*) as n FROM wikis").get() as { n: number; @@ -116,6 +190,13 @@ A short note. Edit me to see how diffs work. ], ); + // >25 revisions: its oldest history entries render via reconstruction. + seedNoteWithHistory(db, alice, "alices-garden", { + slug: "changelog", + title: "Changelog", + versions: changelogVersions(), + }); + seedWiki( db, bob, diff --git a/tests/server/routes/history.test.ts b/tests/server/routes/history.test.ts new file mode 100644 index 0000000..537c2a5 --- /dev/null +++ b/tests/server/routes/history.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { LIMITS } from "../../../src/lib/limits.ts"; +import { + createNote, + getSnapshots, + listRevisions, + reconstructContent, + saveNoteEdit, + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp, TEST_DID } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "hist-test-wiki"; +const WIKI_AT_URI = `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`; +const NOTE_SLUG = "changelog"; +const NOTE_AT_URI = `at://${TEST_DID}/wiki.lichen.note/hist-note`; +const HISTORY_URL = `http://localhost/@${TEST_DID}/${SLUG}/${NOTE_SLUG}/-/history`; + +// One more revision than the snapshot cap, so the oldest entries lose their +// snapshot and must be reconstructed. +const N = LIMITS.snapshotCap + 6; + +const versions: string[] = []; +{ + let body = "# Changelog\n\nbaseline paragraph that stays put.\n"; + versions.push(body); + for (let i = 1; i < N; i++) { + body += `\n- entry ${i}: change number ${i}`; + versions.push(body); + } +} + +beforeAll(() => { + cleanupWikiAndDependents(SLUG); + upsertWiki( + SLUG, + TEST_DID, + "History Test Wiki", + "public", + WIKI_AT_URI, + new Date().toISOString(), + ); + upsertMembership( + WIKI_AT_URI, + SLUG, + TEST_DID, + "admin", + `at://${TEST_DID}/wiki.lichen.membership/h1`, + new Date().toISOString(), + ); + createNote( + NOTE_AT_URI, + `at://${TEST_DID}/wiki.lichen.noteRevision/h-rev-0`, + WIKI_AT_URI, + SLUG, + NOTE_SLUG, + "Changelog", + TEST_DID, + versions[0] ?? "", + ); + for (let i = 1; i < N; i++) { + saveNoteEdit( + `at://${TEST_DID}/wiki.lichen.noteRevision/h-rev-${i}`, + WIKI_AT_URI, + NOTE_SLUG, + versions[i] ?? "", + TEST_DID, + `edit ${i}`, + ); + } +}); + +afterAll(() => { + cleanupWikiAndDependents(SLUG); +}); + +describe("history reconstruction beyond the snapshot cap", () => { + test("revisions exceed the capped snapshot count", () => { + expect(listRevisions(NOTE_AT_URI).length).toBe(N); + expect(getSnapshots(NOTE_AT_URI).length).toBe(LIMITS.snapshotCap); + }); + + test("reconstructContent recovers old revisions whose snapshot was evicted", () => { + // Revisions in chain (id) order map 1:1 onto `versions`. + const ascending = [...listRevisions(NOTE_AT_URI)].reverse(); + const snapshotUris = new Set( + getSnapshots(NOTE_AT_URI).map((s) => s.revision_at_uri), + ); + + // The oldest entries have no snapshot — the reconstruction path. + expect(snapshotUris.has(ascending[0]?.at_uri ?? "")).toBe(false); + for (const idx of [0, 3, 5]) { + const rev = ascending[idx]; + expect(rev).toBeDefined(); + expect(snapshotUris.has(rev?.at_uri ?? "")).toBe(false); + expect(reconstructContent(NOTE_AT_URI, rev?.at_uri ?? "")).toBe( + versions[idx] ?? "", + ); + } + }); + + test("history route renders word-level diffs for the reconstructed page", async () => { + const page1 = await app.handle(new Request(HISTORY_URL)); + expect(page1.status).toBe(200); + const html1 = await page1.text(); + + // Follow the real "View more" cursor to the older (reconstructed) page. + const before = html1.match(/before=(\d+)/)?.[1]; + expect(before).toBeDefined(); + + const page2 = await app.handle( + new Request(`${HISTORY_URL}?before=${before}`), + ); + expect(page2.status).toBe(200); + const html2 = await page2.text(); + + // Reconstructed entries render as real diffs (insertions), not the raw + // patch-text
fallback.
+ expect(html2).toContain("