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
↓ koh-f319f052.tar.gzatlas at this save
files changed11 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.offerREADME.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.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+}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+}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+}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+}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+}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+}