core/plugin/wasmguest/bnwasm/transport.go
Alex Dunmow 7881aeee05 feat(wasmguest): guest DB driver bnwasm (WO-WZ-004)
Guest-side database access over the db.* host calls (abiv1 db.proto),
two surfaces sharing one injected transport:

- database/sql driver registered as "bnwasm" (driver.go) — QueryContext/
  ExecContext/BeginTx over db.query/db.exec/db.tx_*; named args rejected.
- plugin.Pool (pool.go) handing out a pgx.Tx-shaped value (tx.go), so the
  pgx-flavored sqlc DBTX every current plugin generates against
  (sql_package: pgx/v5) is satisfied with no source edits. This is the
  primary path: their DBTX needs pgconn.CommandTag/pgx.Rows/pgx.Row, which
  database/sql cannot produce.

DbValue↔Go mapping (dbvalue.go) covers all 11 oneof arms both directions;
DbError surfaces as *pgconn.PgError (SQLSTATE preserved for errors.As);
nested tx/savepoints rejected with a clear error (no fleet plugin uses
them). The scan contract is pinned in the exported DbValueFixtures table
(dbvalue_fixtures.go) that the WO-WZ-007 host executor mirrors.

Tests: driver_test.go (fake host — every DbValue variant round-trips with
correct scan types, exec rows-affected, ordered host-call assertions for
tx commit/rollback sequences, post-rollback autocommit carries no handle)
and sqlcgen_test.go (vendored sqlc-style Queries + WithTx run against the
fake host). Native + wasip1 builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:41:45 +08:00

165 lines
5.5 KiB
Go

// Package bnwasm is the guest-side database access layer for wazero-loaded
// BlockNinja plugins. It presents two surfaces over the SAME db.* host calls
// (abiv1 db.proto), so plugin code that talks to Postgres keeps compiling and
// running unchanged inside the wasm sandbox:
//
// - A database/sql/driver registered as "bnwasm" (driver.go), for plugins or
// sqlc configs that use the standard library.
// - A plugin.Pool implementation (pool.go) that hands out a pgx.Tx-shaped
// value (tx.go), so the pgx-flavored sqlc DBTX interface every current
// plugin generates against (sql_package: "pgx/v5") is satisfied with no
// source edits. This is the PRIMARY path: symposium/messenger sqlc output
// uses pgconn.CommandTag / pgx.Rows / pgx.Row, which database/sql cannot
// produce, so the pgx surface is what their generated db packages bind to.
//
// # The transport seam
//
// Every DB operation is "marshal a db.<op> request, invoke it, unmarshal the
// reply". That transport is a Transport func injected at construction, mirroring
// caps.CallFunc exactly, so the marshaling/scanning logic here stays natively
// testable with a fake host (driver_test.go, sqlcgen_test.go) while the wasm
// guest shim binds the real host_call-backed transport (wired from package
// wasmguest, which imports this package — never the reverse). A nil Transport
// (native builds, DESCRIBE probes) fails every call cleanly with errNoHost
// instead of nil-panicking, matching package caps.
package bnwasm
import (
"context"
"errors"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/jackc/pgx/v5/pgconn"
"google.golang.org/protobuf/proto"
)
// Transport is the guest→host DB transport: marshal req, invoke the host with
// the db.* method, and unmarshal the reply into resp. It is structurally
// identical to caps.CallFunc so the wasip1 shim can bind one func to both.
type Transport func(method string, req, resp proto.Message) error
// db.* method names (see core/docs/wasm-abi.md §"Capability calls").
const (
methodQuery = "db.query"
methodExec = "db.exec"
methodTxBegin = "db.tx_begin"
methodTxCommit = "db.tx_commit"
methodTxRollback = "db.tx_rollback"
)
// errNoHost is returned by every operation when no transport is bound (native
// build / DESCRIBE probe). It is distinct so tests can assert on it.
var errNoHost = errors.New("bnwasm: no host transport bound (native build)")
// dbErr maps a DbError from a response into a *pgconn.PgError, the same error
// type pgx surfaces, so plugin code that does errors.As(err, &pgErr) to inspect
// a SQLSTATE (e.g. "23505" unique_violation) keeps working across the sandbox.
func dbErr(e *abiv1.DbError) error {
if e == nil {
return nil
}
return &pgconn.PgError{Code: e.GetCode(), Message: e.GetMessage()}
}
func (t Transport) query(ctx context.Context, sql string, txHandle uint64, args []any) (*abiv1.DbRowsResponse, error) {
if t == nil {
return nil, errNoHost
}
if err := ctxErr(ctx); err != nil {
return nil, err
}
vals, err := toDbValues(args)
if err != nil {
return nil, err
}
resp := &abiv1.DbRowsResponse{}
if err := t(methodQuery, &abiv1.DbQueryRequest{Sql: sql, Args: vals, TxHandle: txHandle}, resp); err != nil {
return nil, err
}
if err := dbErr(resp.GetError()); err != nil {
return nil, err
}
return resp, nil
}
func (t Transport) exec(ctx context.Context, sql string, txHandle uint64, args []any) (pgconn.CommandTag, error) {
if t == nil {
return pgconn.CommandTag{}, errNoHost
}
if err := ctxErr(ctx); err != nil {
return pgconn.CommandTag{}, err
}
vals, err := toDbValues(args)
if err != nil {
return pgconn.CommandTag{}, err
}
resp := &abiv1.DbExecResponse{}
if err := t(methodExec, &abiv1.DbExecRequest{Sql: sql, Args: vals, TxHandle: txHandle}, resp); err != nil {
return pgconn.CommandTag{}, err
}
if err := dbErr(resp.GetError()); err != nil {
return pgconn.CommandTag{}, err
}
// Encode rows-affected in a CommandTag whose trailing integer pgconn parses
// back out via RowsAffected() — the only field sqlc-generated code reads.
return pgconn.NewCommandTag(fmt.Sprintf("EXEC %d", resp.GetRowsAffected())), nil
}
func (t Transport) txBegin(ctx context.Context) (uint64, error) {
if t == nil {
return 0, errNoHost
}
if err := ctxErr(ctx); err != nil {
return 0, err
}
resp := &abiv1.DbTxBeginResponse{}
if err := t(methodTxBegin, &abiv1.DbTxBeginRequest{}, resp); err != nil {
return 0, err
}
if err := dbErr(resp.GetError()); err != nil {
return 0, err
}
if resp.GetTxHandle() == 0 {
return 0, errors.New("bnwasm: host returned tx_handle 0 (never valid)")
}
return resp.GetTxHandle(), nil
}
func (t Transport) txCommit(ctx context.Context, handle uint64) error {
if t == nil {
return errNoHost
}
if err := ctxErr(ctx); err != nil {
return err
}
resp := &abiv1.DbTxCommitResponse{}
if err := t(methodTxCommit, &abiv1.DbTxCommitRequest{TxHandle: handle}, resp); err != nil {
return err
}
return dbErr(resp.GetError())
}
func (t Transport) txRollback(ctx context.Context, handle uint64) error {
if t == nil {
return errNoHost
}
if err := ctxErr(ctx); err != nil {
return err
}
resp := &abiv1.DbTxRollbackResponse{}
if err := t(methodTxRollback, &abiv1.DbTxRollbackRequest{TxHandle: handle}, resp); err != nil {
return err
}
return dbErr(resp.GetError())
}
// ctxErr short-circuits an already-cancelled/expired context before crossing
// the boundary, matching the caps stubs; the host enforces the invoke deadline.
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}