Skip to content

Latest commit

 

History

History
518 lines (393 loc) · 12.4 KB

File metadata and controls

518 lines (393 loc) · 12.4 KB

Plugin Examples — All Four Tiers

Complete reference for Tier 1 Subscription, Tier 2 Trigger, Tier 3 RPC, and Tier 4 Custom Type.

File Purpose
manifest.json Full manifest for plugin name orders-plugin
handler.go Go handler logic (all tiers)
main_wasip1.go WASM exports (dispatch, malloc)

Architecture: docs/architecture/plugins.md


Quick try (built-in demo, no WASM)

The server ships a native demo plugin (internal/plugin/builtin/demo) with all four tiers:

make migrate && make run
POST /v1/admin/plugins
X-Tenant-Id: default
Content-Type: application/json

{
  "name": "demo",
  "nativeBuiltin": true,
  "enabled": true
}
Tier Try it
1 Create any row → plugin receives records.after.insert on event.Emit path
2 POST /v1/data/tables/{t}/rows with "name":"alice" → stored as "ALICE"
3 POST /v1/data/rpc/hello body {}
4 Create column typeId: "plugin.demo.money" then write a non-empty value

Install orders-plugin (this example)

Option A — WASM

cd examples/plugins
GOOS=wasip1 GOARCH=wasm go build -o orders-plugin.wasm .
WASM_B64=$(base64 -i orders-plugin.wasm | tr -d '\n')
POST /v1/admin/plugins
X-Tenant-Id: default
Content-Type: application/json

{
  "name": "orders-plugin",
  "version": "1.0.0",
  "enabled": true,
  "config": { "currency": "USD" },
  "manifest": { ... paste manifest.json ... },
  "wasmBase64": "<WASM_B64>"
}

Option B — manifest only (requires WASM or registered native handler)

Use the same body without wasmBase64 only if you register Handle via plugin.RegisterNative("orders-plugin", ...).


Tier 1 — Subscription (Database Webhook / event.Emit)

Not Supabase Realtime WebSocket. Same delivery path as lc_event_sinks: row change → EmitEventevent.Bus → subscribers.

Manifest

{
  "subscriptions": [
    {
      "events": ["records.after.insert", "records.after.update"],
      "tableFilter": "orders"
    }
  ]
}
  • events empty → all row-level records.* (plugin runtime default in code)
  • tableFilter empty → all tables

Host → plugin dispatch (JSON)

{
  "action": "subscription",
  "tenantId": "default",
  "pluginId": "",
  "pluginName": "orders-plugin",
  "eventType": "records.after.insert",
  "tableId": "orders",
  "data": {
    "row": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "amount": 120.5,
      "status": "pending"
    }
  },
  "config": { "currency": "USD" }
}

Same payload shape as external webhook envelope data field; see pkg/eventschema.

Parallel: external event-sink (HTTP webhook)

POST /v1/admin/event-sinks
X-Tenant-Id: default

{
  "name": "orders-webhook",
  "targetUrl": "https://example.com/hooks/orders",
  "tableFilter": "orders",
  "eventTypes": ["records.after.insert"],
  "enabled": true
}

Both sink and plugin subscription fire from one event.Emit.


Tier 2 — Trigger (before / after row SQL)

Manifest

{
  "triggers": [
    {
      "table": "orders",
      "timing": "before",
      "operations": ["insert", "update"]
    },
    {
      "table": "orders",
      "timing": "after",
      "operations": ["insert"]
    }
  ]
}
Field Values
timing before (mutate/deny) · after (side effects)
operations insert, update, delete, bulkUpsert, bulkDelete
table logical table name; empty = all tables

before — host dispatch

{
  "action": "trigger",
  "timing": "before",
  "operation": "insert",
  "tableId": "orders",
  "cells": {
    "customer_name": "  Alice  ",
    "amount": 99.5
  }
}

before — plugin response (mutate cells)

{
  "ok": true,
  "cells": {
    "customer_name": "Alice",
    "amount": 99.5,
    "status": "pending"
  }
}

Deny write

{ "ok": false, "deny": true, "error": "order limit exceeded" }

Try with HTTP

POST /v1/data/tables/orders/rows
X-Tenant-Id: default

{
  "customer_name": "  Bob  ",
  "amount": 50
}

With orders-plugin trigger: status defaults to pending, name trimmed.


Tier 3 — RPC (Database Function)

Supabase-style: POST /v1/data/rpc/{function_name}.

Manifest

{
  "rpc": [
    { "name": "order_summary", "method": "POST" },
    { "name": "ping", "method": "GET" }
  ]
}

List functions

GET /v1/data/rpc
X-Tenant-Id: default
{ "functions": ["order_summary", "ping"] }

Call RPC

POST /v1/data/rpc/order_summary
X-Tenant-Id: default
Content-Type: application/json

{ "orderId": "550e8400-e29b-41d4-a716-446655440000" }

Example response (from handler.go):

{
  "orderId": "550e8400-e29b-41d4-a716-446655440000",
  "summary": {
    "lineCount": 3,
    "total": "299.00",
    "currency": "USD"
  }
}
GET /v1/data/rpc/ping?source=playground
X-Tenant-Id: default

Host → plugin RPC dispatch

{
  "action": "rpc",
  "method": "POST",
  "body": { "orderId": "" },
  "query": {},
  "config": { "currency": "USD" }
}

Tier 4 — Custom Type (Postgres TYPE-like)

Manifest

{
  "types": [
    {
      "id": "money",
      "name": "money",
      "pgType": "numeric",
      "description": "Non-empty decimal amount"
    }
  ]
}

Registered column type id: plugin.orders-plugin.money

Create table + money column

POST /v1/admin/tables
{ "name": "orders", "label": "Orders" }

POST /v1/admin/columns
{
  "tableId": "orders",
  "name": "amount",
  "typeId": "plugin.orders-plugin.money",
  "position": 1
}

GET /v1/admin/types lists plugin.orders-plugin.money after plugin reload.

Type ops (host → plugin)

typeOp When
validate Before accepting cell value
toPG Before SQL bind
fromPG After read (future)
compute Virtual compute (future)
{
  "action": "type",
  "typeOp": "validate",
  "typeId": "plugin.orders-plugin.money",
  "columnId": "",
  "value": "12.50"
}

Empty string → { "ok": false, "error": "money: value required" }.

Write row

POST /v1/data/tables/orders/rows
X-Tenant-Id: default

{ "amount": "12.50", "customer_name": "Carol" }

Multiple plugins (split responsibilities)

One tenant can install many plugins. Each row in lc_plugins has a unique name per tenant; manifest only declares the tiers that plugin needs.

Tenant "default"
├── search-indexer     → Tier 1 only (push to ES)
├── audit-trigger      → Tier 2 only (before write on orders)
├── reporting-rpc      → Tier 3 only (report functions)
├── finance-types      → Tier 4: plugin.finance-types.money, .percent
└── geo-types          → Tier 4: plugin.geo-types.wkt, .geojson

See also: manifest.search-indexer.json, manifest.finance-types.json, manifest.geo-types.json.

Principle: one plugin = one bounded capability

Pattern Plugin name Manifest
Index / webhook consumer search-indexer subscriptions only
Business rules order-rules triggers only
API extensions reporting-rpc rpc only
Domain types finance-types types only

Install each with separate POST /v1/admin/plugins (each with its own WASM or native handler).

Tier 1 & 2 — all matching plugins run

On each event.Emit or row write, the host iterates every enabled plugin (ordered by plugin name):

  • Subscription: each plugin whose events + tableFilter match receives the dispatch.
  • Before trigger: plugins run in sequence; each may patch cells; any plugin can deny the write.
  • After trigger: all matching plugins run (errors do not roll back SQL).

Example: search-indexer indexes the row while audit-trigger writes an audit entry — both subscribe to records.after.insert.

Tier 3 — RPC name must be unique per tenant

POST /v1/data/rpc/{name} resolves the first plugin (by name order) that declares that RPC name.

Do not register the same rpc[].name on two plugins. Use prefixed names:

{ "rpc": [{ "name": "finance_calc_tax", "method": "POST" }] }
{ "rpc": [{ "name": "geo_distance_km", "method": "POST" }] }

Tier 4 — two plugins, different types (no collision)

Full type id is plugin.{pluginName}.{typeName}, not just typeName.

Plugin Manifest types[].name Column typeId
finance-types money plugin.finance-types.money
finance-types percent plugin.finance-types.percent
geo-types wkt plugin.geo-types.wkt
geo-types geojson plugin.geo-types.geojson

Two plugins can both define "name": "money" in manifest — they still differ as plugin.finance-types.money vs plugin.geo-types.money (if you chose that name).

Host routes type ops by parsing typeId → calls only the owning plugin:

column typeId = plugin.finance-types.money
  → dispatch to plugin "finance-types", typeOp = validate | toPG | …

Install two type-only plugins

POST /v1/admin/plugins
{
  "name": "finance-types",
  "enabled": true,
  "manifest": { ... manifest.finance-types.json ... },
  "wasmBase64": "..."
}

POST /v1/admin/plugins
{
  "name": "geo-types",
  "enabled": true,
  "manifest": { ... manifest.geo-types.json ... },
  "wasmBase64": "..."
}

Same table can mix columns:

POST /v1/admin/columns
{ "tableId": "orders", "name": "amount", "typeId": "plugin.finance-types.money", "position": 1 }

POST /v1/admin/columns
{ "tableId": "orders", "name": "delivery_area", "typeId": "plugin.geo-types.geojson", "position": 2 }

One WASM per plugin

Each plugin record carries its own wasm_module. Typical layout:

examples/
├── plugins-finance/   → finance-types.wasm
├── plugins-geo/       → geo-types.wasm
└── plugins-search/    → search-indexer.wasm

Alternative (not implemented today): a single fat plugin with internal routing — harder to deploy/version; prefer multiple small plugins.

Checklist

Rule Reason
Unique plugin name per tenant DB constraint
Unique RPC name across tenant First-match routing
Use full typeId on columns plugin.{name}.{type}
Narrow manifest per plugin Only pay cost for declared tiers
Order-sensitive before triggers Plugins run A→Z by name; rename if order matters

End-to-end script (demo plugin)

BASE=http://localhost:8080
TENANT=default

# Install demo
curl -sS -X POST "$BASE/v1/admin/plugins" \
  -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \
  -d '{"name":"demo","nativeBuiltin":true,"enabled":true}'

# Tier 3 RPC
curl -sS -X POST "$BASE/v1/data/rpc/hello" \
  -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \
  -d '{"ping":1}'

# Tier 2 + 1: create table & row (adjust if table exists)
curl -sS -X POST "$BASE/v1/admin/tables" \
  -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \
  -d '{"name":"items","label":"Items"}'

curl -sS -X POST "$BASE/v1/admin/columns" \
  -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \
  -d '{"tableId":"items","name":"name","typeId":"text","position":1}'

curl -sS -X POST "$BASE/v1/data/tables/items/rows" \
  -H "X-Tenant-Id: $TENANT" -H "Content-Type: application/json" \
  -d '{"name":"alice"}'
# → name stored as "ALICE" (Tier 2); Tier 1 subscription fires on event.Emit

# Audit invocations
curl -sS "$BASE/v1/admin/plugin-invocations?limit=10" \
  -H "X-Tenant-Id: $TENANT"

Dispatch protocol summary

Tier action Key request fields Key response fields
1 subscription eventType, tableId, data ok
2 trigger timing, operation, tableId, cells ok, cells, deny, error
3 rpc method, body, query ok, statusCode, body
4 type typeOp, typeId, value ok, value, error

SDK types: pkg/pluginsdk/sdk.go