Lua Scripting #
Without Lua scripts, HappyView's query endpoints return raw records and procedure endpoints proxy simple creates and updates. Lua scripts let you go much further:
- Add filtering logic
- Transform responses
- Validate input
- Compose multi-record operations
- Build entirely custom behavior
Scripts are attached to query and procedure lexicons and run in a sandboxed Lua VM with access to the Record API, a database API, an HTTP client API, a JSON API, and a set of context globals.
For scripts that react to record changes from the network (rather than XRPC requests), see Index Hooks.
Script structure #
Every script must define a handle() function. HappyView calls it when the XRPC endpoint is hit and returns its result as JSON to the client.
function handle()
-- your logic here
return { key = "value" }
end
You can define helper functions and variables outside handle(). They're evaluated once when the script loads, then handle() is called per request.
Sandbox #
Scripts run in a restricted environment. The following standard Lua modules are removed and unavailable:
os, io, debug, package, require, dofile, loadfile, load, collectgarbage
An instruction limit of 1,000,000 prevents infinite loops. Exceeding it terminates the script with an error.
Context globals #
These globals are set automatically before handle() is called.
Procedure globals #
| Global | Type | Description |
|---|---|---|
method |
string | The XRPC method name (e.g. xyz.statusphere.setStatus) |
input |
table | Parsed JSON request body |
caller_did |
string | DID of the authenticated user |
collection |
string | Target collection NSID |
Query globals #
| Global | Type | Description |
|---|---|---|
method |
string | The XRPC method name |
params |
table | Query string parameters (all values are strings) |
collection |
string | Target collection NSID |
Queries are unauthenticated: there is no caller_did or input.
Utility globals #
Available in both queries and procedures:
| Function | Returns | Description |
|---|---|---|
now() |
string | Current UTC timestamp in ISO 8601 format |
log(message) |
— | Log a message (appears in server logs at debug level) |
TID() |
string | Generate a fresh AT Protocol TID (13-character sortable identifier) |
toarray(table) |
table | Mark a table as a JSON array for serialization (see below) |
toarray #
Lua tables don't distinguish between arrays and objects. When a table is serialized to JSON, an empty table {} becomes a JSON object {} instead of an array []. The toarray() function marks a table so it always serializes as a JSON array — even when empty.
return { items = toarray(results) }
-- With results: [{"name": "a"}, {"name": "b"}]
-- Without results: {"items": []} (not {"items": {}})
You don't need toarray() on results from db.query, db.search, db.backlinks, or db.raw — those already return properly marked arrays. Use it when you build a table yourself with table.insert().
Record API #
The Record API is only available in procedure scripts. It handles creating, updating, loading, and deleting AT Protocol records. Writes are proxied to the caller's PDS and indexed locally.
Constructor #
local r = Record("xyz.statusphere.status", { status = "\ud83d\ude0a", createdAt = now() })
Creates a new record instance for the given collection. The optional second argument sets initial field values. The record's _key_type is automatically set from the lexicon's key definition. Default values from the schema are populated for any missing fields.
Static methods #
-- Save multiple records in parallel
Record.save_all({ record1, record2, record3 })
-- Load a record from the local database by AT URI
local r = Record.load("at://did:plc:abc/xyz.statusphere.status/abc123")
-- Returns nil if not found
-- Load multiple records in parallel
local records = Record.load_all({ uri1, uri2 })
-- Returns nil entries for URIs not found
Instance methods #
-- Save (creates or updates depending on whether _uri is set)
r:save()
-- Delete from PDS and local database
r:delete()
-- Set the record key type (tid, any, nsid, or literal:*)
r:set_key_type("tid")
-- Set a specific record key
r:set_rkey("my-key")
-- Auto-generate a record key based on _key_type
local key = r:generate_rkey()
Key type behavior for generate_rkey():
| Key type | Generated rkey |
|---|---|
tid |
Sortable timestamp-based ID |
any |
Same as tid |
literal:value |
The literal value after the colon |
nsid |
Error — use set_rkey() instead |
Instance fields #
These fields are set automatically and are read-only (writes raise an error):
| Field | Type | Description |
|---|---|---|
_uri |
string? | AT URI — set after save(), cleared after delete() |
_cid |
string? | Content hash — set after save(), cleared after delete() |
_key_type |
string? | Record key type from the lexicon definition |
_rkey |
string? | Record key — set via set_rkey() or generate_rkey() |
_collection |
string | Collection NSID (always set) |
_schema |
table? | Schema definition from the lexicon (used for validation) |
Schema validation #
When a record has a schema (loaded from the lexicon):
- On save: required fields are checked, and missing required fields raise an error
- On construction: default values from schema properties are auto-populated
- On save: only fields defined in the schema's
propertiesare sent to the PDS
Save behavior #
r:save() auto-detects create vs update:
- If
_uriis nil → callscreateRecordon the PDS - If
_uriis set → callsputRecordon the PDS
After a successful save, _uri and _cid are updated on the record instance.
Database API #
The db table provides access to the database. Available in both queries and procedures.
db.query #
local result = db.query({
collection = "xyz.statusphere.status", -- required
did = "did:plc:abc", -- optional: filter by DID
limit = 20, -- optional: max 100, default 20
offset = 0, -- optional: for pagination
sort = "name", -- optional: field to sort by, default "indexed_at"
sortDirection = "asc", -- optional: "asc" or "desc", default "desc"
})
-- result.records — array of record tables (each includes a "uri" field)
-- result.cursor — present when more records exist
The sort field can be a top-level column (indexed_at, did, uri) or any field inside the record's value object (e.g. name, createdAt). Field names must contain only alphanumeric characters and underscores.
db.get #
local record = db.get("at://did:plc:abc/xyz.statusphere.status/abc123")
-- Returns the record table or nil
-- The returned table includes a "uri" field
db.search #
local result = db.search({
collection = "xyz.statusphere.status", -- required
field = "displayName", -- required: record field to search
query = "alice", -- required: search term
limit = 10, -- optional: max 100, default 10
})
-- result.records — array of matching records, ranked by relevance:
-- exact match > prefix match > contains match, then alphabetical
db.backlinks #
Find records that reference a given AT URI anywhere in their data. Useful for finding likes on a post, replies to a thread, or any record that links to another.
local result = db.backlinks({
collection = "xyz.statusphere.status", -- required
uri = "at://did:plc:abc/xyz.statusphere.status/foo", -- required: the URI to find references to
did = "did:plc:abc", -- optional: filter by DID
limit = 20, -- optional: max 100, default 20
offset = 0, -- optional: for pagination
})
-- result.records — array of records whose data contains the given URI
-- result.cursor — present when more records exist
The search checks the full record data, so it works regardless of which field holds the reference (subject, parent, reply.root, etc.).
db.count #
local n = db.count("xyz.statusphere.status")
local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID
db.raw #
Run a raw SQL query against the database. Supports SELECT, INSERT, UPDATE, DELETE, and CREATE TABLE statements.
-- Read query
local rows = db.raw(
"SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3",
{ "xyz.statusphere.status", "did:plc:abc", 10 }
)
for _, row in ipairs(rows) do
-- row.uri, row.did, row.record (JSONB is returned as a Lua table)
end
-- Write query (returns affected rows, if any)
db.raw("CREATE TABLE IF NOT EXISTS my_table (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.raw("INSERT INTO my_table (id, value) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET value = $2",
{ "key1", "hello" })
Parameters are passed as an array and bound to $1, $2, etc. Supported parameter types: strings, integers, numbers, booleans, and nil.
Column types are mapped automatically:
| Postgres type | Lua type |
|---|---|
TEXT, VARCHAR |
string |
INT4, INT8 |
integer |
FLOAT4, FLOAT8 |
number |
BOOL |
boolean |
JSON, JSONB |
table |
TIMESTAMPTZ |
string (ISO 8601) |
| Other | string (fallback) |
HTTP API #
The http table provides async HTTP client functions. Available in both queries and procedures.
Methods #
All methods take a URL and an optional options table, and return a response table.
http.get(url, opts?)
http.post(url, opts?)
http.put(url, opts?)
http.patch(url, opts?)
http.delete(url, opts?)
http.head(url, opts?)
Options #
The optional second argument is a table with:
| Field | Type | Description |
|---|---|---|
headers |
table | Request headers as key-value string pairs |
body |
string | Request body (ignored for GET and HEAD) |
Response #
Every method returns a table with:
| Field | Type | Description |
|---|---|---|
status |
integer | HTTP status code |
body |
string | Response body text (empty string for HEAD) |
headers |
table | Response headers as key-value pairs (lowercase keys) |
Examples #
-- Simple GET
local resp = http.get("https://api.example.com/data")
-- resp.status = 200, resp.body = "...", resp.headers["content-type"] = "application/json"
-- GET with custom headers
local resp = http.get("https://api.example.com/data", {
headers = { ["authorization"] = "Bearer token123" }
})
-- POST with JSON body
local resp = http.post("https://api.example.com/hook", {
body = '{"key": "value"}',
headers = { ["content-type"] = "application/json" }
})
-- PUT, PATCH, DELETE, HEAD follow the same pattern
local resp = http.put(url, { body = data, headers = { ... } })
local resp = http.patch(url, { body = data, headers = { ... } })
local resp = http.delete(url, { headers = { ... } })
local resp = http.head(url)
AT Protocol API #
The atproto table provides AT Protocol utility functions. Available in queries, procedures, and index hooks.
atproto.resolve_service_endpoint #
local endpoint = atproto.resolve_service_endpoint(did)
Resolves a DID to its AT Protocol service endpoint URL by fetching the DID document. Supports both did:plc:* (via the PLC directory) and did:web:* (via .well-known/did.json).
| Parameter | Type | Description |
|---|---|---|
did |
string | The DID to resolve |
Returns: The service endpoint URL as a string, or nil if resolution fails (DID not found, no PDS service in document, network error).
Examples #
-- Resolve a did:plc DID
local endpoint = atproto.resolve_service_endpoint("did:plc:abc123")
-- endpoint = "https://pds.example.com"
-- Resolve a did:web DID
local endpoint = atproto.resolve_service_endpoint("did:web:example.com")
-- endpoint = "https://example.com"
-- Handle resolution failure
local endpoint = atproto.resolve_service_endpoint("did:plc:unknown")
if not endpoint then
return { error = "Could not resolve DID" }
end
-- Use with HTTP API to call a remote XRPC endpoint
local endpoint = atproto.resolve_service_endpoint(did)
if endpoint then
local resp = http.get(endpoint .. "/xrpc/com.example.method")
local data = json.decode(resp.body)
end
atproto.get_labels #
local labels = atproto.get_labels(uri)
Returns an array of labels for a single AT URI. Merges external labels (from subscribed labelers) with self-labels (from the record's labels.values[] field).
| Parameter | Type | Description |
|---|---|---|
uri |
string | AT URI of the record to query |
Each label in the array is a table with:
| Field | Type | Description |
|---|---|---|
src |
string | DID of the labeler (or record author) |
uri |
string | AT URI this label applies to |
val |
string | Label value (e.g. "nsfw", "!hide") |
cts |
string | Timestamp when the label was created |
Expired labels are automatically filtered out. Returns an empty array if no labels exist.
atproto.get_labels_batch #
local labels_by_uri = atproto.get_labels_batch(uris)
Batch version of get_labels. Takes an array of AT URIs and returns a table keyed by URI, where each value is an array of labels.
| Parameter | Type | Description |
|---|---|---|
uris |
table | Array of AT URI strings |
Returns: A table keyed by URI. Each value is an array of label tables (same shape as get_labels). URIs with no labels have an empty array.
Label Examples #
-- Get labels for a single game
local labels = atproto.get_labels("at://did:plc:abc/games.gamesgamesgamesgames.game/rkey1")
for _, label in ipairs(labels) do
if label.val == "!hide" then
-- skip this game in feed results
end
end
-- Batch fetch labels for multiple games (efficient for feed hydration)
local uris = {}
for _, item in ipairs(skeleton) do
uris[#uris + 1] = item.game
end
local labels_by_uri = atproto.get_labels_batch(uris)
for _, uri in ipairs(uris) do
local labels = labels_by_uri[uri]
for _, label in ipairs(labels) do
if label.val == "!hide" then
-- filter out this game
end
end
end
JSON API #
The json global provides JSON serialization and deserialization. Available in queries, procedures, and index hooks.
json.encode #
local str = json.encode({ key = "value", items = { 1, 2, 3 } })
-- '{"key":"value","items":[1,2,3]}'
Converts a Lua table to a JSON string.
json.decode #
local tbl = json.decode('{"key": "value"}')
-- tbl.key == "value"
Parses a JSON string into a Lua table. Returns an error if the input is not valid JSON.
Standard libraries #
The following Lua 5.4 standard library modules are available:
`string`
- [`byte`](https://lua.org/manual/5.4/manual.html#pdf-string.byte) - [`char`](https://lua.org/manual/5.4/manual.html#pdf-string.char) - [`find`](https://lua.org/manual/5.4/manual.html#pdf-string.find) - [`format`](https://lua.org/manual/5.4/manual.html#pdf-string.format) - [`gmatch`](https://lua.org/manual/5.4/manual.html#pdf-string.gmatch) - [`gsub`](https://lua.org/manual/5.4/manual.html#pdf-string.gsub) - [`len`](https://lua.org/manual/5.4/manual.html#pdf-string.len) - [`lower`](https://lua.org/manual/5.4/manual.html#pdf-string.lower) - [`match`](https://lua.org/manual/5.4/manual.html#pdf-string.match) - [`rep`](https://lua.org/manual/5.4/manual.html#pdf-string.rep) - [`reverse`](https://lua.org/manual/5.4/manual.html#pdf-string.reverse) - [`sub`](https://lua.org/manual/5.4/manual.html#pdf-string.sub) - [`upper`](https://lua.org/manual/5.4/manual.html#pdf-string.upper)`table`
- [`concat`](https://lua.org/manual/5.4/manual.html#pdf-table.concat) - [`insert`](https://lua.org/manual/5.4/manual.html#pdf-table.insert) - [`remove`](https://lua.org/manual/5.4/manual.html#pdf-table.remove) - [`sort`](https://lua.org/manual/5.4/manual.html#pdf-table.sort) - [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack)`math`
- [`abs`](https://lua.org/manual/5.4/manual.html#pdf-math.abs) - [`ceil`](https://lua.org/manual/5.4/manual.html#pdf-math.ceil) - [`floor`](https://lua.org/manual/5.4/manual.html#pdf-math.floor) - [`max`](https://lua.org/manual/5.4/manual.html#pdf-math.max) - [`min`](https://lua.org/manual/5.4/manual.html#pdf-math.min) - [`random`](https://lua.org/manual/5.4/manual.html#pdf-math.random) - [`sqrt`](https://lua.org/manual/5.4/manual.html#pdf-math.sqrt) - [`huge`](https://lua.org/manual/5.4/manual.html#pdf-math.huge) - [`pi`](https://lua.org/manual/5.4/manual.html#pdf-math.pi)Standard builtins
- [`print`](https://lua.org/manual/5.4/manual.html#pdf-print) - [`tostring`](https://lua.org/manual/5.4/manual.html#pdf-tostring) - [`tonumber`](https://lua.org/manual/5.4/manual.html#pdf-tonumber) - [`type`](https://lua.org/manual/5.4/manual.html#pdf-type) - [`pairs`](https://lua.org/manual/5.4/manual.html#pdf-pairs) - [`ipairs`](https://lua.org/manual/5.4/manual.html#pdf-ipairs) - [`next`](https://lua.org/manual/5.4/manual.html#pdf-next) - [`select`](https://lua.org/manual/5.4/manual.html#pdf-select) - [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack) - [`error`](https://lua.org/manual/5.4/manual.html#pdf-error) - [`pcall`](https://lua.org/manual/5.4/manual.html#pdf-pcall) - [`xpcall`](https://lua.org/manual/5.4/manual.html#pdf-xpcall) - [`assert`](https://lua.org/manual/5.4/manual.html#pdf-assert) - [`setmetatable`](https://lua.org/manual/5.4/manual.html#pdf-setmetatable) - [`getmetatable`](https://lua.org/manual/5.4/manual.html#pdf-getmetatable) - [`rawget`](https://lua.org/manual/5.4/manual.html#pdf-rawget) - [`rawset`](https://lua.org/manual/5.4/manual.html#pdf-rawset) - [`rawequal`](https://lua.org/manual/5.4/manual.html#pdf-rawequal)Debugging #
Logging #
Use log() to trace script execution. Output appears in the server logs at debug level with the field lua_log:
function handle()
log("handle called with params: " .. tostring(params.limit))
local result = db.query({ collection = collection, limit = params.limit })
log("query returned " .. #result.records .. " records")
return result
end
To see log output, make sure your RUST_LOG environment variable includes debug level for HappyView (the default happyview=debug works). See Configuration.
Error messages #
When a script fails, the client receives a generic 500 response:
{"error": "script execution failed"}: covers syntax errors, runtime errors, missinghandle()function, and errors raised witherror(){"error": "script exceeded execution time limit"}: the script hit the 1,000,000 instruction limit
The full error message is logged server-side at error level. Check the server logs to see the actual Lua error, including line numbers and stack traces.
Common mistakes #
- Missing
handle()function: Every script must define a globalhandle()function. If it's missing or misspelled, the script fails silently with "script execution failed". - Calling
error()for expected conditions: Lua'serror()triggers a 500 response. For expected conditions like "record not found", return a structured error response instead:return { error = "not found" }. - Infinite loops: The sandbox enforces a 1,000,000 instruction limit. If your script processes large data sets, paginate with
db.query()limits instead of loading everything at once. - Forgetting
paramsvalues are strings: All query string parameters arrive as strings. Usetonumber(params.limit)if you need a number.
Example scripts #
See the example script references for complete, ready-to-use scripts:
Queries:
- Get a record — fetch a single record by AT URI
- Paginated list — list records with cursor-based pagination and DID filtering
- List or fetch — combined single-record lookup and paginated listing
- Expanded query — list statuses with user profiles in a single response
Procedures:
- Create a record — simple write that saves input as a record
- Upsert a record — create or update using a deterministic rkey
- Update or delete — single endpoint handling create, update, and delete
- Batch save — create multiple records in parallel with
Record.save_all() - Sidecar records — create linked records across collections with a shared rkey
- Cascading delete — delete a record and all related records
- Complex mutations — load, transform, and save a record with multiple field changes
Index Hooks:
- Algolia sync — push records to an Algolia search index on create/update/delete
Next steps #
- Index Hooks: React to record changes from the network in real time
- Lexicons: Understand how record, query, and procedure lexicons work together
- XRPC API: See how endpoints behave with and without Lua scripts
- Dashboard: Use the web editor with context-aware completions