@@ -43,18 +43,24 @@ pub const QuerySafety = enum(c_int) {
4343
4444const MAX_CONNECTIONS : usize = 16 ;
4545
46+ const URL_BUF_SIZE : usize = 512 ;
47+
4648const 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
5357var 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
6066var 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.
133171pub 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