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

Files b1c728e7eb4bf51bf75a6af39ebff6d562839a67
466 lines · 15.9 KB · ~4.6k 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, id 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
  const count = db
158
    .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1")
159
    .get(who.id)!.n;
160
  if (count >= VAULT_MAX)
161
    return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`);
162
  let title: string;
163
  if (input.title === undefined || input.title.trim() === "")
164
    title = freeTitle(db, who.id, "Untitled");
165
  else {
166
    const clean = cleanTitle(input.title);
167
    if (!clean.ok) return clean;
168
    if (titleTaken(db, who.id, clean.value))
169
      return fail("invalid", `A note called “${clean.value}” already exists.`);
170
    title = clean.value;
171
  }
172
  const note = db
173
    .query<Note, [string, string, string, string]>(
174
      "INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *",
175
    )
176
    .get(title, body.value, who.id, who.name)!;
177
  tell("note.added", note);
178
  return ok(note);
179
}
180
181
/**
182
 * One note, for `who` to do `verb` to. The door is asked before the row is
183
 * looked for, so whoever may not is told that and never learns whether the
184
 * row exists; whoever may, and asked for nothing, hears `not_found`.
185
 */
186
function noteFor(
187
  db: Database,
188
  who: Who,
189
  verb: "read" | "update" | "delete",
190
  id: number,
191
  said: { signIn: string; no: string },
192
): Result<Note> {
193
  const note = Number.isInteger(id)
194
    ? db.query<Note, [number]>("SELECT * FROM note WHERE id = ?1").get(id)
195
    : null;
196
  // With no row to show, the door is asked about a row of their own: whoever
197
  // could not pass even then is refused, and learns nothing about what
198
  // exists; whoever could is told there is no such note.
199
  const row: Readonly<Record<string, unknown>> =
200
    note === null ? { author_id: who.role === "anon" ? null : who.id } : { ...note };
201
  if (permit(who, "note", verb, row) !== "yes")
202
    return refuse(who, said.signIn, said.no);
203
  if (note !== null) return ok(note);
204
  if (!Number.isInteger(id))
205
    return fail("invalid", "A note id is a whole number.");
206
  return fail("not_found", "No such note.");
207
}
208
209
export function getNote(
210
  db: Database,
211
  who: Who,
212
  input: { id: number },
213
): Result<Note> {
214
  return noteFor(db, who, "read", input.id, {
215
    signIn: "Sign in to read this note.",
216
    no: "You may not read this note.",
217
  });
218
}
219
220
/** A note in the asker's vault by its title, any case. */
221
export function findNote(
222
  db: Database,
223
  who: Who,
224
  input: { title: string },
225
): Result<Note> {
226
  if (who.role === "anon")
227
    return fail("signin_required", "Sign in to open your notes.");
228
  const title = input.title.replace(/\s+/g, " ").trim();
229
  const note = db
230
    .query<Note, [string, string]>(
231
      "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE",
232
    )
233
    .get(who.id, title);
234
  if (note === null) return fail("not_found", `No note called “${title}”.`);
235
  return getNote(db, who, { id: note.id });
236
}
237
238
/** The note with this title, made (empty, or from `body`) when there is none yet: following a link, opening today. */
239
export function openOrCreate(
240
  db: Database,
241
  who: Who,
242
  input: { title: string; body?: string },
243
): Result<Note & { created: boolean }> {
244
  const found = findNote(db, who, input);
245
  if (found.ok) return ok({ ...found.value, created: false });
246
  if (found.error !== "not_found") return found;
247
  const made = addNote(db, who, { title: input.title, body: input.body ?? "" });
248
  return made.ok ? ok({ ...made.value, created: true }) : made;
249
}
250
251
export interface Change {
252
  readonly id: number;
253
  readonly title?: string;
254
  readonly body?: string;
255
  readonly pinned?: boolean;
256
}
257
258
export const updateNote = (
259
  db: Database,
260
  who: Who,
261
  input: Change,
262
): Result<Note & { relinked: number }> =>
263
  audited(db, who, "note", "update", input.id, update(db, who, input));
264
265
/**
266
 * Change a note's title, words or pin. A new title is carried into every
267
 * note of the vault that linked to the old one, in the same transaction,
268
 * so a rename never leaves a link pointing at nothing.
269
 */
270
function update(
271
  db: Database,
272
  who: Who,
273
  input: Change,
274
): Result<Note & { relinked: number }> {
275
  const found = noteFor(db, who, "update", input.id, {
276
    signIn: "Sign in to edit a note.",
277
    no: "Only the author can edit a note.",
278
  });
279
  if (!found.ok) return found;
280
  const before = found.value;
281
  let title = before.title;
282
  if (input.title !== undefined) {
283
    const clean = cleanTitle(input.title);
284
    if (!clean.ok) return clean;
285
    if (titleTaken(db, before.author_id, clean.value, before.id))
286
      return fail("invalid", `A note called “${clean.value}” already exists.`);
287
    title = clean.value;
288
  }
289
  let body = before.body;
290
  if (input.body !== undefined) {
291
    const checked = checkBody(input.body);
292
    if (!checked.ok) return checked;
293
    body = checked.value;
294
  }
295
  const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0;
296
  if (title === before.title && body === before.body && pinned === before.pinned)
297
    return ok({ ...before, relinked: 0 });
298
  const renamed = title.toLowerCase() !== before.title.toLowerCase();
299
  const touched: Note[] = [];
300
  const note = db.transaction(() => {
301
    const saved = db
302
      .query<Note, [string, string, number, number, number]>(
303
        // A pin is not an edit: only new words or a new title move a note up by recency.
304
        "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 *",
305
      )
306
      .get(title, body, pinned, before.id, title !== before.title || body !== before.body ? 1 : 0)!;
307
    if (renamed || title !== before.title)
308
      for (const other of db
309
        .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2")
310
        .all(before.author_id, before.id)) {
311
        const next = relink(other.body, before.title, title);
312
        if (next === other.body) continue;
313
        touched.push(
314
          db
315
            .query<Note, [string, number]>(
316
              "UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *",
317
            )
318
            .get(next, other.id)!,
319
        );
320
      }
321
    return saved;
322
  })();
323
  tell("note.changed", note);
324
  for (const other of touched) tell("note.changed", other);
325
  return ok({ ...note, relinked: touched.length });
326
}
327
328
export const deleteNote = (
329
  db: Database,
330
  who: Who,
331
  input: { id: number },
332
): Result<{ id: number }> =>
333
  audited(db, who, "note", "delete", input.id, remove(db, who, input));
334
335
function remove(
336
  db: Database,
337
  who: Who,
338
  input: { id: number },
339
): Result<{ id: number }> {
340
  const found = noteFor(db, who, "delete", input.id, {
341
    signIn: "Sign in to remove a note.",
342
    no: "Only the author can remove a note.",
343
  });
344
  if (!found.ok) return found;
345
  db.query("DELETE FROM note WHERE id = ?1").run(found.value.id);
346
  emit("note.removed", { id: found.value.id }, found.value.author_id);
347
  return ok({ id: found.value.id });
348
}
349
350
/** Search the asker's vault (see `vault.search` for the language). */
351
export function searchNotes(
352
  db: Database,
353
  who: Who,
354
  input: { q: string; limit?: number },
355
): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> {
356
  const vault = listNotes(db, who);
357
  if (!vault.ok) return vault;
358
  const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500);
359
  return ok(
360
    search(vault.value, input.q.slice(0, 500))
361
      .slice(0, limit)
362
      .map(({ note, snippet }) => ({
363
        id: note.id,
364
        title: note.title,
365
        snippet,
366
        updated_at: note.updated_at,
367
      })),
368
  );
369
}
370
371
/** Today's daily note title, `YYYY-MM-DD`, in the given time zone offset (minutes, as `Date#getTimezoneOffset`). */
372
export const today = (offsetMinutes = 0, now = new Date()): string =>
373
  new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10);
374
375
/** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */
376
export const canDelete = (who: Who, note: Note): boolean =>
377
  permit(who, "note", "delete", { ...note }) === "yes";
378
379
/** A welcome note: what the vault can do, written in what it can do. */
380
export const WELCOME = `# Welcome to your vault
381
382
This is a note. Every note is **markdown**, saved as you type, and private to you.
383
384
## Linking thoughts
385
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.
386
387
Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]]
388
389
## Tags
390
Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it.
391
392
## The rest of markdown
393
- ==highlights==, ~~strikes~~, \`code\`, *emphasis*
394
- [x] tasks you've done
395
- [ ] tasks you haven't
396
397
> [!tip] Keys
398
> **⌘K** / **Ctrl K** — jump to any note, or create one
399
> **⌘E** / **Ctrl E** — switch between editing and reading
400
> **⌘⇧D** / **Ctrl ⇧ D** — today's daily note
401
> **⌘G** / **Ctrl G** — the graph
402
403
| Where | What |
404
| --- | --- |
405
| Left | your notes, pinned first, and your tags |
406
| Middle | the note |
407
| Right | outline, links and backlinks |
408
409
Open the **graph** to see how everything connects.
410
`;
411
412
/**
413
 * The audit trail, newest first, for whoever the table lets read it (an
414
 * admin, as the scaffold has it). `before` pages back by id. Audit rows name
415
 * no author, so a cell that gave `list` to a row's role would find nothing
416
 * of its own: anything but "yes" is a refusal here.
417
 */
418
export function listAudit(
419
  db: Database,
420
  who: Who,
421
  input: { limit?: number; before?: number } = {},
422
): Result<AuditRow[]> {
423
  if (permit(who, "audit", "list") !== "yes")
424
    return refuse(
425
      who,
426
      "Sign in to read the audit trail.",
427
      "Only an admin can read the audit trail.",
428
    );
429
  const limit = Math.min(
430
    Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
431
    500,
432
  );
433
  const before = Number.isInteger(input.before)
434
    ? input.before!
435
    : Number.MAX_SAFE_INTEGER;
436
  return ok(
437
    db
438
      .query<
439
        AuditRow,
440
        [number, number]
441
      >("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2")
442
      .all(before, limit),
443
  );
444
}
445
446
export function getAudit(
447
  db: Database,
448
  who: Who,
449
  input: { id: number },
450
): Result<AuditRow> {
451
  const row = Number.isInteger(input.id)
452
    ? db
453
        .query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1")
454
        .get(input.id)
455
    : null;
456
  if (permit(who, "audit", "read", { ...row }) !== "yes")
457
    return refuse(
458
      who,
459
      "Sign in to read the audit trail.",
460
      "Only an admin can read the audit trail.",
461
    );
462
  if (row !== null) return ok(row);
463
  if (!Number.isInteger(input.id))
464
    return fail("invalid", "An audit id is a whole number.");
465
  return fail("not_found", "No such audit row.");
466
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts