# 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](#record-api), a [database API](#database-api), an [HTTP client API](#http-api), a [JSON API](#json-api), and a set of [context globals](#context-globals).
For scripts that react to record changes from the network (rather than XRPC requests), see [Index Hooks](index-hooks.md).
## 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.
```lua
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)) |
### 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.
```lua
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
```lua
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
```lua
-- 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
```lua
-- 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 `properties` are sent to the PDS
### Save behavior
`r:save()` auto-detects create vs update:
- If `_uri` is nil → calls `createRecord` on the PDS
- If `_uri` is set → calls `putRecord` on 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
```lua
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
```lua
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
```lua
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.
```lua
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
```lua
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.
```lua
-- 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](#response).
```lua
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
```lua
-- 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](index-hooks.md).
### atproto.resolve_service_endpoint
```lua
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
```lua
-- 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
```lua
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
```lua
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
```lua
-- 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](index-hooks.md).
### json.encode
```lua
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
```lua
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`:
```lua
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](../getting-started/configuration.md).
### Error messages
When a script fails, the client receives a generic `500` response:
- `{"error": "script execution failed"}`: covers syntax errors, runtime errors, missing `handle()` function, and errors raised with `error()`
- `{"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 global `handle()` function. If it's missing or misspelled, the script fails silently with "script execution failed".
- **Calling `error()` for expected conditions**: Lua's `error()` 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 `params` values are strings**: All query string parameters arrive as strings. Use `tonumber(params.limit)` if you need a number.
## Example scripts
See the example script references for complete, ready-to-use scripts:
**Queries:**
- [Get a record](../reference/scripts/get-record.md) — fetch a single record by AT URI
- [Paginated list](../reference/scripts/paginated-list.md) — list records with cursor-based pagination and DID filtering
- [List or fetch](../reference/scripts/list-or-fetch.md) — combined single-record lookup and paginated listing
- [Expanded query](../reference/scripts/expanded-query.md) — list statuses with user profiles in a single response
**Procedures:**
- [Create a record](../reference/scripts/create-record.md) — simple write that saves input as a record
- [Upsert a record](../reference/scripts/upsert-record.md) — create or update using a deterministic rkey
- [Update or delete](../reference/scripts/update-or-delete.md) — single endpoint handling create, update, and delete
- [Batch save](../reference/scripts/batch-save.md) — create multiple records in parallel with `Record.save_all()`
- [Sidecar records](../reference/scripts/sidecar-records.md) — create linked records across collections with a shared rkey
- [Cascading delete](../reference/scripts/cascading-delete.md) — delete a record and all related records
- [Complex mutations](../reference/scripts/complex-mutations.md) — load, transform, and save a record with multiple field changes
**Index Hooks:**
- [Algolia sync](../reference/scripts/algolia-sync.md) — push records to an Algolia search index on create/update/delete
## Next steps
- [Index Hooks](index-hooks.md): React to record changes from the network in real time
- [Lexicons](lexicons.md): Understand how record, query, and procedure lexicons work together
- [XRPC API](../reference/xrpc-api.md): See how endpoints behave with and without Lua scripts
- [Dashboard](../getting-started/dashboard.md#lua-editor): Use the web editor with context-aware completions