A lexicon-driven AppView for ATProto.
happyview docs guides scripting.md
24 kB

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 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 #

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

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, 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 — 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:

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