diff --git a/src/lib/blob.ts b/src/lib/blob.ts index 8dbe81d..8fdec69 100644 --- a/src/lib/blob.ts +++ b/src/lib/blob.ts @@ -1,5 +1,4 @@ import type { RevisionBlob } from "../atproto/pds.ts"; -import { insertBlob } from "../server/db/queries/index.ts"; const BLOB_URL_RE = /!\[[^\]]*\]\(\/blob\/(did:[^/]+)\/([^)\s]+)\)/g; @@ -64,12 +63,3 @@ export function buildBlobsForContent( } return blobs; } - -export function persistBlobs( - blobs: RevisionBlob[], - revisionAtUri: string, -): void { - for (const blob of blobs) { - insertBlob(blob.ref.$link, revisionAtUri, blob.mimeType, blob.ref.$link); - } -} diff --git a/src/lib/import-export/export.ts b/src/lib/import-export/export.ts index 73fcf0d..e575cd8 100644 --- a/src/lib/import-export/export.ts +++ b/src/lib/import-export/export.ts @@ -1,6 +1,10 @@ import { zipSync } from "fflate"; import { getBlobsByCids } from "../../server/db/queries/blob.ts"; -import { getCurrentNote, listNotes } from "../../server/db/queries/index.ts"; +import { + getCurrentNote, + getSidebarNotes, + listNotesWithContent, +} from "../../server/db/queries/index.ts"; import { MIME_TO_EXT } from "../constants.ts"; import { resolvePdsEndpoint } from "../identity.ts"; import { rewriteForExport } from "./markdown-transform.ts"; @@ -13,15 +17,14 @@ const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; * Blob images are fetched and saved as attachments/{cid}.{ext}. */ export async function exportWikiZip(wikiSlug: string): Promise { - const notes = listNotes(wikiSlug); + const notes = listNotesWithContent(wikiSlug); const slugToTitle = new Map(); const noteContents = new Map(); for (const note of notes) { slugToTitle.set(note.slug, note.title); - const current = getCurrentNote(wikiSlug, note.slug); - if (current) { - noteContents.set(note.slug, current.content); + if (note.content !== null) { + noteContents.set(note.slug, note.content); } } @@ -139,9 +142,8 @@ export function exportNoteMd( const current = getCurrentNote(wikiSlug, noteSlug); if (!current) return null; - const notes = listNotes(wikiSlug); const slugToTitle = new Map(); - for (const note of notes) { + for (const note of getSidebarNotes(wikiSlug)) { slugToTitle.set(note.slug, note.title); } diff --git a/src/lib/import-export/import.ts b/src/lib/import-export/import.ts index cf3e387..325ccf4 100644 --- a/src/lib/import-export/import.ts +++ b/src/lib/import-export/import.ts @@ -2,7 +2,7 @@ import { writeNoteRecord, writeRevisionRecord } from "../../atproto/pds.ts"; import type { getAgent } from "../../atproto/session.ts"; import { createNote } from "../../server/db/queries/index.ts"; import type { RequestContext } from "../access.ts"; -import { type BlobMeta, buildBlobsForContent, persistBlobs } from "../blob.ts"; +import { type BlobMeta, buildBlobsForContent } from "../blob.ts"; import { createDiff } from "../diff.ts"; import type { Messages } from "../i18n/index.ts"; import { processImage } from "../image.ts"; @@ -94,8 +94,8 @@ export async function importWikiAction( did, content, "Imported", + blobs.length > 0 ? blobs : undefined, ); - persistBlobs(blobs, revisionAtUri); } return { wikiSlug, noteCount: notes.length, warnings }; diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 99057e9..8beda78 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -17,7 +17,6 @@ import { type BlobMeta, buildBlobsForContent, parseBlobMetadata, - persistBlobs, } from "../blob.ts"; import { COLLECTIONS } from "../constants.ts"; import { createDiff } from "../diff.ts"; @@ -163,8 +162,8 @@ export async function createNoteAction( did, fields.content, fields.message, + blobs.length > 0 ? blobs : undefined, ); - persistBlobs(blobs, revisionAtUri); return { noteSlug }; } @@ -248,8 +247,8 @@ export async function editNoteAction( did, fields.message, newTitle, + blobs.length > 0 ? blobs : undefined, ); - persistBlobs(blobs, revisionAtUri); } /** diff --git a/src/server/db/queries/blob.ts b/src/server/db/queries/blob.ts index b7be804..3dd1205 100644 --- a/src/server/db/queries/blob.ts +++ b/src/server/db/queries/blob.ts @@ -1,20 +1,6 @@ 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(); diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 6620320..88157fd 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -1,6 +1,5 @@ // Barrel re-export — all query functions accessible from "db/queries" export type { MembershipRow, RequestRow, WikiRow } from "../types.ts"; -export { insertBlob } from "./blob.ts"; export { deleteBookmarkByUri, deleteBookmarkByWiki, @@ -33,7 +32,7 @@ export { getNoteBySlug, getNoteWithCurrent, getSidebarNotes, - listNotes, + listNotesWithContent, saveNoteEdit, searchNotes, upsertNote, diff --git a/src/server/db/queries/note.ts b/src/server/db/queries/note.ts index b25457f..19a6169 100644 --- a/src/server/db/queries/note.ts +++ b/src/server/db/queries/note.ts @@ -1,3 +1,4 @@ +import type { RevisionBlob } from "../../../atproto/pds.ts"; import { createDiff } from "../../../lib/diff.ts"; import { escapeLikePattern } from "../../../lib/html.ts"; import { getDb } from "../index.ts"; @@ -27,14 +28,28 @@ interface NoteWithCurrent { export function getSidebarNotes( wikiSlug: string, ): { slug: string; title: string }[] { - return listNotes(wikiSlug).map((n) => ({ slug: n.slug, title: n.title })); + const db = getDb(); + return db + .query( + "SELECT slug, title FROM notes WHERE wiki_slug = ? ORDER BY created_at DESC", + ) + .all(wikiSlug) as { slug: string; title: string }[]; } -export function listNotes(wikiSlug: string): NoteRow[] { +/** Fetch all notes with their current content in a single query. */ +export function listNotesWithContent( + wikiSlug: string, +): { slug: string; title: string; content: string | null }[] { const db = getDb(); return db - .query("SELECT * FROM notes WHERE wiki_slug = ? ORDER BY created_at DESC") - .all(wikiSlug) as NoteRow[]; + .query( + `SELECT n.slug, n.title, c.content + FROM notes n + LEFT JOIN current_note c ON c.note_at_uri = n.at_uri + WHERE n.wiki_slug = ? + ORDER BY n.created_at DESC`, + ) + .all(wikiSlug) as { slug: string; title: string; content: string | null }[]; } export interface NoteSearchResult extends NoteRow { @@ -160,6 +175,7 @@ export function createNote( did: string, initialContent: string, message?: string, + blobs?: RevisionBlob[], ): void { const db = getDb(); const diff = createDiff("", initialContent); @@ -178,6 +194,7 @@ export function createNote( diff, message: message ?? null, newContent: initialContent, + ...(blobs ? { blobs } : {}), }); })(); @@ -196,6 +213,7 @@ export function saveNoteEdit( did: string, message?: string, newTitle?: string, + blobs?: RevisionBlob[], ): void { const db = getDb(); @@ -234,6 +252,7 @@ export function saveNoteEdit( diff, message: message ?? null, newContent, + ...(blobs ? { blobs } : {}), }); })(); diff --git a/tests/server/db/queries/blob.test.ts b/tests/server/db/queries/blob.test.ts index 108c0ca..ee8886b 100644 --- a/tests/server/db/queries/blob.test.ts +++ b/tests/server/db/queries/blob.test.ts @@ -1,9 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { getDb } from "../../../../src/server/db/index.ts"; -import { - getBlobsByCids, - insertBlob, -} from "../../../../src/server/db/queries/blob.ts"; +import { getBlobsByCids } from "../../../../src/server/db/queries/blob.ts"; const db = getDb(); @@ -11,15 +8,7 @@ const TEST_DID = "did:plc:blobtest"; const WIKI_SLUG = "blob-test-wiki"; const WIKI_AT_URI = `at://${TEST_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; const NOTE_AT_URI = `at://${TEST_DID}/wiki.lichen.note/note1`; -const REV_URI_1 = `at://${TEST_DID}/wiki.lichen.noteRevision/rev1`; -const REV_URI_2 = `at://${TEST_DID}/wiki.lichen.noteRevision/rev2`; - -const TEST_CIDS = [ - "bafytest-blob-1", - "bafytest-blob-2", - "bafytest-blob-3", - "bafytest-blob-dup", -]; +const REV_URI = `at://${TEST_DID}/wiki.lichen.noteRevision/rev1`; beforeAll(() => { db.run( @@ -32,90 +21,36 @@ beforeAll(() => { ); db.run( "INSERT INTO revisions (note_at_uri, did, at_uri, diff) VALUES (?, ?, ?, ?)", - [NOTE_AT_URI, TEST_DID, REV_URI_1, ""], + [NOTE_AT_URI, TEST_DID, REV_URI, ""], ); db.run( - "INSERT INTO revisions (note_at_uri, did, at_uri, diff) VALUES (?, ?, ?, ?)", - [NOTE_AT_URI, TEST_DID, REV_URI_2, ""], + "INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) VALUES (?, ?, ?, ?)", + ["bafytest-blob-1", REV_URI, "image/webp", "bafytest-blob-1"], + ); + db.run( + "INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) VALUES (?, ?, ?, ?)", + ["bafytest-blob-2", REV_URI, "image/gif", "bafytest-blob-2"], ); }); afterAll(() => { - for (const cid of TEST_CIDS) { - db.run("DELETE FROM blobs WHERE cid = ?", [cid]); - } db.run("DELETE FROM wikis WHERE slug = ?", [WIKI_SLUG]); }); -describe("insertBlob", () => { - test("inserts a blob row", () => { - insertBlob( - "bafytest-blob-1", - REV_URI_1, - "image/png", - "data/blobs/test1.png", - ); - - const row = db - .query("SELECT * FROM blobs WHERE cid = ?") - .get("bafytest-blob-1") as Record | null; - expect(row).not.toBeNull(); - expect(row?.["mime_type"]).toBe("image/png"); - expect(row?.["storage_key"]).toBe("data/blobs/test1.png"); - }); - - test("INSERT OR IGNORE on duplicate CID", () => { - insertBlob( - "bafytest-blob-dup", - REV_URI_1, - "image/jpeg", - "data/blobs/first.jpg", - ); - // Insert again with different values -- should be ignored - insertBlob( - "bafytest-blob-dup", - REV_URI_2, - "image/png", - "data/blobs/second.png", - ); - - const row = db - .query("SELECT * FROM blobs WHERE cid = ?") - .get("bafytest-blob-dup") as Record | null; - // Original values preserved - expect(row?.["mime_type"]).toBe("image/jpeg"); - expect(row?.["storage_key"]).toBe("data/blobs/first.jpg"); - }); -}); - describe("getBlobsByCids", () => { test("empty array returns empty array", () => { expect(getBlobsByCids([])).toEqual([]); }); test("returns matching blobs", () => { - insertBlob( - "bafytest-blob-2", - REV_URI_1, - "image/webp", - "data/blobs/test2.webp", - ); - insertBlob( - "bafytest-blob-3", - REV_URI_1, - "image/gif", - "data/blobs/test3.gif", - ); - - const blobs = getBlobsByCids(["bafytest-blob-2", "bafytest-blob-3"]); + const blobs = getBlobsByCids(["bafytest-blob-1", "bafytest-blob-2"]); expect(blobs).toHaveLength(2); const cids = blobs.map((b) => b.cid); + expect(cids).toContain("bafytest-blob-1"); expect(cids).toContain("bafytest-blob-2"); - expect(cids).toContain("bafytest-blob-3"); }); test("nonexistent CIDs return empty array", () => { - const blobs = getBlobsByCids(["bafynonexistent1", "bafynonexistent2"]); - expect(blobs).toEqual([]); + expect(getBlobsByCids(["bafynonexistent1"])).toEqual([]); }); }); diff --git a/tests/server/db/queries/note.test.ts b/tests/server/db/queries/note.test.ts index 7d1aaa0..e93a0f4 100644 --- a/tests/server/db/queries/note.test.ts +++ b/tests/server/db/queries/note.test.ts @@ -8,7 +8,7 @@ import { getCurrentNote, getNoteByAtUri, getNoteBySlug, - listNotes, + listNotesWithContent, saveNoteEdit, searchNotes, upsertNote, @@ -29,7 +29,6 @@ beforeAll(() => { cleanupNotes("test", "search-test-*"); cleanupNotes("test", "upsert-test-*"); cleanupNotes("test", "delete-test-*"); - cleanupNotes("test", "list-test-*"); cleanupNotes("test", "read-test-*"); // Create notes for read-only tests @@ -68,36 +67,39 @@ afterAll(() => { cleanupNotes("test", "search-test-*"); cleanupNotes("test", "upsert-test-*"); cleanupNotes("test", "delete-test-*"); - cleanupNotes("test", "list-test-*"); cleanupNotes("test", "read-test-*"); }); -describe("listNotes", () => { - test("returns notes for a wiki", () => { - const notes = listNotes("test"); +describe("listNotesWithContent", () => { + test("returns notes with content for a wiki", () => { + const notes = listNotesWithContent("test"); expect(notes.length).toBeGreaterThanOrEqual(3); - const slugs = notes.map((n) => n.slug); - expect(slugs).toContain("read-test-home"); - expect(slugs).toContain("read-test-hello"); - expect(slugs).toContain("read-test-guide"); - }); - - test("returns notes with titles", () => { - const notes = listNotes("test"); const hello = notes.find((n) => n.slug === "read-test-hello"); + expect(hello).toBeDefined(); expect(hello?.title).toBe("Hello World"); + expect(hello?.content).toContain("# Hello World"); }); test("returns empty array for nonexistent wiki", () => { - const notes = listNotes("nonexistent"); - expect(notes).toEqual([]); + expect(listNotesWithContent("nonexistent")).toEqual([]); }); - test("notes have correct wiki_slug", () => { - const notes = listNotes("test"); - for (const note of notes) { - expect(note.wiki_slug).toBe("test"); - } + test("content is null for notes without current_note", () => { + const noteAtUri = `at://${TEST_DID}/wiki.lichen.note/${generateTid()}`; + upsertNote( + "test", + "read-test-no-content", + "No Content", + TEST_DID, + noteAtUri, + "2026-01-01T00:00:00.000Z", + ); + const notes = listNotesWithContent("test"); + const found = notes.find((n) => n.slug === "read-test-no-content"); + expect(found).toBeDefined(); + expect(found?.content).toBeNull(); + // cleanup + getDb().run("DELETE FROM notes WHERE at_uri = ?", [noteAtUri]); }); }); diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index 0ed4d08..3315d1d 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { generateTid } from "../../../../src/lib/tid.ts"; import { getDb } from "../../../../src/server/db/index.ts"; +import { getBlobsByCids } from "../../../../src/server/db/queries/blob.ts"; import { capSnapshots, createNote, @@ -21,11 +22,13 @@ beforeAll(() => { ensureTestWiki(); cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); + cleanupNotes("test", "blob-test-*"); }); afterAll(() => { cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); + cleanupNotes("test", "blob-test-*"); }); describe("backlinks", () => { @@ -249,3 +252,120 @@ describe("snapshots", () => { expect(getSnapshots("at://fake/uri")).toEqual([]); }); }); + +describe("blobs in appendRevisionTx", () => { + test("createNote persists blobs", () => { + const nUri = noteUri(); + createNote( + nUri, + revUri(), + "test", + "blob-test-create", + "Blob Create", + TEST_DID, + "content", + undefined, + [ + { + $type: "blob", + ref: { $link: "bafyrev-create-1" }, + mimeType: "image/png", + size: 1000, + }, + { + $type: "blob", + ref: { $link: "bafyrev-create-2" }, + mimeType: "image/webp", + size: 2000, + }, + ], + ); + const blobs = getBlobsByCids(["bafyrev-create-1", "bafyrev-create-2"]); + expect(blobs).toHaveLength(2); + expect(blobs.map((b) => b.mime_type).sort()).toEqual([ + "image/png", + "image/webp", + ]); + }); + + test("saveNoteEdit persists blobs", () => { + createNote( + noteUri(), + revUri(), + "test", + "blob-test-edit", + "Blob Edit", + TEST_DID, + "v1", + ); + saveNoteEdit( + revUri(), + "test", + "blob-test-edit", + "v2", + TEST_DID, + undefined, + undefined, + [ + { + $type: "blob", + ref: { $link: "bafyrev-edit-1" }, + mimeType: "image/gif", + size: 500, + }, + ], + ); + const blobs = getBlobsByCids(["bafyrev-edit-1"]); + expect(blobs).toHaveLength(1); + expect(blobs[0]?.mime_type).toBe("image/gif"); + }); + + test("createNote without blobs inserts no blob rows", () => { + createNote( + noteUri(), + revUri(), + "test", + "blob-test-none", + "No Blobs", + TEST_DID, + "content", + ); + const blobs = getBlobsByCids(["bafyrev-none-should-not-exist"]); + expect(blobs).toEqual([]); + }); + + test("duplicate blob CID is ignored", () => { + const nUri = noteUri(); + const blob = { + $type: "blob" as const, + ref: { $link: "bafyrev-dup-1" }, + mimeType: "image/jpeg", + size: 300, + }; + createNote( + nUri, + revUri(), + "test", + "blob-test-dup", + "Blob Dup", + TEST_DID, + "v1", + undefined, + [blob], + ); + // Same CID on a second revision -- INSERT OR IGNORE should keep the original + saveNoteEdit( + revUri(), + "test", + "blob-test-dup", + "v2", + TEST_DID, + undefined, + undefined, + [{ ...blob, mimeType: "image/png", size: 999 }], + ); + const blobs = getBlobsByCids(["bafyrev-dup-1"]); + expect(blobs).toHaveLength(1); + expect(blobs[0]?.mime_type).toBe("image/jpeg"); + }); +});