/** * 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 { 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; } export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; 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. */ const tell = (type: string, note: Note): void => { emit(type, { id: note.id, title: note.title, updated_at: note.updated_at }, note.author_id); }; /** * 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 } = {}, ): 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."); return ok( db .query( "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT ?2", ) .all(who.id, 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; export const addNote = ( db: Database, who: Who, input: { title?: string; body?: string }, ): Result => audited(db, who, "note", "create", undefined, add(db, who, input)); function add( db: Database, who: Who, input: { title?: string; body?: string }, ): 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 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(who.id)!.n; if (count >= VAULT_MAX) return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`); let title: string; if (input.title === undefined || input.title.trim() === "") title = freeTitle(db, who.id, "Untitled"); else { const clean = cleanTitle(input.title); if (!clean.ok) return clean; if (titleTaken(db, who.id, clean.value)) return fail("invalid", `A note called “${clean.value}” already exists.`); title = clean.value; } const note = db .query( "INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", ) .get(title, body.value, who.id, who.name)!; tell("note.added", note); return ok(note); } /** * 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. const row: Readonly> = note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note }; 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 by its title, any case. */ export function findNote( db: Database, who: Who, input: { title: string }, ): Result { if (who.role === "anon") return fail("signin_required", "Sign in to open your notes."); const title = input.title.replace(/\s+/g, " ").trim(); const note = db .query( "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", ) .get(who.id, 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 }, ): 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, { title: input.title, 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; } 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; let title = before.title; if (input.title !== undefined) { const clean = cleanTitle(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; if (title === before.title && body === before.body && pinned === before.pinned) return ok({ ...before, relinked: 0 }); const renamed = title.toLowerCase() !== before.title.toLowerCase(); const touched: Note[] = []; const note = db.transaction(() => { const saved = db .query( // A pin is not an edit: only new words or a new title move a note up by recency. "UPDATE note SET title = ?1, body = ?2, pinned = ?3, 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 ? 1 : 0)!; 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("note.changed", note); for (const other of touched) tell("note.changed", other); 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; db.query("DELETE FROM note WHERE id = ?1").run(found.value.id); emit("note.removed", { id: found.value.id }, found.value.author_id); return ok({ id: found.value.id }); } /** Search the asker's vault (see `vault.search` for the language). */ export function searchNotes( db: Database, who: Who, input: { q: string; limit?: number }, ): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> { const vault = listNotes(db, who); 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."); }