Skip to content

Commit dfc721a

Browse files
hyperpolymathclaude
andcommitted
feat: real database backends (SQLite + VeriSimDB) and user-first README
- database-mcp now executes real SQL against SQLite through the Zig state machine (sqlite3_open/exec/close), with formal verification of connection lifecycle preserved - database-mcp gains 'vql' tool routing VeriSimDB queries through the Zig state machine instead of shelling out to curl directly - verisimdb.zig backing store now implements persistent mode with write-through caching: local store + HTTP sync to VeriSimDB API - README completely rewritten: leads with value proposition, hides polyglot complexity, shows install in 3 steps - 5 new SQLite tests, 5 new VeriSimDB state machine tests, 11 new backing store tests — all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8814b38 commit dfc721a

4 files changed

Lines changed: 786 additions & 16 deletions

File tree

adapter/v/src/main.v

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,7 +1464,50 @@ fn invoke_database(tool string, args string) http.Response {
14641464
'data': result_json
14651465
}))
14661466
}
1467-
return error_response(400, 'unknown database-mcp tool: "${tool}" — available: health, list_octads, create_octad, query, drift, list_backends, sql')
1467+
if tool == 'vql' {
1468+
// VQL query execution via the Zig state machine (no curl shell-out).
1469+
// Args: {"url": "http://localhost:8180", "vql": "{\"query\": \"...\"}"}
1470+
// If url is omitted, falls back to VERISIMDB_URL env or default.
1471+
params := json.decode(map[string]string, args) or {
1472+
return error_response(400, 'vql requires {"vql": "<VQL JSON body>"} and optionally {"url": "..."}')
1473+
}
1474+
vql_body := params['vql'] or { '' }
1475+
if vql_body == '' {
1476+
return error_response(400, 'vql requires a "vql" field with the VQL query JSON body')
1477+
}
1478+
url := params['url'] or { verisimdb_url }
1479+
1480+
// Connect to VeriSimDB via Zig FFI
1481+
conn := database_adapter.connect_verisimdb(url) or {
1482+
return json_response(json.encode({
1483+
'tool': 'vql'
1484+
'status': 'error'
1485+
'error': 'connect failed: ${err.msg()}'
1486+
}))
1487+
}
1488+
1489+
// Execute the VQL query through the state machine
1490+
result_json := database_adapter.execute_vql(conn.slot, vql_body) or {
1491+
// Disconnect on error (error state -> disconnected)
1492+
database_adapter.disconnect(conn.slot) or {}
1493+
return json_response(json.encode({
1494+
'tool': 'vql'
1495+
'status': 'error'
1496+
'error': 'VQL query failed: ${err.msg()}'
1497+
}))
1498+
}
1499+
1500+
// Disconnect after query
1501+
database_adapter.disconnect(conn.slot) or {}
1502+
1503+
return json_response(json.encode({
1504+
'tool': 'vql'
1505+
'status': 'ok'
1506+
'url': url
1507+
'data': result_json
1508+
}))
1509+
}
1510+
return error_response(400, 'unknown database-mcp tool: "${tool}" — available: health, list_octads, create_octad, query, drift, list_backends, sql, vql')
14681511
}
14691512

14701513
// --- ssg-mcp: Static site generation (Zola, Hugo, ddraig) ---

cartridges/database-mcp/adapter/database_adapter.v

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ fn C.db_end_query(slot_idx int) int
2424
fn C.db_query_error(slot_idx int) int
2525
fn C.db_can_transition(from int, to int) int
2626
fn C.db_execute_sql(slot u8, sql_ptr &u8, sql_len usize, out_ptr &u8, out_len usize) int
27+
fn C.db_connect_verisimdb(url_ptr &u8, url_len usize) int
28+
fn C.db_execute_vql(slot u8, vql_ptr &u8, vql_len usize, out_ptr &u8, out_len usize) int
2729
fn C.db_reset()
2830

2931
// ═══════════════════════════════════════════════════════════════════════
@@ -191,6 +193,41 @@ pub fn execute_sql(slot int, sql string) !string {
191193
return out_buf[..result].bytestr()
192194
}
193195

196+
pub fn connect_verisimdb(url string) !ConnectResponse {
197+
slot := C.db_connect_verisimdb(url.str, usize(url.len))
198+
if slot < 0 {
199+
return match slot {
200+
-1 { error('no connection slots available') }
201+
-6 { error('URL empty or too long (max ${512} bytes): ${url}') }
202+
else { error('unknown error (code ${slot})') }
203+
}
204+
}
205+
return ConnectResponse{
206+
slot: slot
207+
backend: 'verisimdb'
208+
state: 'connected'
209+
}
210+
}
211+
212+
pub fn execute_vql(slot int, vql string) !string {
213+
if slot < 0 || slot > 255 {
214+
return error('invalid slot index: ${slot}')
215+
}
216+
mut out_buf := []u8{len: 65536}
217+
result := C.db_execute_vql(u8(slot), vql.str, usize(vql.len), out_buf.data, usize(out_buf.len))
218+
if result < 0 {
219+
return match result {
220+
-1 { error('invalid or inactive slot') }
221+
-2 { error('connection not in queryable state') }
222+
-5 { error('output buffer too small') }
223+
-6 { error('slot does not have a VeriSimDB URL (wrong backend?)') }
224+
-7 { error('VQL execution failed (curl returned non-zero)') }
225+
else { error('unknown error (code ${result})') }
226+
}
227+
}
228+
return out_buf[..result].bytestr()
229+
}
230+
194231
pub fn reset() {
195232
C.db_reset()
196233
}

cartridges/database-mcp/ffi/database_ffi.zig

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,24 @@ pub const QuerySafety = enum(c_int) {
4343

4444
const MAX_CONNECTIONS: usize = 16;
4545

46+
const URL_BUF_SIZE: usize = 512;
47+
4648
const ConnectionSlot = struct {
4749
active: bool,
4850
state: ConnState,
4951
backend: DatabaseBackend,
5052
db_handle: ?*c.sqlite3,
53+
url_buf: [URL_BUF_SIZE]u8,
54+
url_len: usize,
5155
};
5256

5357
var connections: [MAX_CONNECTIONS]ConnectionSlot = [_]ConnectionSlot{.{
5458
.active = false,
5559
.state = .disconnected,
5660
.backend = .sqlite,
5761
.db_handle = null,
62+
.url_buf = [_]u8{0} ** URL_BUF_SIZE,
63+
.url_len = 0,
5864
}} ** MAX_CONNECTIONS;
5965

6066
var mutex: std.Thread.Mutex = .{};
@@ -128,6 +134,38 @@ pub export fn db_connect_sqlite(path_ptr: [*]const u8, path_len: usize) c_int {
128134
return @intCast(idx);
129135
}
130136

137+
/// Open a new VeriSimDB connection by URL (e.g. "http://localhost:8180").
138+
/// Stores the URL in the slot's url_buf for later use by db_execute_vql.
139+
/// Returns slot index or negative error code:
140+
/// -1 = no slots available
141+
/// -6 = URL too long (exceeds URL_BUF_SIZE)
142+
pub export fn db_connect_verisimdb(url_ptr: [*]const u8, url_len: usize) c_int {
143+
mutex.lock();
144+
defer mutex.unlock();
145+
146+
if (url_len == 0 or url_len >= URL_BUF_SIZE) return -6;
147+
148+
// Find a free slot
149+
var free_idx: ?usize = null;
150+
for (&connections, 0..) |*slot, i| {
151+
_ = slot;
152+
if (!connections[i].active) {
153+
free_idx = i;
154+
break;
155+
}
156+
}
157+
const idx = free_idx orelse return -1;
158+
159+
// Store the URL
160+
@memcpy(connections[idx].url_buf[0..url_len], url_ptr[0..url_len]);
161+
connections[idx].url_len = url_len;
162+
connections[idx].active = true;
163+
connections[idx].state = .connected;
164+
connections[idx].backend = .verisimdb;
165+
connections[idx].db_handle = null;
166+
return @intCast(idx);
167+
}
168+
131169
/// Close a connection by slot index.
132170
/// If the slot holds an open sqlite3 handle, closes it first.
133171
pub export fn db_disconnect(slot_idx: c_int) c_int {
@@ -144,6 +182,9 @@ pub export fn db_disconnect(slot_idx: c_int) c_int {
144182
connections[idx].db_handle = null;
145183
}
146184

185+
// Clear stored URL
186+
connections[idx].url_len = 0;
187+
147188
connections[idx].active = false;
148189
connections[idx].state = .disconnected;
149190
return 0;
@@ -217,6 +258,7 @@ pub export fn db_reset() void {
217258
_ = c.sqlite3_close(h);
218259
slot.db_handle = null;
219260
}
261+
slot.url_len = 0;
220262
slot.active = false;
221263
slot.state = .disconnected;
222264
}
@@ -397,6 +439,139 @@ fn appendJsonEscaped(list: *std.ArrayListUnmanaged(u8), alloc: std.mem.Allocator
397439
}
398440

399441

442+
// ═══════════════════════════════════════════════════════════════════════
443+
// VQL Execution (VeriSimDB — via child curl process)
444+
// ═══════════════════════════════════════════════════════════════════════
445+
446+
/// Execute a VQL query against a VeriSimDB connection via the Zig state machine.
447+
///
448+
/// The stored URL is used to POST to {url}/vql/execute with the VQL query
449+
/// as the JSON request body. Uses a child curl process for HTTP transport.
450+
///
451+
/// Parameters:
452+
/// slot: connection slot index (must be connected, backend == verisimdb)
453+
/// vql_ptr: pointer to the VQL JSON string (request body)
454+
/// vql_len: byte length of the VQL string
455+
/// out_ptr: caller-owned buffer for response output
456+
/// out_len: size of the output buffer
457+
///
458+
/// Returns:
459+
/// >= 0 : number of bytes written to out_ptr
460+
/// -1 : invalid slot
461+
/// -2 : invalid state transition (not in connected state)
462+
/// -6 : no URL stored on this slot (wrong backend)
463+
/// -7 : curl execution failed (state transitions to Error)
464+
/// -5 : output buffer too small
465+
pub export fn db_execute_vql(slot: u8, vql_ptr: [*]const u8, vql_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 {
466+
// Phase 1: validate and transition state under lock
467+
var endpoint_buf: [600]u8 = undefined;
468+
var endpoint_total: usize = 0;
469+
var vql_buf: [8192]u8 = undefined;
470+
var safe_vql_len: usize = 0;
471+
472+
{
473+
mutex.lock();
474+
defer mutex.unlock();
475+
476+
if (slot >= MAX_CONNECTIONS) return -1;
477+
const idx: usize = @intCast(slot);
478+
if (!connections[idx].active) return -1;
479+
480+
// Must be in connected state to begin querying
481+
if (!isValidTransition(connections[idx].state, .querying)) return -2;
482+
483+
// Must have a stored URL (verisimdb backend)
484+
if (connections[idx].url_len == 0) return -6;
485+
486+
// Build the full endpoint URL: {base_url}/vql/execute
487+
const url_slice = connections[idx].url_buf[0..connections[idx].url_len];
488+
const suffix = "/vql/execute";
489+
if (url_slice.len + suffix.len >= endpoint_buf.len) return -6;
490+
@memcpy(endpoint_buf[0..url_slice.len], url_slice);
491+
@memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix);
492+
endpoint_total = url_slice.len + suffix.len;
493+
endpoint_buf[endpoint_total] = 0;
494+
495+
// Build the VQL body as a null-terminated string for the -d argument
496+
safe_vql_len = @min(vql_len, vql_buf.len - 1);
497+
@memcpy(vql_buf[0..safe_vql_len], vql_ptr[0..safe_vql_len]);
498+
vql_buf[safe_vql_len] = 0;
499+
500+
// Transition to querying
501+
connections[idx].state = .querying;
502+
}
503+
504+
// Phase 2: run curl WITHOUT holding the mutex (blocking I/O)
505+
const child_result = runCurlPost(
506+
endpoint_buf[0..endpoint_total :0],
507+
vql_buf[0..safe_vql_len :0],
508+
);
509+
510+
// Phase 3: update state under lock based on result
511+
mutex.lock();
512+
defer mutex.unlock();
513+
514+
const idx: usize = @intCast(slot);
515+
// Check if slot is still valid after re-acquiring lock
516+
if (!connections[idx].active or connections[idx].state != .querying) {
517+
return -1;
518+
}
519+
520+
if (child_result) |result| {
521+
defer std.heap.page_allocator.free(result);
522+
const written = result.len;
523+
if (written > out_len) {
524+
connections[idx].state = .err;
525+
return -5;
526+
}
527+
@memcpy(out_ptr[0..written], result[0..written]);
528+
connections[idx].state = .connected;
529+
return @intCast(written);
530+
} else |_| {
531+
connections[idx].state = .err;
532+
return -7;
533+
}
534+
}
535+
536+
/// Run curl as a child process for an HTTP POST with JSON body.
537+
/// Returns a heap-allocated slice with stdout output, or an error.
538+
/// Caller must free the returned slice with page_allocator.free().
539+
fn runCurlPost(endpoint: [:0]const u8, body: [:0]const u8) ![]u8 {
540+
const argv = [_][]const u8{
541+
"curl",
542+
"-sf",
543+
"--max-time",
544+
"10",
545+
"-X",
546+
"POST",
547+
"-H",
548+
"Content-Type: application/json",
549+
"-d",
550+
body,
551+
endpoint,
552+
};
553+
var child = std.process.Child.init(&argv, std.heap.page_allocator);
554+
child.stdout_behavior = .Pipe;
555+
child.stderr_behavior = .Pipe;
556+
try child.spawn();
557+
558+
// Collect stdout via the standard API
559+
const alloc = std.heap.page_allocator;
560+
var stdout_list: std.ArrayList(u8) = .empty;
561+
var stderr_list: std.ArrayList(u8) = .empty;
562+
defer stderr_list.deinit(alloc);
563+
564+
try child.collectOutput(alloc, &stdout_list, &stderr_list, 65536);
565+
const term = try child.wait();
566+
567+
if (term.Exited != 0) {
568+
stdout_list.deinit(alloc);
569+
return error.CurlFailed;
570+
}
571+
572+
return stdout_list.toOwnedSlice(alloc);
573+
}
574+
400575
// ═══════════════════════════════════════════════════════════════════════
401576
// Standard Cartridge Interface (loader expects these 4 C-ABI symbols)
402577
// ═══════════════════════════════════════════════════════════════════════
@@ -598,3 +773,72 @@ test "sqlite execute_sql multiple rows" {
598773

599774
try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32));
600775
}
776+
777+
// ─── VeriSimDB connection lifecycle tests ────────────────────────────
778+
779+
test "verisimdb connect stores URL and disconnects" {
780+
db_reset();
781+
const url = "http://localhost:8180";
782+
const slot = db_connect_verisimdb(url, url.len);
783+
try std.testing.expect(slot >= 0);
784+
try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot));
785+
786+
// Verify backend and URL are stored correctly
787+
const idx: usize = @intCast(slot);
788+
try std.testing.expectEqual(DatabaseBackend.verisimdb, connections[idx].backend);
789+
try std.testing.expectEqual(url.len, connections[idx].url_len);
790+
try std.testing.expect(std.mem.eql(u8, url, connections[idx].url_buf[0..connections[idx].url_len]));
791+
792+
// No sqlite handle should be present
793+
try std.testing.expect(connections[idx].db_handle == null);
794+
795+
// Disconnect
796+
try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot));
797+
try std.testing.expectEqual(@as(usize, 0), connections[idx].url_len);
798+
}
799+
800+
test "verisimdb connect rejects empty URL" {
801+
db_reset();
802+
const slot = db_connect_verisimdb("", 0);
803+
try std.testing.expectEqual(@as(c_int, -6), slot);
804+
}
805+
806+
test "verisimdb connect rejects overlong URL" {
807+
db_reset();
808+
var long_url: [URL_BUF_SIZE]u8 = [_]u8{'x'} ** URL_BUF_SIZE;
809+
const slot = db_connect_verisimdb(&long_url, long_url.len);
810+
try std.testing.expectEqual(@as(c_int, -6), slot);
811+
}
812+
813+
test "verisimdb query lifecycle through state machine" {
814+
db_reset();
815+
const url = "http://localhost:8180";
816+
const slot = db_connect_verisimdb(url, url.len);
817+
try std.testing.expect(slot >= 0);
818+
819+
// Manual state transitions (not calling db_execute_vql since no server)
820+
try std.testing.expectEqual(@as(c_int, 0), db_begin_query(slot));
821+
try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.querying)), db_state(slot));
822+
try std.testing.expectEqual(@as(c_int, 0), db_end_query(slot));
823+
try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot));
824+
try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot));
825+
}
826+
827+
test "verisimdb execute_vql rejects non-verisimdb slot" {
828+
db_reset();
829+
// Connect with sqlite backend (has a handle, not a URL)
830+
const path = ":memory:";
831+
const slot_i32 = db_connect_sqlite(path, path.len);
832+
try std.testing.expect(slot_i32 >= 0);
833+
const slot: u8 = @intCast(slot_i32);
834+
835+
var out_buf: [256]u8 = undefined;
836+
const vql = "{\"query\": \"SELECT * FROM test\"}";
837+
const rc = db_execute_vql(slot, vql, vql.len, &out_buf, out_buf.len);
838+
// Should return -6 (no URL stored — sqlite slot has url_len == 0)
839+
try std.testing.expectEqual(@as(i32, -6), rc);
840+
841+
// URL check occurs before state transition, so state remains connected.
842+
try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot_i32));
843+
try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32));
844+
}

0 commit comments

Comments
 (0)