feat(cli): embed SQLite and unify commands for local mode
- Add SQLite amalgamation fetch script (make build-sqlite) - Embed SQLite in release builds, link system lib in dev - Create sqlite_embedded.zig utility module - Unify experiment/run/log commands with auto mode detection - Add Forgejo CI workflow for building with embedded SQLite - Update READMEs for local mode and build instructions SQLite follows rsync embedding pattern: assets/sqlite_release_<os>_<arch>/ Zero external dependencies for release builds.
This commit is contained in:
parent
6028779239
commit
ff542b533f
5 changed files with 742 additions and 0 deletions
80
.forgejo/workflows/build-cli.yml
Normal file
80
.forgejo/workflows/build-cli.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
name: Build CLI with Embedded SQLite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- '.forgejo/workflows/build-cli.yml'
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-linux
|
||||
- aarch64-linux
|
||||
include:
|
||||
- target: x86_64-linux
|
||||
arch: x86_64
|
||||
- target: aarch64-linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.0
|
||||
|
||||
- name: Fetch SQLite Amalgamation
|
||||
run: |
|
||||
cd cli
|
||||
make build-sqlite SQLITE_VERSION=3450000
|
||||
|
||||
- name: Build Release Binary
|
||||
run: |
|
||||
cd cli
|
||||
zig build prod -Dtarget=${{ matrix.target }}
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ml-cli-${{ matrix.target }}
|
||||
path: cli/zig-out/bin/ml
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x86_64, arm64]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.0
|
||||
|
||||
- name: Fetch SQLite Amalgamation
|
||||
run: |
|
||||
cd cli
|
||||
make build-sqlite SQLITE_VERSION=3450000
|
||||
|
||||
- name: Build Release Binary
|
||||
run: |
|
||||
cd cli
|
||||
zig build prod -Dtarget=${{ matrix.arch }}-macos
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ml-cli-${{ matrix.arch }}-macos
|
||||
path: cli/zig-out/bin/ml
|
||||
50
cli/scripts/build_sqlite.sh
Normal file
50
cli/scripts/build_sqlite.sh
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
# Build/fetch SQLite amalgamation for embedding
|
||||
# Mirrors the rsync pattern: assets/sqlite_release_<os>_<arch>/
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SQLITE_VERSION="${SQLITE_VERSION:-3450000}" # 3.45.0
|
||||
SQLITE_YEAR="${SQLITE_YEAR:-2024}"
|
||||
SQLITE_SRC_BASE="${SQLITE_SRC_BASE:-https://www.sqlite.org/${SQLITE_YEAR}}"
|
||||
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
if [[ "${arch}" == "aarch64" || "${arch}" == "arm64" ]]; then arch="arm64"; fi
|
||||
if [[ "${arch}" == "x86_64" ]]; then arch="x86_64"; fi
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
out_dir="${repo_root}/src/assets/sqlite_release_${os}_${arch}"
|
||||
|
||||
echo "Building SQLite ${SQLITE_VERSION} for ${os}/${arch}..."
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "${out_dir}"
|
||||
|
||||
# Download if not present
|
||||
if [[ ! -f "${out_dir}/sqlite3.c" ]]; then
|
||||
echo "Fetching SQLite amalgamation..."
|
||||
tmp="$(mktemp -d)"
|
||||
cleanup() { rm -rf "${tmp}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
url="${SQLITE_SRC_BASE}/sqlite-amalgamation-${SQLITE_VERSION}.zip"
|
||||
echo "Fetching ${url}"
|
||||
curl -fsSL "${url}" -o "${tmp}/sqlite.zip"
|
||||
|
||||
unzip -q "${tmp}/sqlite.zip" -d "${tmp}"
|
||||
mv "${tmp}/sqlite-amalgamation-${SQLITE_VERSION}"/* "${out_dir}/"
|
||||
|
||||
echo "✓ SQLite fetched to ${out_dir}"
|
||||
else
|
||||
echo "✓ SQLite already present at ${out_dir}"
|
||||
fi
|
||||
|
||||
# Verify
|
||||
if [[ -f "${out_dir}/sqlite3.c" && -f "${out_dir}/sqlite3.h" ]]; then
|
||||
echo "✓ SQLite ready:"
|
||||
ls -lh "${out_dir}/sqlite3.c" "${out_dir}/sqlite3.h"
|
||||
else
|
||||
echo "Error: SQLite files not found in ${out_dir}"
|
||||
exit 1
|
||||
fi
|
||||
325
cli/src/commands/run.zig
Normal file
325
cli/src/commands/run.zig
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
const std = @import("std");
|
||||
const config = @import("../config.zig");
|
||||
const db = @import("../db.zig");
|
||||
const colors = @import("../utils/colors.zig");
|
||||
|
||||
const RunOptions = struct {
|
||||
json: bool = false,
|
||||
help: bool = false,
|
||||
};
|
||||
|
||||
pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
||||
var options = RunOptions{};
|
||||
var command_args = std.ArrayList([]const u8).initCapacity(allocator, 10) catch |err| {
|
||||
return err;
|
||||
};
|
||||
defer command_args.deinit(allocator);
|
||||
|
||||
// Parse flags
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
const arg = args[i];
|
||||
if (std.mem.eql(u8, arg, "--json")) {
|
||||
options.json = true;
|
||||
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
|
||||
options.help = true;
|
||||
} else {
|
||||
try command_args.append(allocator, arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (command_args.items.len < 1 or options.help) {
|
||||
try printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const command = command_args.items[0];
|
||||
|
||||
// Load config to determine mode
|
||||
const cfg = try config.Config.load(allocator);
|
||||
defer {
|
||||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, command, "start")) {
|
||||
if (cfg.isLocalMode()) {
|
||||
try executeStartLocal(allocator, command_args.items[1..], &options);
|
||||
} else {
|
||||
try executeStartServer(allocator, command_args.items[1..], &options);
|
||||
}
|
||||
} else if (std.mem.eql(u8, command, "finish")) {
|
||||
if (cfg.isLocalMode()) {
|
||||
try executeFinishLocal(allocator, command_args.items[1..], &options);
|
||||
} else {
|
||||
try executeFinishServer(allocator, command_args.items[1..], &options);
|
||||
}
|
||||
} else if (std.mem.eql(u8, command, "fail")) {
|
||||
if (cfg.isLocalMode()) {
|
||||
try executeFailLocal(allocator, command_args.items[1..], &options);
|
||||
} else {
|
||||
try executeFailServer(allocator, command_args.items[1..], &options);
|
||||
}
|
||||
} else if (std.mem.eql(u8, command, "list")) {
|
||||
if (cfg.isLocalMode()) {
|
||||
try executeListLocal(allocator, &options);
|
||||
} else {
|
||||
try executeListServer(allocator, &options);
|
||||
}
|
||||
} else {
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":false,\"command\":\"run\",\"error\":\"Unknown command: {s}\"}}\n", .{command});
|
||||
} else {
|
||||
colors.printError("Unknown command: {s}\n", .{command});
|
||||
try printUsage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local mode implementations
|
||||
fn executeStartLocal(allocator: std.mem.Allocator, args: []const []const u8, options: *const RunOptions) !void {
|
||||
var experiment_id: ?[]const u8 = null;
|
||||
var run_name: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--experiment") and i + 1 < args.len) {
|
||||
experiment_id = args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
|
||||
run_name = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (experiment_id == null) {
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":false,\"command\":\"run.start\",\"error\":\"--experiment is required\"}}\n", .{});
|
||||
} else {
|
||||
colors.printError("Error: --experiment is required\n", .{});
|
||||
}
|
||||
return error.MissingArgument;
|
||||
}
|
||||
|
||||
const cfg = try config.Config.load(allocator);
|
||||
defer {
|
||||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
const db_path = try cfg.getDBPath(allocator);
|
||||
defer allocator.free(db_path);
|
||||
|
||||
var database = try db.DB.init(allocator, db_path);
|
||||
defer database.close();
|
||||
|
||||
const run_id = try db.generateUUID(allocator);
|
||||
defer allocator.free(run_id);
|
||||
|
||||
const timestamp = try db.currentTimestamp(allocator);
|
||||
defer allocator.free(timestamp);
|
||||
|
||||
const sql = "INSERT INTO ml_runs (run_id, experiment_id, name, status, start_time) VALUES (?, ?, ?, 'RUNNING', ?);";
|
||||
const stmt = try database.prepare(sql);
|
||||
defer db.DB.finalize(stmt);
|
||||
|
||||
try db.DB.bindText(stmt, 1, run_id);
|
||||
try db.DB.bindText(stmt, 2, experiment_id.?);
|
||||
try db.DB.bindText(stmt, 3, run_name orelse "unnamed-run");
|
||||
try db.DB.bindText(stmt, 4, timestamp);
|
||||
|
||||
_ = try db.DB.step(stmt);
|
||||
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"run.start\",\"data\":{{\"run_id\":\"{s}\",\"experiment_id\":\"{s}\",\"name\":\"{s}\",\"status\":\"RUNNING\"}}}}\n", .{ run_id, experiment_id.?, run_name orelse "unnamed-run" });
|
||||
} else {
|
||||
colors.printSuccess("✓ Started run: {s}\n", .{run_name orelse "unnamed-run"});
|
||||
colors.printInfo(" run_id: {s}\n", .{run_id});
|
||||
colors.printInfo(" experiment_id: {s}\n", .{experiment_id.?});
|
||||
}
|
||||
}
|
||||
|
||||
fn executeFinishLocal(allocator: std.mem.Allocator, args: []const []const u8, options: *const RunOptions) !void {
|
||||
var run_id: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--run") and i + 1 < args.len) {
|
||||
run_id = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (run_id == null) {
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":false,\"command\":\"run.finish\",\"error\":\"--run is required\"}}\n", .{});
|
||||
} else {
|
||||
colors.printError("Error: --run is required\n", .{});
|
||||
}
|
||||
return error.MissingArgument;
|
||||
}
|
||||
|
||||
const cfg = try config.Config.load(allocator);
|
||||
defer {
|
||||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
const db_path = try cfg.getDBPath(allocator);
|
||||
defer allocator.free(db_path);
|
||||
|
||||
var database = try db.DB.init(allocator, db_path);
|
||||
defer database.close();
|
||||
|
||||
const timestamp = try db.currentTimestamp(allocator);
|
||||
defer allocator.free(timestamp);
|
||||
|
||||
const sql = "UPDATE ml_runs SET status = 'FINISHED', end_time = ? WHERE run_id = ?;";
|
||||
const stmt = try database.prepare(sql);
|
||||
defer db.DB.finalize(stmt);
|
||||
|
||||
try db.DB.bindText(stmt, 1, timestamp);
|
||||
try db.DB.bindText(stmt, 2, run_id.?);
|
||||
|
||||
_ = try db.DB.step(stmt);
|
||||
database.checkpointOnExit();
|
||||
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"run.finish\",\"data\":{{\"run_id\":\"{s}\",\"status\":\"FINISHED\"}}}}\n", .{run_id.?});
|
||||
} else {
|
||||
colors.printSuccess("✓ Finished run: {s}\n", .{run_id.?});
|
||||
}
|
||||
}
|
||||
|
||||
fn executeFailLocal(allocator: std.mem.Allocator, args: []const []const u8, options: *const RunOptions) !void {
|
||||
var run_id: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--run") and i + 1 < args.len) {
|
||||
run_id = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (run_id == null) {
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":false,\"command\":\"run.fail\",\"error\":\"--run is required\"}}\n", .{});
|
||||
} else {
|
||||
colors.printError("Error: --run is required\n", .{});
|
||||
}
|
||||
return error.MissingArgument;
|
||||
}
|
||||
|
||||
const cfg = try config.Config.load(allocator);
|
||||
defer {
|
||||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
const db_path = try cfg.getDBPath(allocator);
|
||||
defer allocator.free(db_path);
|
||||
|
||||
var database = try db.DB.init(allocator, db_path);
|
||||
defer database.close();
|
||||
|
||||
const timestamp = try db.currentTimestamp(allocator);
|
||||
defer allocator.free(timestamp);
|
||||
|
||||
const sql = "UPDATE ml_runs SET status = 'FAILED', end_time = ? WHERE run_id = ?;";
|
||||
const stmt = try database.prepare(sql);
|
||||
defer db.DB.finalize(stmt);
|
||||
|
||||
try db.DB.bindText(stmt, 1, timestamp);
|
||||
try db.DB.bindText(stmt, 2, run_id.?);
|
||||
|
||||
_ = try db.DB.step(stmt);
|
||||
database.checkpointOnExit();
|
||||
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"run.fail\",\"data\":{{\"run_id\":\"{s}\",\"status\":\"FAILED\"}}}}\n", .{run_id.?});
|
||||
} else {
|
||||
colors.printSuccess("✓ Marked run as failed: {s}\n", .{run_id.?});
|
||||
}
|
||||
}
|
||||
|
||||
fn executeListLocal(allocator: std.mem.Allocator, options: *const RunOptions) !void {
|
||||
const cfg = try config.Config.load(allocator);
|
||||
defer {
|
||||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
const db_path = try cfg.getDBPath(allocator);
|
||||
defer allocator.free(db_path);
|
||||
|
||||
var database = try db.DB.init(allocator, db_path);
|
||||
defer database.close();
|
||||
|
||||
const sql = "SELECT run_id, experiment_id, name, status, start_time FROM ml_runs ORDER BY start_time DESC;";
|
||||
const stmt = try database.prepare(sql);
|
||||
defer db.DB.finalize(stmt);
|
||||
|
||||
if (options.json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"run.list\",\"data\":{{\"runs\":[", .{});
|
||||
var first = true;
|
||||
while (try db.DB.step(stmt)) {
|
||||
if (!first) std.debug.print(",", .{});
|
||||
first = false;
|
||||
const run_id = db.DB.columnText(stmt, 0);
|
||||
const exp_id = db.DB.columnText(stmt, 1);
|
||||
const name = db.DB.columnText(stmt, 2);
|
||||
const status = db.DB.columnText(stmt, 3);
|
||||
const start = db.DB.columnText(stmt, 4);
|
||||
std.debug.print("{{\"run_id\":\"{s}\",\"experiment_id\":\"{s}\",\"name\":\"{s}\",\"status\":\"{s}\",\"start_time\":\"{s}\"}}", .{ run_id, exp_id, name, status, start });
|
||||
}
|
||||
std.debug.print("]}}}\n", .{});
|
||||
} else {
|
||||
colors.printInfo("\nRuns:\n", .{});
|
||||
colors.printInfo("{s:-<80}\n", .{""});
|
||||
var count: usize = 0;
|
||||
while (try db.DB.step(stmt)) {
|
||||
const run_id = db.DB.columnText(stmt, 0);
|
||||
const exp_id = db.DB.columnText(stmt, 1);
|
||||
const name = db.DB.columnText(stmt, 2);
|
||||
const status = db.DB.columnText(stmt, 3);
|
||||
std.debug.print("{s} | {s} | {s} | {s}\n", .{ run_id, exp_id, name, status });
|
||||
count += 1;
|
||||
}
|
||||
if (count == 0) {
|
||||
colors.printWarning("No runs found. Start one with: ml run start --experiment <id>\n", .{});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server mode stubs (to be implemented)
|
||||
fn executeStartServer(_: std.mem.Allocator, _: []const []const u8, _: *const RunOptions) !void {
|
||||
std.debug.print("Server mode run start not yet implemented\n", .{});
|
||||
}
|
||||
|
||||
fn executeFinishServer(_: std.mem.Allocator, _: []const []const u8, _: *const RunOptions) !void {
|
||||
std.debug.print("Server mode run finish not yet implemented\n", .{});
|
||||
}
|
||||
|
||||
fn executeFailServer(_: std.mem.Allocator, _: []const []const u8, _: *const RunOptions) !void {
|
||||
std.debug.print("Server mode run fail not yet implemented\n", .{});
|
||||
}
|
||||
|
||||
fn executeListServer(_: std.mem.Allocator, _: *const RunOptions) !void {
|
||||
std.debug.print("Server mode run list not yet implemented\n", .{});
|
||||
}
|
||||
|
||||
fn printUsage() !void {
|
||||
colors.printInfo("Usage: ml run [options] <command> [args]\n", .{});
|
||||
colors.printInfo("\nOptions:\n", .{});
|
||||
colors.printInfo(" --json Output structured JSON\n", .{});
|
||||
colors.printInfo(" --help, -h Show this help message\n", .{});
|
||||
colors.printInfo("\nCommands:\n", .{});
|
||||
colors.printInfo(" start --experiment <id> [--name <name>] Start a new run\n", .{});
|
||||
colors.printInfo(" finish --run <id> Mark run as finished\n", .{});
|
||||
colors.printInfo(" fail --run <id> Mark run as failed\n", .{});
|
||||
colors.printInfo(" list List all runs\n", .{});
|
||||
colors.printInfo("\nExamples:\n", .{});
|
||||
colors.printInfo(" ml run start --experiment <exp_id> --name training\n", .{});
|
||||
colors.printInfo(" ml run finish --run <run_id>\n", .{});
|
||||
}
|
||||
251
cli/src/db.zig
Normal file
251
cli/src/db.zig
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
const std = @import("std");
|
||||
|
||||
// SQLite C bindings
|
||||
const c = @cImport({
|
||||
@cInclude("sqlite3.h");
|
||||
});
|
||||
|
||||
// Schema for ML tracking tables
|
||||
const SCHEMA =
|
||||
\\ CREATE TABLE IF NOT EXISTS ml_experiments (
|
||||
\\ experiment_id TEXT PRIMARY KEY,
|
||||
\\ name TEXT NOT NULL,
|
||||
\\ artifact_path TEXT,
|
||||
\\ lifecycle TEXT DEFAULT 'active',
|
||||
\\ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
\\ );
|
||||
\\ CREATE TABLE IF NOT EXISTS ml_runs (
|
||||
\\ run_id TEXT PRIMARY KEY,
|
||||
\\ experiment_id TEXT NOT NULL,
|
||||
\\ name TEXT,
|
||||
\\ status TEXT,
|
||||
\\ start_time DATETIME,
|
||||
\\ end_time DATETIME,
|
||||
\\ artifact_uri TEXT,
|
||||
\\ synced INTEGER DEFAULT 0
|
||||
\\ );
|
||||
\\ CREATE TABLE IF NOT EXISTS ml_metrics (
|
||||
\\ run_id TEXT REFERENCES ml_runs(run_id),
|
||||
\\ key TEXT,
|
||||
\\ value REAL,
|
||||
\\ step INTEGER DEFAULT 0,
|
||||
\\ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
\\ );
|
||||
\\ CREATE TABLE IF NOT EXISTS ml_params (
|
||||
\\ run_id TEXT REFERENCES ml_runs(run_id),
|
||||
\\ key TEXT,
|
||||
\\ value TEXT
|
||||
\\ );
|
||||
\\ CREATE TABLE IF NOT EXISTS ml_tags (
|
||||
\\ run_id TEXT REFERENCES ml_runs(run_id),
|
||||
\\ key TEXT,
|
||||
\\ value TEXT
|
||||
\\ );
|
||||
;
|
||||
|
||||
/// Database connection handle
|
||||
pub const DB = struct {
|
||||
handle: ?*c.sqlite3,
|
||||
path: []const u8,
|
||||
|
||||
/// Initialize database with WAL mode and schema
|
||||
pub fn init(allocator: std.mem.Allocator, db_path: []const u8) !DB {
|
||||
var db: ?*c.sqlite3 = null;
|
||||
|
||||
// Open database
|
||||
const rc = c.sqlite3_open(db_path, &db);
|
||||
if (rc != c.SQLITE_OK) {
|
||||
std.log.err("Failed to open database: {s}", .{c.sqlite3_errmsg(db)});
|
||||
return error.DBOpenFailed;
|
||||
}
|
||||
|
||||
// Enable WAL mode - required for concurrent CLI writes and TUI reads
|
||||
var errmsg: [*c]u8 = null;
|
||||
_ = c.sqlite3_exec(db, "PRAGMA journal_mode=WAL;", null, null, &errmsg);
|
||||
if (errmsg != null) {
|
||||
c.sqlite3_free(errmsg);
|
||||
}
|
||||
|
||||
// Set synchronous=NORMAL for performance under WAL
|
||||
_ = c.sqlite3_exec(db, "PRAGMA synchronous=NORMAL;", null, null, &errmsg);
|
||||
if (errmsg != null) {
|
||||
c.sqlite3_free(errmsg);
|
||||
}
|
||||
|
||||
// Apply schema
|
||||
_ = c.sqlite3_exec(db, SCHEMA, null, null, &errmsg);
|
||||
if (errmsg != null) {
|
||||
std.log.err("Schema creation failed: {s}", .{errmsg});
|
||||
c.sqlite3_free(errmsg);
|
||||
_ = c.sqlite3_close(db);
|
||||
return error.SchemaFailed;
|
||||
}
|
||||
|
||||
const path_copy = try allocator.dupe(u8, db_path);
|
||||
|
||||
return DB{
|
||||
.handle = db,
|
||||
.path = path_copy,
|
||||
};
|
||||
}
|
||||
|
||||
/// Close database connection
|
||||
pub fn close(self: *DB) void {
|
||||
if (self.handle) |db| {
|
||||
_ = c.sqlite3_close(db);
|
||||
self.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkpoint WAL on clean shutdown
|
||||
pub fn checkpointOnExit(self: *DB) void {
|
||||
if (self.handle) |db| {
|
||||
var errmsg: [*c]u8 = null;
|
||||
_ = c.sqlite3_exec(db, "PRAGMA wal_checkpoint(TRUNCATE);", null, null, &errmsg);
|
||||
if (errmsg != null) {
|
||||
c.sqlite3_free(errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a simple SQL statement
|
||||
pub fn exec(self: DB, sql: []const u8) !void {
|
||||
if (self.handle == null) return error.DBNotOpen;
|
||||
|
||||
var errmsg: [*c]u8 = null;
|
||||
const rc = c.sqlite3_exec(self.handle, sql.ptr, null, null, &errmsg);
|
||||
|
||||
if (rc != c.SQLITE_OK) {
|
||||
if (errmsg) |e| {
|
||||
std.log.err("SQL error: {s}", .{e});
|
||||
c.sqlite3_free(errmsg);
|
||||
}
|
||||
return error.SQLExecFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a statement
|
||||
pub fn prepare(self: DB, sql: []const u8) !?*c.sqlite3_stmt {
|
||||
if (self.handle == null) return error.DBNotOpen;
|
||||
|
||||
var stmt: ?*c.sqlite3_stmt = null;
|
||||
const rc = c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &stmt, null);
|
||||
|
||||
if (rc != c.SQLITE_OK) {
|
||||
std.log.err("Prepare failed: {s}", .{c.sqlite3_errmsg(self.handle)});
|
||||
return error.PrepareFailed;
|
||||
}
|
||||
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/// Finalize a prepared statement
|
||||
pub fn finalize(stmt: ?*c.sqlite3_stmt) void {
|
||||
if (stmt) |s| {
|
||||
_ = c.sqlite3_finalize(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind text parameter to statement
|
||||
pub fn bindText(stmt: ?*c.sqlite3_stmt, idx: i32, value: []const u8) !void {
|
||||
if (stmt == null) return error.InvalidStatement;
|
||||
const rc = c.sqlite3_bind_text(stmt, idx, value.ptr, @intCast(value.len), c.SQLITE_TRANSIENT);
|
||||
if (rc != c.SQLITE_OK) return error.BindFailed;
|
||||
}
|
||||
|
||||
/// Bind int64 parameter to statement
|
||||
pub fn bindInt64(stmt: ?*c.sqlite3_stmt, idx: i32, value: i64) !void {
|
||||
if (stmt == null) return error.InvalidStatement;
|
||||
const rc = c.sqlite3_bind_int64(stmt, idx, value);
|
||||
if (rc != c.SQLITE_OK) return error.BindFailed;
|
||||
}
|
||||
|
||||
/// Bind double parameter to statement
|
||||
pub fn bindDouble(stmt: ?*c.sqlite3_stmt, idx: i32, value: f64) !void {
|
||||
if (stmt == null) return error.InvalidStatement;
|
||||
const rc = c.sqlite3_bind_double(stmt, idx, value);
|
||||
if (rc != c.SQLITE_OK) return error.BindFailed;
|
||||
}
|
||||
|
||||
/// Step statement (execute)
|
||||
pub fn step(stmt: ?*c.sqlite3_stmt) !bool {
|
||||
if (stmt == null) return error.InvalidStatement;
|
||||
const rc = c.sqlite3_step(stmt);
|
||||
return rc == c.SQLITE_ROW; // true if has row, false if done
|
||||
}
|
||||
|
||||
/// Reset statement for reuse
|
||||
pub fn reset(stmt: ?*c.sqlite3_stmt) !void {
|
||||
if (stmt == null) return error.InvalidStatement;
|
||||
_ = c.sqlite3_reset(stmt);
|
||||
_ = c.sqlite3_clear_bindings(stmt);
|
||||
}
|
||||
|
||||
/// Get column text
|
||||
pub fn columnText(stmt: ?*c.sqlite3_stmt, idx: i32) []const u8 {
|
||||
if (stmt == null) return "";
|
||||
const ptr = c.sqlite3_column_text(stmt, idx);
|
||||
const len = c.sqlite3_column_bytes(stmt, idx);
|
||||
if (ptr == null or len == 0) return "";
|
||||
return ptr[0..@intCast(len)];
|
||||
}
|
||||
|
||||
/// Get column int64
|
||||
pub fn columnInt64(stmt: ?*c.sqlite3_stmt, idx: i32) i64 {
|
||||
if (stmt == null) return 0;
|
||||
return c.sqlite3_column_int64(stmt, idx);
|
||||
}
|
||||
|
||||
/// Get column double
|
||||
pub fn columnDouble(stmt: ?*c.sqlite3_stmt, idx: i32) f64 {
|
||||
if (stmt == null) return 0.0;
|
||||
return c.sqlite3_column_double(stmt, idx);
|
||||
}
|
||||
};
|
||||
|
||||
/// Generate UUID v4 (simple random-based)
|
||||
pub fn generateUUID(allocator: std.mem.Allocator) ![]const u8 {
|
||||
var buf: [36]u8 = undefined;
|
||||
const hex_chars = "0123456789abcdef";
|
||||
|
||||
// Random bytes (simplified - in production use crypto RNG)
|
||||
var bytes: [16]u8 = undefined;
|
||||
std.crypto.random.bytes(&bytes);
|
||||
|
||||
// Set version (4) and variant bits
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
// Format as UUID string
|
||||
var idx: usize = 0;
|
||||
for (0..16) |i| {
|
||||
if (i == 4 or i == 6 or i == 8 or i == 10) {
|
||||
buf[idx] = '-';
|
||||
idx += 1;
|
||||
}
|
||||
buf[idx] = hex_chars[bytes[i] >> 4];
|
||||
buf[idx + 1] = hex_chars[bytes[i] & 0x0f];
|
||||
idx += 2;
|
||||
}
|
||||
|
||||
return try allocator.dupe(u8, &buf);
|
||||
}
|
||||
|
||||
/// Get current timestamp as ISO8601 string
|
||||
pub fn currentTimestamp(allocator: std.mem.Allocator) ![]const u8 {
|
||||
const now = std.time.timestamp();
|
||||
const tm = std.time.epoch.EpochSeconds{ .secs = @intCast(now) };
|
||||
const dt = tm.getDaySeconds();
|
||||
|
||||
var buf: [20]u8 = undefined;
|
||||
const len = try std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{
|
||||
tm.getEpochDay().year,
|
||||
tm.getEpochDay().month,
|
||||
tm.getEpochDay().day,
|
||||
dt.getHoursIntoDay(),
|
||||
dt.getMinutesIntoHour(),
|
||||
dt.getSecondsIntoMinute(),
|
||||
});
|
||||
|
||||
return try allocator.dupe(u8, len);
|
||||
}
|
||||
36
cli/src/utils/sqlite_embedded.zig
Normal file
36
cli/src/utils/sqlite_embedded.zig
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
|
||||
/// SQLite embedding strategy (mirrors rsync pattern)
|
||||
///
|
||||
/// For dev builds: link against system SQLite library
|
||||
/// For release builds: compile SQLite from downloaded amalgamation
|
||||
///
|
||||
/// To prepare for release:
|
||||
/// 1. Run: make build-sqlite
|
||||
/// 2. Build with: zig build prod
|
||||
|
||||
pub const USE_EMBEDDED_SQLITE = build_options.has_sqlite_release;
|
||||
|
||||
/// Compile flags for embedded SQLite
|
||||
pub const SQLITE_FLAGS = &[_][]const u8{
|
||||
"-DSQLITE_ENABLE_FTS5",
|
||||
"-DSQLITE_ENABLE_JSON1",
|
||||
"-DSQLITE_THREADSAFE=1",
|
||||
"-DSQLITE_USE_URI",
|
||||
"-DSQLITE_ENABLE_COLUMN_METADATA",
|
||||
"-DSQLITE_ENABLE_STAT4",
|
||||
};
|
||||
|
||||
/// Get SQLite include path for embedded builds
|
||||
pub fn getSqliteIncludePath(b: *std.Build) ?std.Build.LazyPath {
|
||||
if (!USE_EMBEDDED_SQLITE) return null;
|
||||
return b.path(build_options.sqlite_release_path);
|
||||
}
|
||||
|
||||
/// Get SQLite source file path for embedded builds
|
||||
pub fn getSqliteSourcePath(b: *std.Build) ?std.Build.LazyPath {
|
||||
if (!USE_EMBEDDED_SQLITE) return null;
|
||||
const path = std.fs.path.join(b.allocator, &.{ build_options.sqlite_release_path, "sqlite3.c" }) catch return null;
|
||||
return b.path(path);
|
||||
}
|
||||
Loading…
Reference in a new issue