🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Extract some shared functions

juprodh 321beefd 09a99d3f

+246 -236
+1 -1
biome.json
··· 1 1 { 2 - "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", 2 + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", 3 3 "vcs": { 4 4 "enabled": true, 5 5 "clientKind": "git",
+8 -1
public/editor/preview.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 + import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; 2 3 3 4 const md = new MarkdownIt({ html: false, linkify: true }); 5 + md.use(wikilinkPlugin); 6 + 7 + function getWikiSlug(): string | undefined { 8 + const match = window.location.pathname.match(/^\/wiki\/([^/]+)/); 9 + return match?.[1]; 10 + } 4 11 5 12 export function renderPreview(preview: HTMLElement, doc: string): void { 6 - preview.innerHTML = md.render(doc); 13 + preview.innerHTML = md.render(doc, { wikiSlug: getWikiSlug() }); 7 14 }
+8 -21
src/atproto/routes.ts
··· 6 6 import { getDevAccounts, getDevPdsUrl, getHandleResolverUrl } from "./env.ts"; 7 7 import { getClient } from "./session.ts"; 8 8 9 - // Resolve handle to DID using configurable resolver (defaults to bsky.social) 9 + function getSafeReturnTo(cookieHeader: string | null): string { 10 + const match = cookieHeader?.match(/(?:^|;\s*)returnTo=([^;]+)/); 11 + const raw = match?.[1] ? decodeURIComponent(match[1]) : null; 12 + return raw?.startsWith("/") && !raw.includes("//") ? raw : "/"; 13 + } 14 + 10 15 async function resolveHandle(handle: string): Promise<string> { 11 16 const base = getHandleResolverUrl(); 12 17 const resp = await fetch( ··· 107 112 const { session } = await client.callback(params); 108 113 const did = session.did; 109 114 110 - // Read returnTo cookie for post-login redirect 111 - const cookieHeader = request.headers.get("cookie") ?? ""; 112 - const returnToMatch = cookieHeader.match(/(?:^|;\s*)returnTo=([^;]+)/); 113 - const returnToRaw = returnToMatch?.[1] 114 - ? decodeURIComponent(returnToMatch[1]) 115 - : null; 116 - // Validate: must start with / and not contain // (open redirect prevention) 117 - const returnToUrl = 118 - returnToRaw?.startsWith("/") && !returnToRaw.includes("//") 119 - ? returnToRaw 120 - : "/"; 115 + const returnToUrl = getSafeReturnTo(request.headers.get("cookie")); 121 116 122 117 return new Response(null, { 123 118 status: 302, ··· 168 163 }); 169 164 } 170 165 171 - const cookieHeader = request.headers.get("cookie") ?? ""; 172 - const returnToMatch = cookieHeader.match(/(?:^|;\s*)returnTo=([^;]+)/); 173 - const returnToRaw = returnToMatch?.[1] 174 - ? decodeURIComponent(returnToMatch[1]) 175 - : null; 176 - const returnToUrl = 177 - returnToRaw?.startsWith("/") && !returnToRaw.includes("//") 178 - ? returnToRaw 179 - : "/"; 166 + const returnToUrl = getSafeReturnTo(request.headers.get("cookie")); 180 167 181 168 return new Response(null, { 182 169 status: 302,
+98 -69
src/firehose/handlers.ts
··· 1 1 import type { CommitEvt } from "@atproto/sync"; 2 - import { didOwnsUri, parseAtUri } from "../lib/at-uri.ts"; 2 + import { didOwnsUri } from "../lib/at-uri.ts"; 3 3 import { COLLECTIONS } from "../lib/constants.ts"; 4 4 import { 5 5 applyRevisionFromFirehose, ··· 59 59 createdAt: string; 60 60 } 61 61 62 - function isValidRecord(record: unknown): record is Record<string, unknown> { 63 - return typeof record === "object" && record !== null; 62 + // --- Type guards --- 63 + 64 + type Rec = Record<string, unknown>; 65 + 66 + function hasString(r: Rec, key: string): boolean { 67 + return typeof r[key] === "string" && r[key] !== ""; 64 68 } 65 69 70 + function isWikiRecord(r: Rec): r is Rec & WikiRecord { 71 + return ( 72 + hasString(r, "name") && 73 + hasString(r, "visibility") && 74 + hasString(r, "createdAt") 75 + ); 76 + } 77 + 78 + function isNoteRecord(r: Rec): r is Rec & NoteRecord { 79 + return ( 80 + hasString(r, "slug") && 81 + hasString(r, "title") && 82 + hasString(r, "wikiRef") && 83 + hasString(r, "createdAt") 84 + ); 85 + } 86 + 87 + function isRevisionRecord(r: Rec): r is Rec & RevisionRecord { 88 + return ( 89 + hasString(r, "noteRef") && 90 + hasString(r, "diff") && 91 + hasString(r, "diffFormat") && 92 + hasString(r, "createdAt") 93 + ); 94 + } 95 + 96 + function isMembershipRecord(r: Rec): r is Rec & MembershipRecord { 97 + return ( 98 + hasString(r, "memberDid") && 99 + hasString(r, "wikiRef") && 100 + hasString(r, "role") && 101 + hasString(r, "createdAt") 102 + ); 103 + } 104 + 105 + function isMemberRequestRecord(r: Rec): r is Rec & MemberRequestRecord { 106 + return hasString(r, "wikiRef") && hasString(r, "createdAt"); 107 + } 108 + 109 + function isBookmarkRecord(r: Rec): r is Rec & BookmarkRecord { 110 + return hasString(r, "wikiRef") && hasString(r, "createdAt"); 111 + } 112 + 113 + // --- Event dispatch --- 114 + 66 115 export function handleCommitEvent(evt: CommitEvt): void { 116 + const atUri = evt.uri.toString(); 117 + if (!didOwnsUri(evt.did, atUri)) return; 118 + 67 119 if (evt.event === "delete") { 68 - handleDelete(evt); 120 + handleDelete(atUri, evt.collection); 69 121 return; 70 122 } 71 123 72 124 const record = evt.record; 73 - if (!isValidRecord(record)) return; 125 + if (typeof record !== "object" || record === null) return; 126 + const r = record as Rec; 74 127 75 128 switch (evt.collection) { 76 129 case COLLECTIONS.wiki: 77 - handleWiki(evt, record as unknown as WikiRecord); 130 + if (isWikiRecord(r)) handleWiki(evt.did, evt.rkey, atUri, r); 78 131 break; 79 132 case COLLECTIONS.note: 80 - handleNote(evt, record as unknown as NoteRecord); 133 + if (isNoteRecord(r)) handleNote(evt.did, atUri, r); 81 134 break; 82 135 case COLLECTIONS.noteRevision: 83 - handleRevision(evt, record as unknown as RevisionRecord); 136 + if (isRevisionRecord(r)) handleRevision(evt.did, atUri, r); 84 137 break; 85 138 case COLLECTIONS.membership: 86 - handleMembership(evt, record as unknown as MembershipRecord); 139 + if (isMembershipRecord(r)) handleMembership(evt.did, atUri, r); 87 140 break; 88 141 case COLLECTIONS.memberRequest: 89 - handleMemberRequest(evt, record as unknown as MemberRequestRecord); 142 + if (isMemberRequestRecord(r)) handleMemberRequest(evt.did, atUri, r); 90 143 break; 91 144 case COLLECTIONS.bookmark: 92 - handleBookmark(evt, record as unknown as BookmarkRecord); 145 + if (isBookmarkRecord(r)) handleBookmark(evt.did, atUri, r); 93 146 break; 94 147 } 95 148 } 96 149 97 - function handleWiki(evt: CommitEvt, record: WikiRecord): void { 98 - if (!record.name || !record.visibility || !record.createdAt) return; 150 + // --- Handlers (validation already done by type guards + didOwnsUri) --- 99 151 100 - const atUri = evt.uri.toString(); 101 - if (!didOwnsUri(evt.did, atUri)) return; 102 - 152 + function handleWiki( 153 + did: string, 154 + rkey: string, 155 + atUri: string, 156 + record: WikiRecord, 157 + ): void { 103 158 upsertWiki( 104 - evt.rkey, 105 - evt.did, 159 + rkey, 160 + did, 106 161 record.name, 107 162 record.visibility, 108 163 atUri, ··· 112 167 ); 113 168 } 114 169 115 - function handleNote(evt: CommitEvt, record: NoteRecord): void { 116 - if (!record.slug || !record.title || !record.wikiRef || !record.createdAt) 117 - return; 118 - 119 - const atUri = evt.uri.toString(); 120 - if (!didOwnsUri(evt.did, atUri)) return; 121 - 122 - const wikiParsed = parseAtUri(record.wikiRef); 123 - if (!wikiParsed) return; 124 - 170 + function handleNote(did: string, atUri: string, record: NoteRecord): void { 125 171 const wiki = getWikiByAtUri(record.wikiRef); 126 172 if (!wiki) return; 127 173 ··· 129 175 wiki.slug, 130 176 record.slug, 131 177 record.title, 132 - evt.did, 178 + did, 133 179 atUri, 134 180 record.createdAt, 135 181 ); 136 182 } 137 183 138 - function handleRevision(evt: CommitEvt, record: RevisionRecord): void { 139 - if ( 140 - !record.noteRef || 141 - !record.diff || 142 - !record.diffFormat || 143 - !record.createdAt 144 - ) 145 - return; 146 - 147 - const atUri = evt.uri.toString(); 148 - if (!didOwnsUri(evt.did, atUri)) return; 149 - 184 + function handleRevision( 185 + did: string, 186 + atUri: string, 187 + record: RevisionRecord, 188 + ): void { 150 189 const note = getNoteByAtUri(record.noteRef); 151 190 if (!note) return; 152 191 153 192 applyRevisionFromFirehose( 154 193 note.at_uri, 155 194 note.wiki_slug, 156 - evt.did, 195 + did, 157 196 atUri, 158 197 record.parentRevision ?? null, 159 198 record.diff, ··· 163 202 ); 164 203 } 165 204 166 - function handleMembership(evt: CommitEvt, record: MembershipRecord): void { 167 - if (!record.memberDid || !record.wikiRef || !record.role || !record.createdAt) 168 - return; 169 - 170 - const atUri = evt.uri.toString(); 171 - if (!didOwnsUri(evt.did, atUri)) return; 172 - 205 + function handleMembership( 206 + did: string, 207 + atUri: string, 208 + record: MembershipRecord, 209 + ): void { 173 210 const wiki = getWikiByAtUri(record.wikiRef); 174 211 if (!wiki) return; 175 212 176 - if (wiki.did !== evt.did) return; 213 + if (wiki.did !== did) return; 177 214 178 215 upsertMembership( 179 216 wiki.slug, ··· 185 222 } 186 223 187 224 function handleMemberRequest( 188 - evt: CommitEvt, 225 + did: string, 226 + atUri: string, 189 227 record: MemberRequestRecord, 190 228 ): void { 191 - if (!record.wikiRef || !record.createdAt) return; 192 - 193 - const atUri = evt.uri.toString(); 194 - if (!didOwnsUri(evt.did, atUri)) return; 195 - 196 229 const wiki = getWikiByAtUri(record.wikiRef); 197 230 if (!wiki) return; 198 231 199 - upsertRequest(wiki.slug, evt.did, atUri, record.createdAt); 232 + upsertRequest(wiki.slug, did, atUri, record.createdAt); 200 233 } 201 234 202 - function handleBookmark(evt: CommitEvt, record: BookmarkRecord): void { 203 - if (!record.wikiRef || !record.createdAt) return; 204 - 205 - const atUri = evt.uri.toString(); 206 - if (!didOwnsUri(evt.did, atUri)) return; 207 - 235 + function handleBookmark( 236 + did: string, 237 + atUri: string, 238 + record: BookmarkRecord, 239 + ): void { 208 240 const wiki = getWikiByAtUri(record.wikiRef); 209 241 if (!wiki) return; 210 242 211 - upsertBookmark(evt.did, record.wikiRef, atUri, record.createdAt); 243 + upsertBookmark(did, record.wikiRef, atUri, record.createdAt); 212 244 } 213 245 214 - function handleDelete(evt: CommitEvt): void { 215 - const atUri = evt.uri.toString(); 216 - if (!didOwnsUri(evt.did, atUri)) return; 217 - 218 - switch (evt.collection) { 246 + function handleDelete(atUri: string, collection: string): void { 247 + switch (collection) { 219 248 case COLLECTIONS.wiki: 220 249 deleteWikiByAtUri(atUri); 221 250 break;
+5 -42
src/lib/markdown.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 - import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; 3 - import { escapeHtml } from "./html.ts"; 2 + import { 3 + type WikilinkEnv, 4 + wikilinkPlugin, 5 + } from "./markdown/wikilink-plugin.ts"; 4 6 import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; 5 7 6 8 interface RenderResult { ··· 8 10 hasViz: boolean; 9 11 } 10 12 11 - interface MarkdownEnv extends VizPluginEnv { 12 - wikiSlug?: string; 13 - } 13 + interface MarkdownEnv extends VizPluginEnv, WikilinkEnv {} 14 14 15 15 const md = new MarkdownIt({ html: false, linkify: true }); 16 - 17 - // Wikilink plugin: transforms [[slug]] and [[slug|label]] into links 18 - function wikilinkPlugin(mdi: MarkdownIt): void { 19 - mdi.inline.ruler.push("wikilink", (state: StateInline, silent: boolean) => { 20 - const src = state.src; 21 - const pos = state.pos; 22 - 23 - if (src[pos] !== "[" || src[pos + 1] !== "[") return false; 24 - 25 - const closeIdx = src.indexOf("]]", pos + 2); 26 - if (closeIdx === -1) return false; 27 - 28 - if (!silent) { 29 - const content = src.slice(pos + 2, closeIdx); 30 - let slug: string; 31 - let label: string; 32 - if (content.includes("|")) { 33 - const parts = content.split("|"); 34 - slug = parts[0] ?? content; 35 - label = parts.slice(1).join("|"); 36 - } else { 37 - slug = content; 38 - label = content; 39 - } 40 - 41 - const env = state.env as MarkdownEnv; 42 - const wikiSlug = env.wikiSlug; 43 - const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); 44 - 45 - const token = state.push("html_inline", "", 0); 46 - token.content = `<a href="${escapeHtml(href)}" class="wikilink">${escapeHtml(label.trim())}</a>`; 47 - } 48 - 49 - state.pos = closeIdx + 2; 50 - return true; 51 - }); 52 - } 53 16 54 17 // YouTube embed plugin: replaces paragraphs containing only a YouTube link 55 18 // with a responsive iframe embed (privacy-enhanced mode).
+1 -1
src/lib/orchestrators/bookmark.ts
··· 28 28 upsertBookmark(did, wikiAtUri, atUri, now); 29 29 } 30 30 31 - export async function removeBookmarkAction( 31 + export async function deleteBookmarkAction( 32 32 did: string, 33 33 wikiAtUri: string, 34 34 session: Session | null,
+1 -1
src/lib/orchestrators/membership.ts
··· 185 185 * NotFoundError if membership not found, 186 186 * PdsWriteError on PDS failure. 187 187 */ 188 - export async function removeMemberAction( 188 + export async function deleteMemberAction( 189 189 ctx: WikiRequestContext, 190 190 memberDid: string, 191 191 ): Promise<void> {
+10 -15
src/server/db/queries/wiki.ts
··· 10 10 11 11 export type WikiSort = "updated" | "created"; 12 12 13 + const WIKI_WITH_COUNTS = ` 14 + SELECT w.*, COUNT(n.slug) as note_count, 15 + pc.handle as owner_handle, pc.avatar as owner_avatar 16 + FROM wikis w 17 + LEFT JOIN notes n ON n.wiki_slug = w.slug 18 + LEFT JOIN profile_cache pc ON pc.did = w.did`; 19 + 13 20 export function listPublicWikisPaginated(options: { 14 21 query?: string; 15 22 language?: string; ··· 44 51 45 52 const wikis = db 46 53 .query( 47 - `SELECT w.*, COUNT(n.slug) as note_count, 48 - pc.handle as owner_handle, pc.avatar as owner_avatar 49 - FROM wikis w 50 - LEFT JOIN notes n ON n.wiki_slug = w.slug 51 - LEFT JOIN profile_cache pc ON pc.did = w.did 54 + `${WIKI_WITH_COUNTS} 52 55 WHERE ${where} 53 56 GROUP BY w.slug 54 57 ORDER BY ${orderCol} DESC ··· 83 86 const db = getDb(); 84 87 return db 85 88 .query( 86 - `SELECT w.*, COUNT(n.slug) as note_count, 87 - pc.handle as owner_handle, pc.avatar as owner_avatar 88 - FROM wikis w 89 - LEFT JOIN notes n ON n.wiki_slug = w.slug 90 - LEFT JOIN profile_cache pc ON pc.did = w.did 89 + `${WIKI_WITH_COUNTS} 91 90 WHERE w.did = ? 92 91 GROUP BY w.slug 93 92 ORDER BY w.updated_at DESC`, ··· 99 98 const db = getDb(); 100 99 return db 101 100 .query( 102 - `SELECT w.*, COUNT(n.slug) as note_count, 103 - pc.handle as owner_handle, pc.avatar as owner_avatar 104 - FROM wikis w 105 - LEFT JOIN notes n ON n.wiki_slug = w.slug 106 - LEFT JOIN profile_cache pc ON pc.did = w.did 101 + `${WIKI_WITH_COUNTS} 107 102 INNER JOIN memberships m ON m.wiki_slug = w.slug AND m.did = ? 108 103 WHERE w.did != ? 109 104 GROUP BY w.slug
+11 -4
src/server/db/seed.ts
··· 61 61 62 62 db.run("BEGIN TRANSACTION"); 63 63 try { 64 - db.run(` 65 - INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES 66 - ('test', '${mockDid}', 'Test Wiki', 'public', 'A sample wiki for exploring Lichen features.', 'at://${mockDid}/wiki.lichen.wiki/test') 67 - `); 64 + db.run( 65 + "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", 66 + [ 67 + "test", 68 + mockDid, 69 + "Test Wiki", 70 + "public", 71 + "A sample wiki for exploring Lichen features.", 72 + `at://${mockDid}/wiki.lichen.wiki/test`, 73 + ], 74 + ); 68 75 69 76 db.run( 70 77 "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
+2 -2
src/server/routes/bookmark.ts
··· 3 3 import { t } from "../../lib/i18n/index.ts"; 4 4 import { 5 5 addBookmarkAction, 6 - removeBookmarkAction, 6 + deleteBookmarkAction, 7 7 } from "../../lib/orchestrators/bookmark.ts"; 8 8 import { bookmarkButton } from "../../views/bookmark.ts"; 9 9 ··· 19 19 if (action === "add") { 20 20 await addBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 21 21 } else { 22 - await removeBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 22 + await deleteBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 23 23 } 24 24 25 25 const isNowBookmarked = action === "add";
+2 -2
src/server/routes/membership.ts
··· 10 10 addMemberAction, 11 11 approveMemberAction, 12 12 changeMemberRoleAction, 13 - removeMemberAction, 13 + deleteMemberAction, 14 14 requestAccessAction, 15 15 } from "../../lib/orchestrators/membership.ts"; 16 16 import { resolveHandleToDid } from "../../lib/profile.ts"; ··· 50 50 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 51 51 const memberDid = decodeURIComponent(params.memberDid); 52 52 53 - await removeMemberAction(ctx, memberDid); 53 + await deleteMemberAction(ctx, memberDid); 54 54 55 55 return redirect(settingsUrl(params.wikiSlug)); 56 56 },
+22 -34
src/views/theme.ts
··· 1 1 import { escapeHtml } from "../lib/html.ts"; 2 2 3 3 export const THEME = { 4 - // Accent / brand (teal-700) 4 + // Accent 5 5 accentBg: "bg-teal-700", 6 6 accentText: "text-teal-700", 7 7 accentBorder: "border-teal-700", 8 8 accentHoverText: "hover:text-teal-700", 9 - 10 - // Accent dark (teal-800) 11 9 accentDarkHoverBg: "hover:bg-teal-800", 12 10 accentDarkText: "text-teal-800", 13 - 14 - // Accent light (teal-50) 15 11 accentLightHoverBg: "hover:bg-teal-50", 16 12 accentLightFocusBg: "focus:bg-teal-50", 17 - 18 - // Focus / ring states 19 13 accentFocusRing: "focus:ring-teal-600", 20 14 accentFocusBorder: "focus:border-teal-600", 21 15 accentInputFocusBorder: "focus:border-teal-500", 22 16 accentSubtleRing: "focus:ring-teal-200", 23 17 24 - // Text hierarchy (warm stone tones) 18 + // Text 25 19 textPrimary: "text-stone-900", 26 20 textSecondary: "text-stone-700", 27 21 textMuted: "text-stone-500", 28 22 textSecondaryHover: "hover:text-stone-700", 29 23 30 - // Backgrounds (warm stone tones) 24 + // Backgrounds 31 25 bgPage: "bg-stone-50", 32 26 bgSurface: "bg-white", 33 27 bgPlaceholder: "bg-stone-200", 34 28 35 - // Borders (warm stone tones) 29 + // Borders 36 30 borderDefault: "border-stone-200", 37 31 borderInput: "border-stone-300", 38 32 borderSubtle: "border-stone-100", ··· 52 46 53 47 // Fonts 54 48 fontMono: "font-mono", 55 - fontSans: "", // default (system sans-serif via Tailwind) 49 + fontSans: "", 56 50 } as const; 57 51 58 52 export const inputClass = `w-full border ${THEME.borderInput} rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 ${THEME.accentFocusRing}`; ··· 78 72 return `<div class="mb-4 p-3 ${THEME.errorBg} border ${THEME.errorBorder} ${THEME.errorText} text-sm rounded">${escapeHtml(message)}</div>`; 79 73 } 80 74 81 - // --- Hex values for client-side JS (editor, viz renderers) --- 82 - 83 75 export const THEME_HEX = { 84 - // Editor toolbar (stone tones) 85 - toolbarBg: "#f5f5f4", // stone-100 86 - toolbarBorder: "#d6d3d1", // stone-300 76 + // Editor 77 + toolbarBg: "#f5f5f4", 78 + toolbarBorder: "#d6d3d1", 87 79 buttonBg: "#ffffff", 88 - buttonBorder: "#d6d3d1", // stone-300 89 - 90 - // Editor pane 91 - paneBorder: "#d6d3d1", // stone-300 80 + buttonBorder: "#d6d3d1", 81 + paneBorder: "#d6d3d1", 92 82 previewBg: "#ffffff", 93 83 94 - // Viz: text labels 95 - vizText: "#44403c", // stone-700 96 - 97 - // Viz: graph (nature/lichen palette) 98 - graphLinkStroke: "#a8a29e", // stone-400 84 + // Viz 85 + vizText: "#44403c", 86 + graphLinkStroke: "#a8a29e", 99 87 graphNodeStroke: "#ffffff", 100 - graphDefaultColor: "#0f766e", // teal-700 88 + graphDefaultColor: "#0f766e", 101 89 graphGroupColors: [ 102 - "#0f766e", // teal-700 103 - "#b45309", // amber-700 104 - "#047857", // emerald-700 105 - "#dc2626", // red-600 106 - "#7c3aed", // violet-600 107 - "#0e7490", // cyan-700 108 - "#c2410c", // orange-700 109 - "#be185d", // pink-700 90 + "#0f766e", 91 + "#b45309", 92 + "#047857", 93 + "#dc2626", 94 + "#7c3aed", 95 + "#0e7490", 96 + "#c2410c", 97 + "#be185d", 110 98 ], 111 99 } as const;
+20 -7
tests/helpers/cleanup.ts
··· 3 3 4 4 const db = getDb(); 5 5 6 - /** Delete a wiki and all dependent rows (notes, revisions, snapshots, backlinks, memberships, requests). */ 7 - export function cleanupWikiAndDependents(slug: string): void { 8 - const notes = db 9 - .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") 10 - .all(slug) as { at_uri: string }[]; 11 - for (const note of notes) { 6 + function deleteNoteDependents(noteAtUris: { at_uri: string }[]): void { 7 + for (const note of noteAtUris) { 12 8 db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); 13 9 db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); 14 10 db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); 15 11 db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); 16 12 } 13 + } 14 + 15 + export function cleanupWikiAndDependents(slug: string): void { 16 + const notes = db 17 + .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") 18 + .all(slug) as { at_uri: string }[]; 19 + deleteNoteDependents(notes); 17 20 db.run("DELETE FROM notes WHERE wiki_slug = ?", [slug]); 18 21 db.run("DELETE FROM memberships WHERE wiki_slug = ?", [slug]); 19 22 db.run("DELETE FROM requests WHERE wiki_slug = ?", [slug]); 20 23 db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 21 24 } 22 25 26 + export function cleanupNotes(wikiSlug: string, slugGlob: string): void { 27 + const notes = db 28 + .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug GLOB ?") 29 + .all(wikiSlug, slugGlob) as { at_uri: string }[]; 30 + deleteNoteDependents(notes); 31 + db.run("DELETE FROM notes WHERE wiki_slug = ? AND slug GLOB ?", [ 32 + wikiSlug, 33 + slugGlob, 34 + ]); 35 + } 36 + 23 37 const TEST_DID = "did:plc:mock123"; 24 38 25 - /** Ensure the shared "test" wiki exists (idempotent via upsert). Call in beforeAll. */ 26 39 export function ensureTestWiki(): void { 27 40 upsertWiki( 28 41 "test",
+5 -5
tests/lib/orchestrators/bookmark.test.ts
··· 21 21 getAgent: mockGetAgent, 22 22 })); 23 23 24 - const { addBookmarkAction, removeBookmarkAction } = await import( 24 + const { addBookmarkAction, deleteBookmarkAction } = await import( 25 25 "../../../src/lib/orchestrators/bookmark.ts" 26 26 ); 27 27 const { isBookmarked, upsertBookmark } = await import( ··· 61 61 }); 62 62 }); 63 63 64 - describe("removeBookmarkAction", () => { 64 + describe("deleteBookmarkAction", () => { 65 65 test("deletes from DB and PDS with session", async () => { 66 66 upsertBookmark( 67 67 USER_DID, ··· 71 71 ); 72 72 mockDeleteRecord.mockClear(); 73 73 74 - await removeBookmarkAction(USER_DID, WIKI_AT_URI, session); 74 + await deleteBookmarkAction(USER_DID, WIKI_AT_URI, session); 75 75 76 76 expect(mockDeleteRecord).toHaveBeenCalledTimes(1); 77 77 expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); ··· 86 86 ); 87 87 mockDeleteRecord.mockClear(); 88 88 89 - await removeBookmarkAction(USER_DID, WIKI_AT_URI, null); 89 + await deleteBookmarkAction(USER_DID, WIKI_AT_URI, null); 90 90 91 91 expect(mockDeleteRecord).not.toHaveBeenCalled(); 92 92 expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); ··· 94 94 95 95 test("no-op when bookmark does not exist", async () => { 96 96 mockDeleteRecord.mockClear(); 97 - await removeBookmarkAction( 97 + await deleteBookmarkAction( 98 98 USER_DID, 99 99 "at://did:plc:x/wiki.lichen.wiki/fake", 100 100 session,
+7 -7
tests/lib/orchestrators/membership.test.ts
··· 34 34 approveMemberAction, 35 35 changeMemberRoleAction, 36 36 addMemberAction, 37 - removeMemberAction, 37 + deleteMemberAction, 38 38 } = await import("../../../src/lib/orchestrators/membership.ts"); 39 39 40 40 const { ForbiddenError, NotFoundError, PdsWriteError, ValidationError } = ··· 412 412 }); 413 413 }); 414 414 415 - describe("removeMemberAction", () => { 415 + describe("deleteMemberAction", () => { 416 416 test("deletes from DB and PDS", async () => { 417 417 // Setup: add a member to remove 418 418 const db = getDb(); ··· 431 431 432 432 mockDeleteRecord.mockClear(); 433 433 434 - await removeMemberAction(makeCtx(), "did:plc:toremove"); 434 + await deleteMemberAction(makeCtx(), "did:plc:toremove"); 435 435 436 436 expect(mockDeleteRecord).toHaveBeenCalledTimes(1); 437 437 ··· 448 448 }); 449 449 450 450 test("throws ValidationError when removing wiki owner", async () => { 451 - expect(removeMemberAction(makeCtx(), OWNER_DID)).rejects.toBeInstanceOf( 451 + expect(deleteMemberAction(makeCtx(), OWNER_DID)).rejects.toBeInstanceOf( 452 452 ValidationError, 453 453 ); 454 454 }); 455 455 456 456 test("throws NotFoundError when membership not found", async () => { 457 457 expect( 458 - removeMemberAction(makeCtx(), "did:plc:nobody"), 458 + deleteMemberAction(makeCtx(), "did:plc:nobody"), 459 459 ).rejects.toBeInstanceOf(NotFoundError); 460 460 }); 461 461 ··· 479 479 }); 480 480 481 481 expect( 482 - removeMemberAction(makeCtx(), "did:plc:pdsfailremove"), 482 + deleteMemberAction(makeCtx(), "did:plc:pdsfailremove"), 483 483 ).rejects.toBeInstanceOf(PdsWriteError); 484 484 }); 485 485 ··· 500 500 501 501 mockDeleteRecord.mockClear(); 502 502 503 - await removeMemberAction( 503 + await deleteMemberAction( 504 504 makeCtx({ session: null }), 505 505 "did:plc:nosessionremove", 506 506 );
-20
tests/server/db/queries/helpers.ts
··· 1 - import { getDb } from "../../../../src/server/db/index.ts"; 2 - 3 - const db = getDb(); 4 - 5 - /** Clean up notes (and all dependent rows) matching a slug glob pattern in a wiki. */ 6 - export function cleanupNotes(wikiSlug: string, slugGlob: string): void { 7 - const notes = db 8 - .query(`SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug GLOB ?`) 9 - .all(wikiSlug, slugGlob) as { at_uri: string }[]; 10 - for (const note of notes) { 11 - db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); 12 - db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); 13 - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); 14 - db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); 15 - } 16 - db.run(`DELETE FROM notes WHERE wiki_slug = ? AND slug GLOB ?`, [ 17 - wikiSlug, 18 - slugGlob, 19 - ]); 20 - }
+1 -2
tests/server/db/queries/note.test.ts
··· 13 13 searchNotes, 14 14 upsertNote, 15 15 } from "../../../../src/server/db/queries/index.ts"; 16 - import { ensureTestWiki } from "../../../helpers/cleanup.ts"; 17 - import { cleanupNotes } from "./helpers.ts"; 16 + import { cleanupNotes, ensureTestWiki } from "../../../helpers/cleanup.ts"; 18 17 19 18 const db = getDb(); 20 19 const TEST_DID = "did:plc:mock123";
+1 -2
tests/server/db/queries/revision.test.ts
··· 8 8 getSnapshots, 9 9 saveNoteEdit, 10 10 } from "../../../../src/server/db/queries/index.ts"; 11 - import { ensureTestWiki } from "../../../helpers/cleanup.ts"; 12 - import { cleanupNotes } from "./helpers.ts"; 11 + import { cleanupNotes, ensureTestWiki } from "../../../helpers/cleanup.ts"; 13 12 14 13 const db = getDb(); 15 14 const TEST_DID = "did:plc:mock123";