diff --git a/crates/storage-boundary-test/tests/write_boundary.rs b/crates/storage-boundary-test/tests/write_boundary.rs new file mode 100644 index 00000000..1dccf9e9 --- /dev/null +++ b/crates/storage-boundary-test/tests/write_boundary.rs @@ -0,0 +1,256 @@ +//! Cross-boundary integration test for the WRITABLE half of the storage +//! contract. +//! +//! Loads the built synthetic writer bridge (from either the sibling repo or +//! the local `artifacts/extensions/` staging dir) and drives its +//! `storage-write-dispatch@2.0.0` exports through the WIT component boundary +//! via wasmtime, proving that transactional / DDL / DML calls round-trip +//! ACROSS the interface: args are encoded into the component's linear memory +//! by the canonical ABI and the resultset (rowids + counts) is decoded back +//! out, not merely exercised by the component's native Rust logic. This is +//! the mirror-image counterpart to `boundary.rs` (which exercises the +//! read-side `storage-dispatch` interface against the sqlite component). +//! +//! Strategy: identical to the read-side test. The bridge is a plain writer +//! component with NO imports (its `writer` world only exports +//! storage-write-dispatch), so we just instantiate it and call the exports +//! directly. `handle = 1` and `catalog = 1` are the fixed values the bridge +//! serves (it does not model a callback registry / catalog table). + +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::{Config, Engine, Store}; + +mod bindings { + wasmtime::component::bindgen!({ + path: "wit-write", + world: "write-boundary-test", + }); +} + +use bindings::duckdb::extension as ext; +use bindings::WriteBoundaryTest; +use ext::types::Duckvalue; + +/// Store state. The writer bridge's `writer` world imports nothing from the +/// `duckdb:extension` package, but the wasip2 stdlib pulls in `wasi:io` / +/// `wasi:cli` / `wasi:filesystem` implicitly (Mutex + OnceLock allocate, +/// panic-time stderr goes through wasi-cli); satisfy those with a real (but +/// idle) `WasiCtx` so instantiation succeeds. The dispatch calls never touch +/// them. +struct State { + table: ResourceTable, + wasi: wasmtime_wasi::WasiCtx, +} + +impl Default for State { + fn default() -> Self { + State { + table: ResourceTable::new(), + wasi: wasmtime_wasi::WasiCtxBuilder::new().build(), + } + } +} + +impl wasmtime_wasi::WasiView for State { + fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> { + wasmtime_wasi::WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +const HANDLE: u32 = 1; +const CATALOG: u32 = 1; + +fn locate_writer_bridge_wasm() -> std::path::PathBuf { + // 1. explicit env var wins (CI). + if let Ok(p) = std::env::var("DUCKLINK_WRITER_BRIDGE_WASM") { + return std::path::PathBuf::from(p); + } + // 2. staged copy in the local artifacts dir (developer convenience: + // `cp .../synthetic_mutating_ducklink_bridge_dynlink.wasm + // ducklink/artifacts/extensions/synthetic_mutating_ducklink.wasm`). + let manifest = env!("CARGO_MANIFEST_DIR"); + let staged = std::path::Path::new(manifest) + .join("../../artifacts/extensions/synthetic_mutating_ducklink.wasm"); + if staged.exists() { + return staged; + } + // 3. sibling checkout (default dev layout: ~/git/synthetic-mutating-vtab- + // ducklink-bridge/ next to ~/git/ducklink/). + let home = std::env::var("HOME").expect("HOME env var"); + let sibling = std::path::PathBuf::from(home).join( + "git/synthetic-mutating-vtab-ducklink-bridge/target/wasm32-wasip2/release/\ + synthetic_mutating_ducklink_bridge_dynlink.wasm", + ); + if sibling.exists() { + return sibling; + } + panic!( + "writer bridge wasm not found. tried:\n\ + \x20 * $DUCKLINK_WRITER_BRIDGE_WASM (unset)\n\ + \x20 * {}\n\ + \x20 * {}\n\ + build the bridge with:\n\ + \x20 (cd ~/git/synthetic-mutating-vtab-ducklink-bridge && \ + cargo build --target wasm32-wasip2 --release)\n\ + then either set DUCKLINK_WRITER_BRIDGE_WASM= or copy the wasm to \ + the staged location.", + staged.display(), + sibling.display(), + ); +} + +fn columns_kv() -> Vec { + vec![ + ext::types::Columndef { + name: "key".to_string(), + logical: ext::types::Logicaltype::Text, + }, + ext::types::Columndef { + name: "value".to_string(), + logical: ext::types::Logicaltype::Text, + }, + ] +} + +fn row(k: &str, v: &str) -> Vec { + vec![Duckvalue::Text(k.to_string()), Duckvalue::Text(v.to_string())] +} + +fn setup() -> (Store, WriteBoundaryTest) { + let mut config = Config::new(); + config.wasm_component_model(true); + let engine = Engine::new(&config).expect("engine"); + + // The `writer` world imports nothing from duckdb:extension, but the + // wasip2 stdlib the bridge is built against imports wasi:io / wasi:cli / + // wasi:filesystem. Satisfy them with the standard wasi linker. + let mut linker: Linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker).expect("wasi add_to_linker"); + let mut store = Store::new(&engine, State::default()); + + let wasm_path = locate_writer_bridge_wasm(); + let bytes = std::fs::read(&wasm_path) + .unwrap_or_else(|e| panic!("read {}: {e}", wasm_path.display())); + assert_eq!( + &bytes[0..8], + &[0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00], + "not a wasm component (bad magic) at {}", + wasm_path.display() + ); + let component = Component::new(&engine, &bytes).expect("Component::new"); + let instance = + WriteBoundaryTest::instantiate(&mut store, &component, &linker).expect("instantiate"); + (store, instance) +} + +/// Full happy path: begin -> create -> insert -> update -> delete -> commit. +/// Every dispatch call round-trips through the canonical ABI; the assertions +/// check the returned counts / rowids the component emits. +#[test] +fn write_dispatch_crosses_wit_boundary() { + let (mut store, instance) = setup(); + let wd = instance.duckdb_extension_storage_write_dispatch(); + + // begin_transaction across the boundary. + let txn = wd + .call_begin_transaction(&mut store, HANDLE, CATALOG) + .expect("begin_transaction host call") + .expect("begin_transaction component result"); + assert!(txn >= 1, "txn handle must be non-zero, got {txn}"); + + // create_table across the boundary. + let cols = columns_kv(); + wd.call_create_table(&mut store, HANDLE, txn, "kv_store", &cols) + .expect("create_table host call") + .expect("create_table component result"); + + // insert_rows across the boundary. Two rows, count=2. + let rows = vec![row("alpha", "1"), row("beta", "2")]; + let inserted = wd + .call_insert_rows(&mut store, HANDLE, txn, "kv_store", &rows) + .expect("insert_rows host call") + .expect("insert_rows component result"); + assert_eq!(inserted, 2, "insert returned {inserted}, want 2"); + + // update_rows across the boundary. The bridge assigns rowids 1..=N in + // monotonic order; update rowid 1 in place, count=1. + let update_row = vec![row("alpha", "one")]; + let updated = wd + .call_update_rows( + &mut store, + HANDLE, + txn, + "kv_store", + &[1_i64], + &update_row, + ) + .expect("update_rows host call") + .expect("update_rows component result"); + assert_eq!(updated, 1, "update returned {updated}, want 1"); + + // delete_rows across the boundary. Delete rowid 2, count=1. + let deleted = wd + .call_delete_rows(&mut store, HANDLE, txn, "kv_store", &[2_i64]) + .expect("delete_rows host call") + .expect("delete_rows component result"); + assert_eq!(deleted, 1, "delete returned {deleted}, want 1"); + + // commit_transaction across the boundary. + wd.call_commit_transaction(&mut store, HANDLE, txn) + .expect("commit_transaction host call") + .expect("commit_transaction component result"); +} + +/// Rollback path: begin -> insert -> rollback. After rollback, a new txn +/// starts from the pre-rollback committed state (the bridge's committed +/// state was already populated by `write_dispatch_crosses_wit_boundary`, +/// so this test starts with a fresh setup and checks that a rollback +/// leaves no writes visible in the next txn). +#[test] +fn write_dispatch_rollback_crosses_wit_boundary() { + let (mut store, instance) = setup(); + let wd = instance.duckdb_extension_storage_write_dispatch(); + + // begin_transaction + create_table + insert 3 rows. + let txn = wd + .call_begin_transaction(&mut store, HANDLE, CATALOG) + .expect("begin_transaction host call") + .expect("begin_transaction component result"); + let cols = columns_kv(); + wd.call_create_table(&mut store, HANDLE, txn, "kv_store", &cols) + .expect("create_table host call") + .expect("create_table component result"); + let rows = vec![row("k1", "v1"), row("k2", "v2"), row("k3", "v3")]; + let inserted = wd + .call_insert_rows(&mut store, HANDLE, txn, "kv_store", &rows) + .expect("insert_rows host call") + .expect("insert_rows component result"); + assert_eq!(inserted, 3, "insert returned {inserted}, want 3"); + + // rollback_transaction across the boundary. + wd.call_rollback_transaction(&mut store, HANDLE, txn) + .expect("rollback_transaction host call") + .expect("rollback_transaction component result"); + + // Open a new txn and delete a rowid the rolled-back txn had inserted. + // The rollback should have discarded the shadow, so the delete of a + // never-committed rowid returns 0 (nothing to delete). + let txn2 = wd + .call_begin_transaction(&mut store, HANDLE, CATALOG) + .expect("begin_transaction (rerun) host call") + .expect("begin_transaction (rerun) component result"); + let deleted = wd + .call_delete_rows(&mut store, HANDLE, txn2, "kv_store", &[1_i64, 2_i64, 3_i64]) + .expect("delete_rows (post-rollback) host call") + .expect("delete_rows (post-rollback) component result"); + assert_eq!( + deleted, 0, + "post-rollback delete returned {deleted}, want 0 (shadow was discarded)" + ); + wd.call_rollback_transaction(&mut store, HANDLE, txn2) + .expect("cleanup rollback host call") + .expect("cleanup rollback component result"); +} diff --git a/crates/storage-boundary-test/wit-write/boundary.wit b/crates/storage-boundary-test/wit-write/boundary.wit new file mode 100644 index 00000000..863d8add --- /dev/null +++ b/crates/storage-boundary-test/wit-write/boundary.wit @@ -0,0 +1,12 @@ +package boundary-test:write@0.1.0; + +use duckdb:extension/types@2.0.0; +use duckdb:extension/storage-write-dispatch@2.0.0; + +// Host-side world for the write-boundary integration test. Mirrors the +// synthetic ducklink writer bridge's `synthetic:writer/writer` world: exports +// only storage-write-dispatch@2.0.0. No imports (the bridge is a plain writer +// component that does not call any host-side interfaces). +world write-boundary-test { + export duckdb:extension/storage-write-dispatch@2.0.0; +} diff --git a/crates/storage-boundary-test/wit-write/deps.toml b/crates/storage-boundary-test/wit-write/deps.toml new file mode 100644 index 00000000..0ab11cfc --- /dev/null +++ b/crates/storage-boundary-test/wit-write/deps.toml @@ -0,0 +1,6 @@ +[package] +name = "boundary-test:write" +version = "0.1.0" + +[dependencies] +"duckdb:extension" = { path = "deps/duckdb-extension" } diff --git a/crates/storage-boundary-test/wit-write/deps/duckdb-extension/deps.toml b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/deps.toml new file mode 100644 index 00000000..f0d09d11 --- /dev/null +++ b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/deps.toml @@ -0,0 +1,3 @@ +[package] +name = "duckdb:extension" +version = "2.0.0" diff --git a/crates/storage-boundary-test/wit-write/deps/duckdb-extension/storage-write-dispatch.wit b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/storage-write-dispatch.wit new file mode 100644 index 00000000..5fa6a1fd --- /dev/null +++ b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/storage-write-dispatch.wit @@ -0,0 +1,48 @@ +package duckdb:extension@2.0.0; + +use types; + +// Host -> component callbacks for the WRITABLE half of a storage backend +// (additive, 2.1.0). The read-only `storage-dispatch` (attach/list/scan) is left +// INTACT; this adds the mutating path -- transactions, DDL, and DML -- for +// storage backends that can write through. Exported by writable storage +// components via the separate `duckdb-extension-storage-write` world. +// +// Every call carries `handle` = the callback-handle the component passed to +// storage.register-storage; `catalog` is the catalog handle from +// storage-dispatch.storage-attach. +interface storage-write-dispatch { + use types.{duckerror, duckvalue, columndef}; + + // Begin a transaction on `catalog`; returns a transaction handle. + begin-transaction: func(handle: u32, catalog: u32) -> result; + + commit-transaction: func(handle: u32, txn: u32) -> result<_, duckerror>; + rollback-transaction: func(handle: u32, txn: u32) -> result<_, duckerror>; + + // CREATE TABLE within `txn`. + create-table: func(handle: u32, + txn: u32, + table: string, + columns: list) -> result<_, duckerror>; + + // Append rows; returns the number inserted. + insert-rows: func(handle: u32, + txn: u32, + table: string, + rows: list>) -> result; + + // Delete rows by row-id; returns the number deleted. + delete-rows: func(handle: u32, + txn: u32, + table: string, + rowids: list) -> result; + + // Update rows by row-id (parallel `rowids` / `rows`); returns the number + // updated. + update-rows: func(handle: u32, + txn: u32, + table: string, + rowids: list, + rows: list>) -> result; +} diff --git a/crates/storage-boundary-test/wit-write/deps/duckdb-extension/types.wit b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/types.wit new file mode 100644 index 00000000..2da11dfc --- /dev/null +++ b/crates/storage-boundary-test/wit-write/deps/duckdb-extension/types.wit @@ -0,0 +1,173 @@ +package duckdb:extension@2.0.0; + +interface types { + // ESCAPE-HATCH logical type. The `complex` arm carries a DuckDB type-expression + // string (e.g. "INTEGER[]", "STRUCT(a INTEGER, b VARCHAR)"); the core resolves it + // to a real logical type. This makes `logicaltype` a `variant` (was an `enum`); the + // fieldless arms keep their discriminant order, so the canonical-ABI is appended-to, + // not reordered. Any FUTURE type rides this arm with no further contract bump. + variant logicaltype { + boolean, + int64, + uint64, + float64, + text, + blob, + int32, + timestamp, + int8, + int16, + uint8, + uint16, + uint32, + float32, + date, + time, + timestamptz, + decimal, + interval, + uuid, + complex(string), + } + + // DuckDB DECIMAL is a HUGEINT-backed scaled integer: + // value = ((upper as i128) << 64 | lower as i128), interpreted with + // `width` total digits and `scale` fractional digits. + record decimalvalue { + lower: u64, + upper: u64, + width: u8, + scale: u8, + } + + // DuckDB INTERVAL: months + days + microseconds components. + record intervalvalue { + months: s32, + days: s32, + micros: s64, + } + + // 128-bit UUID. hi/lo are the logical big-endian halves of the UUID value + // (NOT the sign-flipped physical hugeint storage DuckDB uses internally). + record uuidvalue { + hi: u64, + lo: u64, + } + + // ESCAPE-HATCH composite value. `type-expr` is a DuckDB type string (e.g. + // "INTEGER[]" / "STRUCT(a INTEGER, b VARCHAR)") and `json` is the value rendered + // as JSON (e.g. "[10,20,30]" / {"a":1,"b":"hi"}). This is a FLAT record -- it does + // NOT reference `duckvalue`, so there is no recursive WIT cycle. The core + // reconstructs the real LIST/STRUCT vector from the JSON via the duckdb C vector + // API (which has no recursion limit). + record complexvalue { + type-expr: string, + json: string, + } + + record funcarg { + name: option, + logical: logicaltype, + } + + flags funcflags { + deterministic, + commutative, + stateless, + sideeffecting, + deprecated, + } + + record funcopts { + description: option, + tags: list, + attributes: funcflags, + } + + record columndef { + name: string, + logical: logicaltype, + } + + record extopts { + description: option, + tags: list, + } + + variant duckvalue { + null, + boolean(bool), + int64(s64), + uint64(u64), + float64(f64), + text(string), + blob(list), + int32(s32), + timestamp(s64), + int8(s8), + int16(s16), + uint8(u8), + uint16(u16), + uint32(u32), + float32(f32), + date(s32), + time(s64), + timestamptz(s64), + decimal(decimalvalue), + interval(intervalvalue), + uuid(uuidvalue), + complex(complexvalue), + } + + type resultset = list>; + type rowbatch = list>; + + record invokeinfo { + rowindex: option, + iswindow: bool, + } + + variant duckerror { + invalidargument(string), + unsupported(string), + invalidstate(string), + io(string), + internal(string), + } + + variant configerror { + invalidkey(string), + typemismatch(string), + unavailable(string), + internalconfig(string), + } + + enum loglevel { + trace, + debug, + info, + warn, + error, + } + + record logfield { + key: string, + value: string, + } + + record loadresult { + name: string, + version: option, + requires: list, + } + + enum capabilitykind { + scalar, + table, + aggregate, + pragma, + macro, + catalog, + file-format, + } +}