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>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package bnwasm
|
|
|
|
import (
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
// toDbValues marshals a positional argument list to wire DbValues. Named args
|
|
// (pgx.NamedArgs, or any pgx.QueryRewriter passed as the sole argument) are
|
|
// rejected: sqlc emits positional parameters, and rewriting a query host-side
|
|
// is out of scope for v1.
|
|
func toDbValues(args []any) ([]*abiv1.DbValue, error) {
|
|
if len(args) == 1 {
|
|
if _, ok := args[0].(pgx.QueryRewriter); ok {
|
|
return nil, fmt.Errorf("bnwasm: named arguments (pgx.QueryRewriter/NamedArgs) are not supported; use positional parameters")
|
|
}
|
|
}
|
|
out := make([]*abiv1.DbValue, len(args))
|
|
for i, a := range args {
|
|
v, err := toDbValue(a)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bnwasm: arg %d: %w", i, err)
|
|
}
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// toDbValue maps one Go query argument to a DbValue. Concrete BlockNinja/pgx
|
|
// types (uuid, time, text[], json) are special-cased so they round-trip to
|
|
// their dedicated DbValue variant; anything implementing driver.Valuer (pgtype
|
|
// scalars, sql.Null*) is normalized through Value(); the rest goes by kind.
|
|
func toDbValue(a any) (*abiv1.DbValue, error) {
|
|
switch v := a.(type) {
|
|
case nil:
|
|
return nullValue(), nil
|
|
case uuid.UUID:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.String()}}, nil
|
|
case uuid.NullUUID:
|
|
if !v.Valid {
|
|
return nullValue(), nil
|
|
}
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.UUID.String()}}, nil
|
|
case time.Time:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(v)}}, nil
|
|
case json.RawMessage:
|
|
if v == nil {
|
|
return nullValue(), nil
|
|
}
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_JsonbValue{JsonbValue: v}}, nil
|
|
case []byte:
|
|
if v == nil {
|
|
return nullValue(), nil
|
|
}
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_BytesValue{BytesValue: v}}, nil
|
|
case []string:
|
|
// nil → NULL (matching []byte/json.RawMessage); an empty-but-non-nil
|
|
// slice stays a non-NULL empty text[].
|
|
if v == nil {
|
|
return nullValue(), nil
|
|
}
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: v}}}, nil
|
|
case []uuid.UUID:
|
|
// uuid[] gets its own variant so the host binds a native uuid[]
|
|
// parameter (queries keep `ANY($1::uuid[])` — no text[]-cast
|
|
// workaround). nil → NULL / empty stays a non-NULL empty uuid[],
|
|
// mirroring the []string convention.
|
|
if v == nil {
|
|
return nullValue(), nil
|
|
}
|
|
strs := make([]string, len(v))
|
|
for i, id := range v {
|
|
strs[i] = id.String()
|
|
}
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: strs}}}, nil
|
|
case string:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: v}}, nil
|
|
case bool:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: v}}, nil
|
|
}
|
|
|
|
// driver.Valuer covers pgtype scalars (Timestamptz, Numeric, Int4, ...) and
|
|
// sql.Null*; recurse on the normalized driver.Value.
|
|
if valuer, ok := a.(driver.Valuer); ok {
|
|
dv, err := valuer.Value()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dv == nil {
|
|
return nullValue(), nil
|
|
}
|
|
return toDbValue(dv)
|
|
}
|
|
|
|
// Fall back to reflection for pointers and the basic numeric kinds.
|
|
rv := reflect.ValueOf(a)
|
|
switch rv.Kind() {
|
|
case reflect.Pointer:
|
|
if rv.IsNil() {
|
|
return nullValue(), nil
|
|
}
|
|
return toDbValue(rv.Elem().Interface())
|
|
case reflect.Bool:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: rv.Bool()}}, nil
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: rv.Int()}}, nil
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: int64(rv.Uint())}}, nil
|
|
case reflect.Float32, reflect.Float64:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_Float64Value{Float64Value: rv.Float()}}, nil
|
|
case reflect.String:
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: rv.String()}}, nil
|
|
}
|
|
return nil, fmt.Errorf("bnwasm: unsupported argument type %T", a)
|
|
}
|
|
|
|
func nullValue() *abiv1.DbValue {
|
|
return &abiv1.DbValue{Kind: &abiv1.DbValue_Null{Null: true}}
|
|
}
|
|
|
|
// naturalValue returns the canonical Go scan value for a DbValue and whether it
|
|
// is SQL NULL. This is the semantic contract WO-WZ-007 (host executor) mirrors:
|
|
//
|
|
// null → (nil, true)
|
|
// bool → (bool, false)
|
|
// int64 → (int64, false)
|
|
// float64 → (float64, false)
|
|
// string → (string, false)
|
|
// bytes → ([]byte, false)
|
|
// timestamp → (time.Time,false)
|
|
// uuid → (string, false) // canonical form; uuid.UUID.Scan accepts it
|
|
// jsonb → ([]byte, false)
|
|
// numeric → (string, false) // lossless decimal string
|
|
// text_array → ([]string, false)
|
|
// uuid_array → ([]string, false) // canonical forms; assign parses into []uuid.UUID
|
|
func naturalValue(dv *abiv1.DbValue) (any, bool) {
|
|
switch k := dv.GetKind().(type) {
|
|
case *abiv1.DbValue_Null:
|
|
return nil, true
|
|
case *abiv1.DbValue_BoolValue:
|
|
return k.BoolValue, false
|
|
case *abiv1.DbValue_Int64Value:
|
|
return k.Int64Value, false
|
|
case *abiv1.DbValue_Float64Value:
|
|
return k.Float64Value, false
|
|
case *abiv1.DbValue_StringValue:
|
|
return k.StringValue, false
|
|
case *abiv1.DbValue_BytesValue:
|
|
return k.BytesValue, false
|
|
case *abiv1.DbValue_TimestampValue:
|
|
return k.TimestampValue.AsTime(), false
|
|
case *abiv1.DbValue_UuidValue:
|
|
return k.UuidValue, false
|
|
case *abiv1.DbValue_JsonbValue:
|
|
return []byte(k.JsonbValue), false
|
|
case *abiv1.DbValue_NumericValue:
|
|
return k.NumericValue, false
|
|
case *abiv1.DbValue_TextArrayValue:
|
|
return append([]string(nil), k.TextArrayValue.GetValues()...), false
|
|
case *abiv1.DbValue_UuidArrayValue:
|
|
// Raw canonical strings, mirroring the single-uuid arm returning a
|
|
// string: assign() parses them into a []uuid.UUID destination (with
|
|
// error propagation), the same way uuid.UUID.Scan parses the scalar.
|
|
return append([]string(nil), k.UuidArrayValue.GetValues()...), false
|
|
default:
|
|
return nil, true
|
|
}
|
|
}
|
|
|
|
// uuidSliceType is the reflect.Type of []uuid.UUID, the canonical scan
|
|
// destination sqlc emits for a uuid[] column (and COALESCE(...::uuid[]) arg).
|
|
var uuidSliceType = reflect.TypeFor[[]uuid.UUID]()
|
|
|
|
// scanValue assigns a DbValue into a destination pointer with pgx-flavored Scan
|
|
// semantics: a nil dest skips the column; a sql.Scanner destination is fed the
|
|
// natural value (including nil for NULL); pointer-to-pointer destinations model
|
|
// nullability (set nil on NULL); everything else is assigned by reflection with
|
|
// integer/float/string/[]byte/[]string coercion.
|
|
func scanValue(dv *abiv1.DbValue, dest any) error {
|
|
if dest == nil {
|
|
return nil // pgx: nil dest skips the value entirely
|
|
}
|
|
nv, isNull := naturalValue(dv)
|
|
|
|
if sc, ok := dest.(sql.Scanner); ok {
|
|
return sc.Scan(nv)
|
|
}
|
|
|
|
rv := reflect.ValueOf(dest)
|
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
|
return fmt.Errorf("bnwasm: scan destination must be a non-nil pointer, got %T", dest)
|
|
}
|
|
elem := rv.Elem()
|
|
|
|
// Pointer-to-pointer (e.g. **string): nullable column mapped to *string.
|
|
if elem.Kind() == reflect.Pointer {
|
|
if isNull {
|
|
elem.Set(reflect.Zero(elem.Type()))
|
|
return nil
|
|
}
|
|
np := reflect.New(elem.Type().Elem())
|
|
if err := assign(np.Elem(), nv); err != nil {
|
|
return err
|
|
}
|
|
elem.Set(np)
|
|
return nil
|
|
}
|
|
|
|
if isNull {
|
|
elem.Set(reflect.Zero(elem.Type()))
|
|
return nil
|
|
}
|
|
return assign(elem, nv)
|
|
}
|
|
|
|
// assign coerces a natural value into a concrete (non-pointer) destination.
|
|
func assign(dst reflect.Value, nv any) error {
|
|
src := reflect.ValueOf(nv)
|
|
|
|
// uuid[] natural value ([]string of canonical UUIDs) → []uuid.UUID: parse
|
|
// each element (mirrors uuid.UUID.Scan for the scalar). reflect can neither
|
|
// assign nor convert []string to []uuid.UUID, so this must be explicit.
|
|
if dst.Type() == uuidSliceType {
|
|
if strs, ok := nv.([]string); ok {
|
|
out := make([]uuid.UUID, len(strs))
|
|
for i, s := range strs {
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return fmt.Errorf("bnwasm: cannot scan %q into uuid at index %d: %w", s, i, err)
|
|
}
|
|
out[i] = id
|
|
}
|
|
dst.Set(reflect.ValueOf(out))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Direct assignability (string→string, time.Time→time.Time, []string→[]string).
|
|
if src.Type().AssignableTo(dst.Type()) {
|
|
dst.Set(src)
|
|
return nil
|
|
}
|
|
// Convertibility (json.RawMessage←[]byte, int64→int32, float64→float32,
|
|
// named string types, []byte→string, ...).
|
|
if src.Type().ConvertibleTo(dst.Type()) {
|
|
// Guard against lossy string<->numeric "conversions" reflect allows for
|
|
// rune/byte-ish types by only converting between compatible kinds.
|
|
if convertibleKinds(src.Kind(), dst.Kind()) {
|
|
dst.Set(src.Convert(dst.Type()))
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("bnwasm: cannot scan %s into %s", src.Type(), dst.Type())
|
|
}
|
|
|
|
func convertibleKinds(src, dst reflect.Kind) bool {
|
|
num := func(k reflect.Kind) bool {
|
|
switch k {
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
|
reflect.Float32, reflect.Float64:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
switch {
|
|
case num(src) && num(dst):
|
|
return true
|
|
case src == reflect.String && dst == reflect.String:
|
|
return true
|
|
case src == reflect.Slice && dst == reflect.Slice: // []byte→json.RawMessage, []string→named
|
|
return true
|
|
case src == reflect.Slice && dst == reflect.String: // []byte→string
|
|
return true
|
|
case src == reflect.String && dst == reflect.Slice: // string→[]byte
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// driverValue maps a DbValue to a database/sql driver.Value (the closed set:
|
|
// nil, int64, float64, bool, []byte, string, time.Time). uuid/numeric surface
|
|
// as strings and text[] as a Postgres array literal string, since driver.Value
|
|
// has no richer representation; callers scan those through the column's
|
|
// sql.Scanner as usual.
|
|
func driverValue(dv *abiv1.DbValue) driver.Value {
|
|
nv, isNull := naturalValue(dv)
|
|
if isNull {
|
|
return nil
|
|
}
|
|
switch v := nv.(type) {
|
|
case []string:
|
|
return encodePgTextArray(v)
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// encodePgTextArray renders a []string as a Postgres text[] literal, e.g.
|
|
// {"a","b,c"}, quoting/escaping every element for lossless reconstruction.
|
|
func encodePgTextArray(vals []string) string {
|
|
var b strings.Builder
|
|
b.WriteByte('{')
|
|
for i, s := range vals {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteByte('"')
|
|
b.WriteString(strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s))
|
|
b.WriteByte('"')
|
|
}
|
|
b.WriteByte('}')
|
|
return b.String()
|
|
}
|