Compare commits
9 Commits
c6305d18c2
...
40ba4ee9de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ba4ee9de | ||
|
|
081bacf2ab | ||
|
|
7881aeee05 | ||
|
|
bec3a43f55 | ||
|
|
5f4fa9db0f | ||
|
|
dc621e6d96 | ||
|
|
9bbc793563 | ||
|
|
c35199f0bc | ||
|
|
704b046727 |
7
Makefile
7
Makefile
@ -20,6 +20,13 @@ install-ninja:
|
||||
proto:
|
||||
buf generate --path proto/orchestrator/v1/plugin_registry.proto
|
||||
|
||||
# Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf
|
||||
# module under abi/ — deliberately not part of the proto/ submodule; see
|
||||
# abi/buf.yaml and docs/wasm-abi.md). Emits Go into abi/v1/.
|
||||
.PHONY: abi
|
||||
abi:
|
||||
cd abi && buf lint && buf generate
|
||||
|
||||
.PHONY: update-sdk
|
||||
update-sdk:
|
||||
@set -e; \
|
||||
|
||||
12
abi/buf.gen.yaml
Normal file
12
abi/buf.gen.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Codegen for the wasm plugin ABI (WO-WZ-001).
|
||||
#
|
||||
# go_package is set explicitly in each .proto (managed mode not needed —
|
||||
# unlike the proto/ submodule, we own these files). With
|
||||
# paths=source_relative and out: ., v1/*.proto emits to abi/v1/*.pb.go,
|
||||
# i.e. Go package git.dev.alexdunmow.com/block/core/abi/v1 (abiv1).
|
||||
version: v2
|
||||
plugins:
|
||||
- local: protoc-gen-go
|
||||
out: .
|
||||
opt:
|
||||
- paths=source_relative
|
||||
21
abi/buf.yaml
Normal file
21
abi/buf.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
# Buf module for the wasm plugin ABI (WO-WZ-001).
|
||||
#
|
||||
# Deliberately separate from the repo-root buf.yaml: that one wraps the
|
||||
# proto/ git submodule (shared block/proto service contracts), while this
|
||||
# ABI is SDK-internal and versions in lockstep with the guest shim, so it
|
||||
# lives repo-local under abi/proto. Run buf from this directory (`make abi`
|
||||
# at the repo root).
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
# The WO/spec fix the schema path at abi/proto/v1/ (module-relative v1/)
|
||||
# while the proto package is abi.v1, so the package↔directory rule
|
||||
# cannot hold without an abi/proto/abi/v1 stutter.
|
||||
- PACKAGE_DIRECTORY_MATCH
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
541
abi/proto/v1/capability.proto
Normal file
541
abi/proto/v1/capability.proto
Normal file
@ -0,0 +1,541 @@
|
||||
// capability.proto — guest→host capability calls (WO-WZ-001).
|
||||
//
|
||||
// Each CoreServices interface (core/plugin/deps.go) becomes a host-function
|
||||
// family; every interface method gets one request/response message pair
|
||||
// here, mirroring the Go signature 1:1. UUIDs travel as canonical strings;
|
||||
// map[string]any / []byte-JSON values travel as JSON bytes.
|
||||
//
|
||||
// Calls cross via the generic HostCallRequest/HostCallResponse envelope; the
|
||||
// method string selects the pair (e.g. "content.get_author_profile" →
|
||||
// ContentGetAuthorProfileRequest/Response). Full method table in
|
||||
// core/docs/wasm-abi.md.
|
||||
//
|
||||
// Not represented here by design:
|
||||
// - CoreServices.Pool → db.proto (the DB driver messages)
|
||||
// - CoreServices.Interceptors → host-side only (never crosses)
|
||||
// - CoreServices.AppURL/MediaPath → LoadRequest.host_config (invoke.proto)
|
||||
// - CoreServices.CoreServiceBindings → manifest core_service_bindings
|
||||
// - RAGService.RegisterContentFetcher → manifest rag_content_fetcher_types
|
||||
// + the RAG_FETCH hook (callback inversion)
|
||||
// - ai.ToolDefinition.Handler → host→guest tool execution is a runtime-WO
|
||||
// concern (flagged in core/docs/wasm-abi.md)
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "v1/invoke.proto";
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// HostCallRequest is the generic guest→host capability envelope.
|
||||
message HostCallRequest {
|
||||
// Capability method, "<family>.<method>" (e.g. "crypto.encrypt_secret").
|
||||
string method = 1;
|
||||
// Serialized family request message.
|
||||
bytes payload = 2;
|
||||
}
|
||||
|
||||
// HostCallResponse is the generic host→guest capability return envelope.
|
||||
message HostCallResponse {
|
||||
// Serialized family response message; empty when error is set.
|
||||
bytes payload = 1;
|
||||
// Set when the capability call failed; the guest SDK surfaces it as a
|
||||
// normal Go error.
|
||||
AbiError error = 2;
|
||||
}
|
||||
|
||||
// --- content.* (content.Content) ---
|
||||
|
||||
message ContentGetAuthorProfileRequest {
|
||||
string id = 1; // UUID
|
||||
}
|
||||
|
||||
message ContentGetAuthorProfileResponse {
|
||||
AuthorProfile author = 1;
|
||||
}
|
||||
|
||||
// AuthorProfile mirrors content.AuthorProfile.
|
||||
message AuthorProfile {
|
||||
string id = 1; // UUID
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
string bio = 4;
|
||||
string avatar_url = 5;
|
||||
string website = 6;
|
||||
map<string, string> social_links = 7;
|
||||
}
|
||||
|
||||
message ContentGetPageRequest {
|
||||
string slug = 1;
|
||||
}
|
||||
|
||||
message ContentGetPageResponse {
|
||||
PageInfo page = 1;
|
||||
}
|
||||
|
||||
// PageInfo mirrors content.PageInfo.
|
||||
message PageInfo {
|
||||
string id = 1; // UUID
|
||||
string slug = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
message ContentGetPostRequest {
|
||||
string slug = 1;
|
||||
}
|
||||
|
||||
message ContentGetPostResponse {
|
||||
PostInfo post = 1;
|
||||
}
|
||||
|
||||
// PostInfo mirrors content.PostInfo.
|
||||
message PostInfo {
|
||||
string id = 1; // UUID
|
||||
string slug = 2;
|
||||
string title = 3;
|
||||
string excerpt = 4;
|
||||
string featured_image_url = 5;
|
||||
string author_id = 6; // UUID
|
||||
}
|
||||
|
||||
message ContentSlugifyRequest {
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
message ContentSlugifyResponse {
|
||||
string slug = 1;
|
||||
}
|
||||
|
||||
message ContentBlockNoteToHtmlRequest {
|
||||
// JSON encoding of the BlockNote document map.
|
||||
bytes doc_json = 1;
|
||||
}
|
||||
|
||||
message ContentBlockNoteToHtmlResponse {
|
||||
string html = 1;
|
||||
}
|
||||
|
||||
message ContentGenerateExcerptRequest {
|
||||
string html = 1;
|
||||
int32 max_len = 2;
|
||||
}
|
||||
|
||||
message ContentGenerateExcerptResponse {
|
||||
string excerpt = 1;
|
||||
}
|
||||
|
||||
message ContentStripHtmlRequest {
|
||||
string html = 1;
|
||||
}
|
||||
|
||||
message ContentStripHtmlResponse {
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
// --- settings.* (settings.Settings) + settings.update (settings.Updater) ---
|
||||
|
||||
message SettingsGetSiteSettingsRequest {}
|
||||
|
||||
message SettingsGetSiteSettingsResponse {
|
||||
// JSON encoding of the site settings map.
|
||||
bytes settings_json = 1;
|
||||
}
|
||||
|
||||
message SettingsGetPluginSettingsRequest {
|
||||
string plugin_name = 1;
|
||||
}
|
||||
|
||||
message SettingsGetPluginSettingsResponse {
|
||||
// JSON encoding of the plugin settings map.
|
||||
bytes settings_json = 1;
|
||||
}
|
||||
|
||||
message SettingsUpdateSiteSettingRequest {
|
||||
string key = 1;
|
||||
// JSON encoding of the value.
|
||||
bytes value_json = 2;
|
||||
}
|
||||
|
||||
message SettingsUpdateSiteSettingResponse {}
|
||||
|
||||
// --- gating.* (gating.Gating) ---
|
||||
|
||||
message GatingGetSubscriberTierLevelRequest {
|
||||
string user_id = 1; // UUID
|
||||
}
|
||||
|
||||
message GatingGetSubscriberTierLevelResponse {
|
||||
int32 level = 1;
|
||||
}
|
||||
|
||||
message GatingEvaluateAccessRequest {
|
||||
int32 user_tier_level = 1;
|
||||
// Absent rule means "no rule" (access granted).
|
||||
AccessRule rule = 2;
|
||||
}
|
||||
|
||||
message GatingEvaluateAccessResponse {
|
||||
AccessResult result = 1;
|
||||
}
|
||||
|
||||
// AccessRule mirrors gating.AccessRule.
|
||||
message AccessRule {
|
||||
int32 min_tier_level = 1;
|
||||
string override_tier_id = 2;
|
||||
string teaser_mode = 3; // "hard", "soft", "none"
|
||||
int32 teaser_percent = 4;
|
||||
}
|
||||
|
||||
// AccessResult mirrors gating.AccessResult.
|
||||
message AccessResult {
|
||||
bool has_access = 1;
|
||||
string teaser_mode = 2;
|
||||
int32 teaser_percent = 3;
|
||||
int32 required_level = 4;
|
||||
}
|
||||
|
||||
// --- crypto.* (crypto.Crypto) ---
|
||||
|
||||
message CryptoEncryptSecretRequest {
|
||||
string plaintext = 1;
|
||||
}
|
||||
|
||||
message CryptoEncryptSecretResponse {
|
||||
string ciphertext = 1;
|
||||
}
|
||||
|
||||
message CryptoDecryptSecretRequest {
|
||||
string ciphertext = 1;
|
||||
}
|
||||
|
||||
message CryptoDecryptSecretResponse {
|
||||
string plaintext = 1;
|
||||
}
|
||||
|
||||
// --- menus.* (menus.Menus) ---
|
||||
|
||||
message MenusGetMenuByNameRequest {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message MenusGetMenuByNameResponse {
|
||||
Menu menu = 1;
|
||||
}
|
||||
|
||||
// Menu mirrors menus.Menu.
|
||||
message Menu {
|
||||
string id = 1; // UUID
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message MenusGetMenuItemsRequest {
|
||||
string menu_id = 1; // UUID
|
||||
}
|
||||
|
||||
message MenusGetMenuItemsResponse {
|
||||
repeated MenuItem items = 1;
|
||||
}
|
||||
|
||||
// MenuItem mirrors menus.MenuItem.
|
||||
message MenuItem {
|
||||
string id = 1; // UUID
|
||||
string menu_id = 2; // UUID
|
||||
string label = 3;
|
||||
string url = 4;
|
||||
string page_slug = 5;
|
||||
optional string parent_id = 6; // UUID; unset mirrors nil *uuid.UUID
|
||||
int32 sort_order = 7;
|
||||
bool open_in_new_tab = 8;
|
||||
string css_class = 9;
|
||||
string item_type = 10;
|
||||
string icon = 11;
|
||||
}
|
||||
|
||||
// --- datasources.* (datasources.Datasources) ---
|
||||
|
||||
message DatasourcesResolveBucketRequest {
|
||||
string bucket_id = 1; // UUID
|
||||
}
|
||||
|
||||
message DatasourcesResolveBucketResponse {
|
||||
DatasourceResult result = 1;
|
||||
}
|
||||
|
||||
message DatasourcesResolveBucketByKeyRequest {
|
||||
string bucket_key = 1;
|
||||
}
|
||||
|
||||
message DatasourcesResolveBucketByKeyResponse {
|
||||
DatasourceResult result = 1;
|
||||
}
|
||||
|
||||
// DatasourceResult mirrors datasources.Result ([]any / map[string]any as
|
||||
// JSON bytes).
|
||||
message DatasourceResult {
|
||||
// JSON array of items.
|
||||
bytes items_json = 1;
|
||||
int32 total = 2;
|
||||
// JSON object; empty when absent.
|
||||
bytes meta_json = 3;
|
||||
}
|
||||
|
||||
// --- users.* (auth.PublicUsers) ---
|
||||
|
||||
message UsersGetByUsernameRequest {
|
||||
string username = 1;
|
||||
}
|
||||
|
||||
message UsersGetByUsernameResponse {
|
||||
PublicUserProfile user = 1;
|
||||
}
|
||||
|
||||
message UsersGetByIdRequest {
|
||||
string id = 1; // UUID
|
||||
}
|
||||
|
||||
message UsersGetByIdResponse {
|
||||
PublicUserProfile user = 1;
|
||||
}
|
||||
|
||||
// PublicUserProfile mirrors auth.PublicUserProfile.
|
||||
message PublicUserProfile {
|
||||
string id = 1; // UUID
|
||||
string email = 2;
|
||||
string username = 3;
|
||||
string display_name = 4;
|
||||
string avatar_url = 5;
|
||||
string bio = 6;
|
||||
bool email_verified = 7;
|
||||
string role = 8;
|
||||
}
|
||||
|
||||
// --- subscriptions.* (subscriptions.Subscriptions) ---
|
||||
|
||||
message SubscriptionsGetUserTierLevelRequest {
|
||||
string user_id = 1; // UUID
|
||||
}
|
||||
|
||||
message SubscriptionsGetUserTierLevelResponse {
|
||||
TierLevel tier_level = 1;
|
||||
}
|
||||
|
||||
// TierLevel mirrors subscriptions.TierLevel.
|
||||
message TierLevel {
|
||||
int32 level = 1;
|
||||
// JSON feature payload.
|
||||
bytes features = 2;
|
||||
}
|
||||
|
||||
message SubscriptionsGetTierBySlugRequest {
|
||||
string slug = 1;
|
||||
}
|
||||
|
||||
message SubscriptionsGetTierBySlugResponse {
|
||||
Tier tier = 1;
|
||||
}
|
||||
|
||||
// Tier mirrors subscriptions.Tier.
|
||||
message Tier {
|
||||
string id = 1; // UUID
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
int32 level = 4;
|
||||
string description = 5;
|
||||
// JSON feature payload.
|
||||
bytes features = 6;
|
||||
bool is_default = 7;
|
||||
int32 position = 8;
|
||||
}
|
||||
|
||||
message SubscriptionsListTiersRequest {}
|
||||
|
||||
message SubscriptionsListTiersResponse {
|
||||
repeated Tier tiers = 1;
|
||||
}
|
||||
|
||||
message SubscriptionsListActivePlansRequest {
|
||||
string tier_id = 1; // UUID
|
||||
}
|
||||
|
||||
message SubscriptionsListActivePlansResponse {
|
||||
repeated Plan plans = 1;
|
||||
}
|
||||
|
||||
// Plan mirrors subscriptions.Plan.
|
||||
message Plan {
|
||||
string id = 1; // UUID
|
||||
string tier_id = 2; // UUID
|
||||
string billing_interval = 3;
|
||||
int32 amount = 4;
|
||||
string currency = 5;
|
||||
bool is_active = 6;
|
||||
google.protobuf.Timestamp created_at = 7;
|
||||
}
|
||||
|
||||
// --- media.deposit (plugin.Media) ---
|
||||
|
||||
// MediaDepositRequest mirrors plugin.MediaDeposit.
|
||||
message MediaDepositRequest {
|
||||
// Optional deterministic media row ID (UUID); empty = host generates one.
|
||||
string id = 1;
|
||||
string filename = 2;
|
||||
bytes data = 3;
|
||||
string alt_text = 4;
|
||||
string folder = 5;
|
||||
string source = 6;
|
||||
}
|
||||
|
||||
// MediaDepositResponse mirrors plugin.MediaResult.
|
||||
message MediaDepositResponse {
|
||||
string id = 1; // UUID
|
||||
// Ready-to-use reference ("media:<uuid>").
|
||||
string ref = 2;
|
||||
bool created = 3;
|
||||
}
|
||||
|
||||
// --- email.send (plugin.EmailSender) ---
|
||||
|
||||
message EmailSendRequest {
|
||||
string to = 1;
|
||||
string subject = 2;
|
||||
string body = 3;
|
||||
}
|
||||
|
||||
message EmailSendResponse {}
|
||||
|
||||
// --- ai.text_call (CoreServices.AITextCall) ---
|
||||
|
||||
message AiTextCallRequest {
|
||||
string task_key = 1;
|
||||
string system_prompt = 2;
|
||||
string user_message = 3;
|
||||
}
|
||||
|
||||
message AiTextCallResponse {
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
// --- ai.tools.* (ai.ToolRegistry) ---
|
||||
|
||||
// AiToolRegisterRequest mirrors ai.ToolDefinition (minus Handler, which
|
||||
// stays guest-side; execution direction is a runtime-WO concern).
|
||||
message AiToolRegisterRequest {
|
||||
string slug = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
// JSON encoding of the parameter schema map.
|
||||
bytes parameter_schema_json = 4;
|
||||
}
|
||||
|
||||
message AiToolRegisterResponse {}
|
||||
|
||||
// --- bridge.* (plugin.PluginBridge) ---
|
||||
//
|
||||
// The bridge shares in-process Go values today; across sandboxes only the
|
||||
// registration/lookup surface serializes. Typed cross-plugin calls need a
|
||||
// runtime design (see core/docs/wasm-abi.md, open items).
|
||||
|
||||
message BridgeRegisterServiceRequest {
|
||||
string plugin_name = 1;
|
||||
string service_name = 2;
|
||||
}
|
||||
|
||||
message BridgeRegisterServiceResponse {}
|
||||
|
||||
message BridgeGetServiceRequest {
|
||||
string plugin_name = 1;
|
||||
string service_name = 2;
|
||||
}
|
||||
|
||||
message BridgeGetServiceResponse {
|
||||
bool available = 1;
|
||||
}
|
||||
|
||||
// --- jobs.submit (plugin.JobRunner) ---
|
||||
|
||||
message JobsSubmitRequest {
|
||||
string job_type = 1;
|
||||
// JSON job configuration.
|
||||
bytes config_json = 2;
|
||||
}
|
||||
|
||||
message JobsSubmitResponse {}
|
||||
|
||||
// --- embeddings.* (plugin.EmbeddingService) ---
|
||||
|
||||
message EmbeddingsGenerateEmbeddingRequest {
|
||||
string text = 1;
|
||||
}
|
||||
|
||||
message EmbeddingsGenerateEmbeddingResponse {
|
||||
repeated float embedding = 1;
|
||||
}
|
||||
|
||||
message EmbeddingsEmbedContentRequest {
|
||||
string source_type = 1;
|
||||
string source_id = 2; // UUID
|
||||
string text = 3;
|
||||
}
|
||||
|
||||
message EmbeddingsEmbedContentResponse {
|
||||
bool embedded = 1;
|
||||
}
|
||||
|
||||
message EmbeddingsIsAvailableRequest {}
|
||||
|
||||
message EmbeddingsIsAvailableResponse {
|
||||
bool available = 1;
|
||||
}
|
||||
|
||||
// --- rag.* (plugin.RAGService) ---
|
||||
|
||||
message RagQueryRequest {
|
||||
string query = 1;
|
||||
int32 limit = 2;
|
||||
}
|
||||
|
||||
message RagQueryResponse {
|
||||
repeated RagResult results = 1;
|
||||
}
|
||||
|
||||
// RagResult mirrors plugin.RAGResult.
|
||||
message RagResult {
|
||||
string content = 1;
|
||||
double score = 2;
|
||||
map<string, string> metadata = 3;
|
||||
}
|
||||
|
||||
message RagOnContentChangedRequest {
|
||||
string content_type = 1;
|
||||
string content_id = 2; // UUID
|
||||
}
|
||||
|
||||
message RagOnContentChangedResponse {}
|
||||
|
||||
// --- reviews.* (plugin.ReviewSubmitter) ---
|
||||
|
||||
// ReviewsSubmitReviewRequest mirrors plugin.SubmitReviewParams.
|
||||
message ReviewsSubmitReviewRequest {
|
||||
string table_id = 1; // UUID
|
||||
string row_id = 2; // UUID
|
||||
int32 overall_rating = 3;
|
||||
string review_text = 4;
|
||||
// JSON encoding of the per-criterion ratings map.
|
||||
bytes ratings_json = 5;
|
||||
repeated string photos = 6;
|
||||
}
|
||||
|
||||
message ReviewsSubmitReviewResponse {
|
||||
string review_id = 1;
|
||||
}
|
||||
|
||||
// --- badges.refresh (plugin.BadgeRefresher) ---
|
||||
|
||||
message BadgesRefreshBadgesRequest {
|
||||
string table_id = 1; // UUID
|
||||
string row_id = 2; // UUID
|
||||
}
|
||||
|
||||
message BadgesRefreshBadgesResponse {}
|
||||
108
abi/proto/v1/db.proto
Normal file
108
abi/proto/v1/db.proto
Normal file
@ -0,0 +1,108 @@
|
||||
// db.proto — DB driver messages (WO-WZ-001).
|
||||
//
|
||||
// The guest SDK ships a database/sql driver that marshals query text + args
|
||||
// out through these messages and rows back, so sqlc-generated plugin code
|
||||
// works unchanged. The host executes on a connection under the per-plugin
|
||||
// Postgres role. Transactions map to a host-side handle held per guest call
|
||||
// chain, with a hard deadline so a guest can never pin a connection.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// DbValue is one pgx-mappable parameter or column value.
|
||||
message DbValue {
|
||||
oneof kind {
|
||||
// SQL NULL (the bool carries no information; true by convention).
|
||||
bool null = 1;
|
||||
bool bool_value = 2;
|
||||
int64 int64_value = 3;
|
||||
double float64_value = 4;
|
||||
string string_value = 5;
|
||||
bytes bytes_value = 6;
|
||||
google.protobuf.Timestamp timestamp_value = 7;
|
||||
// UUID in canonical string form.
|
||||
string uuid_value = 8;
|
||||
// JSON/JSONB payload bytes.
|
||||
bytes jsonb_value = 9;
|
||||
// NUMERIC in decimal string form (lossless).
|
||||
string numeric_value = 10;
|
||||
// text[] array.
|
||||
TextArray text_array_value = 11;
|
||||
}
|
||||
}
|
||||
|
||||
// TextArray is a Postgres text[] value.
|
||||
message TextArray {
|
||||
repeated string values = 1;
|
||||
}
|
||||
|
||||
// DbRow is one result row; values align with DbRowsResponse.columns.
|
||||
message DbRow {
|
||||
repeated DbValue values = 1;
|
||||
}
|
||||
|
||||
// DbError carries a database failure back to the guest driver.
|
||||
message DbError {
|
||||
// Postgres SQLSTATE when available (e.g. "23505"); empty otherwise.
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// DbQueryRequest executes a rows-returning statement.
|
||||
message DbQueryRequest {
|
||||
string sql = 1;
|
||||
repeated DbValue args = 2;
|
||||
// Transaction handle from DbTxBeginResponse; 0 = no transaction
|
||||
// (autocommit).
|
||||
uint64 tx_handle = 3;
|
||||
}
|
||||
|
||||
// DbRowsResponse returns the full buffered result set.
|
||||
message DbRowsResponse {
|
||||
repeated string columns = 1;
|
||||
repeated DbRow rows = 2;
|
||||
DbError error = 3;
|
||||
}
|
||||
|
||||
// DbExecRequest executes a statement without returning rows.
|
||||
message DbExecRequest {
|
||||
string sql = 1;
|
||||
repeated DbValue args = 2;
|
||||
// Transaction handle from DbTxBeginResponse; 0 = no transaction.
|
||||
uint64 tx_handle = 3;
|
||||
}
|
||||
|
||||
message DbExecResponse {
|
||||
int64 rows_affected = 1;
|
||||
DbError error = 2;
|
||||
}
|
||||
|
||||
// DbTxBeginRequest opens a host-side transaction for this call chain.
|
||||
message DbTxBeginRequest {}
|
||||
|
||||
message DbTxBeginResponse {
|
||||
// Opaque handle referencing the host-side transaction; never 0 on success.
|
||||
uint64 tx_handle = 1;
|
||||
DbError error = 2;
|
||||
}
|
||||
|
||||
message DbTxCommitRequest {
|
||||
uint64 tx_handle = 1;
|
||||
}
|
||||
|
||||
message DbTxCommitResponse {
|
||||
DbError error = 1;
|
||||
}
|
||||
|
||||
message DbTxRollbackRequest {
|
||||
uint64 tx_handle = 1;
|
||||
}
|
||||
|
||||
message DbTxRollbackResponse {
|
||||
DbError error = 1;
|
||||
}
|
||||
37
abi/proto/v1/http.proto
Normal file
37
abi/proto/v1/http.proto
Normal file
@ -0,0 +1,37 @@
|
||||
// http.proto — buffered HTTP request/response payloads for the HANDLE_HTTP
|
||||
// hook (WO-WZ-001).
|
||||
//
|
||||
// v1 buffers full bodies: no streaming, SSE, or WebSockets inside plugins
|
||||
// (per the wasm migration design spec §5). The guest runs its real
|
||||
// chi/connect mux internally and answers one HttpRequest with one
|
||||
// HttpResponse.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// HttpRequest is a fully buffered HTTP request forwarded to the guest mux.
|
||||
message HttpRequest {
|
||||
string method = 1;
|
||||
// Request path (no scheme/host/query).
|
||||
string path = 2;
|
||||
// Raw query string (without the leading '?').
|
||||
string raw_query = 3;
|
||||
// Canonical header name → values.
|
||||
map<string, HeaderValues> headers = 4;
|
||||
bytes body = 5;
|
||||
}
|
||||
|
||||
// HeaderValues holds the values of one multi-valued HTTP header.
|
||||
message HeaderValues {
|
||||
repeated string values = 1;
|
||||
}
|
||||
|
||||
// HttpResponse is the guest's fully buffered response.
|
||||
message HttpResponse {
|
||||
int32 status = 1;
|
||||
map<string, HeaderValues> headers = 2;
|
||||
bytes body = 3;
|
||||
}
|
||||
189
abi/proto/v1/invoke.proto
Normal file
189
abi/proto/v1/invoke.proto
Normal file
@ -0,0 +1,189 @@
|
||||
// invoke.proto — the bn_invoke envelope, hook catalog, and the job/lifecycle
|
||||
// hook payloads (WO-WZ-001).
|
||||
//
|
||||
// Every host→guest call crosses the wasm boundary as one guest export:
|
||||
//
|
||||
// bn_invoke(hook_id, ptr, len) → packed(ptr, len)
|
||||
//
|
||||
// where (ptr, len) frames a serialized InvokeRequest and the packed return
|
||||
// frames a serialized InvokeResponse. Hook-specific payloads (render.proto,
|
||||
// http.proto, and the messages below) travel inside InvokeRequest.payload /
|
||||
// InvokeResponse.payload. See core/docs/wasm-abi.md.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
import "v1/manifest.proto";
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// Hook identifies the guest entry point being invoked.
|
||||
enum Hook {
|
||||
HOOK_UNSPECIFIED = 0;
|
||||
// Render one block: payload = RenderBlockRequest / RenderBlockResponse.
|
||||
HOOK_RENDER_BLOCK = 1;
|
||||
// Render one template: payload = RenderTemplateRequest / RenderTemplateResponse.
|
||||
HOOK_RENDER_TEMPLATE = 2;
|
||||
// Forward a buffered HTTP request to the guest's mux:
|
||||
// payload = HttpRequest / HttpResponse.
|
||||
HOOK_HANDLE_HTTP = 3;
|
||||
// Run a background job handler: payload = JobRequest / JobResponse.
|
||||
HOOK_JOB = 4;
|
||||
// Plugin load lifecycle: payload = LoadRequest / LoadResponse.
|
||||
HOOK_LOAD = 5;
|
||||
// Plugin unload lifecycle: payload = UnloadRequest / UnloadResponse.
|
||||
HOOK_UNLOAD = 6;
|
||||
// Re-fetch content for RAG re-indexing:
|
||||
// payload = RagFetchRequest / RagFetchResponse.
|
||||
HOOK_RAG_FETCH = 7;
|
||||
// Media lifecycle event delivery: payload = MediaHookRequest / MediaHookResponse.
|
||||
HOOK_MEDIA_HOOK = 8;
|
||||
// Capture the static manifest at publish time:
|
||||
// payload = DescribeRequest / DescribeResponse.
|
||||
HOOK_DESCRIBE = 9;
|
||||
}
|
||||
|
||||
// InvokeRequest is the host→guest call envelope.
|
||||
message InvokeRequest {
|
||||
Hook hook = 1;
|
||||
// Serialized hook-specific request message (see Hook value comments).
|
||||
bytes payload = 2;
|
||||
// Milliseconds the guest has to answer; the host also enforces this
|
||||
// deadline on the wasm instance (WithCloseOnContextDone).
|
||||
int64 deadline_ms = 3;
|
||||
}
|
||||
|
||||
// InvokeResponse is the guest→host return envelope.
|
||||
message InvokeResponse {
|
||||
// Serialized hook-specific response message; empty when error is set.
|
||||
bytes payload = 1;
|
||||
// Set when the hook failed; the host treats decode failures and traps as
|
||||
// implicit ABI_ERROR_CODE_INTERNAL.
|
||||
AbiError error = 2;
|
||||
}
|
||||
|
||||
// AbiErrorCode classifies boundary-crossing failures.
|
||||
enum AbiErrorCode {
|
||||
ABI_ERROR_CODE_UNSPECIFIED = 0;
|
||||
// The handler ran and failed; message carries the Go error text.
|
||||
ABI_ERROR_CODE_INTERNAL = 1;
|
||||
// The payload could not be decoded.
|
||||
ABI_ERROR_CODE_DECODE = 2;
|
||||
// The hook/capability is not implemented by the callee.
|
||||
ABI_ERROR_CODE_UNIMPLEMENTED = 3;
|
||||
// The call exceeded its deadline; the instance is considered poisoned.
|
||||
ABI_ERROR_CODE_DEADLINE_EXCEEDED = 4;
|
||||
// The caller is not entitled to this capability.
|
||||
ABI_ERROR_CODE_PERMISSION_DENIED = 5;
|
||||
}
|
||||
|
||||
// AbiError is the structured error carried by InvokeResponse and
|
||||
// HostCallResponse.
|
||||
message AbiError {
|
||||
AbiErrorCode code = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// --- DESCRIBE ---
|
||||
|
||||
// DescribeRequest asks the guest for its static manifest (publish time only).
|
||||
message DescribeRequest {
|
||||
// ABI major version of the calling host/publish tool.
|
||||
uint32 host_abi_version = 1;
|
||||
}
|
||||
|
||||
// DescribeResponse returns the static manifest.
|
||||
message DescribeResponse {
|
||||
PluginManifest manifest = 1;
|
||||
}
|
||||
|
||||
// --- LOAD / UNLOAD ---
|
||||
|
||||
// LoadRequest carries the static host configuration the .so world exposed as
|
||||
// CoreServices.AppURL / CoreServices.MediaPath.
|
||||
message LoadRequest {
|
||||
HostConfig host_config = 1;
|
||||
}
|
||||
|
||||
// HostConfig mirrors the plain-value CoreServices fields.
|
||||
message HostConfig {
|
||||
string app_url = 1; // CoreServices.AppURL
|
||||
string media_path = 2; // CoreServices.MediaPath
|
||||
}
|
||||
|
||||
message LoadResponse {}
|
||||
|
||||
message UnloadRequest {}
|
||||
|
||||
message UnloadResponse {}
|
||||
|
||||
// --- JOB ---
|
||||
|
||||
// JobRequest dispatches one background job to the guest handler registered
|
||||
// for job_type (manifest.job_types).
|
||||
message JobRequest {
|
||||
string job_type = 1;
|
||||
// JSON job configuration (json.RawMessage in JobHandlerFunc).
|
||||
bytes config_json = 2;
|
||||
}
|
||||
|
||||
// JobResponse returns the handler's JSON result.
|
||||
message JobResponse {
|
||||
bytes result_json = 1;
|
||||
}
|
||||
|
||||
// --- RAG_FETCH ---
|
||||
|
||||
// RagFetchRequest asks the guest's registered content fetcher
|
||||
// (manifest.rag_content_fetcher_types) for a content item's text.
|
||||
message RagFetchRequest {
|
||||
string content_type = 1;
|
||||
string content_id = 2; // UUID
|
||||
}
|
||||
|
||||
// RagFetchResponse mirrors plugin.ContentFetcher's return values.
|
||||
message RagFetchResponse {
|
||||
string title = 1;
|
||||
string text = 2;
|
||||
}
|
||||
|
||||
// --- MEDIA_HOOK ---
|
||||
|
||||
// MediaHookRequest delivers one media lifecycle event
|
||||
// (plugin.MediaHooksProvider).
|
||||
message MediaHookRequest {
|
||||
oneof event {
|
||||
MediaAnalyzedEvent media_analyzed = 1; // OnMediaAnalyzed
|
||||
ModerationDecisionEvent moderation_decision = 2; // OnModerationDecision
|
||||
}
|
||||
}
|
||||
|
||||
message MediaHookResponse {}
|
||||
|
||||
// MediaAnalyzedEvent mirrors plugin.MediaAnalyzedEvent (UUIDs as strings).
|
||||
message MediaAnalyzedEvent {
|
||||
string media_id = 1;
|
||||
string analysis_id = 2;
|
||||
string content_hash = 3;
|
||||
string status = 4;
|
||||
string source_plugin = 5;
|
||||
string source_type = 6;
|
||||
string source_ref_id = 7;
|
||||
string safe_adult = 8;
|
||||
string safe_violence = 9;
|
||||
string safe_racy = 10;
|
||||
}
|
||||
|
||||
// ModerationDecisionEvent mirrors plugin.ModerationDecisionEvent.
|
||||
message ModerationDecisionEvent {
|
||||
string media_id = 1;
|
||||
string analysis_id = 2;
|
||||
string status = 3;
|
||||
string previous_status = 4;
|
||||
string source_plugin = 5;
|
||||
string source_type = 6;
|
||||
string source_ref_id = 7;
|
||||
string moderated_by = 8;
|
||||
string note = 9;
|
||||
}
|
||||
215
abi/proto/v1/manifest.proto
Normal file
215
abi/proto/v1/manifest.proto
Normal file
@ -0,0 +1,215 @@
|
||||
// manifest.proto — the static plugin manifest (WO-WZ-001).
|
||||
//
|
||||
// PluginManifest is the wire form of everything that is *static data* in
|
||||
// core/plugin/registration.go (PluginRegistration). It is produced once at
|
||||
// publish time by calling the guest's DESCRIBE hook and stored as manifest.pb
|
||||
// inside the .bnp artifact; the CMS loader reads it without instantiating the
|
||||
// wasm module.
|
||||
//
|
||||
// Function-valued registration fields cannot cross the wire; they map to
|
||||
// either a hook (Load/Unload/JobHandlers/MediaHooks/HTTPHandler), a boolean
|
||||
// presence flag here, or an artifact directory (Assets → assets/,
|
||||
// Schemas → schemas/, Migrations → migrations/). See core/docs/wasm-abi.md
|
||||
// for the full field-by-field mapping.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// PluginManifest mirrors the static surface of plugin.PluginRegistration.
|
||||
message PluginManifest {
|
||||
// ABI major version the plugin was built against. The host rejects
|
||||
// manifests whose major version it does not support.
|
||||
uint32 abi_version = 1;
|
||||
|
||||
// PluginRegistration.Name / .Version.
|
||||
string name = 2;
|
||||
string version = 3;
|
||||
|
||||
// PluginRegistration.Dependencies.
|
||||
repeated Dependency dependencies = 4;
|
||||
|
||||
// Blocks registered via BlockRegistry.Register (captured by DESCRIBE).
|
||||
repeated BlockMeta blocks = 5;
|
||||
// Blocks registered via BlockRegistry.RegisterTemplateOverride[WithSource].
|
||||
repeated BlockTemplateOverride block_template_overrides = 6;
|
||||
|
||||
// Templates registered via TemplateRegistry (captured by DESCRIBE).
|
||||
repeated string template_keys = 7; // TemplateRegistry.Register
|
||||
repeated SystemTemplateMeta system_templates = 8; // RegisterSystemTemplate
|
||||
repeated PageTemplateMeta page_templates = 9; // RegisterPageTemplate
|
||||
repeated string email_wrapper_system_keys = 10; // RegisterEmailWrapper
|
||||
|
||||
// PluginRegistration.AdminPages.
|
||||
repeated AdminPage admin_pages = 11;
|
||||
|
||||
// PluginRegistration.SettingsSchema / .ThemePresets / .BundledFonts (JSON).
|
||||
bytes settings_schema = 12;
|
||||
bytes theme_presets = 13;
|
||||
bytes bundled_fonts = 14;
|
||||
|
||||
// PluginRegistration.MasterPages.
|
||||
repeated MasterPageDefinition master_pages = 15;
|
||||
|
||||
// PluginRegistration.AIActions.
|
||||
repeated AiAction ai_actions = 16;
|
||||
|
||||
// RBAC roles for the plugin's own Connect services, merged from
|
||||
// ServiceRegistration (full method name → role, e.g.
|
||||
// "/symposium.v1.ForumService/CreateThread" → "admin").
|
||||
map<string, string> rbac_method_roles = 17;
|
||||
|
||||
// PluginRegistration.CSSManifest.
|
||||
CssManifest css_manifest = 18;
|
||||
|
||||
// PluginRegistration.RequiredIconPacks.
|
||||
repeated string required_icon_packs = 19;
|
||||
|
||||
// PluginRegistration.DirectoryExtensions (static fields only; the
|
||||
// panel-section / pin-decorator callbacks are counted so the host knows
|
||||
// how many guest callbacks exist).
|
||||
DirectoryExtensions directory_extensions = 20;
|
||||
|
||||
// Job types the plugin handles (keys of PluginRegistration.JobHandlers).
|
||||
// The host dispatches these via the JOB hook.
|
||||
repeated string job_types = 21;
|
||||
|
||||
// Content types the plugin registered RAG content fetchers for
|
||||
// (RAGService.RegisterContentFetcher inverts to a manifest declaration;
|
||||
// the host calls back via the RAG_FETCH hook).
|
||||
repeated string rag_content_fetcher_types = 22;
|
||||
|
||||
// PluginRegistration.SettingsPanel — Module Federation path of the
|
||||
// settings panel component ("" when the plugin has none).
|
||||
string settings_panel = 23;
|
||||
|
||||
// Presence flags for function-valued registration fields that invert to
|
||||
// hooks at runtime.
|
||||
bool has_http_handler = 24; // PluginRegistration.HTTPHandler → HANDLE_HTTP
|
||||
bool has_load_hook = 25; // PluginRegistration.Load → LOAD
|
||||
bool has_unload_hook = 26; // PluginRegistration.Unload → UNLOAD
|
||||
bool has_media_hooks = 27; // PluginRegistration.MediaHooks → MEDIA_HOOK
|
||||
bool has_provisioner = 28; // PluginRegistration.RegisterWithProvisioner
|
||||
|
||||
// Core CMS services the plugin mounts with custom RBAC roles
|
||||
// (CoreServiceBindings.Bind becomes a static declaration; the host
|
||||
// constructs and mounts the handlers).
|
||||
repeated CoreServiceBinding core_service_bindings = 29;
|
||||
}
|
||||
|
||||
// Dependency mirrors plugin.Dependency.
|
||||
message Dependency {
|
||||
string plugin = 1;
|
||||
string min_version = 2;
|
||||
bool required = 3;
|
||||
}
|
||||
|
||||
// BlockMeta mirrors blocks.BlockMeta. Source is omitted: the host assigns it
|
||||
// from the manifest's plugin name at load time.
|
||||
message BlockMeta {
|
||||
string key = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
// blocks.BlockCategory string ("content", "layout", "navigation", "blog",
|
||||
// "theme").
|
||||
string category = 4;
|
||||
bool has_internal_slot = 5;
|
||||
bool hidden = 6;
|
||||
string editor_js = 7;
|
||||
}
|
||||
|
||||
// BlockTemplateOverride captures BlockRegistry.RegisterTemplateOverride and
|
||||
// RegisterTemplateOverrideWithSource registrations.
|
||||
message BlockTemplateOverride {
|
||||
string template_key = 1;
|
||||
string block_key = 2;
|
||||
// Override source label; empty for plain RegisterTemplateOverride.
|
||||
string source = 3;
|
||||
}
|
||||
|
||||
// SystemTemplateMeta mirrors templates.SystemTemplateMeta.
|
||||
message SystemTemplateMeta {
|
||||
string key = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
}
|
||||
|
||||
// PageTemplateMeta mirrors templates.PageTemplateMeta plus the system
|
||||
// template it was registered under.
|
||||
message PageTemplateMeta {
|
||||
string system_key = 1;
|
||||
string key = 2;
|
||||
string title = 3;
|
||||
string description = 4;
|
||||
repeated string slots = 5;
|
||||
}
|
||||
|
||||
// AdminPage mirrors plugin.AdminPage.
|
||||
message AdminPage {
|
||||
string key = 1;
|
||||
string title = 2;
|
||||
string icon = 3;
|
||||
string route = 4;
|
||||
}
|
||||
|
||||
// AiAction mirrors plugin.AIAction.
|
||||
message AiAction {
|
||||
string key = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string default_provider = 4;
|
||||
string default_model = 5;
|
||||
}
|
||||
|
||||
// CssManifest mirrors plugin.CSSManifest.
|
||||
message CssManifest {
|
||||
map<string, string> npm_packages = 1;
|
||||
repeated string css_directives = 2;
|
||||
string input_css_append = 3;
|
||||
}
|
||||
|
||||
// DirectoryExtensions mirrors the static fields of plugin.DirectoryExtensions.
|
||||
message DirectoryExtensions {
|
||||
repeated string boolean_filter_fields = 1;
|
||||
repeated string select_filter_fields = 2;
|
||||
map<string, BadgeLabel> badge_labels = 3;
|
||||
// Counts of the callback slices (PanelSections / PinDecorators) so the host
|
||||
// knows how many guest callbacks the plugin registered. Their invocation
|
||||
// hook is a runtime-WO concern.
|
||||
uint32 panel_section_count = 4;
|
||||
uint32 pin_decorator_count = 5;
|
||||
}
|
||||
|
||||
// BadgeLabel mirrors the [2]string value of DirectoryExtensions.BadgeLabels.
|
||||
message BadgeLabel {
|
||||
string positive = 1;
|
||||
string negative = 2;
|
||||
}
|
||||
|
||||
// MasterPageDefinition mirrors plugin.MasterPageDefinition.
|
||||
message MasterPageDefinition {
|
||||
string key = 1;
|
||||
string title = 2;
|
||||
repeated string page_templates = 3;
|
||||
repeated MasterPageBlock blocks = 4;
|
||||
}
|
||||
|
||||
// MasterPageBlock mirrors plugin.MasterPageBlock.
|
||||
message MasterPageBlock {
|
||||
string block_key = 1;
|
||||
string title = 2;
|
||||
// JSON encoding of the block's content map.
|
||||
bytes content_json = 3;
|
||||
optional string html_content = 4;
|
||||
string slot = 5;
|
||||
int32 sort_order = 6;
|
||||
}
|
||||
|
||||
// CoreServiceBinding mirrors a plugin.CoreServiceBindings.Bind call: mount
|
||||
// the named core-provided Connect service with these RBAC roles.
|
||||
message CoreServiceBinding {
|
||||
string service_name = 1;
|
||||
map<string, string> method_roles = 2;
|
||||
}
|
||||
200
abi/proto/v1/render.proto
Normal file
200
abi/proto/v1/render.proto
Normal file
@ -0,0 +1,200 @@
|
||||
// render.proto — block/template render payloads and the explicit
|
||||
// render-context envelope (WO-WZ-001).
|
||||
//
|
||||
// RenderContext serializes every value blocks currently read from ctx via
|
||||
// core/blocks/context.go. Function-valued context entries (SlotRenderer,
|
||||
// MediaResolver, EmbedResolver, the generic Queries value) cannot cross the
|
||||
// wire; see core/docs/wasm-abi.md for how each one maps.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package abi.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
|
||||
|
||||
// RenderBlockRequest renders one block (blocks.BlockFunc).
|
||||
message RenderBlockRequest {
|
||||
string block_key = 1;
|
||||
// JSON encoding of the block's content map.
|
||||
bytes content_json = 2;
|
||||
RenderContext render_context = 3;
|
||||
}
|
||||
|
||||
message RenderBlockResponse {
|
||||
string html = 1;
|
||||
}
|
||||
|
||||
// RenderTemplateRequest renders one template (templates.TemplateFunc).
|
||||
message RenderTemplateRequest {
|
||||
string template_key = 1;
|
||||
// JSON encoding of the template's doc map (the page document).
|
||||
bytes doc_json = 2;
|
||||
RenderContext render_context = 3;
|
||||
}
|
||||
|
||||
message RenderTemplateResponse {
|
||||
bytes html = 1;
|
||||
}
|
||||
|
||||
// RenderContext is the explicit envelope of the context values enumerated
|
||||
// from core/blocks/context.go. Absent sub-messages mean the corresponding
|
||||
// ctx value was not set (the getters return nil / zero values).
|
||||
message RenderContext {
|
||||
// blocks.GetRequest — subset of *http.Request that render code reads.
|
||||
RequestInfo request = 1;
|
||||
// blocks.GetBlockContext — the pongo2 template data struct.
|
||||
BlockContext block_context = 2;
|
||||
// blocks.GetTemplateKey.
|
||||
string template_key = 3;
|
||||
// blocks.GetCurrentPage.
|
||||
PageContext page = 4;
|
||||
// blocks.GetCurrentBlogPost.
|
||||
PostContext post = 5;
|
||||
// blocks.GetCurrentAuthor.
|
||||
AuthorContext author = 6;
|
||||
// blocks.GetCurrentCategory.
|
||||
CategoryContext category = 7;
|
||||
// blocks.GetMasterPage; presence doubles as IsMasterPageContext.
|
||||
MasterPageContext master_page = 8;
|
||||
// blocks.GetRequestedPath (404 pages).
|
||||
string requested_path = 9;
|
||||
// blocks.GetInjectedSlots (master page rendering).
|
||||
map<string, string> injected_slots = 10;
|
||||
// blocks.IsEditor.
|
||||
bool is_editor = 11;
|
||||
// blocks.GetExpectedSlots.
|
||||
repeated string expected_slots = 12;
|
||||
// blocks.GetBlockID (UUID; empty for uuid.Nil).
|
||||
string block_id = 13;
|
||||
// blocks.GetCurrentPageID (UUID; empty for uuid.Nil).
|
||||
string current_page_id = 14;
|
||||
// blocks.GetHumanProofBanner.
|
||||
HumanProofBanner human_proof_banner = 15;
|
||||
// blocks.GetDetailRow.
|
||||
DetailRow detail_row = 16;
|
||||
// Active theme variables, JSON-encoded.
|
||||
bytes theme_json = 17;
|
||||
// Site locale (BCP 47).
|
||||
string locale = 18;
|
||||
}
|
||||
|
||||
// RequestInfo carries the request subset render code reads from
|
||||
// blocks.GetRequest (single-valued header map, matching BlockContext).
|
||||
message RequestInfo {
|
||||
string method = 1;
|
||||
string url = 2;
|
||||
string path = 3;
|
||||
string host = 4;
|
||||
string raw_query = 5;
|
||||
map<string, string> headers = 6;
|
||||
map<string, string> cookies = 7;
|
||||
string remote_ip = 8;
|
||||
string referrer = 9;
|
||||
string user_agent = 10;
|
||||
}
|
||||
|
||||
// PageContext mirrors blocks.PageContext.
|
||||
message PageContext {
|
||||
string id = 1; // UUID
|
||||
string slug = 2;
|
||||
string title = 3;
|
||||
string post_type = 4; // "page", "post", "master", "system"
|
||||
string status = 5; // "published", "draft", "scheduled"
|
||||
}
|
||||
|
||||
// PostContext mirrors blocks.PostContext.
|
||||
message PostContext {
|
||||
string id = 1; // UUID
|
||||
string slug = 2;
|
||||
string title = 3;
|
||||
string excerpt = 4;
|
||||
string featured_image_url = 5;
|
||||
string author_id = 6; // UUID
|
||||
google.protobuf.Timestamp published_at = 7;
|
||||
int32 reading_time = 8;
|
||||
bool is_featured = 9;
|
||||
}
|
||||
|
||||
// AuthorContext mirrors blocks.AuthorContext.
|
||||
message AuthorContext {
|
||||
string id = 1; // UUID
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
string bio = 4;
|
||||
string avatar_url = 5;
|
||||
}
|
||||
|
||||
// CategoryContext mirrors blocks.CategoryContext.
|
||||
message CategoryContext {
|
||||
string id = 1; // UUID
|
||||
string name = 2;
|
||||
string slug = 3;
|
||||
}
|
||||
|
||||
// MasterPageContext mirrors blocks.MasterPageContext.
|
||||
message MasterPageContext {
|
||||
string id = 1; // UUID
|
||||
string slug = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
// HumanProofBanner mirrors blocks.HumanProofBannerData.
|
||||
message HumanProofBanner {
|
||||
int32 active_time_minutes = 1;
|
||||
int32 keystroke_count = 2;
|
||||
int32 session_count = 3;
|
||||
string post_slug = 4;
|
||||
}
|
||||
|
||||
// DetailRow mirrors blocks.DetailRowInfo.
|
||||
message DetailRow {
|
||||
string table_id = 1;
|
||||
string row_id = 2;
|
||||
// JSON encoding of the row data map.
|
||||
bytes data_json = 3;
|
||||
}
|
||||
|
||||
// BlockContext mirrors blocks.BlockContext (the pongo2 data struct) 1:1.
|
||||
// map[string]any fields travel as JSON bytes.
|
||||
message BlockContext {
|
||||
string url = 1;
|
||||
string path = 2;
|
||||
string slug = 3;
|
||||
string page_id = 4;
|
||||
string page_title = 5;
|
||||
string template_key = 6;
|
||||
bool is_editor = 7;
|
||||
int64 timestamp = 8;
|
||||
google.protobuf.Timestamp now = 9;
|
||||
bool is_logged_in = 10;
|
||||
string user_id = 11;
|
||||
string user_email = 12;
|
||||
string user_role = 13;
|
||||
string method = 14;
|
||||
string host = 15;
|
||||
map<string, string> query = 16;
|
||||
string referrer = 17;
|
||||
string user_agent = 18;
|
||||
string ip = 19;
|
||||
map<string, string> cookies = 20;
|
||||
map<string, string> headers = 21;
|
||||
string country = 22;
|
||||
string city = 23;
|
||||
string timezone = 24;
|
||||
bytes current_author_json = 25;
|
||||
bytes current_post_json = 26;
|
||||
bytes current_category_json = 27;
|
||||
bytes site_json = 28;
|
||||
bool is_public_logged_in = 29;
|
||||
string public_user_id = 30;
|
||||
string public_username = 31;
|
||||
string public_display_name = 32;
|
||||
bool public_email_verified = 33;
|
||||
string detail_row_id = 34;
|
||||
string detail_table_id = 35;
|
||||
bytes detail_row_data_json = 36;
|
||||
string blog_index_url = 37;
|
||||
string category_page_url = 38;
|
||||
}
|
||||
5167
abi/v1/capability.pb.go
Normal file
5167
abi/v1/capability.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
1056
abi/v1/db.pb.go
Normal file
1056
abi/v1/db.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
296
abi/v1/http.pb.go
Normal file
296
abi/v1/http.pb.go
Normal file
@ -0,0 +1,296 @@
|
||||
// http.proto — buffered HTTP request/response payloads for the HANDLE_HTTP
|
||||
// hook (WO-WZ-001).
|
||||
//
|
||||
// v1 buffers full bodies: no streaming, SSE, or WebSockets inside plugins
|
||||
// (per the wasm migration design spec §5). The guest runs its real
|
||||
// chi/connect mux internally and answers one HttpRequest with one
|
||||
// HttpResponse.
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc (unknown)
|
||||
// source: v1/http.proto
|
||||
|
||||
package abiv1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// HttpRequest is a fully buffered HTTP request forwarded to the guest mux.
|
||||
type HttpRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"`
|
||||
// Request path (no scheme/host/query).
|
||||
Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
|
||||
// Raw query string (without the leading '?').
|
||||
RawQuery string `protobuf:"bytes,3,opt,name=raw_query,json=rawQuery,proto3" json:"raw_query,omitempty"`
|
||||
// Canonical header name → values.
|
||||
Headers map[string]*HeaderValues `protobuf:"bytes,4,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Body []byte `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HttpRequest) Reset() {
|
||||
*x = HttpRequest{}
|
||||
mi := &file_v1_http_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HttpRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HttpRequest) ProtoMessage() {}
|
||||
|
||||
func (x *HttpRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_http_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HttpRequest.ProtoReflect.Descriptor instead.
|
||||
func (*HttpRequest) Descriptor() ([]byte, []int) {
|
||||
return file_v1_http_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetMethod() string {
|
||||
if x != nil {
|
||||
return x.Method
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetPath() string {
|
||||
if x != nil {
|
||||
return x.Path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetRawQuery() string {
|
||||
if x != nil {
|
||||
return x.RawQuery
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetHeaders() map[string]*HeaderValues {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HeaderValues holds the values of one multi-valued HTTP header.
|
||||
type HeaderValues struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HeaderValues) Reset() {
|
||||
*x = HeaderValues{}
|
||||
mi := &file_v1_http_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HeaderValues) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HeaderValues) ProtoMessage() {}
|
||||
|
||||
func (x *HeaderValues) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_http_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead.
|
||||
func (*HeaderValues) Descriptor() ([]byte, []int) {
|
||||
return file_v1_http_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *HeaderValues) GetValues() []string {
|
||||
if x != nil {
|
||||
return x.Values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpResponse is the guest's fully buffered response.
|
||||
type HttpResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Headers map[string]*HeaderValues `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HttpResponse) Reset() {
|
||||
*x = HttpResponse{}
|
||||
mi := &file_v1_http_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HttpResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HttpResponse) ProtoMessage() {}
|
||||
|
||||
func (x *HttpResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_http_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HttpResponse.ProtoReflect.Descriptor instead.
|
||||
func (*HttpResponse) Descriptor() ([]byte, []int) {
|
||||
return file_v1_http_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetStatus() int32 {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetHeaders() map[string]*HeaderValues {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_v1_http_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_v1_http_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\rv1/http.proto\x12\x06abi.v1\"\xf8\x01\n" +
|
||||
"\vHttpRequest\x12\x16\n" +
|
||||
"\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" +
|
||||
"\x04path\x18\x02 \x01(\tR\x04path\x12\x1b\n" +
|
||||
"\traw_query\x18\x03 \x01(\tR\brawQuery\x12:\n" +
|
||||
"\aheaders\x18\x04 \x03(\v2 .abi.v1.HttpRequest.HeadersEntryR\aheaders\x12\x12\n" +
|
||||
"\x04body\x18\x05 \x01(\fR\x04body\x1aP\n" +
|
||||
"\fHeadersEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01\"&\n" +
|
||||
"\fHeaderValues\x12\x16\n" +
|
||||
"\x06values\x18\x01 \x03(\tR\x06values\"\xc9\x01\n" +
|
||||
"\fHttpResponse\x12\x16\n" +
|
||||
"\x06status\x18\x01 \x01(\x05R\x06status\x12;\n" +
|
||||
"\aheaders\x18\x02 \x03(\v2!.abi.v1.HttpResponse.HeadersEntryR\aheaders\x12\x12\n" +
|
||||
"\x04body\x18\x03 \x01(\fR\x04body\x1aP\n" +
|
||||
"\fHeadersEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01B0Z.git.dev.alexdunmow.com/block/core/abi/v1;abiv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_v1_http_proto_rawDescOnce sync.Once
|
||||
file_v1_http_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_v1_http_proto_rawDescGZIP() []byte {
|
||||
file_v1_http_proto_rawDescOnce.Do(func() {
|
||||
file_v1_http_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_v1_http_proto_rawDesc), len(file_v1_http_proto_rawDesc)))
|
||||
})
|
||||
return file_v1_http_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_v1_http_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_v1_http_proto_goTypes = []any{
|
||||
(*HttpRequest)(nil), // 0: abi.v1.HttpRequest
|
||||
(*HeaderValues)(nil), // 1: abi.v1.HeaderValues
|
||||
(*HttpResponse)(nil), // 2: abi.v1.HttpResponse
|
||||
nil, // 3: abi.v1.HttpRequest.HeadersEntry
|
||||
nil, // 4: abi.v1.HttpResponse.HeadersEntry
|
||||
}
|
||||
var file_v1_http_proto_depIdxs = []int32{
|
||||
3, // 0: abi.v1.HttpRequest.headers:type_name -> abi.v1.HttpRequest.HeadersEntry
|
||||
4, // 1: abi.v1.HttpResponse.headers:type_name -> abi.v1.HttpResponse.HeadersEntry
|
||||
1, // 2: abi.v1.HttpRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues
|
||||
1, // 3: abi.v1.HttpResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues
|
||||
4, // [4:4] is the sub-list for method output_type
|
||||
4, // [4:4] is the sub-list for method input_type
|
||||
4, // [4:4] is the sub-list for extension type_name
|
||||
4, // [4:4] is the sub-list for extension extendee
|
||||
0, // [0:4] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_v1_http_proto_init() }
|
||||
func file_v1_http_proto_init() {
|
||||
if File_v1_http_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_http_proto_rawDesc), len(file_v1_http_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_v1_http_proto_goTypes,
|
||||
DependencyIndexes: file_v1_http_proto_depIdxs,
|
||||
MessageInfos: file_v1_http_proto_msgTypes,
|
||||
}.Build()
|
||||
File_v1_http_proto = out.File
|
||||
file_v1_http_proto_goTypes = nil
|
||||
file_v1_http_proto_depIdxs = nil
|
||||
}
|
||||
1385
abi/v1/invoke.pb.go
Normal file
1385
abi/v1/invoke.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
1450
abi/v1/manifest.pb.go
Normal file
1450
abi/v1/manifest.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
1665
abi/v1/render.pb.go
Normal file
1665
abi/v1/render.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -1115,11 +1115,11 @@ func gitignoredTrackedWarning(repoDir string, w io.Writer) {
|
||||
if names == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
|
||||
_, _ = fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
|
||||
for n := range strings.SplitSeq(names, "\n") {
|
||||
fmt.Fprintln(w, " "+n)
|
||||
_, _ = fmt.Fprintln(w, " "+n)
|
||||
}
|
||||
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
|
||||
_, _ = fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
|
||||
}
|
||||
|
||||
// untrackedFilesWarning writes a warning to w listing untracked files in
|
||||
@ -1134,11 +1134,11 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
|
||||
if names == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
|
||||
_, _ = fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
|
||||
for n := range strings.SplitSeq(names, "\n") {
|
||||
fmt.Fprintln(w, " "+n)
|
||||
_, _ = fmt.Fprintln(w, " "+n)
|
||||
}
|
||||
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
|
||||
_, _ = fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
|
||||
}
|
||||
|
||||
// emitPublishWarnings runs the publish-time warning helpers in the order the
|
||||
@ -1181,11 +1181,11 @@ func submoduleWarning(repoDir string, w io.Writer) {
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
|
||||
_, _ = fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
|
||||
for _, p := range paths {
|
||||
fmt.Fprintln(w, " "+p)
|
||||
_, _ = fmt.Fprintln(w, " "+p)
|
||||
}
|
||||
fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
|
||||
_, _ = fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
|
||||
}
|
||||
|
||||
// autoCommitPluginMod stages and commits plugin.mod with the given message,
|
||||
|
||||
320
docs/wasm-abi.md
Normal file
320
docs/wasm-abi.md
Normal file
@ -0,0 +1,320 @@
|
||||
# Wasm Plugin ABI (v1)
|
||||
|
||||
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
|
||||
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
|
||||
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
|
||||
Design rationale: the wasm plugin migration design spec in the cms repo
|
||||
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
|
||||
|
||||
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
|
||||
The `abi/` buf module is deliberately separate from the repo-root buf config:
|
||||
`proto/` is the shared block/proto git submodule (service API contracts),
|
||||
while this ABI is SDK-internal and versions in lockstep with the guest shim,
|
||||
so it lives repo-local.
|
||||
|
||||
## Versioning — `abi_version`
|
||||
|
||||
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
|
||||
**major** version the plugin was built against. Current value: **1**.
|
||||
|
||||
- The host **rejects** any manifest whose major version it does not support —
|
||||
at install/publish time (manifest read) and again at `DESCRIBE`
|
||||
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
|
||||
newer guest shim can refuse an older host symmetrically).
|
||||
- Within a major version, evolution is protobuf-additive only: new fields,
|
||||
new `Hook` values, new capability methods. Removing or renaming anything
|
||||
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
|
||||
in `abi/buf.yaml`) enforces this against the previous commit.
|
||||
|
||||
## Module lifecycle — REACTOR mode (`_initialize`, no `_start`)
|
||||
|
||||
Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for
|
||||
`go:wasmexport`; this repo's floor is higher — check `go.mod`):
|
||||
|
||||
```
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
|
||||
```
|
||||
|
||||
with one boilerplate main file next to the untouched Registration:
|
||||
|
||||
```go
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
|
||||
|
||||
func init() { wasmguest.Serve(Registration) }
|
||||
func main() {} // never called — reactor mode
|
||||
```
|
||||
|
||||
A reactor module exports `_initialize` instead of `_start`. The host MUST
|
||||
run `_initialize` exactly once per instance **before any `bn_invoke`**
|
||||
(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package
|
||||
init funcs — hence `Serve`, which stores the registration and returns.
|
||||
`main` exists only to satisfy the linker and is never called.
|
||||
|
||||
> Command mode (plain `go build`) does NOT work and must not be used
|
||||
> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start`
|
||||
> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go
|
||||
> deadlock detector and traps, while a returning `main` exits and closes
|
||||
> the module — either way the exports are never callable. The original
|
||||
> blocking-main design was amended to reactor mode for this reason.
|
||||
|
||||
## Calling convention (ptr+len, packed u64)
|
||||
|
||||
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
|
||||
protobuf bytes in guest linear memory. The guest exports (via
|
||||
`go:wasmexport`):
|
||||
|
||||
| Export | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
|
||||
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
|
||||
|
||||
Host functions (guest→host capability calls) live in wasm import module
|
||||
**`blockninja`** and use the same shape in reverse. There is exactly ONE
|
||||
generic import rather than one symbol per capability family:
|
||||
|
||||
```
|
||||
(blockninja) host_call(ptr: u32, len: u32) → packed: u64
|
||||
```
|
||||
|
||||
The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose
|
||||
`method` string — `"<family>.<snake_method>"`, including the `db.*` driver
|
||||
methods — already selects the family); the host returns a packed `u64`
|
||||
framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`.
|
||||
The guest shim releases that buffer after decoding, so the host must not
|
||||
reuse it. Per-family import symbols were considered and rejected (decision,
|
||||
WO-WZ-002): they would add ~40 declarations on both sides for zero type
|
||||
safety, since the payloads are opaque protobuf bytes either way.
|
||||
|
||||
Buffers MUST come from `bn_alloc` on both paths — the guest rejects a
|
||||
`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`),
|
||||
and treats an unknown host-call response pointer the same way.
|
||||
|
||||
A `packed` value of `0` means the callee could not even produce an envelope
|
||||
(allocation failure / trap); the caller treats it as
|
||||
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
||||
|
||||
> A logically-empty response is **not** packed `0`. A successful hook whose
|
||||
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
|
||||
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
|
||||
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
|
||||
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
|
||||
> `len==0 → return 0` shortcut made every successful empty-response hook —
|
||||
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
|
||||
> instance. The cms host in WO-WZ-006 must likewise not conflate a
|
||||
> zero-length payload with a missing envelope.)
|
||||
|
||||
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
||||
concurrency comes from the per-plugin instance pool.
|
||||
|
||||
## Hook catalog (`invoke.proto`)
|
||||
|
||||
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}` →
|
||||
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
|
||||
|
||||
| Hook | Request / Response | Fires |
|
||||
|---|---|---|
|
||||
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
|
||||
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
|
||||
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
|
||||
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
|
||||
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
|
||||
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
|
||||
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
|
||||
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
|
||||
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
|
||||
|
||||
## Capability calls (`capability.proto`, `db.proto`)
|
||||
|
||||
Guest→host. Envelope: `HostCallRequest{method, payload}` →
|
||||
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
|
||||
each pair mirrors one Go interface method from `CoreServices` 1:1
|
||||
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
|
||||
|
||||
| Family | Methods | Go surface |
|
||||
|---|---|---|
|
||||
| `content` | `get_author_profile`, `get_page`, `get_post`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | `content.Content` |
|
||||
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting` | `settings.Settings`, `settings.Updater` |
|
||||
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
|
||||
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
|
||||
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
|
||||
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
|
||||
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
|
||||
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
|
||||
| `media` | `deposit` | `plugin.Media` |
|
||||
| `email` | `send` | `plugin.EmailSender` |
|
||||
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
|
||||
| `bridge` | `register_service`, `get_service` | `plugin.PluginBridge` |
|
||||
| `jobs` | `submit` | `plugin.JobRunner` |
|
||||
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
|
||||
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
|
||||
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
|
||||
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
|
||||
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
||||
|
||||
The guest half of the SDK implements the existing Go interfaces as stubs
|
||||
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
|
||||
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
|
||||
the wasm shim binds `call` to the real `host_call` transport, tests inject a
|
||||
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
|
||||
instead of nil-panicking.
|
||||
|
||||
### Method disposition (every `CoreServices` member)
|
||||
|
||||
No silent gaps: each member is either a guest stub or served host-side.
|
||||
|
||||
| Member | Disposition |
|
||||
|---|---|
|
||||
| `Content` (7 methods) | **stub** — `caps/content.go` |
|
||||
| `Settings` / `SettingsUpdater` | **stub** — `caps/settings.go` (one value, both fields) |
|
||||
| `Gating` | **stub** — `caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
|
||||
| `Crypto` | **stub** — `caps/crypto.go` |
|
||||
| `Menus` | **stub** — `caps/menus.go` |
|
||||
| `Datasources` | **stub** — `caps/datasources.go` |
|
||||
| `PublicUsers` | **stub** — `caps/users.go` |
|
||||
| `Subscriptions` | **stub** — `caps/subscriptions.go` |
|
||||
| `Media` | **stub** — `caps/media.go` |
|
||||
| `ToolRegistry` + `AITextCall` | **stub** — `caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
|
||||
| `EmailSender` | **stub** — `caps/email.go` |
|
||||
| `Bridge` | **stub** — `caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
|
||||
| `ReviewSubmitter` | **stub** — `caps/reviews.go` |
|
||||
| `BadgeRefresher` | **stub** — `caps/badges.go` |
|
||||
| `JobRunner` | **stub** — `caps/jobs.go` |
|
||||
| `EmbeddingService` | **stub** — `caps/embeddings.go` |
|
||||
| `RAGService` | **stub** — `caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
|
||||
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
|
||||
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. Auth context reaches the guest via `HttpRequest` headers (host-side interceptors already ran). |
|
||||
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
|
||||
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
|
||||
|
||||
Interface satisfaction is proven at compile time by a `var _ <iface> =
|
||||
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
|
||||
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
|
||||
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
|
||||
crosses the ABI for real.
|
||||
|
||||
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
|
||||
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
|
||||
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
|
||||
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
|
||||
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
|
||||
zero value / best-effort on transport failure.
|
||||
|
||||
`CoreServices` members that do **not** cross as capability calls:
|
||||
|
||||
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
|
||||
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
|
||||
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
|
||||
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
|
||||
chain's deadline so a guest can never pin a connection. **Guest side
|
||||
(WO-WZ-004):** `core/plugin/wasmguest/bnwasm` implements this over the
|
||||
transport as two surfaces — a `database/sql` driver registered as `"bnwasm"`,
|
||||
and a `plugin.Pool` handing out a `pgx.Tx`-shaped value. The latter is the
|
||||
primary path: current plugins' sqlc configs use `sql_package: "pgx/v5"`, so
|
||||
their generated `DBTX` needs `pgconn.CommandTag`/`pgx.Rows`/`pgx.Row` (which
|
||||
`database/sql` cannot produce), and the `Pool`/`Tx` satisfy it with no source
|
||||
edits. `DbError` surfaces as `*pgconn.PgError` (SQLSTATE preserved for
|
||||
`errors.As`). Named args (`pgx.NamedArgs`/`QueryRewriter`) and nested
|
||||
transactions/savepoints are rejected with clear errors — no fleet plugin uses
|
||||
either. The DbValue↔Go scan mapping is pinned in the exported
|
||||
`bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors.
|
||||
**`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a
|
||||
repeated string with no per-element NULL, so a Postgres `text[]` like
|
||||
`{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as
|
||||
a whole can still be SQL NULL (nil `[]string` → `DbValue_Null`); only a NULL
|
||||
*inside* the array is unrepresentable. The host executor must honor this same
|
||||
limit (encode a NULL element as `""` or reject it), not invent a sentinel.
|
||||
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
|
||||
`manifest.rbac_method_roles`.
|
||||
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
|
||||
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
|
||||
declaration; the host constructs and mounts the handlers.
|
||||
- `RAGService.RegisterContentFetcher` → static
|
||||
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
|
||||
callback inversion.
|
||||
|
||||
## Error semantics
|
||||
|
||||
`AbiError{code, message}` travels in `InvokeResponse.error` and
|
||||
`HostCallResponse.error`:
|
||||
|
||||
| Code | Meaning | Instance consequence |
|
||||
|---|---|---|
|
||||
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
|
||||
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
|
||||
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
|
||||
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
|
||||
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
|
||||
|
||||
Host-function errors surface to plugin code as ordinary Go errors via the
|
||||
guest SDK. Repeated instance failures trip the existing
|
||||
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
|
||||
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
|
||||
sqlc/pgx error handling keeps working.
|
||||
|
||||
## Manifest ↔ `PluginRegistration` mapping
|
||||
|
||||
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
|
||||
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
|
||||
Field-by-field:
|
||||
|
||||
| `PluginRegistration` field | Wire counterpart |
|
||||
|---|---|
|
||||
| `Name` | `PluginManifest.name` |
|
||||
| `Version` | `PluginManifest.version` |
|
||||
| `Dependencies` | `dependencies` (`Dependency`) |
|
||||
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
|
||||
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
|
||||
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
|
||||
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
|
||||
| `SettingsSchema` | `settings_schema` (JSON bytes) |
|
||||
| `ThemePresets` | `theme_presets` (JSON bytes) |
|
||||
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
|
||||
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
|
||||
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
|
||||
| `SettingsPanel` | `settings_panel` |
|
||||
| `AdminPages` | `admin_pages` (`AdminPage`) |
|
||||
| `CSSManifest` | `css_manifest` (`CssManifest`) |
|
||||
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
|
||||
| `JobHandlers` | `job_types` + `HOOK_JOB` |
|
||||
| `AIActions` | `ai_actions` (`AiAction`) |
|
||||
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
|
||||
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
|
||||
| `Load` | `has_load_hook` + `HOOK_LOAD` |
|
||||
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
|
||||
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
|
||||
| `RequiredIconPacks` | `required_icon_packs` |
|
||||
|
||||
## Render context
|
||||
|
||||
`RenderContext` (`render.proto`) is the explicit envelope of every value
|
||||
blocks read from `ctx` today (`core/blocks/context.go`): request info,
|
||||
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
|
||||
category/master-page, requested path, injected/expected slots, editor flag,
|
||||
block + page IDs, human-proof banner, detail row, theme variables JSON, and
|
||||
locale.
|
||||
|
||||
Function-valued context entries cannot serialize; their v1 mapping:
|
||||
|
||||
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
|
||||
- `SlotRenderer` / `MediaResolver` / `EmbedResolver` → **open items** (below).
|
||||
|
||||
## Open items (flagged for runtime WOs — additive, no major bump needed)
|
||||
|
||||
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing
|
||||
its guest-side `Handler` needs a host→guest hook (e.g. `HOOK_AI_TOOL_CALL`).
|
||||
Deliberately not added here — the WO fixes the v1 hook catalog.
|
||||
- **Job progress**: `plugin.JobHandlerFunc`'s `progress(current, total,
|
||||
message)` callback needs a `jobs.progress` host function.
|
||||
- **Bridge calls**: `bridge.register_service`/`get_service` cover
|
||||
registration/lookup only; typed cross-plugin *invocation* (today: shared
|
||||
in-process Go values) needs a runtime design.
|
||||
- **Directory extension callbacks**: `panel_section_count` /
|
||||
`pin_decorator_count` declare the guest callbacks; invoking them needs a
|
||||
hook.
|
||||
- **Slot/media/embed resolvers in render**: container-slot rendering and
|
||||
media/embed resolution during a guest render need host functions (or host-
|
||||
side pre-rendering into `RenderContext`).
|
||||
3
go.mod
3
go.mod
@ -12,6 +12,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
golang.org/x/mod v0.34.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
@ -28,6 +29,6 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@ -62,14 +62,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
283
plugin/wasmguest/bnwasm/dbvalue.go
Normal file
283
plugin/wasmguest/bnwasm/dbvalue.go
Normal file
@ -0,0 +1,283 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/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 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)
|
||||
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
|
||||
default:
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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()
|
||||
}
|
||||
183
plugin/wasmguest/bnwasm/dbvalue_fixtures.go
Normal file
183
plugin/wasmguest/bnwasm/dbvalue_fixtures.go
Normal file
@ -0,0 +1,183 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// FixtureTime is the fixed timestamp used by the shared DbValue fixtures, so
|
||||
// both sides of the boundary compare against one deterministic value.
|
||||
var FixtureTime = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
|
||||
|
||||
// FixtureUUID is the fixed UUID used by the shared DbValue fixtures.
|
||||
var FixtureUUID = uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
|
||||
// DbValueFixture is one entry in the canonical DbValue↔Go mapping table.
|
||||
type DbValueFixture struct {
|
||||
// Name identifies the DbValue variant.
|
||||
Name string
|
||||
// Value is the wire value the host would send back in a DbRowsResponse cell
|
||||
// (and the guest would send as an argument for the non-NULL scalar cases).
|
||||
Value *abiv1.DbValue
|
||||
// NewDest returns a fresh pointer to the canonical Go scan destination type
|
||||
// that plugin sqlc code binds for this variant.
|
||||
NewDest func() any
|
||||
// Want is the expected dereferenced value after scanning Value into
|
||||
// NewDest(). Compared with reflect.DeepEqual (times via .Equal).
|
||||
Want any
|
||||
// DriverValue is the expected database/sql driver.Value for this variant,
|
||||
// documenting the "bnwasm" driver's mapping (uuid/numeric→string,
|
||||
// text[]→Postgres array literal).
|
||||
DriverValue any
|
||||
}
|
||||
|
||||
// DbValueFixtures is the authoritative DbValue↔Go mapping table. It is the
|
||||
// shared contract WO-WZ-007 (the CMS host executor) MUST mirror: every variant
|
||||
// the guest driver produces on scan, the host must be able to build on read,
|
||||
// and vice-versa. Exported (non-test) precisely so the host's tests in the CMS
|
||||
// module can import and assert against the same table rather than duplicating a
|
||||
// drift-prone copy.
|
||||
//
|
||||
// Every oneof arm of abiv1.DbValue.Kind appears at least once; several arms
|
||||
// carry extra entries that exercise encoding edge cases (the zero timestamp,
|
||||
// negative/very-large numerics, an empty text[], and text[] elements that force
|
||||
// encodePgTextArray's quoting/escaping paths) so the host mirror must reproduce
|
||||
// them too.
|
||||
//
|
||||
// Contract limit — NULL array elements: abiv1.TextArray is a repeated string,
|
||||
// which has no per-element NULL. A Postgres text[] value like '{a,NULL,b}'
|
||||
// therefore CANNOT round-trip through this boundary: a NULL element collapses to
|
||||
// the empty string "". The whole array can still be SQL NULL (a nil []string /
|
||||
// DbValue_Null), but an individual NULL *inside* the array is unrepresentable.
|
||||
// The WO-WZ-007 host executor MUST honor this same limit (encode a NULL element
|
||||
// as "" or reject it) — it must not invent a sentinel.
|
||||
var DbValueFixtures = []DbValueFixture{
|
||||
{
|
||||
Name: "null",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Null{Null: true}},
|
||||
NewDest: func() any { return new(*string) }, // **string: NULL → (*string)(nil)
|
||||
Want: (*string)(nil),
|
||||
DriverValue: nil,
|
||||
},
|
||||
{
|
||||
Name: "bool",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: true}},
|
||||
NewDest: func() any { return new(bool) },
|
||||
Want: true,
|
||||
DriverValue: true,
|
||||
},
|
||||
{
|
||||
Name: "int64",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 42}},
|
||||
NewDest: func() any { return new(int64) },
|
||||
Want: int64(42),
|
||||
DriverValue: int64(42),
|
||||
},
|
||||
{
|
||||
Name: "int32", // narrowing coercion sqlc emits for int4 columns
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 7}},
|
||||
NewDest: func() any { return new(int32) },
|
||||
Want: int32(7),
|
||||
DriverValue: int64(7),
|
||||
},
|
||||
{
|
||||
Name: "float64",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Float64Value{Float64Value: 3.5}},
|
||||
NewDest: func() any { return new(float64) },
|
||||
Want: 3.5,
|
||||
DriverValue: 3.5,
|
||||
},
|
||||
{
|
||||
Name: "string",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: "hello"}},
|
||||
NewDest: func() any { return new(string) },
|
||||
Want: "hello",
|
||||
DriverValue: "hello",
|
||||
},
|
||||
{
|
||||
Name: "bytes",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_BytesValue{BytesValue: []byte{0x01, 0x02, 0x03}}},
|
||||
NewDest: func() any { return new([]byte) },
|
||||
Want: []byte{0x01, 0x02, 0x03},
|
||||
DriverValue: []byte{0x01, 0x02, 0x03},
|
||||
},
|
||||
{
|
||||
Name: "timestamp",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(FixtureTime)}},
|
||||
NewDest: func() any { return new(time.Time) },
|
||||
Want: FixtureTime,
|
||||
DriverValue: FixtureTime,
|
||||
},
|
||||
{
|
||||
Name: "uuid",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: FixtureUUID.String()}},
|
||||
NewDest: func() any { return new(uuid.UUID) },
|
||||
Want: FixtureUUID,
|
||||
DriverValue: FixtureUUID.String(),
|
||||
},
|
||||
{
|
||||
Name: "jsonb",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_JsonbValue{JsonbValue: []byte(`{"a":1}`)}},
|
||||
NewDest: func() any { return new(json.RawMessage) },
|
||||
Want: json.RawMessage(`{"a":1}`),
|
||||
DriverValue: []byte(`{"a":1}`),
|
||||
},
|
||||
{
|
||||
Name: "numeric",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "123.45"}},
|
||||
NewDest: func() any { return new(string) },
|
||||
Want: "123.45",
|
||||
DriverValue: "123.45",
|
||||
},
|
||||
{
|
||||
Name: "text_array",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{"a", "b"}}}},
|
||||
NewDest: func() any { return new([]string) },
|
||||
Want: []string{"a", "b"},
|
||||
DriverValue: `{"a","b"}`,
|
||||
},
|
||||
|
||||
// --- encoding edge cases (extra entries beyond one-per-variant) ---
|
||||
{
|
||||
Name: "timestamp_zero", // the Go zero time.Time (year 1) must survive the timestamppb round-trip
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(time.Time{})}},
|
||||
NewDest: func() any { return new(time.Time) },
|
||||
Want: time.Time{},
|
||||
DriverValue: time.Time{},
|
||||
},
|
||||
{
|
||||
Name: "numeric_negative",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "-98765.4321"}},
|
||||
NewDest: func() any { return new(string) },
|
||||
Want: "-98765.4321",
|
||||
DriverValue: "-98765.4321",
|
||||
},
|
||||
{
|
||||
Name: "numeric_large", // far beyond int64/float64 range: numeric stays a lossless decimal string
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "123456789012345678901234567890.123456789"}},
|
||||
NewDest: func() any { return new(string) },
|
||||
Want: "123456789012345678901234567890.123456789",
|
||||
DriverValue: "123456789012345678901234567890.123456789",
|
||||
},
|
||||
{
|
||||
Name: "text_array_empty", // empty-but-non-nil text[] → "{}" (distinct from a NULL array)
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{}}}},
|
||||
NewDest: func() any { return new([]string) },
|
||||
Want: []string(nil),
|
||||
DriverValue: "{}",
|
||||
},
|
||||
{
|
||||
// Elements that force encodePgTextArray's quoting/escaping: a comma
|
||||
// (needs quoting), a double-quote and a backslash (need escaping), and an
|
||||
// empty-string element (renders as the empty quoted "").
|
||||
Name: "text_array_quoting",
|
||||
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{"a,b", `x"y`, `p\q`, ""}}}},
|
||||
NewDest: func() any { return new([]string) },
|
||||
Want: []string{"a,b", `x"y`, `p\q`, ""},
|
||||
DriverValue: `{"a,b","x\"y","p\\q",""}`,
|
||||
},
|
||||
}
|
||||
75
plugin/wasmguest/bnwasm/dbvalue_test.go
Normal file
75
plugin/wasmguest/bnwasm/dbvalue_test.go
Normal file
@ -0,0 +1,75 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
)
|
||||
|
||||
// TestToDbValueTextArrayNilVsEmpty pins the guest-side arg encoding for []string:
|
||||
// a nil slice marshals to SQL NULL (matching []byte/json.RawMessage), while an
|
||||
// empty-but-non-nil slice stays a non-NULL empty text[]. WO-WZ-007's host must
|
||||
// mirror this nil→NULL convention.
|
||||
func TestToDbValueTextArrayNilVsEmpty(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
dv, err := toDbValue([]string(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok {
|
||||
t.Fatalf("nil []string: want DbValue_Null, got %T", dv.GetKind())
|
||||
}
|
||||
})
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
dv, err := toDbValue([]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue)
|
||||
if !ok {
|
||||
t.Fatalf("empty []string: want DbValue_TextArrayValue, got %T", dv.GetKind())
|
||||
}
|
||||
if got := ta.TextArrayValue.GetValues(); len(got) != 0 {
|
||||
t.Fatalf("empty []string: want zero-length text[], got %#v", got)
|
||||
}
|
||||
})
|
||||
t.Run("values", func(t *testing.T) {
|
||||
dv, err := toDbValue([]string{"a", "b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue)
|
||||
if !ok {
|
||||
t.Fatalf("want DbValue_TextArrayValue, got %T", dv.GetKind())
|
||||
}
|
||||
if got := ta.TextArrayValue.GetValues(); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Fatalf("want [a b], got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestToDbValueNilConvention locks the nil→NULL convention across the reference
|
||||
// types so []string stays consistent with []byte and json.RawMessage.
|
||||
func TestToDbValueNilConvention(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
}{
|
||||
{"bytes", []byte(nil)},
|
||||
{"json", json.RawMessage(nil)},
|
||||
{"text_array", []string(nil)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dv, err := toDbValue(c.in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok {
|
||||
t.Fatalf("nil %s: want DbValue_Null, got %T", c.name, dv.GetKind())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
201
plugin/wasmguest/bnwasm/driver.go
Normal file
201
plugin/wasmguest/bnwasm/driver.go
Normal file
@ -0,0 +1,201 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
)
|
||||
|
||||
// DriverName is the database/sql driver name registered by this package.
|
||||
const DriverName = "bnwasm"
|
||||
|
||||
func init() { sql.Register(DriverName, Driver{}) }
|
||||
|
||||
// defaultTransport is the process-wide transport the "bnwasm" database/sql
|
||||
// driver uses (the guest has exactly one host). The wasip1 shim sets it via
|
||||
// SetDefaultTransport; native builds leave it nil so opened connections fail
|
||||
// cleanly with errNoHost. Guarded because sql.DB may dial from any goroutine.
|
||||
var (
|
||||
defaultMu sync.RWMutex
|
||||
defaultTransport Transport
|
||||
)
|
||||
|
||||
// SetDefaultTransport binds the transport used by database/sql connections
|
||||
// opened via sql.Open("bnwasm", ...). Called once from the wasm guest shim.
|
||||
func SetDefaultTransport(t Transport) {
|
||||
defaultMu.Lock()
|
||||
defaultTransport = t
|
||||
defaultMu.Unlock()
|
||||
}
|
||||
|
||||
func currentTransport() Transport {
|
||||
defaultMu.RLock()
|
||||
defer defaultMu.RUnlock()
|
||||
return defaultTransport
|
||||
}
|
||||
|
||||
// Driver is the database/sql/driver.Driver for the wasm DB ABI.
|
||||
type Driver struct{}
|
||||
|
||||
var (
|
||||
_ driver.Driver = Driver{}
|
||||
_ driver.DriverContext = Driver{}
|
||||
)
|
||||
|
||||
// Open ignores its DSN — the transport is process-global (one host per guest).
|
||||
func (d Driver) Open(name string) (driver.Conn, error) {
|
||||
return &conn{t: currentTransport()}, nil
|
||||
}
|
||||
|
||||
// OpenConnector lets sql.OpenDB bypass DSN parsing entirely.
|
||||
func (d Driver) OpenConnector(name string) (driver.Connector, error) {
|
||||
return connector{}, nil
|
||||
}
|
||||
|
||||
// NewConnector returns a driver.Connector bound to an explicit transport, for
|
||||
// callers that construct a *sql.DB via sql.OpenDB without touching the global.
|
||||
func NewConnector(t Transport) driver.Connector { return connector{t: t, explicit: true} }
|
||||
|
||||
type connector struct {
|
||||
t Transport
|
||||
explicit bool
|
||||
}
|
||||
|
||||
func (c connector) Connect(context.Context) (driver.Conn, error) {
|
||||
t := c.t
|
||||
if !c.explicit {
|
||||
t = currentTransport()
|
||||
}
|
||||
return &conn{t: t}, nil
|
||||
}
|
||||
|
||||
func (c connector) Driver() driver.Driver { return Driver{} }
|
||||
|
||||
// conn is one logical connection: it holds no real connection state guest-side,
|
||||
// only the transport and the current transaction handle (0 = autocommit).
|
||||
type conn struct {
|
||||
t Transport
|
||||
txHandle uint64
|
||||
inTx bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ driver.Conn = (*conn)(nil)
|
||||
_ driver.QueryerContext = (*conn)(nil)
|
||||
_ driver.ExecerContext = (*conn)(nil)
|
||||
_ driver.ConnBeginTx = (*conn)(nil)
|
||||
)
|
||||
|
||||
func (c *conn) Prepare(query string) (driver.Stmt, error) {
|
||||
return nil, fmt.Errorf("bnwasm: prepared statements are not supported; use QueryContext/ExecContext")
|
||||
}
|
||||
|
||||
func (c *conn) Close() error { return nil }
|
||||
|
||||
func (c *conn) Begin() (driver.Tx, error) { return c.BeginTx(context.Background(), driver.TxOptions{}) }
|
||||
|
||||
func (c *conn) BeginTx(ctx context.Context, _ driver.TxOptions) (driver.Tx, error) {
|
||||
if c.inTx {
|
||||
return nil, errNestedTx
|
||||
}
|
||||
handle, err := c.t.txBegin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.txHandle = handle
|
||||
c.inTx = true
|
||||
return &sqlTx{c: c}, nil
|
||||
}
|
||||
|
||||
func (c *conn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
posArgs, err := namedToPositional(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := c.t.exec(ctx, query, c.txHandle, posArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sqlResult{rowsAffected: tag.RowsAffected()}, nil
|
||||
}
|
||||
|
||||
func (c *conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
posArgs, err := namedToPositional(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.t.query(ctx, query, c.txHandle, posArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newDriverRows(resp), nil
|
||||
}
|
||||
|
||||
// sqlTx is the database/sql transaction handle; it clears the conn's handle on
|
||||
// completion so subsequent statements run in autocommit again.
|
||||
type sqlTx struct{ c *conn }
|
||||
|
||||
func (t sqlTx) Commit() error {
|
||||
err := t.c.t.txCommit(context.Background(), t.c.txHandle)
|
||||
t.c.txHandle, t.c.inTx = 0, false
|
||||
return err
|
||||
}
|
||||
|
||||
func (t sqlTx) Rollback() error {
|
||||
err := t.c.t.txRollback(context.Background(), t.c.txHandle)
|
||||
t.c.txHandle, t.c.inTx = 0, false
|
||||
return err
|
||||
}
|
||||
|
||||
type sqlResult struct{ rowsAffected int64 }
|
||||
|
||||
func (r sqlResult) LastInsertId() (int64, error) {
|
||||
return 0, fmt.Errorf("bnwasm: LastInsertId is not supported (Postgres uses RETURNING)")
|
||||
}
|
||||
func (r sqlResult) RowsAffected() (int64, error) { return r.rowsAffected, nil }
|
||||
|
||||
// driverRows adapts a buffered DbRowsResponse to database/sql/driver.Rows.
|
||||
type driverRows struct {
|
||||
resp *abiv1.DbRowsResponse
|
||||
idx int
|
||||
}
|
||||
|
||||
func newDriverRows(resp *abiv1.DbRowsResponse) *driverRows { return &driverRows{resp: resp, idx: 0} }
|
||||
|
||||
func (r *driverRows) Columns() []string { return r.resp.GetColumns() }
|
||||
|
||||
func (r *driverRows) Close() error { return nil }
|
||||
|
||||
func (r *driverRows) Next(dest []driver.Value) error {
|
||||
rows := r.resp.GetRows()
|
||||
if r.idx >= len(rows) {
|
||||
return io.EOF
|
||||
}
|
||||
cells := rows[r.idx].GetValues()
|
||||
if len(dest) != len(cells) {
|
||||
return fmt.Errorf("bnwasm: expected %d columns, got %d destinations", len(cells), len(dest))
|
||||
}
|
||||
for i, cell := range cells {
|
||||
dest[i] = driverValue(cell)
|
||||
}
|
||||
r.idx++
|
||||
return nil
|
||||
}
|
||||
|
||||
// namedToPositional flattens database/sql NamedValues to positional args,
|
||||
// rejecting any that carry a name (sqlc emits positional $N parameters only).
|
||||
func namedToPositional(args []driver.NamedValue) ([]any, error) {
|
||||
out := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
if a.Name != "" {
|
||||
return nil, fmt.Errorf("bnwasm: named argument %q is not supported; use positional parameters", a.Name)
|
||||
}
|
||||
out[i] = a.Value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
304
plugin/wasmguest/bnwasm/driver_test.go
Normal file
304
plugin/wasmguest/bnwasm/driver_test.go
Normal file
@ -0,0 +1,304 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// recordedCall captures one db.* host call for ordered-sequence assertions.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
sql string
|
||||
args []*abiv1.DbValue
|
||||
txHandle uint64
|
||||
}
|
||||
|
||||
// fakeHost is a scripted db.* transport: it records every call and answers from
|
||||
// programmable fields, so the guest driver is exercised end-to-end with no real
|
||||
// Postgres and the exact host-call sequence can be asserted.
|
||||
type fakeHost struct {
|
||||
calls []recordedCall
|
||||
|
||||
queryResp *abiv1.DbRowsResponse // returned for db.query
|
||||
execRows int64 // rows_affected for db.exec
|
||||
txHandle uint64 // handle handed out on db.tx_begin
|
||||
dbError *abiv1.DbError // if set, attached to the next query/exec response
|
||||
failWith error // if set, the transport returns this transport-level error
|
||||
}
|
||||
|
||||
func (f *fakeHost) call(method string, req, resp proto.Message) error {
|
||||
rc := recordedCall{method: method}
|
||||
switch r := req.(type) {
|
||||
case *abiv1.DbQueryRequest:
|
||||
rc.sql, rc.args, rc.txHandle = r.GetSql(), r.GetArgs(), r.GetTxHandle()
|
||||
case *abiv1.DbExecRequest:
|
||||
rc.sql, rc.args, rc.txHandle = r.GetSql(), r.GetArgs(), r.GetTxHandle()
|
||||
case *abiv1.DbTxCommitRequest:
|
||||
rc.txHandle = r.GetTxHandle()
|
||||
case *abiv1.DbTxRollbackRequest:
|
||||
rc.txHandle = r.GetTxHandle()
|
||||
}
|
||||
f.calls = append(f.calls, rc)
|
||||
|
||||
if f.failWith != nil {
|
||||
return f.failWith
|
||||
}
|
||||
switch out := resp.(type) {
|
||||
case *abiv1.DbRowsResponse:
|
||||
if f.queryResp != nil {
|
||||
proto.Merge(out, f.queryResp)
|
||||
}
|
||||
out.Error = f.dbError
|
||||
case *abiv1.DbExecResponse:
|
||||
out.RowsAffected = f.execRows
|
||||
out.Error = f.dbError
|
||||
case *abiv1.DbTxBeginResponse:
|
||||
out.TxHandle = f.txHandle
|
||||
out.Error = f.dbError
|
||||
case *abiv1.DbTxCommitResponse:
|
||||
out.Error = f.dbError
|
||||
case *abiv1.DbTxRollbackResponse:
|
||||
out.Error = f.dbError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeHost) methods() []string {
|
||||
ms := make([]string, len(f.calls))
|
||||
for i, c := range f.calls {
|
||||
ms[i] = c.method
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
func rowsWith(columns []string, cells ...*abiv1.DbValue) *abiv1.DbRowsResponse {
|
||||
return &abiv1.DbRowsResponse{Columns: columns, Rows: []*abiv1.DbRow{{Values: cells}}}
|
||||
}
|
||||
|
||||
// deref returns the value a NewDest()-style pointer points at.
|
||||
func deref(ptr any) any { return reflect.ValueOf(ptr).Elem().Interface() }
|
||||
|
||||
func equalScan(want, got any) bool {
|
||||
if w, ok := want.(time.Time); ok {
|
||||
g, ok := got.(time.Time)
|
||||
return ok && w.Equal(g)
|
||||
}
|
||||
return reflect.DeepEqual(want, got)
|
||||
}
|
||||
|
||||
// TestDbValueScanRoundTrip proves every DbValue variant round-trips through a
|
||||
// SELECT and scans into its canonical Go type — the core contract WO-WZ-007
|
||||
// mirrors. Driven by the shared DbValueFixtures table.
|
||||
func TestDbValueScanRoundTrip(t *testing.T) {
|
||||
for _, fx := range DbValueFixtures {
|
||||
t.Run(fx.Name, func(t *testing.T) {
|
||||
host := &fakeHost{queryResp: rowsWith([]string{fx.Name}, fx.Value)}
|
||||
pool := NewPool(host.call)
|
||||
|
||||
dest := fx.NewDest()
|
||||
if err := pool.QueryRow(context.Background(), "SELECT x").Scan(dest); err != nil {
|
||||
t.Fatalf("scan %s: %v", fx.Name, err)
|
||||
}
|
||||
if got := deref(dest); !equalScan(fx.Want, got) {
|
||||
t.Fatalf("scan %s: want %#v, got %#v", fx.Name, fx.Want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDbValueDriverValue proves the database/sql "bnwasm" driver maps every
|
||||
// DbValue variant to the documented driver.Value.
|
||||
func TestDbValueDriverValue(t *testing.T) {
|
||||
for _, fx := range DbValueFixtures {
|
||||
t.Run(fx.Name, func(t *testing.T) {
|
||||
if got := driverValue(fx.Value); !equalScan(fx.DriverValue, got) {
|
||||
t.Fatalf("driverValue %s: want %#v, got %#v", fx.Name, fx.DriverValue, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRowsAffected(t *testing.T) {
|
||||
host := &fakeHost{execRows: 5}
|
||||
pool := NewPool(host.call)
|
||||
|
||||
tag, err := pool.Exec(context.Background(), "UPDATE t SET x = 1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag.RowsAffected() != 5 {
|
||||
t.Fatalf("rows affected: want 5, got %d", tag.RowsAffected())
|
||||
}
|
||||
if host.calls[0].txHandle != 0 {
|
||||
t.Fatalf("autocommit exec should carry tx_handle 0, got %d", host.calls[0].txHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTxCommitSequence asserts the ordered host calls for a begin/exec/commit
|
||||
// transaction, and that every statement inside carries the tx handle.
|
||||
func TestTxCommitSequence(t *testing.T) {
|
||||
host := &fakeHost{txHandle: 77, execRows: 1}
|
||||
pool := NewPool(host.call)
|
||||
ctx := context.Background()
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "INSERT INTO t VALUES ($1)", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantMethods := []string{methodTxBegin, methodExec, methodTxCommit}
|
||||
if got := host.methods(); !reflect.DeepEqual(got, wantMethods) {
|
||||
t.Fatalf("method sequence: want %v, got %v", wantMethods, got)
|
||||
}
|
||||
if host.calls[1].txHandle != 77 {
|
||||
t.Fatalf("in-tx exec should carry handle 77, got %d", host.calls[1].txHandle)
|
||||
}
|
||||
if host.calls[2].txHandle != 77 {
|
||||
t.Fatalf("commit should carry handle 77, got %d", host.calls[2].txHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTxRollbackThenAutocommit asserts a rollback sequence, and that a query
|
||||
// issued afterward (on the pool) carries NO tx handle.
|
||||
func TestTxRollbackThenAutocommit(t *testing.T) {
|
||||
host := &fakeHost{txHandle: 9, execRows: 1}
|
||||
pool := NewPool(host.call)
|
||||
ctx := context.Background()
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "SELECT 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantMethods := []string{methodTxBegin, methodTxRollback, methodExec}
|
||||
if got := host.methods(); !reflect.DeepEqual(got, wantMethods) {
|
||||
t.Fatalf("method sequence: want %v, got %v", wantMethods, got)
|
||||
}
|
||||
if h := host.calls[1].txHandle; h != 9 {
|
||||
t.Fatalf("rollback should carry handle 9, got %d", h)
|
||||
}
|
||||
if h := host.calls[2].txHandle; h != 0 {
|
||||
t.Fatalf("post-rollback exec must carry tx_handle 0, got %d", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTxUseAfterCloseRejected(t *testing.T) {
|
||||
host := &fakeHost{txHandle: 3}
|
||||
tx, err := NewPool(host.call).Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(context.Background(), "SELECT 1"); !errors.Is(err, pgx.ErrTxClosed) {
|
||||
t.Fatalf("exec after commit: want ErrTxClosed, got %v", err)
|
||||
}
|
||||
if err := tx.Rollback(context.Background()); !errors.Is(err, pgx.ErrTxClosed) {
|
||||
t.Fatalf("rollback after commit: want ErrTxClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedTxRejected(t *testing.T) {
|
||||
host := &fakeHost{txHandle: 1}
|
||||
tx, err := NewPool(host.call).Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Begin(context.Background()); !errors.Is(err, errNestedTx) {
|
||||
t.Fatalf("nested Begin: want errNestedTx, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedArgsRejected(t *testing.T) {
|
||||
host := &fakeHost{}
|
||||
pool := NewPool(host.call)
|
||||
_, err := pool.Query(context.Background(), "SELECT $1", pgx.NamedArgs{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected named-args rejection, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDbErrorSurfacesAsPgError proves a DbError carrying a SQLSTATE reaches the
|
||||
// plugin as a *pgconn.PgError, so errors.As-based constraint checks keep working.
|
||||
func TestDbErrorSurfacesAsPgError(t *testing.T) {
|
||||
host := &fakeHost{dbError: &abiv1.DbError{Code: "23505", Message: "duplicate key"}}
|
||||
_, err := NewPool(host.call).Exec(context.Background(), "INSERT INTO t VALUES (1)")
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
t.Fatalf("want *pgconn.PgError, got %T (%v)", err, err)
|
||||
}
|
||||
if pgErr.Code != "23505" {
|
||||
t.Fatalf("want SQLSTATE 23505, got %q", pgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoHostFailsCleanly(t *testing.T) {
|
||||
pool := NewPool(nil)
|
||||
if _, err := pool.Exec(context.Background(), "SELECT 1"); !errors.Is(err, errNoHost) {
|
||||
t.Fatalf("want errNoHost, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRowNoRows(t *testing.T) {
|
||||
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{Columns: []string{"x"}}}
|
||||
var x int
|
||||
err := NewPool(host.call).QueryRow(context.Background(), "SELECT x").Scan(&x)
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("want ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseSQLDriverRegistered proves the "bnwasm" driver is registered and
|
||||
// drives a SELECT end-to-end through database/sql against the fake host.
|
||||
func TestDatabaseSQLDriverRegistered(t *testing.T) {
|
||||
host := &fakeHost{queryResp: rowsWith(
|
||||
[]string{"id", "name"},
|
||||
&abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 1}},
|
||||
&abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: "alice"}},
|
||||
)}
|
||||
db := sql.OpenDB(NewConnector(host.call))
|
||||
defer db.Close()
|
||||
|
||||
var id int
|
||||
var name string
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT id, name FROM t").Scan(&id, &name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != 1 || name != "alice" {
|
||||
t.Fatalf("got id=%d name=%q", id, name)
|
||||
}
|
||||
if !slicesContains(sql.Drivers(), DriverName) {
|
||||
t.Fatalf("driver %q not registered; have %v", DriverName, sql.Drivers())
|
||||
}
|
||||
}
|
||||
|
||||
func slicesContains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
60
plugin/wasmguest/bnwasm/pool.go
Normal file
60
plugin/wasmguest/bnwasm/pool.go
Normal file
@ -0,0 +1,60 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Pool implements plugin.Pool (the pgx-flavored interface plugins bind their
|
||||
// sqlc DBTX to) over the db.* host calls. It is also itself a valid DBTX: its
|
||||
// Exec/Query/QueryRow run in autocommit (tx_handle 0), so a plugin can pass the
|
||||
// Pool directly to sqlc's New(db) for non-transactional queries, exactly as it
|
||||
// passes a *pgxpool.Pool today.
|
||||
type Pool struct {
|
||||
t Transport
|
||||
}
|
||||
|
||||
// NewPool returns a Pool bound to transport t. A nil t (native build / DESCRIBE
|
||||
// probe) yields a Pool whose every operation fails cleanly with errNoHost.
|
||||
func NewPool(t Transport) *Pool { return &Pool{t: t} }
|
||||
|
||||
// Begin opens a host-side transaction and returns a pgx.Tx bound to its handle.
|
||||
func (p *Pool) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
handle, err := p.t.txBegin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{t: p.t, handle: handle}, nil
|
||||
}
|
||||
|
||||
// Exec runs a statement in autocommit and returns its command tag.
|
||||
func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
return p.t.exec(ctx, sql, 0, args)
|
||||
}
|
||||
|
||||
// Query runs a rows-returning statement in autocommit.
|
||||
func (p *Pool) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
resp, err := p.t.query(ctx, sql, 0, args)
|
||||
if err != nil {
|
||||
return errRows(err), err
|
||||
}
|
||||
return newPgxRows(resp), nil
|
||||
}
|
||||
|
||||
// QueryRow runs a rows-returning statement in autocommit and returns the first
|
||||
// row; any error is deferred to Row.Scan, matching pgx.
|
||||
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
resp, err := p.t.query(ctx, sql, 0, args)
|
||||
if err != nil {
|
||||
return &pgxRow{err: err}
|
||||
}
|
||||
return &pgxRow{rows: newPgxRows(resp)}
|
||||
}
|
||||
|
||||
// errRows is a closed, empty pgx.Rows carrying err, so a Query caller that
|
||||
// ignores the returned error and iterates still terminates and reports it.
|
||||
func errRows(err error) *pgxRows {
|
||||
return &pgxRows{idx: -1, err: err, closed: true}
|
||||
}
|
||||
120
plugin/wasmguest/bnwasm/rows.go
Normal file
120
plugin/wasmguest/bnwasm/rows.go
Normal file
@ -0,0 +1,120 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// pgxRows adapts a buffered DbRowsResponse to the pgx.Rows interface. The full
|
||||
// result set is already materialized (the host returns it in one message), so
|
||||
// iteration is a slice cursor. Only the methods sqlc-generated code exercises
|
||||
// carry real behavior; the rest satisfy the interface.
|
||||
type pgxRows struct {
|
||||
columns []string
|
||||
rows []*abiv1.DbRow
|
||||
idx int // index of the row Next() last advanced to; -1 before first Next
|
||||
err error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newPgxRows(resp *abiv1.DbRowsResponse) *pgxRows {
|
||||
return &pgxRows{columns: resp.GetColumns(), rows: resp.GetRows(), idx: -1}
|
||||
}
|
||||
|
||||
func (r *pgxRows) Close() { r.closed = true }
|
||||
|
||||
func (r *pgxRows) Err() error { return r.err }
|
||||
|
||||
func (r *pgxRows) CommandTag() pgconn.CommandTag {
|
||||
return pgconn.NewCommandTag(fmt.Sprintf("SELECT %d", len(r.rows)))
|
||||
}
|
||||
|
||||
func (r *pgxRows) FieldDescriptions() []pgconn.FieldDescription {
|
||||
fds := make([]pgconn.FieldDescription, len(r.columns))
|
||||
for i, c := range r.columns {
|
||||
fds[i] = pgconn.FieldDescription{Name: c}
|
||||
}
|
||||
return fds
|
||||
}
|
||||
|
||||
func (r *pgxRows) Next() bool {
|
||||
if r.err != nil || r.closed {
|
||||
return false
|
||||
}
|
||||
if r.idx+1 >= len(r.rows) {
|
||||
r.Close() // pgx auto-closes when iteration is exhausted
|
||||
return false
|
||||
}
|
||||
r.idx++
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *pgxRows) Scan(dest ...any) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if r.idx < 0 || r.idx >= len(r.rows) {
|
||||
return errors.New("bnwasm: Scan called without a current row (call Next first)")
|
||||
}
|
||||
if err := scanRow(r.rows[r.idx], dest); err != nil {
|
||||
r.err = err
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgxRows) Values() ([]any, error) {
|
||||
if r.idx < 0 || r.idx >= len(r.rows) {
|
||||
return nil, errors.New("bnwasm: Values called without a current row")
|
||||
}
|
||||
cells := r.rows[r.idx].GetValues()
|
||||
out := make([]any, len(cells))
|
||||
for i, c := range cells {
|
||||
v, _ := naturalValue(c)
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgxRows) RawValues() [][]byte { return nil }
|
||||
|
||||
func (r *pgxRows) Conn() *pgx.Conn { return nil }
|
||||
|
||||
// pgxRow is the single-row QueryRow result. Like pgx, it defers all errors
|
||||
// (including the query error) to Scan, and reports pgx.ErrNoRows when empty.
|
||||
type pgxRow struct {
|
||||
rows *pgxRows
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *pgxRow) Scan(dest ...any) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
defer r.rows.Close()
|
||||
if !r.rows.Next() {
|
||||
if r.rows.Err() != nil {
|
||||
return r.rows.Err()
|
||||
}
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return r.rows.Scan(dest...)
|
||||
}
|
||||
|
||||
func scanRow(row *abiv1.DbRow, dest []any) error {
|
||||
cells := row.GetValues()
|
||||
if len(dest) != len(cells) {
|
||||
return fmt.Errorf("bnwasm: scan expected %d destinations, got %d columns", len(dest), len(cells))
|
||||
}
|
||||
for i, d := range dest {
|
||||
if err := scanValue(cells[i], d); err != nil {
|
||||
return fmt.Errorf("bnwasm: scan column %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
207
plugin/wasmguest/bnwasm/sqlcgen_test.go
Normal file
207
plugin/wasmguest/bnwasm/sqlcgen_test.go
Normal file
@ -0,0 +1,207 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/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, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) 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))
|
||||
}
|
||||
}
|
||||
164
plugin/wasmguest/bnwasm/transport.go
Normal file
164
plugin/wasmguest/bnwasm/transport.go
Normal file
@ -0,0 +1,164 @@
|
||||
// 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/core/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()}
|
||||
}
|
||||
|
||||
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, 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{}, 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, 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 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 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()
|
||||
}
|
||||
102
plugin/wasmguest/bnwasm/tx.go
Normal file
102
plugin/wasmguest/bnwasm/tx.go
Normal file
@ -0,0 +1,102 @@
|
||||
package bnwasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// errNestedTx is returned by Tx.Begin. pgx models a nested Begin as a SAVEPOINT;
|
||||
// the db.* ABI has no savepoint verb (v1 scope), and a grep of the plugin fleet
|
||||
// (symposium, messenger) found no nested-transaction/savepoint use, so this is
|
||||
// rejected explicitly rather than silently degrading correctness.
|
||||
var errNestedTx = errors.New("bnwasm: nested transactions (savepoints) are not supported")
|
||||
|
||||
// Tx is a pgx.Tx bound to a host-side transaction handle. Every Exec/Query/
|
||||
// QueryRow carries the handle so the host runs it on the transaction's
|
||||
// connection; Commit/Rollback release it. After either, the Tx is closed and
|
||||
// further statements return pgx.ErrTxClosed. The host also drops the handle at
|
||||
// the call chain's deadline (WO-WZ-007), so a guest that leaks a Tx without
|
||||
// committing has it rolled back host-side — a guest can never pin a connection.
|
||||
type Tx struct {
|
||||
t Transport
|
||||
handle uint64
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Begin would start a savepoint-backed nested transaction: unsupported.
|
||||
func (tx *Tx) Begin(ctx context.Context) (pgx.Tx, error) { return nil, errNestedTx }
|
||||
|
||||
func (tx *Tx) Commit(ctx context.Context) error {
|
||||
if tx.closed {
|
||||
return pgx.ErrTxClosed
|
||||
}
|
||||
tx.closed = true
|
||||
return tx.t.txCommit(ctx, tx.handle)
|
||||
}
|
||||
|
||||
func (tx *Tx) Rollback(ctx context.Context) error {
|
||||
if tx.closed {
|
||||
return pgx.ErrTxClosed
|
||||
}
|
||||
tx.closed = true
|
||||
return tx.t.txRollback(ctx, tx.handle)
|
||||
}
|
||||
|
||||
func (tx *Tx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
if tx.closed {
|
||||
return pgconn.CommandTag{}, pgx.ErrTxClosed
|
||||
}
|
||||
return tx.t.exec(ctx, sql, tx.handle, args)
|
||||
}
|
||||
|
||||
func (tx *Tx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
if tx.closed {
|
||||
return errRows(pgx.ErrTxClosed), pgx.ErrTxClosed
|
||||
}
|
||||
resp, err := tx.t.query(ctx, sql, tx.handle, args)
|
||||
if err != nil {
|
||||
return errRows(err), err
|
||||
}
|
||||
return newPgxRows(resp), nil
|
||||
}
|
||||
|
||||
func (tx *Tx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
if tx.closed {
|
||||
return &pgxRow{err: pgx.ErrTxClosed}
|
||||
}
|
||||
resp, err := tx.t.query(ctx, sql, tx.handle, args)
|
||||
if err != nil {
|
||||
return &pgxRow{err: err}
|
||||
}
|
||||
return &pgxRow{rows: newPgxRows(resp)}
|
||||
}
|
||||
|
||||
// --- Unsupported pgx.Tx surface (not emitted by sqlc; explicit errors) ---
|
||||
|
||||
func (tx *Tx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) {
|
||||
return 0, errors.New("bnwasm: CopyFrom is not supported over the wasm DB ABI")
|
||||
}
|
||||
|
||||
func (tx *Tx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults {
|
||||
return errBatchResults{errors.New("bnwasm: SendBatch is not supported over the wasm DB ABI")}
|
||||
}
|
||||
|
||||
func (tx *Tx) LargeObjects() pgx.LargeObjects { return pgx.LargeObjects{} }
|
||||
|
||||
func (tx *Tx) Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) {
|
||||
return nil, errors.New("bnwasm: Prepare is not supported over the wasm DB ABI")
|
||||
}
|
||||
|
||||
func (tx *Tx) Conn() *pgx.Conn { return nil }
|
||||
|
||||
// errBatchResults is a pgx.BatchResults that reports the same error from every
|
||||
// method, so an unsupported SendBatch surfaces cleanly instead of nil-panicking.
|
||||
type errBatchResults struct{ err error }
|
||||
|
||||
func (e errBatchResults) Exec() (pgconn.CommandTag, error) { return pgconn.CommandTag{}, e.err }
|
||||
func (e errBatchResults) Query() (pgx.Rows, error) { return errRows(e.err), e.err }
|
||||
func (e errBatchResults) QueryRow() pgx.Row { return &pgxRow{err: e.err} }
|
||||
func (e errBatchResults) Close() error { return e.err }
|
||||
52
plugin/wasmguest/caps/ai.go
Normal file
52
plugin/wasmguest/caps/ai.go
Normal file
@ -0,0 +1,52 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/ai"
|
||||
)
|
||||
|
||||
// aiStub implements ai.ToolRegistry (ai.tools.register) and backs the
|
||||
// CoreServices.AITextCall func field (ai.text_call).
|
||||
//
|
||||
// ToolDefinition.Handler stays guest-side: registration marshals only the
|
||||
// static descriptor. Host→guest tool execution is a runtime-WO concern
|
||||
// flagged in core/docs/wasm-abi.md.
|
||||
type aiStub struct{ base }
|
||||
|
||||
var _ ai.ToolRegistry = (*aiStub)(nil)
|
||||
|
||||
// Register has no error channel; a transport failure is dropped (the host
|
||||
// records nothing, matching the "best effort at load" contract).
|
||||
func (s *aiStub) Register(tool *ai.ToolDefinition) {
|
||||
if tool == nil {
|
||||
return
|
||||
}
|
||||
req := &abiv1.AiToolRegisterRequest{
|
||||
Slug: tool.Slug,
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
}
|
||||
if tool.ParameterSchema != nil {
|
||||
if raw, err := json.Marshal(tool.ParameterSchema); err == nil {
|
||||
req.ParameterSchemaJson = raw
|
||||
}
|
||||
}
|
||||
_ = s.invoke(context.Background(), "tools.register", req, &abiv1.AiToolRegisterResponse{})
|
||||
}
|
||||
|
||||
// textCall backs CoreServices.AITextCall.
|
||||
func (s *aiStub) textCall(ctx context.Context, taskKey, systemPrompt, userMessage string) (string, error) {
|
||||
req := &abiv1.AiTextCallRequest{
|
||||
TaskKey: taskKey,
|
||||
SystemPrompt: systemPrompt,
|
||||
UserMessage: userMessage,
|
||||
}
|
||||
resp := &abiv1.AiTextCallResponse{}
|
||||
if err := s.invoke(ctx, "text_call", req, resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetText(), nil
|
||||
}
|
||||
20
plugin/wasmguest/caps/badges.go
Normal file
20
plugin/wasmguest/caps/badges.go
Normal file
@ -0,0 +1,20 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// badgesStub implements plugin.BadgeRefresher over the badges.refresh_badges
|
||||
// capability call.
|
||||
type badgesStub struct{ base }
|
||||
|
||||
var _ plugin.BadgeRefresher = (*badgesStub)(nil)
|
||||
|
||||
func (s *badgesStub) RefreshBadges(ctx context.Context, tableID, rowID uuid.UUID) error {
|
||||
req := &abiv1.BadgesRefreshBadgesRequest{TableId: tableID.String(), RowId: rowID.String()}
|
||||
return s.invoke(ctx, "refresh_badges", req, &abiv1.BadgesRefreshBadgesResponse{})
|
||||
}
|
||||
35
plugin/wasmguest/caps/bridge.go
Normal file
35
plugin/wasmguest/caps/bridge.go
Normal file
@ -0,0 +1,35 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
)
|
||||
|
||||
// bridgeStub implements plugin.PluginBridge over bridge.* capability calls.
|
||||
//
|
||||
// The bridge shares in-process Go values today; across sandboxes only the
|
||||
// registration/lookup *surface* serializes — the service value itself cannot
|
||||
// cross. RegisterService therefore forwards only plugin/service names (the
|
||||
// value is dropped), and GetService returns nil (availability is checked but a
|
||||
// typed value cannot be reconstructed guest-side). Typed cross-plugin
|
||||
// invocation is an open ABI item (core/docs/wasm-abi.md).
|
||||
type bridgeStub struct{ base }
|
||||
|
||||
var _ plugin.PluginBridge = (*bridgeStub)(nil)
|
||||
|
||||
// RegisterService has no error channel; a transport failure is dropped.
|
||||
func (s *bridgeStub) RegisterService(pluginName, serviceName string, _ any) {
|
||||
req := &abiv1.BridgeRegisterServiceRequest{PluginName: pluginName, ServiceName: serviceName}
|
||||
_ = s.invoke(context.Background(), "register_service", req, &abiv1.BridgeRegisterServiceResponse{})
|
||||
}
|
||||
|
||||
// GetService reports availability host-side but cannot return the concrete Go
|
||||
// value across the sandbox boundary, so it always returns nil. Callers using
|
||||
// plugin.GetServiceAs correctly observe (zero, false).
|
||||
func (s *bridgeStub) GetService(pluginName, serviceName string) any {
|
||||
req := &abiv1.BridgeGetServiceRequest{PluginName: pluginName, ServiceName: serviceName}
|
||||
_ = s.invoke(context.Background(), "get_service", req, &abiv1.BridgeGetServiceResponse{})
|
||||
return nil
|
||||
}
|
||||
89
plugin/wasmguest/caps/caps.go
Normal file
89
plugin/wasmguest/caps/caps.go
Normal file
@ -0,0 +1,89 @@
|
||||
// Package caps implements every CoreServices capability interface
|
||||
// (core/plugin/deps.go) as a guest-side stub that marshals to the WO-WZ-001
|
||||
// capability messages (abiv1) and dispatches them through a single generic
|
||||
// guest→host transport. Plugin code keeps compiling and calling against
|
||||
// content.Content, settings.Settings, plugin.PluginBridge, etc. — unchanged —
|
||||
// while the concrete work now happens host-side over the wasm ABI.
|
||||
//
|
||||
// # The transport seam
|
||||
//
|
||||
// A capability call is "marshal a family request, invoke
|
||||
// '<family>.<method>', unmarshal the reply". That transport is injected as a
|
||||
// CallFunc so the marshaling logic in this package stays natively testable
|
||||
// (a fake CallFunc in caps_roundtrip_test.go), while the wasm guest shim
|
||||
// (package wasmguest, wasip1) binds the real host_call-backed transport. This
|
||||
// package deliberately does NOT import wasmguest: wasmguest imports caps (to
|
||||
// assemble the services and reach the guest-side RAG fetcher registry), so the
|
||||
// dependency only points one way.
|
||||
//
|
||||
// # Method disposition
|
||||
//
|
||||
// Every CoreServices member is either a stub here or documented host-side in
|
||||
// core/docs/wasm-abi.md §"Capability calls". Host-side members (no stub):
|
||||
// Pool (db.* driver), Interceptors (host connect options), AppURL/MediaPath
|
||||
// (delivered in LoadRequest.host_config), and CoreServiceBindings (static
|
||||
// manifest.core_service_bindings the host mounts). RAGService.
|
||||
// RegisterContentFetcher is guest-side (records fetchers for HOOK_RAG_FETCH);
|
||||
// its Query/OnContentChanged marshal out.
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// CallFunc is the guest→host capability transport: marshal req, invoke the
|
||||
// host with method "<family>.<method>", and unmarshal the reply into resp. It
|
||||
// matches wasmguest.CallHost exactly so the wasip1 shim can bind it directly.
|
||||
// A nil CallFunc (native builds, DESCRIBE probes) makes every capability call
|
||||
// fail cleanly with errNoHost rather than panic.
|
||||
type CallFunc func(method string, req, resp proto.Message) error
|
||||
|
||||
// abiCoded is implemented by transport errors that carry an ABI error code
|
||||
// (wasmguest.HostError does). It lets the stubs map a DEADLINE_EXCEEDED reply
|
||||
// onto context.DeadlineExceeded without importing wasmguest.
|
||||
type abiCoded interface {
|
||||
AbiErrorCode() abiv1.AbiErrorCode
|
||||
}
|
||||
|
||||
// base is embedded by every family stub: the family name and the transport.
|
||||
type base struct {
|
||||
family string
|
||||
call CallFunc
|
||||
}
|
||||
|
||||
// invoke performs one capability call and maps any transport error with
|
||||
// family/method context. ctx is honored up front — an already-cancelled or
|
||||
// expired context short-circuits before crossing the boundary — but is not
|
||||
// forwarded to the transport itself (the host enforces the invoke deadline
|
||||
// carried in InvokeRequest.deadline_ms; see core/docs/wasm-abi.md).
|
||||
func (b base) invoke(ctx context.Context, method string, req, resp proto.Message) error {
|
||||
if b.call == nil {
|
||||
return fmt.Errorf("%s.%s: no host transport bound (native build)", b.family, method)
|
||||
}
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%s.%s: %w", b.family, method, err)
|
||||
}
|
||||
}
|
||||
return b.mapErr(method, b.call(b.family+"."+method, req, resp))
|
||||
}
|
||||
|
||||
// mapErr wraps a transport error with family/method context so plugin logs
|
||||
// stay legible, and translates an ABI deadline into context.DeadlineExceeded
|
||||
// so callers' errors.Is(err, context.DeadlineExceeded) keeps working.
|
||||
func (b base) mapErr(method string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var coded abiCoded
|
||||
if errors.As(err, &coded) &&
|
||||
coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_DEADLINE_EXCEEDED {
|
||||
return fmt.Errorf("%s.%s: %w: %v", b.family, method, context.DeadlineExceeded, err)
|
||||
}
|
||||
return fmt.Errorf("%s.%s: %w", b.family, method, err)
|
||||
}
|
||||
677
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal file
677
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal file
@ -0,0 +1,677 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/ai"
|
||||
"git.dev.alexdunmow.com/block/core/gating"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// -update regenerates the golden capability payloads. WO-WZ-006 (cms host)
|
||||
// replays these SAME goldens to prove the host and guest agree on the wire, so
|
||||
// they must stay deterministic (proto marshaled with Deterministic=true).
|
||||
var update = flag.Bool("update", false, "regenerate golden capability payloads")
|
||||
|
||||
const goldenDir = "testdata/golden"
|
||||
|
||||
// Fixed IDs keep the goldens stable across runs.
|
||||
var (
|
||||
idUser = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
idAuthor = uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
idMenu = uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
idItem = uuid.MustParse("44444444-4444-4444-4444-444444444444")
|
||||
idParent = uuid.MustParse("55555555-5555-5555-5555-555555555555")
|
||||
idBucket = uuid.MustParse("66666666-6666-6666-6666-666666666666")
|
||||
idTier = uuid.MustParse("77777777-7777-7777-7777-777777777777")
|
||||
idPlan = uuid.MustParse("88888888-8888-8888-8888-888888888888")
|
||||
idMedia = uuid.MustParse("99999999-9999-9999-9999-999999999999")
|
||||
idTable = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
idRow = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
planTime = time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
func goldenPath(name string) string { return filepath.Join(goldenDir, name+".pb") }
|
||||
|
||||
func detMarshal(t *testing.T, m proto.Message) []byte {
|
||||
t.Helper()
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fakeTransport is the swapped-in CallFunc: it checks the request the stub
|
||||
// produced against a golden, then feeds back a canned response golden.
|
||||
type fakeTransport struct {
|
||||
t *testing.T
|
||||
name string // golden basename for the current call
|
||||
cannedResp proto.Message // synthesizes the resp golden under -update
|
||||
err error // returned instead of a response (error-mapping cases)
|
||||
gotMethod string
|
||||
}
|
||||
|
||||
func (f *fakeTransport) call(method string, req, resp proto.Message) error {
|
||||
f.gotMethod = method
|
||||
reqBytes := detMarshal(f.t, req)
|
||||
reqPath := goldenPath(f.name + "_req")
|
||||
if *update {
|
||||
writeGolden(f.t, reqPath, reqBytes)
|
||||
} else {
|
||||
want, err := os.ReadFile(reqPath)
|
||||
if err != nil {
|
||||
f.t.Fatalf("%s: read request golden (run -update?): %v", f.name, err)
|
||||
}
|
||||
if !equalProto(f.t, want, reqBytes, req) {
|
||||
f.t.Errorf("%s: request payload drifted from golden %s", f.name, reqPath)
|
||||
}
|
||||
}
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
respPath := goldenPath(f.name + "_resp")
|
||||
if *update {
|
||||
respBytes := detMarshal(f.t, f.cannedResp)
|
||||
writeGolden(f.t, respPath, respBytes)
|
||||
if err := proto.Unmarshal(respBytes, resp); err != nil {
|
||||
f.t.Fatalf("%s: unmarshal canned resp: %v", f.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
respBytes, err := os.ReadFile(respPath)
|
||||
if err != nil {
|
||||
f.t.Fatalf("%s: read response golden (run -update?): %v", f.name, err)
|
||||
}
|
||||
if err := proto.Unmarshal(respBytes, resp); err != nil {
|
||||
f.t.Fatalf("%s: unmarshal response golden: %v", f.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeGolden(t *testing.T, path string, b []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir golden: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// equalProto compares two serializations by decoding both into fresh messages
|
||||
// of the same type, so semantically-equal encodings still match.
|
||||
func equalProto(t *testing.T, want, got []byte, sample proto.Message) bool {
|
||||
t.Helper()
|
||||
a := sample.ProtoReflect().New().Interface()
|
||||
b := sample.ProtoReflect().New().Interface()
|
||||
if err := proto.Unmarshal(want, a); err != nil {
|
||||
t.Fatalf("unmarshal want: %v", err)
|
||||
}
|
||||
if err := proto.Unmarshal(got, b); err != nil {
|
||||
t.Fatalf("unmarshal got: %v", err)
|
||||
}
|
||||
return proto.Equal(a, b)
|
||||
}
|
||||
|
||||
// capCase is one capability round trip: set up the fake, call the stub through
|
||||
// the assembled CoreServices, and assert both the wire method and the decoded
|
||||
// Go result.
|
||||
type capCase struct {
|
||||
name string
|
||||
wantMethod string
|
||||
resp proto.Message
|
||||
run func(t *testing.T, cs plugin.CoreServices)
|
||||
}
|
||||
|
||||
func TestCapabilityRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := &fakeTransport{t: t}
|
||||
cs := NewCoreServices(f.call)
|
||||
|
||||
cases := []capCase{
|
||||
// --- content ---
|
||||
{
|
||||
name: "content_get_author_profile", wantMethod: "content.get_author_profile",
|
||||
resp: &abiv1.ContentGetAuthorProfileResponse{Author: &abiv1.AuthorProfile{
|
||||
Id: idAuthor.String(), Name: "Ada", Slug: "ada", Bio: "hi",
|
||||
AvatarUrl: "https://x/a.png", Website: "https://ada.dev",
|
||||
SocialLinks: map[string]string{"x": "@ada", "gh": "ada"},
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Content.GetAuthorProfile(ctx, idAuthor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != idAuthor || got.Name != "Ada" || got.SocialLinks["x"] != "@ada" {
|
||||
t.Errorf("author = %+v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_get_page", wantMethod: "content.get_page",
|
||||
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Content.GetPage(ctx, "about")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Slug != "about" || got.Title != "About Us" {
|
||||
t.Errorf("page = %+v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_get_post", wantMethod: "content.get_post",
|
||||
resp: &abiv1.ContentGetPostResponse{Post: &abiv1.PostInfo{
|
||||
Id: idAuthor.String(), Slug: "hello", Title: "Hello", Excerpt: "hi",
|
||||
FeaturedImageUrl: "media:x", AuthorId: idAuthor.String(),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Content.GetPost(ctx, "hello")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "Hello" || got.AuthorID != idAuthor {
|
||||
t.Errorf("post = %+v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_slugify", wantMethod: "content.slugify",
|
||||
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if got := cs.Content.Slugify("Hello World"); got != "hello-world" {
|
||||
t.Errorf("slug = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_block_note_to_html", wantMethod: "content.block_note_to_html",
|
||||
resp: &abiv1.ContentBlockNoteToHtmlResponse{Html: "<p>hi</p>"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if got := cs.Content.BlockNoteToHTML(ctx, map[string]any{"type": "doc"}); got != "<p>hi</p>" {
|
||||
t.Errorf("html = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_generate_excerpt", wantMethod: "content.generate_excerpt",
|
||||
resp: &abiv1.ContentGenerateExcerptResponse{Excerpt: "short"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if got := cs.Content.GenerateExcerpt("<p>long text</p>", 5); got != "short" {
|
||||
t.Errorf("excerpt = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_strip_html", wantMethod: "content.strip_html",
|
||||
resp: &abiv1.ContentStripHtmlResponse{Text: "plain"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if got := cs.Content.StripHTML("<b>plain</b>"); got != "plain" {
|
||||
t.Errorf("text = %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- settings ---
|
||||
{
|
||||
name: "settings_get_site_settings", wantMethod: "settings.get_site_settings",
|
||||
resp: &abiv1.SettingsGetSiteSettingsResponse{SettingsJson: []byte(`{"site_name":"Fixture","theme":"dark"}`)},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
m, err := cs.Settings.GetSiteSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["site_name"] != "Fixture" {
|
||||
t.Errorf("settings = %+v", m)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "settings_get_plugin_settings", wantMethod: "settings.get_plugin_settings",
|
||||
resp: &abiv1.SettingsGetPluginSettingsResponse{SettingsJson: []byte(`{"enabled":true}`)},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
m, err := cs.Settings.GetPluginSettings(ctx, "symposium")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["enabled"] != true {
|
||||
t.Errorf("plugin settings = %+v", m)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "settings_update_site_setting", wantMethod: "settings.update_site_setting",
|
||||
resp: &abiv1.SettingsUpdateSiteSettingResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if err := cs.SettingsUpdater.UpdateSiteSetting(ctx, "theme", "light"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- gating ---
|
||||
{
|
||||
name: "gating_get_subscriber_tier_level", wantMethod: "gating.get_subscriber_tier_level",
|
||||
resp: &abiv1.GatingGetSubscriberTierLevelResponse{Level: 3},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Gating.GetSubscriberTierLevel(ctx, idUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Errorf("level = %d", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gating_evaluate_access", wantMethod: "gating.evaluate_access",
|
||||
resp: &abiv1.GatingEvaluateAccessResponse{Result: &abiv1.AccessResult{
|
||||
HasAccess: false, TeaserMode: "soft", TeaserPercent: 20, RequiredLevel: 2,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got := cs.Gating.EvaluateAccess(1, &gating.AccessRule{MinTierLevel: 2, TeaserMode: "soft", TeaserPercent: 20})
|
||||
if got.HasAccess || got.TeaserMode != "soft" || got.RequiredLevel != 2 {
|
||||
t.Errorf("access = %+v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- crypto ---
|
||||
{
|
||||
name: "crypto_encrypt_secret", wantMethod: "crypto.encrypt_secret",
|
||||
resp: &abiv1.CryptoEncryptSecretResponse{Ciphertext: "enc:abc"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Crypto.EncryptSecret("plain")
|
||||
if err != nil || got != "enc:abc" {
|
||||
t.Errorf("ciphertext = %q err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "crypto_decrypt_secret", wantMethod: "crypto.decrypt_secret",
|
||||
resp: &abiv1.CryptoDecryptSecretResponse{Plaintext: "plain"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Crypto.DecryptSecret("enc:abc")
|
||||
if err != nil || got != "plain" {
|
||||
t.Errorf("plaintext = %q err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- menus ---
|
||||
{
|
||||
name: "menus_get_menu_by_name", wantMethod: "menus.get_menu_by_name",
|
||||
resp: &abiv1.MenusGetMenuByNameResponse{Menu: &abiv1.Menu{Id: idMenu.String(), Name: "main"}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Menus.GetMenuByName(ctx, "main")
|
||||
if err != nil || got.ID != idMenu || got.Name != "main" {
|
||||
t.Errorf("menu = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "menus_get_menu_items", wantMethod: "menus.get_menu_items",
|
||||
resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{
|
||||
Id: idItem.String(), MenuId: idMenu.String(), Label: "Home", Url: "/",
|
||||
PageSlug: "home", ParentId: proto.String(idParent.String()), SortOrder: 1,
|
||||
OpenInNewTab: true, CssClass: "nav", ItemType: "link", Icon: "home",
|
||||
}}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Menus.GetMenuItems(ctx, idMenu)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("items = %+v err = %v", got, err)
|
||||
}
|
||||
it := got[0]
|
||||
if it.ID != idItem || it.ParentID == nil || *it.ParentID != idParent || !it.OpenInNewTab {
|
||||
t.Errorf("item = %+v", it)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- datasources ---
|
||||
{
|
||||
name: "datasources_resolve_bucket", wantMethod: "datasources.resolve_bucket",
|
||||
resp: &abiv1.DatasourcesResolveBucketResponse{Result: &abiv1.DatasourceResult{
|
||||
ItemsJson: []byte(`[{"id":1},{"id":2}]`), Total: 2, MetaJson: []byte(`{"page":1}`),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Datasources.ResolveBucket(ctx, idBucket)
|
||||
if err != nil || got.Total != 2 || len(got.Items) != 2 || got.Meta["page"] != float64(1) {
|
||||
t.Errorf("result = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datasources_resolve_bucket_by_key", wantMethod: "datasources.resolve_bucket_by_key",
|
||||
resp: &abiv1.DatasourcesResolveBucketByKeyResponse{Result: &abiv1.DatasourceResult{
|
||||
ItemsJson: []byte(`[]`), Total: 0,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Datasources.ResolveBucketByKey(ctx, "featured")
|
||||
if err != nil || got.Total != 0 {
|
||||
t.Errorf("result = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- users ---
|
||||
{
|
||||
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
||||
resp: &abiv1.UsersGetByUsernameResponse{User: &abiv1.PublicUserProfile{
|
||||
Id: idUser.String(), Email: "u@x.com", Username: "u", DisplayName: "U",
|
||||
AvatarUrl: "a", Bio: "b", EmailVerified: true, Role: "member",
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.PublicUsers.GetByUsername(ctx, "u")
|
||||
if err != nil || got.ID != idUser || !got.EmailVerified {
|
||||
t.Errorf("user = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "users_get_by_id", wantMethod: "users.get_by_id",
|
||||
resp: &abiv1.UsersGetByIdResponse{User: &abiv1.PublicUserProfile{Id: idUser.String(), Username: "u"}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.PublicUsers.GetByID(ctx, idUser)
|
||||
if err != nil || got.Username != "u" {
|
||||
t.Errorf("user = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- subscriptions ---
|
||||
{
|
||||
name: "subscriptions_get_user_tier_level", wantMethod: "subscriptions.get_user_tier_level",
|
||||
resp: &abiv1.SubscriptionsGetUserTierLevelResponse{TierLevel: &abiv1.TierLevel{Level: 5, Features: []byte(`{"a":1}`)}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Subscriptions.GetUserTierLevel(ctx, idUser)
|
||||
if err != nil || got.Level != 5 || string(got.Features) != `{"a":1}` {
|
||||
t.Errorf("tier level = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscriptions_get_tier_by_slug", wantMethod: "subscriptions.get_tier_by_slug",
|
||||
resp: &abiv1.SubscriptionsGetTierBySlugResponse{Tier: &abiv1.Tier{
|
||||
Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3, Description: "d",
|
||||
Features: []byte(`{}`), IsDefault: true, Position: 2,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Subscriptions.GetTierBySlug(ctx, "gold")
|
||||
if err != nil || got.ID != idTier || got.Level != 3 || !got.IsDefault {
|
||||
t.Errorf("tier = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscriptions_list_tiers", wantMethod: "subscriptions.list_tiers",
|
||||
resp: &abiv1.SubscriptionsListTiersResponse{Tiers: []*abiv1.Tier{
|
||||
{Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3},
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Subscriptions.ListTiers(ctx)
|
||||
if err != nil || len(got) != 1 || got[0].Slug != "gold" {
|
||||
t.Errorf("tiers = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscriptions_list_active_plans", wantMethod: "subscriptions.list_active_plans",
|
||||
resp: &abiv1.SubscriptionsListActivePlansResponse{Plans: []*abiv1.Plan{{
|
||||
Id: idPlan.String(), TierId: idTier.String(), BillingInterval: "month",
|
||||
Amount: 4900, Currency: "usd", IsActive: true, CreatedAt: timestamppb.New(planTime),
|
||||
}}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Subscriptions.ListActivePlans(ctx, idTier)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("plans = %+v err = %v", got, err)
|
||||
}
|
||||
p := got[0]
|
||||
if p.ID != idPlan || p.Amount != 4900 || !p.CreatedAt.Equal(planTime) {
|
||||
t.Errorf("plan = %+v", p)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- media ---
|
||||
{
|
||||
name: "media_deposit", wantMethod: "media.deposit",
|
||||
resp: &abiv1.MediaDepositResponse{Id: idMedia.String(), Ref: "media:" + idMedia.String(), Created: true},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Media.Deposit(ctx, plugin.MediaDeposit{
|
||||
ID: idMedia, Filename: "hero.jpg", Data: []byte("bytes"), AltText: "alt", Folder: "f", Source: "plugin",
|
||||
})
|
||||
if err != nil || got.ID != idMedia || !got.Created {
|
||||
t.Errorf("media = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- email ---
|
||||
{
|
||||
name: "email_send", wantMethod: "email.send",
|
||||
resp: &abiv1.EmailSendResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if err := cs.EmailSender.Send("to@x.com", "Subj", "Body"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- ai ---
|
||||
{
|
||||
name: "ai_text_call", wantMethod: "ai.text_call",
|
||||
resp: &abiv1.AiTextCallResponse{Text: "generated"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.AITextCall(ctx, "summarize", "sys", "user")
|
||||
if err != nil || got != "generated" {
|
||||
t.Errorf("text = %q err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai_tools_register", wantMethod: "ai.tools.register",
|
||||
resp: &abiv1.AiToolRegisterResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
cs.ToolRegistry.Register(&ai.ToolDefinition{
|
||||
Slug: "lookup", Name: "Lookup", Description: "d",
|
||||
ParameterSchema: map[string]any{"type": "object"},
|
||||
})
|
||||
},
|
||||
},
|
||||
// --- bridge ---
|
||||
{
|
||||
name: "bridge_register_service", wantMethod: "bridge.register_service",
|
||||
resp: &abiv1.BridgeRegisterServiceResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
cs.Bridge.RegisterService("symposium", "search", struct{}{})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bridge_get_service", wantMethod: "bridge.get_service",
|
||||
resp: &abiv1.BridgeGetServiceResponse{Available: true},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if got := cs.Bridge.GetService("symposium", "search"); got != nil {
|
||||
t.Errorf("get_service crossed a value: %v (want nil)", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- jobs ---
|
||||
{
|
||||
name: "jobs_submit", wantMethod: "jobs.submit",
|
||||
resp: &abiv1.JobsSubmitResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if err := cs.JobRunner.Submit(ctx, "reindex", []byte(`{"full":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- embeddings ---
|
||||
{
|
||||
name: "embeddings_generate_embedding", wantMethod: "embeddings.generate_embedding",
|
||||
resp: &abiv1.EmbeddingsGenerateEmbeddingResponse{Embedding: []float32{0.1, 0.2, 0.3}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.EmbeddingService.GenerateEmbedding(ctx, "text")
|
||||
if err != nil || len(got) != 3 || got[0] != 0.1 {
|
||||
t.Errorf("embedding = %v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embeddings_embed_content", wantMethod: "embeddings.embed_content",
|
||||
resp: &abiv1.EmbeddingsEmbedContentResponse{Embedded: true},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.EmbeddingService.EmbedContent(ctx, "post", idRow, "text")
|
||||
if err != nil || !got {
|
||||
t.Errorf("embedded = %v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embeddings_is_available", wantMethod: "embeddings.is_available",
|
||||
resp: &abiv1.EmbeddingsIsAvailableResponse{Available: true},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if !cs.EmbeddingService.IsAvailable() {
|
||||
t.Errorf("is_available = false")
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- rag ---
|
||||
{
|
||||
name: "rag_query", wantMethod: "rag.query",
|
||||
resp: &abiv1.RagQueryResponse{Results: []*abiv1.RagResult{
|
||||
{Content: "chunk", Score: 0.9, Metadata: map[string]string{"src": "post"}},
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.RAGService.Query(ctx, "q", 5)
|
||||
if err != nil || len(got) != 1 || got[0].Score != 0.9 || got[0].Metadata["src"] != "post" {
|
||||
t.Errorf("rag = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rag_on_content_changed", wantMethod: "rag.on_content_changed",
|
||||
resp: &abiv1.RagOnContentChangedResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
cs.RAGService.OnContentChanged(ctx, "post", idRow)
|
||||
},
|
||||
},
|
||||
// --- reviews ---
|
||||
{
|
||||
name: "reviews_submit_review", wantMethod: "reviews.submit_review",
|
||||
resp: &abiv1.ReviewsSubmitReviewResponse{ReviewId: "rev-1"},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.ReviewSubmitter.SubmitReview(ctx, plugin.SubmitReviewParams{
|
||||
TableID: idTable, RowID: idRow, OverallRating: 4, ReviewText: "good",
|
||||
Ratings: map[string]any{"food": 5}, Photos: []string{"media:1"},
|
||||
})
|
||||
if err != nil || got != "rev-1" {
|
||||
t.Errorf("review id = %q err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- badges ---
|
||||
{
|
||||
name: "badges_refresh_badges", wantMethod: "badges.refresh_badges",
|
||||
resp: &abiv1.BadgesRefreshBadgesResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
if err := cs.BadgeRefresher.RefreshBadges(ctx, idTable, idRow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f.name = tc.name
|
||||
f.cannedResp = tc.resp
|
||||
f.err = nil
|
||||
f.gotMethod = ""
|
||||
tc.run(t, cs)
|
||||
if f.gotMethod != tc.wantMethod {
|
||||
t.Errorf("method = %q, want %q", f.gotMethod, tc.wantMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAbiErr is a transport error carrying an ABI code, like wasmguest.HostError.
|
||||
type fakeAbiErr struct {
|
||||
code abiv1.AbiErrorCode
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *fakeAbiErr) Error() string { return e.msg }
|
||||
func (e *fakeAbiErr) AbiErrorCode() abiv1.AbiErrorCode { return e.code }
|
||||
|
||||
func TestErrorMappingCarriesFamilyMethod(t *testing.T) {
|
||||
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED, msg: "denied"}}
|
||||
cs := NewCoreServices(f.call)
|
||||
f.name = "content_get_page" // reuse the request golden for the compare
|
||||
|
||||
_, err := cs.Content.GetPage(context.Background(), "about")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "content.get_page") || !strings.Contains(got, "denied") {
|
||||
t.Errorf("error %q lacks family/method or message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMappingDeadlineBecomesContextDeadline(t *testing.T) {
|
||||
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DEADLINE_EXCEEDED, msg: "deadline"}}
|
||||
cs := NewCoreServices(f.call)
|
||||
f.name = "crypto_encrypt_secret"
|
||||
|
||||
_, err := cs.Crypto.EncryptSecret("plain")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("error %q does not wrap context.DeadlineExceeded", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "crypto.encrypt_secret") {
|
||||
t.Errorf("error %q lacks family/method", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilTransportFailsCleanly(t *testing.T) {
|
||||
cs := NewCoreServices(nil)
|
||||
_, err := cs.Content.GetPage(context.Background(), "about")
|
||||
if err == nil || !strings.Contains(err.Error(), "no host transport") {
|
||||
t.Errorf("nil transport error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRAGRegisterContentFetcherStaysGuestSide verifies RegisterContentFetcher
|
||||
// records fetchers locally (no host call) for HOOK_RAG_FETCH dispatch.
|
||||
func TestRAGRegisterContentFetcherStaysGuestSide(t *testing.T) {
|
||||
f := &fakeTransport{t: t}
|
||||
cs := NewCoreServices(f.call)
|
||||
rag, ok := cs.RAGService.(*RAGStub)
|
||||
if !ok {
|
||||
t.Fatalf("RAGService is %T, want *RAGStub", cs.RAGService)
|
||||
}
|
||||
called := false
|
||||
rag.RegisterContentFetcher("post", func(context.Context, uuid.UUID) (string, string, error) {
|
||||
called = true
|
||||
return "T", "body", nil
|
||||
})
|
||||
if f.gotMethod != "" {
|
||||
t.Errorf("RegisterContentFetcher made a host call %q", f.gotMethod)
|
||||
}
|
||||
if types := rag.FetcherTypes(); len(types) != 1 || types[0] != "post" {
|
||||
t.Errorf("fetcher types = %v", types)
|
||||
}
|
||||
fetcher, ok := rag.Fetcher("post")
|
||||
if !ok {
|
||||
t.Fatal("fetcher not found")
|
||||
}
|
||||
if _, _, _ = fetcher(context.Background(), idRow); !called {
|
||||
t.Error("registered fetcher not invoked")
|
||||
}
|
||||
}
|
||||
105
plugin/wasmguest/caps/content.go
Normal file
105
plugin/wasmguest/caps/content.go
Normal file
@ -0,0 +1,105 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/content"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// contentStub implements content.Content over content.* capability calls.
|
||||
type contentStub struct{ base }
|
||||
|
||||
var _ content.Content = (*contentStub)(nil)
|
||||
|
||||
func (s *contentStub) GetAuthorProfile(ctx context.Context, id uuid.UUID) (*content.AuthorProfile, error) {
|
||||
resp := &abiv1.ContentGetAuthorProfileResponse{}
|
||||
if err := s.invoke(ctx, "get_author_profile", &abiv1.ContentGetAuthorProfileRequest{Id: id.String()}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := resp.GetAuthor()
|
||||
if a == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &content.AuthorProfile{
|
||||
ID: parseUUID(a.GetId()),
|
||||
Name: a.GetName(),
|
||||
Slug: a.GetSlug(),
|
||||
Bio: a.GetBio(),
|
||||
AvatarURL: a.GetAvatarUrl(),
|
||||
Website: a.GetWebsite(),
|
||||
SocialLinks: a.GetSocialLinks(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *contentStub) GetPage(ctx context.Context, slug string) (*content.PageInfo, error) {
|
||||
resp := &abiv1.ContentGetPageResponse{}
|
||||
if err := s.invoke(ctx, "get_page", &abiv1.ContentGetPageRequest{Slug: slug}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := resp.GetPage()
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &content.PageInfo{ID: parseUUID(p.GetId()), Slug: p.GetSlug(), Title: p.GetTitle()}, nil
|
||||
}
|
||||
|
||||
func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostInfo, error) {
|
||||
resp := &abiv1.ContentGetPostResponse{}
|
||||
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := resp.GetPost()
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &content.PostInfo{
|
||||
ID: parseUUID(p.GetId()),
|
||||
Slug: p.GetSlug(),
|
||||
Title: p.GetTitle(),
|
||||
Excerpt: p.GetExcerpt(),
|
||||
FeaturedImageURL: p.GetFeaturedImageUrl(),
|
||||
AuthorID: parseUUID(p.GetAuthorId()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Slugify has no error channel in the interface; a transport failure degrades
|
||||
// to the empty string (the caller treats an empty slug as "unavailable").
|
||||
func (s *contentStub) Slugify(text string) string {
|
||||
resp := &abiv1.ContentSlugifyResponse{}
|
||||
if err := s.invoke(context.Background(), "slugify", &abiv1.ContentSlugifyRequest{Text: text}, resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
return resp.GetSlug()
|
||||
}
|
||||
|
||||
func (s *contentStub) BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
|
||||
docJSON, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resp := &abiv1.ContentBlockNoteToHtmlResponse{}
|
||||
if err := s.invoke(ctx, "block_note_to_html", &abiv1.ContentBlockNoteToHtmlRequest{DocJson: docJSON}, resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
return resp.GetHtml()
|
||||
}
|
||||
|
||||
func (s *contentStub) GenerateExcerpt(html string, maxLen int) string {
|
||||
resp := &abiv1.ContentGenerateExcerptResponse{}
|
||||
req := &abiv1.ContentGenerateExcerptRequest{Html: html, MaxLen: int32(maxLen)}
|
||||
if err := s.invoke(context.Background(), "generate_excerpt", req, resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
return resp.GetExcerpt()
|
||||
}
|
||||
|
||||
func (s *contentStub) StripHTML(str string) string {
|
||||
resp := &abiv1.ContentStripHtmlResponse{}
|
||||
if err := s.invoke(context.Background(), "strip_html", &abiv1.ContentStripHtmlRequest{Html: str}, resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
return resp.GetText()
|
||||
}
|
||||
49
plugin/wasmguest/caps/coreservices.go
Normal file
49
plugin/wasmguest/caps/coreservices.go
Normal file
@ -0,0 +1,49 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
)
|
||||
|
||||
// NewCoreServices assembles the guest-side CoreServices value plugins receive
|
||||
// in their Load/HTTP/Job hooks: every capability interface is a stub that
|
||||
// marshals to a "<family>.<method>" host call over the injected transport.
|
||||
//
|
||||
// The transport is injected (not a package global) so the marshaling logic is
|
||||
// natively testable with a fake CallFunc; the wasm shim (package wasmguest,
|
||||
// wasip1) passes its host_call-backed CallHost. A nil call yields stubs that
|
||||
// fail every capability with a clear "no host transport" error — the shape
|
||||
// used by DESCRIBE probes, which never reach a live host.
|
||||
//
|
||||
// Members intentionally left zero because they do NOT cross as capability
|
||||
// calls (all documented in core/docs/wasm-abi.md §"Capability calls"):
|
||||
//
|
||||
// - Pool → the db.* driver messages (db.proto)
|
||||
// - Interceptors → host-side connect options; RBAC from the manifest
|
||||
// - MediaPath / AppURL → delivered in LoadRequest.host_config at load
|
||||
// - CoreServiceBindings → static manifest.core_service_bindings; host mounts
|
||||
func NewCoreServices(call CallFunc) plugin.CoreServices {
|
||||
settings := &settingsStub{base{family: "settings", call: call}}
|
||||
ai := &aiStub{base{family: "ai", call: call}}
|
||||
|
||||
return plugin.CoreServices{
|
||||
Content: &contentStub{base{family: "content", call: call}},
|
||||
Settings: settings,
|
||||
SettingsUpdater: settings,
|
||||
Gating: &gatingStub{base{family: "gating", call: call}},
|
||||
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
||||
Menus: &menusStub{base{family: "menus", call: call}},
|
||||
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
||||
PublicUsers: &usersStub{base{family: "users", call: call}},
|
||||
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
||||
Media: &mediaStub{base{family: "media", call: call}},
|
||||
ToolRegistry: ai,
|
||||
AITextCall: ai.textCall,
|
||||
EmailSender: &emailStub{base{family: "email", call: call}},
|
||||
Bridge: &bridgeStub{base{family: "bridge", call: call}},
|
||||
ReviewSubmitter: &reviewsStub{base{family: "reviews", call: call}},
|
||||
BadgeRefresher: &badgesStub{base{family: "badges", call: call}},
|
||||
JobRunner: &jobsStub{base{family: "jobs", call: call}},
|
||||
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
||||
RAGService: NewRAGStub(call),
|
||||
}
|
||||
}
|
||||
30
plugin/wasmguest/caps/crypto.go
Normal file
30
plugin/wasmguest/caps/crypto.go
Normal file
@ -0,0 +1,30 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/crypto"
|
||||
)
|
||||
|
||||
// cryptoStub implements crypto.Crypto over crypto.* capability calls. The
|
||||
// interface takes no context; host calls run under the invoke deadline.
|
||||
type cryptoStub struct{ base }
|
||||
|
||||
var _ crypto.Crypto = (*cryptoStub)(nil)
|
||||
|
||||
func (s *cryptoStub) EncryptSecret(plaintext string) (string, error) {
|
||||
resp := &abiv1.CryptoEncryptSecretResponse{}
|
||||
if err := s.invoke(context.Background(), "encrypt_secret", &abiv1.CryptoEncryptSecretRequest{Plaintext: plaintext}, resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetCiphertext(), nil
|
||||
}
|
||||
|
||||
func (s *cryptoStub) DecryptSecret(ciphertext string) (string, error) {
|
||||
resp := &abiv1.CryptoDecryptSecretResponse{}
|
||||
if err := s.invoke(context.Background(), "decrypt_secret", &abiv1.CryptoDecryptSecretRequest{Ciphertext: ciphertext}, resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetPlaintext(), nil
|
||||
}
|
||||
46
plugin/wasmguest/caps/datasources.go
Normal file
46
plugin/wasmguest/caps/datasources.go
Normal file
@ -0,0 +1,46 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/datasources"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// datasourcesStub implements datasources.Datasources over datasources.*
|
||||
// capability calls. Items ([]any) and Meta (map[string]any) cross as JSON.
|
||||
type datasourcesStub struct{ base }
|
||||
|
||||
var _ datasources.Datasources = (*datasourcesStub)(nil)
|
||||
|
||||
func (s *datasourcesStub) ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*datasources.Result, error) {
|
||||
resp := &abiv1.DatasourcesResolveBucketResponse{}
|
||||
req := &abiv1.DatasourcesResolveBucketRequest{BucketId: bucketID.String()}
|
||||
if err := s.invoke(ctx, "resolve_bucket", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datasourceResultFromProto(resp.GetResult()), nil
|
||||
}
|
||||
|
||||
func (s *datasourcesStub) ResolveBucketByKey(ctx context.Context, bucketKey string) (*datasources.Result, error) {
|
||||
resp := &abiv1.DatasourcesResolveBucketByKeyResponse{}
|
||||
req := &abiv1.DatasourcesResolveBucketByKeyRequest{BucketKey: bucketKey}
|
||||
if err := s.invoke(ctx, "resolve_bucket_by_key", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datasourceResultFromProto(resp.GetResult()), nil
|
||||
}
|
||||
|
||||
func datasourceResultFromProto(r *abiv1.DatasourceResult) *datasources.Result {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := &datasources.Result{Total: int(r.GetTotal())}
|
||||
if items := r.GetItemsJson(); len(items) > 0 {
|
||||
_ = json.Unmarshal(items, &out.Items)
|
||||
}
|
||||
out.Meta = unmarshalMap(r.GetMetaJson())
|
||||
return out
|
||||
}
|
||||
19
plugin/wasmguest/caps/email.go
Normal file
19
plugin/wasmguest/caps/email.go
Normal file
@ -0,0 +1,19 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
)
|
||||
|
||||
// emailStub implements plugin.EmailSender over the email.send capability call.
|
||||
// The interface takes no context; the call runs under the invoke deadline.
|
||||
type emailStub struct{ base }
|
||||
|
||||
var _ plugin.EmailSender = (*emailStub)(nil)
|
||||
|
||||
func (s *emailStub) Send(to, subject, body string) error {
|
||||
req := &abiv1.EmailSendRequest{To: to, Subject: subject, Body: body}
|
||||
return s.invoke(context.Background(), "send", req, &abiv1.EmailSendResponse{})
|
||||
}
|
||||
46
plugin/wasmguest/caps/embeddings.go
Normal file
46
plugin/wasmguest/caps/embeddings.go
Normal file
@ -0,0 +1,46 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// embeddingsStub implements plugin.EmbeddingService over embeddings.*
|
||||
// capability calls.
|
||||
type embeddingsStub struct{ base }
|
||||
|
||||
var _ plugin.EmbeddingService = (*embeddingsStub)(nil)
|
||||
|
||||
func (s *embeddingsStub) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) {
|
||||
resp := &abiv1.EmbeddingsGenerateEmbeddingResponse{}
|
||||
req := &abiv1.EmbeddingsGenerateEmbeddingRequest{Text: text}
|
||||
if err := s.invoke(ctx, "generate_embedding", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetEmbedding(), nil
|
||||
}
|
||||
|
||||
func (s *embeddingsStub) EmbedContent(ctx context.Context, sourceType string, sourceID uuid.UUID, text string) (bool, error) {
|
||||
resp := &abiv1.EmbeddingsEmbedContentResponse{}
|
||||
req := &abiv1.EmbeddingsEmbedContentRequest{
|
||||
SourceType: sourceType,
|
||||
SourceId: sourceID.String(),
|
||||
Text: text,
|
||||
}
|
||||
if err := s.invoke(ctx, "embed_content", req, resp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetEmbedded(), nil
|
||||
}
|
||||
|
||||
// IsAvailable has no error channel; a transport failure reports unavailable.
|
||||
func (s *embeddingsStub) IsAvailable() bool {
|
||||
resp := &abiv1.EmbeddingsIsAvailableResponse{}
|
||||
if err := s.invoke(context.Background(), "is_available", &abiv1.EmbeddingsIsAvailableRequest{}, resp); err != nil {
|
||||
return false
|
||||
}
|
||||
return resp.GetAvailable()
|
||||
}
|
||||
51
plugin/wasmguest/caps/gating.go
Normal file
51
plugin/wasmguest/caps/gating.go
Normal file
@ -0,0 +1,51 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/gating"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// gatingStub implements gating.Gating over gating.* capability calls.
|
||||
type gatingStub struct{ base }
|
||||
|
||||
var _ gating.Gating = (*gatingStub)(nil)
|
||||
|
||||
func (s *gatingStub) GetSubscriberTierLevel(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
resp := &abiv1.GatingGetSubscriberTierLevelResponse{}
|
||||
req := &abiv1.GatingGetSubscriberTierLevelRequest{UserId: userID.String()}
|
||||
if err := s.invoke(ctx, "get_subscriber_tier_level", req, resp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(resp.GetLevel()), nil
|
||||
}
|
||||
|
||||
// EvaluateAccess mirrors the host's decision. The rule evaluation is a pure,
|
||||
// deterministic function (gating.EvaluateAccess) so the interface exposes no
|
||||
// error channel; the call still crosses the boundary to keep host and guest
|
||||
// on identical logic, and falls back to the local pure function if the
|
||||
// transport is unavailable.
|
||||
func (s *gatingStub) EvaluateAccess(userTierLevel int, rule *gating.AccessRule) gating.AccessResult {
|
||||
req := &abiv1.GatingEvaluateAccessRequest{UserTierLevel: int32(userTierLevel)}
|
||||
if rule != nil {
|
||||
req.Rule = &abiv1.AccessRule{
|
||||
MinTierLevel: int32(rule.MinTierLevel),
|
||||
OverrideTierId: rule.OverrideTierID,
|
||||
TeaserMode: rule.TeaserMode,
|
||||
TeaserPercent: int32(rule.TeaserPercent),
|
||||
}
|
||||
}
|
||||
resp := &abiv1.GatingEvaluateAccessResponse{}
|
||||
if err := s.invoke(context.Background(), "evaluate_access", req, resp); err != nil {
|
||||
return gating.EvaluateAccess(userTierLevel, rule)
|
||||
}
|
||||
r := resp.GetResult()
|
||||
return gating.AccessResult{
|
||||
HasAccess: r.GetHasAccess(),
|
||||
TeaserMode: r.GetTeaserMode(),
|
||||
TeaserPercent: int(r.GetTeaserPercent()),
|
||||
RequiredLevel: int(r.GetRequiredLevel()),
|
||||
}
|
||||
}
|
||||
18
plugin/wasmguest/caps/jobs.go
Normal file
18
plugin/wasmguest/caps/jobs.go
Normal file
@ -0,0 +1,18 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
)
|
||||
|
||||
// jobsStub implements plugin.JobRunner over the jobs.submit capability call.
|
||||
type jobsStub struct{ base }
|
||||
|
||||
var _ plugin.JobRunner = (*jobsStub)(nil)
|
||||
|
||||
func (s *jobsStub) Submit(ctx context.Context, jobType string, config []byte) error {
|
||||
req := &abiv1.JobsSubmitRequest{JobType: jobType, ConfigJson: config}
|
||||
return s.invoke(ctx, "submit", req, &abiv1.JobsSubmitResponse{})
|
||||
}
|
||||
36
plugin/wasmguest/caps/media.go
Normal file
36
plugin/wasmguest/caps/media.go
Normal file
@ -0,0 +1,36 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// mediaStub implements plugin.Media over the media.deposit capability call.
|
||||
type mediaStub struct{ base }
|
||||
|
||||
var _ plugin.Media = (*mediaStub)(nil)
|
||||
|
||||
func (s *mediaStub) Deposit(ctx context.Context, deposit plugin.MediaDeposit) (plugin.MediaResult, error) {
|
||||
req := &abiv1.MediaDepositRequest{
|
||||
Filename: deposit.Filename,
|
||||
Data: deposit.Data,
|
||||
AltText: deposit.AltText,
|
||||
Folder: deposit.Folder,
|
||||
Source: deposit.Source,
|
||||
}
|
||||
if deposit.ID != uuid.Nil {
|
||||
req.Id = deposit.ID.String()
|
||||
}
|
||||
resp := &abiv1.MediaDepositResponse{}
|
||||
if err := s.invoke(ctx, "deposit", req, resp); err != nil {
|
||||
return plugin.MediaResult{}, err
|
||||
}
|
||||
return plugin.MediaResult{
|
||||
ID: parseUUID(resp.GetId()),
|
||||
Ref: resp.GetRef(),
|
||||
Created: resp.GetCreated(),
|
||||
}, nil
|
||||
}
|
||||
55
plugin/wasmguest/caps/menus.go
Normal file
55
plugin/wasmguest/caps/menus.go
Normal file
@ -0,0 +1,55 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/menus"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// menusStub implements menus.Menus over menus.* capability calls.
|
||||
type menusStub struct{ base }
|
||||
|
||||
var _ menus.Menus = (*menusStub)(nil)
|
||||
|
||||
func (s *menusStub) GetMenuByName(ctx context.Context, name string) (*menus.Menu, error) {
|
||||
resp := &abiv1.MenusGetMenuByNameResponse{}
|
||||
if err := s.invoke(ctx, "get_menu_by_name", &abiv1.MenusGetMenuByNameRequest{Name: name}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := resp.GetMenu()
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &menus.Menu{ID: parseUUID(m.GetId()), Name: m.GetName()}, nil
|
||||
}
|
||||
|
||||
func (s *menusStub) GetMenuItems(ctx context.Context, menuID uuid.UUID) ([]menus.MenuItem, error) {
|
||||
resp := &abiv1.MenusGetMenuItemsResponse{}
|
||||
req := &abiv1.MenusGetMenuItemsRequest{MenuId: menuID.String()}
|
||||
if err := s.invoke(ctx, "get_menu_items", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]menus.MenuItem, 0, len(resp.GetItems()))
|
||||
for _, it := range resp.GetItems() {
|
||||
mi := menus.MenuItem{
|
||||
ID: parseUUID(it.GetId()),
|
||||
MenuID: parseUUID(it.GetMenuId()),
|
||||
Label: it.GetLabel(),
|
||||
URL: it.GetUrl(),
|
||||
PageSlug: it.GetPageSlug(),
|
||||
SortOrder: it.GetSortOrder(),
|
||||
OpenInNewTab: it.GetOpenInNewTab(),
|
||||
CssClass: it.GetCssClass(),
|
||||
ItemType: it.GetItemType(),
|
||||
Icon: it.GetIcon(),
|
||||
}
|
||||
if it.ParentId != nil {
|
||||
pid := parseUUID(it.GetParentId())
|
||||
mi.ParentID = &pid
|
||||
}
|
||||
items = append(items, mi)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
78
plugin/wasmguest/caps/rag.go
Normal file
78
plugin/wasmguest/caps/rag.go
Normal file
@ -0,0 +1,78 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RAGStub implements plugin.RAGService. Query and OnContentChanged marshal out
|
||||
// to rag.* capability calls, while RegisterContentFetcher records the fetcher
|
||||
// guest-side: the host re-indexes by inverting the call as a HOOK_RAG_FETCH
|
||||
// callback, so the wasm shim reads the recorded fetchers to dispatch it. The
|
||||
// stub is exported so the shim can reach FetcherTypes/Fetcher.
|
||||
type RAGStub struct {
|
||||
base
|
||||
fetchers map[string]plugin.ContentFetcher
|
||||
}
|
||||
|
||||
var _ plugin.RAGService = (*RAGStub)(nil)
|
||||
|
||||
// NewRAGStub builds a RAG stub with the given transport. A nil transport
|
||||
// (DESCRIBE probe, native build) still records fetchers so their content
|
||||
// types can be captured into the manifest.
|
||||
func NewRAGStub(call CallFunc) *RAGStub {
|
||||
return &RAGStub{
|
||||
base: base{family: "rag", call: call},
|
||||
fetchers: make(map[string]plugin.ContentFetcher),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RAGStub) RegisterContentFetcher(contentType string, fetcher plugin.ContentFetcher) {
|
||||
s.fetchers[contentType] = fetcher
|
||||
}
|
||||
|
||||
func (s *RAGStub) Query(ctx context.Context, query string, limit int) ([]plugin.RAGResult, error) {
|
||||
req := &abiv1.RagQueryRequest{Query: query, Limit: int32(limit)}
|
||||
resp := &abiv1.RagQueryResponse{}
|
||||
if err := s.invoke(ctx, "query", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]plugin.RAGResult, 0, len(resp.GetResults()))
|
||||
for _, r := range resp.GetResults() {
|
||||
results = append(results, plugin.RAGResult{
|
||||
Content: r.GetContent(),
|
||||
Score: r.GetScore(),
|
||||
Metadata: r.GetMetadata(),
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// OnContentChanged has no error channel; a transport failure is dropped (the
|
||||
// host will re-index on its own schedule regardless).
|
||||
func (s *RAGStub) OnContentChanged(ctx context.Context, contentType string, contentID uuid.UUID) {
|
||||
req := &abiv1.RagOnContentChangedRequest{ContentType: contentType, ContentId: contentID.String()}
|
||||
_ = s.invoke(ctx, "on_content_changed", req, &abiv1.RagOnContentChangedResponse{})
|
||||
}
|
||||
|
||||
// Fetcher returns the content fetcher registered for a content type, for
|
||||
// HOOK_RAG_FETCH dispatch by the wasm shim.
|
||||
func (s *RAGStub) Fetcher(contentType string) (plugin.ContentFetcher, bool) {
|
||||
f, ok := s.fetchers[contentType]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// FetcherTypes lists the registered content-fetcher types in sorted order
|
||||
// (deterministic manifest capture).
|
||||
func (s *RAGStub) FetcherTypes() []string {
|
||||
types := make([]string, 0, len(s.fetchers))
|
||||
for k := range s.fetchers {
|
||||
types = append(types, k)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
35
plugin/wasmguest/caps/reviews.go
Normal file
35
plugin/wasmguest/caps/reviews.go
Normal file
@ -0,0 +1,35 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
)
|
||||
|
||||
// reviewsStub implements plugin.ReviewSubmitter over the reviews.submit_review
|
||||
// capability call.
|
||||
type reviewsStub struct{ base }
|
||||
|
||||
var _ plugin.ReviewSubmitter = (*reviewsStub)(nil)
|
||||
|
||||
func (s *reviewsStub) SubmitReview(ctx context.Context, params plugin.SubmitReviewParams) (string, error) {
|
||||
req := &abiv1.ReviewsSubmitReviewRequest{
|
||||
TableId: params.TableID.String(),
|
||||
RowId: params.RowID.String(),
|
||||
OverallRating: params.OverallRating,
|
||||
ReviewText: params.ReviewText,
|
||||
Photos: params.Photos,
|
||||
}
|
||||
if params.Ratings != nil {
|
||||
if raw, err := json.Marshal(params.Ratings); err == nil {
|
||||
req.RatingsJson = raw
|
||||
}
|
||||
}
|
||||
resp := &abiv1.ReviewsSubmitReviewResponse{}
|
||||
if err := s.invoke(ctx, "submit_review", req, resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetReviewId(), nil
|
||||
}
|
||||
44
plugin/wasmguest/caps/settings.go
Normal file
44
plugin/wasmguest/caps/settings.go
Normal file
@ -0,0 +1,44 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/settings"
|
||||
)
|
||||
|
||||
// settingsStub implements both settings.Settings and settings.Updater over
|
||||
// settings.* capability calls. One instance backs both CoreServices fields.
|
||||
type settingsStub struct{ base }
|
||||
|
||||
var (
|
||||
_ settings.Settings = (*settingsStub)(nil)
|
||||
_ settings.Updater = (*settingsStub)(nil)
|
||||
)
|
||||
|
||||
func (s *settingsStub) GetSiteSettings(ctx context.Context) (map[string]any, error) {
|
||||
resp := &abiv1.SettingsGetSiteSettingsResponse{}
|
||||
if err := s.invoke(ctx, "get_site_settings", &abiv1.SettingsGetSiteSettingsRequest{}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalMap(resp.GetSettingsJson()), nil
|
||||
}
|
||||
|
||||
func (s *settingsStub) GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error) {
|
||||
resp := &abiv1.SettingsGetPluginSettingsResponse{}
|
||||
req := &abiv1.SettingsGetPluginSettingsRequest{PluginName: pluginName}
|
||||
if err := s.invoke(ctx, "get_plugin_settings", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalMap(resp.GetSettingsJson()), nil
|
||||
}
|
||||
|
||||
func (s *settingsStub) UpdateSiteSetting(ctx context.Context, key string, value any) error {
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := &abiv1.SettingsUpdateSiteSettingRequest{Key: key, ValueJson: valueJSON}
|
||||
return s.invoke(ctx, "update_site_setting", req, &abiv1.SettingsUpdateSiteSettingResponse{})
|
||||
}
|
||||
91
plugin/wasmguest/caps/subscriptions.go
Normal file
91
plugin/wasmguest/caps/subscriptions.go
Normal file
@ -0,0 +1,91 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/subscriptions"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// subscriptionsStub implements subscriptions.Subscriptions over
|
||||
// subscriptions.* capability calls.
|
||||
type subscriptionsStub struct{ base }
|
||||
|
||||
var _ subscriptions.Subscriptions = (*subscriptionsStub)(nil)
|
||||
|
||||
func (s *subscriptionsStub) GetUserTierLevel(ctx context.Context, userID uuid.UUID) (*subscriptions.TierLevel, error) {
|
||||
resp := &abiv1.SubscriptionsGetUserTierLevelResponse{}
|
||||
req := &abiv1.SubscriptionsGetUserTierLevelRequest{UserId: userID.String()}
|
||||
if err := s.invoke(ctx, "get_user_tier_level", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl := resp.GetTierLevel()
|
||||
if tl == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &subscriptions.TierLevel{Level: int(tl.GetLevel()), Features: tl.GetFeatures()}, nil
|
||||
}
|
||||
|
||||
func (s *subscriptionsStub) GetTierBySlug(ctx context.Context, slug string) (*subscriptions.Tier, error) {
|
||||
resp := &abiv1.SubscriptionsGetTierBySlugResponse{}
|
||||
req := &abiv1.SubscriptionsGetTierBySlugRequest{Slug: slug}
|
||||
if err := s.invoke(ctx, "get_tier_by_slug", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := resp.GetTier()
|
||||
if t == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tier := tierFromProto(t)
|
||||
return &tier, nil
|
||||
}
|
||||
|
||||
func (s *subscriptionsStub) ListTiers(ctx context.Context) ([]subscriptions.Tier, error) {
|
||||
resp := &abiv1.SubscriptionsListTiersResponse{}
|
||||
if err := s.invoke(ctx, "list_tiers", &abiv1.SubscriptionsListTiersRequest{}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tiers := make([]subscriptions.Tier, 0, len(resp.GetTiers()))
|
||||
for _, t := range resp.GetTiers() {
|
||||
tiers = append(tiers, tierFromProto(t))
|
||||
}
|
||||
return tiers, nil
|
||||
}
|
||||
|
||||
func (s *subscriptionsStub) ListActivePlans(ctx context.Context, tierID uuid.UUID) ([]subscriptions.Plan, error) {
|
||||
resp := &abiv1.SubscriptionsListActivePlansResponse{}
|
||||
req := &abiv1.SubscriptionsListActivePlansRequest{TierId: tierID.String()}
|
||||
if err := s.invoke(ctx, "list_active_plans", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans := make([]subscriptions.Plan, 0, len(resp.GetPlans()))
|
||||
for _, p := range resp.GetPlans() {
|
||||
plan := subscriptions.Plan{
|
||||
ID: parseUUID(p.GetId()),
|
||||
TierID: parseUUID(p.GetTierId()),
|
||||
BillingInterval: p.GetBillingInterval(),
|
||||
Amount: p.GetAmount(),
|
||||
Currency: p.GetCurrency(),
|
||||
IsActive: p.GetIsActive(),
|
||||
}
|
||||
if ts := p.GetCreatedAt(); ts != nil {
|
||||
plan.CreatedAt = ts.AsTime()
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func tierFromProto(t *abiv1.Tier) subscriptions.Tier {
|
||||
return subscriptions.Tier{
|
||||
ID: parseUUID(t.GetId()),
|
||||
Name: t.GetName(),
|
||||
Slug: t.GetSlug(),
|
||||
Level: int(t.GetLevel()),
|
||||
Description: t.GetDescription(),
|
||||
Features: t.GetFeatures(),
|
||||
IsDefault: t.GetIsDefault(),
|
||||
Position: int(t.GetPosition()),
|
||||
}
|
||||
}
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
summarizesysuser
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
generated
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
lookupLookupd"{"type":"object"}
|
||||
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
symposiumsearch
|
||||
1
plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
symposiumsearch
|
||||
0
plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
{"type":"doc"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<p>hi</p>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<p>long text</p>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
short
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$22222222-2222-2222-2222-222222222222
|
||||
5
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb
vendored
Normal file
5
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
l
|
||||
$22222222-2222-2222-2222-222222222222Adaada"hi*https://x/a.png2https://ada.dev:
|
||||
ghada:
|
||||
x@ada
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
about
|
||||
3
plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
7
|
||||
$22222222-2222-2222-2222-222222222222aboutAbout Us
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
hello
|
||||
3
plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
g
|
||||
$22222222-2222-2222-2222-222222222222helloHello"hi*media:x2$22222222-2222-2222-2222-222222222222
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
Hello World
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
hello-world
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<b>plain</b>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
enc:abc
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
enc:abc
|
||||
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
featured
|
||||
3
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
[]
|
||||
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$66666666-6666-6666-6666-666666666666
|
||||
4
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb
vendored
Normal file
4
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
#
|
||||
[{"id":1},{"id":2}]
|
||||
{"page":1}
|
||||
2
plugin/wasmguest/caps/testdata/golden/email_send_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/email_send_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
to@x.comSubjBody
|
||||
0
plugin/wasmguest/caps/testdata/golden/email_send_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/email_send_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbbtext
|
||||
1
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
text
|
||||
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<0C><><EFBFBD>=<3D><>L><3E><><EFBFBD>>
|
||||
0
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
soft
|
||||
3
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
soft
|
||||
2
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$11111111-1111-1111-1111-111111111111
|
||||
1
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
reindex
{"full":true}
|
||||
0
plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$99999999-9999-9999-9999-999999999999hero.jpgbytes"alt*f2plugin
|
||||
2
plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$99999999-9999-9999-9999-999999999999*media:99999999-9999-9999-9999-999999999999
|
||||
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
main
|
||||
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
,
|
||||
$33333333-3333-3333-3333-333333333333main
|
||||
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$33333333-3333-3333-3333-333333333333
|
||||
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
–
|
||||
$44444444-4444-4444-4444-444444444444$33333333-3333-3333-3333-333333333333Home"/*home2$55555555-5555-5555-5555-5555555555558@JnavRlinkZhome
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user