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

Files main
170 lines · 6.5 KB · ~1.9k 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
  // Shared folders (src/spaces.ts): a folder of a git repository that its
77
  // members write in together. Its notes are ordinary notes whose vault is
78
  // the space — author_id `space:<id>` — so every vault query holds as it
79
  // is; `path` is the note's file in the repository, and the synced_* pair
80
  // is what the repository last had (src/sync.ts), so a difference is an
81
  // edit not committed yet.
82
  `CREATE TABLE space (
83
    id INTEGER PRIMARY KEY,
84
    name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
85
    repo TEXT NOT NULL,
86
    branch TEXT NOT NULL,
87
    folder TEXT NOT NULL,
88
    owner_id TEXT NOT NULL,
89
    owner_name TEXT NOT NULL,
90
    join_code TEXT UNIQUE,
91
    head TEXT,
92
    synced_at TEXT,
93
    sync_error TEXT,
94
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
95
  )`,
96
  `CREATE TABLE space_member (
97
    space_id INTEGER NOT NULL REFERENCES space (id) ON DELETE CASCADE,
98
    who_id TEXT NOT NULL,
99
    who_name TEXT NOT NULL,
100
    joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
101
    PRIMARY KEY (space_id, who_id)
102
  )`,
103
  `CREATE INDEX space_member_who ON space_member (who_id)`,
104
  `ALTER TABLE note ADD COLUMN path TEXT`,
105
  `ALTER TABLE note ADD COLUMN synced_path TEXT`,
106
  `ALTER TABLE note ADD COLUMN synced_oid TEXT`,
107
  `CREATE UNIQUE INDEX note_path ON note (author_id, path) WHERE path IS NOT NULL`,
108
  // A shared note removed here whose file the repository still has: the
109
  // next commit deletes it.
110
  `CREATE TABLE space_gone (
111
    space_id INTEGER NOT NULL REFERENCES space (id) ON DELETE CASCADE,
112
    path TEXT NOT NULL,
113
    PRIMARY KEY (space_id, path)
114
  )`,
115
];
116
117
export function openDb(dir = dataDir): Database {
118
  mkdirSync(dir, { recursive: true });
119
  const db = new Database(join(dir, "app.db"));
120
  db.exec("PRAGMA journal_mode = WAL");
121
  db.exec("PRAGMA foreign_keys = ON");
122
  db.exec("PRAGMA busy_timeout = 5000");
123
  migrate(db);
124
  return db;
125
}
126
127
/** Apply the migrations past the recorded version, all or none. Returns the version. */
128
export function migrate(db: Database): number {
129
  db.exec(
130
    "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')))",
131
  );
132
  return db.transaction(() => {
133
    const current = db
134
      .query<
135
        { version: number },
136
        []
137
      >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
138
      .get()!.version;
139
    migrations.slice(current).forEach((sql, i) => {
140
      db.exec(sql);
141
      db.query("INSERT INTO schema_version (version) VALUES (?1)").run(
142
        current + i + 1,
143
      );
144
    });
145
    return migrations.length;
146
  })();
147
}
148
149
export function check(db: Database): { version: number; integrity: string } {
150
  const version = db
151
    .query<
152
      { version: number },
153
      []
154
    >("SELECT max(version) AS version FROM schema_version")
155
    .get()!.version;
156
  const integrity = db
157
    .query<{ integrity_check: string }, []>("PRAGMA integrity_check")
158
    .get()!.integrity_check;
159
  return { version, integrity };
160
}
161
162
if (import.meta.main) {
163
  const db = openDb();
164
  const { version, integrity } = check(db);
165
  console.log(`schema version ${version}`);
166
  if (process.argv.includes("--check")) {
167
    console.log(`integrity ${integrity}`);
168
    process.exit(integrity === "ok" ? 0 : 1);
169
  }
170
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts