/** * Everything the app can do, as plain functions: `(db, who, input)` in, * data or a named failure out. The page, the JSON API, the MCP server and * the terminal are doors onto these and decide nothing themselves — so a * rule written here holds at every door, and a new door is only a new * way to call. */ import type { Database } from "bun:sqlite"; import { record, type AuditRow } from "./audit.ts"; import { permit } from "./contract.ts"; import { emit } from "./live.ts"; import { cleanFolder, cleanSegment, edited, fileOf, folderOf, getSpace, isMember, spaceOf, spaceVault, tellSpace, type Space, } from "./spaces.ts"; import { relink, search } from "./vault.ts"; import type { Who } from "./who.ts"; export interface Note { readonly id: number; readonly title: string; readonly body: string; readonly pinned: number; readonly author_id: string; readonly author_name: string; readonly created_at: string; readonly updated_at: string; /** A shared note's file in its repository; null for a note of your own. */ readonly path: string | null; /** Where the repository last had it, and the git blob it had (src/sync.ts). */ readonly synced_path: string | null; readonly synced_oid: string | null; } export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid" | "conflict"; export type Result = | { ok: true; value: T } | { ok: false; error: Failure; message: string }; const ok = (value: T): Result => ({ ok: true, value }); const fail = (error: Failure, message: string): Result => ({ ok: false, error, message, }); export const TITLE_MAX = 200; export const BODY_MAX = 200_000; /** The vault, at most: a list asks for no more than this many. */ export const VAULT_MAX = 5000; /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ const refuse = (who: Who, signIn: string, no: string): Result => who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); /** * A change, written into the audit trail whichever way it went: the row it * made or touched, or the one it was refused. Every action that changes * something returns through here, so the trail holds at every door. */ const audited = ( db: Database, who: Who, resource: string, verb: "create" | "update" | "delete", target: number | undefined, result: Result, ): Result => { record(db, { who, resource, verb, target: result.ok ? result.value.id : target, outcome: result.ok ? "ok" : result.error, }); return result; }; /** Whose vault a change belongs to: its live events reach only them — or, in a shared folder, its members. */ const tell = (db: Database, type: string, note: Note): void => { const data = { id: note.id, title: note.title, updated_at: note.updated_at }; const space = spaceOf(note.author_id); if (space === undefined) emit(type, data, note.author_id); else tellSpace(db, space, type, data); }; /** * The vault `who` means: their own, or — named by `space` — a shared * folder they are in. Every vault query takes the answer's `vault` where * it once took `who.id`, which is all a shared folder needs. */ function vaultFor( db: Database, who: Who, space: number | undefined, ): Result<{ vault: string; space?: Space }> { if (who.role === "anon") return fail("signin_required", "Sign in to open your notes."); if (space === undefined) return ok({ vault: who.id }); const found = getSpace(db, who, space); if (!found.ok) return found; return ok({ vault: spaceVault(space), space: found.value }); } /** * A title, as the vault keeps it: trimmed, one line, and free of the * characters a [[link]] uses to say something else (`[ ] | # ^`). */ export function cleanTitle(raw: string): Result { const title = raw.replace(/\s+/g, " ").trim(); if (title.length === 0 || title.length > TITLE_MAX) return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); if (/[\[\]|#^\\]/.test(title)) return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them."); return ok(title); } const checkBody = (body: string): Result => body.length > BODY_MAX ? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`) : ok(body); /** * The vault: the notes of whoever asks, pinned first, then the most * recently changed. A vault is its author's — the table gives `list` to the * row's author — so the list is always the asker's own notes, and nobody * else's, whatever role they hold. */ export function listNotes( db: Database, who: Who, input: { limit?: number; space?: number } = {}, ): Result { const limit = Math.min( Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1), VAULT_MAX, ); const may = permit(who, "note", "list"); if (may === "no" || who.role === "anon") return refuse(who, "Sign in to open your notes.", "You may not see these notes."); const vault = vaultFor(db, who, input.space); if (!vault.ok) return vault; return ok( db .query( "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2", ) .all(vault.value.vault, limit), ); } /** The next free "Untitled", "Untitled 2", … in a vault. */ function freeTitle(db: Database, authorId: string, base: string): string { const taken = (t: string) => db .query<{ id: number }, [string, string]>( "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", ) .get(authorId, t) !== null; if (!taken(base)) return base; for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`; } const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean => db .query<{ id: number }, [string, string, number]>( "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3", ) .get(authorId, title, except) !== null; /** What a new note may say: in a shared folder, also which folder (or a title `folder/Title`). */ export interface NewNote { readonly title?: string; readonly body?: string; readonly space?: number; readonly folder?: string; } export const addNote = (db: Database, who: Who, input: NewNote): Result => audited(db, who, "note", "create", undefined, add(db, who, input)); /** A shared note's title and folder: its title is a file name, and `a/b/Title` puts it in a folder. */ function placeOf(input: { title: string; folder?: string }): Result<{ title: string; folder: string }> { const cut = input.title.lastIndexOf("/"); const title = cleanSegment(cut < 0 ? input.title : input.title.slice(cut + 1)); if (!title.ok) return title; const folder = cleanFolder( [input.folder ?? "", cut < 0 ? "" : input.title.slice(0, cut)].filter((f) => f !== "").join("/"), ); if (!folder.ok) return folder; return ok({ title: title.value, folder: folder.value }); } function add(db: Database, who: Who, input: NewNote): Result { // The table decides; the type only learns it here: whoever may create is // signed in, and carries a name to write beside the note. if (who.role === "anon" || permit(who, "note", "create") !== "yes") return refuse(who, "Sign in to add a note.", "You may not add a note."); const where = vaultFor(db, who, input.space); if (!where.ok) return where; const { vault, space } = where.value; const body = checkBody(input.body ?? ""); if (!body.ok) return body; const count = db .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1") .get(vault)!.n; if (count >= VAULT_MAX) return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`); let title: string; let folder = ""; if (input.title === undefined || input.title.trim() === "" || input.title.trim().endsWith("/")) { if (space !== undefined) { const at = cleanFolder(`${input.folder ?? ""}/${input.title ?? ""}`); if (!at.ok) return at; folder = at.value; } title = freeTitle(db, vault, "Untitled"); } else if (space !== undefined) { const place = placeOf({ title: input.title, ...(input.folder === undefined ? {} : { folder: input.folder }) }); if (!place.ok) return place; title = place.value.title; folder = place.value.folder; } else { const clean = cleanTitle(input.title); if (!clean.ok) return clean; title = clean.value; } if (titleTaken(db, vault, title)) return fail("invalid", `A note called “${title}” already exists.`); const path = space === undefined ? null : fileOf(space, folder, title); if (path !== null && pathTaken(db, vault, path)) return fail("invalid", `There is already a file at ${path}.`); const note = db .query( "INSERT INTO note (title, body, author_id, author_name, path) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *", ) .get(title, body.value, vault, who.name, path)!; tell(db, "note.added", note); if (space !== undefined) edited(space.id); return ok(note); } const pathTaken = (db: Database, vault: string, path: string, except = -1): boolean => db .query<{ id: number }, [string, string, number]>( "SELECT id FROM note WHERE author_id = ?1 AND path = ?2 COLLATE NOCASE AND id != ?3", ) .get(vault, path, except) !== null; /** * One note, for `who` to do `verb` to. The door is asked before the row is * looked for, so whoever may not is told that and never learns whether the * row exists; whoever may, and asked for nothing, hears `not_found`. */ function noteFor( db: Database, who: Who, verb: "read" | "update" | "delete", id: number, said: { signIn: string; no: string }, ): Result { const note = Number.isInteger(id) ? db.query("SELECT * FROM note WHERE id = ?1").get(id) : null; // With no row to show, the door is asked about a row of their own: whoever // could not pass even then is refused, and learns nothing about what // exists; whoever could is told there is no such note. // A shared note is its folder's: whoever is in the folder is the row's // member, which is what the table gives a shared note's verbs to. const space = note === null ? undefined : spaceOf(note.author_id); const row: Readonly> = note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note, member_id: space !== undefined && who.role !== "anon" && isMember(db, space, who) ? who.id : null, }; if (permit(who, "note", verb, row) !== "yes") return refuse(who, said.signIn, said.no); if (note !== null) return ok(note); if (!Number.isInteger(id)) return fail("invalid", "A note id is a whole number."); return fail("not_found", "No such note."); } export function getNote( db: Database, who: Who, input: { id: number }, ): Result { return noteFor(db, who, "read", input.id, { signIn: "Sign in to read this note.", no: "You may not read this note.", }); } /** A note in the asker's vault (or a shared folder of theirs) by its title, any case. */ export function findNote( db: Database, who: Who, input: { title: string; space?: number }, ): Result { const where = vaultFor(db, who, input.space); if (!where.ok) return where; // In a shared folder `ideas/Pricing` names the note Pricing. const raw = input.space === undefined ? input.title : input.title.slice(input.title.lastIndexOf("/") + 1); const title = raw.replace(/\s+/g, " ").trim(); const note = db .query( "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", ) .get(where.value.vault, title); if (note === null) return fail("not_found", `No note called “${title}”.`); return getNote(db, who, { id: note.id }); } /** The note with this title, made (empty, or from `body`) when there is none yet: following a link, opening today. */ export function openOrCreate( db: Database, who: Who, input: { title: string; body?: string; space?: number; folder?: string }, ): Result { const found = findNote(db, who, input); if (found.ok) return ok({ ...found.value, created: false }); if (found.error !== "not_found") return found; const made = addNote(db, who, { ...input, body: input.body ?? "" }); return made.ok ? ok({ ...made.value, created: true }) : made; } export interface Change { readonly id: number; readonly title?: string; readonly body?: string; readonly pinned?: boolean; /** A shared note's folder, relative to its space's: a move. */ readonly folder?: string; /** * The `updated_at` the change was written against. When the note has * moved on since — someone else saved it — new words or a new title are * refused as a `conflict` rather than written over theirs. */ readonly base?: string; } export const updateNote = ( db: Database, who: Who, input: Change, ): Result => audited(db, who, "note", "update", input.id, update(db, who, input)); /** * Change a note's title, words or pin. A new title is carried into every * note of the vault that linked to the old one, in the same transaction, * so a rename never leaves a link pointing at nothing. */ function update( db: Database, who: Who, input: Change, ): Result { const found = noteFor(db, who, "update", input.id, { signIn: "Sign in to edit a note.", no: "Only the author can edit a note.", }); if (!found.ok) return found; const before = found.value; const spaceId = spaceOf(before.author_id); const space = spaceId === undefined ? undefined : getSpace(db, who, spaceId); if (space !== undefined && !space.ok) return space; if ( input.base !== undefined && input.base !== before.updated_at && ((input.body !== undefined && input.body !== before.body) || (input.title !== undefined && input.title !== before.title)) ) return fail("conflict", `${before.author_name} saved this note while you were writing.`); let title = before.title; if (input.title !== undefined) { const clean = space === undefined ? cleanTitle(input.title) : cleanSegment(input.title); if (!clean.ok) return clean; if (titleTaken(db, before.author_id, clean.value, before.id)) return fail("invalid", `A note called “${clean.value}” already exists.`); title = clean.value; } let body = before.body; if (input.body !== undefined) { const checked = checkBody(input.body); if (!checked.ok) return checked; body = checked.value; } const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0; // A shared note's file follows its title, and its folder when it moves. let path = before.path; if (space !== undefined && before.path !== null) { let folder = folderOf(space.value, before.path); if (input.folder !== undefined) { const clean = cleanFolder(input.folder); if (!clean.ok) return clean; folder = clean.value; } // A file whose name was never its title (two files of one name, read // from the repository) keeps its name until it is renamed or moved. path = title === before.title && input.folder === undefined ? before.path : fileOf(space.value, folder, title); if (path !== before.path && pathTaken(db, before.author_id, path, before.id)) return fail("invalid", `There is already a file at ${path}.`); } if (title === before.title && body === before.body && pinned === before.pinned && path === before.path) return ok({ ...before, relinked: 0 }); const renamed = title.toLowerCase() !== before.title.toLowerCase(); const touched: Note[] = []; // In a shared folder the name beside a note is whoever wrote in it last. const by = space !== undefined && who.role !== "anon" ? who.name : before.author_name; const note = db.transaction(() => { const saved = db .query( // A pin is not an edit: only new words, a new title or a move move a note up by recency. "UPDATE note SET title = ?1, body = ?2, pinned = ?3, path = ?6, author_name = CASE WHEN ?5 THEN ?7 ELSE author_name END, updated_at = CASE WHEN ?5 THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE updated_at END WHERE id = ?4 RETURNING *", ) .get( title, body, pinned, before.id, title !== before.title || body !== before.body || path !== before.path ? 1 : 0, path, by, )!; if (renamed || title !== before.title) for (const other of db .query("SELECT * FROM note WHERE author_id = ?1 AND id != ?2") .all(before.author_id, before.id)) { const next = relink(other.body, before.title, title); if (next === other.body) continue; touched.push( db .query( "UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *", ) .get(next, other.id)!, ); } return saved; })(); tell(db, "note.changed", note); for (const other of touched) tell(db, "note.changed", other); if (spaceId !== undefined) edited(spaceId); return ok({ ...note, relinked: touched.length }); } export const deleteNote = ( db: Database, who: Who, input: { id: number }, ): Result<{ id: number }> => audited(db, who, "note", "delete", input.id, remove(db, who, input)); function remove( db: Database, who: Who, input: { id: number }, ): Result<{ id: number }> { const found = noteFor(db, who, "delete", input.id, { signIn: "Sign in to remove a note.", no: "Only the author can remove a note.", }); if (!found.ok) return found; const note = found.value; const space = spaceOf(note.author_id); db.transaction(() => { db.query("DELETE FROM note WHERE id = ?1").run(note.id); // The repository still has its file until the next commit takes it away. if (space !== undefined && note.synced_path !== null) db.query("INSERT OR IGNORE INTO space_gone (space_id, path) VALUES (?1, ?2)").run(space, note.synced_path); })(); if (space === undefined) emit("note.removed", { id: note.id }, note.author_id); else { tellSpace(db, space, "note.removed", { id: note.id }); edited(space); } return ok({ id: note.id }); } /** Search the asker's vault (see `vault.search` for the language). */ export function searchNotes( db: Database, who: Who, input: { q: string; limit?: number; space?: number }, ): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> { const vault = listNotes(db, who, input.space === undefined ? {} : { space: input.space }); if (!vault.ok) return vault; const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500); return ok( search(vault.value, input.q.slice(0, 500)) .slice(0, limit) .map(({ note, snippet }) => ({ id: note.id, title: note.title, snippet, updated_at: note.updated_at, })), ); } /** Today's daily note title, `YYYY-MM-DD`, in the given time zone offset (minutes, as `Date#getTimezoneOffset`). */ export const today = (offsetMinutes = 0, now = new Date()): string => new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10); /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */ export const canDelete = (who: Who, note: Note): boolean => permit(who, "note", "delete", { ...note }) === "yes"; /** A welcome note: what the vault can do, written in what it can do. */ export const WELCOME = `# Welcome to your vault This is a note. Every note is **markdown**, saved as you type, and private to you. ## Linking thoughts Write [[double brackets]] around a title to link notes — that one doesn't exist yet, so following it creates it. Links work both ways: open a note and its **backlinks** show everything pointing at it. Rename a note and every link to it follows. Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]] ## Tags Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it. ## The rest of markdown - ==highlights==, ~~strikes~~, \`code\`, *emphasis* - [x] tasks you've done - [ ] tasks you haven't > [!tip] Keys > **⌘K** / **Ctrl K** — jump to any note, or create one > **⌘E** / **Ctrl E** — switch between editing and reading > **⌘⇧D** / **Ctrl ⇧ D** — today's daily note > **⌘G** / **Ctrl G** — the graph | Where | What | | --- | --- | | Left | your notes, pinned first, and your tags | | Middle | the note | | Right | outline, links and backlinks | Open the **graph** to see how everything connects. `; /** * The audit trail, newest first, for whoever the table lets read it (an * admin, as the scaffold has it). `before` pages back by id. Audit rows name * no author, so a cell that gave `list` to a row's role would find nothing * of its own: anything but "yes" is a refusal here. */ export function listAudit( db: Database, who: Who, input: { limit?: number; before?: number } = {}, ): Result { if (permit(who, "audit", "list") !== "yes") return refuse( who, "Sign in to read the audit trail.", "Only an admin can read the audit trail.", ); const limit = Math.min( Math.max(Math.trunc(input.limit ?? 100) || 100, 1), 500, ); const before = Number.isInteger(input.before) ? input.before! : Number.MAX_SAFE_INTEGER; return ok( db .query< AuditRow, [number, number] >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") .all(before, limit), ); } export function getAudit( db: Database, who: Who, input: { id: number }, ): Result { const row = Number.isInteger(input.id) ? db .query("SELECT * FROM audit WHERE id = ?1") .get(input.id) : null; if (permit(who, "audit", "read", { ...row }) !== "yes") return refuse( who, "Sign in to read the audit trail.", "Only an admin can read the audit trail.", ); if (row !== null) return ok(row); if (!Number.isInteger(input.id)) return fail("invalid", "An audit id is a whole number."); return fail("not_found", "No such audit row."); }