diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 6593a01..d6ca974 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,17 +1,14 @@ import type { CommitEvt } from "@atproto/sync"; import { didOwnsUri, parseAtUri } from "../lib/at-uri.ts"; import { COLLECTIONS } from "../lib/constants.ts"; -import { extractWikilinks } from "../lib/markdown.ts"; import { + applyRevisionFromFirehose, deleteMembershipByUri, deleteNoteByAtUri, deleteRequestByUri, deleteWikiByAtUri, getNoteByAtUri, getWikiByAtUri, - insertBlob, - insertRevisionFromFirehose, - updateBacklinks, upsertMembership, upsertNote, upsertRequest, @@ -141,25 +138,17 @@ function handleRevision(evt: CommitEvt, record: RevisionRecord): void { const note = getNoteByAtUri(record.noteRef); if (!note) return; - const newContent = insertRevisionFromFirehose( + applyRevisionFromFirehose( note.at_uri, + note.wiki_slug, evt.did, atUri, record.parentRevision ?? null, record.diff, record.diffFormat, record.message ?? null, + record.blobs ?? [], ); - - const wikiSlugs = extractWikilinks(newContent); - updateBacklinks(note.at_uri, note.wiki_slug, wikiSlugs); - - if (record.blobs?.length) { - const revisionAtUri = evt.uri.toString(); - for (const blob of record.blobs) { - insertBlob(blob.cid, revisionAtUri, blob.mimeType, blob.cid); - } - } } function handleMembership(evt: CommitEvt, record: MembershipRecord): void { diff --git a/src/lib/access.ts b/src/lib/access.ts index 73a1eb7..cc8906d 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -3,7 +3,11 @@ import { getSessionFromRequest, type Session, } from "../atproto/session.ts"; -import { getMemberRole, getWiki } from "../server/db/queries/index.ts"; +import { + getMemberRole, + getRequest, + getWiki, +} from "../server/db/queries/index.ts"; import type { WikiRow } from "../server/db/types.ts"; import { ForbiddenError, NotFoundError } from "./errors.ts"; import { type Locale, resolveLocale } from "./i18n/index.ts"; @@ -60,6 +64,8 @@ export interface RequestContext { effectiveDid: string; access: AccessLevel; locale: Locale; + /** true only when access is "none" and the user has a pending access request */ + hasPendingRequest: boolean; } /** RequestContext with wiki guaranteed non-null. */ @@ -104,15 +110,33 @@ export async function resolveRequestContext( ); if (!wikiSlug) { - return { session, wiki: null, effectiveDid, access: "none", locale }; + return { + session, + wiki: null, + effectiveDid, + access: "none", + locale, + hasPendingRequest: false, + }; } const wiki = getWiki(wikiSlug); if (!wiki) { - return { session, wiki: null, effectiveDid, access: "none", locale }; + return { + session, + wiki: null, + effectiveDid, + access: "none", + locale, + hasPendingRequest: false, + }; } const role = getMemberRole(wiki.slug, effectiveDid); const access = getAccessLevel(wiki, effectiveDid, role); - return { session, wiki, effectiveDid, access, locale }; + const hasPendingRequest = + access === "none" && effectiveDid + ? getRequest(wiki.slug, effectiveDid) !== null + : false; + return { session, wiki, effectiveDid, access, locale, hasPendingRequest }; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 827e6fe..6f81b86 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,13 @@ export const COLLECTIONS = { bookmark: "pub.coral.bookmark", } as const; +export type MemberRole = "admin" | "contributor" | "viewer"; + +export function normalizeRole(raw: string | null | undefined): MemberRole { + if (raw === "admin" || raw === "viewer") return raw; + return "contributor"; +} + export const VIZ_SCRIPTS = [ "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", "/public/viz/dist.js", diff --git a/src/lib/orchestrators/helpers.ts b/src/lib/orchestrators/helpers.ts new file mode 100644 index 0000000..140f9ca --- /dev/null +++ b/src/lib/orchestrators/helpers.ts @@ -0,0 +1,17 @@ +import { formatError, PdsWriteError } from "../errors.ts"; + +/** + * Run a PDS write operation, wrapping any error in a PdsWriteError. + * Use for all PDS writes in orchestrators to avoid copy-pasted try/catch blocks. + */ +export async function withPdsError( + label: string, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof PdsWriteError) throw err; + throw new PdsWriteError(`${label}: ${formatError(err)}`); + } +} diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index a70921c..2bf07cc 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -13,6 +13,7 @@ import { } from "../../server/db/queries/index.ts"; import type { WikiRequestContext } from "../access.ts"; import { parseAtUri } from "../at-uri.ts"; +import type { MemberRole } from "../constants.ts"; import { COLLECTIONS } from "../constants.ts"; import { ForbiddenError, @@ -21,7 +22,8 @@ import { PdsWriteError, ValidationError, } from "../errors.ts"; -import { generateTid } from "../tid.ts"; +import { currentTimestamp, generateTid } from "../tid.ts"; +import { withPdsError } from "./helpers.ts"; /** * Request access to a wiki. PDS write → DB write. @@ -34,11 +36,10 @@ export async function requestAccessAction( throw new ForbiddenError("Login required"); } - const now = new Date().toISOString(); + const now = currentTimestamp(); const tid = generateTid(); const atUri = `at://${ctx.session.did}/pub.coral.memberRequest/${tid}`; - // PDS write try { const agent = await getAgent(ctx.session); await writeMemberRequestRecord( @@ -54,7 +55,6 @@ export async function requestAccessAction( ); } - // DB write upsertRequest(ctx.wiki.slug, ctx.session.did, atUri, now); } @@ -65,33 +65,28 @@ export async function requestAccessAction( export async function approveMemberAction( ctx: WikiRequestContext, memberDid: string, - role: "admin" | "contributor" | "viewer" = "contributor", + role: MemberRole = "contributor", ): Promise { - const now = new Date().toISOString(); + const now = currentTimestamp(); const membershipTid = generateTid(); const membershipAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${membershipTid}`; - // PDS write first (skip in dev mode) if (ctx.session) { - try { - const agent = await getAgent(ctx.session); + const session = ctx.session; + await withPdsError("approve membership", async () => { + const agent = await getAgent(session); await writeMembershipRecord( agent, - ctx.session.did, + session.did, membershipTid, memberDid, ctx.wiki.at_uri, role, now, ); - } catch (err) { - throw new PdsWriteError( - `Failed to write membership to PDS: ${formatError(err)}`, - ); - } + }); } - // DB write deleteRequest(ctx.wiki.slug, memberDid); upsertMembership(ctx.wiki.slug, memberDid, role, membershipAtUri, now); } @@ -105,7 +100,7 @@ export async function approveMemberAction( export async function changeMemberRoleAction( ctx: WikiRequestContext, memberDid: string, - newRole: "admin" | "contributor" | "viewer", + newRole: MemberRole, ): Promise { if (memberDid === ctx.wiki.did) { throw new ValidationError("Cannot change the wiki owner's role"); @@ -120,11 +115,12 @@ export async function changeMemberRoleAction( const newAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${newTid}`; if (ctx.session) { + const session = ctx.session; // Best-effort: delete old PDS record if we own it const parsed = parseAtUri(existing.at_uri); - if (parsed && parsed.did === ctx.session.did) { + if (parsed && parsed.did === session.did) { try { - const agent = await getAgent(ctx.session); + const agent = await getAgent(session); await deleteRecord( agent, parsed.did, @@ -136,23 +132,18 @@ export async function changeMemberRoleAction( } } - // Write new PDS record with updated role - try { - const agent = await getAgent(ctx.session); + await withPdsError("change member role", async () => { + const agent = await getAgent(session); await writeMembershipRecord( agent, - ctx.session.did, + session.did, newTid, memberDid, ctx.wiki.at_uri, newRole, existing.created_at, ); - } catch (err) { - throw new PdsWriteError( - `Failed to write membership to PDS: ${formatError(err)}`, - ); - } + }); } upsertMembership( @@ -172,29 +163,26 @@ export async function changeMemberRoleAction( export async function addMemberAction( ctx: WikiRequestContext, memberDid: string, - role: "admin" | "contributor" | "viewer" = "contributor", + role: MemberRole = "contributor", ): Promise { - const now = new Date().toISOString(); + const now = currentTimestamp(); const tid = generateTid(); const atUri = `at://${ctx.effectiveDid}/pub.coral.membership/${tid}`; if (ctx.session) { - try { - const agent = await getAgent(ctx.session); + const session = ctx.session; + await withPdsError("add member", async () => { + const agent = await getAgent(session); await writeMembershipRecord( agent, - ctx.session.did, + session.did, tid, memberDid, ctx.wiki.at_uri, role, now, ); - } catch (err) { - throw new PdsWriteError( - `Failed to write membership to PDS: ${formatError(err)}`, - ); - } + }); } upsertMembership(ctx.wiki.slug, memberDid, role, atUri, now); @@ -214,29 +202,24 @@ export async function removeMemberAction( throw new ValidationError("Cannot remove wiki owner"); } - // DB delete (returns at_uri for PDS cleanup) const atUri = deleteMembership(ctx.wiki.slug, memberDid); if (!atUri) { throw new NotFoundError("Membership not found"); } - // PDS cleanup (skip in dev mode) if (ctx.session) { + const session = ctx.session; const parsed = parseAtUri(atUri); if (parsed) { - try { - const agent = await getAgent(ctx.session); + await withPdsError("remove member", async () => { + const agent = await getAgent(session); await deleteRecord( agent, parsed.did, COLLECTIONS.membership, parsed.rkey, ); - } catch (err) { - throw new PdsWriteError( - `Failed to delete membership from PDS: ${formatError(err)}`, - ); - } + }); } } } diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 2793224..5252ae5 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -14,15 +14,11 @@ import { persistBlobs, } from "../blob.ts"; import { createDiff } from "../diff.ts"; -import { - formatError, - NotFoundError, - PdsWriteError, - ValidationError, -} from "../errors.ts"; +import { NotFoundError, ValidationError } from "../errors.ts"; import type { Messages } from "../i18n/index.ts"; import { validateNewNote } from "../note-validation.ts"; -import { generateTid } from "../tid.ts"; +import { currentTimestamp, generateTid } from "../tid.ts"; +import { withPdsError } from "./helpers.ts"; export interface NoteFormFields { title: string; @@ -43,6 +39,35 @@ export function parseNoteFormFields(formData: { return { title, content, message: message || undefined, blobMeta }; } +/** + * Write a revision record to the PDS. Shared between create and edit. + */ +async function writePdsRevision( + agent: Awaited>, + did: string, + revisionTid: string, + noteAtUri: string, + parentRevisionUri: string | null, + oldContent: string, + newContent: string, + message: string | undefined, + blobs: ReturnType, +): Promise { + const diff = createDiff(oldContent, newContent); + const now = currentTimestamp(); + await writeRevisionRecord( + agent, + did, + revisionTid, + noteAtUri, + parentRevisionUri, + diff, + message, + now, + blobs.length > 0 ? blobs : undefined, + ); +} + /** * Full lifecycle for creating a note: validate → PDS write → DB write. * Throws ValidationError, PdsWriteError on failure. @@ -61,16 +86,16 @@ export async function createNoteAction( const { noteSlug } = validation; const blobs = buildBlobsForContent(fields.content, fields.blobMeta); - // PDS write (skip in dev mode without session) + // Generate TIDs once — shared between PDS and DB writes + const noteTid = generateTid(); + const revisionTid = generateTid(); + const noteAtUri = `at://${ctx.effectiveDid}/pub.coral.note/${noteTid}`; + const revisionAtUri = `at://${ctx.effectiveDid}/pub.coral.noteRevision/${revisionTid}`; + if (ctx.session) { const agent = await getAgent(ctx.session); - const now = new Date().toISOString(); - const noteTid = generateTid(); - const revisionTid = generateTid(); - const noteAtUri = `at://${ctx.effectiveDid}/pub.coral.note/${noteTid}`; - const diff = createDiff("", fields.content); - - try { + const now = currentTimestamp(); + await withPdsError("create note", async () => { await writeNoteRecord( agent, ctx.effectiveDid, @@ -80,24 +105,23 @@ export async function createNoteAction( ctx.wiki.at_uri, now, ); - await writeRevisionRecord( + await writePdsRevision( agent, ctx.effectiveDid, revisionTid, noteAtUri, null, - diff, + "", + fields.content, fields.message, - now, - blobs.length > 0 ? blobs : undefined, + blobs, ); - } catch (err) { - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); - } + }); } - // DB write - const result = createNote( + createNote( + noteAtUri, + revisionAtUri, ctx.wiki.slug, noteSlug, fields.title, @@ -105,7 +129,7 @@ export async function createNoteAction( fields.content, fields.message, ); - persistBlobs(blobs, result.revisionAtUri); + persistBlobs(blobs, revisionAtUri); return { noteSlug }; } @@ -133,25 +157,24 @@ export async function editNoteAction( const blobs = buildBlobsForContent(fields.content, fields.blobMeta); const currentNote = getCurrentNote(ctx.wiki.slug, noteSlug); - // PDS write (skip in dev mode without session) + // Generate revision TID once — shared between PDS and DB writes + const revisionTid = generateTid(); + const revisionAtUri = `at://${ctx.effectiveDid}/pub.coral.noteRevision/${revisionTid}`; + const currentContent = currentNote?.content ?? ""; + if (ctx.session) { const agent = await getAgent(ctx.session); - const now = new Date().toISOString(); - const currentContent = currentNote?.content ?? ""; - const diff = createDiff(currentContent, fields.content); - const revisionTid = generateTid(); - - try { - await writeRevisionRecord( + await withPdsError("edit note", async () => { + await writePdsRevision( agent, ctx.effectiveDid, revisionTid, note.at_uri, currentNote?.latest_revision_uri ?? null, - diff, + currentContent, + fields.content, fields.message, - now, - blobs.length > 0 ? blobs : undefined, + blobs, ); if (newTitle) { @@ -169,14 +192,11 @@ export async function editNoteAction( note.created_at, ); } - } catch (err) { - if (err instanceof PdsWriteError) throw err; - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); - } + }); } - // DB write - const result = saveNoteEdit( + saveNoteEdit( + revisionAtUri, ctx.wiki.slug, noteSlug, fields.content, @@ -184,5 +204,5 @@ export async function editNoteAction( fields.message, newTitle, ); - persistBlobs(blobs, result.revisionAtUri); + persistBlobs(blobs, revisionAtUri); } diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 655c39d..297a24c 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -18,16 +18,12 @@ import type { RequestContext, WikiRequestContext } from "../access.ts"; import { parseAtUri } from "../at-uri.ts"; import { COLLECTIONS } from "../constants.ts"; import { createDiff } from "../diff.ts"; -import { - ForbiddenError, - formatError, - PdsWriteError, - ValidationError, -} from "../errors.ts"; +import { ForbiddenError, ValidationError } from "../errors.ts"; import type { Messages } from "../i18n/index.ts"; import { fmt } from "../i18n/index.ts"; import { isValidSlug, slugify } from "../slug.ts"; -import { generateTid } from "../tid.ts"; +import { currentTimestamp, generateTid } from "../tid.ts"; +import { withPdsError } from "./helpers.ts"; export interface WikiFormFields { name: string; @@ -68,15 +64,14 @@ export async function createWikiAction( const validVisibility = fields.visibility === "private" ? "private" : ("public" as const); - const now = new Date().toISOString(); + const now = currentTimestamp(); const did = ctx.effectiveDid; - // PDS write (skip in dev mode without session) let atUri = `at://${did}/pub.coral.wiki/${slug}`; const agent = ctx.session ? await getAgent(ctx.session) : null; if (agent) { - try { + await withPdsError("create wiki", async () => { const result = await writeWikiRecord( agent, did, @@ -87,12 +82,9 @@ export async function createWikiAction( fields.language, ); atUri = result.uri; - } catch (err) { - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); - } + }); } - // DB write upsertWiki( slug, did, @@ -109,7 +101,7 @@ export async function createWikiAction( upsertMembership(slug, did, "admin", membershipAtUri, now); if (agent) { - try { + await withPdsError("create wiki membership", async () => { await writeMembershipRecord( agent, did, @@ -119,22 +111,19 @@ export async function createWikiAction( "admin", now, ); - } catch (err) { - throw new PdsWriteError( - `Failed to write membership to PDS: ${formatError(err)}`, - ); - } + }); } - // Create home note: PDS write → DB write (same lifecycle as wiki creation) + // Create home note — TIDs shared between PDS and DB writes const homeContent = `# Welcome to ${fields.name}\n\nThis is the home page of your wiki. Edit it to get started.`; + const noteTid = generateTid(); + const revisionTid = generateTid(); + const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; + const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; if (agent) { - try { - const noteTid = generateTid(); - const revisionTid = generateTid(); - const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; - const diff = createDiff("", homeContent); + const diff = createDiff("", homeContent); + await withPdsError("create home note", async () => { await writeNoteRecord(agent, did, noteTid, "home", "Home", atUri, now); await writeRevisionRecord( agent, @@ -146,14 +135,10 @@ export async function createWikiAction( undefined, now, ); - } catch (err) { - throw new PdsWriteError( - `Failed to create home note on PDS: ${formatError(err)}`, - ); - } + }); } - createNote(slug, "home", "Home", did, homeContent); + createNote(noteAtUri, revisionAtUri, slug, "home", "Home", did, homeContent); return { wikiSlug: slug }; } @@ -168,11 +153,9 @@ export async function deleteWikiAction(ctx: WikiRequestContext): Promise { throw new ForbiddenError("Only the wiki owner can delete this wiki"); } - // PDS cleanup (skip in dev mode) if (ctx.session) { const agent = await getAgent(ctx.session); - // Delete wiki record const wikiParsed = parseAtUri(ctx.wiki.at_uri); if (wikiParsed) { try { @@ -187,7 +170,6 @@ export async function deleteWikiAction(ctx: WikiRequestContext): Promise { } } - // Delete membership records owned by this user const members = listMembers(ctx.wiki.slug); for (const m of members) { const parsed = parseAtUri(m.at_uri); @@ -206,6 +188,5 @@ export async function deleteWikiAction(ctx: WikiRequestContext): Promise { } } - // DB cascade delete deleteWikiByAtUri(ctx.wiki.at_uri); } diff --git a/src/lib/tid.ts b/src/lib/tid.ts index c743e28..c7ebf18 100644 --- a/src/lib/tid.ts +++ b/src/lib/tid.ts @@ -1,5 +1,9 @@ const BASE32_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; +export function currentTimestamp(): string { + return new Date().toISOString(); +} + let lastTimestamp = 0; export function generateTid(): string { diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index ba60a36..7870228 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -24,12 +24,14 @@ export { upsertMembership, upsertRequest, } from "./membership.ts"; +export type { NoteWithCurrent } from "./note.ts"; export { createNote, deleteNoteByAtUri, getCurrentNote, getNoteByAtUri, getNoteBySlug, + getNoteWithCurrent, getSidebarNotes, listNotes, saveNoteEdit, @@ -37,10 +39,10 @@ export { upsertNote, } from "./note.ts"; export { + applyRevisionFromFirehose, capSnapshots, getBacklinks, getSnapshots, - insertRevisionFromFirehose, updateBacklinks, } from "./revision.ts"; export { diff --git a/src/server/db/queries/note.ts b/src/server/db/queries/note.ts index bb72268..ae88cd5 100644 --- a/src/server/db/queries/note.ts +++ b/src/server/db/queries/note.ts @@ -1,9 +1,13 @@ import { createDiff } from "../../../lib/diff.ts"; import { extractWikilinks } from "../../../lib/markdown.ts"; -import { generateTid } from "../../../lib/tid.ts"; import { getDb } from "../index.ts"; import type { CurrentNoteRow, NoteRow } from "../types.ts"; -import { capSnapshots, updateBacklinks } from "./revision.ts"; +import { capSnapshots } from "./revision.ts"; + +export interface NoteWithCurrent { + note: NoteRow; + current: CurrentNoteRow; +} export function getSidebarNotes( wikiSlug: string, @@ -75,6 +79,18 @@ export function getNoteByAtUri(atUri: string): NoteRow | null { ); } +/** Fetch note + current content in one call. Returns null if either is missing. */ +export function getNoteWithCurrent( + wikiSlug: string, + noteSlug: string, +): NoteWithCurrent | null { + const note = getNoteBySlug(wikiSlug, noteSlug); + if (!note) return null; + const current = getCurrentNote(wikiSlug, noteSlug); + if (!current) return null; + return { note, current }; +} + export function upsertNote( wikiSlug: string, slug: string, @@ -104,59 +120,102 @@ export function deleteNoteByAtUri(atUri: string): void { })(); } +/** + * Append a revision to an existing note (revisions + current_note + backlinks + snapshot). + * Must be called inside an outer db.transaction(). + */ +function appendRevisionTx( + db: ReturnType, + noteAtUri: string, + wikiSlug: string, + did: string, + revisionAtUri: string, + parentRevisionUri: string | null, + oldContent: string, + newContent: string, + message: string | undefined, +): void { + const diff = createDiff(oldContent, newContent); + const slugs = extractWikilinks(newContent); + + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) + VALUES (?, ?, ?, ?, ?, ?)`, + [noteAtUri, did, revisionAtUri, parentRevisionUri, diff, message ?? null], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(note_at_uri) DO UPDATE SET + content = excluded.content, + latest_revision_uri = excluded.latest_revision_uri, + updated_at = excluded.updated_at`, + [noteAtUri, newContent, revisionAtUri], + ); + // Inline backlinks update to stay within the outer transaction + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [noteAtUri, slug, wikiSlug], + ); + } + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [noteAtUri, revisionAtUri, newContent], + ); +} + +/** + * Create a new note with its first revision. + * The caller is responsible for generating noteAtUri and revisionAtUri (e.g. from PDS write TIDs). + */ export function createNote( + noteAtUri: string, + revisionAtUri: string, wikiSlug: string, noteSlug: string, title: string, did: string, initialContent: string, message?: string, -): { noteAtUri: string; revisionAtUri: string } { +): void { const db = getDb(); - const noteTid = generateTid(); - const revisionTid = generateTid(); - const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; - const diff = createDiff("", initialContent); - const slugs = extractWikilinks(initialContent); db.transaction(() => { db.run( "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", [noteSlug, wikiSlug, title, did, noteAtUri], ); - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) - VALUES (?, ?, ?, NULL, ?, ?)`, - [noteAtUri, did, revisionAtUri, diff, message ?? null], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now'))`, - [noteAtUri, initialContent, revisionAtUri], - ); - - updateBacklinks(noteAtUri, wikiSlug, slugs); - - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [noteAtUri, revisionAtUri, initialContent], + appendRevisionTx( + db, + noteAtUri, + wikiSlug, + did, + revisionAtUri, + null, + "", + initialContent, + message, ); })(); capSnapshots(noteAtUri); - - return { noteAtUri, revisionAtUri }; } +/** + * Save an edit to an existing note. + * The caller is responsible for generating revisionAtUri (e.g. from PDS write TIDs). + */ export function saveNoteEdit( + revisionAtUri: string, wikiSlug: string, noteSlug: string, newContent: string, did: string, message?: string, newTitle?: string, -): { revisionAtUri: string } { +): void { const db = getDb(); const note = db @@ -175,10 +234,6 @@ export function saveNoteEdit( const oldContent = current?.content ?? ""; const parentRevisionUri = current?.latest_revision_uri ?? null; - const diff = createDiff(oldContent, newContent); - const revisionTid = generateTid(); - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; - const slugs = extractWikilinks(newContent); db.transaction(() => { if (newTitle) { @@ -188,37 +243,18 @@ export function saveNoteEdit( noteSlug, ]); } - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - note.at_uri, - did, - revisionAtUri, - parentRevisionUri, - diff, - message ?? null, - ], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(note_at_uri) DO UPDATE SET - content = excluded.content, - latest_revision_uri = excluded.latest_revision_uri, - updated_at = excluded.updated_at`, - [note.at_uri, newContent, revisionAtUri], - ); - - updateBacklinks(note.at_uri, wikiSlug, slugs); - - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [note.at_uri, revisionAtUri, newContent], + appendRevisionTx( + db, + note.at_uri, + wikiSlug, + did, + revisionAtUri, + parentRevisionUri, + oldContent, + newContent, + message, ); })(); capSnapshots(note.at_uri); - - return { revisionAtUri }; } diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index bc36041..25b70d1 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -1,4 +1,5 @@ import { applyDiff } from "../../../lib/diff.ts"; +import { extractWikilinks } from "../../../lib/markdown.ts"; import { getDb } from "../index.ts"; import type { BacklinkRow, SnapshotRow } from "../types.ts"; @@ -48,15 +49,22 @@ export function capSnapshots(noteAtUri: string, max = 25): void { ); } -export function insertRevisionFromFirehose( +/** + * Apply a revision from the firehose in a single transaction: + * applies the diff, updates current_note, appends a snapshot, and syncs backlinks. + * Also persists any blob metadata attached to the revision. + */ +export function applyRevisionFromFirehose( noteAtUri: string, + wikiSlug: string, did: string, revisionAtUri: string, parentRevisionUri: string | null, diff: string, diffFormat: string, message: string | null, -): string { + blobs: { cid: string; mimeType: string }[], +): void { const db = getDb(); const current = db @@ -65,6 +73,7 @@ export function insertRevisionFromFirehose( const oldContent = current?.content ?? ""; const newContent = applyDiff(oldContent, diff); + const slugs = extractWikilinks(newContent); db.transaction(() => { db.run( @@ -93,9 +102,21 @@ export function insertRevisionFromFirehose( "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", [noteAtUri, revisionAtUri, newContent], ); + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [noteAtUri, slug, wikiSlug], + ); + } + for (const blob of blobs) { + db.run( + `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) + VALUES (?, ?, ?, ?)`, + [blob.cid, revisionAtUri, blob.mimeType, blob.cid], + ); + } })(); capSnapshots(noteAtUri); - - return newContent; } diff --git a/src/server/routes/membership.ts b/src/server/routes/membership.ts index 1893e6f..23f78e3 100644 --- a/src/server/routes/membership.ts +++ b/src/server/routes/membership.ts @@ -4,6 +4,7 @@ import { resolveWikiContext, type WikiRequestContext, } from "../../lib/access.ts"; +import { normalizeRole } from "../../lib/constants.ts"; import { NotFoundError, ValidationError } from "../../lib/errors.ts"; import { addMemberAction, @@ -56,9 +57,7 @@ export const membershipRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const memberDid = decodeURIComponent(params.memberDid); const formData = await request.formData(); - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; - const role = - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; + const role = normalizeRole(formData.get("role") as string | null); await approveMemberAction(ctx, memberDid, role); @@ -82,9 +81,7 @@ export const membershipRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const memberDid = decodeURIComponent(params.memberDid); const formData = await request.formData(); - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; - const role = - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; + const role = normalizeRole(formData.get("role") as string | null); await changeMemberRoleAction(ctx, memberDid, role); @@ -95,9 +92,7 @@ export const membershipRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const formData = await request.formData(); const handleOrDid = ((formData.get("did") as string | null) ?? "").trim(); - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; - const role = - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; + const role = normalizeRole(formData.get("role") as string | null); if (!handleOrDid) { throw new ValidationError("Handle or DID is required"); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index f290b14..0787344 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -14,11 +14,7 @@ import { noteUrl, redirect } from "../../lib/urls.ts"; import { editNotePage } from "../../views/edit-note.ts"; import { newNotePage } from "../../views/new-note.ts"; import { notePage } from "../../views/note.ts"; -import { - getCurrentNote, - getNoteBySlug, - getSidebarNotes, -} from "../db/queries/index.ts"; +import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; export const noteRoutes = new Elysia({ prefix: "/wiki" }) .get("/:wikiSlug/new", async ({ params, request }) => { @@ -66,13 +62,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) .get("/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); - const note = getNoteBySlug(params.wikiSlug, params.noteSlug); - if (!note) { - throw new NotFoundError("Note not found"); - } - - const current = getCurrentNote(params.wikiSlug, params.noteSlug); - if (!current) { + const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); + if (!data) { throw new NotFoundError("Note not found"); } @@ -81,8 +72,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ctx.wiki.name, params.wikiSlug, params.noteSlug, - note.title, - current.content, + data.note.title, + data.current.content, { session: ctx.session, scripts: EDITOR_SCRIPTS, @@ -105,23 +96,21 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) .get("/:wikiSlug/:noteSlug", async ({ params, request }) => { const ctx = await resolveWikiContext(request, params.wikiSlug, "read"); - const note = getNoteBySlug(params.wikiSlug, params.noteSlug); - if (!note) { + const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); + if (!data) { throw new NotFoundError("Note not found"); } - const current = getCurrentNote(params.wikiSlug, params.noteSlug); - if (!current) { - throw new NotFoundError("Note not found"); - } - - const { html, hasViz } = renderMarkdown(current.content, params.wikiSlug); + const { html, hasViz } = renderMarkdown( + data.current.content, + params.wikiSlug, + ); const sidebarNotes = getSidebarNotes(params.wikiSlug); return htmlResponse( notePage( ctx.wiki.name, params.wikiSlug, - note.title, + data.note.title, html, { scripts: hasViz ? VIZ_SCRIPTS : undefined, diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index d0ce94b..e129ed5 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -19,12 +19,7 @@ import { newWikiPage } from "../../views/new-wiki.ts"; import { notePage } from "../../views/note.ts"; import { settingsPage } from "../../views/settings.ts"; import { wikiPage } from "../../views/wiki.ts"; -import { - getCurrentNote, - getNoteBySlug, - getRequest, - getSidebarNotes, -} from "../db/queries/index.ts"; +import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; export const wikiRoutes = new Elysia({ prefix: "/wiki" }) .get("/new", async ({ request }) => { @@ -102,47 +97,41 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) } if (!canRead(ctx.access)) { - const hasPendingRequest = ctx.session?.did - ? getRequest(ctx.wiki.slug, ctx.session.did) !== null - : false; return htmlResponse( accessDeniedPage(ctx.wiki.name, params.wikiSlug, { session: ctx.session, locale: ctx.locale, - hasPendingRequest, + hasPendingRequest: ctx.hasPendingRequest, }), 403, ); } const sidebarNotes = getSidebarNotes(params.wikiSlug); + const homeData = getNoteWithCurrent(params.wikiSlug, "home"); - const homeNote = getNoteBySlug(params.wikiSlug, "home"); - if (homeNote) { - const current = getCurrentNote(params.wikiSlug, "home"); - if (current) { - const { html, hasViz } = renderMarkdown( - current.content, + if (homeData) { + const { html, hasViz } = renderMarkdown( + homeData.current.content, + params.wikiSlug, + ); + return htmlResponse( + notePage( + ctx.wiki.name, params.wikiSlug, - ); - return htmlResponse( - notePage( - ctx.wiki.name, - params.wikiSlug, - homeNote.title, - html, - { - scripts: hasViz ? VIZ_SCRIPTS : undefined, - session: ctx.session, - sidebarNotes, - currentNoteSlug: "home", - locale: ctx.locale, - accessLevel: ctx.access, - }, - ctx.wiki.language, - ), - ); - } + homeData.note.title, + html, + { + scripts: hasViz ? VIZ_SCRIPTS : undefined, + session: ctx.session, + sidebarNotes, + currentNoteSlug: "home", + locale: ctx.locale, + accessLevel: ctx.access, + }, + ctx.wiki.language, + ), + ); } return htmlResponse( diff --git a/tests/lib/orchestrators/membership.test.ts b/tests/lib/orchestrators/membership.test.ts index 301d8fc..b89f13c 100644 --- a/tests/lib/orchestrators/membership.test.ts +++ b/tests/lib/orchestrators/membership.test.ts @@ -66,6 +66,7 @@ function makeCtx( effectiveDid: ADMIN_DID, access: "admin", locale: "en", + hasPendingRequest: false, ...overrides, }; } diff --git a/tests/lib/orchestrators/note.test.ts b/tests/lib/orchestrators/note.test.ts index 27ce936..d989464 100644 --- a/tests/lib/orchestrators/note.test.ts +++ b/tests/lib/orchestrators/note.test.ts @@ -59,6 +59,7 @@ function makeCtx( effectiveDid: WIKI_DID, access: "admin", locale: "en", + hasPendingRequest: false, ...overrides, }; } diff --git a/tests/lib/orchestrators/wiki.test.ts b/tests/lib/orchestrators/wiki.test.ts index f166a19..061bed4 100644 --- a/tests/lib/orchestrators/wiki.test.ts +++ b/tests/lib/orchestrators/wiki.test.ts @@ -69,6 +69,7 @@ function makeCtx(overrides: Partial = {}): RequestContext { effectiveDid: TEST_DID, access: "none", locale: "en", + hasPendingRequest: false, ...overrides, }; } @@ -83,6 +84,7 @@ function makeWikiCtx( effectiveDid: TEST_DID, access: "admin", locale: "en", + hasPendingRequest: false, ...overrides, }; } diff --git a/tests/server/db/queries/note.test.ts b/tests/server/db/queries/note.test.ts index df5b95e..8a588b6 100644 --- a/tests/server/db/queries/note.test.ts +++ b/tests/server/db/queries/note.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { applyDiff } from "../../../../src/lib/diff.ts"; +import { generateTid } from "../../../../src/lib/tid.ts"; import { getDb } from "../../../../src/server/db/index.ts"; import { createNote, @@ -17,6 +18,9 @@ import { cleanupNotes } from "./helpers.ts"; const db = getDb(); const TEST_DID = "did:plc:mock123"; +const noteUri = () => `at://${TEST_DID}/pub.coral.note/${generateTid()}`; +const revUri = () => `at://${TEST_DID}/pub.coral.noteRevision/${generateTid()}`; + beforeAll(() => { cleanupNotes("test", "write-test-*"); cleanupNotes("test", "edit-test-*"); @@ -72,26 +76,21 @@ describe("getCurrentNote", () => { }); test("returns null for nonexistent note", () => { - const current = getCurrentNote("test", "nonexistent"); - expect(current).toBeNull(); + expect(getCurrentNote("test", "nonexistent")).toBeNull(); }); test("returns null for nonexistent wiki", () => { - const current = getCurrentNote("nonexistent", "hello"); - expect(current).toBeNull(); + expect(getCurrentNote("nonexistent", "hello")).toBeNull(); }); test("returns different content for different notes", () => { const hello = getCurrentNote("test", "hello"); const gettingStarted = getCurrentNote("test", "getting-started"); - expect(hello).not.toBeNull(); - expect(gettingStarted).not.toBeNull(); expect(hello?.content).not.toBe(gettingStarted?.content); }); test("home note is accessible", () => { const home = getCurrentNote("test", "home"); - expect(home).not.toBeNull(); expect(home?.content).toContain("# Welcome to the Test Wiki"); }); }); @@ -106,13 +105,11 @@ describe("getNoteBySlug", () => { }); test("returns null for missing slug", () => { - const note = getNoteBySlug("test", "nonexistent-slug"); - expect(note).toBeNull(); + expect(getNoteBySlug("test", "nonexistent-slug")).toBeNull(); }); test("returns null for wrong wiki", () => { - const note = getNoteBySlug("nonexistent", "hello"); - expect(note).toBeNull(); + expect(getNoteBySlug("nonexistent", "hello")).toBeNull(); }); }); @@ -120,7 +117,11 @@ describe("createNote", () => { const INITIAL_CONTENT = "# New Note\n\nSome content here."; test("inserts rows into notes, revisions, current_note", () => { - const { noteAtUri, revisionAtUri } = createNote( + const nUri = noteUri(); + const rUri = revUri(); + createNote( + nUri, + rUri, "test", "write-test-create", "Write Test Create", @@ -128,28 +129,24 @@ describe("createNote", () => { INITIAL_CONTENT, ); - const note = getNoteBySlug("test", "write-test-create"); - expect(note).not.toBeNull(); - expect(note?.title).toBe("Write Test Create"); - - const current = getCurrentNote("test", "write-test-create"); - expect(current).not.toBeNull(); - expect(current?.content).toBe(INITIAL_CONTENT); + expect(getNoteBySlug("test", "write-test-create")?.title).toBe( + "Write Test Create", + ); + expect(getCurrentNote("test", "write-test-create")?.content).toBe( + INITIAL_CONTENT, + ); const revision = db .query("SELECT * FROM revisions WHERE note_at_uri = ?") - .get(noteAtUri) as { - diff: string; - parent_revision_uri: string | null; - } | null; - expect(revision).not.toBeNull(); + .get(nUri) as { parent_revision_uri: string | null } | null; expect(revision?.parent_revision_uri).toBeNull(); - - expect(revisionAtUri).toBeDefined(); }); test("first revision diffs from empty string", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "write-test-diff-check", "Diff Check", @@ -159,72 +156,126 @@ describe("createNote", () => { const revision = db .query("SELECT diff FROM revisions WHERE note_at_uri = ?") - .get(noteAtUri) as { diff: string } | null; - expect(revision).not.toBeNull(); + .get(nUri) as { diff: string } | null; expect(applyDiff("", revision?.diff ?? "")).toBe(INITIAL_CONTENT); }); test("noteAtUri uses TID rkey", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "write-test-uri-note", "URI Note", TEST_DID, "content", ); - expect(noteAtUri).toMatch( + expect(nUri).toMatch( /^at:\/\/did:plc:mock123\/pub\.coral\.note\/[a-z2-7]{13}$/, ); }); test("revisionAtUri uses TID rkey", () => { - const { revisionAtUri } = createNote( + const rUri = revUri(); + createNote( + noteUri(), + rUri, "test", "write-test-uri-rev", "URI Rev", TEST_DID, "content", ); - expect(revisionAtUri).toMatch( + expect(rUri).toMatch( /^at:\/\/did:plc:mock123\/pub\.coral\.noteRevision\/[a-z2-7]{13}$/, ); }); + test("snapshot revision_at_uri matches provided revisionAtUri", () => { + const nUri = noteUri(); + const rUri = revUri(); + createNote( + nUri, + rUri, + "test", + "write-test-snap-uri", + "Snap URI", + TEST_DID, + "content", + ); + const snap = db + .query("SELECT revision_at_uri FROM snapshots WHERE note_at_uri = ?") + .get(nUri) as { revision_at_uri: string } | null; + expect(snap?.revision_at_uri).toBe(rUri); + }); + test("throws on duplicate (wiki_slug, slug)", () => { - createNote("test", "write-test-dup", "Dup", TEST_DID, "first"); + createNote( + noteUri(), + revUri(), + "test", + "write-test-dup", + "Dup", + TEST_DID, + "first", + ); expect(() => - createNote("test", "write-test-dup", "Dup", TEST_DID, "second"), + createNote( + noteUri(), + revUri(), + "test", + "write-test-dup", + "Dup", + TEST_DID, + "second", + ), ).toThrow(); }); }); describe("saveNoteEdit", () => { test("creates a revision row with correct note_at_uri", () => { + const nUri = noteUri(); createNote( + nUri, + revUri(), "test", "edit-test-rev-row", "Rev Row", TEST_DID, "original content", ); - const { revisionAtUri } = saveNoteEdit( + + const rUri = revUri(); + saveNoteEdit( + rUri, "test", "edit-test-rev-row", "updated content", TEST_DID, ); - const note = getNoteBySlug("test", "edit-test-rev-row"); const revision = db .query("SELECT * FROM revisions WHERE at_uri = ?") - .get(revisionAtUri) as { note_at_uri: string } | null; - expect(revision).not.toBeNull(); - expect(revision?.note_at_uri).toBe(note?.at_uri); + .get(rUri) as { note_at_uri: string } | null; + expect(revision?.note_at_uri).toBe(nUri); }); test("applyDiff(oldContent, diff) equals new content", () => { - createNote("test", "edit-test-diff", "Diff", TEST_DID, "old content"); - const { revisionAtUri } = saveNoteEdit( + createNote( + noteUri(), + revUri(), + "test", + "edit-test-diff", + "Diff", + TEST_DID, + "old content", + ); + + const rUri = revUri(); + saveNoteEdit( + rUri, "test", "edit-test-diff", "new content after edit", @@ -233,35 +284,48 @@ describe("saveNoteEdit", () => { const revision = db .query("SELECT diff FROM revisions WHERE at_uri = ?") - .get(revisionAtUri) as { diff: string } | null; - expect(revision).not.toBeNull(); + .get(rUri) as { diff: string } | null; expect(applyDiff("old content", revision?.diff ?? "")).toBe( "new content after edit", ); }); test("current_note.content equals new content after save", () => { - createNote("test", "edit-test-current", "Current", TEST_DID, "original"); - saveNoteEdit("test", "edit-test-current", "edited content", TEST_DID); - - const current = getCurrentNote("test", "edit-test-current"); - expect(current?.content).toBe("edited content"); - }); - - test("parent of second revision equals first revision at_uri", () => { - createNote("test", "edit-test-chain", "Chain", TEST_DID, "v1"); - const { revisionAtUri: rev1Uri } = saveNoteEdit( + createNote( + noteUri(), + revUri(), "test", - "edit-test-chain", - "v2", + "edit-test-current", + "Current", TEST_DID, + "original", ); - const { revisionAtUri: rev2Uri } = saveNoteEdit( + saveNoteEdit( + revUri(), + "test", + "edit-test-current", + "edited content", + TEST_DID, + ); + expect(getCurrentNote("test", "edit-test-current")?.content).toBe( + "edited content", + ); + }); + + test("parent of second revision equals first revision at_uri", () => { + createNote( + noteUri(), + revUri(), "test", "edit-test-chain", - "v3", + "Chain", TEST_DID, + "v1", ); + const rev1Uri = revUri(); + saveNoteEdit(rev1Uri, "test", "edit-test-chain", "v2", TEST_DID); + const rev2Uri = revUri(); + saveNoteEdit(rev2Uri, "test", "edit-test-chain", "v3", TEST_DID); const rev2 = db .query("SELECT parent_revision_uri FROM revisions WHERE at_uri = ?") @@ -270,17 +334,33 @@ describe("saveNoteEdit", () => { }); test("second edit: current_note reflects final content", () => { - createNote("test", "edit-test-final", "Final", TEST_DID, "v1"); - saveNoteEdit("test", "edit-test-final", "v2", TEST_DID); - saveNoteEdit("test", "edit-test-final", "v3 final", TEST_DID); - - const current = getCurrentNote("test", "edit-test-final"); - expect(current?.content).toBe("v3 final"); + createNote( + noteUri(), + revUri(), + "test", + "edit-test-final", + "Final", + TEST_DID, + "v1", + ); + saveNoteEdit(revUri(), "test", "edit-test-final", "v2", TEST_DID); + saveNoteEdit(revUri(), "test", "edit-test-final", "v3 final", TEST_DID); + expect(getCurrentNote("test", "edit-test-final")?.content).toBe("v3 final"); }); test("optional message is stored on revision row", () => { - createNote("test", "edit-test-msg", "Msg", TEST_DID, "content"); - const { revisionAtUri } = saveNoteEdit( + createNote( + noteUri(), + revUri(), + "test", + "edit-test-msg", + "Msg", + TEST_DID, + "content", + ); + const rUri = revUri(); + saveNoteEdit( + rUri, "test", "edit-test-msg", "updated", @@ -290,25 +370,29 @@ describe("saveNoteEdit", () => { const revision = db .query("SELECT message FROM revisions WHERE at_uri = ?") - .get(revisionAtUri) as { message: string | null } | null; + .get(rUri) as { message: string | null } | null; expect(revision?.message).toBe("my edit summary"); }); test("throws for nonexistent note slug", () => { expect(() => - saveNoteEdit("test", "does-not-exist-xyz", "content", TEST_DID), + saveNoteEdit(revUri(), "test", "does-not-exist-xyz", "content", TEST_DID), ).toThrow(); }); - test("revisionAtUri uses TID rkey", () => { - createNote("test", "edit-test-tid", "TID", TEST_DID, "content"); - const { revisionAtUri } = saveNoteEdit( + test("revision at_uri in DB matches the provided uri", () => { + createNote( + noteUri(), + revUri(), "test", "edit-test-tid", - "updated", + "TID", TEST_DID, + "content", ); - expect(revisionAtUri).toMatch( + const rUri = revUri(); + saveNoteEdit(rUri, "test", "edit-test-tid", "updated", TEST_DID); + expect(rUri).toMatch( /^at:\/\/did:plc:mock123\/pub\.coral\.noteRevision\/[a-z2-7]{13}$/, ); }); @@ -317,6 +401,8 @@ describe("saveNoteEdit", () => { describe("searchNotes", () => { test("finds notes matching title", () => { createNote( + noteUri(), + revUri(), "test", "search-test-alpha", "Search Alpha", @@ -329,50 +415,61 @@ describe("searchNotes", () => { }); test("case-insensitive matching", () => { - createNote("test", "search-test-beta", "Search Beta", TEST_DID, "content"); + createNote( + noteUri(), + revUri(), + "test", + "search-test-beta", + "Search Beta", + TEST_DID, + "content", + ); const results = searchNotes("search beta"); expect(results.some((n) => n.slug === "search-test-beta")).toBe(true); }); test("filters by wikiSlug when provided", () => { createNote( + noteUri(), + revUri(), "test", "search-test-gamma", "Search Gamma", TEST_DID, "content", ); - const withFilter = searchNotes("Gamma", "test"); - expect(withFilter.some((n) => n.slug === "search-test-gamma")).toBe(true); - - const wrongWiki = searchNotes("Gamma", "nonexistent-wiki"); - expect(wrongWiki).toEqual([]); + expect( + searchNotes("Gamma", "test").some((n) => n.slug === "search-test-gamma"), + ).toBe(true); + expect(searchNotes("Gamma", "nonexistent-wiki")).toEqual([]); }); test("returns empty for no match", () => { - const results = searchNotes("zzz-nonexistent-note-zzz"); - expect(results).toEqual([]); + expect(searchNotes("zzz-nonexistent-note-zzz")).toEqual([]); }); }); describe("getNoteByAtUri", () => { test("returns note by AT URI", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "upsert-test-byuri", "By URI", TEST_DID, "content", ); - const note = getNoteByAtUri(noteAtUri); - expect(note).not.toBeNull(); + const note = getNoteByAtUri(nUri); expect(note?.slug).toBe("upsert-test-byuri"); expect(note?.wiki_slug).toBe("test"); }); test("returns null for nonexistent AT URI", () => { - const note = getNoteByAtUri("at://did:plc:fake/pub.coral.note/nonexistent"); - expect(note).toBeNull(); + expect( + getNoteByAtUri("at://did:plc:fake/pub.coral.note/nonexistent"), + ).toBeNull(); }); }); @@ -383,22 +480,20 @@ describe("upsertNote", () => { "upsert-test-new", "Upsert New", TEST_DID, - "at://did:plc:mock123/pub.coral.note/upsert1", + noteUri(), "2026-01-01T00:00:00.000Z", ); - - const note = getNoteBySlug("test", "upsert-test-new"); - expect(note).not.toBeNull(); - expect(note?.title).toBe("Upsert New"); + expect(getNoteBySlug("test", "upsert-test-new")?.title).toBe("Upsert New"); }); test("updates title on conflict", () => { + const uri = noteUri(); upsertNote( "test", "upsert-test-conflict", "Original", TEST_DID, - "at://did:plc:mock123/pub.coral.note/upsert2", + uri, "2026-01-01T00:00:00.000Z", ); upsertNote( @@ -406,18 +501,21 @@ describe("upsertNote", () => { "upsert-test-conflict", "Updated Title", TEST_DID, - "at://did:plc:mock123/pub.coral.note/upsert2", + uri, "2026-01-01T00:00:00.000Z", ); - - const note = getNoteBySlug("test", "upsert-test-conflict"); - expect(note?.title).toBe("Updated Title"); + expect(getNoteBySlug("test", "upsert-test-conflict")?.title).toBe( + "Updated Title", + ); }); }); describe("deleteNoteByAtUri", () => { test("deletes note and cascading rows", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "delete-test-note", "Delete Me", @@ -425,31 +523,19 @@ describe("deleteNoteByAtUri", () => { "content to delete", ); - // Verify it exists - expect(getNoteByAtUri(noteAtUri)).not.toBeNull(); - expect(getCurrentNote("test", "delete-test-note")).not.toBeNull(); - - deleteNoteByAtUri(noteAtUri); - - // Note and current_note gone - expect(getNoteByAtUri(noteAtUri)).toBeNull(); + expect(getNoteByAtUri(nUri)).not.toBeNull(); + deleteNoteByAtUri(nUri); + expect(getNoteByAtUri(nUri)).toBeNull(); expect(getCurrentNote("test", "delete-test-note")).toBeNull(); - - // Revisions gone - const revisions = db - .query("SELECT * FROM revisions WHERE note_at_uri = ?") - .all(noteAtUri); - expect(revisions).toEqual([]); - - // Snapshots gone - const snapshots = db - .query("SELECT * FROM snapshots WHERE note_at_uri = ?") - .all(noteAtUri); - expect(snapshots).toEqual([]); + expect( + db.query("SELECT * FROM revisions WHERE note_at_uri = ?").all(nUri), + ).toEqual([]); + expect( + db.query("SELECT * FROM snapshots WHERE note_at_uri = ?").all(nUri), + ).toEqual([]); }); test("no-op for nonexistent AT URI", () => { - // Should not throw deleteNoteByAtUri("at://did:plc:fake/pub.coral.note/nope"); }); }); diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index 316a79b..5b5c23e 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { generateTid } from "../../../../src/lib/tid.ts"; import { getDb } from "../../../../src/server/db/index.ts"; import { capSnapshots, @@ -12,6 +13,9 @@ import { cleanupNotes } from "./helpers.ts"; const db = getDb(); const TEST_DID = "did:plc:mock123"; +const noteUri = () => `at://${TEST_DID}/pub.coral.note/${generateTid()}`; +const revUri = () => `at://${TEST_DID}/pub.coral.noteRevision/${generateTid()}`; + beforeAll(() => { cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); @@ -24,7 +28,10 @@ afterAll(() => { describe("backlinks", () => { test("createNote populates backlinks from wikilinks", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "backlink-test-create", "Backlink Create", @@ -32,26 +39,29 @@ describe("backlinks", () => { "Link to [[target-a]] and [[target-b]]", ); const links = getBacklinks("test", "target-a"); - const sourceUris = links.map((l) => l.source_note_uri); - expect(sourceUris).toContain(noteAtUri); + expect(links.map((l) => l.source_note_uri)).toContain(nUri); }); test("createNote with no wikilinks produces no backlinks", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "backlink-test-nolinks", "No Links", TEST_DID, "No links here", ); - const all = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri); - expect(all).toEqual([]); + expect( + db.query("SELECT * FROM backlinks WHERE source_note_uri = ?").all(nUri), + ).toEqual([]); }); test("getBacklinks returns rows for target slug", () => { createNote( + noteUri(), + revUri(), "test", "backlink-test-read", "Read", @@ -64,7 +74,10 @@ describe("backlinks", () => { }); test("saveNoteEdit updates backlinks", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "backlink-test-edit", "Edit", @@ -72,46 +85,56 @@ describe("backlinks", () => { "Link to [[old-target]]", ); saveNoteEdit( + revUri(), "test", "backlink-test-edit", "Now links to [[new-target]]", TEST_DID, ); - const links = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri) as { target_note_slug: string }[]; - const targets = links.map((l) => l.target_note_slug); + const targets = ( + db + .query("SELECT * FROM backlinks WHERE source_note_uri = ?") + .all(nUri) as { target_note_slug: string }[] + ).map((l) => l.target_note_slug); expect(targets).toContain("new-target"); expect(targets).not.toContain("old-target"); }); test("saveNoteEdit clears backlinks when wikilinks removed", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "backlink-test-clear", "Clear", TEST_DID, "Link to [[will-remove]]", ); - saveNoteEdit("test", "backlink-test-clear", "No more links", TEST_DID); - - const links = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri); - expect(links).toEqual([]); + saveNoteEdit( + revUri(), + "test", + "backlink-test-clear", + "No more links", + TEST_DID, + ); + expect( + db.query("SELECT * FROM backlinks WHERE source_note_uri = ?").all(nUri), + ).toEqual([]); }); test("backlinks have correct wiki_slug", () => { createNote( + noteUri(), + revUri(), "test", "backlink-test-wslug", "WSlug", TEST_DID, "Link to [[check-slug]]", ); - const links = getBacklinks("test", "check-slug"); - for (const link of links) { + for (const link of getBacklinks("test", "check-slug")) { expect(link.wiki_slug).toBe("test"); } }); @@ -119,92 +142,105 @@ describe("backlinks", () => { describe("snapshots", () => { test("createNote creates a snapshot", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "snapshot-test-create", "Snap Create", TEST_DID, "Initial content", ); - const snaps = getSnapshots(noteAtUri); + const snaps = getSnapshots(nUri); expect(snaps.length).toBe(1); expect(snaps[0]?.content).toBe("Initial content"); }); - test("snapshot revision_at_uri matches the revision", () => { - const { noteAtUri, revisionAtUri } = createNote( + test("snapshot revision_at_uri matches provided revisionAtUri", () => { + const nUri = noteUri(); + const rUri = revUri(); + createNote( + nUri, + rUri, "test", "snapshot-test-revuri", "Snap Rev", TEST_DID, "Content", ); - const snaps = getSnapshots(noteAtUri); - expect(snaps[0]?.revision_at_uri).toBe(revisionAtUri); + expect(getSnapshots(nUri)[0]?.revision_at_uri).toBe(rUri); }); test("saveNoteEdit appends a snapshot", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "snapshot-test-edit", "Snap Edit", TEST_DID, "v1", ); - saveNoteEdit("test", "snapshot-test-edit", "v2", TEST_DID); - - const snaps = getSnapshots(noteAtUri); + saveNoteEdit(revUri(), "test", "snapshot-test-edit", "v2", TEST_DID); + const snaps = getSnapshots(nUri); expect(snaps.length).toBe(2); - // DESC order: newest first expect(snaps[0]?.content).toBe("v2"); expect(snaps[1]?.content).toBe("v1"); }); test("capSnapshots removes oldest beyond limit", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "snapshot-test-cap", "Snap Cap", TEST_DID, "v1", ); - // Create 4 more snapshots (total 5 with the one from createNote) for (let i = 2; i <= 5; i++) { - saveNoteEdit("test", "snapshot-test-cap", `v${i}`, TEST_DID); + saveNoteEdit(revUri(), "test", "snapshot-test-cap", `v${i}`, TEST_DID); } - expect(getSnapshots(noteAtUri).length).toBe(5); - - // Cap at 3 — should remove 2 oldest - capSnapshots(noteAtUri, 3); - const remaining = getSnapshots(noteAtUri); + expect(getSnapshots(nUri).length).toBe(5); + capSnapshots(nUri, 3); + const remaining = getSnapshots(nUri); expect(remaining.length).toBe(3); expect(remaining[0]?.content).toBe("v5"); expect(remaining[2]?.content).toBe("v3"); }); test("capSnapshots is a no-op when under limit", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "snapshot-test-under", "Snap Under", TEST_DID, "only one", ); - capSnapshots(noteAtUri, 25); - expect(getSnapshots(noteAtUri).length).toBe(1); + capSnapshots(nUri, 25); + expect(getSnapshots(nUri).length).toBe(1); }); test("capSnapshots at exact limit keeps all", () => { - const { noteAtUri } = createNote( + const nUri = noteUri(); + createNote( + nUri, + revUri(), "test", "snapshot-test-exact", "Snap Exact", TEST_DID, "v1", ); - saveNoteEdit("test", "snapshot-test-exact", "v2", TEST_DID); - capSnapshots(noteAtUri, 2); - expect(getSnapshots(noteAtUri).length).toBe(2); + saveNoteEdit(revUri(), "test", "snapshot-test-exact", "v2", TEST_DID); + capSnapshots(nUri, 2); + expect(getSnapshots(nUri).length).toBe(2); }); test("getSnapshots returns empty for unknown note", () => {