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

Files 6f31b533b13d96cd4b38d39bc59da84ada17375b
131 lines · 4.9 KB · ~1.4k 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
  // The app's own accounts, for people without a forge account (src/accounts.ts).
61
  // A session keeps only its token's hash.
62
  `CREATE TABLE account (
63
    id INTEGER PRIMARY KEY,
64
    email TEXT NOT NULL UNIQUE CHECK (length(email) BETWEEN 3 AND 320),
65
    name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
66
    password_hash TEXT NOT NULL,
67
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
68
  )`,
69
  `CREATE TABLE session (
70
    token_hash TEXT PRIMARY KEY,
71
    account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE CASCADE,
72
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
73
    expires_at TEXT NOT NULL
74
  )`,
75
  `CREATE INDEX session_account ON session (account_id)`,
76
];
77
78
export function openDb(dir = dataDir): Database {
79
  mkdirSync(dir, { recursive: true });
80
  const db = new Database(join(dir, "app.db"));
81
  db.exec("PRAGMA journal_mode = WAL");
82
  db.exec("PRAGMA foreign_keys = ON");
83
  db.exec("PRAGMA busy_timeout = 5000");
84
  migrate(db);
85
  return db;
86
}
87
88
/** Apply the migrations past the recorded version, all or none. Returns the version. */
89
export function migrate(db: Database): number {
90
  db.exec(
91
    "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')))",
92
  );
93
  return db.transaction(() => {
94
    const current = db
95
      .query<
96
        { version: number },
97
        []
98
      >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
99
      .get()!.version;
100
    migrations.slice(current).forEach((sql, i) => {
101
      db.exec(sql);
102
      db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
103
        current + i + 1,
104
      );
105
    });
106
    return migrations.length;
107
  })();
108
}
109
110
export function check(db: Database): { version: number; integrity: string } {
111
  const version = db
112
    .query<
113
      { version: number },
114
      []
115
    >("SELECT max(version) AS version FROM schema_version")
116
    .get()!.version;
117
  const integrity = db
118
    .query<{ integrity_check: string }, []>("PRAGMA integrity_check")
119
    .get()!.integrity_check;
120
  return { version, integrity };
121
}
122
123
if (import.meta.main) {
124
  const db = openDb();
125
  const { version, integrity } = check(db);
126
  console.log(`schema version ${version}`);
127
  if (process.argv.includes("--check")) {
128
    console.log(`integrity ${integrity}`);
129
    process.exit(integrity === "ok" ? 0 : 1);
130
  }
131
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts