f319f052973b

add phase 0 ground: V skeleton, vendored sqlite bindings, library checks

time
2026-09-23 19:42 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
none, the first save
11 files .kohignore+31 −0README.md+30 −0VERSION+1 −0src/cli/cli.v+43 −0src/koh3.v+8 −0src/storage/sqlite.v+259 −0src/storage/sqlite_test.v+62 −0tests/library_check_test.v+133 −0v.mod+6 −0vendor/sqlite/sqlite3.clargevendor/sqlite/sqlite3.h+13715 −0
.kohignore+31 −0
@@ -0,0 +1,31 @@1+# koh ignore rules (.kohignore)2+# Generated by koh init. Edit to fit the project.3+4+# Patterns are matched against repo-relative paths. Suffix `/` restricts5+# to directories. Leading `/` anchors to the repo root. A pattern like6+# `*.log` matches any file with that extension anywhere in the tree.7+#8+# koh already ignores .koh, .koh-wal, .koh-shm, .koh-v2/ and .git/ automatically.9+10+# Build output11+zig-out/12+.zig-cache/13+zig-cache/14+target/15+build/16+dist/17+out/18+19+# Dependencies20+node_modules/21+22+# OS noise23+.DS_Store24+Thumbs.db25+26+# Logs27+*.log28+29+# Koh artifacts30+*.bundle31+*.koh.offer

view file

README.md+30 −0
@@ -0,0 +1,30 @@1+# Koh 32+3+One SQLite file per repo, one line of history where nothing is ever erased,4+optional sync to an S3 bucket or a local folder.5+6+Koh 3 is a rewrite of Koh in V: 18 commands down from 45, no forge, no lanes,7+no manifests — a single `.koh` file at the repo root is the only source of8+truth.9+10+**The specification is `/Users/june/Downloads/Koh 3 — God Document.md`.**11+12+## Build13+14+```sh15+v -o build/koh3 src/koh3.v16+```17+18+Requires V 0.5.x. SQLite is vendored in `vendor/sqlite/`; there are no other19+dependencies.20+21+## Development22+23+```sh24+v test src/          # unit tests25+v test tests/        # end-to-end CLI tests26+v fmt -verify src/ tests/27+```28+29+See `AGENTS.md` for the layout, the line ceiling, and the Koh discipline this30+repo follows.

view file

VERSION+1 −0
@@ -0,0 +1 @@1+3.0.0

view file

src/cli/cli.v+43 −0
@@ -0,0 +1,43 @@1+//! Command line entry: dispatch table, usage text and exit codes.2+//!3+//! Exit codes are fixed across every command: 0 success, 1 refusal or error,4+//! 2 bad usage. Each command returns its code; nothing else calls `exit`.5+6+module cli7+8+// Read from VERSION at build time, so the binary and the manifest cannot drift.9+pub const version = $embed_file('../../VERSION').to_string().trim_space()10+11+const usage = 'koh ${version} — one SQLite file per repo, one line of history12+13+usage: koh <command> [options]14+15+  save, checkpoint, status, log, diff16+  undo, revert, load17+  flag, pluck, peek18+  offer, steal, remote19+  init, doctor, strip, migrate20+21+run "koh help <command>" for one command\'s options.'22+23+pub fn run(args []string) int {24+	if args.len < 2 {25+		eprintln(usage)26+		return 227+	}28+	match args[1] {29+		'--version', '-v' {30+			println('koh ${version}')31+			return 032+		}33+		'--help', '-h', 'help' {34+			println(usage)35+			return 036+		}37+		else {38+			eprintln('koh: unknown command "${args[1]}"')39+			eprintln('run "koh --help" for the command list')40+			return 241+		}42+	}43+}

view file

src/koh3.v+8 −0
@@ -0,0 +1,8 @@1+module main2+3+import os4+import src.cli5+6+fn main() {7+	exit(cli.run(os.args))8+}

view file

src/storage/sqlite.v+259 −0
@@ -0,0 +1,259 @@1+//! SQLite bindings over the vendored amalgamation in `vendor/sqlite`.2+//!3+//! V's own `db.sqlite` module links whatever SQLite the host provides. Koh4+//! links its own copy instead, so macOS and Linux run the same SQLite and the5+//! build depends on nothing outside the repo. Only the calls Koh needs are6+//! declared here — no ORM, no pooling, no reflection.7+8+module storage9+10+#flag -I@VMODROOT/vendor/sqlite11+#flag @VMODROOT/vendor/sqlite/sqlite3.c12+#include "sqlite3.h"13+14+// Opaque handles. V wants every C type named in a declaration to exist.15+pub struct C.sqlite3 {}16+17+pub struct C.sqlite3_stmt {}18+19+fn C.sqlite3_libversion() &char20+21+fn C.sqlite3_open_v2(filename &char, pp_db &&C.sqlite3, flags i32, vfs &char) i3222+23+fn C.sqlite3_close_v2(db &C.sqlite3) i3224+25+fn C.sqlite3_errmsg(db &C.sqlite3) &char26+27+fn C.sqlite3_busy_timeout(db &C.sqlite3, ms i32) i3228+29+fn C.sqlite3_exec(db &C.sqlite3, sql &char, callback voidptr, arg voidptr, errmsg &&char) i3230+31+fn C.sqlite3_free(ptr voidptr)32+33+fn C.sqlite3_prepare_v2(db &C.sqlite3, sql &char, n i32, stmt &&C.sqlite3_stmt, tail &&char) i3234+35+fn C.sqlite3_step(stmt &C.sqlite3_stmt) i3236+37+fn C.sqlite3_reset(stmt &C.sqlite3_stmt) i3238+39+fn C.sqlite3_finalize(stmt &C.sqlite3_stmt) i3240+41+fn C.sqlite3_bind_blob(stmt &C.sqlite3_stmt, idx i32, val voidptr, n i32, done voidptr) i3242+43+fn C.sqlite3_bind_text(stmt &C.sqlite3_stmt, idx i32, val &char, n i32, done voidptr) i3244+45+fn C.sqlite3_bind_int64(stmt &C.sqlite3_stmt, idx i32, val i64) i3246+47+fn C.sqlite3_bind_null(stmt &C.sqlite3_stmt, idx i32) i3248+49+fn C.sqlite3_column_blob(stmt &C.sqlite3_stmt, idx i32) voidptr50+51+fn C.sqlite3_column_bytes(stmt &C.sqlite3_stmt, idx i32) i3252+53+fn C.sqlite3_column_text(stmt &C.sqlite3_stmt, idx i32) &u854+55+fn C.sqlite3_column_int64(stmt &C.sqlite3_stmt, idx i32) i6456+57+fn C.sqlite3_column_type(stmt &C.sqlite3_stmt, idx i32) i3258+59+fn C.sqlite3_changes(db &C.sqlite3) i3260+61+const sqlite_ok = 062+const sqlite_row = 10063+const sqlite_done = 10164+const sqlite_open_readwrite = 0x0000000265+const sqlite_open_create = 0x0000000466+const sqlite_null = 567+68+// SQLITE_TRANSIENT: tells SQLite to copy the buffer instead of borrowing it.69+const transient = unsafe { voidptr(isize(-1)) }70+71+fn cstring(s string) &char {72+	buf := unsafe { malloc_noscan(s.len + 1) }73+	unsafe {74+		vmemcpy(buf, s.str, s.len)75+		buf[s.len] = 076+	}77+	return &char(buf)78+}79+80+pub fn libversion() string {81+	return unsafe { cstring_to_vstring(C.sqlite3_libversion()) }82+}83+84+@[heap]85+pub struct Db {86+mut:87+	conn &C.sqlite3 = unsafe { nil }88+}89+90+// open a database file, creating it when missing. `busy_timeout_ms` is how91+// long a second writer waits before giving up.92+pub fn open(path string, busy_timeout_ms int) !Db {93+	path_c := cstring(path)94+	defer {95+		unsafe { free(path_c) }96+	}97+	mut conn := &C.sqlite3(unsafe { nil })98+	flags := sqlite_open_readwrite | sqlite_open_create99+	code := C.sqlite3_open_v2(path_c, &conn, flags, unsafe { nil })100+	if code != sqlite_ok {101+		msg := unsafe { cstring_to_vstring(C.sqlite3_errmsg(conn)) }102+		C.sqlite3_close_v2(conn)103+		return error('cannot open ${path}: ${msg}')104+	}105+	C.sqlite3_busy_timeout(conn, busy_timeout_ms)106+	return Db{107+		conn: conn108+	}109+}110+111+pub fn (db &Db) close() {112+	if db.conn != unsafe { nil } {113+		C.sqlite3_close_v2(db.conn)114+	}115+}116+117+pub fn (db &Db) last_error() string {118+	return unsafe { cstring_to_vstring(C.sqlite3_errmsg(db.conn)) }119+}120+121+pub fn (db &Db) changes() int {122+	return C.sqlite3_changes(db.conn)123+}124+125+// exec runs one or more statements that return no rows.126+pub fn (db &Db) exec(query string) ! {127+	query_c := cstring(query)128+	defer {129+		unsafe { free(query_c) }130+	}131+	mut errmsg := &char(unsafe { nil })132+	code := C.sqlite3_exec(db.conn, query_c, unsafe { nil }, unsafe { nil }, &errmsg)133+	if code != sqlite_ok {134+		msg := if errmsg != unsafe { nil } {135+			unsafe { cstring_to_vstring(errmsg) }136+		} else {137+			db.last_error()138+		}139+		if errmsg != unsafe { nil } {140+			C.sqlite3_free(errmsg)141+		}142+		return error(msg)143+	}144+}145+146+// Stmt is a prepared statement. Call finalize() when done; `defer stmt.finalize()`147+// is the normal shape.148+@[heap]149+pub struct Stmt {150+mut:151+	stmt &C.sqlite3_stmt = unsafe { nil }152+	db   &Db             = unsafe { nil }153+}154+155+pub fn (db &Db) prepare(query string) !Stmt {156+	query_c := cstring(query)157+	defer {158+		unsafe { free(query_c) }159+	}160+	mut stmt := &C.sqlite3_stmt(unsafe { nil })161+	code := C.sqlite3_prepare_v2(db.conn, query_c, query.len, &stmt, unsafe { nil })162+	if code != sqlite_ok {163+		return error('${db.last_error()} in: ${query}')164+	}165+	return Stmt{166+		stmt: stmt167+		db:   db168+	}169+}170+171+pub fn (stmt &Stmt) bind_text(idx int, val string) ! {172+	if C.sqlite3_bind_text(stmt.stmt, idx, val.str, val.len, transient) != sqlite_ok {173+		return error(stmt.db.last_error())174+	}175+}176+177+pub fn (stmt &Stmt) bind_blob(idx int, val []u8) ! {178+	mut ptr := unsafe { nil }179+	if val.len > 0 {180+		ptr = val.data181+	}182+	if C.sqlite3_bind_blob(stmt.stmt, idx, ptr, val.len, transient) != sqlite_ok {183+		return error(stmt.db.last_error())184+	}185+}186+187+pub fn (stmt &Stmt) bind_int(idx int, val i64) ! {188+	if C.sqlite3_bind_int64(stmt.stmt, idx, val) != sqlite_ok {189+		return error(stmt.db.last_error())190+	}191+}192+193+pub fn (stmt &Stmt) bind_null(idx int) ! {194+	if C.sqlite3_bind_null(stmt.stmt, idx) != sqlite_ok {195+		return error(stmt.db.last_error())196+	}197+}198+199+// step advances the statement. Returns true while rows are available.200+pub fn (stmt &Stmt) step() !bool {201+	code := C.sqlite3_step(stmt.stmt)202+	match code {203+		sqlite_row { return true }204+		sqlite_done { return false }205+		else { return error(stmt.db.last_error()) }206+	}207+}208+209+pub fn (stmt &Stmt) reset() {210+	C.sqlite3_reset(stmt.stmt)211+}212+213+pub fn (stmt &Stmt) finalize() {214+	if stmt.stmt != unsafe { nil } {215+		C.sqlite3_finalize(stmt.stmt)216+	}217+}218+219+fn (stmt &Stmt) is_null(idx int) bool {220+	return C.sqlite3_column_type(stmt.stmt, idx) == sqlite_null221+}222+223+pub fn (stmt &Stmt) text(idx int) string {224+	ptr := C.sqlite3_column_text(stmt.stmt, idx)225+	if ptr == unsafe { nil } {226+		return ''227+	}228+	n := C.sqlite3_column_bytes(stmt.stmt, idx)229+	return unsafe { ptr.vstring_with_len(n) }230+}231+232+pub fn (stmt &Stmt) blob(idx int) []u8 {233+	ptr := C.sqlite3_column_blob(stmt.stmt, idx)234+	n := C.sqlite3_column_bytes(stmt.stmt, idx)235+	if ptr == unsafe { nil } || n == 0 {236+		return []237+	}238+	mut out := []u8{len: n}239+	unsafe { vmemcpy(out.data, ptr, n) }240+	return out241+}242+243+pub fn (stmt &Stmt) int(idx int) i64 {244+	return C.sqlite3_column_int64(stmt.stmt, idx)245+}246+247+pub fn (stmt &Stmt) text_or_none(idx int) ?string {248+	if stmt.is_null(idx) {249+		return none250+	}251+	return stmt.text(idx)252+}253+254+pub fn (stmt &Stmt) int_or_none(idx int) ?i64 {255+	if stmt.is_null(idx) {256+		return none257+	}258+	return stmt.int(idx)259+}

view file

src/storage/sqlite_test.v+62 −0
@@ -0,0 +1,62 @@1+module storage2+3+import os4+5+fn temp_db(name string) !(Db, string) {6+	dir := os.join_path(os.temp_dir(), 'koh3_test_${os.getpid()}_${name}')7+	os.mkdir_all(dir)!8+	db := open(os.join_path(dir, 'test.db'), 5000)!9+	return db, dir10+}11+12+fn test_links_the_vendored_sqlite() {13+	// Guards the point of vendoring: the binary must carry our amalgamation,14+	// not whatever SQLite the host happens to provide.15+	assert libversion() == '3.49.1'16+}17+18+fn test_blob_round_trip_keeps_embedded_nul() {19+	mut db, dir := temp_db('blob')!20+	defer {21+		db.close()22+		os.rmdir_all(dir) or {}23+	}24+	db.exec('CREATE TABLE objects (hash TEXT PRIMARY KEY, data BLOB)')!25+	payload := [u8(0x00), 0x41, 0x00, 0xff, 0x7a]26+	mut ins := db.prepare('INSERT INTO objects (hash, data) VALUES (?, ?)')!27+	ins.bind_text(1, 'abc')!28+	ins.bind_blob(2, payload)!29+	ins.step()!30+	ins.finalize()31+32+	mut sel := db.prepare('SELECT data, missing FROM (SELECT data, NULL AS missing FROM objects)')!33+	assert sel.step()!34+	assert sel.blob(0) == payload35+	assert sel.text_or_none(1) == none36+	sel.finalize()37+38+	mut sel2 := db.prepare('SELECT hash FROM objects WHERE hash = ?')!39+	sel2.bind_text(1, 'abc')!40+	assert sel2.step()!41+	assert sel2.text(0) == 'abc'42+	assert !sel2.step()!43+	sel2.finalize()44+}45+46+fn test_foreign_keys_enforce_references() {47+	mut db, dir := temp_db('pragma')!48+	defer {49+		db.close()50+		os.rmdir_all(dir) or {}51+	}52+	db.exec('PRAGMA journal_mode=WAL')!53+	db.exec('PRAGMA foreign_keys=ON')!54+	db.exec('CREATE TABLE saves (id TEXT PRIMARY KEY)')!55+	db.exec('CREATE TABLE save_files (save_id TEXT REFERENCES saves(id), path TEXT)')!56+	db.exec("INSERT INTO saves (id) VALUES ('a')")!57+	if _ := db.exec("INSERT INTO save_files (save_id, path) VALUES ('ghost', 'x')") {58+		assert false, 'a foreign key violation should have failed the insert'59+	} else {60+		assert err.msg().contains('FOREIGN KEY')61+	}62+}

view file

tests/library_check_test.v+133 −0
@@ -0,0 +1,133 @@1+//! Phase 0 library checks.2+//!3+//! Koh 3 depends on five things outside its own source: SQLite (verified in4+//! src/storage), Blake3, zlib, SHA-256/HMAC and an HTTP client. This file pins5+//! the behaviour Koh relies on for the four stdlib ones, against vectors6+//! produced by other implementations of the same algorithms. If a V upgrade7+//! breaks an assumption, it breaks here rather than in a save.8+9+module tests10+11+import compress.zlib12+import crypto.blake313+import crypto.hmac14+import crypto.sha25615+import net16+import net.http17+import time18+19+// Vectors from the official blake3 bindings, cross-checked against the20+// naming of real Koh 2 objects: `.koh/objects/<hash[0..2]>/<hash[2..]>` is21+// blake3(uncompressed bytes) of a zlib stream.22+fn test_blake3_vectors() {23+	assert blake3.sum256(''.bytes()).hex() == 'af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262'24+	assert blake3.sum256('abc'.bytes()).hex() == '6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85'25+	assert blake3.sum256('The quick brown fox jumps over the lazy dog'.bytes()).hex() == '2f1514181aadccd913abd94cfa592701a5686ab23f8df1dff1b74710febc6d4a'26+}27+28+fn test_blake3_streaming_matches_one_shot() {29+	mut d := blake3.Digest.new_hash()!30+	d.write('The quick brown '.bytes())!31+	d.write('fox jumps over the lazy dog'.bytes())!32+	assert d.checksum(32) == blake3.sum256('The quick brown fox jumps over the lazy dog'.bytes())33+}34+35+fn test_zlib_round_trip() {36+	// Object bytes must survive a trip through the store byte for byte,37+	// including an empty payload and one with every byte value.38+	mut all_bytes := []u8{len: 256}39+	for i in 0 .. 256 {40+		all_bytes[i] = u8(i)41+	}42+	mut payloads := [[]u8{}, 'hello koh'.bytes()]43+	payloads << all_bytes44+	for payload in payloads {45+		packed := zlib.compress(payload)!46+		assert zlib.decompress(packed)! == payload47+	}48+}49+50+fn test_sha256_and_hmac_vectors() {51+	assert sha256.sum256('abc'.bytes()).hex() == 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'52+	assert sha256.hexhash('abc') == 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'53+	// HMAC-SHA256, the primitive behind SigV4 signing.54+	mac := hmac.new('key'.bytes(), 'The quick brown fox jumps over the lazy dog'.bytes(),55+		sha256.sum256, 64)56+	assert mac.hex() == 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'57+}58+59+struct EtagHandler {60+mut:61+	seen_if_none_match string62+}63+64+fn (mut h EtagHandler) handle(req http.Request) http.Response {65+	h.seen_if_none_match = req.header.get_custom('If-None-Match', http.HeaderQueryConfig{}) or {66+		''67+	}68+	mut resp := http.Response{69+		body: 'payload'70+	}71+	if h.seen_if_none_match == '*' {72+		resp.status_code = 41273+		resp.status_msg = 'Precondition Failed'74+	} else {75+		resp.status_code = 20076+		resp.status_msg = 'OK'77+	}78+	resp.header.add_custom('ETag', '"3b1f"') or {}79+	return resp80+}81+82+// Starts the probe server on the first free port, so parallel test runs do not83+// fight over a fixed one.84+fn start_probe_server() !(int, &http.Server) {85+	for port in [19301, 19302, 19303, 19304, 19305] {86+		listener := net.listen_tcp(.ip, '127.0.0.1:${port}') or { continue }87+		mut server := &http.Server{88+			listener:             listener89+			handler:              EtagHandler{}90+			show_startup_message: false91+		}92+		spawn server.listen_and_serve()93+		server.wait_till_running()!94+		return port, server95+	}96+	return error('no free port for the probe server')97+}98+99+// The S3 backend needs custom request headers, the response status, and the100+// ETag that conditional writes are keyed on. TLS itself is exercised against a101+// live bucket, not here.102+fn test_http_client_sends_headers_and_reads_status_and_etag() {103+	port, mut server := start_probe_server()!104+	defer {105+		server.stop()106+	}107+	url := 'http://127.0.0.1:${port}/probe'108+	mut hdr := http.new_header()109+	hdr.add_custom('If-None-Match', '*')!110+111+	resp := http.fetch(112+		url:    url113+		method: .get114+		header: hdr115+	)!116+	assert resp.status_code == 412117+	assert resp.header.get_custom('ETag', http.HeaderQueryConfig{}) or { '' } == '"3b1f"'118+119+	plain := http.fetch(url: url, method: .get)!120+	assert plain.status_code == 200121+	assert plain.body == 'payload'122+123+	// Retries: the S3 backend retries network errors and 5xx. A dead port must124+	// surface as an error rather than a hang.125+	if _ := http.fetch(126+		url:          'http://127.0.0.1:19399/gone'127+		method:       .get128+		read_timeout: 2 * time.second129+	)130+	{131+		assert false, 'a request to a closed port should fail'132+	}133+}

view file

v.mod+6 −0
@@ -0,0 +1,6 @@1+Module {2+	name: 'koh3'3+	version: '3.0.0'4+	description: 'One SQLite file per repo, one line of history.'5+	license: 'MIT'6+}

view file

vendor/sqlite/sqlite3.clarge

over 1 MB, not shown

view file

vendor/sqlite/sqlite3.h+13715 −0

not shown here: too long for one page

view file