//! The `.koh` file: schema, connection settings and machine-local state. //! //! One file at the repo root holds everything — objects, saves, events and //! bookkeeping. There is no second source of truth, so there is nothing to //! rebuild, verify against, or recover. module storage import os pub const schema_version = 1 pub const file_name = '.koh' pub const busy_timeout_ms = 5000 // Every fact Koh knows lives in these six tables. `state` and `file_cache` are // machine-local: they are never synced and may be dropped at any time. const schema = ' CREATE TABLE objects ( hash TEXT PRIMARY KEY, size INTEGER NOT NULL, data BLOB NOT NULL ); CREATE TABLE saves ( id TEXT PRIMARY KEY, parent TEXT REFERENCES saves(id), message TEXT NOT NULL, created_at INTEGER NOT NULL, machine TEXT NOT NULL, is_checkpoint INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0, agent TEXT, session TEXT, restores TEXT, replayed_from TEXT ); CREATE TABLE save_files ( save_id TEXT NOT NULL REFERENCES saves(id), path TEXT NOT NULL, hash TEXT NOT NULL REFERENCES objects(hash), executable INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (save_id, path) ); CREATE TABLE events ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, save_id TEXT, path TEXT, reason TEXT, created_at INTEGER NOT NULL, machine TEXT NOT NULL ); CREATE TABLE state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE file_cache ( path TEXT PRIMARY KEY, size INTEGER NOT NULL, mtime_ns INTEGER NOT NULL, ctime_ns INTEGER NOT NULL, hash TEXT NOT NULL ); CREATE INDEX saves_parent ON saves(parent); CREATE INDEX save_files_hash ON save_files(hash); ' pub const head_key = 'head' @[heap] pub struct Store { pub mut: db Db root string } // find_root walks up from `start` until it finds a `.koh` file. pub fn find_root(start string) ?string { mut dir := os.real_path(start) for { if os.is_file(os.join_path(dir, file_name)) { return dir } parent := os.dir(dir) if parent == dir || parent == '' { return none } dir = parent } return none } // open connects to the repo at `root`. A missing `.koh` is an error, not a // silent creation: `koh init` is the only thing that creates a repo. pub fn open(root string) !Store { return open_at(os.join_path(root, file_name), root) } // open_at connects to one database file that stands in for the repo's `.koh`. // Nothing but migration needs that. pub fn open_at(path string, root string) !Store { if !os.is_file(path) { return error('no ${file_name} in ${root} — run "koh init"') } db := connect(path, busy_timeout_ms)! apply_settings(db)! check_version(db)! return Store{ db: db root: root } } // create writes a new `.koh` in `root`. pub fn create(root string) !Store { return create_at(os.join_path(root, file_name), root) } // create_at writes a fresh schema into an explicit file. pub fn create_at(path string, root string) !Store { if os.is_file(path) { return error('${path} already exists') } db := connect(path, busy_timeout_ms)! apply_settings(db)! db.exec('BEGIN IMMEDIATE')! db.exec(schema)! db.exec('PRAGMA user_version = ${schema_version}')! db.exec('END')! return Store{ db: db root: root } } pub fn (s &Store) close() { s.db.close() } fn apply_settings(db &Db) ! { // WAL for crash safety, full sync so a finished save is on disk, foreign // keys so a half-written save cannot exist. db.exec('PRAGMA journal_mode=WAL')! db.exec('PRAGMA synchronous=FULL')! db.exec('PRAGMA foreign_keys=ON')! } fn check_version(db &Db) ! { mut stmt := db.prepare('PRAGMA user_version')! version := if stmt.step()! { stmt.int(0) } else { 0 } stmt.finalize() if version > schema_version { return error('this .koh was written by a newer koh (schema ${version}, this build knows ${schema_version})') } if version < schema_version { return error('this .koh needs an upgrade koh does not know how to do (schema ${version})') } } // What any write says after waiting busy_timeout_ms for another writer. pub const busy = 'another koh command is writing to this repo — wait for it to finish, then run this again' pub fn (s &Store) begin() ! { s.db.exec('BEGIN IMMEDIATE')! } pub fn (s &Store) end() ! { s.db.exec('END')! } pub fn (s &Store) rollback() { s.db.exec('ROLLBACK') or {} } // stripped_paths are the paths a whole-history strip removed. A strip bans its path for good, // so no walk of the tree may pick it up again. pub fn (s &Store) stripped_paths() ![]string { mut stmt := s.db.prepare("SELECT DISTINCT path FROM events WHERE kind = 'strip' AND IFNULL(save_id, '') = ''")! defer { stmt.finalize() } mut paths := []string{} for stmt.step()! { paths << stmt.text(0) } return paths } pub fn (s &Store) state(key string) ?string { mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none } defer { stmt.finalize() } stmt.bind_text(1, key) or { return none } if stmt.step() or { return none } { return stmt.text(0) } return none } pub fn (s &Store) set_state(key string, value string) ! { mut stmt := s.db.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')! defer { stmt.finalize() } stmt.bind_text(1, key)! stmt.bind_text(2, value)! stmt.step()! } pub fn (s &Store) remove_state(key string) ! { mut stmt := s.db.prepare('DELETE FROM state WHERE key = ?')! defer { stmt.finalize() } stmt.bind_text(1, key)! stmt.step()! } // backup writes a consistent copy of the database. `VACUUM INTO` never copies // a WAL mid-write, which a raw file copy would. pub fn (s &Store) backup(path string) ! { if os.exists(path) { return error('${path} already exists') } s.db.exec("VACUUM INTO '${path.replace("'", "''")}'")! } pub fn (s &Store) integrity_check() ![]string { mut problems := []string{} mut stmt := s.db.prepare('PRAGMA integrity_check')! defer { stmt.finalize() } for stmt.step()! { line := stmt.text(0) if line != 'ok' { problems << line } } return problems }