pluginsdk/plugin/wasmguest/bnwasm/sqlcgen_test.go
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

208 lines
6.1 KiB
Go

package bnwasm
import (
"context"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// --- Vendored sqlc output ---------------------------------------------------
//
// The block below is byte-for-byte in the shape `sqlc generate` emits for the
// pgx/v5 sql_package with the same overrides symposium uses (uuid→uuid.UUID,
// jsonb→json.RawMessage, emit_pointers_for_null_types). It is checked in so a
// compile of THIS test is proof that generated plugin code binds to the bnwasm
// Pool/Tx (as its DBTX) and pgxRows/pgxRow (via Scan) with no edits — the same
// DBTX interface, the same pgconn.CommandTag / pgx.Rows / pgx.Row signatures.
type DBTX interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
Query(context.Context, string, ...any) (pgx.Rows, error)
QueryRow(context.Context, string, ...any) pgx.Row
}
func New(db DBTX) *Queries { return &Queries{db: db} }
type Queries struct{ db DBTX }
func (q *Queries) WithTx(tx pgx.Tx) *Queries { return &Queries{db: tx} }
type Widget struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Count int32 `json:"count"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
const createWidget = `-- name: CreateWidget :one
INSERT INTO widgets (name, notes) VALUES ($1, $2) RETURNING id
`
type CreateWidgetParams struct {
Name string `json:"name"`
Notes *string `json:"notes"`
}
func (q *Queries) CreateWidget(ctx context.Context, arg CreateWidgetParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, createWidget, arg.Name, arg.Notes)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getWidget = `-- name: GetWidget :one
SELECT id, name, notes, count, created_at FROM widgets WHERE id = $1
`
func (q *Queries) GetWidget(ctx context.Context, id uuid.UUID) (Widget, error) {
row := q.db.QueryRow(ctx, getWidget, id)
var i Widget
err := row.Scan(&i.ID, &i.Name, &i.Notes, &i.Count, &i.CreatedAt)
return i, err
}
const listWidgets = `-- name: ListWidgets :many
SELECT id, name, notes, count, created_at FROM widgets ORDER BY name
`
func (q *Queries) ListWidgets(ctx context.Context) ([]Widget, error) {
rows, err := q.db.Query(ctx, listWidgets)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Widget
for rows.Next() {
var i Widget
if err := rows.Scan(&i.ID, &i.Name, &i.Notes, &i.Count, &i.CreatedAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteWidget = `-- name: DeleteWidget :exec
DELETE FROM widgets WHERE id = $1
`
func (q *Queries) DeleteWidget(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, deleteWidget, id)
return err
}
// --- Compile-time proof the bnwasm surfaces satisfy the generated interfaces --
var (
_ DBTX = (*Pool)(nil)
_ DBTX = (*Tx)(nil)
_ pgx.Tx = (*Tx)(nil)
)
// --- End-to-end runs against the fake host ----------------------------------
func widgetRow(id uuid.UUID, name string, count int32) *abiv1.DbRow {
return &abiv1.DbRow{Values: []*abiv1.DbValue{
{Kind: &abiv1.DbValue_UuidValue{UuidValue: id.String()}},
{Kind: &abiv1.DbValue_StringValue{StringValue: name}},
{Kind: &abiv1.DbValue_Null{Null: true}}, // notes → NULL → (*string)(nil)
{Kind: &abiv1.DbValue_Int64Value{Int64Value: int64(count)}},
{Kind: &abiv1.DbValue_Null{Null: true}}, // created_at → NULL Timestamptz
}}
}
func TestSqlcGetWidget(t *testing.T) {
id := uuid.New()
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id", "name", "notes", "count", "created_at"},
Rows: []*abiv1.DbRow{widgetRow(id, "gadget", 3)},
}}
q := New(NewPool(host.call))
w, err := q.GetWidget(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if w.ID != id || w.Name != "gadget" || w.Count != 3 {
t.Fatalf("unexpected widget: %+v", w)
}
if w.Notes != nil {
t.Fatalf("notes should be nil for NULL column, got %v", *w.Notes)
}
if w.CreatedAt.Valid {
t.Fatalf("created_at should be invalid for NULL column")
}
}
func TestSqlcListWidgets(t *testing.T) {
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id", "name", "notes", "count", "created_at"},
Rows: []*abiv1.DbRow{
widgetRow(uuid.New(), "alpha", 1),
widgetRow(uuid.New(), "beta", 2),
},
}}
q := New(NewPool(host.call))
items, err := q.ListWidgets(context.Background())
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0].Name != "alpha" || items[1].Name != "beta" {
t.Fatalf("unexpected list: %+v", items)
}
}
// TestSqlcWithTxCreate proves the generated WithTx path binds to the bnwasm Tx
// and that CreateWidget's RETURNING id scans, all inside one transaction with
// the right ordered host calls.
func TestSqlcWithTxCreate(t *testing.T) {
newID := uuid.New()
host := &fakeHost{
txHandle: 42,
queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id"},
Rows: []*abiv1.DbRow{{Values: []*abiv1.DbValue{{Kind: &abiv1.DbValue_UuidValue{UuidValue: newID.String()}}}}},
},
}
pool := NewPool(host.call)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
notes := "hi"
gotID, err := New(pool).WithTx(tx).CreateWidget(ctx, CreateWidgetParams{Name: "w", Notes: &notes})
if err != nil {
t.Fatal(err)
}
if gotID != newID {
t.Fatalf("returning id: want %s got %s", newID, gotID)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
// begin → query(RETURNING) → commit; the query carried the tx handle.
if got := host.methods(); len(got) != 3 || got[0] != methodTxBegin || got[1] != methodQuery || got[2] != methodTxCommit {
t.Fatalf("method sequence: %v", got)
}
if host.calls[1].txHandle != 42 {
t.Fatalf("in-tx query should carry handle 42, got %d", host.calls[1].txHandle)
}
// The NULL-able *string arg marshaled as a real string arg.
if len(host.calls[1].args) != 2 {
t.Fatalf("want 2 args, got %d", len(host.calls[1].args))
}
}