diff --git a/.gitignore b/.gitignore index c761b4d..cf28675 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ public/editor/dist.js public/dist.css .env .env.* +data/blobs/ .DS_Store diff --git a/src/atproto/pds.ts b/src/atproto/pds.ts index 9a78daf..b443689 100644 --- a/src/atproto/pds.ts +++ b/src/atproto/pds.ts @@ -1,4 +1,5 @@ import type { Agent } from "@atproto/api"; +import { COLLECTIONS } from "../lib/constants.ts"; export interface PdsWriteResult { uri: string; @@ -15,10 +16,10 @@ export async function writeWikiRecord( ): Promise { const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.wiki", + collection: COLLECTIONS.wiki, rkey: slug, record: { - $type: "pub.coral.wiki", + $type: COLLECTIONS.wiki, name, visibility, createdAt, @@ -38,10 +39,10 @@ export async function writeNoteRecord( ): Promise { const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.note", + collection: COLLECTIONS.note, rkey: tid, record: { - $type: "pub.coral.note", + $type: COLLECTIONS.note, slug, title, wikiRef, @@ -68,7 +69,7 @@ export async function writeRevisionRecord( blobs?: RevisionBlob[], ): Promise { const record: Record = { - $type: "pub.coral.noteRevision", + $type: COLLECTIONS.noteRevision, noteRef, diff, diffFormat: "diff-match-patch", @@ -87,7 +88,7 @@ export async function writeRevisionRecord( const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.noteRevision", + collection: COLLECTIONS.noteRevision, rkey: tid, record, }); @@ -105,10 +106,10 @@ export async function writeMembershipRecord( ): Promise { const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.membership", + collection: COLLECTIONS.membership, rkey: tid, record: { - $type: "pub.coral.membership", + $type: COLLECTIONS.membership, memberDid, wikiRef, role, @@ -127,10 +128,10 @@ export async function writeMemberRequestRecord( ): Promise { const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.memberRequest", + collection: COLLECTIONS.memberRequest, rkey: tid, record: { - $type: "pub.coral.memberRequest", + $type: COLLECTIONS.memberRequest, wikiRef, createdAt, }, @@ -147,10 +148,10 @@ export async function writeBookmarkRecord( ): Promise { const response = await agent.com.atproto.repo.putRecord({ repo: did, - collection: "pub.coral.bookmark", + collection: COLLECTIONS.bookmark, rkey: tid, record: { - $type: "pub.coral.bookmark", + $type: COLLECTIONS.bookmark, noteRef, createdAt, }, diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 27aa515..e2d8982 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,4 +1,6 @@ import type { CommitEvt } from "@atproto/sync"; +import { didOwnsUri, parseAtUri } from "../lib/at-uri.ts"; +import { COLLECTIONS } from "../lib/constants.ts"; import { extractWikilinks } from "../lib/markdown.ts"; import { deleteMembershipByAtUri, @@ -14,7 +16,7 @@ import { upsertNote, upsertRequest, upsertWiki, -} from "../server/db/queries.ts"; +} from "../server/db/queries/index.ts"; interface WikiRecord { name: string; @@ -55,19 +57,6 @@ function isValidRecord(record: unknown): record is Record { return typeof record === "object" && record !== null; } -function parseAtUri( - atUri: string, -): { did: string; collection: string; rkey: string } | null { - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); - if (!match?.[1] || !match[2] || !match[3]) return null; - return { did: match[1], collection: match[2], rkey: match[3] }; -} - -function didOwnsUri(eventDid: string, atUri: string): boolean { - const parsed = parseAtUri(atUri); - return parsed !== null && parsed.did === eventDid; -} - export function handleCommitEvent(evt: CommitEvt): void { if (evt.event === "delete") { handleDelete(evt); @@ -78,19 +67,19 @@ export function handleCommitEvent(evt: CommitEvt): void { if (!isValidRecord(record)) return; switch (evt.collection) { - case "pub.coral.wiki": + case COLLECTIONS.wiki: handleWiki(evt, record as unknown as WikiRecord); break; - case "pub.coral.note": + case COLLECTIONS.note: handleNote(evt, record as unknown as NoteRecord); break; - case "pub.coral.noteRevision": + case COLLECTIONS.noteRevision: handleRevision(evt, record as unknown as RevisionRecord); break; - case "pub.coral.membership": + case COLLECTIONS.membership: handleMembership(evt, record as unknown as MembershipRecord); break; - case "pub.coral.memberRequest": + case COLLECTIONS.memberRequest: handleMemberRequest(evt, record as unknown as MemberRequestRecord); break; } @@ -212,16 +201,16 @@ function handleDelete(evt: CommitEvt): void { if (!didOwnsUri(evt.did, atUri)) return; switch (evt.collection) { - case "pub.coral.wiki": + case COLLECTIONS.wiki: deleteWikiByAtUri(atUri); break; - case "pub.coral.note": + case COLLECTIONS.note: deleteNoteByAtUri(atUri); break; - case "pub.coral.membership": + case COLLECTIONS.membership: deleteMembershipByAtUri(atUri); break; - case "pub.coral.memberRequest": + case COLLECTIONS.memberRequest: deleteRequestByAtUri(atUri); break; } diff --git a/src/firehose/index.ts b/src/firehose/index.ts index ddbbd5b..9dbbb63 100644 --- a/src/firehose/index.ts +++ b/src/firehose/index.ts @@ -1,7 +1,8 @@ import { IdResolver } from "@atproto/identity"; import { type Event, Firehose, MemoryRunner } from "@atproto/sync"; import { getRelayUrl } from "../atproto/env.ts"; -import { getCursor, setCursor } from "../server/db/queries.ts"; +import { COLLECTIONS } from "../lib/constants.ts"; +import { getCursor, setCursor } from "../server/db/queries/index.ts"; import { handleCommitEvent } from "./handlers.ts"; const relayUrl = getRelayUrl(); @@ -25,14 +26,7 @@ const firehose = new Firehose({ idResolver, runner, service: relayUrl, - filterCollections: [ - "pub.coral.wiki", - "pub.coral.note", - "pub.coral.noteRevision", - "pub.coral.membership", - "pub.coral.memberRequest", - "pub.coral.bookmark", - ], + filterCollections: Object.values(COLLECTIONS), excludeIdentity: true, excludeAccount: true, handleEvent: (evt: Event) => { diff --git a/src/lib/at-uri.ts b/src/lib/at-uri.ts new file mode 100644 index 0000000..16ce4c0 --- /dev/null +++ b/src/lib/at-uri.ts @@ -0,0 +1,16 @@ +export interface ParsedAtUri { + did: string; + collection: string; + rkey: string; +} + +export function parseAtUri(atUri: string): ParsedAtUri | null { + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); + if (!match?.[1] || !match[2] || !match[3]) return null; + return { did: match[1], collection: match[2], rkey: match[3] }; +} + +export function didOwnsUri(eventDid: string, atUri: string): boolean { + const parsed = parseAtUri(atUri); + return parsed !== null && parsed.did === eventDid; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..2f54c84 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,8 @@ +export const COLLECTIONS = { + wiki: "pub.coral.wiki", + note: "pub.coral.note", + noteRevision: "pub.coral.noteRevision", + membership: "pub.coral.membership", + memberRequest: "pub.coral.memberRequest", + bookmark: "pub.coral.bookmark", +} as const; diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..58ad9fa --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,3 @@ +export function formatError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/server/db/queries.ts b/src/server/db/queries.ts deleted file mode 100644 index 4ee0801..0000000 --- a/src/server/db/queries.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { applyDiff, createDiff } from "../../lib/diff.ts"; -import { extractWikilinks } from "../../lib/markdown.ts"; -import { generateTid } from "../../lib/tid.ts"; -import { getDb } from "./index.ts"; - -export interface WikiRow { - slug: string; - did: string; - name: string; - visibility: string; - at_uri: string; - created_at: string; - updated_at: string; -} - -export interface NoteRow { - slug: string; - wiki_slug: string; - title: string; - did: string; - at_uri: string; - created_at: string; -} - -export interface CurrentNoteRow { - note_at_uri: string; - content: string; - latest_revision_uri: string; - updated_at: string; -} - -export function listWikis(): WikiRow[] { - const db = getDb(); - return db - .query("SELECT * FROM wikis ORDER BY updated_at DESC") - .all() as WikiRow[]; -} - -export function getWiki(slug: string): WikiRow | null { - const db = getDb(); - return ( - (db.query("SELECT * FROM wikis WHERE slug = ?").get(slug) as WikiRow) ?? - null - ); -} - -export function listNotes(wikiSlug: string): NoteRow[] { - const db = getDb(); - return db - .query("SELECT * FROM notes WHERE wiki_slug = ? ORDER BY created_at DESC") - .all(wikiSlug) as NoteRow[]; -} - -export function searchWikis(query: string): WikiRow[] { - const db = getDb(); - const pattern = `%${query}%`; - return db - .query( - `SELECT * FROM wikis - WHERE visibility = 'public' AND (name LIKE ? OR slug LIKE ?) - ORDER BY updated_at DESC LIMIT 20`, - ) - .all(pattern, pattern) as WikiRow[]; -} - -export function searchNotes( - query: string, - wikiSlug?: string, -): (NoteRow & { wiki_name: string })[] { - const db = getDb(); - const pattern = `%${query}%`; - if (wikiSlug) { - return db - .query( - `SELECT n.*, w.name AS wiki_name FROM notes n - JOIN wikis w ON w.slug = n.wiki_slug - WHERE n.wiki_slug = ? AND n.title LIKE ? - ORDER BY n.created_at DESC LIMIT 20`, - ) - .all(wikiSlug, pattern) as (NoteRow & { wiki_name: string })[]; - } - return db - .query( - `SELECT n.*, w.name AS wiki_name FROM notes n - JOIN wikis w ON w.slug = n.wiki_slug - WHERE n.title LIKE ? - ORDER BY n.created_at DESC LIMIT 20`, - ) - .all(pattern) as (NoteRow & { wiki_name: string })[]; -} - -export function getCurrentNote( - wikiSlug: string, - noteSlug: string, -): CurrentNoteRow | null { - const db = getDb(); - const note = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as { at_uri: string } | null; - if (!note) return null; - - return ( - (db - .query("SELECT * FROM current_note WHERE note_at_uri = ?") - .get(note.at_uri) as CurrentNoteRow) ?? null - ); -} - -export interface BacklinkRow { - source_note_uri: string; - target_note_slug: string; - wiki_slug: string; -} - -export interface SnapshotRow { - id: number; - note_at_uri: string; - revision_at_uri: string; - content: string; - created_at: string; -} - -export function updateBacklinks( - noteAtUri: string, - wikiSlug: string, - slugs: string[], -): void { - const db = getDb(); - db.transaction(() => { - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); - for (const slug of slugs) { - db.run( - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", - [noteAtUri, slug, wikiSlug], - ); - } - })(); -} - -export function getBacklinks( - wikiSlug: string, - noteSlug: string, -): BacklinkRow[] { - const db = getDb(); - return db - .query( - "SELECT * FROM backlinks WHERE wiki_slug = ? AND target_note_slug = ?", - ) - .all(wikiSlug, noteSlug) as BacklinkRow[]; -} - -export function getSnapshots(noteAtUri: string): SnapshotRow[] { - const db = getDb(); - return db - .query("SELECT * FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC") - .all(noteAtUri) as SnapshotRow[]; -} - -export function capSnapshots(noteAtUri: string, max = 25): void { - const db = getDb(); - db.run( - `DELETE FROM snapshots WHERE note_at_uri = ? AND id NOT IN ( - SELECT id FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC LIMIT ? - )`, - [noteAtUri, noteAtUri, max], - ); -} - -export function getNoteBySlug( - wikiSlug: string, - noteSlug: string, -): NoteRow | null { - const db = getDb(); - return ( - (db - .query("SELECT * FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as NoteRow) ?? null - ); -} - -export function createNote( - wikiSlug: string, - noteSlug: string, - title: string, - did: string, - initialContent: string, - message?: string, -): { noteAtUri: string; revisionAtUri: string } { - const db = getDb(); - const noteTid = generateTid(); - const revisionTid = generateTid(); - const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; - const diff = createDiff("", initialContent); - - db.transaction(() => { - db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - [noteSlug, wikiSlug, title, did, noteAtUri], - ); - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) - VALUES (?, ?, ?, NULL, ?, ?)`, - [noteAtUri, did, revisionAtUri, diff, message ?? null], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now'))`, - [noteAtUri, initialContent, revisionAtUri], - ); - })(); - - // Populate backlinks from wikilinks in content - const slugs = extractWikilinks(initialContent); - updateBacklinks(noteAtUri, wikiSlug, slugs); - - // Create snapshot and cap at 25 - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [noteAtUri, revisionAtUri, initialContent], - ); - capSnapshots(noteAtUri); - - return { noteAtUri, revisionAtUri }; -} - -export function saveNoteEdit( - wikiSlug: string, - noteSlug: string, - newContent: string, - did: string, - message?: string, - newTitle?: string, -): { revisionAtUri: string } { - const db = getDb(); - - const note = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as { at_uri: string } | null; - if (!note) throw new Error(`Note not found: ${wikiSlug}/${noteSlug}`); - - const current = db - .query( - "SELECT content, latest_revision_uri FROM current_note WHERE note_at_uri = ?", - ) - .get(note.at_uri) as { - content: string; - latest_revision_uri: string | null; - } | null; - - const oldContent = current?.content ?? ""; - const parentRevisionUri = current?.latest_revision_uri ?? null; - const diff = createDiff(oldContent, newContent); - const revisionTid = generateTid(); - const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; - - db.transaction(() => { - if (newTitle) { - db.run("UPDATE notes SET title = ? WHERE wiki_slug = ? AND slug = ?", [ - newTitle, - wikiSlug, - noteSlug, - ]); - } - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - note.at_uri, - did, - revisionAtUri, - parentRevisionUri, - diff, - message ?? null, - ], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(note_at_uri) DO UPDATE SET - content = excluded.content, - latest_revision_uri = excluded.latest_revision_uri, - updated_at = excluded.updated_at`, - [note.at_uri, newContent, revisionAtUri], - ); - })(); - - // Update backlinks from wikilinks in new content - const slugs = extractWikilinks(newContent); - updateBacklinks(note.at_uri, wikiSlug, slugs); - - // Create snapshot and cap at 25 - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [note.at_uri, revisionAtUri, newContent], - ); - capSnapshots(note.at_uri); - - return { revisionAtUri }; -} - -// --- Firehose ingestion queries --- - -export function getWikiByAtUri(atUri: string): WikiRow | null { - const db = getDb(); - return ( - (db.query("SELECT * FROM wikis WHERE at_uri = ?").get(atUri) as WikiRow) ?? - null - ); -} - -export function getNoteByAtUri( - atUri: string, -): (NoteRow & { wiki_slug: string }) | null { - const db = getDb(); - return ( - (db.query("SELECT * FROM notes WHERE at_uri = ?").get(atUri) as - | (NoteRow & { wiki_slug: string }) - | null) ?? null - ); -} - -export function upsertWiki( - slug: string, - did: string, - name: string, - visibility: string, - atUri: string, - createdAt: string, -): void { - const db = getDb(); - db.run( - `INSERT INTO wikis (slug, did, name, visibility, at_uri, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(slug) DO UPDATE SET - name = excluded.name, - visibility = excluded.visibility, - updated_at = datetime('now')`, - [slug, did, name, visibility, atUri, createdAt], - ); -} - -export function deleteWikiByAtUri(atUri: string): void { - const db = getDb(); - const wiki = db - .query("SELECT slug FROM wikis WHERE at_uri = ?") - .get(atUri) as { slug: string } | null; - if (!wiki) return; - - db.transaction(() => { - const notes = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") - .all(wiki.slug) 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 snapshots WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); - } - db.run("DELETE FROM notes WHERE wiki_slug = ?", [wiki.slug]); - db.run("DELETE FROM memberships WHERE wiki_slug = ?", [wiki.slug]); - db.run("DELETE FROM requests WHERE wiki_slug = ?", [wiki.slug]); - db.run("DELETE FROM wikis WHERE slug = ?", [wiki.slug]); - })(); -} - -export function upsertNote( - wikiSlug: string, - slug: string, - title: string, - did: string, - atUri: string, - createdAt: string, -): void { - const db = getDb(); - db.run( - `INSERT INTO notes (slug, wiki_slug, title, did, at_uri, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(wiki_slug, slug) DO UPDATE SET - title = excluded.title`, - [slug, wikiSlug, title, did, atUri, createdAt], - ); -} - -export function deleteNoteByAtUri(atUri: string): void { - const db = getDb(); - db.transaction(() => { - db.run("DELETE FROM current_note WHERE note_at_uri = ?", [atUri]); - db.run("DELETE FROM revisions WHERE note_at_uri = ?", [atUri]); - db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [atUri]); - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [atUri]); - db.run("DELETE FROM notes WHERE at_uri = ?", [atUri]); - })(); -} - -export function insertRevisionFromFirehose( - noteAtUri: string, - did: string, - revisionAtUri: string, - parentRevisionUri: string | null, - diff: string, - diffFormat: string, - message: string | null, -): string { - const db = getDb(); - - const current = db - .query("SELECT content FROM current_note WHERE note_at_uri = ?") - .get(noteAtUri) as { content: string } | null; - - const oldContent = current?.content ?? ""; - const newContent = applyDiff(oldContent, diff); - - db.transaction(() => { - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, diff_format, message) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - noteAtUri, - did, - revisionAtUri, - parentRevisionUri, - diff, - diffFormat, - message, - ], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(note_at_uri) DO UPDATE SET - content = excluded.content, - latest_revision_uri = excluded.latest_revision_uri, - updated_at = excluded.updated_at`, - [noteAtUri, newContent, revisionAtUri], - ); - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [noteAtUri, revisionAtUri, newContent], - ); - })(); - - capSnapshots(noteAtUri); - - return newContent; -} - -export function upsertMembership( - wikiSlug: string, - did: string, - role: string, - atUri: string, - createdAt: string, -): void { - const db = getDb(); - db.run( - `INSERT INTO memberships (wiki_slug, did, role, at_uri, created_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(wiki_slug, did) DO UPDATE SET - role = excluded.role`, - [wikiSlug, did, role, atUri, createdAt], - ); -} - -export function deleteMembershipByAtUri(atUri: string): void { - const db = getDb(); - db.run("DELETE FROM memberships WHERE at_uri = ?", [atUri]); -} - -export function upsertRequest( - wikiSlug: string, - did: string, - atUri: string, - createdAt: string, -): void { - const db = getDb(); - db.run( - `INSERT INTO requests (wiki_slug, did, at_uri, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(wiki_slug, did) DO UPDATE SET - at_uri = excluded.at_uri`, - [wikiSlug, did, atUri, createdAt], - ); -} - -export function deleteRequestByAtUri(atUri: string): void { - const db = getDb(); - db.run("DELETE FROM requests WHERE at_uri = ?", [atUri]); -} - -// --- Cursor queries --- - -export function getCursor(): number | null { - const db = getDb(); - const row = db - .query("SELECT cursor FROM firehose_cursor WHERE id = 1") - .get() as { cursor: number } | null; - return row?.cursor ?? null; -} - -export function setCursor(cursor: number): void { - const db = getDb(); - db.run( - `INSERT INTO firehose_cursor (id, cursor, updated_at) - VALUES (1, ?, datetime('now')) - ON CONFLICT(id) DO UPDATE SET - cursor = excluded.cursor, - updated_at = excluded.updated_at`, - [cursor], - ); -} - -// --- Blob queries --- - -export interface BlobRow { - cid: string; - revision_at_uri: string; - mime_type: string; - storage_key: string; - created_at: string; -} - -export function insertBlob( - cid: string, - revisionAtUri: string, - mimeType: string, - storageKey: string, -): void { - const db = getDb(); - db.run( - `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) - VALUES (?, ?, ?, ?)`, - [cid, revisionAtUri, mimeType, storageKey], - ); -} - -export function getBlobsByCids(cids: string[]): BlobRow[] { - if (cids.length === 0) return []; - const db = getDb(); - const placeholders = cids.map(() => "?").join(", "); - return db - .query(`SELECT * FROM blobs WHERE cid IN (${placeholders})`) - .all(...cids) as BlobRow[]; -} diff --git a/src/server/db/queries/blob.ts b/src/server/db/queries/blob.ts new file mode 100644 index 0000000..b7be804 --- /dev/null +++ b/src/server/db/queries/blob.ts @@ -0,0 +1,25 @@ +import { getDb } from "../index.ts"; +import type { BlobRow } from "../types.ts"; + +export function insertBlob( + cid: string, + revisionAtUri: string, + mimeType: string, + storageKey: string, +): void { + const db = getDb(); + db.run( + `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) + VALUES (?, ?, ?, ?)`, + [cid, revisionAtUri, mimeType, storageKey], + ); +} + +export function getBlobsByCids(cids: string[]): BlobRow[] { + if (cids.length === 0) return []; + const db = getDb(); + const placeholders = cids.map(() => "?").join(", "); + return db + .query(`SELECT * FROM blobs WHERE cid IN (${placeholders})`) + .all(...cids) as BlobRow[]; +} diff --git a/src/server/db/queries/cursor.ts b/src/server/db/queries/cursor.ts new file mode 100644 index 0000000..ec2d806 --- /dev/null +++ b/src/server/db/queries/cursor.ts @@ -0,0 +1,21 @@ +import { getDb } from "../index.ts"; + +export function getCursor(): number | null { + const db = getDb(); + const row = db + .query("SELECT cursor FROM firehose_cursor WHERE id = 1") + .get() as { cursor: number } | null; + return row?.cursor ?? null; +} + +export function setCursor(cursor: number): void { + const db = getDb(); + db.run( + `INSERT INTO firehose_cursor (id, cursor, updated_at) + VALUES (1, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + cursor = excluded.cursor, + updated_at = excluded.updated_at`, + [cursor], + ); +} diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts new file mode 100644 index 0000000..1b4533d --- /dev/null +++ b/src/server/db/queries/index.ts @@ -0,0 +1,43 @@ +// Barrel re-export — all query functions accessible from "db/queries" +export type { + BacklinkRow, + BlobRow, + CurrentNoteRow, + NoteRow, + SnapshotRow, + WikiRow, +} from "../types.ts"; +export { getBlobsByCids, insertBlob } from "./blob.ts"; +export { getCursor, setCursor } from "./cursor.ts"; +export { + deleteMembershipByAtUri, + deleteRequestByAtUri, + upsertMembership, + upsertRequest, +} from "./membership.ts"; +export { + createNote, + deleteNoteByAtUri, + getCurrentNote, + getNoteByAtUri, + getNoteBySlug, + listNotes, + saveNoteEdit, + searchNotes, + upsertNote, +} from "./note.ts"; +export { + capSnapshots, + getBacklinks, + getSnapshots, + insertRevisionFromFirehose, + updateBacklinks, +} from "./revision.ts"; +export { + deleteWikiByAtUri, + getWiki, + getWikiByAtUri, + listWikis, + searchWikis, + upsertWiki, +} from "./wiki.ts"; diff --git a/src/server/db/queries/membership.ts b/src/server/db/queries/membership.ts new file mode 100644 index 0000000..2a0e27a --- /dev/null +++ b/src/server/db/queries/membership.ts @@ -0,0 +1,44 @@ +import { getDb } from "../index.ts"; + +export function upsertMembership( + wikiSlug: string, + did: string, + role: string, + atUri: string, + createdAt: string, +): void { + const db = getDb(); + db.run( + `INSERT INTO memberships (wiki_slug, did, role, at_uri, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(wiki_slug, did) DO UPDATE SET + role = excluded.role`, + [wikiSlug, did, role, atUri, createdAt], + ); +} + +export function deleteMembershipByAtUri(atUri: string): void { + const db = getDb(); + db.run("DELETE FROM memberships WHERE at_uri = ?", [atUri]); +} + +export function upsertRequest( + wikiSlug: string, + did: string, + atUri: string, + createdAt: string, +): void { + const db = getDb(); + db.run( + `INSERT INTO requests (wiki_slug, did, at_uri, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(wiki_slug, did) DO UPDATE SET + at_uri = excluded.at_uri`, + [wikiSlug, did, atUri, createdAt], + ); +} + +export function deleteRequestByAtUri(atUri: string): void { + const db = getDb(); + db.run("DELETE FROM requests WHERE at_uri = ?", [atUri]); +} diff --git a/src/server/db/queries/note.ts b/src/server/db/queries/note.ts new file mode 100644 index 0000000..7bc3690 --- /dev/null +++ b/src/server/db/queries/note.ts @@ -0,0 +1,250 @@ +import { createDiff } from "../../../lib/diff.ts"; +import { extractWikilinks } from "../../../lib/markdown.ts"; +import { generateTid } from "../../../lib/tid.ts"; +import { getDb } from "../index.ts"; +import type { CurrentNoteRow, NoteRow } from "../types.ts"; + +export function listNotes(wikiSlug: string): NoteRow[] { + const db = getDb(); + return db + .query("SELECT * FROM notes WHERE wiki_slug = ? ORDER BY created_at DESC") + .all(wikiSlug) as NoteRow[]; +} + +export function searchNotes( + query: string, + wikiSlug?: string, +): (NoteRow & { wiki_name: string })[] { + const db = getDb(); + const pattern = `%${query}%`; + if (wikiSlug) { + return db + .query( + `SELECT n.*, w.name AS wiki_name FROM notes n + JOIN wikis w ON w.slug = n.wiki_slug + WHERE n.wiki_slug = ? AND n.title LIKE ? + ORDER BY n.created_at DESC LIMIT 20`, + ) + .all(wikiSlug, pattern) as (NoteRow & { wiki_name: string })[]; + } + return db + .query( + `SELECT n.*, w.name AS wiki_name FROM notes n + JOIN wikis w ON w.slug = n.wiki_slug + WHERE n.title LIKE ? + ORDER BY n.created_at DESC LIMIT 20`, + ) + .all(pattern) as (NoteRow & { wiki_name: string })[]; +} + +export function getCurrentNote( + wikiSlug: string, + noteSlug: string, +): CurrentNoteRow | null { + const db = getDb(); + const note = db + .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") + .get(wikiSlug, noteSlug) as { at_uri: string } | null; + if (!note) return null; + + return ( + (db + .query("SELECT * FROM current_note WHERE note_at_uri = ?") + .get(note.at_uri) as CurrentNoteRow) ?? null + ); +} + +export function getNoteBySlug( + wikiSlug: string, + noteSlug: string, +): NoteRow | null { + const db = getDb(); + return ( + (db + .query("SELECT * FROM notes WHERE wiki_slug = ? AND slug = ?") + .get(wikiSlug, noteSlug) as NoteRow) ?? null + ); +} + +export function getNoteByAtUri( + atUri: string, +): (NoteRow & { wiki_slug: string }) | null { + const db = getDb(); + return ( + (db.query("SELECT * FROM notes WHERE at_uri = ?").get(atUri) as + | (NoteRow & { wiki_slug: string }) + | null) ?? null + ); +} + +export function upsertNote( + wikiSlug: string, + slug: string, + title: string, + did: string, + atUri: string, + createdAt: string, +): void { + const db = getDb(); + db.run( + `INSERT INTO notes (slug, wiki_slug, title, did, at_uri, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(wiki_slug, slug) DO UPDATE SET + title = excluded.title`, + [slug, wikiSlug, title, did, atUri, createdAt], + ); +} + +export function deleteNoteByAtUri(atUri: string): void { + const db = getDb(); + db.transaction(() => { + db.run("DELETE FROM current_note WHERE note_at_uri = ?", [atUri]); + db.run("DELETE FROM revisions WHERE note_at_uri = ?", [atUri]); + db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [atUri]); + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [atUri]); + db.run("DELETE FROM notes WHERE at_uri = ?", [atUri]); + })(); +} + +export function createNote( + wikiSlug: string, + noteSlug: string, + title: string, + did: string, + initialContent: string, + message?: string, +): { noteAtUri: string; revisionAtUri: string } { + const db = getDb(); + const noteTid = generateTid(); + const revisionTid = generateTid(); + const noteAtUri = `at://${did}/pub.coral.note/${noteTid}`; + const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; + const diff = createDiff("", initialContent); + const slugs = extractWikilinks(initialContent); + + db.transaction(() => { + db.run( + "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", + [noteSlug, wikiSlug, title, did, noteAtUri], + ); + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) + VALUES (?, ?, ?, NULL, ?, ?)`, + [noteAtUri, did, revisionAtUri, diff, message ?? null], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now'))`, + [noteAtUri, initialContent, revisionAtUri], + ); + + // Backlinks + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [noteAtUri, slug, wikiSlug], + ); + } + + // Snapshot + cap + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [noteAtUri, revisionAtUri, initialContent], + ); + db.run( + `DELETE FROM snapshots WHERE note_at_uri = ? AND id NOT IN ( + SELECT id FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC LIMIT 25 + )`, + [noteAtUri, noteAtUri], + ); + })(); + + return { noteAtUri, revisionAtUri }; +} + +export function saveNoteEdit( + wikiSlug: string, + noteSlug: string, + newContent: string, + did: string, + message?: string, + newTitle?: string, +): { revisionAtUri: string } { + const db = getDb(); + + const note = db + .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") + .get(wikiSlug, noteSlug) as { at_uri: string } | null; + if (!note) throw new Error(`Note not found: ${wikiSlug}/${noteSlug}`); + + const current = db + .query( + "SELECT content, latest_revision_uri FROM current_note WHERE note_at_uri = ?", + ) + .get(note.at_uri) as { + content: string; + latest_revision_uri: string | null; + } | null; + + const oldContent = current?.content ?? ""; + const parentRevisionUri = current?.latest_revision_uri ?? null; + const diff = createDiff(oldContent, newContent); + const revisionTid = generateTid(); + const revisionAtUri = `at://${did}/pub.coral.noteRevision/${revisionTid}`; + const slugs = extractWikilinks(newContent); + + db.transaction(() => { + if (newTitle) { + db.run("UPDATE notes SET title = ? WHERE wiki_slug = ? AND slug = ?", [ + newTitle, + wikiSlug, + noteSlug, + ]); + } + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + note.at_uri, + did, + revisionAtUri, + parentRevisionUri, + diff, + message ?? null, + ], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(note_at_uri) DO UPDATE SET + content = excluded.content, + latest_revision_uri = excluded.latest_revision_uri, + updated_at = excluded.updated_at`, + [note.at_uri, newContent, revisionAtUri], + ); + + // Backlinks + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [note.at_uri, slug, wikiSlug], + ); + } + + // Snapshot + cap + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [note.at_uri, revisionAtUri, newContent], + ); + db.run( + `DELETE FROM snapshots WHERE note_at_uri = ? AND id NOT IN ( + SELECT id FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC LIMIT 25 + )`, + [note.at_uri, note.at_uri], + ); + })(); + + return { revisionAtUri }; +} diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts new file mode 100644 index 0000000..bc36041 --- /dev/null +++ b/src/server/db/queries/revision.ts @@ -0,0 +1,101 @@ +import { applyDiff } from "../../../lib/diff.ts"; +import { getDb } from "../index.ts"; +import type { BacklinkRow, SnapshotRow } from "../types.ts"; + +export function updateBacklinks( + noteAtUri: string, + wikiSlug: string, + slugs: string[], +): void { + const db = getDb(); + db.transaction(() => { + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [noteAtUri, slug, wikiSlug], + ); + } + })(); +} + +export function getBacklinks( + wikiSlug: string, + noteSlug: string, +): BacklinkRow[] { + const db = getDb(); + return db + .query( + "SELECT * FROM backlinks WHERE wiki_slug = ? AND target_note_slug = ?", + ) + .all(wikiSlug, noteSlug) as BacklinkRow[]; +} + +export function getSnapshots(noteAtUri: string): SnapshotRow[] { + const db = getDb(); + return db + .query("SELECT * FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC") + .all(noteAtUri) as SnapshotRow[]; +} + +export function capSnapshots(noteAtUri: string, max = 25): void { + const db = getDb(); + db.run( + `DELETE FROM snapshots WHERE note_at_uri = ? AND id NOT IN ( + SELECT id FROM snapshots WHERE note_at_uri = ? ORDER BY id DESC LIMIT ? + )`, + [noteAtUri, noteAtUri, max], + ); +} + +export function insertRevisionFromFirehose( + noteAtUri: string, + did: string, + revisionAtUri: string, + parentRevisionUri: string | null, + diff: string, + diffFormat: string, + message: string | null, +): string { + const db = getDb(); + + const current = db + .query("SELECT content FROM current_note WHERE note_at_uri = ?") + .get(noteAtUri) as { content: string } | null; + + const oldContent = current?.content ?? ""; + const newContent = applyDiff(oldContent, diff); + + db.transaction(() => { + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, diff_format, message) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + noteAtUri, + did, + revisionAtUri, + parentRevisionUri, + diff, + diffFormat, + message, + ], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(note_at_uri) DO UPDATE SET + content = excluded.content, + latest_revision_uri = excluded.latest_revision_uri, + updated_at = excluded.updated_at`, + [noteAtUri, newContent, revisionAtUri], + ); + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [noteAtUri, revisionAtUri, newContent], + ); + })(); + + capSnapshots(noteAtUri); + + return newContent; +} diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts new file mode 100644 index 0000000..6ea0c8a --- /dev/null +++ b/src/server/db/queries/wiki.ts @@ -0,0 +1,81 @@ +import { getDb } from "../index.ts"; +import type { WikiRow } from "../types.ts"; + +export function listWikis(): WikiRow[] { + const db = getDb(); + return db + .query("SELECT * FROM wikis ORDER BY updated_at DESC") + .all() as WikiRow[]; +} + +export function getWiki(slug: string): WikiRow | null { + const db = getDb(); + return ( + (db.query("SELECT * FROM wikis WHERE slug = ?").get(slug) as WikiRow) ?? + null + ); +} + +export function searchWikis(query: string): WikiRow[] { + const db = getDb(); + const pattern = `%${query}%`; + return db + .query( + `SELECT * FROM wikis + WHERE visibility = 'public' AND (name LIKE ? OR slug LIKE ?) + ORDER BY updated_at DESC LIMIT 20`, + ) + .all(pattern, pattern) as WikiRow[]; +} + +export function getWikiByAtUri(atUri: string): WikiRow | null { + const db = getDb(); + return ( + (db.query("SELECT * FROM wikis WHERE at_uri = ?").get(atUri) as WikiRow) ?? + null + ); +} + +export function upsertWiki( + slug: string, + did: string, + name: string, + visibility: string, + atUri: string, + createdAt: string, +): void { + const db = getDb(); + db.run( + `INSERT INTO wikis (slug, did, name, visibility, at_uri, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(slug) DO UPDATE SET + name = excluded.name, + visibility = excluded.visibility, + updated_at = datetime('now')`, + [slug, did, name, visibility, atUri, createdAt], + ); +} + +export function deleteWikiByAtUri(atUri: string): void { + const db = getDb(); + const wiki = db + .query("SELECT slug FROM wikis WHERE at_uri = ?") + .get(atUri) as { slug: string } | null; + if (!wiki) return; + + db.transaction(() => { + const notes = db + .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") + .all(wiki.slug) 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 snapshots WHERE note_at_uri = ?", [note.at_uri]); + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); + } + db.run("DELETE FROM notes WHERE wiki_slug = ?", [wiki.slug]); + db.run("DELETE FROM memberships WHERE wiki_slug = ?", [wiki.slug]); + db.run("DELETE FROM requests WHERE wiki_slug = ?", [wiki.slug]); + db.run("DELETE FROM wikis WHERE slug = ?", [wiki.slug]); + })(); +} diff --git a/src/server/db/types.ts b/src/server/db/types.ts new file mode 100644 index 0000000..b9f9e37 --- /dev/null +++ b/src/server/db/types.ts @@ -0,0 +1,47 @@ +export interface WikiRow { + slug: string; + did: string; + name: string; + visibility: string; + at_uri: string; + created_at: string; + updated_at: string; +} + +export interface NoteRow { + slug: string; + wiki_slug: string; + title: string; + did: string; + at_uri: string; + created_at: string; +} + +export interface CurrentNoteRow { + note_at_uri: string; + content: string; + latest_revision_uri: string; + updated_at: string; +} + +export interface BacklinkRow { + source_note_uri: string; + target_note_slug: string; + wiki_slug: string; +} + +export interface SnapshotRow { + id: number; + note_at_uri: string; + revision_at_uri: string; + content: string; + created_at: string; +} + +export interface BlobRow { + cid: string; + revision_at_uri: string; + mime_type: string; + storage_key: string; + created_at: string; +} diff --git a/src/server/routes/blob.ts b/src/server/routes/blob.ts index 3831140..15909c2 100644 --- a/src/server/routes/blob.ts +++ b/src/server/routes/blob.ts @@ -4,6 +4,7 @@ import { IdResolver } from "@atproto/identity"; import { Elysia } from "elysia"; import { getSessionFromRequest } from "../../atproto/routes.ts"; import { createAgent } from "../../atproto/session.ts"; +import { formatError } from "../../lib/errors.ts"; import { processImage } from "../../lib/image.ts"; const LOCAL_BLOB_DIR = "data/blobs"; @@ -42,8 +43,7 @@ export const blobRoutes = new Elysia() try { processed = await processImage(buffer, file.type); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return new Response(JSON.stringify({ error: msg }), { + return new Response(JSON.stringify({ error: formatError(err) }), { status: 400, headers: { "Content-Type": "application/json" }, }); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 0560267..75ce1b2 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -6,8 +6,10 @@ import { } from "../../atproto/pds.ts"; import { getSessionFromRequest } from "../../atproto/routes.ts"; import { createAgent, type Session } from "../../atproto/session.ts"; +import { parseAtUri } from "../../lib/at-uri.ts"; import { extractBlobRefs } from "../../lib/blob.ts"; import { createDiff } from "../../lib/diff.ts"; +import { formatError } from "../../lib/errors.ts"; import { renderMarkdown } from "../../lib/markdown.ts"; import { isValidSlug, slugify } from "../../lib/slug.ts"; import { generateTid } from "../../lib/tid.ts"; @@ -22,7 +24,7 @@ import { insertBlob, listNotes, saveNoteEdit, -} from "../db/queries.ts"; +} from "../db/queries/index.ts"; const VIZ_SCRIPTS = [ "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", @@ -189,8 +191,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) blobs.length > 0 ? blobs : undefined, ); } catch (pdsErr) { - const msg = pdsErr instanceof Error ? pdsErr.message : String(pdsErr); - return new Response(`Failed to save to PDS: ${msg}`, { + return new Response(`Failed to save to PDS: ${formatError(pdsErr)}`, { status: 502, }); } @@ -209,8 +210,9 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ); revisionAtUri = result.revisionAtUri; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return new Response(`Failed to create note: ${msg}`, { status: 500 }); + return new Response(`Failed to create note: ${formatError(err)}`, { + status: 500, + }); } persistBlobs(blobs, revisionAtUri); @@ -304,14 +306,14 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ); if (newTitle) { - const noteTid = note.at_uri.split("/").pop(); - if (!noteTid) { - throw new Error("Invalid at_uri: missing TID"); + const parsed = parseAtUri(note.at_uri); + if (!parsed) { + throw new Error("Invalid at_uri: cannot parse"); } await writeNoteRecord( agent, did, - noteTid, + parsed.rkey, note.slug, newTitle, wiki.at_uri, @@ -319,8 +321,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ); } } catch (pdsErr) { - const msg = pdsErr instanceof Error ? pdsErr.message : String(pdsErr); - return new Response(`Failed to save to PDS: ${msg}`, { + return new Response(`Failed to save to PDS: ${formatError(pdsErr)}`, { status: 502, }); } @@ -339,8 +340,9 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ); revisionAtUri = result.revisionAtUri; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return new Response(`Failed to save edit: ${msg}`, { status: 500 }); + return new Response(`Failed to save edit: ${formatError(err)}`, { + status: 500, + }); } persistBlobs(blobs, revisionAtUri); diff --git a/src/server/routes/search.ts b/src/server/routes/search.ts index 1599973..ebb3657 100644 --- a/src/server/routes/search.ts +++ b/src/server/routes/search.ts @@ -1,6 +1,6 @@ import { Elysia } from "elysia"; import { escapeHtml } from "../../lib/html.ts"; -import { searchNotes, searchWikis } from "../db/queries.ts"; +import { searchNotes, searchWikis } from "../db/queries/index.ts"; export const searchRoutes = new Elysia().get("/search", ({ query }) => { const q = (query.q as string | undefined) ?? ""; diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index c7be7a1..6680e99 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -10,7 +10,7 @@ import { getWiki, listNotes, listWikis, -} from "../db/queries.ts"; +} from "../db/queries/index.ts"; const VIZ_SCRIPTS = [ "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", diff --git a/tests/firehose/handlers.test.ts b/tests/firehose/handlers.test.ts index 09a3b4e..b7451ab 100644 --- a/tests/firehose/handlers.test.ts +++ b/tests/firehose/handlers.test.ts @@ -9,7 +9,7 @@ import { getWiki, listNotes, setCursor, -} from "../../src/server/db/queries.ts"; +} from "../../src/server/db/queries/index.ts"; const db = getDb(); diff --git a/tests/server/db/queries/helpers.ts b/tests/server/db/queries/helpers.ts new file mode 100644 index 0000000..127e1ab --- /dev/null +++ b/tests/server/db/queries/helpers.ts @@ -0,0 +1,20 @@ +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.test.ts b/tests/server/db/queries/note.test.ts similarity index 53% rename from tests/server/db/queries.test.ts rename to tests/server/db/queries/note.test.ts index 2c2124a..108237f 100644 --- a/tests/server/db/queries.test.ts +++ b/tests/server/db/queries/note.test.ts @@ -1,78 +1,26 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { applyDiff } from "../../../src/lib/diff.ts"; -import { getDb } from "../../../src/server/db/index.ts"; +import { applyDiff } from "../../../../src/lib/diff.ts"; +import { getDb } from "../../../../src/server/db/index.ts"; import { - capSnapshots, createNote, - getBacklinks, getCurrentNote, getNoteBySlug, - getSnapshots, - getWiki, listNotes, - listWikis, saveNoteEdit, - updateBacklinks, -} from "../../../src/server/db/queries.ts"; +} from "../../../../src/server/db/queries/index.ts"; +import { cleanupNotes } from "./helpers.ts"; -// getDb() initializes schema + seeds on first call const db = getDb(); +const TEST_DID = "did:plc:mock123"; beforeAll(() => { - // Remove stale write-test data from previous runs (file-backed DB persists across runs) - const staleNotes = db - .query( - "SELECT at_uri FROM notes WHERE wiki_slug = 'test' AND (slug GLOB 'write-test-*' OR slug GLOB 'edit-test-*' OR slug GLOB 'backlink-test-*' OR slug GLOB 'snapshot-test-*')", - ) - .all() as { at_uri: string }[]; - for (const note of staleNotes) { - 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 = 'test' AND (slug GLOB 'write-test-*' OR slug GLOB 'edit-test-*' OR slug GLOB 'backlink-test-*' OR slug GLOB 'snapshot-test-*')", - ); + cleanupNotes("test", "write-test-*"); + cleanupNotes("test", "edit-test-*"); }); afterAll(() => { - db.close(); -}); - -describe("listWikis", () => { - test("returns seeded wikis", () => { - const wikis = listWikis(); - expect(wikis.length).toBe(1); - const slugs = wikis.map((w) => w.slug); - expect(slugs).toContain("test"); - }); - - test("returns all expected fields", () => { - const wikis = listWikis(); - const testWiki = wikis.find((w) => w.slug === "test"); - expect(testWiki).toBeDefined(); - expect(testWiki?.name).toBe("Test Wiki"); - expect(testWiki?.visibility).toBe("public"); - expect(testWiki?.did).toBe("did:plc:mock123"); - expect(testWiki?.at_uri).toContain("pub.coral.wiki/test"); - expect(testWiki?.created_at).toBeDefined(); - expect(testWiki?.updated_at).toBeDefined(); - }); -}); - -describe("getWiki", () => { - test("returns wiki by slug", () => { - const wiki = getWiki("test"); - expect(wiki).not.toBeNull(); - expect(wiki?.name).toBe("Test Wiki"); - expect(wiki?.visibility).toBe("public"); - }); - - test("returns null for nonexistent slug", () => { - const wiki = getWiki("does-not-exist"); - expect(wiki).toBeNull(); - }); + cleanupNotes("test", "write-test-*"); + cleanupNotes("test", "edit-test-*"); }); describe("listNotes", () => { @@ -159,7 +107,6 @@ describe("getNoteBySlug", () => { }); describe("createNote", () => { - const TEST_DID = "did:plc:mock123"; const INITIAL_CONTENT = "# New Note\n\nSome content here."; test("inserts rows into notes, revisions, current_note", () => { @@ -242,8 +189,6 @@ describe("createNote", () => { }); describe("saveNoteEdit", () => { - const TEST_DID = "did:plc:mock123"; - test("creates a revision row with correct note_at_uri", () => { createNote( "test", @@ -358,197 +303,3 @@ describe("saveNoteEdit", () => { ); }); }); - -describe("backlinks", () => { - const TEST_DID = "did:plc:mock123"; - - test("createNote populates backlinks from wikilinks", () => { - const { noteAtUri } = createNote( - "test", - "backlink-test-create", - "Backlink Create", - TEST_DID, - "Link to [[target-a]] and [[target-b]]", - ); - const links = getBacklinks("test", "target-a"); - const sourceUris = links.map((l) => l.source_note_uri); - expect(sourceUris).toContain(noteAtUri); - }); - - test("createNote with no wikilinks produces no backlinks", () => { - const { noteAtUri } = createNote( - "test", - "backlink-test-nolinks", - "No Links", - TEST_DID, - "No links here", - ); - const all = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri); - expect(all).toEqual([]); - }); - - test("getBacklinks returns rows for target slug", () => { - createNote( - "test", - "backlink-test-read", - "Read", - TEST_DID, - "Link to [[some-target]]", - ); - const links = getBacklinks("test", "some-target"); - expect(links.length).toBeGreaterThanOrEqual(1); - expect(links[0]?.wiki_slug).toBe("test"); - }); - - test("saveNoteEdit updates backlinks", () => { - const { noteAtUri } = createNote( - "test", - "backlink-test-edit", - "Edit", - TEST_DID, - "Link to [[old-target]]", - ); - saveNoteEdit( - "test", - "backlink-test-edit", - "Now links to [[new-target]]", - TEST_DID, - ); - - const links = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri) as { target_note_slug: string }[]; - const targets = links.map((l) => l.target_note_slug); - expect(targets).toContain("new-target"); - expect(targets).not.toContain("old-target"); - }); - - test("saveNoteEdit clears backlinks when wikilinks removed", () => { - const { noteAtUri } = createNote( - "test", - "backlink-test-clear", - "Clear", - TEST_DID, - "Link to [[will-remove]]", - ); - saveNoteEdit("test", "backlink-test-clear", "No more links", TEST_DID); - - const links = db - .query("SELECT * FROM backlinks WHERE source_note_uri = ?") - .all(noteAtUri); - expect(links).toEqual([]); - }); - - test("backlinks have correct wiki_slug", () => { - createNote( - "test", - "backlink-test-wslug", - "WSlug", - TEST_DID, - "Link to [[check-slug]]", - ); - const links = getBacklinks("test", "check-slug"); - for (const link of links) { - expect(link.wiki_slug).toBe("test"); - } - }); -}); - -describe("snapshots", () => { - const TEST_DID = "did:plc:mock123"; - - test("createNote creates a snapshot", () => { - const { noteAtUri } = createNote( - "test", - "snapshot-test-create", - "Snap Create", - TEST_DID, - "Initial content", - ); - const snaps = getSnapshots(noteAtUri); - expect(snaps.length).toBe(1); - expect(snaps[0]?.content).toBe("Initial content"); - }); - - test("snapshot revision_at_uri matches the revision", () => { - const { noteAtUri, revisionAtUri } = createNote( - "test", - "snapshot-test-revuri", - "Snap Rev", - TEST_DID, - "Content", - ); - const snaps = getSnapshots(noteAtUri); - expect(snaps[0]?.revision_at_uri).toBe(revisionAtUri); - }); - - test("saveNoteEdit appends a snapshot", () => { - const { noteAtUri } = createNote( - "test", - "snapshot-test-edit", - "Snap Edit", - TEST_DID, - "v1", - ); - saveNoteEdit("test", "snapshot-test-edit", "v2", TEST_DID); - - const snaps = getSnapshots(noteAtUri); - expect(snaps.length).toBe(2); - // DESC order: newest first - expect(snaps[0]?.content).toBe("v2"); - expect(snaps[1]?.content).toBe("v1"); - }); - - test("capSnapshots removes oldest beyond limit", () => { - const { noteAtUri } = createNote( - "test", - "snapshot-test-cap", - "Snap Cap", - TEST_DID, - "v1", - ); - // Create 4 more snapshots (total 5 with the one from createNote) - for (let i = 2; i <= 5; i++) { - saveNoteEdit("test", "snapshot-test-cap", `v${i}`, TEST_DID); - } - expect(getSnapshots(noteAtUri).length).toBe(5); - - // Cap at 3 — should remove 2 oldest - capSnapshots(noteAtUri, 3); - const remaining = getSnapshots(noteAtUri); - expect(remaining.length).toBe(3); - expect(remaining[0]?.content).toBe("v5"); - expect(remaining[2]?.content).toBe("v3"); - }); - - test("capSnapshots is a no-op when under limit", () => { - const { noteAtUri } = createNote( - "test", - "snapshot-test-under", - "Snap Under", - TEST_DID, - "only one", - ); - capSnapshots(noteAtUri, 25); - expect(getSnapshots(noteAtUri).length).toBe(1); - }); - - test("capSnapshots at exact limit keeps all", () => { - const { noteAtUri } = createNote( - "test", - "snapshot-test-exact", - "Snap Exact", - TEST_DID, - "v1", - ); - saveNoteEdit("test", "snapshot-test-exact", "v2", TEST_DID); - capSnapshots(noteAtUri, 2); - expect(getSnapshots(noteAtUri).length).toBe(2); - }); - - test("getSnapshots returns empty for unknown note", () => { - expect(getSnapshots("at://fake/uri")).toEqual([]); - }); -}); diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts new file mode 100644 index 0000000..316a79b --- /dev/null +++ b/tests/server/db/queries/revision.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { getDb } from "../../../../src/server/db/index.ts"; +import { + capSnapshots, + createNote, + getBacklinks, + getSnapshots, + saveNoteEdit, +} from "../../../../src/server/db/queries/index.ts"; +import { cleanupNotes } from "./helpers.ts"; + +const db = getDb(); +const TEST_DID = "did:plc:mock123"; + +beforeAll(() => { + cleanupNotes("test", "backlink-test-*"); + cleanupNotes("test", "snapshot-test-*"); +}); + +afterAll(() => { + cleanupNotes("test", "backlink-test-*"); + cleanupNotes("test", "snapshot-test-*"); +}); + +describe("backlinks", () => { + test("createNote populates backlinks from wikilinks", () => { + const { noteAtUri } = createNote( + "test", + "backlink-test-create", + "Backlink Create", + TEST_DID, + "Link to [[target-a]] and [[target-b]]", + ); + const links = getBacklinks("test", "target-a"); + const sourceUris = links.map((l) => l.source_note_uri); + expect(sourceUris).toContain(noteAtUri); + }); + + test("createNote with no wikilinks produces no backlinks", () => { + const { noteAtUri } = createNote( + "test", + "backlink-test-nolinks", + "No Links", + TEST_DID, + "No links here", + ); + const all = db + .query("SELECT * FROM backlinks WHERE source_note_uri = ?") + .all(noteAtUri); + expect(all).toEqual([]); + }); + + test("getBacklinks returns rows for target slug", () => { + createNote( + "test", + "backlink-test-read", + "Read", + TEST_DID, + "Link to [[some-target]]", + ); + const links = getBacklinks("test", "some-target"); + expect(links.length).toBeGreaterThanOrEqual(1); + expect(links[0]?.wiki_slug).toBe("test"); + }); + + test("saveNoteEdit updates backlinks", () => { + const { noteAtUri } = createNote( + "test", + "backlink-test-edit", + "Edit", + TEST_DID, + "Link to [[old-target]]", + ); + saveNoteEdit( + "test", + "backlink-test-edit", + "Now links to [[new-target]]", + TEST_DID, + ); + + const links = db + .query("SELECT * FROM backlinks WHERE source_note_uri = ?") + .all(noteAtUri) as { target_note_slug: string }[]; + const targets = links.map((l) => l.target_note_slug); + expect(targets).toContain("new-target"); + expect(targets).not.toContain("old-target"); + }); + + test("saveNoteEdit clears backlinks when wikilinks removed", () => { + const { noteAtUri } = createNote( + "test", + "backlink-test-clear", + "Clear", + TEST_DID, + "Link to [[will-remove]]", + ); + saveNoteEdit("test", "backlink-test-clear", "No more links", TEST_DID); + + const links = db + .query("SELECT * FROM backlinks WHERE source_note_uri = ?") + .all(noteAtUri); + expect(links).toEqual([]); + }); + + test("backlinks have correct wiki_slug", () => { + createNote( + "test", + "backlink-test-wslug", + "WSlug", + TEST_DID, + "Link to [[check-slug]]", + ); + const links = getBacklinks("test", "check-slug"); + for (const link of links) { + expect(link.wiki_slug).toBe("test"); + } + }); +}); + +describe("snapshots", () => { + test("createNote creates a snapshot", () => { + const { noteAtUri } = createNote( + "test", + "snapshot-test-create", + "Snap Create", + TEST_DID, + "Initial content", + ); + const snaps = getSnapshots(noteAtUri); + expect(snaps.length).toBe(1); + expect(snaps[0]?.content).toBe("Initial content"); + }); + + test("snapshot revision_at_uri matches the revision", () => { + const { noteAtUri, revisionAtUri } = createNote( + "test", + "snapshot-test-revuri", + "Snap Rev", + TEST_DID, + "Content", + ); + const snaps = getSnapshots(noteAtUri); + expect(snaps[0]?.revision_at_uri).toBe(revisionAtUri); + }); + + test("saveNoteEdit appends a snapshot", () => { + const { noteAtUri } = createNote( + "test", + "snapshot-test-edit", + "Snap Edit", + TEST_DID, + "v1", + ); + saveNoteEdit("test", "snapshot-test-edit", "v2", TEST_DID); + + const snaps = getSnapshots(noteAtUri); + expect(snaps.length).toBe(2); + // DESC order: newest first + expect(snaps[0]?.content).toBe("v2"); + expect(snaps[1]?.content).toBe("v1"); + }); + + test("capSnapshots removes oldest beyond limit", () => { + const { noteAtUri } = createNote( + "test", + "snapshot-test-cap", + "Snap Cap", + TEST_DID, + "v1", + ); + // Create 4 more snapshots (total 5 with the one from createNote) + for (let i = 2; i <= 5; i++) { + saveNoteEdit("test", "snapshot-test-cap", `v${i}`, TEST_DID); + } + expect(getSnapshots(noteAtUri).length).toBe(5); + + // Cap at 3 — should remove 2 oldest + capSnapshots(noteAtUri, 3); + const remaining = getSnapshots(noteAtUri); + expect(remaining.length).toBe(3); + expect(remaining[0]?.content).toBe("v5"); + expect(remaining[2]?.content).toBe("v3"); + }); + + test("capSnapshots is a no-op when under limit", () => { + const { noteAtUri } = createNote( + "test", + "snapshot-test-under", + "Snap Under", + TEST_DID, + "only one", + ); + capSnapshots(noteAtUri, 25); + expect(getSnapshots(noteAtUri).length).toBe(1); + }); + + test("capSnapshots at exact limit keeps all", () => { + const { noteAtUri } = createNote( + "test", + "snapshot-test-exact", + "Snap Exact", + TEST_DID, + "v1", + ); + saveNoteEdit("test", "snapshot-test-exact", "v2", TEST_DID); + capSnapshots(noteAtUri, 2); + expect(getSnapshots(noteAtUri).length).toBe(2); + }); + + test("getSnapshots returns empty for unknown note", () => { + expect(getSnapshots("at://fake/uri")).toEqual([]); + }); +}); diff --git a/tests/server/db/queries/wiki.test.ts b/tests/server/db/queries/wiki.test.ts new file mode 100644 index 0000000..16f9980 --- /dev/null +++ b/tests/server/db/queries/wiki.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { getWiki, listWikis } from "../../../../src/server/db/queries/index.ts"; + +describe("listWikis", () => { + test("returns seeded wikis", () => { + const wikis = listWikis(); + expect(wikis.length).toBe(1); + const slugs = wikis.map((w) => w.slug); + expect(slugs).toContain("test"); + }); + + test("returns all expected fields", () => { + const wikis = listWikis(); + const testWiki = wikis.find((w) => w.slug === "test"); + expect(testWiki).toBeDefined(); + expect(testWiki?.name).toBe("Test Wiki"); + expect(testWiki?.visibility).toBe("public"); + expect(testWiki?.did).toBe("did:plc:mock123"); + expect(testWiki?.at_uri).toContain("pub.coral.wiki/test"); + expect(testWiki?.created_at).toBeDefined(); + expect(testWiki?.updated_at).toBeDefined(); + }); +}); + +describe("getWiki", () => { + test("returns wiki by slug", () => { + const wiki = getWiki("test"); + expect(wiki).not.toBeNull(); + expect(wiki?.name).toBe("Test Wiki"); + expect(wiki?.visibility).toBe("public"); + }); + + test("returns null for nonexistent slug", () => { + const wiki = getWiki("does-not-exist"); + expect(wiki).toBeNull(); + }); +});