storage.v

5.2 KB · raw

1//! The `.koh` file: schema, connection settings and machine-local state.2//!3//! One file at the repo root holds everything — objects, saves, events and4//! bookkeeping. There is no second source of truth, so there is nothing to5//! rebuild, verify against, or recover.67module storage89import os1011pub const schema_version = 112pub const file_name = '.koh'13pub const busy_timeout_ms = 50001415// Every fact Koh knows lives in these six tables. `state` and `file_cache` are16// machine-local: they are never synced and may be dropped at any time.17const schema = '18CREATE TABLE objects (19	hash       TEXT PRIMARY KEY,20	size       INTEGER NOT NULL,21	data       BLOB NOT NULL22);2324CREATE TABLE saves (25	id            TEXT PRIMARY KEY,26	parent        TEXT REFERENCES saves(id),27	message       TEXT NOT NULL,28	created_at    INTEGER NOT NULL,29	machine       TEXT NOT NULL,30	is_checkpoint INTEGER NOT NULL DEFAULT 0,31	size_bytes    INTEGER NOT NULL DEFAULT 0,32	agent         TEXT,33	session       TEXT,34	restores      TEXT,35	replayed_from TEXT36);3738CREATE TABLE save_files (39	save_id    TEXT NOT NULL REFERENCES saves(id),40	path       TEXT NOT NULL,41	hash       TEXT NOT NULL REFERENCES objects(hash),42	executable INTEGER NOT NULL DEFAULT 0,43	PRIMARY KEY (save_id, path)44);4546CREATE TABLE events (47	id         TEXT PRIMARY KEY,48	kind       TEXT NOT NULL,49	save_id    TEXT,50	path       TEXT,51	reason     TEXT,52	created_at INTEGER NOT NULL,53	machine    TEXT NOT NULL54);5556CREATE TABLE state (57	key   TEXT PRIMARY KEY,58	value TEXT NOT NULL59);6061CREATE TABLE file_cache (62	path     TEXT PRIMARY KEY,63	size     INTEGER NOT NULL,64	mtime_ns INTEGER NOT NULL,65	ctime_ns INTEGER NOT NULL,66	hash     TEXT NOT NULL67);6869CREATE INDEX saves_parent ON saves(parent);70CREATE INDEX save_files_hash ON save_files(hash);71'7273pub const head_key = 'head'7475@[heap]76pub struct Store {77pub mut:78	db   Db79	root string80}8182// find_root walks up from `start` until it finds a `.koh` file.83pub fn find_root(start string) ?string {84	mut dir := os.real_path(start)85	for {86		if os.is_file(os.join_path(dir, file_name)) {87			return dir88		}89		parent := os.dir(dir)90		if parent == dir || parent == '' {91			return none92		}93		dir = parent94	}95	return none96}9798// open connects to the repo at `root`. A missing `.koh` is an error, not a99// silent creation: `koh init` is the only thing that creates a repo.100pub fn open(root string) !Store {101	path := os.join_path(root, file_name)102	if !os.is_file(path) {103		return error('no ${file_name} in ${root} — run "koh init"')104	}105	db := connect(path, busy_timeout_ms)!106	apply_settings(db)!107	check_version(db)!108	return Store{109		db:   db110		root: root111	}112}113114// create writes a new `.koh` in `root` and seeds the default ignore file.115pub fn create(root string) !Store {116	path := os.join_path(root, file_name)117	if os.is_file(path) {118		return error('${path} already exists')119	}120	db := connect(path, busy_timeout_ms)!121	apply_settings(db)!122	db.exec('BEGIN IMMEDIATE')!123	db.exec(schema)!124	db.exec('PRAGMA user_version = ${schema_version}')!125	db.exec('COMMIT')!126	return Store{127		db:   db128		root: root129	}130}131132pub fn (s &Store) close() {133	s.db.close()134}135136fn apply_settings(db &Db) ! {137	// WAL for crash safety, full sync so a committed save is on disk, foreign138	// keys so a half-written save cannot exist.139	db.exec('PRAGMA journal_mode=WAL')!140	db.exec('PRAGMA synchronous=FULL')!141	db.exec('PRAGMA foreign_keys=ON')!142}143144fn check_version(db &Db) ! {145	mut stmt := db.prepare('PRAGMA user_version')!146	version := if stmt.step()! { stmt.int(0) } else { 0 }147	stmt.finalize()148	if version > schema_version {149		return error('this .koh was written by a newer koh (schema ${version}, this build knows ${schema_version})')150	}151	if version < schema_version {152		return error('this .koh needs an upgrade koh does not know how to do (schema ${version})')153	}154}155156pub fn (s &Store) begin() ! {157	s.db.exec('BEGIN IMMEDIATE')!158}159160pub fn (s &Store) commit() ! {161	s.db.exec('COMMIT')!162}163164pub fn (s &Store) rollback() {165	s.db.exec('ROLLBACK') or {}166}167168pub fn (s &Store) state(key string) ?string {169	mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none }170	defer {171		stmt.finalize()172	}173	stmt.bind_text(1, key) or { return none }174	if stmt.step() or { return none } {175		return stmt.text(0)176	}177	return none178}179180pub fn (s &Store) set_state(key string, value string) ! {181	mut stmt := s.db.prepare('INSERT INTO state (key, value) VALUES (?, ?)182		ON CONFLICT(key) DO UPDATE SET value = excluded.value')!183	defer {184		stmt.finalize()185	}186	stmt.bind_text(1, key)!187	stmt.bind_text(2, value)!188	stmt.step()!189}190191pub fn (s &Store) remove_state(key string) ! {192	mut stmt := s.db.prepare('DELETE FROM state WHERE key = ?')!193	defer {194		stmt.finalize()195	}196	stmt.bind_text(1, key)!197	stmt.step()!198}199200// backup writes a consistent copy of the database. `VACUUM INTO` never copies201// a WAL mid-write, which a raw file copy would.202pub fn (s &Store) backup(path string) ! {203	if os.exists(path) {204		return error('${path} already exists')205	}206	s.db.exec("VACUUM INTO '${path.replace("'", "''")}'")!207}208209pub fn (s &Store) integrity_check() ![]string {210	mut problems := []string{}211	mut stmt := s.db.prepare('PRAGMA integrity_check')!212	defer {213		stmt.finalize()214	}215	for stmt.step()! {216		line := stmt.text(0)217		if line != 'ok' {218			problems << line219		}220	}221	return problems222}