storage.v

6.5 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	return open_at(os.join_path(root, file_name), root)102}103104// open_at connects to one database file that stands in for the repo's `.koh`.105// Nothing but migration needs that.106pub fn open_at(path string, root string) !Store {107	if !os.is_file(path) {108		return error('no ${file_name} in ${root} — run "koh init"')109	}110	db := connect(path, busy_timeout_ms)!111	apply_settings(db)!112	check_version(db)!113	return Store{114		db:   db115		root: root116	}117}118119// create writes a new `.koh` in `root`.120pub fn create(root string) !Store {121	return create_at(os.join_path(root, file_name), root)122}123124// create_at writes a fresh schema into an explicit file.125pub fn create_at(path string, root string) !Store {126	if os.is_file(path) {127		return error('${path} already exists')128	}129	db := connect(path, busy_timeout_ms)!130	apply_settings(db)!131	db.exec('BEGIN IMMEDIATE')!132	db.exec(schema)!133	db.exec('PRAGMA user_version = ${schema_version}')!134	db.exec('END')!135	return Store{136		db:   db137		root: root138	}139}140141pub fn (s &Store) close() {142	s.db.close()143}144145fn apply_settings(db &Db) ! {146	// WAL for crash safety, full sync so a finished save is on disk, foreign147	// keys so a half-written save cannot exist.148	db.exec('PRAGMA journal_mode=WAL')!149	db.exec('PRAGMA synchronous=FULL')!150	db.exec('PRAGMA foreign_keys=ON')!151}152153fn check_version(db &Db) ! {154	mut stmt := db.prepare('PRAGMA user_version')!155	version := if stmt.step()! { stmt.int(0) } else { 0 }156	stmt.finalize()157	if version > schema_version {158		return error('this .koh was written by a newer koh (schema ${version}, this build knows ${schema_version})')159	}160	if version < schema_version {161		return error('this .koh needs an upgrade koh does not know how to do (schema ${version})')162	}163}164165// What any write says after waiting busy_timeout_ms for another writer.166pub const busy = 'another koh command is writing to this repo — wait for it to finish, then run this again'167168// begin takes the write lock at once (IMMEDIATE), so a second writer waits busy_timeout_ms here169// instead of failing part way through.170pub fn (s &Store) begin() ! {171	s.db.exec('BEGIN IMMEDIATE')!172}173174// end finishes the transaction begin opened.175pub fn (s &Store) end() ! {176	s.db.exec('END')!177}178179// rollback drops its own failure: the caller is already returning the error that sent it here.180pub fn (s &Store) rollback() {181	s.db.exec('ROLLBACK') or {}182}183184// stripped_paths are the paths a whole-history strip removed. A strip bans its path for good,185// so no walk of the tree may pick it up again.186pub fn (s &Store) stripped_paths() ![]string {187	mut stmt := s.db.prepare("SELECT DISTINCT path FROM events WHERE kind = 'strip' AND IFNULL(save_id, '') = ''")!188	defer {189		stmt.finalize()190	}191	mut paths := []string{}192	for stmt.step()! {193		paths << stmt.text(0)194	}195	return paths196}197198// state reads a machine-local fact. A read error answers none, the same as unset.199pub fn (s &Store) state(key string) ?string {200	mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none }201	defer {202		stmt.finalize()203	}204	stmt.bind_text(1, key) or { return none }205	if stmt.step() or { return none } {206		return stmt.text(0)207	}208	return none209}210211pub fn (s &Store) set_state(key string, value string) ! {212	mut stmt := s.db.prepare('INSERT INTO state (key, value) VALUES (?, ?)213		ON CONFLICT(key) DO UPDATE SET value = excluded.value')!214	defer {215		stmt.finalize()216	}217	stmt.bind_text(1, key)!218	stmt.bind_text(2, value)!219	stmt.step()!220}221222pub fn (s &Store) remove_state(key string) ! {223	mut stmt := s.db.prepare('DELETE FROM state WHERE key = ?')!224	defer {225		stmt.finalize()226	}227	stmt.bind_text(1, key)!228	stmt.step()!229}230231// backup writes a consistent copy of the database. `VACUUM INTO` never copies232// a WAL mid-write, which a raw file copy would.233pub fn (s &Store) backup(path string) ! {234	if os.exists(path) {235		return error('${path} already exists')236	}237	s.db.exec("VACUUM INTO '${path.replace("'", "''")}'")!238}239240// integrity_check returns SQLite's complaints; empty means the file is sound.241pub fn (s &Store) integrity_check() ![]string {242	mut problems := []string{}243	mut stmt := s.db.prepare('PRAGMA integrity_check')!244	defer {245		stmt.finalize()246	}247	for stmt.step()! {248		line := stmt.text(0)249		if line != 'ok' {250			problems << line251		}252	}253	return problems254}