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

Files 7260e8fc5b5c3ca9332ff3506ef97626f456e658
115 lines · 4.2 KB · ~1.2k tok Raw History
1
/**
2
 * The database: one SQLite file under FORGE_APP_DATA, so production and
3
 * every preview each have their own. WAL so readers never wait on the
4
 * writer (the terminal is a second process on the same file), foreign keys
5
 * on, and a forward-only list of migrations applied once, in a
6
 * transaction, when the file is opened.
7
 *
8
 *   bun run src/db.ts --check    the schema version and an integrity check
9
 */
10
11
import { Database } from "bun:sqlite";
12
import { mkdirSync } from "node:fs";
13
import { join } from "node:path";
14
import { dataDir } from "./env.ts";
15
16
/** Append, never edit: a migration that has run is history. */
17
export const migrations: string[] = [
18
  `CREATE TABLE note (
19
    id INTEGER PRIMARY KEY,
20
    body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 500),
21
    author_id TEXT NOT NULL,
22
    author_name TEXT NOT NULL,
23
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
24
    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
25
  )`,
26
  `CREATE INDEX note_author ON note (author_id)`,
27
  // Who tried to change what, and whether they were let. Appended by
28
  // src/audit.ts, never edited; read by admins at GET /api/audit.
29
  `CREATE TABLE audit (
30
    id INTEGER PRIMARY KEY,
31
    at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
32
    who_id TEXT,
33
    who_name TEXT,
34
    role TEXT NOT NULL,
35
    resource TEXT NOT NULL,
36
    verb TEXT NOT NULL,
37
    target TEXT,
38
    allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)),
39
    outcome TEXT NOT NULL
40
  )`,
41
  // The vault: a note is a titled markdown page, its title unique in its
42
  // author's vault (any case), so a [[link]] names exactly one. The
43
  // scaffold's 500-character notes become the bodies of titled ones.
44
  `CREATE TABLE page (
45
    id INTEGER PRIMARY KEY,
46
    title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
47
    body TEXT NOT NULL DEFAULT '' CHECK (length(body) <= 200000),
48
    pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
49
    author_id TEXT NOT NULL,
50
    author_name TEXT NOT NULL,
51
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
52
    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
53
  )`,
54
  `INSERT INTO page (id, title, body, author_id, author_name, created_at, updated_at)
55
    SELECT id, 'Note ' || id, body, author_id, author_name, created_at, updated_at FROM note`,
56
  `DROP TABLE note`,
57
  `ALTER TABLE page RENAME TO note`,
58
  `CREATE UNIQUE INDEX note_title ON note (author_id, title COLLATE NOCASE)`,
59
  `CREATE INDEX note_recent ON note (author_id, updated_at)`,
60
];
61
62
export function openDb(dir = dataDir): Database {
63
  mkdirSync(dir, { recursive: true });
64
  const db = new Database(join(dir, "app.db"));
65
  db.exec("PRAGMA journal_mode = WAL");
66
  db.exec("PRAGMA foreign_keys = ON");
67
  db.exec("PRAGMA busy_timeout = 5000");
68
  migrate(db);
69
  return db;
70
}
71
72
/** Apply the migrations past the recorded version, all or none. Returns the version. */
73
export function migrate(db: Database): number {
74
  db.exec(
75
    "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))",
76
  );
77
  return db.transaction(() => {
78
    const current = db
79
      .query<
80
        { version: number },
81
        []
82
      >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
83
      .get()!.version;
84
    migrations.slice(current).forEach((sql, i) => {
85
      db.exec(sql);
86
      db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
87
        current + i + 1,
88
      );
89
    });
90
    return migrations.length;
91
  })();
92
}
93
94
export function check(db: Database): { version: number; integrity: string } {
95
  const version = db
96
    .query<
97
      { version: number },
98
      []
99
    >("SELECT max(version) AS version FROM schema_version")
100
    .get()!.version;
101
  const integrity = db
102
    .query<{ integrity_check: string }, []>("PRAGMA integrity_check")
103
    .get()!.integrity_check;
104
  return { version, integrity };
105
}
106
107
if (import.meta.main) {
108
  const db = openDb();
109
  const { version, integrity } = check(db);
110
  console.log(`schema version ${version}`);
111
  if (process.argv.includes("--check")) {
112
    console.log(`integrity ${integrity}`);
113
    process.exit(integrity === "ok" ? 0 : 1);
114
  }
115
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts