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

202 lines
5.5 KiB
Go

package bnwasm
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"sync"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// DriverName is the database/sql driver name registered by this package.
const DriverName = "bnwasm"
func init() { sql.Register(DriverName, Driver{}) }
// defaultTransport is the process-wide transport the "bnwasm" database/sql
// driver uses (the guest has exactly one host). The wasip1 shim sets it via
// SetDefaultTransport; native builds leave it nil so opened connections fail
// cleanly with errNoHost. Guarded because sql.DB may dial from any goroutine.
var (
defaultMu sync.RWMutex
defaultTransport Transport
)
// SetDefaultTransport binds the transport used by database/sql connections
// opened via sql.Open("bnwasm", ...). Called once from the wasm guest shim.
func SetDefaultTransport(t Transport) {
defaultMu.Lock()
defaultTransport = t
defaultMu.Unlock()
}
func currentTransport() Transport {
defaultMu.RLock()
defer defaultMu.RUnlock()
return defaultTransport
}
// Driver is the database/sql/driver.Driver for the wasm DB ABI.
type Driver struct{}
var (
_ driver.Driver = Driver{}
_ driver.DriverContext = Driver{}
)
// Open ignores its DSN — the transport is process-global (one host per guest).
func (d Driver) Open(name string) (driver.Conn, error) {
return &conn{t: currentTransport()}, nil
}
// OpenConnector lets sql.OpenDB bypass DSN parsing entirely.
func (d Driver) OpenConnector(name string) (driver.Connector, error) {
return connector{}, nil
}
// NewConnector returns a driver.Connector bound to an explicit transport, for
// callers that construct a *sql.DB via sql.OpenDB without touching the global.
func NewConnector(t Transport) driver.Connector { return connector{t: t, explicit: true} }
type connector struct {
t Transport
explicit bool
}
func (c connector) Connect(context.Context) (driver.Conn, error) {
t := c.t
if !c.explicit {
t = currentTransport()
}
return &conn{t: t}, nil
}
func (c connector) Driver() driver.Driver { return Driver{} }
// conn is one logical connection: it holds no real connection state guest-side,
// only the transport and the current transaction handle (0 = autocommit).
type conn struct {
t Transport
txHandle uint64
inTx bool
}
var (
_ driver.Conn = (*conn)(nil)
_ driver.QueryerContext = (*conn)(nil)
_ driver.ExecerContext = (*conn)(nil)
_ driver.ConnBeginTx = (*conn)(nil)
)
func (c *conn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("bnwasm: prepared statements are not supported; use QueryContext/ExecContext")
}
func (c *conn) Close() error { return nil }
func (c *conn) Begin() (driver.Tx, error) { return c.BeginTx(context.Background(), driver.TxOptions{}) }
func (c *conn) BeginTx(ctx context.Context, _ driver.TxOptions) (driver.Tx, error) {
if c.inTx {
return nil, errNestedTx
}
handle, err := c.t.txBegin(ctx)
if err != nil {
return nil, err
}
c.txHandle = handle
c.inTx = true
return &sqlTx{c: c}, nil
}
func (c *conn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
posArgs, err := namedToPositional(args)
if err != nil {
return nil, err
}
tag, err := c.t.exec(ctx, query, c.txHandle, posArgs)
if err != nil {
return nil, err
}
return sqlResult{rowsAffected: tag.RowsAffected()}, nil
}
func (c *conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
posArgs, err := namedToPositional(args)
if err != nil {
return nil, err
}
resp, err := c.t.query(ctx, query, c.txHandle, posArgs)
if err != nil {
return nil, err
}
return newDriverRows(resp), nil
}
// sqlTx is the database/sql transaction handle; it clears the conn's handle on
// completion so subsequent statements run in autocommit again.
type sqlTx struct{ c *conn }
func (t sqlTx) Commit() error {
err := t.c.t.txCommit(context.Background(), t.c.txHandle)
t.c.txHandle, t.c.inTx = 0, false
return err
}
func (t sqlTx) Rollback() error {
err := t.c.t.txRollback(context.Background(), t.c.txHandle)
t.c.txHandle, t.c.inTx = 0, false
return err
}
type sqlResult struct{ rowsAffected int64 }
func (r sqlResult) LastInsertId() (int64, error) {
return 0, fmt.Errorf("bnwasm: LastInsertId is not supported (Postgres uses RETURNING)")
}
func (r sqlResult) RowsAffected() (int64, error) { return r.rowsAffected, nil }
// driverRows adapts a buffered DbRowsResponse to database/sql/driver.Rows.
type driverRows struct {
resp *abiv1.DbRowsResponse
idx int
}
func newDriverRows(resp *abiv1.DbRowsResponse) *driverRows { return &driverRows{resp: resp, idx: 0} }
func (r *driverRows) Columns() []string { return r.resp.GetColumns() }
func (r *driverRows) Close() error { return nil }
func (r *driverRows) Next(dest []driver.Value) error {
rows := r.resp.GetRows()
if r.idx >= len(rows) {
return io.EOF
}
cells := rows[r.idx].GetValues()
if len(dest) != len(cells) {
return fmt.Errorf("bnwasm: expected %d columns, got %d destinations", len(cells), len(dest))
}
for i, cell := range cells {
dest[i] = driverValue(cell)
}
r.idx++
return nil
}
// namedToPositional flattens database/sql NamedValues to positional args,
// rejecting any that carry a name (sqlc emits positional $N parameters only).
func namedToPositional(args []driver.NamedValue) ([]any, error) {
out := make([]any, len(args))
for i, a := range args {
if a.Name != "" {
return nil, fmt.Errorf("bnwasm: named argument %q is not supported; use positional parameters", a.Name)
}
out[i] = a.Value
}
return out, nil
}