diff --git a/biome.json b/biome.json index 1482987..10c239a 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/public/editor/preview.ts b/public/editor/preview.ts index a137ff8..052f47e 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -1,7 +1,14 @@ import MarkdownIt from "markdown-it"; +import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; const md = new MarkdownIt({ html: false, linkify: true }); +md.use(wikilinkPlugin); + +function getWikiSlug(): string | undefined { + const match = window.location.pathname.match(/^\/wiki\/([^/]+)/); + return match?.[1]; +} export function renderPreview(preview: HTMLElement, doc: string): void { - preview.innerHTML = md.render(doc); + preview.innerHTML = md.render(doc, { wikiSlug: getWikiSlug() }); } diff --git a/src/atproto/routes.ts b/src/atproto/routes.ts index b70dae6..d1dbc6b 100644 --- a/src/atproto/routes.ts +++ b/src/atproto/routes.ts @@ -6,7 +6,12 @@ import { loginPage } from "../views/login.ts"; import { getDevAccounts, getDevPdsUrl, getHandleResolverUrl } from "./env.ts"; import { getClient } from "./session.ts"; -// Resolve handle to DID using configurable resolver (defaults to bsky.social) +function getSafeReturnTo(cookieHeader: string | null): string { + const match = cookieHeader?.match(/(?:^|;\s*)returnTo=([^;]+)/); + const raw = match?.[1] ? decodeURIComponent(match[1]) : null; + return raw?.startsWith("/") && !raw.includes("//") ? raw : "/"; +} + async function resolveHandle(handle: string): Promise { const base = getHandleResolverUrl(); const resp = await fetch( @@ -107,17 +112,7 @@ export function atprotoRoutes() { const { session } = await client.callback(params); const did = session.did; - // Read returnTo cookie for post-login redirect - const cookieHeader = request.headers.get("cookie") ?? ""; - const returnToMatch = cookieHeader.match(/(?:^|;\s*)returnTo=([^;]+)/); - const returnToRaw = returnToMatch?.[1] - ? decodeURIComponent(returnToMatch[1]) - : null; - // Validate: must start with / and not contain // (open redirect prevention) - const returnToUrl = - returnToRaw?.startsWith("/") && !returnToRaw.includes("//") - ? returnToRaw - : "/"; + const returnToUrl = getSafeReturnTo(request.headers.get("cookie")); return new Response(null, { status: 302, @@ -168,15 +163,7 @@ export function atprotoRoutes() { }); } - const cookieHeader = request.headers.get("cookie") ?? ""; - const returnToMatch = cookieHeader.match(/(?:^|;\s*)returnTo=([^;]+)/); - const returnToRaw = returnToMatch?.[1] - ? decodeURIComponent(returnToMatch[1]) - : null; - const returnToUrl = - returnToRaw?.startsWith("/") && !returnToRaw.includes("//") - ? returnToRaw - : "/"; + const returnToUrl = getSafeReturnTo(request.headers.get("cookie")); return new Response(null, { status: 302, diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 475c464..fa5dded 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,5 +1,5 @@ import type { CommitEvt } from "@atproto/sync"; -import { didOwnsUri, parseAtUri } from "../lib/at-uri.ts"; +import { didOwnsUri } from "../lib/at-uri.ts"; import { COLLECTIONS } from "../lib/constants.ts"; import { applyRevisionFromFirehose, @@ -59,50 +59,105 @@ interface BookmarkRecord { createdAt: string; } -function isValidRecord(record: unknown): record is Record { - return typeof record === "object" && record !== null; +// --- Type guards --- + +type Rec = Record; + +function hasString(r: Rec, key: string): boolean { + return typeof r[key] === "string" && r[key] !== ""; +} + +function isWikiRecord(r: Rec): r is Rec & WikiRecord { + return ( + hasString(r, "name") && + hasString(r, "visibility") && + hasString(r, "createdAt") + ); +} + +function isNoteRecord(r: Rec): r is Rec & NoteRecord { + return ( + hasString(r, "slug") && + hasString(r, "title") && + hasString(r, "wikiRef") && + hasString(r, "createdAt") + ); } +function isRevisionRecord(r: Rec): r is Rec & RevisionRecord { + return ( + hasString(r, "noteRef") && + hasString(r, "diff") && + hasString(r, "diffFormat") && + hasString(r, "createdAt") + ); +} + +function isMembershipRecord(r: Rec): r is Rec & MembershipRecord { + return ( + hasString(r, "memberDid") && + hasString(r, "wikiRef") && + hasString(r, "role") && + hasString(r, "createdAt") + ); +} + +function isMemberRequestRecord(r: Rec): r is Rec & MemberRequestRecord { + return hasString(r, "wikiRef") && hasString(r, "createdAt"); +} + +function isBookmarkRecord(r: Rec): r is Rec & BookmarkRecord { + return hasString(r, "wikiRef") && hasString(r, "createdAt"); +} + +// --- Event dispatch --- + export function handleCommitEvent(evt: CommitEvt): void { + const atUri = evt.uri.toString(); + if (!didOwnsUri(evt.did, atUri)) return; + if (evt.event === "delete") { - handleDelete(evt); + handleDelete(atUri, evt.collection); return; } const record = evt.record; - if (!isValidRecord(record)) return; + if (typeof record !== "object" || record === null) return; + const r = record as Rec; switch (evt.collection) { case COLLECTIONS.wiki: - handleWiki(evt, record as unknown as WikiRecord); + if (isWikiRecord(r)) handleWiki(evt.did, evt.rkey, atUri, r); break; case COLLECTIONS.note: - handleNote(evt, record as unknown as NoteRecord); + if (isNoteRecord(r)) handleNote(evt.did, atUri, r); break; case COLLECTIONS.noteRevision: - handleRevision(evt, record as unknown as RevisionRecord); + if (isRevisionRecord(r)) handleRevision(evt.did, atUri, r); break; case COLLECTIONS.membership: - handleMembership(evt, record as unknown as MembershipRecord); + if (isMembershipRecord(r)) handleMembership(evt.did, atUri, r); break; case COLLECTIONS.memberRequest: - handleMemberRequest(evt, record as unknown as MemberRequestRecord); + if (isMemberRequestRecord(r)) handleMemberRequest(evt.did, atUri, r); break; case COLLECTIONS.bookmark: - handleBookmark(evt, record as unknown as BookmarkRecord); + if (isBookmarkRecord(r)) handleBookmark(evt.did, atUri, r); break; } } -function handleWiki(evt: CommitEvt, record: WikiRecord): void { - if (!record.name || !record.visibility || !record.createdAt) return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; +// --- Handlers (validation already done by type guards + didOwnsUri) --- +function handleWiki( + did: string, + rkey: string, + atUri: string, + record: WikiRecord, +): void { upsertWiki( - evt.rkey, - evt.did, + rkey, + did, record.name, record.visibility, atUri, @@ -112,16 +167,7 @@ function handleWiki(evt: CommitEvt, record: WikiRecord): void { ); } -function handleNote(evt: CommitEvt, record: NoteRecord): void { - if (!record.slug || !record.title || !record.wikiRef || !record.createdAt) - return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - - const wikiParsed = parseAtUri(record.wikiRef); - if (!wikiParsed) return; - +function handleNote(did: string, atUri: string, record: NoteRecord): void { const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; @@ -129,31 +175,24 @@ function handleNote(evt: CommitEvt, record: NoteRecord): void { wiki.slug, record.slug, record.title, - evt.did, + did, atUri, record.createdAt, ); } -function handleRevision(evt: CommitEvt, record: RevisionRecord): void { - if ( - !record.noteRef || - !record.diff || - !record.diffFormat || - !record.createdAt - ) - return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - +function handleRevision( + did: string, + atUri: string, + record: RevisionRecord, +): void { const note = getNoteByAtUri(record.noteRef); if (!note) return; applyRevisionFromFirehose( note.at_uri, note.wiki_slug, - evt.did, + did, atUri, record.parentRevision ?? null, record.diff, @@ -163,17 +202,15 @@ function handleRevision(evt: CommitEvt, record: RevisionRecord): void { ); } -function handleMembership(evt: CommitEvt, record: MembershipRecord): void { - if (!record.memberDid || !record.wikiRef || !record.role || !record.createdAt) - return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - +function handleMembership( + did: string, + atUri: string, + record: MembershipRecord, +): void { const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; - if (wiki.did !== evt.did) return; + if (wiki.did !== did) return; upsertMembership( wiki.slug, @@ -185,37 +222,29 @@ function handleMembership(evt: CommitEvt, record: MembershipRecord): void { } function handleMemberRequest( - evt: CommitEvt, + did: string, + atUri: string, record: MemberRequestRecord, ): void { - if (!record.wikiRef || !record.createdAt) return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; - upsertRequest(wiki.slug, evt.did, atUri, record.createdAt); + upsertRequest(wiki.slug, did, atUri, record.createdAt); } -function handleBookmark(evt: CommitEvt, record: BookmarkRecord): void { - if (!record.wikiRef || !record.createdAt) return; - - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - +function handleBookmark( + did: string, + atUri: string, + record: BookmarkRecord, +): void { const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; - upsertBookmark(evt.did, record.wikiRef, atUri, record.createdAt); + upsertBookmark(did, record.wikiRef, atUri, record.createdAt); } -function handleDelete(evt: CommitEvt): void { - const atUri = evt.uri.toString(); - if (!didOwnsUri(evt.did, atUri)) return; - - switch (evt.collection) { +function handleDelete(atUri: string, collection: string): void { + switch (collection) { case COLLECTIONS.wiki: deleteWikiByAtUri(atUri); break; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 7ca0bb6..b351589 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,6 +1,8 @@ import MarkdownIt from "markdown-it"; -import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; -import { escapeHtml } from "./html.ts"; +import { + type WikilinkEnv, + wikilinkPlugin, +} from "./markdown/wikilink-plugin.ts"; import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; interface RenderResult { @@ -8,49 +10,10 @@ interface RenderResult { hasViz: boolean; } -interface MarkdownEnv extends VizPluginEnv { - wikiSlug?: string; -} +interface MarkdownEnv extends VizPluginEnv, WikilinkEnv {} const md = new MarkdownIt({ html: false, linkify: true }); -// Wikilink plugin: transforms [[slug]] and [[slug|label]] into links -function wikilinkPlugin(mdi: MarkdownIt): void { - mdi.inline.ruler.push("wikilink", (state: StateInline, silent: boolean) => { - const src = state.src; - const pos = state.pos; - - if (src[pos] !== "[" || src[pos + 1] !== "[") return false; - - const closeIdx = src.indexOf("]]", pos + 2); - if (closeIdx === -1) return false; - - if (!silent) { - const content = src.slice(pos + 2, closeIdx); - let slug: string; - let label: string; - if (content.includes("|")) { - const parts = content.split("|"); - slug = parts[0] ?? content; - label = parts.slice(1).join("|"); - } else { - slug = content; - label = content; - } - - const env = state.env as MarkdownEnv; - const wikiSlug = env.wikiSlug; - const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); - - const token = state.push("html_inline", "", 0); - token.content = `${escapeHtml(label.trim())}`; - } - - state.pos = closeIdx + 2; - return true; - }); -} - // YouTube embed plugin: replaces paragraphs containing only a YouTube link // with a responsive iframe embed (privacy-enhanced mode). function youtubePlugin(mdi: MarkdownIt): void { diff --git a/src/lib/markdown/wikilink-plugin.ts b/src/lib/markdown/wikilink-plugin.ts new file mode 100644 index 0000000..a3653c3 --- /dev/null +++ b/src/lib/markdown/wikilink-plugin.ts @@ -0,0 +1,43 @@ +import type MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; +import { escapeHtml } from "../html.ts"; + +export interface WikilinkEnv { + wikiSlug?: string; +} + +export function wikilinkPlugin(mdi: MarkdownIt): void { + mdi.inline.ruler.push("wikilink", (state: StateInline, silent: boolean) => { + const src = state.src; + const pos = state.pos; + + if (src[pos] !== "[" || src[pos + 1] !== "[") return false; + + const closeIdx = src.indexOf("]]", pos + 2); + if (closeIdx === -1) return false; + + if (!silent) { + const content = src.slice(pos + 2, closeIdx); + let slug: string; + let label: string; + if (content.includes("|")) { + const parts = content.split("|"); + slug = parts[0] ?? content; + label = parts.slice(1).join("|"); + } else { + slug = content; + label = content; + } + + const env = state.env as WikilinkEnv; + const wikiSlug = env.wikiSlug; + const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); + + const token = state.push("html_inline", "", 0); + token.content = `${escapeHtml(label.trim())}`; + } + + state.pos = closeIdx + 2; + return true; + }); +} diff --git a/src/lib/orchestrators/bookmark.ts b/src/lib/orchestrators/bookmark.ts index 70c4168..a63eef8 100644 --- a/src/lib/orchestrators/bookmark.ts +++ b/src/lib/orchestrators/bookmark.ts @@ -28,7 +28,7 @@ export async function addBookmarkAction( upsertBookmark(did, wikiAtUri, atUri, now); } -export async function removeBookmarkAction( +export async function deleteBookmarkAction( did: string, wikiAtUri: string, session: Session | null, diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index 11a774d..4e70f9a 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -185,7 +185,7 @@ export async function addMemberAction( * NotFoundError if membership not found, * PdsWriteError on PDS failure. */ -export async function removeMemberAction( +export async function deleteMemberAction( ctx: WikiRequestContext, memberDid: string, ): Promise { diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index a787a3c..15ef7e0 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -10,6 +10,13 @@ export interface WikiWithNoteCount extends WikiRow { export type WikiSort = "updated" | "created"; +const WIKI_WITH_COUNTS = ` + SELECT w.*, COUNT(n.slug) as note_count, + pc.handle as owner_handle, pc.avatar as owner_avatar + FROM wikis w + LEFT JOIN notes n ON n.wiki_slug = w.slug + LEFT JOIN profile_cache pc ON pc.did = w.did`; + export function listPublicWikisPaginated(options: { query?: string; language?: string; @@ -44,11 +51,7 @@ export function listPublicWikisPaginated(options: { const wikis = db .query( - `SELECT w.*, COUNT(n.slug) as note_count, - pc.handle as owner_handle, pc.avatar as owner_avatar - FROM wikis w - LEFT JOIN notes n ON n.wiki_slug = w.slug - LEFT JOIN profile_cache pc ON pc.did = w.did + `${WIKI_WITH_COUNTS} WHERE ${where} GROUP BY w.slug ORDER BY ${orderCol} DESC @@ -83,11 +86,7 @@ export function listOwnedWikis(did: string): WikiWithNoteCount[] { const db = getDb(); return db .query( - `SELECT w.*, COUNT(n.slug) as note_count, - pc.handle as owner_handle, pc.avatar as owner_avatar - FROM wikis w - LEFT JOIN notes n ON n.wiki_slug = w.slug - LEFT JOIN profile_cache pc ON pc.did = w.did + `${WIKI_WITH_COUNTS} WHERE w.did = ? GROUP BY w.slug ORDER BY w.updated_at DESC`, @@ -99,11 +98,7 @@ export function listCollaboratingWikis(did: string): WikiWithNoteCount[] { const db = getDb(); return db .query( - `SELECT w.*, COUNT(n.slug) as note_count, - pc.handle as owner_handle, pc.avatar as owner_avatar - FROM wikis w - LEFT JOIN notes n ON n.wiki_slug = w.slug - LEFT JOIN profile_cache pc ON pc.did = w.did + `${WIKI_WITH_COUNTS} INNER JOIN memberships m ON m.wiki_slug = w.slug AND m.did = ? WHERE w.did != ? GROUP BY w.slug diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index 62f21ad..ea8effb 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -61,10 +61,17 @@ This wiki runs on [[hello|ATProto]]. Every edit is a diff stored permanently. db.run("BEGIN TRANSACTION"); try { - db.run(` - INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES - ('test', '${mockDid}', 'Test Wiki', 'public', 'A sample wiki for exploring Lichen features.', 'at://${mockDid}/wiki.lichen.wiki/test') - `); + db.run( + "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", + [ + "test", + mockDid, + "Test Wiki", + "public", + "A sample wiki for exploring Lichen features.", + `at://${mockDid}/wiki.lichen.wiki/test`, + ], + ); db.run( "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", diff --git a/src/server/routes/bookmark.ts b/src/server/routes/bookmark.ts index 246e3ff..bb2f310 100644 --- a/src/server/routes/bookmark.ts +++ b/src/server/routes/bookmark.ts @@ -3,7 +3,7 @@ import { resolveRequestContext } from "../../lib/access.ts"; import { t } from "../../lib/i18n/index.ts"; import { addBookmarkAction, - removeBookmarkAction, + deleteBookmarkAction, } from "../../lib/orchestrators/bookmark.ts"; import { bookmarkButton } from "../../views/bookmark.ts"; @@ -19,7 +19,7 @@ export const bookmarkRoutes = new Elysia().post( if (action === "add") { await addBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); } else { - await removeBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); + await deleteBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); } const isNowBookmarked = action === "add"; diff --git a/src/server/routes/membership.ts b/src/server/routes/membership.ts index e34d9ed..c4b97ec 100644 --- a/src/server/routes/membership.ts +++ b/src/server/routes/membership.ts @@ -10,7 +10,7 @@ import { addMemberAction, approveMemberAction, changeMemberRoleAction, - removeMemberAction, + deleteMemberAction, requestAccessAction, } from "../../lib/orchestrators/membership.ts"; import { resolveHandleToDid } from "../../lib/profile.ts"; @@ -50,7 +50,7 @@ export const membershipRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const memberDid = decodeURIComponent(params.memberDid); - await removeMemberAction(ctx, memberDid); + await deleteMemberAction(ctx, memberDid); return redirect(settingsUrl(params.wikiSlug)); }, diff --git a/src/views/theme.ts b/src/views/theme.ts index f567825..bddaa04 100644 --- a/src/views/theme.ts +++ b/src/views/theme.ts @@ -1,38 +1,32 @@ import { escapeHtml } from "../lib/html.ts"; export const THEME = { - // Accent / brand (teal-700) + // Accent accentBg: "bg-teal-700", accentText: "text-teal-700", accentBorder: "border-teal-700", accentHoverText: "hover:text-teal-700", - - // Accent dark (teal-800) accentDarkHoverBg: "hover:bg-teal-800", accentDarkText: "text-teal-800", - - // Accent light (teal-50) accentLightHoverBg: "hover:bg-teal-50", accentLightFocusBg: "focus:bg-teal-50", - - // Focus / ring states accentFocusRing: "focus:ring-teal-600", accentFocusBorder: "focus:border-teal-600", accentInputFocusBorder: "focus:border-teal-500", accentSubtleRing: "focus:ring-teal-200", - // Text hierarchy (warm stone tones) + // Text textPrimary: "text-stone-900", textSecondary: "text-stone-700", textMuted: "text-stone-500", textSecondaryHover: "hover:text-stone-700", - // Backgrounds (warm stone tones) + // Backgrounds bgPage: "bg-stone-50", bgSurface: "bg-white", bgPlaceholder: "bg-stone-200", - // Borders (warm stone tones) + // Borders borderDefault: "border-stone-200", borderInput: "border-stone-300", borderSubtle: "border-stone-100", @@ -52,7 +46,7 @@ export const THEME = { // Fonts fontMono: "font-mono", - fontSans: "", // default (system sans-serif via Tailwind) + fontSans: "", } as const; export const inputClass = `w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}`; @@ -78,34 +72,28 @@ export function errorBanner(message: string | undefined): string { return `
${escapeHtml(message)}
`; } -// --- Hex values for client-side JS (editor, viz renderers) --- - export const THEME_HEX = { - // Editor toolbar (stone tones) - toolbarBg: "#f5f5f4", // stone-100 - toolbarBorder: "#d6d3d1", // stone-300 + // Editor + toolbarBg: "#f5f5f4", + toolbarBorder: "#d6d3d1", buttonBg: "#ffffff", - buttonBorder: "#d6d3d1", // stone-300 - - // Editor pane - paneBorder: "#d6d3d1", // stone-300 + buttonBorder: "#d6d3d1", + paneBorder: "#d6d3d1", previewBg: "#ffffff", - // Viz: text labels - vizText: "#44403c", // stone-700 - - // Viz: graph (nature/lichen palette) - graphLinkStroke: "#a8a29e", // stone-400 + // Viz + vizText: "#44403c", + graphLinkStroke: "#a8a29e", graphNodeStroke: "#ffffff", - graphDefaultColor: "#0f766e", // teal-700 + graphDefaultColor: "#0f766e", graphGroupColors: [ - "#0f766e", // teal-700 - "#b45309", // amber-700 - "#047857", // emerald-700 - "#dc2626", // red-600 - "#7c3aed", // violet-600 - "#0e7490", // cyan-700 - "#c2410c", // orange-700 - "#be185d", // pink-700 + "#0f766e", + "#b45309", + "#047857", + "#dc2626", + "#7c3aed", + "#0e7490", + "#c2410c", + "#be185d", ], } as const; diff --git a/tests/helpers/cleanup.ts b/tests/helpers/cleanup.ts index ca422ee..4d00015 100644 --- a/tests/helpers/cleanup.ts +++ b/tests/helpers/cleanup.ts @@ -3,26 +3,39 @@ import { upsertWiki } from "../../src/server/db/queries/index.ts"; const db = getDb(); -/** Delete a wiki and all dependent rows (notes, revisions, snapshots, backlinks, memberships, requests). */ -export function cleanupWikiAndDependents(slug: string): void { - const notes = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") - .all(slug) as { at_uri: string }[]; - for (const note of notes) { +function deleteNoteDependents(noteAtUris: { at_uri: string }[]): void { + for (const note of noteAtUris) { db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); } +} + +export function cleanupWikiAndDependents(slug: string): void { + const notes = db + .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") + .all(slug) as { at_uri: string }[]; + deleteNoteDependents(notes); db.run("DELETE FROM notes WHERE wiki_slug = ?", [slug]); db.run("DELETE FROM memberships WHERE wiki_slug = ?", [slug]); db.run("DELETE FROM requests WHERE wiki_slug = ?", [slug]); db.run("DELETE FROM wikis WHERE slug = ?", [slug]); } +export function cleanupNotes(wikiSlug: string, slugGlob: string): void { + const notes = db + .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug GLOB ?") + .all(wikiSlug, slugGlob) as { at_uri: string }[]; + deleteNoteDependents(notes); + db.run("DELETE FROM notes WHERE wiki_slug = ? AND slug GLOB ?", [ + wikiSlug, + slugGlob, + ]); +} + const TEST_DID = "did:plc:mock123"; -/** Ensure the shared "test" wiki exists (idempotent via upsert). Call in beforeAll. */ export function ensureTestWiki(): void { upsertWiki( "test", diff --git a/tests/lib/orchestrators/bookmark.test.ts b/tests/lib/orchestrators/bookmark.test.ts index ef176d3..aa4d5a8 100644 --- a/tests/lib/orchestrators/bookmark.test.ts +++ b/tests/lib/orchestrators/bookmark.test.ts @@ -21,7 +21,7 @@ mock.module("../../../src/atproto/session.ts", () => ({ getAgent: mockGetAgent, })); -const { addBookmarkAction, removeBookmarkAction } = await import( +const { addBookmarkAction, deleteBookmarkAction } = await import( "../../../src/lib/orchestrators/bookmark.ts" ); const { isBookmarked, upsertBookmark } = await import( @@ -61,7 +61,7 @@ describe("addBookmarkAction", () => { }); }); -describe("removeBookmarkAction", () => { +describe("deleteBookmarkAction", () => { test("deletes from DB and PDS with session", async () => { upsertBookmark( USER_DID, @@ -71,7 +71,7 @@ describe("removeBookmarkAction", () => { ); mockDeleteRecord.mockClear(); - await removeBookmarkAction(USER_DID, WIKI_AT_URI, session); + await deleteBookmarkAction(USER_DID, WIKI_AT_URI, session); expect(mockDeleteRecord).toHaveBeenCalledTimes(1); expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); @@ -86,7 +86,7 @@ describe("removeBookmarkAction", () => { ); mockDeleteRecord.mockClear(); - await removeBookmarkAction(USER_DID, WIKI_AT_URI, null); + await deleteBookmarkAction(USER_DID, WIKI_AT_URI, null); expect(mockDeleteRecord).not.toHaveBeenCalled(); expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); @@ -94,7 +94,7 @@ describe("removeBookmarkAction", () => { test("no-op when bookmark does not exist", async () => { mockDeleteRecord.mockClear(); - await removeBookmarkAction( + await deleteBookmarkAction( USER_DID, "at://did:plc:x/wiki.lichen.wiki/fake", session, diff --git a/tests/lib/orchestrators/membership.test.ts b/tests/lib/orchestrators/membership.test.ts index 55cedb8..760e890 100644 --- a/tests/lib/orchestrators/membership.test.ts +++ b/tests/lib/orchestrators/membership.test.ts @@ -34,7 +34,7 @@ const { approveMemberAction, changeMemberRoleAction, addMemberAction, - removeMemberAction, + deleteMemberAction, } = await import("../../../src/lib/orchestrators/membership.ts"); const { ForbiddenError, NotFoundError, PdsWriteError, ValidationError } = @@ -412,7 +412,7 @@ describe("addMemberAction", () => { }); }); -describe("removeMemberAction", () => { +describe("deleteMemberAction", () => { test("deletes from DB and PDS", async () => { // Setup: add a member to remove const db = getDb(); @@ -431,7 +431,7 @@ describe("removeMemberAction", () => { mockDeleteRecord.mockClear(); - await removeMemberAction(makeCtx(), "did:plc:toremove"); + await deleteMemberAction(makeCtx(), "did:plc:toremove"); expect(mockDeleteRecord).toHaveBeenCalledTimes(1); @@ -448,14 +448,14 @@ describe("removeMemberAction", () => { }); test("throws ValidationError when removing wiki owner", async () => { - expect(removeMemberAction(makeCtx(), OWNER_DID)).rejects.toBeInstanceOf( + expect(deleteMemberAction(makeCtx(), OWNER_DID)).rejects.toBeInstanceOf( ValidationError, ); }); test("throws NotFoundError when membership not found", async () => { expect( - removeMemberAction(makeCtx(), "did:plc:nobody"), + deleteMemberAction(makeCtx(), "did:plc:nobody"), ).rejects.toBeInstanceOf(NotFoundError); }); @@ -479,7 +479,7 @@ describe("removeMemberAction", () => { }); expect( - removeMemberAction(makeCtx(), "did:plc:pdsfailremove"), + deleteMemberAction(makeCtx(), "did:plc:pdsfailremove"), ).rejects.toBeInstanceOf(PdsWriteError); }); @@ -500,7 +500,7 @@ describe("removeMemberAction", () => { mockDeleteRecord.mockClear(); - await removeMemberAction( + await deleteMemberAction( makeCtx({ session: null }), "did:plc:nosessionremove", ); diff --git a/tests/server/db/queries/helpers.ts b/tests/server/db/queries/helpers.ts deleted file mode 100644 index 127e1ab..0000000 --- a/tests/server/db/queries/helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getDb } from "../../../../src/server/db/index.ts"; - -const db = getDb(); - -/** Clean up notes (and all dependent rows) matching a slug glob pattern in a wiki. */ -export function cleanupNotes(wikiSlug: string, slugGlob: string): void { - const notes = db - .query(`SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug GLOB ?`) - .all(wikiSlug, slugGlob) as { at_uri: string }[]; - for (const note of notes) { - db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); - db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); - } - db.run(`DELETE FROM notes WHERE wiki_slug = ? AND slug GLOB ?`, [ - wikiSlug, - slugGlob, - ]); -} diff --git a/tests/server/db/queries/note.test.ts b/tests/server/db/queries/note.test.ts index 11faeb3..7d1aaa0 100644 --- a/tests/server/db/queries/note.test.ts +++ b/tests/server/db/queries/note.test.ts @@ -13,8 +13,7 @@ import { searchNotes, upsertNote, } from "../../../../src/server/db/queries/index.ts"; -import { ensureTestWiki } from "../../../helpers/cleanup.ts"; -import { cleanupNotes } from "./helpers.ts"; +import { cleanupNotes, ensureTestWiki } from "../../../helpers/cleanup.ts"; const db = getDb(); const TEST_DID = "did:plc:mock123"; diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index 44097f9..0ed4d08 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -8,8 +8,7 @@ import { getSnapshots, saveNoteEdit, } from "../../../../src/server/db/queries/index.ts"; -import { ensureTestWiki } from "../../../helpers/cleanup.ts"; -import { cleanupNotes } from "./helpers.ts"; +import { cleanupNotes, ensureTestWiki } from "../../../helpers/cleanup.ts"; const db = getDb(); const TEST_DID = "did:plc:mock123";