group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

hezo / server / db.ts
4.4 kB 128 lines
1// SQLite access. v2 is a single global DB — no per-project SQLite, 2// no blob directory. Everything content-shaped lives in client CRDTs. 3// Schema is managed by versioned migrations (server/schemas/index.ts). 4 5import Database from 'better-sqlite3' 6import * as fs from 'node:fs' 7import * as path from 'node:path' 8import { GLOBAL_MIGRATIONS, type Migration } from './schemas/index.js' 9import { DATA_DIR } from './paths.js' 10 11export { DATA_DIR } 12 13// v1 (Centrosome era) shipped a different global schema (no 14// identity_pub on users, no webauthn_credentials, no outbox table) plus 15// a `projects/<id>.sqlite` directory of per-project DBs that v2 doesn't 16// understand. If we boot against a v1 volume, wipe it clean rather than 17// limping forward with stale tables that v2 queries will silently miss. 18function looksLikeV1Schema(db: Database.Database): boolean { 19 const hasMeta = db 20 .prepare( 21 `SELECT 1 AS x FROM sqlite_master WHERE type='table' AND name='meta'`, 22 ) 23 .get() as { x: number } | undefined 24 if (!hasMeta) return false 25 const hasV2Table = db 26 .prepare( 27 `SELECT 1 AS x FROM sqlite_master WHERE type='table' AND name='webauthn_credentials'`, 28 ) 29 .get() as { x: number } | undefined 30 if (hasV2Table) return false 31 const hasUsers = db 32 .prepare( 33 `SELECT 1 AS x FROM sqlite_master WHERE type='table' AND name='users'`, 34 ) 35 .get() as { x: number } | undefined 36 // If users existed without a webauthn_credentials sibling, this is 37 // either v1 or a half-migrated v2 we should also rebuild. 38 return !!hasUsers 39} 40 41function wipeAllUserTables(db: Database.Database): void { 42 const tables = db 43 .prepare( 44 `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, 45 ) 46 .all() as Array<{ name: string }> 47 db.exec('PRAGMA foreign_keys = OFF') 48 for (const { name } of tables) db.exec(`DROP TABLE IF EXISTS "${name}"`) 49 db.exec('PRAGMA foreign_keys = ON') 50} 51 52// Drop the v1 `projects/` directory if it exists — those were the 53// per-project SQLite files + content-addressed blob dirs. v2 doesn't 54// touch them, so leaving them in place would just eat disk forever. 55function wipeV1ProjectFiles(): void { 56 const projectsDir = path.join(DATA_DIR, 'projects') 57 if (!fs.existsSync(projectsDir)) return 58 try { 59 fs.rmSync(projectsDir, { recursive: true, force: true }) 60 console.warn(`[migration] removed legacy v1 projects dir: ${projectsDir}`) 61 } catch (err) { 62 console.warn('[migration] could not remove legacy projects dir:', err) 63 } 64} 65 66function applyMigrations( 67 db: Database.Database, 68 migrations: Migration[], 69): void { 70 db.exec( 71 'CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)', 72 ) 73 const row = db 74 .prepare(`SELECT v FROM meta WHERE k = 'schema_version'`) 75 .get() as { v: string } | undefined 76 const current = row ? parseInt(row.v, 10) : 0 77 for (const m of migrations) { 78 if (m.version <= current) continue 79 const run = (): void => { 80 if (m.sql) db.exec(m.sql) 81 if (m.apply) m.apply(db) 82 db.prepare( 83 `INSERT INTO meta (k, v) VALUES ('schema_version', ?) 84 ON CONFLICT(k) DO UPDATE SET v = excluded.v`, 85 ).run(String(m.version)) 86 } 87 if (m.useTransaction === false) { 88 run() 89 } else { 90 db.transaction(run)() 91 } 92 } 93} 94 95const globalDb = new Database(path.join(DATA_DIR, 'global.sqlite')) 96globalDb.pragma('journal_mode = WAL') 97globalDb.pragma('foreign_keys = ON') 98 99if (looksLikeV1Schema(globalDb)) { 100 console.warn('[migration] detected v1 schema in global.sqlite; wiping for v2 bootstrap') 101 wipeAllUserTables(globalDb) 102 wipeV1ProjectFiles() 103} 104 105applyMigrations(globalDb, GLOBAL_MIGRATIONS) 106 107export function db(): Database.Database { 108 return globalDb 109} 110 111// Periodic outbox TTL sweep. Outbox entries expire 30 days after 112// creation regardless of delivery. WebAuthn challenges expire after 113// a few minutes. .unref() so the timer doesn't keep the process alive. 114const SWEEP_INTERVAL_MS = 60 * 60 * 1000 // 1h 115const sweepTimer = setInterval(() => { 116 try { 117 const n = globalDb 118 .prepare('DELETE FROM outbox WHERE expires_at < ?') 119 .run(Date.now()).changes 120 if (n > 0) console.log(`[outbox] swept ${n} expired entries`) 121 globalDb 122 .prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?') 123 .run(Date.now()) 124 } catch (err) { 125 console.warn('[sweep]', err) 126 } 127}, SWEEP_INTERVAL_MS) 128sweepTimer.unref()