🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Refactor to better represent data flow

juprodh 604bd82d 00005584

+627 -444
+4 -15
src/firehose/handlers.ts
··· 1 1 import type { CommitEvt } from "@atproto/sync"; 2 2 import { didOwnsUri, parseAtUri } from "../lib/at-uri.ts"; 3 3 import { COLLECTIONS } from "../lib/constants.ts"; 4 - import { extractWikilinks } from "../lib/markdown.ts"; 5 4 import { 5 + applyRevisionFromFirehose, 6 6 deleteMembershipByUri, 7 7 deleteNoteByAtUri, 8 8 deleteRequestByUri, 9 9 deleteWikiByAtUri, 10 10 getNoteByAtUri, 11 11 getWikiByAtUri, 12 - insertBlob, 13 - insertRevisionFromFirehose, 14 - updateBacklinks, 15 12 upsertMembership, 16 13 upsertNote, 17 14 upsertRequest, ··· 141 138 const note = getNoteByAtUri(record.noteRef); 142 139 if (!note) return; 143 140 144 - const newContent = insertRevisionFromFirehose( 141 + applyRevisionFromFirehose( 145 142 note.at_uri, 143 + note.wiki_slug, 146 144 evt.did, 147 145 atUri, 148 146 record.parentRevision ?? null, 149 147 record.diff, 150 148 record.diffFormat, 151 149 record.message ?? null, 150 + record.blobs ?? [], 152 151 ); 153 - 154 - const wikiSlugs = extractWikilinks(newContent); 155 - updateBacklinks(note.at_uri, note.wiki_slug, wikiSlugs); 156 - 157 - if (record.blobs?.length) { 158 - const revisionAtUri = evt.uri.toString(); 159 - for (const blob of record.blobs) { 160 - insertBlob(blob.cid, revisionAtUri, blob.mimeType, blob.cid); 161 - } 162 - } 163 152 } 164 153 165 154 function handleMembership(evt: CommitEvt, record: MembershipRecord): void {
+28 -4
src/lib/access.ts
··· 3 3 getSessionFromRequest, 4 4 type Session, 5 5 } from "../atproto/session.ts"; 6 - import { getMemberRole, getWiki } from "../server/db/queries/index.ts"; 6 + import { 7 + getMemberRole, 8 + getRequest, 9 + getWiki, 10 + } from "../server/db/queries/index.ts"; 7 11 import type { WikiRow } from "../server/db/types.ts"; 8 12 import { ForbiddenError, NotFoundError } from "./errors.ts"; 9 13 import { type Locale, resolveLocale } from "./i18n/index.ts"; ··· 60 64 effectiveDid: string; 61 65 access: AccessLevel; 62 66 locale: Locale; 67 + /** true only when access is "none" and the user has a pending access request */ 68 + hasPendingRequest: boolean; 63 69 } 64 70 65 71 /** RequestContext with wiki guaranteed non-null. */ ··· 104 110 ); 105 111 106 112 if (!wikiSlug) { 107 - return { session, wiki: null, effectiveDid, access: "none", locale }; 113 + return { 114 + session, 115 + wiki: null, 116 + effectiveDid, 117 + access: "none", 118 + locale, 119 + hasPendingRequest: false, 120 + }; 108 121 } 109 122 110 123 const wiki = getWiki(wikiSlug); 111 124 if (!wiki) { 112 - return { session, wiki: null, effectiveDid, access: "none", locale }; 125 + return { 126 + session, 127 + wiki: null, 128 + effectiveDid, 129 + access: "none", 130 + locale, 131 + hasPendingRequest: false, 132 + }; 113 133 } 114 134 115 135 const role = getMemberRole(wiki.slug, effectiveDid); 116 136 const access = getAccessLevel(wiki, effectiveDid, role); 117 - return { session, wiki, effectiveDid, access, locale }; 137 + const hasPendingRequest = 138 + access === "none" && effectiveDid 139 + ? getRequest(wiki.slug, effectiveDid) !== null 140 + : false; 141 + return { session, wiki, effectiveDid, access, locale, hasPendingRequest }; 118 142 }
+7
src/lib/constants.ts
··· 7 7 bookmark: "pub.coral.bookmark", 8 8 } as const; 9 9 10 + export type MemberRole = "admin" | "contributor" | "viewer"; 11 + 12 + export function normalizeRole(raw: string | null | undefined): MemberRole { 13 + if (raw === "admin" || raw === "viewer") return raw; 14 + return "contributor"; 15 + } 16 + 10 17 export const VIZ_SCRIPTS = [ 11 18 "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", 12 19 "/public/viz/dist.js",
+17
src/lib/orchestrators/helpers.ts
··· 1 + import { formatError, PdsWriteError } from "../errors.ts"; 2 + 3 + /** 4 + * Run a PDS write operation, wrapping any error in a PdsWriteError. 5 + * Use for all PDS writes in orchestrators to avoid copy-pasted try/catch blocks. 6 + */ 7 + export async function withPdsError<T>( 8 + label: string, 9 + fn: () => Promise<T>, 10 + ): Promise<T> { 11 + try { 12 + return await fn(); 13 + } catch (err) { 14 + if (err instanceof PdsWriteError) throw err; 15 + throw new PdsWriteError(`${label}: ${formatError(err)}`); 16 + } 17 + }
+30 -47
src/lib/orchestrators/membership.ts
··· 13 13 } from "../../server/db/queries/index.ts"; 14 14 import type { WikiRequestContext } from "../access.ts"; 15 15 import { parseAtUri } from "../at-uri.ts"; 16 + import type { MemberRole } from "../constants.ts"; 16 17 import { COLLECTIONS } from "../constants.ts"; 17 18 import { 18 19 ForbiddenError, ··· 21 22 PdsWriteError, 22 23 ValidationError, 23 24 } from "../errors.ts"; 24 - import { generateTid } from "../tid.ts"; 25 + import { currentTimestamp, generateTid } from "../tid.ts"; 26 + import { withPdsError } from "./helpers.ts"; 25 27 26 28 /** 27 29 * Request access to a wiki. PDS write → DB write. ··· 34 36 throw new ForbiddenError("Login required"); 35 37 } 36 38 37 - const now = new Date().toISOString(); 39 + const now = currentTimestamp(); 38 40 const tid = generateTid(); 39 41 const atUri = `at://${ctx.session.did}/pub.coral.memberRequest/${tid}`; 40 42 41 - // PDS write 42 43 try { 43 44 const agent = await getAgent(ctx.session); 44 45 await writeMemberRequestRecord( ··· 54 55 ); 55 56 } 56 57 57 - // DB write 58 58 upsertRequest(ctx.wiki.slug, ctx.session.did, atUri, now); 59 59 } 60 60 ··· 65 65 export async function approveMemberAction( 66 66 ctx: WikiRequestContext, 67 67 memberDid: string, 68 - role: "admin" | "contributor" | "viewer" = "contributor", 68 + role: MemberRole = "contributor", 69 69 ): Promise<void> { 70 - const now = new Date().toISOString(); 70 + const now = currentTimestamp(); 71 71 const membershipTid = generateTid(); 72 72 const membershipAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${membershipTid}`; 73 73 74 - // PDS write first (skip in dev mode) 75 74 if (ctx.session) { 76 - try { 77 - const agent = await getAgent(ctx.session); 75 + const session = ctx.session; 76 + await withPdsError("approve membership", async () => { 77 + const agent = await getAgent(session); 78 78 await writeMembershipRecord( 79 79 agent, 80 - ctx.session.did, 80 + session.did, 81 81 membershipTid, 82 82 memberDid, 83 83 ctx.wiki.at_uri, 84 84 role, 85 85 now, 86 86 ); 87 - } catch (err) { 88 - throw new PdsWriteError( 89 - `Failed to write membership to PDS: ${formatError(err)}`, 90 - ); 91 - } 87 + }); 92 88 } 93 89 94 - // DB write 95 90 deleteRequest(ctx.wiki.slug, memberDid); 96 91 upsertMembership(ctx.wiki.slug, memberDid, role, membershipAtUri, now); 97 92 } ··· 105 100 export async function changeMemberRoleAction( 106 101 ctx: WikiRequestContext, 107 102 memberDid: string, 108 - newRole: "admin" | "contributor" | "viewer", 103 + newRole: MemberRole, 109 104 ): Promise<void> { 110 105 if (memberDid === ctx.wiki.did) { 111 106 throw new ValidationError("Cannot change the wiki owner's role"); ··· 120 115 const newAtUri = `at://${ctx.effectiveDid}/pub.coral.membership/${newTid}`; 121 116 122 117 if (ctx.session) { 118 + const session = ctx.session; 123 119 // Best-effort: delete old PDS record if we own it 124 120 const parsed = parseAtUri(existing.at_uri); 125 - if (parsed && parsed.did === ctx.session.did) { 121 + if (parsed && parsed.did === session.did) { 126 122 try { 127 - const agent = await getAgent(ctx.session); 123 + const agent = await getAgent(session); 128 124 await deleteRecord( 129 125 agent, 130 126 parsed.did, ··· 136 132 } 137 133 } 138 134 139 - // Write new PDS record with updated role 140 - try { 141 - const agent = await getAgent(ctx.session); 135 + await withPdsError("change member role", async () => { 136 + const agent = await getAgent(session); 142 137 await writeMembershipRecord( 143 138 agent, 144 - ctx.session.did, 139 + session.did, 145 140 newTid, 146 141 memberDid, 147 142 ctx.wiki.at_uri, 148 143 newRole, 149 144 existing.created_at, 150 145 ); 151 - } catch (err) { 152 - throw new PdsWriteError( 153 - `Failed to write membership to PDS: ${formatError(err)}`, 154 - ); 155 - } 146 + }); 156 147 } 157 148 158 149 upsertMembership( ··· 172 163 export async function addMemberAction( 173 164 ctx: WikiRequestContext, 174 165 memberDid: string, 175 - role: "admin" | "contributor" | "viewer" = "contributor", 166 + role: MemberRole = "contributor", 176 167 ): Promise<void> { 177 - const now = new Date().toISOString(); 168 + const now = currentTimestamp(); 178 169 const tid = generateTid(); 179 170 const atUri = `at://${ctx.effectiveDid}/pub.coral.membership/${tid}`; 180 171 181 172 if (ctx.session) { 182 - try { 183 - const agent = await getAgent(ctx.session); 173 + const session = ctx.session; 174 + await withPdsError("add member", async () => { 175 + const agent = await getAgent(session); 184 176 await writeMembershipRecord( 185 177 agent, 186 - ctx.session.did, 178 + session.did, 187 179 tid, 188 180 memberDid, 189 181 ctx.wiki.at_uri, 190 182 role, 191 183 now, 192 184 ); 193 - } catch (err) { 194 - throw new PdsWriteError( 195 - `Failed to write membership to PDS: ${formatError(err)}`, 196 - ); 197 - } 185 + }); 198 186 } 199 187 200 188 upsertMembership(ctx.wiki.slug, memberDid, role, atUri, now); ··· 214 202 throw new ValidationError("Cannot remove wiki owner"); 215 203 } 216 204 217 - // DB delete (returns at_uri for PDS cleanup) 218 205 const atUri = deleteMembership(ctx.wiki.slug, memberDid); 219 206 if (!atUri) { 220 207 throw new NotFoundError("Membership not found"); 221 208 } 222 209 223 - // PDS cleanup (skip in dev mode) 224 210 if (ctx.session) { 211 + const session = ctx.session; 225 212 const parsed = parseAtUri(atUri); 226 213 if (parsed) { 227 - try { 228 - const agent = await getAgent(ctx.session); 214 + await withPdsError("remove member", async () => { 215 + const agent = await getAgent(session); 229 216 await deleteRecord( 230 217 agent, 231 218 parsed.did, 232 219 COLLECTIONS.membership, 233 220 parsed.rkey, 234 221 ); 235 - } catch (err) { 236 - throw new PdsWriteError( 237 - `Failed to delete membership from PDS: ${formatError(err)}`, 238 - ); 239 - } 222 + }); 240 223 } 241 224 } 242 225 }
+63 -43
src/lib/orchestrators/note.ts
··· 14 14 persistBlobs, 15 15 } from "../blob.ts"; 16 16 import { createDiff } from "../diff.ts"; 17 - import { 18 - formatError, 19 - NotFoundError, 20 - PdsWriteError, 21 - ValidationError, 22 - } from "../errors.ts"; 17 + import { NotFoundError, ValidationError } from "../errors.ts"; 23 18 import type { Messages } from "../i18n/index.ts"; 24 19 import { validateNewNote } from "../note-validation.ts"; 25 - import { generateTid } from "../tid.ts"; 20 + import { currentTimestamp, generateTid } from "../tid.ts"; 21 + import { withPdsError } from "./helpers.ts"; 26 22 27 23 export interface NoteFormFields { 28 24 title: string; ··· 44 40 } 45 41 46 42 /** 43 + * Write a revision record to the PDS. Shared between create and edit. 44 + */ 45 + async function writePdsRevision( 46 + agent: Awaited<ReturnType<typeof getAgent>>, 47 + did: string, 48 + revisionTid: string, 49 + noteAtUri: string, 50 + parentRevisionUri: string | null, 51 + oldContent: string, 52 + newContent: string, 53 + message: string | undefined, 54 + blobs: ReturnType<typeof buildBlobsForContent>, 55 + ): Promise<void> { 56 + const diff = createDiff(oldContent, newContent); 57 + const now = currentTimestamp(); 58 + await writeRevisionRecord( 59 + agent, 60 + did, 61 + revisionTid, 62 + noteAtUri, 63 + parentRevisionUri, 64 + diff, 65 + message, 66 + now, 67 + blobs.length > 0 ? blobs : undefined, 68 + ); 69 + } 70 + 71 + /** 47 72 * Full lifecycle for creating a note: validate → PDS write → DB write. 48 73 * Throws ValidationError, PdsWriteError on failure. 49 74 * Returns the created note slug. ··· 61 86 const { noteSlug } = validation; 62 87 const blobs = buildBlobsForContent(fields.content, fields.blobMeta); 63 88 64 - // PDS write (skip in dev mode without session) 89 + // Generate TIDs once — shared between PDS and DB writes 90 + const noteTid = generateTid(); 91 + const revisionTid = generateTid(); 92 + const noteAtUri = `at://${ctx.effectiveDid}/pub.coral.note/${noteTid}`; 93 + const revisionAtUri = `at://${ctx.effectiveDid}/pub.coral.noteRevision/${revisionTid}`; 94 + 65 95 if (ctx.session) { 66 96 const agent = await getAgent(ctx.session); 67 - const now = new Date().toISOString(); 68 - const noteTid = generateTid(); 69 - const revisionTid = generateTid(); 70 - const noteAtUri = `at://${ctx.effectiveDid}/pub.coral.note/${noteTid}`; 71 - const diff = createDiff("", fields.content); 72 - 73 - try { 97 + const now = currentTimestamp(); 98 + await withPdsError("create note", async () => { 74 99 await writeNoteRecord( 75 100 agent, 76 101 ctx.effectiveDid, ··· 80 105 ctx.wiki.at_uri, 81 106 now, 82 107 ); 83 - await writeRevisionRecord( 108 + await writePdsRevision( 84 109 agent, 85 110 ctx.effectiveDid, 86 111 revisionTid, 87 112 noteAtUri, 88 113 null, 89 - diff, 114 + "", 115 + fields.content, 90 116 fields.message, 91 - now, 92 - blobs.length > 0 ? blobs : undefined, 117 + blobs, 93 118 ); 94 - } catch (err) { 95 - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); 96 - } 119 + }); 97 120 } 98 121 99 - // DB write 100 - const result = createNote( 122 + createNote( 123 + noteAtUri, 124 + revisionAtUri, 101 125 ctx.wiki.slug, 102 126 noteSlug, 103 127 fields.title, ··· 105 129 fields.content, 106 130 fields.message, 107 131 ); 108 - persistBlobs(blobs, result.revisionAtUri); 132 + persistBlobs(blobs, revisionAtUri); 109 133 110 134 return { noteSlug }; 111 135 } ··· 133 157 const blobs = buildBlobsForContent(fields.content, fields.blobMeta); 134 158 const currentNote = getCurrentNote(ctx.wiki.slug, noteSlug); 135 159 136 - // PDS write (skip in dev mode without session) 160 + // Generate revision TID once — shared between PDS and DB writes 161 + const revisionTid = generateTid(); 162 + const revisionAtUri = `at://${ctx.effectiveDid}/pub.coral.noteRevision/${revisionTid}`; 163 + const currentContent = currentNote?.content ?? ""; 164 + 137 165 if (ctx.session) { 138 166 const agent = await getAgent(ctx.session); 139 - const now = new Date().toISOString(); 140 - const currentContent = currentNote?.content ?? ""; 141 - const diff = createDiff(currentContent, fields.content); 142 - const revisionTid = generateTid(); 143 - 144 - try { 145 - await writeRevisionRecord( 167 + await withPdsError("edit note", async () => { 168 + await writePdsRevision( 146 169 agent, 147 170 ctx.effectiveDid, 148 171 revisionTid, 149 172 note.at_uri, 150 173 currentNote?.latest_revision_uri ?? null, 151 - diff, 174 + currentContent, 175 + fields.content, 152 176 fields.message, 153 - now, 154 - blobs.length > 0 ? blobs : undefined, 177 + blobs, 155 178 ); 156 179 157 180 if (newTitle) { ··· 169 192 note.created_at, 170 193 ); 171 194 } 172 - } catch (err) { 173 - if (err instanceof PdsWriteError) throw err; 174 - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); 175 - } 195 + }); 176 196 } 177 197 178 - // DB write 179 - const result = saveNoteEdit( 198 + saveNoteEdit( 199 + revisionAtUri, 180 200 ctx.wiki.slug, 181 201 noteSlug, 182 202 fields.content, ··· 184 204 fields.message, 185 205 newTitle, 186 206 ); 187 - persistBlobs(blobs, result.revisionAtUri); 207 + persistBlobs(blobs, revisionAtUri); 188 208 }
+17 -36
src/lib/orchestrators/wiki.ts
··· 18 18 import { parseAtUri } from "../at-uri.ts"; 19 19 import { COLLECTIONS } from "../constants.ts"; 20 20 import { createDiff } from "../diff.ts"; 21 - import { 22 - ForbiddenError, 23 - formatError, 24 - PdsWriteError, 25 - ValidationError, 26 - } from "../errors.ts"; 21 + import { ForbiddenError, ValidationError } from "../errors.ts"; 27 22 import type { Messages } from "../i18n/index.ts"; 28 23 import { fmt } from "../i18n/index.ts"; 29 24 import { isValidSlug, slugify } from "../slug.ts"; 30 - import { generateTid } from "../tid.ts"; 25 + import { currentTimestamp, generateTid } from "../tid.ts"; 26 + import { withPdsError } from "./helpers.ts"; 31 27 32 28 export interface WikiFormFields { 33 29 name: string; ··· 68 64 69 65 const validVisibility = 70 66 fields.visibility === "private" ? "private" : ("public" as const); 71 - const now = new Date().toISOString(); 67 + const now = currentTimestamp(); 72 68 const did = ctx.effectiveDid; 73 69 74 - // PDS write (skip in dev mode without session) 75 70 let atUri = `at://${did}/pub.coral.wiki/${slug}`; 76 71 const agent = ctx.session ? await getAgent(ctx.session) : null; 77 72 78 73 if (agent) { 79 - try { 74 + await withPdsError("create wiki", async () => { 80 75 const result = await writeWikiRecord( 81 76 agent, 82 77 did, ··· 87 82 fields.language, 88 83 ); 89 84 atUri = result.uri; 90 - } catch (err) { 91 - throw new PdsWriteError(`Failed to save to PDS: ${formatError(err)}`); 92 - } 85 + }); 93 86 } 94 87 95 - // DB write 96 88 upsertWiki( 97 89 slug, 98 90 did, ··· 109 101 upsertMembership(slug, did, "admin", membershipAtUri, now); 110 102 111 103 if (agent) { 112 - try { 104 + await withPdsError("create wiki membership", async () => { 113 105 await writeMembershipRecord( 114 106 agent, 115 107 did, ··· 119 111 "admin", 120 112 now, 121 113 ); 122 - } catch (err) { 123 - throw new PdsWriteError( 124 - `Failed to write membership to PDS: ${formatError(err)}`, 125 - ); 126 - } 114 + }); 127 115 } 128 116 129 - // Create home note: PDS write → DB write (same lifecycle as wiki creation) 117 + // Create home note — TIDs shared between PDS and DB writes 130 118 const homeContent = `# Welcome to ${fields.name}\n\nThis is the home page of your wiki. Edit it to get started.`; 119 + const noteTid = generateTid(); 120 + const revisionTid = generateTid(); 121 + const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; 122 + const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; 131 123 132 124 if (agent) { 133 - try { 134 - const noteTid = generateTid(); 135 - const revisionTid = generateTid(); 136 - const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; 137 - const diff = createDiff("", homeContent); 125 + const diff = createDiff("", homeContent); 126 + await withPdsError("create home note", async () => { 138 127 await writeNoteRecord(agent, did, noteTid, "home", "Home", atUri, now); 139 128 await writeRevisionRecord( 140 129 agent, ··· 146 135 undefined, 147 136 now, 148 137 ); 149 - } catch (err) { 150 - throw new PdsWriteError( 151 - `Failed to create home note on PDS: ${formatError(err)}`, 152 - ); 153 - } 138 + }); 154 139 } 155 140 156 - createNote(slug, "home", "Home", did, homeContent); 141 + createNote(noteAtUri, revisionAtUri, slug, "home", "Home", did, homeContent); 157 142 158 143 return { wikiSlug: slug }; 159 144 } ··· 168 153 throw new ForbiddenError("Only the wiki owner can delete this wiki"); 169 154 } 170 155 171 - // PDS cleanup (skip in dev mode) 172 156 if (ctx.session) { 173 157 const agent = await getAgent(ctx.session); 174 158 175 - // Delete wiki record 176 159 const wikiParsed = parseAtUri(ctx.wiki.at_uri); 177 160 if (wikiParsed) { 178 161 try { ··· 187 170 } 188 171 } 189 172 190 - // Delete membership records owned by this user 191 173 const members = listMembers(ctx.wiki.slug); 192 174 for (const m of members) { 193 175 const parsed = parseAtUri(m.at_uri); ··· 206 188 } 207 189 } 208 190 209 - // DB cascade delete 210 191 deleteWikiByAtUri(ctx.wiki.at_uri); 211 192 }
+4
src/lib/tid.ts
··· 1 1 const BASE32_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 2 2 3 + export function currentTimestamp(): string { 4 + return new Date().toISOString(); 5 + } 6 + 3 7 let lastTimestamp = 0; 4 8 5 9 export function generateTid(): string {
+3 -1
src/server/db/queries/index.ts
··· 24 24 upsertMembership, 25 25 upsertRequest, 26 26 } from "./membership.ts"; 27 + export type { NoteWithCurrent } from "./note.ts"; 27 28 export { 28 29 createNote, 29 30 deleteNoteByAtUri, 30 31 getCurrentNote, 31 32 getNoteByAtUri, 32 33 getNoteBySlug, 34 + getNoteWithCurrent, 33 35 getSidebarNotes, 34 36 listNotes, 35 37 saveNoteEdit, ··· 37 39 upsertNote, 38 40 } from "./note.ts"; 39 41 export { 42 + applyRevisionFromFirehose, 40 43 capSnapshots, 41 44 getBacklinks, 42 45 getSnapshots, 43 - insertRevisionFromFirehose, 44 46 updateBacklinks, 45 47 } from "./revision.ts"; 46 48 export {
+97 -61
src/server/db/queries/note.ts
··· 1 1 import { createDiff } from "../../../lib/diff.ts"; 2 2 import { extractWikilinks } from "../../../lib/markdown.ts"; 3 - import { generateTid } from "../../../lib/tid.ts"; 4 3 import { getDb } from "../index.ts"; 5 4 import type { CurrentNoteRow, NoteRow } from "../types.ts"; 6 - import { capSnapshots, updateBacklinks } from "./revision.ts"; 5 + import { capSnapshots } from "./revision.ts"; 6 + 7 + export interface NoteWithCurrent { 8 + note: NoteRow; 9 + current: CurrentNoteRow; 10 + } 7 11 8 12 export function getSidebarNotes( 9 13 wikiSlug: string, ··· 75 79 ); 76 80 } 77 81 82 + /** Fetch note + current content in one call. Returns null if either is missing. */ 83 + export function getNoteWithCurrent( 84 + wikiSlug: string, 85 + noteSlug: string, 86 + ): NoteWithCurrent | null { 87 + const note = getNoteBySlug(wikiSlug, noteSlug); 88 + if (!note) return null; 89 + const current = getCurrentNote(wikiSlug, noteSlug); 90 + if (!current) return null; 91 + return { note, current }; 92 + } 93 + 78 94 export function upsertNote( 79 95 wikiSlug: string, 80 96 slug: string, ··· 104 120 })(); 105 121 } 106 122 123 + /** 124 + * Append a revision to an existing note (revisions + current_note + backlinks + snapshot). 125 + * Must be called inside an outer db.transaction(). 126 + */ 127 + function appendRevisionTx( 128 + db: ReturnType<typeof getDb>, 129 + noteAtUri: string, 130 + wikiSlug: string, 131 + did: string, 132 + revisionAtUri: string, 133 + parentRevisionUri: string | null, 134 + oldContent: string, 135 + newContent: string, 136 + message: string | undefined, 137 + ): void { 138 + const diff = createDiff(oldContent, newContent); 139 + const slugs = extractWikilinks(newContent); 140 + 141 + db.run( 142 + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 143 + VALUES (?, ?, ?, ?, ?, ?)`, 144 + [noteAtUri, did, revisionAtUri, parentRevisionUri, diff, message ?? null], 145 + ); 146 + db.run( 147 + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 148 + VALUES (?, ?, ?, datetime('now')) 149 + ON CONFLICT(note_at_uri) DO UPDATE SET 150 + content = excluded.content, 151 + latest_revision_uri = excluded.latest_revision_uri, 152 + updated_at = excluded.updated_at`, 153 + [noteAtUri, newContent, revisionAtUri], 154 + ); 155 + // Inline backlinks update to stay within the outer transaction 156 + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); 157 + for (const slug of slugs) { 158 + db.run( 159 + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 160 + [noteAtUri, slug, wikiSlug], 161 + ); 162 + } 163 + db.run( 164 + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 165 + [noteAtUri, revisionAtUri, newContent], 166 + ); 167 + } 168 + 169 + /** 170 + * Create a new note with its first revision. 171 + * The caller is responsible for generating noteAtUri and revisionAtUri (e.g. from PDS write TIDs). 172 + */ 107 173 export function createNote( 174 + noteAtUri: string, 175 + revisionAtUri: string, 108 176 wikiSlug: string, 109 177 noteSlug: string, 110 178 title: string, 111 179 did: string, 112 180 initialContent: string, 113 181 message?: string, 114 - ): { noteAtUri: string; revisionAtUri: string } { 182 + ): void { 115 183 const db = getDb(); 116 - const noteTid = generateTid(); 117 - const revisionTid = generateTid(); 118 - const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; 119 - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; 120 - const diff = createDiff("", initialContent); 121 - const slugs = extractWikilinks(initialContent); 122 184 123 185 db.transaction(() => { 124 186 db.run( 125 187 "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 126 188 [noteSlug, wikiSlug, title, did, noteAtUri], 127 189 ); 128 - db.run( 129 - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 130 - VALUES (?, ?, ?, NULL, ?, ?)`, 131 - [noteAtUri, did, revisionAtUri, diff, message ?? null], 132 - ); 133 - db.run( 134 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 135 - VALUES (?, ?, ?, datetime('now'))`, 136 - [noteAtUri, initialContent, revisionAtUri], 137 - ); 138 - 139 - updateBacklinks(noteAtUri, wikiSlug, slugs); 140 - 141 - db.run( 142 - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 143 - [noteAtUri, revisionAtUri, initialContent], 190 + appendRevisionTx( 191 + db, 192 + noteAtUri, 193 + wikiSlug, 194 + did, 195 + revisionAtUri, 196 + null, 197 + "", 198 + initialContent, 199 + message, 144 200 ); 145 201 })(); 146 202 147 203 capSnapshots(noteAtUri); 148 - 149 - return { noteAtUri, revisionAtUri }; 150 204 } 151 205 206 + /** 207 + * Save an edit to an existing note. 208 + * The caller is responsible for generating revisionAtUri (e.g. from PDS write TIDs). 209 + */ 152 210 export function saveNoteEdit( 211 + revisionAtUri: string, 153 212 wikiSlug: string, 154 213 noteSlug: string, 155 214 newContent: string, 156 215 did: string, 157 216 message?: string, 158 217 newTitle?: string, 159 - ): { revisionAtUri: string } { 218 + ): void { 160 219 const db = getDb(); 161 220 162 221 const note = db ··· 175 234 176 235 const oldContent = current?.content ?? ""; 177 236 const parentRevisionUri = current?.latest_revision_uri ?? null; 178 - const diff = createDiff(oldContent, newContent); 179 - const revisionTid = generateTid(); 180 - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; 181 - const slugs = extractWikilinks(newContent); 182 237 183 238 db.transaction(() => { 184 239 if (newTitle) { ··· 188 243 noteSlug, 189 244 ]); 190 245 } 191 - db.run( 192 - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 193 - VALUES (?, ?, ?, ?, ?, ?)`, 194 - [ 195 - note.at_uri, 196 - did, 197 - revisionAtUri, 198 - parentRevisionUri, 199 - diff, 200 - message ?? null, 201 - ], 202 - ); 203 - db.run( 204 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 205 - VALUES (?, ?, ?, datetime('now')) 206 - ON CONFLICT(note_at_uri) DO UPDATE SET 207 - content = excluded.content, 208 - latest_revision_uri = excluded.latest_revision_uri, 209 - updated_at = excluded.updated_at`, 210 - [note.at_uri, newContent, revisionAtUri], 211 - ); 212 - 213 - updateBacklinks(note.at_uri, wikiSlug, slugs); 214 - 215 - db.run( 216 - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 217 - [note.at_uri, revisionAtUri, newContent], 246 + appendRevisionTx( 247 + db, 248 + note.at_uri, 249 + wikiSlug, 250 + did, 251 + revisionAtUri, 252 + parentRevisionUri, 253 + oldContent, 254 + newContent, 255 + message, 218 256 ); 219 257 })(); 220 258 221 259 capSnapshots(note.at_uri); 222 - 223 - return { revisionAtUri }; 224 260 }
+25 -4
src/server/db/queries/revision.ts
··· 1 1 import { applyDiff } from "../../../lib/diff.ts"; 2 + import { extractWikilinks } from "../../../lib/markdown.ts"; 2 3 import { getDb } from "../index.ts"; 3 4 import type { BacklinkRow, SnapshotRow } from "../types.ts"; 4 5 ··· 48 49 ); 49 50 } 50 51 51 - export function insertRevisionFromFirehose( 52 + /** 53 + * Apply a revision from the firehose in a single transaction: 54 + * applies the diff, updates current_note, appends a snapshot, and syncs backlinks. 55 + * Also persists any blob metadata attached to the revision. 56 + */ 57 + export function applyRevisionFromFirehose( 52 58 noteAtUri: string, 59 + wikiSlug: string, 53 60 did: string, 54 61 revisionAtUri: string, 55 62 parentRevisionUri: string | null, 56 63 diff: string, 57 64 diffFormat: string, 58 65 message: string | null, 59 - ): string { 66 + blobs: { cid: string; mimeType: string }[], 67 + ): void { 60 68 const db = getDb(); 61 69 62 70 const current = db ··· 65 73 66 74 const oldContent = current?.content ?? ""; 67 75 const newContent = applyDiff(oldContent, diff); 76 + const slugs = extractWikilinks(newContent); 68 77 69 78 db.transaction(() => { 70 79 db.run( ··· 93 102 "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 94 103 [noteAtUri, revisionAtUri, newContent], 95 104 ); 105 + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); 106 + for (const slug of slugs) { 107 + db.run( 108 + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 109 + [noteAtUri, slug, wikiSlug], 110 + ); 111 + } 112 + for (const blob of blobs) { 113 + db.run( 114 + `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) 115 + VALUES (?, ?, ?, ?)`, 116 + [blob.cid, revisionAtUri, blob.mimeType, blob.cid], 117 + ); 118 + } 96 119 })(); 97 120 98 121 capSnapshots(noteAtUri); 99 - 100 - return newContent; 101 122 }
+4 -9
src/server/routes/membership.ts
··· 4 4 resolveWikiContext, 5 5 type WikiRequestContext, 6 6 } from "../../lib/access.ts"; 7 + import { normalizeRole } from "../../lib/constants.ts"; 7 8 import { NotFoundError, ValidationError } from "../../lib/errors.ts"; 8 9 import { 9 10 addMemberAction, ··· 56 57 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 57 58 const memberDid = decodeURIComponent(params.memberDid); 58 59 const formData = await request.formData(); 59 - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; 60 - const role = 61 - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; 60 + const role = normalizeRole(formData.get("role") as string | null); 62 61 63 62 await approveMemberAction(ctx, memberDid, role); 64 63 ··· 82 81 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 83 82 const memberDid = decodeURIComponent(params.memberDid); 84 83 const formData = await request.formData(); 85 - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; 86 - const role = 87 - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; 84 + const role = normalizeRole(formData.get("role") as string | null); 88 85 89 86 await changeMemberRoleAction(ctx, memberDid, role); 90 87 ··· 95 92 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 96 93 const formData = await request.formData(); 97 94 const handleOrDid = ((formData.get("did") as string | null) ?? "").trim(); 98 - const roleRaw = (formData.get("role") as string | null) ?? "contributor"; 99 - const role = 100 - roleRaw === "admin" || roleRaw === "viewer" ? roleRaw : "contributor"; 95 + const role = normalizeRole(formData.get("role") as string | null); 101 96 102 97 if (!handleOrDid) { 103 98 throw new ValidationError("Handle or DID is required");
+12 -23
src/server/routes/note.ts
··· 14 14 import { editNotePage } from "../../views/edit-note.ts"; 15 15 import { newNotePage } from "../../views/new-note.ts"; 16 16 import { notePage } from "../../views/note.ts"; 17 - import { 18 - getCurrentNote, 19 - getNoteBySlug, 20 - getSidebarNotes, 21 - } from "../db/queries/index.ts"; 17 + import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; 22 18 23 19 export const noteRoutes = new Elysia({ prefix: "/wiki" }) 24 20 .get("/:wikiSlug/new", async ({ params, request }) => { ··· 66 62 .get("/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { 67 63 const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); 68 64 69 - const note = getNoteBySlug(params.wikiSlug, params.noteSlug); 70 - if (!note) { 71 - throw new NotFoundError("Note not found"); 72 - } 73 - 74 - const current = getCurrentNote(params.wikiSlug, params.noteSlug); 75 - if (!current) { 65 + const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); 66 + if (!data) { 76 67 throw new NotFoundError("Note not found"); 77 68 } 78 69 ··· 81 72 ctx.wiki.name, 82 73 params.wikiSlug, 83 74 params.noteSlug, 84 - note.title, 85 - current.content, 75 + data.note.title, 76 + data.current.content, 86 77 { 87 78 session: ctx.session, 88 79 scripts: EDITOR_SCRIPTS, ··· 105 96 .get("/:wikiSlug/:noteSlug", async ({ params, request }) => { 106 97 const ctx = await resolveWikiContext(request, params.wikiSlug, "read"); 107 98 108 - const note = getNoteBySlug(params.wikiSlug, params.noteSlug); 109 - if (!note) { 110 - throw new NotFoundError("Note not found"); 111 - } 112 - 113 - const current = getCurrentNote(params.wikiSlug, params.noteSlug); 114 - if (!current) { 99 + const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); 100 + if (!data) { 115 101 throw new NotFoundError("Note not found"); 116 102 } 117 103 118 - const { html, hasViz } = renderMarkdown(current.content, params.wikiSlug); 104 + const { html, hasViz } = renderMarkdown( 105 + data.current.content, 106 + params.wikiSlug, 107 + ); 119 108 const sidebarNotes = getSidebarNotes(params.wikiSlug); 120 109 return htmlResponse( 121 110 notePage( 122 111 ctx.wiki.name, 123 112 params.wikiSlug, 124 - note.title, 113 + data.note.title, 125 114 html, 126 115 { 127 116 scripts: hasViz ? VIZ_SCRIPTS : undefined,
+24 -35
src/server/routes/wiki.ts
··· 19 19 import { notePage } from "../../views/note.ts"; 20 20 import { settingsPage } from "../../views/settings.ts"; 21 21 import { wikiPage } from "../../views/wiki.ts"; 22 - import { 23 - getCurrentNote, 24 - getNoteBySlug, 25 - getRequest, 26 - getSidebarNotes, 27 - } from "../db/queries/index.ts"; 22 + import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; 28 23 29 24 export const wikiRoutes = new Elysia({ prefix: "/wiki" }) 30 25 .get("/new", async ({ request }) => { ··· 102 97 } 103 98 104 99 if (!canRead(ctx.access)) { 105 - const hasPendingRequest = ctx.session?.did 106 - ? getRequest(ctx.wiki.slug, ctx.session.did) !== null 107 - : false; 108 100 return htmlResponse( 109 101 accessDeniedPage(ctx.wiki.name, params.wikiSlug, { 110 102 session: ctx.session, 111 103 locale: ctx.locale, 112 - hasPendingRequest, 104 + hasPendingRequest: ctx.hasPendingRequest, 113 105 }), 114 106 403, 115 107 ); 116 108 } 117 109 118 110 const sidebarNotes = getSidebarNotes(params.wikiSlug); 111 + const homeData = getNoteWithCurrent(params.wikiSlug, "home"); 119 112 120 - const homeNote = getNoteBySlug(params.wikiSlug, "home"); 121 - if (homeNote) { 122 - const current = getCurrentNote(params.wikiSlug, "home"); 123 - if (current) { 124 - const { html, hasViz } = renderMarkdown( 125 - current.content, 113 + if (homeData) { 114 + const { html, hasViz } = renderMarkdown( 115 + homeData.current.content, 116 + params.wikiSlug, 117 + ); 118 + return htmlResponse( 119 + notePage( 120 + ctx.wiki.name, 126 121 params.wikiSlug, 127 - ); 128 - return htmlResponse( 129 - notePage( 130 - ctx.wiki.name, 131 - params.wikiSlug, 132 - homeNote.title, 133 - html, 134 - { 135 - scripts: hasViz ? VIZ_SCRIPTS : undefined, 136 - session: ctx.session, 137 - sidebarNotes, 138 - currentNoteSlug: "home", 139 - locale: ctx.locale, 140 - accessLevel: ctx.access, 141 - }, 142 - ctx.wiki.language, 143 - ), 144 - ); 145 - } 122 + homeData.note.title, 123 + html, 124 + { 125 + scripts: hasViz ? VIZ_SCRIPTS : undefined, 126 + session: ctx.session, 127 + sidebarNotes, 128 + currentNoteSlug: "home", 129 + locale: ctx.locale, 130 + accessLevel: ctx.access, 131 + }, 132 + ctx.wiki.language, 133 + ), 134 + ); 146 135 } 147 136 148 137 return htmlResponse(
+1
tests/lib/orchestrators/membership.test.ts
··· 66 66 effectiveDid: ADMIN_DID, 67 67 access: "admin", 68 68 locale: "en", 69 + hasPendingRequest: false, 69 70 ...overrides, 70 71 }; 71 72 }
+1
tests/lib/orchestrators/note.test.ts
··· 59 59 effectiveDid: WIKI_DID, 60 60 access: "admin", 61 61 locale: "en", 62 + hasPendingRequest: false, 62 63 ...overrides, 63 64 }; 64 65 }
+2
tests/lib/orchestrators/wiki.test.ts
··· 69 69 effectiveDid: TEST_DID, 70 70 access: "none", 71 71 locale: "en", 72 + hasPendingRequest: false, 72 73 ...overrides, 73 74 }; 74 75 } ··· 83 84 effectiveDid: TEST_DID, 84 85 access: "admin", 85 86 locale: "en", 87 + hasPendingRequest: false, 86 88 ...overrides, 87 89 }; 88 90 }
+204 -118
tests/server/db/queries/note.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 2 import { applyDiff } from "../../../../src/lib/diff.ts"; 3 + import { generateTid } from "../../../../src/lib/tid.ts"; 3 4 import { getDb } from "../../../../src/server/db/index.ts"; 4 5 import { 5 6 createNote, ··· 16 17 17 18 const db = getDb(); 18 19 const TEST_DID = "did:plc:mock123"; 20 + 21 + const noteUri = () => `at://${TEST_DID}/pub.coral.note/${generateTid()}`; 22 + const revUri = () => `at://${TEST_DID}/pub.coral.noteRevision/${generateTid()}`; 19 23 20 24 beforeAll(() => { 21 25 cleanupNotes("test", "write-test-*"); ··· 72 76 }); 73 77 74 78 test("returns null for nonexistent note", () => { 75 - const current = getCurrentNote("test", "nonexistent"); 76 - expect(current).toBeNull(); 79 + expect(getCurrentNote("test", "nonexistent")).toBeNull(); 77 80 }); 78 81 79 82 test("returns null for nonexistent wiki", () => { 80 - const current = getCurrentNote("nonexistent", "hello"); 81 - expect(current).toBeNull(); 83 + expect(getCurrentNote("nonexistent", "hello")).toBeNull(); 82 84 }); 83 85 84 86 test("returns different content for different notes", () => { 85 87 const hello = getCurrentNote("test", "hello"); 86 88 const gettingStarted = getCurrentNote("test", "getting-started"); 87 - expect(hello).not.toBeNull(); 88 - expect(gettingStarted).not.toBeNull(); 89 89 expect(hello?.content).not.toBe(gettingStarted?.content); 90 90 }); 91 91 92 92 test("home note is accessible", () => { 93 93 const home = getCurrentNote("test", "home"); 94 - expect(home).not.toBeNull(); 95 94 expect(home?.content).toContain("# Welcome to the Test Wiki"); 96 95 }); 97 96 }); ··· 106 105 }); 107 106 108 107 test("returns null for missing slug", () => { 109 - const note = getNoteBySlug("test", "nonexistent-slug"); 110 - expect(note).toBeNull(); 108 + expect(getNoteBySlug("test", "nonexistent-slug")).toBeNull(); 111 109 }); 112 110 113 111 test("returns null for wrong wiki", () => { 114 - const note = getNoteBySlug("nonexistent", "hello"); 115 - expect(note).toBeNull(); 112 + expect(getNoteBySlug("nonexistent", "hello")).toBeNull(); 116 113 }); 117 114 }); 118 115 ··· 120 117 const INITIAL_CONTENT = "# New Note\n\nSome content here."; 121 118 122 119 test("inserts rows into notes, revisions, current_note", () => { 123 - const { noteAtUri, revisionAtUri } = createNote( 120 + const nUri = noteUri(); 121 + const rUri = revUri(); 122 + createNote( 123 + nUri, 124 + rUri, 124 125 "test", 125 126 "write-test-create", 126 127 "Write Test Create", ··· 128 129 INITIAL_CONTENT, 129 130 ); 130 131 131 - const note = getNoteBySlug("test", "write-test-create"); 132 - expect(note).not.toBeNull(); 133 - expect(note?.title).toBe("Write Test Create"); 134 - 135 - const current = getCurrentNote("test", "write-test-create"); 136 - expect(current).not.toBeNull(); 137 - expect(current?.content).toBe(INITIAL_CONTENT); 132 + expect(getNoteBySlug("test", "write-test-create")?.title).toBe( 133 + "Write Test Create", 134 + ); 135 + expect(getCurrentNote("test", "write-test-create")?.content).toBe( 136 + INITIAL_CONTENT, 137 + ); 138 138 139 139 const revision = db 140 140 .query("SELECT * FROM revisions WHERE note_at_uri = ?") 141 - .get(noteAtUri) as { 142 - diff: string; 143 - parent_revision_uri: string | null; 144 - } | null; 145 - expect(revision).not.toBeNull(); 141 + .get(nUri) as { parent_revision_uri: string | null } | null; 146 142 expect(revision?.parent_revision_uri).toBeNull(); 147 - 148 - expect(revisionAtUri).toBeDefined(); 149 143 }); 150 144 151 145 test("first revision diffs from empty string", () => { 152 - const { noteAtUri } = createNote( 146 + const nUri = noteUri(); 147 + createNote( 148 + nUri, 149 + revUri(), 153 150 "test", 154 151 "write-test-diff-check", 155 152 "Diff Check", ··· 159 156 160 157 const revision = db 161 158 .query("SELECT diff FROM revisions WHERE note_at_uri = ?") 162 - .get(noteAtUri) as { diff: string } | null; 163 - expect(revision).not.toBeNull(); 159 + .get(nUri) as { diff: string } | null; 164 160 expect(applyDiff("", revision?.diff ?? "")).toBe(INITIAL_CONTENT); 165 161 }); 166 162 167 163 test("noteAtUri uses TID rkey", () => { 168 - const { noteAtUri } = createNote( 164 + const nUri = noteUri(); 165 + createNote( 166 + nUri, 167 + revUri(), 169 168 "test", 170 169 "write-test-uri-note", 171 170 "URI Note", 172 171 TEST_DID, 173 172 "content", 174 173 ); 175 - expect(noteAtUri).toMatch( 174 + expect(nUri).toMatch( 176 175 /^at:\/\/did:plc:mock123\/pub\.coral\.note\/[a-z2-7]{13}$/, 177 176 ); 178 177 }); 179 178 180 179 test("revisionAtUri uses TID rkey", () => { 181 - const { revisionAtUri } = createNote( 180 + const rUri = revUri(); 181 + createNote( 182 + noteUri(), 183 + rUri, 182 184 "test", 183 185 "write-test-uri-rev", 184 186 "URI Rev", 185 187 TEST_DID, 186 188 "content", 187 189 ); 188 - expect(revisionAtUri).toMatch( 190 + expect(rUri).toMatch( 189 191 /^at:\/\/did:plc:mock123\/pub\.coral\.noteRevision\/[a-z2-7]{13}$/, 190 192 ); 191 193 }); 192 194 195 + test("snapshot revision_at_uri matches provided revisionAtUri", () => { 196 + const nUri = noteUri(); 197 + const rUri = revUri(); 198 + createNote( 199 + nUri, 200 + rUri, 201 + "test", 202 + "write-test-snap-uri", 203 + "Snap URI", 204 + TEST_DID, 205 + "content", 206 + ); 207 + const snap = db 208 + .query("SELECT revision_at_uri FROM snapshots WHERE note_at_uri = ?") 209 + .get(nUri) as { revision_at_uri: string } | null; 210 + expect(snap?.revision_at_uri).toBe(rUri); 211 + }); 212 + 193 213 test("throws on duplicate (wiki_slug, slug)", () => { 194 - createNote("test", "write-test-dup", "Dup", TEST_DID, "first"); 214 + createNote( 215 + noteUri(), 216 + revUri(), 217 + "test", 218 + "write-test-dup", 219 + "Dup", 220 + TEST_DID, 221 + "first", 222 + ); 195 223 expect(() => 196 - createNote("test", "write-test-dup", "Dup", TEST_DID, "second"), 224 + createNote( 225 + noteUri(), 226 + revUri(), 227 + "test", 228 + "write-test-dup", 229 + "Dup", 230 + TEST_DID, 231 + "second", 232 + ), 197 233 ).toThrow(); 198 234 }); 199 235 }); 200 236 201 237 describe("saveNoteEdit", () => { 202 238 test("creates a revision row with correct note_at_uri", () => { 239 + const nUri = noteUri(); 203 240 createNote( 241 + nUri, 242 + revUri(), 204 243 "test", 205 244 "edit-test-rev-row", 206 245 "Rev Row", 207 246 TEST_DID, 208 247 "original content", 209 248 ); 210 - const { revisionAtUri } = saveNoteEdit( 249 + 250 + const rUri = revUri(); 251 + saveNoteEdit( 252 + rUri, 211 253 "test", 212 254 "edit-test-rev-row", 213 255 "updated content", 214 256 TEST_DID, 215 257 ); 216 258 217 - const note = getNoteBySlug("test", "edit-test-rev-row"); 218 259 const revision = db 219 260 .query("SELECT * FROM revisions WHERE at_uri = ?") 220 - .get(revisionAtUri) as { note_at_uri: string } | null; 221 - expect(revision).not.toBeNull(); 222 - expect(revision?.note_at_uri).toBe(note?.at_uri); 261 + .get(rUri) as { note_at_uri: string } | null; 262 + expect(revision?.note_at_uri).toBe(nUri); 223 263 }); 224 264 225 265 test("applyDiff(oldContent, diff) equals new content", () => { 226 - createNote("test", "edit-test-diff", "Diff", TEST_DID, "old content"); 227 - const { revisionAtUri } = saveNoteEdit( 266 + createNote( 267 + noteUri(), 268 + revUri(), 269 + "test", 270 + "edit-test-diff", 271 + "Diff", 272 + TEST_DID, 273 + "old content", 274 + ); 275 + 276 + const rUri = revUri(); 277 + saveNoteEdit( 278 + rUri, 228 279 "test", 229 280 "edit-test-diff", 230 281 "new content after edit", ··· 233 284 234 285 const revision = db 235 286 .query("SELECT diff FROM revisions WHERE at_uri = ?") 236 - .get(revisionAtUri) as { diff: string } | null; 237 - expect(revision).not.toBeNull(); 287 + .get(rUri) as { diff: string } | null; 238 288 expect(applyDiff("old content", revision?.diff ?? "")).toBe( 239 289 "new content after edit", 240 290 ); 241 291 }); 242 292 243 293 test("current_note.content equals new content after save", () => { 244 - createNote("test", "edit-test-current", "Current", TEST_DID, "original"); 245 - saveNoteEdit("test", "edit-test-current", "edited content", TEST_DID); 246 - 247 - const current = getCurrentNote("test", "edit-test-current"); 248 - expect(current?.content).toBe("edited content"); 249 - }); 250 - 251 - test("parent of second revision equals first revision at_uri", () => { 252 - createNote("test", "edit-test-chain", "Chain", TEST_DID, "v1"); 253 - const { revisionAtUri: rev1Uri } = saveNoteEdit( 294 + createNote( 295 + noteUri(), 296 + revUri(), 254 297 "test", 255 - "edit-test-chain", 256 - "v2", 298 + "edit-test-current", 299 + "Current", 257 300 TEST_DID, 301 + "original", 258 302 ); 259 - const { revisionAtUri: rev2Uri } = saveNoteEdit( 303 + saveNoteEdit( 304 + revUri(), 305 + "test", 306 + "edit-test-current", 307 + "edited content", 308 + TEST_DID, 309 + ); 310 + expect(getCurrentNote("test", "edit-test-current")?.content).toBe( 311 + "edited content", 312 + ); 313 + }); 314 + 315 + test("parent of second revision equals first revision at_uri", () => { 316 + createNote( 317 + noteUri(), 318 + revUri(), 260 319 "test", 261 320 "edit-test-chain", 262 - "v3", 321 + "Chain", 263 322 TEST_DID, 323 + "v1", 264 324 ); 325 + const rev1Uri = revUri(); 326 + saveNoteEdit(rev1Uri, "test", "edit-test-chain", "v2", TEST_DID); 327 + const rev2Uri = revUri(); 328 + saveNoteEdit(rev2Uri, "test", "edit-test-chain", "v3", TEST_DID); 265 329 266 330 const rev2 = db 267 331 .query("SELECT parent_revision_uri FROM revisions WHERE at_uri = ?") ··· 270 334 }); 271 335 272 336 test("second edit: current_note reflects final content", () => { 273 - createNote("test", "edit-test-final", "Final", TEST_DID, "v1"); 274 - saveNoteEdit("test", "edit-test-final", "v2", TEST_DID); 275 - saveNoteEdit("test", "edit-test-final", "v3 final", TEST_DID); 276 - 277 - const current = getCurrentNote("test", "edit-test-final"); 278 - expect(current?.content).toBe("v3 final"); 337 + createNote( 338 + noteUri(), 339 + revUri(), 340 + "test", 341 + "edit-test-final", 342 + "Final", 343 + TEST_DID, 344 + "v1", 345 + ); 346 + saveNoteEdit(revUri(), "test", "edit-test-final", "v2", TEST_DID); 347 + saveNoteEdit(revUri(), "test", "edit-test-final", "v3 final", TEST_DID); 348 + expect(getCurrentNote("test", "edit-test-final")?.content).toBe("v3 final"); 279 349 }); 280 350 281 351 test("optional message is stored on revision row", () => { 282 - createNote("test", "edit-test-msg", "Msg", TEST_DID, "content"); 283 - const { revisionAtUri } = saveNoteEdit( 352 + createNote( 353 + noteUri(), 354 + revUri(), 355 + "test", 356 + "edit-test-msg", 357 + "Msg", 358 + TEST_DID, 359 + "content", 360 + ); 361 + const rUri = revUri(); 362 + saveNoteEdit( 363 + rUri, 284 364 "test", 285 365 "edit-test-msg", 286 366 "updated", ··· 290 370 291 371 const revision = db 292 372 .query("SELECT message FROM revisions WHERE at_uri = ?") 293 - .get(revisionAtUri) as { message: string | null } | null; 373 + .get(rUri) as { message: string | null } | null; 294 374 expect(revision?.message).toBe("my edit summary"); 295 375 }); 296 376 297 377 test("throws for nonexistent note slug", () => { 298 378 expect(() => 299 - saveNoteEdit("test", "does-not-exist-xyz", "content", TEST_DID), 379 + saveNoteEdit(revUri(), "test", "does-not-exist-xyz", "content", TEST_DID), 300 380 ).toThrow(); 301 381 }); 302 382 303 - test("revisionAtUri uses TID rkey", () => { 304 - createNote("test", "edit-test-tid", "TID", TEST_DID, "content"); 305 - const { revisionAtUri } = saveNoteEdit( 383 + test("revision at_uri in DB matches the provided uri", () => { 384 + createNote( 385 + noteUri(), 386 + revUri(), 306 387 "test", 307 388 "edit-test-tid", 308 - "updated", 389 + "TID", 309 390 TEST_DID, 391 + "content", 310 392 ); 311 - expect(revisionAtUri).toMatch( 393 + const rUri = revUri(); 394 + saveNoteEdit(rUri, "test", "edit-test-tid", "updated", TEST_DID); 395 + expect(rUri).toMatch( 312 396 /^at:\/\/did:plc:mock123\/pub\.coral\.noteRevision\/[a-z2-7]{13}$/, 313 397 ); 314 398 }); ··· 317 401 describe("searchNotes", () => { 318 402 test("finds notes matching title", () => { 319 403 createNote( 404 + noteUri(), 405 + revUri(), 320 406 "test", 321 407 "search-test-alpha", 322 408 "Search Alpha", ··· 329 415 }); 330 416 331 417 test("case-insensitive matching", () => { 332 - createNote("test", "search-test-beta", "Search Beta", TEST_DID, "content"); 418 + createNote( 419 + noteUri(), 420 + revUri(), 421 + "test", 422 + "search-test-beta", 423 + "Search Beta", 424 + TEST_DID, 425 + "content", 426 + ); 333 427 const results = searchNotes("search beta"); 334 428 expect(results.some((n) => n.slug === "search-test-beta")).toBe(true); 335 429 }); 336 430 337 431 test("filters by wikiSlug when provided", () => { 338 432 createNote( 433 + noteUri(), 434 + revUri(), 339 435 "test", 340 436 "search-test-gamma", 341 437 "Search Gamma", 342 438 TEST_DID, 343 439 "content", 344 440 ); 345 - const withFilter = searchNotes("Gamma", "test"); 346 - expect(withFilter.some((n) => n.slug === "search-test-gamma")).toBe(true); 347 - 348 - const wrongWiki = searchNotes("Gamma", "nonexistent-wiki"); 349 - expect(wrongWiki).toEqual([]); 441 + expect( 442 + searchNotes("Gamma", "test").some((n) => n.slug === "search-test-gamma"), 443 + ).toBe(true); 444 + expect(searchNotes("Gamma", "nonexistent-wiki")).toEqual([]); 350 445 }); 351 446 352 447 test("returns empty for no match", () => { 353 - const results = searchNotes("zzz-nonexistent-note-zzz"); 354 - expect(results).toEqual([]); 448 + expect(searchNotes("zzz-nonexistent-note-zzz")).toEqual([]); 355 449 }); 356 450 }); 357 451 358 452 describe("getNoteByAtUri", () => { 359 453 test("returns note by AT URI", () => { 360 - const { noteAtUri } = createNote( 454 + const nUri = noteUri(); 455 + createNote( 456 + nUri, 457 + revUri(), 361 458 "test", 362 459 "upsert-test-byuri", 363 460 "By URI", 364 461 TEST_DID, 365 462 "content", 366 463 ); 367 - const note = getNoteByAtUri(noteAtUri); 368 - expect(note).not.toBeNull(); 464 + const note = getNoteByAtUri(nUri); 369 465 expect(note?.slug).toBe("upsert-test-byuri"); 370 466 expect(note?.wiki_slug).toBe("test"); 371 467 }); 372 468 373 469 test("returns null for nonexistent AT URI", () => { 374 - const note = getNoteByAtUri("at://did:plc:fake/pub.coral.note/nonexistent"); 375 - expect(note).toBeNull(); 470 + expect( 471 + getNoteByAtUri("at://did:plc:fake/pub.coral.note/nonexistent"), 472 + ).toBeNull(); 376 473 }); 377 474 }); 378 475 ··· 383 480 "upsert-test-new", 384 481 "Upsert New", 385 482 TEST_DID, 386 - "at://did:plc:mock123/pub.coral.note/upsert1", 483 + noteUri(), 387 484 "2026-01-01T00:00:00.000Z", 388 485 ); 389 - 390 - const note = getNoteBySlug("test", "upsert-test-new"); 391 - expect(note).not.toBeNull(); 392 - expect(note?.title).toBe("Upsert New"); 486 + expect(getNoteBySlug("test", "upsert-test-new")?.title).toBe("Upsert New"); 393 487 }); 394 488 395 489 test("updates title on conflict", () => { 490 + const uri = noteUri(); 396 491 upsertNote( 397 492 "test", 398 493 "upsert-test-conflict", 399 494 "Original", 400 495 TEST_DID, 401 - "at://did:plc:mock123/pub.coral.note/upsert2", 496 + uri, 402 497 "2026-01-01T00:00:00.000Z", 403 498 ); 404 499 upsertNote( ··· 406 501 "upsert-test-conflict", 407 502 "Updated Title", 408 503 TEST_DID, 409 - "at://did:plc:mock123/pub.coral.note/upsert2", 504 + uri, 410 505 "2026-01-01T00:00:00.000Z", 411 506 ); 412 - 413 - const note = getNoteBySlug("test", "upsert-test-conflict"); 414 - expect(note?.title).toBe("Updated Title"); 507 + expect(getNoteBySlug("test", "upsert-test-conflict")?.title).toBe( 508 + "Updated Title", 509 + ); 415 510 }); 416 511 }); 417 512 418 513 describe("deleteNoteByAtUri", () => { 419 514 test("deletes note and cascading rows", () => { 420 - const { noteAtUri } = createNote( 515 + const nUri = noteUri(); 516 + createNote( 517 + nUri, 518 + revUri(), 421 519 "test", 422 520 "delete-test-note", 423 521 "Delete Me", ··· 425 523 "content to delete", 426 524 ); 427 525 428 - // Verify it exists 429 - expect(getNoteByAtUri(noteAtUri)).not.toBeNull(); 430 - expect(getCurrentNote("test", "delete-test-note")).not.toBeNull(); 431 - 432 - deleteNoteByAtUri(noteAtUri); 433 - 434 - // Note and current_note gone 435 - expect(getNoteByAtUri(noteAtUri)).toBeNull(); 526 + expect(getNoteByAtUri(nUri)).not.toBeNull(); 527 + deleteNoteByAtUri(nUri); 528 + expect(getNoteByAtUri(nUri)).toBeNull(); 436 529 expect(getCurrentNote("test", "delete-test-note")).toBeNull(); 437 - 438 - // Revisions gone 439 - const revisions = db 440 - .query("SELECT * FROM revisions WHERE note_at_uri = ?") 441 - .all(noteAtUri); 442 - expect(revisions).toEqual([]); 443 - 444 - // Snapshots gone 445 - const snapshots = db 446 - .query("SELECT * FROM snapshots WHERE note_at_uri = ?") 447 - .all(noteAtUri); 448 - expect(snapshots).toEqual([]); 530 + expect( 531 + db.query("SELECT * FROM revisions WHERE note_at_uri = ?").all(nUri), 532 + ).toEqual([]); 533 + expect( 534 + db.query("SELECT * FROM snapshots WHERE note_at_uri = ?").all(nUri), 535 + ).toEqual([]); 449 536 }); 450 537 451 538 test("no-op for nonexistent AT URI", () => { 452 - // Should not throw 453 539 deleteNoteByAtUri("at://did:plc:fake/pub.coral.note/nope"); 454 540 }); 455 541 });
+84 -48
tests/server/db/queries/revision.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { generateTid } from "../../../../src/lib/tid.ts"; 2 3 import { getDb } from "../../../../src/server/db/index.ts"; 3 4 import { 4 5 capSnapshots, ··· 12 13 const db = getDb(); 13 14 const TEST_DID = "did:plc:mock123"; 14 15 16 + const noteUri = () => `at://${TEST_DID}/pub.coral.note/${generateTid()}`; 17 + const revUri = () => `at://${TEST_DID}/pub.coral.noteRevision/${generateTid()}`; 18 + 15 19 beforeAll(() => { 16 20 cleanupNotes("test", "backlink-test-*"); 17 21 cleanupNotes("test", "snapshot-test-*"); ··· 24 28 25 29 describe("backlinks", () => { 26 30 test("createNote populates backlinks from wikilinks", () => { 27 - const { noteAtUri } = createNote( 31 + const nUri = noteUri(); 32 + createNote( 33 + nUri, 34 + revUri(), 28 35 "test", 29 36 "backlink-test-create", 30 37 "Backlink Create", ··· 32 39 "Link to [[target-a]] and [[target-b]]", 33 40 ); 34 41 const links = getBacklinks("test", "target-a"); 35 - const sourceUris = links.map((l) => l.source_note_uri); 36 - expect(sourceUris).toContain(noteAtUri); 42 + expect(links.map((l) => l.source_note_uri)).toContain(nUri); 37 43 }); 38 44 39 45 test("createNote with no wikilinks produces no backlinks", () => { 40 - const { noteAtUri } = createNote( 46 + const nUri = noteUri(); 47 + createNote( 48 + nUri, 49 + revUri(), 41 50 "test", 42 51 "backlink-test-nolinks", 43 52 "No Links", 44 53 TEST_DID, 45 54 "No links here", 46 55 ); 47 - const all = db 48 - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") 49 - .all(noteAtUri); 50 - expect(all).toEqual([]); 56 + expect( 57 + db.query("SELECT * FROM backlinks WHERE source_note_uri = ?").all(nUri), 58 + ).toEqual([]); 51 59 }); 52 60 53 61 test("getBacklinks returns rows for target slug", () => { 54 62 createNote( 63 + noteUri(), 64 + revUri(), 55 65 "test", 56 66 "backlink-test-read", 57 67 "Read", ··· 64 74 }); 65 75 66 76 test("saveNoteEdit updates backlinks", () => { 67 - const { noteAtUri } = createNote( 77 + const nUri = noteUri(); 78 + createNote( 79 + nUri, 80 + revUri(), 68 81 "test", 69 82 "backlink-test-edit", 70 83 "Edit", ··· 72 85 "Link to [[old-target]]", 73 86 ); 74 87 saveNoteEdit( 88 + revUri(), 75 89 "test", 76 90 "backlink-test-edit", 77 91 "Now links to [[new-target]]", 78 92 TEST_DID, 79 93 ); 80 94 81 - const links = db 82 - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") 83 - .all(noteAtUri) as { target_note_slug: string }[]; 84 - const targets = links.map((l) => l.target_note_slug); 95 + const targets = ( 96 + db 97 + .query("SELECT * FROM backlinks WHERE source_note_uri = ?") 98 + .all(nUri) as { target_note_slug: string }[] 99 + ).map((l) => l.target_note_slug); 85 100 expect(targets).toContain("new-target"); 86 101 expect(targets).not.toContain("old-target"); 87 102 }); 88 103 89 104 test("saveNoteEdit clears backlinks when wikilinks removed", () => { 90 - const { noteAtUri } = createNote( 105 + const nUri = noteUri(); 106 + createNote( 107 + nUri, 108 + revUri(), 91 109 "test", 92 110 "backlink-test-clear", 93 111 "Clear", 94 112 TEST_DID, 95 113 "Link to [[will-remove]]", 96 114 ); 97 - saveNoteEdit("test", "backlink-test-clear", "No more links", TEST_DID); 98 - 99 - const links = db 100 - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") 101 - .all(noteAtUri); 102 - expect(links).toEqual([]); 115 + saveNoteEdit( 116 + revUri(), 117 + "test", 118 + "backlink-test-clear", 119 + "No more links", 120 + TEST_DID, 121 + ); 122 + expect( 123 + db.query("SELECT * FROM backlinks WHERE source_note_uri = ?").all(nUri), 124 + ).toEqual([]); 103 125 }); 104 126 105 127 test("backlinks have correct wiki_slug", () => { 106 128 createNote( 129 + noteUri(), 130 + revUri(), 107 131 "test", 108 132 "backlink-test-wslug", 109 133 "WSlug", 110 134 TEST_DID, 111 135 "Link to [[check-slug]]", 112 136 ); 113 - const links = getBacklinks("test", "check-slug"); 114 - for (const link of links) { 137 + for (const link of getBacklinks("test", "check-slug")) { 115 138 expect(link.wiki_slug).toBe("test"); 116 139 } 117 140 }); ··· 119 142 120 143 describe("snapshots", () => { 121 144 test("createNote creates a snapshot", () => { 122 - const { noteAtUri } = createNote( 145 + const nUri = noteUri(); 146 + createNote( 147 + nUri, 148 + revUri(), 123 149 "test", 124 150 "snapshot-test-create", 125 151 "Snap Create", 126 152 TEST_DID, 127 153 "Initial content", 128 154 ); 129 - const snaps = getSnapshots(noteAtUri); 155 + const snaps = getSnapshots(nUri); 130 156 expect(snaps.length).toBe(1); 131 157 expect(snaps[0]?.content).toBe("Initial content"); 132 158 }); 133 159 134 - test("snapshot revision_at_uri matches the revision", () => { 135 - const { noteAtUri, revisionAtUri } = createNote( 160 + test("snapshot revision_at_uri matches provided revisionAtUri", () => { 161 + const nUri = noteUri(); 162 + const rUri = revUri(); 163 + createNote( 164 + nUri, 165 + rUri, 136 166 "test", 137 167 "snapshot-test-revuri", 138 168 "Snap Rev", 139 169 TEST_DID, 140 170 "Content", 141 171 ); 142 - const snaps = getSnapshots(noteAtUri); 143 - expect(snaps[0]?.revision_at_uri).toBe(revisionAtUri); 172 + expect(getSnapshots(nUri)[0]?.revision_at_uri).toBe(rUri); 144 173 }); 145 174 146 175 test("saveNoteEdit appends a snapshot", () => { 147 - const { noteAtUri } = createNote( 176 + const nUri = noteUri(); 177 + createNote( 178 + nUri, 179 + revUri(), 148 180 "test", 149 181 "snapshot-test-edit", 150 182 "Snap Edit", 151 183 TEST_DID, 152 184 "v1", 153 185 ); 154 - saveNoteEdit("test", "snapshot-test-edit", "v2", TEST_DID); 155 - 156 - const snaps = getSnapshots(noteAtUri); 186 + saveNoteEdit(revUri(), "test", "snapshot-test-edit", "v2", TEST_DID); 187 + const snaps = getSnapshots(nUri); 157 188 expect(snaps.length).toBe(2); 158 - // DESC order: newest first 159 189 expect(snaps[0]?.content).toBe("v2"); 160 190 expect(snaps[1]?.content).toBe("v1"); 161 191 }); 162 192 163 193 test("capSnapshots removes oldest beyond limit", () => { 164 - const { noteAtUri } = createNote( 194 + const nUri = noteUri(); 195 + createNote( 196 + nUri, 197 + revUri(), 165 198 "test", 166 199 "snapshot-test-cap", 167 200 "Snap Cap", 168 201 TEST_DID, 169 202 "v1", 170 203 ); 171 - // Create 4 more snapshots (total 5 with the one from createNote) 172 204 for (let i = 2; i <= 5; i++) { 173 - saveNoteEdit("test", "snapshot-test-cap", `v${i}`, TEST_DID); 205 + saveNoteEdit(revUri(), "test", "snapshot-test-cap", `v${i}`, TEST_DID); 174 206 } 175 - expect(getSnapshots(noteAtUri).length).toBe(5); 176 - 177 - // Cap at 3 — should remove 2 oldest 178 - capSnapshots(noteAtUri, 3); 179 - const remaining = getSnapshots(noteAtUri); 207 + expect(getSnapshots(nUri).length).toBe(5); 208 + capSnapshots(nUri, 3); 209 + const remaining = getSnapshots(nUri); 180 210 expect(remaining.length).toBe(3); 181 211 expect(remaining[0]?.content).toBe("v5"); 182 212 expect(remaining[2]?.content).toBe("v3"); 183 213 }); 184 214 185 215 test("capSnapshots is a no-op when under limit", () => { 186 - const { noteAtUri } = createNote( 216 + const nUri = noteUri(); 217 + createNote( 218 + nUri, 219 + revUri(), 187 220 "test", 188 221 "snapshot-test-under", 189 222 "Snap Under", 190 223 TEST_DID, 191 224 "only one", 192 225 ); 193 - capSnapshots(noteAtUri, 25); 194 - expect(getSnapshots(noteAtUri).length).toBe(1); 226 + capSnapshots(nUri, 25); 227 + expect(getSnapshots(nUri).length).toBe(1); 195 228 }); 196 229 197 230 test("capSnapshots at exact limit keeps all", () => { 198 - const { noteAtUri } = createNote( 231 + const nUri = noteUri(); 232 + createNote( 233 + nUri, 234 + revUri(), 199 235 "test", 200 236 "snapshot-test-exact", 201 237 "Snap Exact", 202 238 TEST_DID, 203 239 "v1", 204 240 ); 205 - saveNoteEdit("test", "snapshot-test-exact", "v2", TEST_DID); 206 - capSnapshots(noteAtUri, 2); 207 - expect(getSnapshots(noteAtUri).length).toBe(2); 241 + saveNoteEdit(revUri(), "test", "snapshot-test-exact", "v2", TEST_DID); 242 + capSnapshots(nUri, 2); 243 + expect(getSnapshots(nUri).length).toBe(2); 208 244 }); 209 245 210 246 test("getSnapshots returns empty for unknown note", () => {