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
The server ships a native demo plugin (internal/plugin/builtin/demo) with all four tiers:
make migrate && make runPOST /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 |
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>"
}Use the same body without wasmBase64 only if you register Handle via plugin.RegisterNative("orders-plugin", ...).
Not Supabase Realtime WebSocket. Same delivery path as lc_event_sinks: row change → EmitEvent → event.Bus → subscribers.
{
"subscriptions": [
{
"events": ["records.after.insert", "records.after.update"],
"tableFilter": "orders"
}
]
}eventsempty → all row-levelrecords.*(plugin runtime default in code)tableFilterempty → all tables
{
"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.
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.
{
"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 |
{
"action": "trigger",
"timing": "before",
"operation": "insert",
"tableId": "orders",
"cells": {
"customer_name": " Alice ",
"amount": 99.5
}
}{
"ok": true,
"cells": {
"customer_name": "Alice",
"amount": 99.5,
"status": "pending"
}
}{ "ok": false, "deny": true, "error": "order limit exceeded" }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.
Supabase-style: POST /v1/data/rpc/{function_name}.
{
"rpc": [
{ "name": "order_summary", "method": "POST" },
{ "name": "ping", "method": "GET" }
]
}GET /v1/data/rpc
X-Tenant-Id: default{ "functions": ["order_summary", "ping"] }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{
"action": "rpc",
"method": "POST",
"body": { "orderId": "…" },
"query": {},
"config": { "currency": "USD" }
}{
"types": [
{
"id": "money",
"name": "money",
"pgType": "numeric",
"description": "Non-empty decimal amount"
}
]
}Registered column type id: plugin.orders-plugin.money
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.
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" }.
POST /v1/data/tables/orders/rows
X-Tenant-Id: default
{ "amount": "12.50", "customer_name": "Carol" }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.
| 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).
On each event.Emit or row write, the host iterates every enabled plugin (ordered by plugin name):
- Subscription: each plugin whose
events+tableFiltermatch 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.
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" }] }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 | …
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 }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.
| 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 |
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"| 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