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>
208 lines
6.1 KiB
Go
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: ¬es})
|
|
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))
|
|
}
|
|
}
|