Skip to content

Reacting to records

How-to

Read first: getting-started/running-locally, getting-started/lexicons, getting-started/scripts

This page shows you how to run your own code each time a record in one of your collections changes, or a labeler applies a label to it.

You’ll add a record script to your project, watch it run, extend it to handle updates and deletes, and then add a label script. The examples use the xyz.statusphere.status collection. Use any record collection your project defines.

You need a project made with hpy happyview create, running under hpy happyview dev. hpy happyview dev picks up each file when you save it, so you don’t need to restart it.

A record script sits next to its collection’s record lexicon in your project. So the collection needs a project lexicon: a file under src/lexicons/ whose path matches its NSID. For xyz.statusphere.status, that’s src/lexicons/xyz/statusphere/status.json. If your project doesn’t have it yet, add it first. See Lexicons.

A record script’s file name is its lexicon’s name, then the action, then the language’s extension. Create status.create.lua (or .js, .ts) next to status.json:

src/
lexicons/
xyz/
statusphere/
status.json
status.create.lua

Its trigger is record.create:xyz.statusphere.status. Put this in the file:

local log = require("internal.logging")
---@param input lexicons.xyz.statusphere.status.Event
---@param ctx happyview.RecordCtx
function handle(input, ctx)
log.info("new status", { uri = input.uri, did = input.did })
-- Return the record so it is indexed as it arrived.
return input.record
end

The types come from .happyview/types/, which hpy happyview dev writes from your lexicons.

input is the event: action, uri, did, collection, rkey and record. The script runs before the record is indexed, and what it returns decides what gets stored. Returning the record stores it unchanged.

A file that breaks the layout fails the build, and hpy happyview dev prints why. For example, status.create.lua with no status.json next to it is an error. hpy happyview check runs the same build without serving the project.

A record script runs for records that arrive from the network. It doesn’t run for an XRPC request. By default, hpy happyview dev reads the real network, so a status posted from a Statusphere client reaches your script.

To watch it, open your local dashboard and go to System > Event Logs. Set Category to Script. Each log.info call appears as a script.log event whose subject is the script’s trigger, record.create:xyz.statusphere.status. info, warn and error lines are recorded here. debug lines aren’t recorded as events.

To see every script run, not just the lines your script logs, start hpy happyview dev with VERBOSE_EVENT_LOGGING=true in its environment. It logs a script.executed event for each run. It writes a lot, so turn it off again when you’re done. The Verbose Event Logging switch under System > General sets the same thing, and wins over the variable.

If the script fails, HappyView tries it up to 4 times in all, then writes the event to Data > Dead Letters. The record is still indexed, so a broken script never stops indexing.

To run the script on a record you choose, without waiting for the network, fire a record create at it from a test. See Testing.

A record.create script only sees creates. To handle every action in one script, rename the file to status.index.lua. Its trigger is record.index:xyz.statusphere.status. HappyView uses status.create, status.update or status.delete when one exists for the action, and falls back to status.index otherwise.

A delete carries no record, so returning input.record on a delete returns nothing and skips the delete. Return true to let it through.

This version also filters: it skips any record whose status field is empty, so those records never reach the index.

local log = require("internal.logging")
---@param input lexicons.xyz.statusphere.status.Event
---@param ctx happyview.RecordCtx
function handle(input, ctx)
if input.action == "delete" then
log.info("status deleted", { uri = input.uri })
return true
end
if not input.record.status or input.record.status == "" then
return nil -- skip: not indexed
end
log.info("status " .. input.action, { uri = input.uri })
return input.record
end

To change what is stored instead, return a different object. It replaces the record in the index. The full table of return values is in Script types and triggers.

Labelers are services that apply labels such as spam or !hide to records and accounts. HappyView can subscribe to a labeler and run a script on each label it sends, before the label is stored.

List the labeler’s DID under [labels] in happyview.toml:

[labels]
subscribe = ["did:plc:ar7c4by46qjdydhdevvrndac"]

HappyView subscribes to each labeler in the list when it starts, and again each time you save happyview.toml under hpy happyview dev. Taking a DID out of the list doesn’t unsubscribe from it: hpy happyview dev tells you the subscription stays until you delete it in the dashboard. Your subscriptions are listed under Integrations > Labelers.

A label script lives in src/labels/, named by the label’s subject:

  • src/labels/xyz.statusphere.status.lua runs for labels on records in that collection. Its trigger is labeler.apply:xyz.statusphere.status.
  • src/labels/_actor.lua runs for labels on accounts. Its trigger is labeler.apply:_actor.

src/labels/ is flat: a file’s name is the whole NSID, so a label script can’t sit in a subdirectory.

input carries the label: src (the labeler’s DID), uri (the subject), val (the label value), neg (true when the label is being removed), cts (when it was created) and, if it expires, exp.

This script keeps only the label values you care about. Returning nothing drops a label. Returning an object merges its fields over the label, so return input keeps it unchanged.

local log = require("internal.logging")
local ALLOWED = { ["!hide"] = true, ["spam"] = true }
---@param input happyview.LabelEvent
---@param ctx happyview.LabelCtx
function handle(input, ctx)
if not ALLOWED[input.val] then
return nil -- drop the label
end
log.info("label kept", { val = input.val, uri = input.uri, neg = input.neg })
return input
end

A label script has no caller, so ctx.caller_did is absent and it can’t write to a PDS or create a job. A failing label script is retried and dead-lettered like a record script, and the label is stored as it arrived. See Script types and triggers.