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 read-only database API, and a set of context globals.
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) |
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 read-only access to indexed records. 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
})
-- result.records — array of record tables (each includes a "uri" field)
-- result.cursor — present when more records exist
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.count #
local n = db.count("xyz.statusphere.status")
local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID
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