benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 40c75dcddedb7a6a4eea93a9f587c3b91283870c
460 lines · 15.4 KB · ~4.5k tok Raw History
1
/**
2
 * Everything the app can do, as plain functions: `(db, who, input)` in,
3
 * data or a named failure out. The page, the JSON API, the MCP server and
4
 * the terminal are doors onto these and decide nothing themselves — so a
5
 * rule written here holds at every door, and a new door is only a new
6
 * way to call.
7
 */
8
9
import type { Database } from "bun:sqlite";
10
import { record, type AuditRow } from "./audit.ts";
11
import { permit } from "./contract.ts";
12
import { emit } from "./live.ts";
13
import { relink, search } from "./vault.ts";
14
import type { Who } from "./who.ts";
15
16
export interface Note {
17
  readonly id: number;
18
  readonly title: string;
19
  readonly body: string;
20
  readonly pinned: number;
21
  readonly author_id: string;
22
  readonly author_name: string;
23
  readonly created_at: string;
24
  readonly updated_at: string;
25
}
26
27
export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
28
export type Result<T> =
29
  | { ok: true; value: T }
30
  | { ok: false; error: Failure; message: string };
31
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
32
const fail = <T>(error: Failure, message: string): Result<T> => ({
33
  ok: false,
34
  error,
35
  message,
36
});
37
38
export const TITLE_MAX = 200;
39
export const BODY_MAX = 200_000;
40
/** The vault, at most: a list asks for no more than this many. */
41
export const VAULT_MAX = 5000;
42
43
/** The refusal for a "no": who is not signed in is asked to; who is, is told. */
44
const refuse = <T>(who: Who, signIn: string, no: string): Result<T> =>
45
  who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no);
46
47
/**
48
 * A change, written into the audit trail whichever way it went: the row it
49
 * made or touched, or the one it was refused. Every action that changes
50
 * something returns through here, so the trail holds at every door.
51
 */
52
const audited = <T extends { readonly id: number }>(
53
  db: Database,
54
  who: Who,
55
  resource: string,
56
  verb: "create" | "update" | "delete",
57
  target: number | undefined,
58
  result: Result<T>,
59
): Result<T> => {
60
  record(db, {
61
    who,
62
    resource,
63
    verb,
64
    target: result.ok ? result.value.id : target,
65
    outcome: result.ok ? "ok" : result.error,
66
  });
67
  return result;
68
};
69
70
/** Whose vault a change belongs to: its live events reach only them. */
71
const tell = (type: string, note: Note): void => {
72
  emit(type, { id: note.id, title: note.title, updated_at: note.updated_at }, note.author_id);
73
};
74
75
/**
76
 * A title, as the vault keeps it: trimmed, one line, and free of the
77
 * characters a [[link]] uses to say something else (`[ ] | # ^`).
78
 */
79
export function cleanTitle(raw: string): Result<string> {
80
  const title = raw.replace(/\s+/g, " ").trim();
81
  if (title.length === 0 || title.length > TITLE_MAX)
82
    return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`);
83
  if (/[\[\]|#^\\]/.test(title))
84
    return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them.");
85
  return ok(title);
86
}
87
88
const checkBody = (body: string): Result<string> =>
89
  body.length > BODY_MAX
90
    ? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`)
91
    : ok(body);
92
93
/**
94
 * The vault: the notes of whoever asks, pinned first, then the most
95
 * recently changed. A vault is its author's — the table gives `list` to the
96
 * row's author — so the list is always the asker's own notes, and nobody
97
 * else's, whatever role they hold.
98
 */
99
export function listNotes(
100
  db: Database,
101
  who: Who,
102
  input: { limit?: number } = {},
103
): Result<Note[]> {
104
  const limit = Math.min(
105
    Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1),
106
    VAULT_MAX,
107
  );
108
  const may = permit(who, "note", "list");
109
  if (may === "no" || who.role === "anon")
110
    return refuse(who, "Sign in to open your notes.", "You may not see these notes.");
111
  return ok(
112
    db
113
      .query<Note, [string, number]>(
114
        "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC LIMIT ?2",
115
      )
116
      .all(who.id, limit),
117
  );
118
}
119
120
/** The next free "Untitled", "Untitled 2", … in a vault. */
121
function freeTitle(db: Database, authorId: string, base: string): string {
122
  const taken = (t: string) =>
123
    db
124
      .query<{ id: number }, [string, string]>(
125
        "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
126
      )
127
      .get(authorId, t) !== null;
128
  if (!taken(base)) return base;
129
  for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`;
130
}
131
132
const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean =>
133
  db
134
    .query<{ id: number }, [string, string, number]>(
135
      "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3",
136
    )
137
    .get(authorId, title, except) !== null;
138
139
export const addNote = (
140
  db: Database,
141
  who: Who,
142
  input: { title?: string; body?: string },
143
): Result<Note> =>
144
  audited(db, who, "note", "create", undefined, add(db, who, input));
145
146
function add(
147
  db: Database,
148
  who: Who,
149
  input: { title?: string; body?: string },
150
): Result<Note> {
151
  // The table decides; the type only learns it here: whoever may create is
152
  // signed in, and carries a name to write beside the note.
153
  if (who.role === "anon" || permit(who, "note", "create") !== "yes")
154
    return refuse(who, "Sign in to add a note.", "You may not add a note.");
155
  const body = checkBody(input.body ?? "");
156
  if (!body.ok) return body;
157
  let title: string;
158
  if (input.title === undefined || input.title.trim() === "")
159
    title = freeTitle(db, who.id, "Untitled");
160
  else {
161
    const clean = cleanTitle(input.title);
162
    if (!clean.ok) return clean;
163
    if (titleTaken(db, who.id, clean.value))
164
      return fail("invalid", `A note called “${clean.value}” already exists.`);
165
    title = clean.value;
166
  }
167
  const note = db
168
    .query<Note, [string, string, string, string]>(
169
      "INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *",
170
    )
171
    .get(title, body.value, who.id, who.name)!;
172
  tell("note.added", note);
173
  return ok(note);
174
}
175
176
/**
177
 * One note, for `who` to do `verb` to. The door is asked before the row is
178
 * looked for, so whoever may not is told that and never learns whether the
179
 * row exists; whoever may, and asked for nothing, hears `not_found`.
180
 */
181
function noteFor(
182
  db: Database,
183
  who: Who,
184
  verb: "read" | "update" | "delete",
185
  id: number,
186
  said: { signIn: string; no: string },
187
): Result<Note> {
188
  const note = Number.isInteger(id)
189
    ? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id)
190
    : null;
191
  // With no row to show, the door is asked about a row of their own: whoever
192
  // could not pass even then is refused, and learns nothing about what
193
  // exists; whoever could is told there is no such note.
194
  const row: Readonly<Record<string, unknown>> =
195
    note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note };
196
  if (permit(who, "note", verb, row) !== "yes")
197
    return refuse(who, said.signIn, said.no);
198
  if (note !== null) return ok(note);
199
  if (!Number.isInteger(id))
200
    return fail("invalid", "A note id is a whole number.");
201
  return fail("not_found", "No such note.");
202
}
203
204
export function getNote(
205
  db: Database,
206
  who: Who,
207
  input: { id: number },
208
): Result<Note> {
209
  return noteFor(db, who, "read", input.id, {
210
    signIn: "Sign in to read this note.",
211
    no: "You may not read this note.",
212
  });
213
}
214
215
/** A note in the asker's vault by its title, any case. */
216
export function findNote(
217
  db: Database,
218
  who: Who,
219
  input: { title: string },
220
): Result<Note> {
221
  if (who.role === "anon")
222
    return fail("signin_required", "Sign in to open your notes.");
223
  const title = input.title.replace(/\s+/g, " ").trim();
224
  const note = db
225
    .query<Note, [string, string]>(
226
      "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
227
    )
228
    .get(who.id, title);
229
  if (note === null) return fail("not_found", `No note called “${title}”.`);
230
  return getNote(db, who, { id: note.id });
231
}
232
233
/** The note with this title, made (empty, or from `body`) when there is none yet: following a link, opening today. */
234
export function openOrCreate(
235
  db: Database,
236
  who: Who,
237
  input: { title: string; body?: string },
238
): Result<Note & { created: boolean }> {
239
  const found = findNote(db, who, input);
240
  if (found.ok) return ok({ ...found.value, created: false });
241
  if (found.error !== "not_found") return found;
242
  const made = addNote(db, who, { title: input.title, body: input.body ?? "" });
243
  return made.ok ? ok({ ...made.value, created: true }) : made;
244
}
245
246
export interface Change {
247
  readonly id: number;
248
  readonly title?: string;
249
  readonly body?: string;
250
  readonly pinned?: boolean;
251
}
252
253
export const updateNote = (
254
  db: Database,
255
  who: Who,
256
  input: Change,
257
): Result<Note & { relinked: number }> =>
258
  audited(db, who, "note", "update", input.id, update(db, who, input));
259
260
/**
261
 * Change a note's title, words or pin. A new title is carried into every
262
 * note of the vault that linked to the old one, in the same transaction,
263
 * so a rename never leaves a link pointing at nothing.
264
 */
265
function update(
266
  db: Database,
267
  who: Who,
268
  input: Change,
269
): Result<Note & { relinked: number }> {
270
  const found = noteFor(db, who, "update", input.id, {
271
    signIn: "Sign in to edit a note.",
272
    no: "Only the author can edit a note.",
273
  });
274
  if (!found.ok) return found;
275
  const before = found.value;
276
  let title = before.title;
277
  if (input.title !== undefined) {
278
    const clean = cleanTitle(input.title);
279
    if (!clean.ok) return clean;
280
    if (titleTaken(db, before.author_id, clean.value, before.id))
281
      return fail("invalid", `A note called “${clean.value}” already exists.`);
282
    title = clean.value;
283
  }
284
  let body = before.body;
285
  if (input.body !== undefined) {
286
    const checked = checkBody(input.body);
287
    if (!checked.ok) return checked;
288
    body = checked.value;
289
  }
290
  const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0;
291
  if (title === before.title && body === before.body && pinned === before.pinned)
292
    return ok({ ...before, relinked: 0 });
293
  const renamed = title.toLowerCase() !== before.title.toLowerCase();
294
  const touched: Note[] = [];
295
  const note = db.transaction(() => {
296
    const saved = db
297
      .query<Note, [string, string, number, number]>(
298
        "UPDATE note SET title = ?1, body = ?2, pinned = ?3, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?4 RETURNING *",
299
      )
300
      .get(title, body, pinned, before.id)!;
301
    if (renamed || title !== before.title)
302
      for (const other of db
303
        .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2")
304
        .all(before.author_id, before.id)) {
305
        const next = relink(other.body, before.title, title);
306
        if (next === other.body) continue;
307
        touched.push(
308
          db
309
            .query<Note, [string, number]>(
310
              "UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *",
311
            )
312
            .get(next, other.id)!,
313
        );
314
      }
315
    return saved;
316
  })();
317
  tell("note.changed", note);
318
  for (const other of touched) tell("note.changed", other);
319
  return ok({ ...note, relinked: touched.length });
320
}
321
322
export const deleteNote = (
323
  db: Database,
324
  who: Who,
325
  input: { id: number },
326
): Result<{ id: number }> =>
327
  audited(db, who, "note", "delete", input.id, remove(db, who, input));
328
329
function remove(
330
  db: Database,
331
  who: Who,
332
  input: { id: number },
333
): Result<{ id: number }> {
334
  const found = noteFor(db, who, "delete", input.id, {
335
    signIn: "Sign in to remove a note.",
336
    no: "Only the author can remove a note.",
337
  });
338
  if (!found.ok) return found;
339
  db.query("DELETE FROM note WHERE id = ?1").run(found.value.id);
340
  emit("note.removed", { id: found.value.id }, found.value.author_id);
341
  return ok({ id: found.value.id });
342
}
343
344
/** Search the asker's vault (see `vault.search` for the language). */
345
export function searchNotes(
346
  db: Database,
347
  who: Who,
348
  input: { q: string; limit?: number },
349
): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> {
350
  const vault = listNotes(db, who);
351
  if (!vault.ok) return vault;
352
  const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500);
353
  return ok(
354
    search(vault.value, input.q.slice(0, 500))
355
      .slice(0, limit)
356
      .map(({ note, snippet }) => ({
357
        id: note.id,
358
        title: note.title,
359
        snippet,
360
        updated_at: note.updated_at,
361
      })),
362
  );
363
}
364
365
/** Today's daily note title, `YYYY-MM-DD`, in the given time zone offset (minutes, as `Date#getTimezoneOffset`). */
366
export const today = (offsetMinutes = 0, now = new Date()): string =>
367
  new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10);
368
369
/** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */
370
export const canDelete = (who: Who, note: Note): boolean =>
371
  permit(who, "note", "delete", { ...note }) === "yes";
372
373
/** A welcome note: what the vault can do, written in what it can do. */
374
export const WELCOME = `# Welcome to your vault
375
376
This is a note. Every note is **markdown**, saved as you type, and private to you.
377
378
## Linking thoughts
379
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.
380
381
Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]]
382
383
## Tags
384
Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it.
385
386
## The rest of markdown
387
- ==highlights==, ~~strikes~~, \`code\`, *emphasis*
388
- [x] tasks you've done
389
- [ ] tasks you haven't
390
391
> [!tip] Keys
392
> **⌘K** / **Ctrl K** — jump to any note, or create one
393
> **⌘E** / **Ctrl E** — switch between editing and reading
394
> **⌘⇧D** / **Ctrl ⇧ D** — today's daily note
395
> **⌘G** / **Ctrl G** — the graph
396
397
| Where | What |
398
| --- | --- |
399
| Left | your notes, pinned first, and your tags |
400
| Middle | the note |
401
| Right | outline, links and backlinks |
402
403
Open the **graph** to see how everything connects.
404
`;
405
406
/**
407
 * The audit trail, newest first, for whoever the table lets read it (an
408
 * admin, as the scaffold has it). `before` pages back by id. Audit rows name
409
 * no author, so a cell that gave `list` to a row's role would find nothing
410
 * of its own: anything but "yes" is a refusal here.
411
 */
412
export function listAudit(
413
  db: Database,
414
  who: Who,
415
  input: { limit?: number; before?: number } = {},
416
): Result<AuditRow[]> {
417
  if (permit(who, "audit", "list") !== "yes")
418
    return refuse(
419
      who,
420
      "Sign in to read the audit trail.",
421
      "Only an admin can read the audit trail.",
422
    );
423
  const limit = Math.min(
424
    Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
425
    500,
426
  );
427
  const before = Number.isInteger(input.before)
428
    ? input.before!
429
    : Number.MAX_SAFE_INTEGER;
430
  return ok(
431
    db
432
      .query<
433
        AuditRow,
434
        [number, number]
435
      >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2")
436
      .all(before, limit),
437
  );
438
}
439
440
export function getAudit(
441
  db: Database,
442
  who: Who,
443
  input: { id: number },
444
): Result<AuditRow> {
445
  const row = Number.isInteger(input.id)
446
    ? db
447
        .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1")
448
        .get(input.id)
449
    : null;
450
  if (permit(who, "audit", "read", { ...row }) !== "yes")
451
    return refuse(
452
      who,
453
      "Sign in to read the audit trail.",
454
      "Only an admin can read the audit trail.",
455
    );
456
  if (row !== null) return ok(row);
457
  if (!Number.isInteger(input.id))
458
    return fail("invalid", "An audit id is a whole number.");
459
  return fail("not_found", "No such audit row.");
460
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts