P5.1: vendor SQLite 3.53.2 + sx bindings

Subplan 02 Slice 2 foundation. vendor/sqlite/ holds the amalgamation
(provenance + upgrade notes in its README); make build compiles it into
build/vendor/libsqlite3.a (statically linked into dist via -L) and
build/vendor/jit/libsqlite3.dylib (dlopen'd by sx run via tests/run.sh's
-L flag) — separate directories because the macOS linker prefers a dylib
over an archive in one search dir.

The sx JIT resolves #foreign symbols via dlsym(RTLD_DEFAULT), where the
already-loaded OS libsqlite3 wins by load order — so the vendored build
renames its API to dist_sqlite3_* (vendor/sqlite/rename.h, -include'd),
making resolution unambiguous in both modes: those symbols exist only in
the vendored products.

src/db/sqlite.sx binds the renamed surface behind Sqlite/SqliteStmt
(open/exec/prepare/bind/step/column/finalize, errmsg, last_insert_rowid,
changes, libversion); opaque handles cross the FFI as usize, strings
read from sqlite are copied before its buffers die.

make test 20/20 (new: sqlite_smoke.sx — pins the loaded version to the
vendored 3.53.2, round trip, reopen persistence, BEGIN/ROLLBACK, errmsg;
also verified as an AOT binary with no libsqlite3 in otool -L).
This commit is contained in:
agra
2026-06-12 12:07:22 +03:00
parent aea3d62b60
commit afec94a113
8 changed files with 284184 additions and 4 deletions

View File

@@ -12,13 +12,35 @@ BUILD_DIR := build
SMOKE := tests/smoke.sx
DIST := src/dist.sx
.PHONY: build test publish-example clean
# Vendored SQLite (vendor/sqlite/README.md): one amalgamation, two build
# products in two DIRECTORIES — the macOS linker prefers a dylib over an
# archive in the same -L directory, and `sx build` must link the static
# copy while `sx run` (the test runner) dlopens the dylib.
VENDOR_DIR := $(BUILD_DIR)/vendor
SQLITE_SRC := vendor/sqlite/sqlite3.c
SQLITE_DEFS := -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=0 -DSQLITE_DEFAULT_MEMSTATUS=0 \
-DSQLITE_OMIT_DEPRECATED -DSQLITE_OMIT_SHARED_CACHE \
-DSQLITE_LIKE_DOESNT_MATCH_BLOBS \
-include vendor/sqlite/rename.h
$(VENDOR_DIR)/libsqlite3.a: $(SQLITE_SRC) vendor/sqlite/sqlite3.h vendor/sqlite/rename.h
@mkdir -p $(VENDOR_DIR)
cc $(SQLITE_DEFS) -O2 -c $(SQLITE_SRC) -o $(VENDOR_DIR)/sqlite3.o
ar rcs $@ $(VENDOR_DIR)/sqlite3.o
$(VENDOR_DIR)/jit/libsqlite3.dylib: $(SQLITE_SRC) vendor/sqlite/sqlite3.h vendor/sqlite/rename.h
@mkdir -p $(VENDOR_DIR)/jit
cc $(SQLITE_DEFS) -O2 -dynamiclib $(SQLITE_SRC) -o $@
.PHONY: build test publish-example vendor clean
vendor: $(VENDOR_DIR)/libsqlite3.a $(VENDOR_DIR)/jit/libsqlite3.dylib
# Compile the product sources (and the smoke program) without running.
build:
build: vendor
@mkdir -p $(BUILD_DIR)
$(SX) build -o $(BUILD_DIR)/smoke $(SMOKE)
$(SX) build -o $(BUILD_DIR)/dist $(DIST)
$(SX) build -o $(BUILD_DIR)/dist $(DIST) -L $(VENDOR_DIR)
# Run the test runner over every tests/**/*.sx. Exits non-zero on any
# failure. Depends on `build` so the CLI acceptance test (tests/cli_*.sx)

201
src/db/sqlite.sx Normal file
View File

@@ -0,0 +1,201 @@
// =====================================================================
// sqlite.sx — thin sx bindings over the VENDORED SQLite amalgamation
// (vendor/sqlite/, see its README for version + upgrade notes).
//
// `#library "sqlite3"` resolves to the vendored build products under
// build/vendor/ (never the OS copy): `sx build` links the static
// archive via `-L build/vendor`; `sx run` dlopens the dylib via
// `-L build/vendor/jit` (tests/run.sh). tests/sqlite_smoke.sx pins
// `sqlite3_libversion()` to the vendored version so a silent fallback
// to the system library fails the suite.
//
// Handles cross the FFI as `usize` (`sqlite3*` / `sqlite3_stmt*` are
// opaque), C strings as `[*]u8` + explicit length or null-termination.
// Strings READ from SQLite (column_text, errmsg, libversion) are COPIED
// into `context.allocator` before returning — sqlite's buffers die on
// the next step/finalize/close.
//
// The wrapper surface is exactly what the storage layer needs: open /
// exec / prepare / bind / step / column / finalize, last_insert_rowid,
// and errmsg for failure detail. Everything else stays behind the FFI
// line until needed.
// =====================================================================
#import "modules/std.sx";
sqlib :: #library "sqlite3";
sqlite3_open :: (path: [*]u8, out_db: *usize) -> i32 #foreign sqlib "dist_sqlite3_open";
sqlite3_close :: (db: usize) -> i32 #foreign sqlib "dist_sqlite3_close";
sqlite3_exec :: (db: usize, sql: [*]u8, cb: usize, arg: usize, errmsg: usize) -> i32 #foreign sqlib "dist_sqlite3_exec";
sqlite3_prepare_v2 :: (db: usize, sql: [*]u8, nbyte: i32, out_stmt: *usize, out_tail: usize) -> i32 #foreign sqlib "dist_sqlite3_prepare_v2";
sqlite3_step :: (stmt: usize) -> i32 #foreign sqlib "dist_sqlite3_step";
sqlite3_finalize :: (stmt: usize) -> i32 #foreign sqlib "dist_sqlite3_finalize";
sqlite3_reset :: (stmt: usize) -> i32 #foreign sqlib "dist_sqlite3_reset";
sqlite3_bind_text :: (stmt: usize, idx: i32, text: [*]u8, n: i32, destructor: isize) -> i32 #foreign sqlib "dist_sqlite3_bind_text";
sqlite3_bind_int64 :: (stmt: usize, idx: i32, v: i64) -> i32 #foreign sqlib "dist_sqlite3_bind_int64";
sqlite3_bind_null :: (stmt: usize, idx: i32) -> i32 #foreign sqlib "dist_sqlite3_bind_null";
sqlite3_column_int64 :: (stmt: usize, icol: i32) -> i64 #foreign sqlib "dist_sqlite3_column_int64";
sqlite3_column_text :: (stmt: usize, icol: i32) -> usize #foreign sqlib "dist_sqlite3_column_text";
sqlite3_column_bytes :: (stmt: usize, icol: i32) -> i32 #foreign sqlib "dist_sqlite3_column_bytes";
sqlite3_column_type :: (stmt: usize, icol: i32) -> i32 #foreign sqlib "dist_sqlite3_column_type";
sqlite3_column_count :: (stmt: usize) -> i32 #foreign sqlib "dist_sqlite3_column_count";
sqlite3_errmsg :: (db: usize) -> usize #foreign sqlib "dist_sqlite3_errmsg";
sqlite3_libversion :: () -> usize #foreign sqlib "dist_sqlite3_libversion";
sqlite3_last_insert_rowid :: (db: usize) -> i64 #foreign sqlib "dist_sqlite3_last_insert_rowid";
sqlite3_changes :: (db: usize) -> i32 #foreign sqlib "dist_sqlite3_changes";
// Result codes (the subset the wrappers interpret).
SQLITE_OK :: 0;
SQLITE_ROW :: 100;
SQLITE_DONE :: 101;
// sqlite3_bind_text destructor sentinel: copy the bytes NOW — sx-side
// buffers (arena strings, stack temporaries) don't outlive the call.
SQLITE_TRANSIENT : isize : -1;
// Column types (sqlite3_column_type).
SQLITE_INTEGER :: 1;
SQLITE_TEXT :: 3;
SQLITE_NULL :: 5;
SqliteErr :: error {
Open,
Exec,
Prepare,
Bind,
Step,
}
// `s` as a null-terminated heap copy (C string), from context.allocator.
sq_cstr :: (s: string) -> [*]u8 {
raw : [*]u8 = xx context.allocator.alloc_bytes(s.len + 1);
if s.len > 0 { memcpy(raw, s.ptr, s.len); }
raw[s.len] = 0;
return raw;
}
// Copy the C string at `p` (0 = empty) into context.allocator.
sq_from_cstr :: (p: usize) -> string {
if p == 0 { return ""; }
cp : [*]u8 = xx p;
n := 0;
while cp[n] != 0 { n += 1; }
raw : [*]u8 = xx context.allocator.alloc_bytes(n + 1);
if n > 0 { memcpy(raw, cp, xx n); }
raw[n] = 0;
return string.{ ptr = raw, len = n };
}
// The linked/loaded SQLite's version string (e.g. "3.53.2").
sqlite_version :: () -> string {
return sq_from_cstr(sqlite3_libversion());
}
// One prepared statement. `finalize` releases it; bind indexes are
// 1-based and column indexes 0-based, as in the C API.
SqliteStmt :: struct {
handle: usize;
db: usize;
bind_text :: (self: *SqliteStmt, idx: i64, s: string) -> !SqliteErr {
rc := sqlite3_bind_text(self.handle, xx idx, s.ptr, xx s.len, SQLITE_TRANSIENT);
if rc != SQLITE_OK { raise error.Bind; }
return;
}
bind_int64 :: (self: *SqliteStmt, idx: i64, v: i64) -> !SqliteErr {
rc := sqlite3_bind_int64(self.handle, xx idx, v);
if rc != SQLITE_OK { raise error.Bind; }
return;
}
bind_null :: (self: *SqliteStmt, idx: i64) -> !SqliteErr {
rc := sqlite3_bind_null(self.handle, xx idx);
if rc != SQLITE_OK { raise error.Bind; }
return;
}
// SQLITE_ROW / SQLITE_DONE on success; anything else raises with the
// detail left in the connection's errmsg.
step :: (self: *SqliteStmt) -> (i64, !SqliteErr) {
rc := sqlite3_step(self.handle);
if rc != SQLITE_ROW and rc != SQLITE_DONE { raise error.Step; }
return xx rc;
}
column_int64 :: (self: *SqliteStmt, icol: i64) -> i64 {
return sqlite3_column_int64(self.handle, xx icol);
}
// Copied into context.allocator; a NULL column reads as "".
column_text :: (self: *SqliteStmt, icol: i64) -> string {
p := sqlite3_column_text(self.handle, xx icol);
if p == 0 { return ""; }
n := sqlite3_column_bytes(self.handle, xx icol);
cp : [*]u8 = xx p;
raw : [*]u8 = xx context.allocator.alloc_bytes(xx (n + 1));
if n > 0 { memcpy(raw, cp, xx n); }
raw[n] = 0;
return string.{ ptr = raw, len = xx n };
}
column_type :: (self: *SqliteStmt, icol: i64) -> i64 {
return xx sqlite3_column_type(self.handle, xx icol);
}
column_count :: (self: *SqliteStmt) -> i64 {
return xx sqlite3_column_count(self.handle);
}
reset :: (self: *SqliteStmt) {
sqlite3_reset(self.handle);
}
finalize :: (self: *SqliteStmt) {
sqlite3_finalize(self.handle);
self.handle = 0;
}
}
// One connection. `open` creates the file (and parent-less path) per
// SQLite's defaults; `close` is safe on an already-closed handle.
Sqlite :: struct {
handle: usize;
open :: (path: string) -> (Sqlite, !SqliteErr) {
h : usize = 0;
rc := sqlite3_open(sq_cstr(path), @h);
if rc != SQLITE_OK {
if h != 0 { sqlite3_close(h); }
raise error.Open;
}
return Sqlite.{ handle = h };
}
close :: (self: *Sqlite) {
if self.handle != 0 { sqlite3_close(self.handle); }
self.handle = 0;
}
// Run one or more ;-separated statements with no result rows
// (DDL, pragmas, BEGIN/COMMIT). Detail via errmsg on failure.
exec :: (self: *Sqlite, sql: string) -> !SqliteErr {
rc := sqlite3_exec(self.handle, sq_cstr(sql), 0, 0, 0);
if rc != SQLITE_OK { raise error.Exec; }
return;
}
prepare :: (self: *Sqlite, sql: string) -> (SqliteStmt, !SqliteErr) {
sh : usize = 0;
rc := sqlite3_prepare_v2(self.handle, sql.ptr, xx sql.len, @sh, 0);
if rc != SQLITE_OK { raise error.Prepare; }
return SqliteStmt.{ handle = sh, db = self.handle };
}
errmsg :: (self: *Sqlite) -> string {
return sq_from_cstr(sqlite3_errmsg(self.handle));
}
last_insert_rowid :: (self: *Sqlite) -> i64 {
return sqlite3_last_insert_rowid(self.handle);
}
changes :: (self: *Sqlite) -> i64 {
return xx sqlite3_changes(self.handle);
}
}

View File

@@ -7,10 +7,16 @@
# process.assert — and exits non-zero on failure.
#
# Locate the compiler via SX (overridable); defaults to the sibling sx repo.
#
# `-L build/vendor/jit` lets the JIT dlopen the VENDORED libsqlite3.dylib
# (built by `make build`) instead of falling back to the OS copy — the
# version assert in tests/sqlite_smoke.sx depends on it.
set -u
SX="${SX:-/Users/agra/projects/sx/zig-out/bin/sx}"
TESTS_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_DIR=$(CDPATH= cd -- "$TESTS_DIR/.." && pwd)
SX_RUN_FLAGS="-L $REPO_DIR/build/vendor/jit"
pass=0
fail=0
@@ -20,7 +26,7 @@ fail=0
# pass/fail counters survive).
for t in $(find "$TESTS_DIR" -name '*.sx' -type f | sort); do
name=${t#"$TESTS_DIR"/}
if "$SX" run "$t" >/dev/null 2>&1; then
if "$SX" run "$t" $SX_RUN_FLAGS >/dev/null 2>&1; then
printf ' %-44s ok\n' "$name"
pass=$((pass + 1))
else

173
tests/sqlite_smoke.sx Normal file
View File

@@ -0,0 +1,173 @@
// Pinned acceptance for P5.1 — the VENDORED SQLite is linked and usable
// through src/db/sqlite.sx.
//
// * version: sqlite3_libversion() equals the vendored amalgamation's
// version (vendor/sqlite/README.md) — a silent fallback to the OS
// libsqlite3 fails here, in both `sx run` (dlopen via -L
// build/vendor/jit) and `sx build` (static link via -L build/vendor).
// * round trip: create table, INSERT via bound parameters (text +
// int64 + null), SELECT back with WHERE binding, typed columns.
// * persistence: rows survive close + reopen of the same file.
// * transactions: BEGIN/ROLLBACK leaves no row behind.
// * errors: bad SQL raises, and errmsg names the problem.
#import "modules/std.sx";
#import "modules/std/fs.sx";
process :: #import "modules/std/process.sx";
sq :: #import "../src/db/sqlite.sx";
VENDORED_VERSION :: "3.53.2";
DBDIR :: ".sx-tmp/sqlite_smoke";
DBPATH :: ".sx-tmp/sqlite_smoke/t.db";
run_case :: (label: string, ok: bool) -> i32 {
if ok { print(" PASS {}\n", label); return 0; }
print(" FAIL {}\n", label);
return 1;
}
check_version :: () -> bool {
v := sq.sqlite_version();
if v != VENDORED_VERSION {
print(" loaded sqlite {} but vendor/sqlite is {}\n", v, VENDORED_VERSION);
return false;
}
return true;
}
check_roundtrip :: () -> bool {
db, oe := sq.Sqlite.open(DBPATH);
if oe { return false; }
ee := false;
db.exec("CREATE TABLE apps (id INTEGER PRIMARY KEY, slug TEXT NOT NULL, owner TEXT, created_at INTEGER NOT NULL)") catch { ee = true; };
if ee { db.close(); return false; }
ins, pe := db.prepare("INSERT INTO apps (slug, owner, created_at) VALUES (?1, ?2, ?3)");
if pe { db.close(); return false; }
berr := false;
ins.bind_text(1, "acme-app") catch { berr = true; };
ins.bind_text(2, "ci") catch { berr = true; };
ins.bind_int64(3, 1781250000) catch { berr = true; };
se := false;
rc := ins.step() catch { se = true; 0 };
ins.finalize();
if berr or se or rc != sq.SQLITE_DONE { db.close(); return false; }
rowid := db.last_insert_rowid();
if rowid != 1 { db.close(); return false; }
// second row with a NULL owner
ins2, pe2 := db.prepare("INSERT INTO apps (slug, owner, created_at) VALUES (?1, ?2, ?3)");
if pe2 { db.close(); return false; }
ins2.bind_text(1, "other-app") catch { berr = true; };
ins2.bind_null(2) catch { berr = true; };
ins2.bind_int64(3, 1781250001) catch { berr = true; };
ins2.step() catch { se = true; 0 };
ins2.finalize();
if berr or se { db.close(); return false; }
// select the first row back through a WHERE binding
sel, pe3 := db.prepare("SELECT id, slug, owner, created_at FROM apps WHERE slug = ?1");
if pe3 { db.close(); return false; }
sel.bind_text(1, "acme-app") catch { berr = true; };
src := sel.step() catch { se = true; 0 };
ok := !berr and !se and src == sq.SQLITE_ROW;
if ok {
ok = sel.column_count() == 4
and sel.column_int64(0) == 1
and sel.column_text(1) == "acme-app"
and sel.column_text(2) == "ci"
and sel.column_int64(3) == 1781250000
and sel.column_type(1) == sq.SQLITE_TEXT;
}
if ok {
drc := sel.step() catch { se = true; 0 };
ok = !se and drc == sq.SQLITE_DONE; // exactly one matching row
}
sel.finalize();
// NULL column reads as "" with column_type NULL
if ok {
sel2, pe4 := db.prepare("SELECT owner FROM apps WHERE slug = 'other-app'");
if pe4 { db.close(); return false; }
nrc := sel2.step() catch { se = true; 0 };
ok = !se and nrc == sq.SQLITE_ROW
and sel2.column_type(0) == sq.SQLITE_NULL
and sel2.column_text(0) == "";
sel2.finalize();
}
db.close();
return ok;
}
check_persistence :: () -> bool {
db, oe := sq.Sqlite.open(DBPATH);
if oe { return false; }
sel, pe := db.prepare("SELECT COUNT(*) FROM apps");
if pe { db.close(); return false; }
se := false;
rc := sel.step() catch { se = true; 0 };
ok := !se and rc == sq.SQLITE_ROW and sel.column_int64(0) == 2;
sel.finalize();
db.close();
return ok;
}
check_transaction_rollback :: () -> bool {
db, oe := sq.Sqlite.open(DBPATH);
if oe { return false; }
ee := false;
db.exec("BEGIN") catch { ee = true; };
db.exec("INSERT INTO apps (slug, owner, created_at) VALUES ('doomed', 'x', 0)") catch { ee = true; };
db.exec("ROLLBACK") catch { ee = true; };
if ee { db.close(); return false; }
sel, pe := db.prepare("SELECT COUNT(*) FROM apps WHERE slug = 'doomed'");
if pe { db.close(); return false; }
se := false;
rc := sel.step() catch { se = true; 0 };
ok := !se and rc == sq.SQLITE_ROW and sel.column_int64(0) == 0;
sel.finalize();
db.close();
return ok;
}
check_error_path :: () -> bool {
db, oe := sq.Sqlite.open(DBPATH);
if oe { return false; }
failed := false;
db.exec("FROBNICATE THE DATABASE") catch { failed = true; };
msg := db.errmsg();
db.close();
if !failed { return false; }
// errmsg names the offending token
i := 0;
found := false;
while i + 10 <= msg.len {
v := string.{ ptr = @msg[i], len = 10 };
if v == "FROBNICATE" { found = true; break; }
i += 1;
}
return found;
}
main :: () -> i32 {
process.run(concat("rm -rf ", DBDIR));
process.run(concat("mkdir -p ", DBDIR));
failures : i32 = 0;
failures += run_case("vendored version is loaded", check_version());
failures += run_case("create/insert/select round trip", check_roundtrip());
failures += run_case("rows persist across reopen", check_persistence());
failures += run_case("BEGIN/ROLLBACK leaves no row", check_transaction_rollback());
failures += run_case("bad SQL raises with errmsg detail", check_error_path());
process.run(concat("rm -rf ", DBDIR));
print("------------------------------------------------\n");
if failures == 0 {
print("sqlite_smoke: ALL CASES PASS\n");
return 0;
}
print("sqlite_smoke: {} CASE(S) FAILED\n", failures);
return 1;
}

21
vendor/sqlite/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# Vendored SQLite
- Version: **3.53.2** (`SQLITE_VERSION` in `sqlite3.h`)
- Source: <https://sqlite.org/2026/sqlite-amalgamation-3530200.zip>
- Zip sha256: `8a310d0a16c7a90cacd4c884e70faa51c902afed2a89f63aaa0126ab83558a32`
- Files kept: `sqlite3.c`, `sqlite3.h` (the amalgamation; `shell.c` and
`sqlite3ext.h` dropped — no shell, no loadable extensions)
- License: public domain (<https://sqlite.org/copyright.html>)
`make build` compiles this into `build/vendor/libsqlite3.a` (statically
linked into the `dist` binary via `-L build/vendor`) and
`build/vendor/jit/libsqlite3.dylib` (dlopen'd by `sx run`, which is how
`make test` executes the test programs). The two locations are separate
on purpose: the macOS linker prefers a dylib over an archive in the same
search directory, and the AOT binary must link the static copy.
`tests/sqlite_smoke.sx` asserts `sqlite3_libversion()` equals the version
above, so a fallback to the OS libsqlite3 fails loudly in both modes.
To upgrade: replace `sqlite3.c`/`sqlite3.h` with a newer amalgamation,
update this file and the version constant in `tests/sqlite_smoke.sx`, and
run `make clean test`.

34
vendor/sqlite/rename.h vendored Normal file
View File

@@ -0,0 +1,34 @@
/*
* Symbol prefix for the vendored SQLite build.
*
* The sx JIT resolves #foreign symbols via dlsym(RTLD_DEFAULT), which
* searches every image already loaded into the process — and the OS
* libsqlite3 is usually among them, so the standard names would silently
* bind to the system copy instead of this vendored one. Prefixing the
* API surface makes resolution unambiguous in both JIT (dlopen) and AOT
* (static link) modes: dist_sqlite3_* exists ONLY in the vendored build.
*
* Only the functions bound in src/db/sqlite.sx are renamed; extend BOTH
* files together when new API is needed. Injected via `-include` in the
* Makefile's SQLITE_DEFS, so sqlite3.c and its embedded sqlite3.h see
* the renames consistently.
*/
#define sqlite3_open dist_sqlite3_open
#define sqlite3_close dist_sqlite3_close
#define sqlite3_exec dist_sqlite3_exec
#define sqlite3_prepare_v2 dist_sqlite3_prepare_v2
#define sqlite3_step dist_sqlite3_step
#define sqlite3_finalize dist_sqlite3_finalize
#define sqlite3_reset dist_sqlite3_reset
#define sqlite3_bind_text dist_sqlite3_bind_text
#define sqlite3_bind_int64 dist_sqlite3_bind_int64
#define sqlite3_bind_null dist_sqlite3_bind_null
#define sqlite3_column_int64 dist_sqlite3_column_int64
#define sqlite3_column_text dist_sqlite3_column_text
#define sqlite3_column_bytes dist_sqlite3_column_bytes
#define sqlite3_column_type dist_sqlite3_column_type
#define sqlite3_column_count dist_sqlite3_column_count
#define sqlite3_errmsg dist_sqlite3_errmsg
#define sqlite3_libversion dist_sqlite3_libversion
#define sqlite3_last_insert_rowid dist_sqlite3_last_insert_rowid
#define sqlite3_changes dist_sqlite3_changes

269376
vendor/sqlite/sqlite3.c vendored Normal file

File diff suppressed because it is too large Load Diff

14347
vendor/sqlite/sqlite3.h vendored Normal file

File diff suppressed because it is too large Load Diff