Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
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>
2026-07-07 10:51:00 +08:00

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()
}