File size: 21,235 Bytes
742e389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
"""SQLite connections, versioned migrations and backups."""

from __future__ import annotations

import os
import sqlite3
import time
from pathlib import Path

SCHEMA_V1 = """
CREATE TABLE principals (
    id            INTEGER PRIMARY KEY,
    issuer        TEXT NOT NULL,
    external_id   TEXT NOT NULL,
    identity_type TEXT NOT NULL CHECK (identity_type IN ('agent', 'system', 'user')),
    role          TEXT NOT NULL CHECK (role IN ('observer', 'worker', 'coordinator', 'provisioner', 'admin')),
    display_name  TEXT,
    disabled      INTEGER NOT NULL DEFAULT 0,
    created_at    REAL NOT NULL,
    UNIQUE (issuer, external_id)
);

CREATE TABLE credentials (
    id           TEXT PRIMARY KEY,
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    secret_hash  BLOB NOT NULL,
    issued_by    INTEGER REFERENCES principals(id),
    created_at   REAL NOT NULL,
    expires_at   REAL,
    revoked_at   REAL
);
-- One active (unrevoked) token per identity; rotation revokes the old one.
CREATE UNIQUE INDEX credentials_one_active ON credentials(principal_id) WHERE revoked_at IS NULL;

-- channel_id 0 means board scope.
CREATE TABLE grants (
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    channel_id   INTEGER NOT NULL,
    action       TEXT NOT NULL,
    source       TEXT NOT NULL CHECK (source IN ('role', 'creator')),
    PRIMARY KEY (principal_id, channel_id, action)
);
CREATE INDEX grants_channel ON grants(channel_id);

CREATE TABLE provisioner_policies (
    principal_id    INTEGER PRIMARY KEY REFERENCES principals(id),
    roles           TEXT NOT NULL,
    channels        TEXT NOT NULL,
    max_ttl_seconds INTEGER NOT NULL,
    max_agents      INTEGER NOT NULL
);

CREATE TABLE channels (
    id          INTEGER PRIMARY KEY,
    name        TEXT NOT NULL UNIQUE,
    description TEXT,
    created_by  INTEGER NOT NULL REFERENCES principals(id),
    created_at  REAL NOT NULL,
    archived    INTEGER NOT NULL DEFAULT 0,
    next_seq    INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE messages (
    id           INTEGER PRIMARY KEY,
    channel_id   INTEGER NOT NULL REFERENCES channels(id),
    seq          INTEGER NOT NULL,
    sender_id    INTEGER NOT NULL REFERENCES principals(id),
    body         TEXT NOT NULL,
    metadata     TEXT,
    reply_to     INTEGER REFERENCES messages(id),
    attachments  TEXT,
    request_id   TEXT NOT NULL,
    payload_hash TEXT NOT NULL,
    created_at   REAL NOT NULL,
    UNIQUE (channel_id, seq),
    UNIQUE (sender_id, request_id)
);

CREATE TABLE attachments (
    id           TEXT PRIMARY KEY,
    channel_id   INTEGER NOT NULL REFERENCES channels(id),
    uploader_id  INTEGER NOT NULL REFERENCES principals(id),
    upload_id    TEXT NOT NULL,
    storage_key  TEXT NOT NULL,
    filename     TEXT,
    content_type TEXT,
    size         INTEGER,
    sha256       TEXT,
    state        TEXT NOT NULL CHECK (state IN ('pending', 'ready')),
    published    INTEGER NOT NULL DEFAULT 0,
    created_at   REAL NOT NULL,
    UNIQUE (uploader_id, upload_id)
);

CREATE TABLE audit_log (
    id       INTEGER PRIMARY KEY,
    ts       REAL NOT NULL,
    actor_id INTEGER,
    action   TEXT NOT NULL,
    target   TEXT,
    detail   TEXT
);
"""

# v2: add the 'member' role (board-wide channel access). SQLite cannot change a
# CHECK constraint in place, so the table is rebuilt (foreign keys are off during
# migrations and checked afterwards).
SCHEMA_V2 = """
CREATE TABLE principals_v2 (
    id            INTEGER PRIMARY KEY,
    issuer        TEXT NOT NULL,
    external_id   TEXT NOT NULL,
    identity_type TEXT NOT NULL CHECK (identity_type IN ('agent', 'system', 'user')),
    role          TEXT NOT NULL CHECK (role IN ('observer', 'worker', 'coordinator', 'member', 'provisioner', 'admin')),
    display_name  TEXT,
    disabled      INTEGER NOT NULL DEFAULT 0,
    created_at    REAL NOT NULL,
    UNIQUE (issuer, external_id)
);
INSERT INTO principals_v2 SELECT id, issuer, external_id, identity_type, role, display_name, disabled, created_at
    FROM principals;
DROP TABLE principals;
ALTER TABLE principals_v2 RENAME TO principals;
"""

# v3: threads (thread_id = the thread's first message), who created each identity,
# personal invite codes, and indexes for per-sender statistics.
SCHEMA_V3 = """
ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);
WITH RECURSIVE roots(id, root) AS (
    SELECT id, id FROM messages WHERE reply_to IS NULL
    UNION ALL
    SELECT m.id, r.root FROM messages m JOIN roots r ON m.reply_to = r.id
)
UPDATE messages SET thread_id = (SELECT root FROM roots WHERE roots.id = messages.id)
WHERE reply_to IS NOT NULL;
CREATE INDEX messages_thread ON messages(thread_id, seq);
CREATE INDEX messages_sender ON messages(sender_id, created_at);
ALTER TABLE principals ADD COLUMN created_by INTEGER REFERENCES principals(id);
ALTER TABLE principals ADD COLUMN joined_via TEXT;
UPDATE principals SET created_by = (
    SELECT actor_id FROM audit_log a
    WHERE a.action = 'agent.create' AND a.target = 'principal:' || principals.id
    ORDER BY a.id LIMIT 1);
CREATE TABLE invites (
    id         INTEGER PRIMARY KEY,
    code_hash  BLOB NOT NULL UNIQUE,
    created_by INTEGER NOT NULL REFERENCES principals(id),
    created_at REAL NOT NULL,
    uses       INTEGER NOT NULL DEFAULT 0,
    revoked_at REAL
);
"""

# v4: messages no longer carry a metadata field.
SCHEMA_V4 = """
ALTER TABLE messages DROP COLUMN metadata;
"""

# v5: uploaded profile pictures (small, so they live in the database and are
# replicated with it).
SCHEMA_V5 = """
CREATE TABLE avatars (
    principal_id INTEGER PRIMARY KEY REFERENCES principals(id),
    content_type TEXT NOT NULL,
    data         BLOB NOT NULL,
    sha256       TEXT NOT NULL,
    updated_at   REAL NOT NULL
);
"""

# v6: a chosen seed for the generated pattern (null = derived from the identity).
SCHEMA_V6 = """
ALTER TABLE principals ADD COLUMN pattern_seed INTEGER;
"""

# v7: Slack-like roles. People are admin / member / viewer; agents and service
# accounts are member or guest and always belong to the person who invited them.
# Channel access comes from public/private/DM channels plus membership, replacing
# per-role grants and provisioner policies. Invites carry what they grant. Tokens
# no longer expire, and people may hold one token per device.
SCHEMA_V7 = """
CREATE TABLE principals_v7 (
    id                  INTEGER PRIMARY KEY,
    issuer              TEXT NOT NULL,
    external_id         TEXT NOT NULL,
    identity_type       TEXT NOT NULL CHECK (identity_type IN ('agent', 'system', 'user')),
    role                TEXT NOT NULL CHECK (role IN ('admin', 'member', 'viewer', 'guest')),
    display_name        TEXT,
    disabled            INTEGER NOT NULL DEFAULT 0,
    created_at          REAL NOT NULL,
    created_by          INTEGER REFERENCES principals(id),
    joined_via          TEXT,
    pattern_seed        INTEGER,
    owner_id            INTEGER REFERENCES principals(id),
    can_create_channels INTEGER NOT NULL DEFAULT 0,
    hf_username         TEXT UNIQUE,
    invite_id           INTEGER REFERENCES invites(id),
    UNIQUE (issuer, external_id)
);
INSERT INTO principals_v7 (id, issuer, external_id, identity_type, role, display_name, disabled, created_at,
                           created_by, joined_via, pattern_seed, owner_id, can_create_channels, hf_username)
SELECT id, issuer, external_id, identity_type,
       CASE
           WHEN identity_type = 'user' AND role = 'admin' THEN 'admin'
           WHEN identity_type = 'user' AND role = 'observer' THEN 'viewer'
           WHEN identity_type = 'user' THEN 'member'
           WHEN role = 'observer' THEN 'guest'
           ELSE 'member'
       END,
       display_name,
       CASE WHEN role = 'provisioner' THEN 1 ELSE disabled END,
       created_at, created_by, joined_via, pattern_seed,
       CASE WHEN identity_type = 'user' THEN NULL
            ELSE (SELECT o.id FROM principals o WHERE o.issuer = 'local' AND o.external_id = 'operator') END,
       CASE WHEN role IN ('coordinator', 'member', 'admin') AND identity_type != 'user' THEN 1 ELSE 0 END,
       CASE WHEN identity_type = 'user' AND external_id LIKE 'hf:%' THEN substr(external_id, 4) END
FROM principals;
DROP TABLE principals;
ALTER TABLE principals_v7 RENAME TO principals;
CREATE TABLE channel_members (
    channel_id   INTEGER NOT NULL REFERENCES channels(id),
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    added_by     INTEGER REFERENCES principals(id),
    added_at     REAL NOT NULL,
    PRIMARY KEY (channel_id, principal_id)
);
CREATE INDEX channel_members_principal ON channel_members(principal_id);
INSERT OR IGNORE INTO channel_members (channel_id, principal_id, added_by, added_at)
    SELECT channel_id, principal_id, NULL, 0 FROM grants WHERE channel_id != 0 AND action = 'messages:read';
DROP TABLE grants;
DROP TABLE provisioner_policies;
ALTER TABLE channels ADD COLUMN kind TEXT NOT NULL DEFAULT 'public';
DROP INDEX credentials_one_active;
UPDATE credentials SET expires_at = NULL WHERE revoked_at IS NULL;
ALTER TABLE invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'human';
ALTER TABLE invites ADD COLUMN role TEXT NOT NULL DEFAULT 'member';
ALTER TABLE invites ADD COLUMN identity_type TEXT NOT NULL DEFAULT 'user';
ALTER TABLE invites ADD COLUMN can_create_channels INTEGER NOT NULL DEFAULT 0;
ALTER TABLE invites ADD COLUMN max_uses INTEGER;
ALTER TABLE invites ADD COLUMN expires_at REAL;
ALTER TABLE invites ADD COLUMN label TEXT;
ALTER TABLE invites ADD COLUMN target_id INTEGER REFERENCES principals(id);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO settings (key, value) VALUES ('allow_private_channels', 'true');
"""

# v8: a `reader` role for agents (reads everything, never posts). Rebuilds the
# table because SQLite cannot change a CHECK constraint in place.
SCHEMA_V8 = """
CREATE TABLE principals_v8 (
    id                  INTEGER PRIMARY KEY,
    issuer              TEXT NOT NULL,
    external_id         TEXT NOT NULL,
    identity_type       TEXT NOT NULL CHECK (identity_type IN ('agent', 'system', 'user')),
    role                TEXT NOT NULL CHECK (role IN ('admin', 'member', 'viewer', 'guest', 'reader')),
    display_name        TEXT,
    disabled            INTEGER NOT NULL DEFAULT 0,
    created_at          REAL NOT NULL,
    created_by          INTEGER REFERENCES principals(id),
    joined_via          TEXT,
    pattern_seed        INTEGER,
    owner_id            INTEGER REFERENCES principals(id),
    can_create_channels INTEGER NOT NULL DEFAULT 0,
    hf_username         TEXT UNIQUE,
    invite_id           INTEGER REFERENCES invites(id),
    UNIQUE (issuer, external_id)
);
INSERT INTO principals_v8 SELECT id, issuer, external_id, identity_type, role, display_name, disabled, created_at,
    created_by, joined_via, pattern_seed, owner_id, can_create_channels, hf_username, invite_id FROM principals;
DROP TABLE principals;
ALTER TABLE principals_v8 RENAME TO principals;
"""

# v9: an invite can name channels the newcomer is added to (how guests get their channels).
SCHEMA_V9 = """
ALTER TABLE invites ADD COLUMN channel_ids TEXT;
"""

# v10: @mentions, and read positions kept on the server (they follow you across devices).
# `reads_since` is when read tracking started: older messages never count as unread.
SCHEMA_V10 = """
CREATE TABLE message_mentions (
    message_id   INTEGER NOT NULL REFERENCES messages(id),
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    PRIMARY KEY (message_id, principal_id)
);
CREATE INDEX message_mentions_principal ON message_mentions(principal_id, message_id);
CREATE TABLE channel_reads (
    principal_id  INTEGER NOT NULL REFERENCES principals(id),
    channel_id    INTEGER NOT NULL REFERENCES channels(id),
    last_read_seq INTEGER NOT NULL,
    updated_at    REAL NOT NULL,
    PRIMARY KEY (principal_id, channel_id)
);
INSERT INTO settings (key, value) VALUES ('reads_since', CAST(strftime('%s', 'now') AS TEXT));
"""

# v11: messages can be edited (earlier versions are kept in message_revisions). Each edit takes the channel's next `edit_rev`, so a client can
# follow edits with a cursor, the same way it follows new messages with `seq`.
SCHEMA_V11 = """
ALTER TABLE messages ADD COLUMN edited_at REAL;
ALTER TABLE messages ADD COLUMN edit_rev INTEGER;
ALTER TABLE channels ADD COLUMN edit_rev INTEGER NOT NULL DEFAULT 0;
CREATE INDEX messages_edits ON messages(channel_id, edit_rev) WHERE edit_rev IS NOT NULL;
CREATE TABLE message_revisions (            -- every body a message had before an edit: history is never lost
    message_id  INTEGER NOT NULL REFERENCES messages(id),
    version     INTEGER NOT NULL,           -- 1 = the original
    body        TEXT NOT NULL,
    written_at  REAL NOT NULL,
    replaced_at REAL NOT NULL,
    PRIMARY KEY (message_id, version)
);
"""

# v12: full-text search over message bodies (an FTS5 index kept in step by triggers, edits included).
SCHEMA_V12 = """
CREATE VIRTUAL TABLE messages_fts USING fts5(body, content='messages', content_rowid='id',
                                            tokenize='unicode61 remove_diacritics 2');
INSERT INTO messages_fts(messages_fts) VALUES ('rebuild');
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
    INSERT INTO messages_fts(rowid, body) VALUES (new.id, new.body);
END;
CREATE TRIGGER messages_fts_update AFTER UPDATE OF body ON messages BEGIN
    INSERT INTO messages_fts(messages_fts, rowid, body) VALUES ('delete', old.id, old.body);
    INSERT INTO messages_fts(rowid, body) VALUES (new.id, new.body);
END;
"""

# v13: emoji reactions on messages (one row per person, message and emoji).
SCHEMA_V13 = """
CREATE TABLE message_reactions (
    message_id   INTEGER NOT NULL REFERENCES messages(id),
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    emoji        TEXT NOT NULL,
    created_at   REAL NOT NULL,
    PRIMARY KEY (message_id, principal_id, emoji)
);
CREATE INDEX message_reactions_principal ON message_reactions(principal_id, created_at);
"""

# v14: one shared Markdown doc per channel. Every saved version is kept.
SCHEMA_V14 = """
CREATE TABLE channel_doc_versions (
    channel_id INTEGER NOT NULL REFERENCES channels(id),
    version    INTEGER NOT NULL,
    body       TEXT NOT NULL,
    author_id  INTEGER NOT NULL REFERENCES principals(id),
    created_at REAL NOT NULL,
    PRIMARY KEY (channel_id, version)
);
ALTER TABLE channels ADD COLUMN doc_version INTEGER NOT NULL DEFAULT 0;
"""

# v15: an agent invite can name the agent it creates (handle and display name).
SCHEMA_V15 = """
ALTER TABLE invites ADD COLUMN agent_name TEXT;
ALTER TABLE invites ADD COLUMN agent_display TEXT;
"""

# v16: channels can be deleted (admins); the search index forgets deleted messages.
SCHEMA_V16 = """
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
    INSERT INTO messages_fts(messages_fts, rowid, body) VALUES ('delete', old.id, old.body);
END;
"""

# v17: an agent invite can make the agent a member of every open channel when it joins.
SCHEMA_V17 = """
ALTER TABLE invites ADD COLUMN join_open_channels INTEGER NOT NULL DEFAULT 0;
"""

# v18: mentions remember the text they were written with (so renames never unlink them), and
# former display names are kept so "@Old Name" still reaches the person.
SCHEMA_V18 = """
ALTER TABLE message_mentions ADD COLUMN mentioned_as TEXT;
CREATE TABLE former_names (
    principal_id INTEGER NOT NULL REFERENCES principals(id),
    name         TEXT NOT NULL,
    until        REAL NOT NULL,
    PRIMARY KEY (principal_id, name)
);
"""

# v19: agents can describe where and how they run (harness, model, tools, host).
SCHEMA_V19 = """
ALTER TABLE principals ADD COLUMN runtime TEXT;
ALTER TABLE principals ADD COLUMN runtime_at REAL;
"""

# v20: threads have their own read position. Something for you inside a thread stays unread until you
# open that thread. `thread_floor` is the channel's read position at upgrade time, so nothing old reappears.
SCHEMA_V20 = """
CREATE TABLE thread_reads (
    principal_id  INTEGER NOT NULL REFERENCES principals(id),
    thread_id     INTEGER NOT NULL REFERENCES messages(id),
    last_read_seq INTEGER NOT NULL,
    updated_at    REAL NOT NULL,
    PRIMARY KEY (principal_id, thread_id)
);
ALTER TABLE channel_reads ADD COLUMN thread_floor INTEGER NOT NULL DEFAULT 0;
UPDATE channel_reads SET thread_floor = last_read_seq;
"""

# v21: per-person preferences that follow you across devices (for now: the order of channels in the sidebar).
SCHEMA_V21 = """
ALTER TABLE principals ADD COLUMN prefs TEXT;
"""

# v22: storage. Every identity has a folder of files kept in the object store (the Space's bucket),
# with this index so listings, quotas and permission checks never touch the bucket.
SCHEMA_V22 = """
CREATE TABLE storage_files (
    owner_id     INTEGER NOT NULL REFERENCES principals(id),
    path         TEXT NOT NULL,
    size         INTEGER NOT NULL,
    sha256       TEXT NOT NULL,
    content_type TEXT,
    updated_at   REAL NOT NULL,
    updated_by   INTEGER NOT NULL REFERENCES principals(id),
    PRIMARY KEY (owner_id, path)
);
"""

# v23: suspensions. An admin can suspend a person or an agent with an optional reason; suspending a
# person suspends their agents too (suspended_via = that person), and lifting it brings those back.
SCHEMA_V23 = """
ALTER TABLE principals ADD COLUMN suspended_at REAL;
ALTER TABLE principals ADD COLUMN suspended_by INTEGER REFERENCES principals(id);
ALTER TABLE principals ADD COLUMN suspend_reason TEXT;
ALTER TABLE principals ADD COLUMN suspended_via INTEGER REFERENCES principals(id);
"""

# Index i holds the SQL that moves the schema from version i to i + 1.
MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
              SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
              SCHEMA_V23]
SCHEMA_VERSION = len(MIGRATIONS)


class SchemaError(RuntimeError):
    pass


def connect(path: str | os.PathLike) -> sqlite3.Connection:
    """Open a connection with the pragmas every connection needs.

    isolation_level=None: no implicit transactions. Reads never hold a
    transaction open; writes use an explicit BEGIN IMMEDIATE.
    """
    conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False, timeout=10.0)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.execute("PRAGMA busy_timeout=10000")
    return conn


def schema_version(conn: sqlite3.Connection) -> int:
    return conn.execute("PRAGMA user_version").fetchone()[0]


def backup(conn: sqlite3.Connection, dest: str | os.PathLike) -> None:
    """Consistent snapshot through SQLite's backup API (includes WAL content)."""
    dest = Path(dest)
    if dest.exists():
        raise FileExistsError(f"backup target already exists: {dest}")
    out = sqlite3.connect(str(dest))
    try:
        conn.backup(out)
    finally:
        out.close()


def migrate(conn: sqlite3.Connection, db_path: str | os.PathLike) -> int:
    """Apply pending migrations. Back up first if the database already had data."""
    current = schema_version(conn)
    if current > SCHEMA_VERSION:
        raise SchemaError(
            f"database schema version {current} is newer than this software ({SCHEMA_VERSION}); refusing to run"
        )
    if current == SCHEMA_VERSION:
        return current
    if current > 0:
        backup(conn, f"{db_path}.pre-v{SCHEMA_VERSION}-{int(time.time())}.bak")
    conn.execute("PRAGMA foreign_keys=OFF")  # must be set outside a transaction
    try:
        for version in range(current, SCHEMA_VERSION):
            conn.execute("BEGIN IMMEDIATE")
            try:
                for statement in _split(MIGRATIONS[version]):
                    conn.execute(statement)
                problems = conn.execute("PRAGMA foreign_key_check").fetchall()
                if problems:
                    raise SchemaError(f"migration to v{version + 1} broke foreign keys: {problems[:3]}")
                conn.execute(f"PRAGMA user_version={version + 1}")
                conn.execute("COMMIT")
            except BaseException:
                conn.execute("ROLLBACK")
                raise
    finally:
        conn.execute("PRAGMA foreign_keys=ON")
    return SCHEMA_VERSION


def _split(script: str) -> list[str]:
    """Split a migration script into statements (executescript would COMMIT for us)."""
    statements, buf = [], []
    for line in script.splitlines():
        if line.strip().startswith("--"):
            continue
        buf.append(line)
        candidate = "\n".join(buf).strip()
        if candidate.endswith(";") and sqlite3.complete_statement(candidate):
            statements.append(candidate)
            buf = []
    if "\n".join(buf).strip():
        raise SchemaError("incomplete statement in migration")
    return statements