A lexicon-driven AppView for ATProto.

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

Next steps #

  • 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