Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a mechanical block/core/X -> block/pluginsdk/X import rewrite. - abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms authoring source (cms/backend/abi/proto/v1) with go_package retargeted to git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept cms/core proto duplication (audit gap 4). - abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1 save the embedded go_package path. - plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim, caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the guest-facing type packages: blocks (+builtin/shared/tags), templates (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai, subscriptions, menus, datasources. Internal imports rewritten core -> pluginsdk; zero block/core references remain. - README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one binding; no replace directives; templates/bn is a synced copy authored in cms. Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
195 lines
7.0 KiB
Go
195 lines
7.0 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/pluginsdk/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()}
|
|
}
|
|
|
|
// ErrTxExpired is returned by a db.* call whose transaction handle the host
|
|
// has already expired — it drops the handle at the call-chain deadline
|
|
// (WO-WZ-007) so a guest can never pin a connection. Unlike a real fault this
|
|
// is retryable: re-run the unit of work in a fresh transaction. The host
|
|
// signals it with ABI_ERROR_CODE_TX_EXPIRED (cms dbexec side, adopted
|
|
// separately); the transport wraps ErrTxExpired around the raw host error so
|
|
// callers can `errors.Is(err, bnwasm.ErrTxExpired)`.
|
|
var ErrTxExpired = errors.New("bnwasm: transaction expired; retry in a new transaction")
|
|
|
|
// abiCoded is satisfied by *wasmguest.HostError (its AbiErrorCode method)
|
|
// without importing that wasip1-only package, so bnwasm can classify a
|
|
// transport error by its ABI code across the sandbox boundary.
|
|
type abiCoded interface {
|
|
AbiErrorCode() abiv1.AbiErrorCode
|
|
}
|
|
|
|
// mapTransportErr translates a transport-level ABI error into a bnwasm
|
|
// sentinel where one exists (TX_EXPIRED → ErrTxExpired), leaving every other
|
|
// error untouched. Applied to the raw t(...) error at each db.* call site.
|
|
func mapTransportErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var coded abiCoded
|
|
if errors.As(err, &coded) && coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED {
|
|
return fmt.Errorf("%w: %v", ErrTxExpired, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
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, mapTransportErr(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{}, mapTransportErr(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, mapTransportErr(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 mapTransportErr(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 mapTransportErr(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()
|
|
}
|