The store slot's methods
Part of the adapter protocol, version 2 (an addition within it; no version of its own).
A store module keeps a host's run state: runs, parks, joins, waits and the arrivals they read, leases, the work queue, library sources, pull cursors and event receipts. listen-fire ships two (@lf/sqlite-store, the default, and @lf/postgres-store). A third-party one is a module listen-fire starts, or reaches at its URL, and calls over adapter protocol 2's HTTP, as it calls any module.
Installing one #
A folder in the project's externals folder, with an lf-external.yaml:
name: acme_store
kind: core
slot: store
command: [node, store.mjs] # started in this folder; or `url:` for a remote one
settings: # optional; prompted by `listen-fire connect acme_store`
- { name: path, label: the state file, secret: false, optional: false }
env: { LOG_LEVEL: info } # optional
- lf.yaml names it for the slot:
core: { store: acme_store }. listen-fire connect acme_storeasks its settings, keeps them on its install entry in the secrets file, and opens it once (so a module that sets itself up, a schema say, does so then).- Every slot takes such a module (the transport): the models, judge, files and browser slots' methods are models-slot.md, judge-slot.md, files-slot.md and browser-slot.md.
The envelope #
The adapter protocol's (the envelope, the transport): one POST per method, the method in the body, the install's settings in settings on every request.
- A runner's requests carry
hostRelease, the release of thelisten-firethat sent it ("0.15.2"; adapter protocol 2.8). A request without it did not state its sender's release: it comes from a runner before 0.15.2, or from a host that does not set it. A module serving runners of several releases answers each by its line: the methods it answers, and the meaning of those whose meaning changed under one name (see Changes). The movements and secrets slots share this envelope, so their requests carry it too. - A method's
paramsis one object, keyed in camel case. Itsresultis the answer;nullfor a method that answers nothing. - An absent optional string is
null. - Instants are
Date.toISOStringstrings; ids and tokens are strings the module mints. - A failure is
ok: falsewith anerror. The coderun_lease_lost, withdata: { runId }, means a fenced write found the run's lease gone: listen-fire treats it as losing the run. Any other code is a store failure. - listen-fire calls a store one method at a time and waits for each answer.
- A write that must not be torn by a crash is one method, which the store keeps as one transaction: all of it or none (
parks.commit,parks.commitSegment,events.startRun,inbound.keepDeliveryandwaits.log). A remote store cannot hold a transaction open across requests, so there is no method that opens one. inbound.keepDeliveryis also where a redelivery is kept out: its receipt is written first, unique on the subscription and the provider's delivery id, so a second delivery under that id, even one arriving while the first's work is still queued, writes nothing and answerskept: false.
Methods #
The first request is always store.open. The others are the run host's store families (RunHostStore in packages/movement-engine/services/run_host/), method for method. Their meaning is the contract suite's: undefined holds every store to the same cases, and apps/cli runs it through a store module (run_host_contract.test.ts).
| method | params | result |
|---|---|---|
store.open | { settings: { [name]: string } } | { tenant }: the team id listen-fire's own runs are kept under |
runs.start | { id, file, movement, startedAt, input, deliveryId?, teamId? }; deliveryId names the delivery whose event started the run; teamId the team the run is kept under (absent or null: the tenant the store was opened under) | null |
runs.status | { runId } | the run's status, or "missing": a contract suite helper, not called by listen-fire |
runs.complete | { runId } | null. The run settled completed: a contract suite helper, not called by listen-fire |
runs.lapsed | { staleMs, limit } | the ids of runs still running whose lease lapsed or was never taken, started more than staleMs ago, oldest first: their worker is gone (a live first firing holds its lease and renews it every third of its length) |
runs.settle | { runId, status, outcome, at }; status completed, parked, running or failed | null |
runs.markParked | { runId } | null |
runs.rerun | { runId, input, at, fence } | { attempt }: the run's next attempt's number; null, and nothing written, when the run's status is not failed. One write, fenced as parks.commit is (a lost fence is run_lease_lost): the run's latest attempt is kept on its row as { attempt, status, startedAt, finishedAt, outcome } (outcome the JSON object it settled with) at the end of attempts; the row's attempt goes up by one; and the run is running again, with input as its input, at as its startedAt, no finishedAt or outcome, no cancel request, and its spend baseline moved to what it has spent (the cost cap counts the new attempt alone). listen-fire calls it under the run's lease, for listen-fire runs rerun |
runs.get | { runId } | a run row, or null |
runs.list | { limit } | run rows, most recent first |
runs.appendTrace | { runId, segmentId, entries: [...] } | null |
runs.traceLength | { runId } | integer |
runs.spent | { runId } | microdollars spent since the spend baseline |
runs.addSpend | { runId, microdollars } | null |
runs.resetSpendBaseline | { runId } | null |
runs.requestCancel | { runId, reason } | null |
records.cancelRequest | { runId } | { reason }, or null when none was asked |
records.markFailed | { runId, message } | null. A running or parked run settles failed with message as its outcome's error; the writes its outcome lists (writes, writeRecords) stay |
parks.putLeaf | { runId, address, reason, wakeAt, state, fence } | null |
parks.claimLeaf | { runId, address, reason } | { reason, state }, or null. Claiming the leaf also drops the run's wait notes that name it as their leaf (waits.note): they were made by a segment that resumed this leaf and died before its progress was kept |
parks.finishLeaf | { runId, address, fence } | null. In the same write, the run's wait notes that name the leaf as their leaf are kept (no longer name it) |
parks.leaves | { runId, parkedOnly, reason, dueBy } | [{ address, reason, status, wakeAt }] |
parks.deleteLeaves | { runId, addresses } | null |
parks.commit | { runId, address, state, fence, park }; park is { kind: "timer", wakeAt }, { kind: "wait", waitId } or { kind: "limit" } | null. One fenced write: the leaf (parks.putLeaf with the kind as its reason; a wait's reason is await); for a timer, the run's resume_timer row (key resume_timer:<runId>, payload { runId }) armed at wakeAt as work.enqueue arms it; for a wait, the run's wait waitId parked at address (a run with no such wait writes nothing, and the store fails), never interleaved with a waits.log on the wait's record, and stranded as it parks when its record is already gone (the newest arrival logged on it after its since is gone, or no key; waits.stranded); whatever the kind, a wait the run parked at address before is parked there no more, and the run's wait notes that name address as their leaf are kept (no longer name it); and the run marked parked (runs.markParked). A lost fence writes nothing (run_lease_lost) |
parks.commitSegment | { runId, teamId, fence, resumed, joins, parks, settle, owedRaces, settledRace }; joins is [{ frameAddress, pending }], parks is [{ address, state, park }] with park as parks.commit takes it; resumed is the address of the leaf the segment resumed, or null; settle is { status, outcome, at } as runs.settle takes it, or null; owedRaces is [{ frameAddress, state }], the races the segment's resumed arm won and left to the run to settle (absent: none); settledRace is the frame address of the race the segment settled, or null (absent: none) | [{ runId, waitId, address }]: the wakes it queued. One fenced write of what a segment held back until its end: each join recorded as joins.recordJoin records it, each park committed in order as parks.commit commits it, resumed finished as parks.finishLeaf finishes it (deleted if resuming, its notes kept) unless one of parks is at its address, the run marked parked when parks is not empty, and then the run settled by settle as runs.settle settles it; then the settle_race row of settledRace is deleted, and each of owedRaces is queued as a settle_race row as work.enqueue queues it (key settle_race:<runId>:<frameAddress>, payload { runId, frameAddress, state }, team teamId, due now; two segments that owe one race keep one row). So when a leaf is finished, whatever it leaves the run to do next is kept in the same write: a park, a join, or a race to settle. In the same write, each wait a park parks whose arrivals are unread (waits.arrivals lists some) gets a wake_await row (a deleted record wakes nothing: its waits keep waiting) (payload { waitId, runId, address }, team teamId, due now), and is answered: reading them in the write, never interleaved with a waits.log on the wait's record, means an arrival logged before it is read here and one logged after it finds the wait parked. A lost fence writes nothing (run_lease_lost); a park the store refuses (a wait the run does not have) writes nothing either. listen-fire calls it once at the end of every segment that parked, recorded a join, resumed a leaf or settled a race, with the run's settle; it never settles the run completed while the run owes a race (parks.owedRaces) other than the one the segment settled. Of the other segments, one that settles its run does so with runs.settle alone, and one that leaves its run as it stands writes nothing: a failed segment of a run already settled. A segment that dies before it leaves the resumed leaf to be resumed again, and the race it settled still owed |
parks.owedRaces | { runId } | [{ frameAddress, state }]: the run's settle_race rows' payloads, oldest first. listen-fire settles each at the end of a drain of the run (and when its row is due), resuming from state, and reads them before it settles a run completed |
joins.recordJoin | { runId, frameAddress, pending, fence } | null |
joins.decrementJoin | { runId, frameAddress, branchAddress, leafAddress } | { closed } |
joins.persistBranchExport | { runId, frameAddress, branchAddress, branchIndex, exports } | null |
joins.collectBranchExports | { runId, frameAddress } | [{ branchAddress, branchIndex, exports }] |
joins.hasPendingJoins | { runId } | boolean |
joins.frames | { runId } | frame addresses |
joins.deleteFrames | { runId, frameAddresses } | null. The frames, their exports, and the settle_race rows owed at them: a race inside an arm another race withdrew is settled by nobody |
leases.claim | { runId, ttlMs } | { token }, or null while another holds it. The lease keeps the arrival mark (waits.mark) taken as it is claimed, in the same write: while it is unexpired, the sweep keeps every arrival above it (waits.sweep), so the step it covers, which takes its own marks after, misses none |
leases.renew | { runId, token, ttlMs } | boolean |
leases.release | { runId, token } | null |
waits.mark | {} | integer: the latest seq logged (0 for none). Every waits.log call made after this answer logs under a greater seq: a wait opened since it reads them all |
waits.log | { teamId, adapterType, arrivals: [{ edge, key, identity, version, landing, gone }], at, resendWindowMs }; key is the record whose edge it arrived on; identity and version as the edge declares them, null for none; landing the arrived record, null for none; gone: the record is gone | { wakes: [{ runId, waitId, address }], stranded: [<a stranded wait, as waits.stranded answers it>] }: wakes, the waits parked on the arrivals' records, one each. One write: each arrival logged at at in order, under a seq greater than every seq before it (store-wide), and a wake_await row (payload { waitId, runId, address }, team teamId, due now) queued per wait answered, so a crash leaves both or neither. A gone arrival answers no wait: a wait whose record is deleted keeps waiting. It strands, at at, each wait parked on its record not stranded already (stranded waits). The newest report on a record wins: any other arrival logged on it clears the stranding of each of its waits, and wakes the parked ones as any arrival does. stranded is the waits the log left stranded (a later arrival in the same log on the record clears a stranding it made). at is any ISO 8601 instant; a store writes a stranding's time as ISO 8601 in UTC with milliseconds (2026-10-31T09:00:00.000Z), so waits.stranded's at sorts as the instants do. A wait is parked while parks.commit parked it at an address whose leaf is still kept. Every arrival is logged, whether or not a wait reads it, except a repeat: one whose identity and version (both null-equal) its team, adapter, edge and record logged within the module's re-send window before at is skipped, and answers no wait (a sender's retry). The store keeps each identity apart from its entry (arrival_identity in listen-fire's stores) until its log's at plus resendWindowMs, so a re-send after the sweep forgot the entry is still a repeat. resendWindowMs is the module's resendWindowSeconds (adapter protocol), an hour when it declares none, or what lf.yaml's resendWindow sets for it. An arrival without an identity is never a repeat. A log never interleaves with a wait's park on the same record's edge (a store with one writer at a time has nothing to do; @lf/postgres-store locks the record's edge for the transaction): either the log sees the park and answers it, or the park commits after the log. So a host that reads a wait's arrivals after parking it misses none |
waits.open | { runId, teamId, adapterType, edge, key, recordId?, since }; since a waits.mark answer; key null (or absent) for a record already gone; recordId the record's id, as the engine names it | { waitId }. The run waits on this edge of this record, reading what is logged after since, and keeps recordId: a wait already there that has none takes it (a delivery opens a run's wait on its trigger's record before the run reaches its await, naming no record), and one that has an id keeps it. A run that already waits on it keeps that wait, and its since. Without a key nothing arrives on it and a park on it strands it; a run that already waits on this edge of a record already gone keeps that wait, as it keeps a wait on a record |
waits.arrivals | { waitId } | { arrivals: [{ seq, identity, version, landing }] }: the arrivals logged after the wait's since on its record's edge (same team and adapter) that the wait has not noted, in seq order; of several with one identity and version, the first only; identity the reported one, or # and the arrival's seq when it reported none, so each such report is its own. Only an arrival without an identity is named so, apart from a bare seq a module reports; a module that reports an identity of exactly # and a number could share it. Gone arrivals are not listed. An unknown wait fails |
waits.note | { waitId, identity, version, matched, leaf?, fence? } | boolean: whether the arrival counts. The wait notes the arrival (no longer listed) and whether its record matched; it counts when it matched and its identity did not match at its last noted arrival (none: it did not). An arrival noted before answers false and changes nothing. leaf, when given, is the address of the leaf the noting segment resumed: the note is kept with that segment's progress (parks.finishLeaf of the leaf, parks.commit at its address, or the segment's parks.commitSegment) and dropped when the leaf is claimed again (parks.claimLeaf), so an arrival a segment that died had taken is read again rather than lost. Until then it reads as noted. fence, when given, is the noting segment's run lease: a run not leased under it notes nothing (run_lease_lost), so a note can never be kept by the segment that took the run over |
waits.stranded | { runId }, or { after?, limit } with after { at, waitId } | [{ waitId, runId, adapterType, edge, key, recordId, at, parked }] (recordId null for a wait opened naming none), oldest stranded first (ties by waitId): the run's stranded waits; or every run's, at most limit, after the wait after names. A wait is stranded when it is parked on a record its module reported deleted: by a gone arrival waits.log logs while it is parked, or as parks.commit (or parks.commitSegment) parks it on a record whose newest arrival after its since is gone, or with no key. at (ISO 8601 in UTC with milliseconds) is when; after.at is any ISO 8601 instant; key is null for a wait opened without one; parked is whether its leaf is still kept where it parked (a race's other arm may have won since). A wait stays stranded until waits.log logs an arrival other than a gone one on its record (the newest report wins: the record came back, or was never deleted), or its run's rows go. listen-fire reads none from a store that answers method_not_implemented (stranded waits) |
waits.sweep | { limit, floor? }; floor: a mark the caller's own running steps hold in its process rather than by a lease (an embedded host's run starts without one) | { swept }: how many arrivals went. Forgets at most limit arrivals, oldest first, each one that both: no live wait reads (one on the same team, adapter, edge and record whose since is below its seq); and is at or below the floor. The floor is the lowest mark an unexpired run lease kept when it was claimed (leases.claim), else the latest seq, and never above floor when it is given: a step that took a mark and has yet to open its wait is covered by its lease, or by the mark its host holds for it. A wait its run opened is live while it exists (it goes with its run's state, leases.dropRunState); one inbound.keepDelivery opened for a run it queued is live while that run exists or its fire_event row is queued (not buried). In the same write it forgets identities whose re-send window has passed (waits.log). listen-fire sweeps on each drain pass, and sweeps nothing from a store that answers method_not_implemented (it logs no arrivals) |
leases.dropRunState | { runId, fence } | boolean. The run's leaves, joins, waits (with their notes) and settle_race rows are dropped |
work.enqueue | { kind, key, payload, dueAt, teamId } | null |
work.enqueueIfAbsent | { kind, key, payload, dueAt, teamId } | boolean: whether it was queued. One write: the row is inserted only when no row has the key; a row there in any state (due, leased, dead) is left as it is, its due time and payload untouched. listen-fire arms a polled listen's standing row with it, so several workers arming it at once queue it once and never move the due time of a row another holds |
work.claimDue | { limit, leaseMs, dueBy, kinds? }; kinds (a drain pool's) leases only rows of those kinds, absent every kind; a dead row is never claimed | [{ id, kind, key, payload, claim, attempts }]; attempts counts the row's claims, this one included, less those released busy, since it was queued or released rescheduled |
work.complete | { id, claim } | null |
work.completeKey | { key } | boolean: whether it was deleted. A row its caller queued a run lease out (dueAt), so a drain takes it only if the caller died, and then ran itself, done: deleted unless a live claim holds it (the caller was slow and a drain took it, and its holder completes it). Completing a fire_event row drops, in the same write, the waits inbound.keepDelivery opened under its key (fireKey) for runs that never started, as work.bury does. listen-fire calls it for what a delivery to /hooks/<module> (or listen-fire run --webhook) landed for the listens on a traversal, which the request runs, and for what listen-fire run --poll polled |
work.release | { id, claim, dueAt, why? }; why is failed (the claim was an attempt; the default), busy (its run was held elsewhere: the attempt is given back) or rescheduled (done, and due again: the attempts start over) | null |
work.bury | { id, claim, reason } | null. The row gave up: kept with reason, unleased and never claimed again; unless it was re-armed since the claim, when it is only unleased. work.enqueue of its key brings it back, its attempts from nought. Burying a fire_event row drops, in the same write, the waits inbound.keepDelivery opened under its key (fireKey) for runs that never started |
sources.put | { teamId, hash, source } | null |
sources.get | { teamId, hash } | the source, or null |
inbound.checkpoint | { subscriptionId } | { checkpoint }, or null |
inbound.saveCheckpoint | { subscriptionId, checkpoint } | null |
listens.states | { pipelines } | { <pipeline>: { heads, resolvedAt, failure, paused, held, parents?, members? } }, one member per listen named that has a state; a listen with none is absent. What a listen whose earlier hops listen-fire resolves was last resolved to: heads the ids of the records it watches (every process routing the listen watches these; none while it is suspended), parents (absent: none) for each resolved hop whose module reports its changes per record it hangs off, by the hop's edge, the ids of those records ({ "Tables": ["app…"] }), which that edge is watched under, members (absent: none) for a listen through or onto a union, each member's own { heads, parents? } in its plan's order of members (heads and parents are theirs together), resolvedAt an ISO 8601 instant, or null for a listen never resolved, failure the named failure of its last resolve (LISTEN_SCOPE_LIMIT: …, LISTEN_RESOLVE_FAILED: …) or null, paused why it is paused or null, held how many arrivals the listen held and did not run while paused (a whole number, 0 if none; a store that answers none is read as 0). pipeline is the listen's, as a run's trigger_id names it. listen-fire reads every resolved listen of its set in one call, at each drain pass |
listens.claimResolve | { pipeline, ttlMs } | { token }, or null while another hold on the listen has not lapsed. Holds the listen's resolve for ttlMs, so one process lists it at a time. A listen with no state gets one, { heads: [], resolvedAt: null, failure: null, paused: null } |
listens.saveResolution | { pipeline, token, resolution: { heads, resolvedAt, failure, parents?, members? } } | true, or false (nothing written) when the hold token names lapsed and was taken since. One write: the listen's state becomes resolution, its paused kept as it is, and the hold is let go |
listens.setPaused | { pipeline, paused } | null. Sets the listen's paused (null: not paused), the rest of its state kept; a listen with no state gets one, never resolved. null also sets held to 0; a paused set while the listen is paused keeps held |
listens.hold | { pipeline } | null. Adds one to the listen's held, as one write whatever other processes add; a listen that is not paused, or has no state, is left as it is (it was resumed meanwhile). A resolve (listens.saveResolution) keeps held as it keeps paused |
writes.record | { adapter, recordType, externalId, runId, depth, at, forgetBefore } | null. Keeps one record write a run sent in the write ledger: the record (adapter the module's name), the run, its place in its chain of writes and arrivals (depth, a whole number, 0 for a run nothing in the ledger caused) and when it was sent (at, an ISO 8601 instant). In the same write it forgets every write sent before forgetBefore, and the run's earlier write of the same record (a run's writes of a record share one depth, so the latest answers writes.depth as they all would; a store may keep both). listen-fire calls it the moment a module answers a createRecord, updateRecord or deleteRecord, before the run does anything else |
writes.depth | { adapter, recordType, externalId, since } | { runId, depth } of the deepest write of the record sent at or after since (the latest of those as deep), or null for none. listen-fire asks it for each arrival that fires a listen, by the record the arrival is about and by its landing, to tell which run's write caused it |
runs.failed | { after?: { at, runId }, until, limit } | [<a run, as runs.get answers it>]: the runs settled failed at or before until, after the run after names, at most limit, oldest failure first (ties by id). The records door reads it (system-events.md); listen-fire reads none from a store that answers method_not_implemented |
releases.note | { version, at } | true when version is new to the store (kept, first seen at), false when it was kept before. listen-fire calls it as serve or drain opens the store; a store that answers method_not_implemented keeps none |
releases.list | { after?: { at, version }, limit } | [{ version, at }], the releases kept after the one after names, oldest first (ties by version), at most limit. The records door reads it; none from a store that answers method_not_implemented |
unjudged.note | { pipeline, arrival, file, movement, deliveryId?, edge, adapter?, recordId?, landing?, reason, cause?, at, attempt?, forgetBefore } | true when the listen pipeline could not judge the arrival arrival (its receipt key) at attempt for the first time (kept, found at), false when that listen, arrival and attempt were kept before; attempt is 0 (absent: 0) as the arrival was delivered, one more for each replay of it through the listen (listen-fire listens replay); adapter is the module the arrival came from, and landing what landed, { recordId?, recordType?, fields } as its module reported it, kept as given and answered by unjudged.list (a module knows its own landings by them: @lf/system its own Arrival Not Judged events); either way, the facts found before forgetBefore are forgotten in the same write. listen-fire calls it wherever it judges an arrival undecided, with forgetBefore its delivery retention (deliveries.retainDays); a store that answers method_not_implemented keeps none |
unjudged.list | { after?: { at, pipeline, arrival, attempt? }, pipeline?, limit } | [{ pipeline, arrival, file, movement, deliveryId, edge, adapter, recordId, landing, reason, cause, at, attempt }] (deliveryId, adapter, recordId, landing and cause may be null), the facts kept after the one after names (its absent attempt: 0), only the listen pipeline's when it names one, oldest first (ties by pipeline, then arrival, then attempt), at most limit. The records door reads it (system-events.md, Arrival Not Judged); none from a store that answers method_not_implemented |
inbound.keepDelivery | { subscriptionId, checkpoint?, delivery: { id, externalId?, receivedAt, source, inline, replayOf?, events, request? }, work: [{ kind, key, payload, dueAt, teamId }], arrivals?: { teamId, adapterType, arrivals, at, resendWindowMs }, waits?: [{ runId, edge, key, fireKey, preNoted? }], unjudged?: [{ pipeline, arrival, file, movement, deliveryId?, edge, adapter?, recordId?, landing?, reason, cause?, at, attempt? }] }; source is webhook, hook, emit, cli or poll (a poll of a listenable edge that fired a listen: its arrivals and its runs' waits, no events); events are the events the delivery produced; request is the request it was received as, { method, path, query, headers, body, checkpoint?, arrivalIds? } (body base64; headers lower case, only those its module declares its inbound handling reads (inbound.captureHeaders) and, under a declared scheme, the content type and the delivery id header, never one whose name says it may hold a credential; body and query without the fields its module declares in inbound.redactFields, the body otherwise as received; checkpoint the pull cursor its module read it against; arrivalIds the receipt keys of the arrivals it brought, all a replay judges again), absent for a delivery no request made (a replay); a poll's delivery gives the page it read instead, { adapter, install?, credential?, arrivals }. A store keeps request as given and answers it from inbound.delivery alone; it is sensitive (a message's text, a person's address), and is forgotten with the delivery or by inbound.sweepDeliveries, whichever is first; arrivals are what the delivery reported, as waits.log takes them; waits are the waits of the runs the delivery queues, on the records each is handed (its trigger, and each record its trigger's arrival carries): key null for a record already gone, fireKey the key of the fire_event row that queues the run, preNoted its trigger's own arrival { identity, version }; unjudged are the listens that could not judge the delivery's arrivals, each as unjudged.note takes it without forgetBefore | { kept, arrivalMark, wakes? }. One write: the delivery's receipt first, then the cursor saved (absent: left as it is; null is a cursor to save), the waits opened, the arrivals, each row queued as work.enqueue queues it, and each of unjudged kept as unjudged.note keeps it (a store that keeps no unjudged arrivals ignores them). arrivalMark is the waits.mark before the delivery's arrivals. waits without arrivals are refused (nothing written): a delivery's waits are opened with its arrivals, under their team and adapter. With arrivals: each of waits is opened first as waits.open opens it, since that mark, under the arrivals' team and adapter, and its preNoted arrival noted on it as judged and not matched, when this write opened it (so the run's trigger never answers it, and a later version of the trigger record that matches still counts); the arrivals are logged as waits.log logs them, with the wake_await row it queues per wait they answer ({ kind: "wake_await", key: "wake_await:<unique>", payload: { waitId, runId, address }, teamId }); each fire_event row's payload gains arrivalMark (a run the delivery queued reads its trigger's waits from there: a reply that lands while it sits in the queue, or later in the same delivery, answers it); and wakes lists those rows (empty for none). A store whose inbound.keepDelivery logs through its own waits.log has queued them there, and queues none a second time. kept: false, and nothing written (no cursor, no arrivals, no rows), when a delivery with the same externalId was kept at the subscription before, or one with the same id. Without an externalId a delivery is never a duplicate |
inbound.forgetDeliveryId | { id } | null. Clears the kept delivery's externalId, its receipt kept: listen-fire calls it when a delivery's events, run inside the request, failed and its sender was answered 500, so the sender's retry under that id is kept as a delivery of its own |
inbound.deliveries | { subscriptionId?, since?, limit }; since: received at or after it | delivery rows without their events, most recent first, at most limit |
inbound.delivery | { id } | a delivery row with its events and its request (when it kept one), or null |
inbound.sweepDeliveries | { before, limit } | how many went. Forgets at most limit deliveries received before before whose runs are all settled (none running or parked) and that no fire_event work row names (its payload.deliveryId), and the request of every delivery received before before (one its runs still hold is kept without it) |
processes.list | {} | { processes, version }: the module processes the hosts on this store started (an array of { pid, listen-fire, machine, command }), at its version; { processes: [], version: 0 } for a list never written |
processes.swap | { version, processes } | true when the list was still at version and is now processes at version + 1; false when another host wrote first (read again and retry) |
events.claim | { listen, key, teamId? }; teamId as runs.start takes it | true the first time this event key reaches this listen under this team; false for a redelivery, which runs nothing. The same listen and key under another team is another receipt |
events.startRun | { listen, key, id, file, movement, startedAt, input, deliveryId?, teamId? }; teamId the receipt's and the run's | events.claim and, when it is true, runs.start, as one write: false, and no run, for a redelivery. @listen-fire/embed sends each run an arrival starts with its tenant as teamId, and the run's facts as input |
The rows:
type RunRow = {
id: string; file: string; movement: string; status: string; startedAt: string; finishedAt?: string | null;
input: string; outcome: string | null; deliveryId: string | null;
attempt?: number; // the latest attempt's number; absent: 1
attempts?: { attempt: number; status: string; startedAt: string; finishedAt: string | null; outcome: unknown }[]; // the earlier ones, oldest first
};
type DeliveryRow = {
id: string; subscriptionId: string; externalId: string | null; receivedAt: string;
source: 'webhook' | 'hook' | 'emit' | 'cli' | 'poll'; inline: boolean; replayOf: string | null;
eventCount: number; runs: { id: string; status: string }[];
events?: unknown[]; // inbound.delivery only
request?: unknown; // inbound.delivery only, while it is kept: the request it was received as (inbound.keepDelivery)
};
A run's outcome is the JSON object listen-fire settled it with: { writes, writeRecords?, returned? }, or { error, writes?, writeRecords? } for a failed run, which keeps the writes it made before it failed. A run's status, startedAt, finishedAt, input and outcome are its latest attempt's. writeRecords lists the run's writes (listen-fire runs show); a store that drops it still works with listen-fire, and its runs list no writes. finishedAt is when the run settled completed or failed; a store that answers none leaves listen-fire runs showing null. returned is what the movement returned, as listen-fire records it (at most 64 KiB of JSON, a file by its reference). A store hands the outcome back as it was given (its members may be kept in columns of their own, as @lf/postgres-store keeps them). A store that drops returned still works with listen-fire: its runs show no returned value.
listen-fire --contract-store <state.db|postgres://…> serves these methods over listen-fire's own stores, as a store module on a Unix socket. A module may forward to it, as the test module does (apps/cli/src/__test__/store_module/forward.mjs). runs.status and runs.complete are the contract suite's helpers and are not called by listen-fire.
Changes #
- listen-fire 0.15.2 (adapter protocol 2.8; a module may take it): a runner's requests carry
hostRelease, the release of the runner that sent it (The envelope); one without it did not state its sender's release (a runner before 0.15.2, or a host that does not set it). Why: a module serving a 0.12 runner and a 0.15 runner at once must answer each by its own method set, andwaits.sweepandinbound.keepDelivery'sarrivalMarkchanged meaning in 0.13 under the same names, so a misread caller gets a wrong answer rather than an error. - Unreleased (after listen-fire 0.13.0; a store keeps it as it keeps the rest of a state): a resolution and a state may carry
members, each member's own{ heads, parents? }of a listen through or onto a union, besideheadsandparents, theirs together. The listen is one, under one pipeline, so its state is one row. - 2026-11-02 (listen-fire 0.13.x; a store module that answers
unjudged.notemust take it):unjudged.note, and each ofinbound.keepDelivery'sunjudged, carryadapter(the module the arrival came from) andlanding(what landed, as its module reported it), which the store keeps andunjudged.listanswers. Why:@lf/systemnever reports an arrival that is itself one of itsArrival Not Judgedevents, and knew one by a prefix of the record id it minted; it now compares the landing'sKindon its ownEvents, which needs the landing kept.@lf/postgres-store's migration 17 and the SQLite state file addarrival_unjudged.adapterand.landing; a fact kept before has neither. A store that drops them leaves@lf/systemunable to tell its own reports, and two listens onEventsthat cannot judge them would report each other's, once a poll. - 2026-10-31, later (listen-fire 0.13.0; a store module that serves several teams must take it):
runs.start,events.claimandevents.startRuntake an optionalteamId, aswaits.*,work.enqueue,sources.*andparks.commitSegmentalready do: absent, the tenant the store was opened under. A receipt is its team's (the same listen and key under another team is another receipt), and a run row is kept under its team. listen-fire sends none for its own runs. Why:@listen-fire/embedserves many tenants over one store, and packed the team intolistenbefore. listen-fire's SQLite state file keysevent_receiptbyteam_id(each receipt kept before is the store's own tenant's) and addsrun.team_id;@lf/postgres-storealready keys both by team. - 2026-11-01, latest (listen-fire 0.13.0; a store module that answers
unjudged.notemust take it):inbound.keepDeliverymay carryunjudged, the listens that could not judge the delivery's arrivals, kept in its write asunjudged.notekeeps each. Why: a poll that a listen could not judge moved its cursor in one write and kept the fact in a second, so a crash between them lost the fact. A store that keeps no unjudged arrivals ignores the field. - 2026-11-01, later (listen-fire 0.13.0; a store module may take it):
inbound.keepDelivery'sdeliverycarriesrequest, the request it was received as, whichinbound.deliveryanswers andinbound.sweepDeliveriesforgets past retention;unjudged.notetakesattemptand keeps each listen, arrival and attempt once;unjudged.listtakespipelineand pages byattempttoo. A store that dropsrequeststill works, and no delivery it kept can be replayed. Why: a listen that could not judge an arrival (a provider's lookup failed) is put through the delivery again once the provider recovers (listen-fire listens replay), and each replay that still cannot judge it is reported once.@lf/postgres-store's migration 16 holdsarrival_unjudged.attemptanddelivery.request; the SQLite state file addsdelivery.request. A kept request holds only the headers its module declares it reads (inbound.captureHeaders, never a signature header), its body and query without the fields its module declares (inbound.redactFields), and the ids of the arrivals its delivery brought; the rest of the body is as received, and may hold what the provider puts in it. - 2026-11-01 (listen-fire 0.13.0; a store module may take it):
unjudged.notekeeps each listen that could not judge an arrival, once per listen and arrival, andunjudged.listpages them; a store that answersmethod_not_implementedkeeps and reports none. Why: listen-fire takes every delivery, so a listen it cannot judge is reported by@lf/system(Arrival Not Judged), never left to a provider's retry.@lf/postgres-store's migration 16 and the SQLite state file addarrival_unjudged. - 2026-10-31 (listen-fire 0.13.0; a store module may take it): what the records door reads (system-events.md).
runs.failedpages the failed runs,releases.notekeeps each release the store is opened under once andreleases.listpages them; a store that answersmethod_not_implementedto any of them reports none.waits.opentakes the record'srecordId, andwaits.strandedanswers it. Why:@lf/systemreports a failed run, a release and a wait that lost its record, with the record it lost.@lf/postgres-store's migration 15 and the SQLite state file addwait_row.record_id,release_seenand an index on failed runs. - 2026-10-31 (listen-fire 0.13.0; a store module must take it): the write ledger.
writes.recordkeeps each record write a run sends, with the run's place in its chain of writes and arrivals and when it was sent, and forgets those sent beforeforgetBefore;writes.depthanswers the deepest write of a record since an instant. Why:suppress_selfleaves the language, and the host breaks loops itself. An arrival on a record a run wrote withinlf.yaml'sloopWindowcontinues that run's chain, and a chain pastloopDepthpauses the listen that would continue it (LISTEN_LOOP). listen-fire calls them for every run that writes and every arrival that fires a listen, and a store module that answersmethod_not_implementedfails there, naming the method.@lf/postgres-store's migration 14 and the SQLite state file addwrite_ledger. A store that cannot answerwrites.depthfails the arrival's delivery (it is tried again), and one that cannot keepwrites.recordfails the run's write call after the write was sent: the loop breaker is never silently off. - 2026-10-31 (listen-fire 0.13.0; a store module must take it):
listens.hold, andheldin a listen's state. A paused listen counts each arrival it does not run, solisten-fire listenscan say how many were held; they are counted, not kept, and a resume clears the count and does not run them. An arrival a listen does not run takes its receipt (events.claim), so a provider's redelivery of it is neither counted twice nor taken for the start of a new chain. A store that answersmethod_not_implementedforlistens.holdfails the delivery that holds an arrival, naming the method. - 2026-10-31 (listen-fire 0.13.0; a store module must take it): the arrival log is swept per record, and a delivery opens its runs' waits as it is kept.
waits.sweeptakes{ limit }and answers{ swept }: an arrival goes once no live wait reads it and it is at or below the floor, the lowest mark an unexpired run lease kept (leases.claimkeepswaits.markas it claims), else the latest seq;beforeis gone.waits.logtakesresendWindowMs, and the store keeps each identity apart from its entry for that window, so a re-send after the sweep is still a repeat.waits.sweepmay takefloor, a mark the caller's own running steps hold in its process: nothing above it goes either (@listen-fire/embedholds each step's, since a run it starts takes no lease).inbound.keepDeliverytakeswaits(the runs it queues, each on its trigger, from the mark before the delivery's arrivals, the trigger's own arrival pre-noted) andarrivals.resendWindowMs, and answersarrivalMark, the mark before the arrivals (it was the one after). A delivery'ssourcemay bepoll: a poll of a listenable edge that fires a listen is kept as a delivery, its arrivals and its runs' waits in one write, with no events.work.buryof afire_eventrow drops the waits opened under it for runs that never started. Why: a run a delivery queues missed the replies that landed while it sat in the queue, and the sweep by age could forget an arrival a slow step was about to read.@lf/postgres-store's migration 13 addslease_mark,arrival_identityandwait_row.fire_key, drops the wait's foreign key on its run (a delivery's wait exists before its run does), and lets a delivery'ssourcebepoll.work.completeKey({ key }) completes a row its caller queued a run lease out and ran itself, unless a drain holds it, and drops the waits opened under it for runs that never started: a delivery's arrivals that a request runs itself (/hooks,listen-fire run --webhook,listen-fire run --poll) are a realfire_eventrow, so their runs' waits are live while the runs start, and a drain retries them if the process dies. A poll that fires keeps its cursor, its arrivals, its runs' waits and onefire_eventrow in oneinbound.keepDelivery; the row carries the runs it minted (runs) and the poll (polled).inbound.keepDeliveryrefuseswaitssent withoutarrivals. - 2026-10-31 (listen-fire 0.13.0):
listens.states,listens.claimResolve,listens.saveResolutionandlistens.setPausedkeep what each listen whose earlier hops listen-fire resolves (listen to f-[b:Boards WHERE …]->-[:Notes]->) was last resolved to, why its last resolve failed and why it is paused, under a hold that lets one process resolve it at a time; a resolve never writespaused, and a pause never writes the rest. listen-fire calls them for every such listen, and a store module that answersmethod_not_implementedfails it there, naming the method.@lf/postgres-store's migration 12 and the SQLite state file addlisten_state. A resolution and a state may carryparents, the records each hop reported per record hangs off; a store keeps it as it keeps the rest of the state. - 2026-10-31 (listen-fire 0.13.0): stranded waits (stranded-waits.md).
waits.loganswers{ wakes, stranded }: a gone arrival strands the waits parked on its record, and a later arrival on the record clears that and wakes them.parks.commitandparks.commitSegmentstrand a wait they park on a record already gone.waits.open'skeymay benull, for a record already gone: one such wait per run and edge.waits.arrivalsanswers nogone.waits.strandedis new: one run's stranded waits, or every run's a page at a time.@lf/postgres-store's migration 11 and the SQLite state file addstranded_atand let a wait's key be null. - 2026-10-31 (listen-fire 0.13.0): callbacks and the awaits keyed by a correlation retired. listen-fire no longer calls
callbacks.*,correlations.*orrecords.closeRun, andparks.committakes no{ kind: "await" }park: anawaitwaits on a listenable edge (waits.*). A store module may still list them; nothing calls them.@lf/postgres-store's migration 10 and the SQLite state file drop their tables: a run parked on a callback under 0.12 cannot resume on 0.13. - 2026-10-30 (within adapter protocol 2; listen-fire 0.13.0 calls them for the arrivals modules report and for
awaiton a listenable edge): waits on listenable edges.waits.mark,waits.log,waits.open,waits.arrivals,waits.noteandwaits.sweepkeep the arrivals modules report and the waits runs keep open on them;parks.committakes{ kind: "wait", waitId }, and any park at an address unparks the run's wait parked there before;leases.dropRunStatedrops a run's waits.inbound.keepDeliverymay carry the delivery'sarrivals, logged in its write, and answers the wake rows it queued (wakes): listen-fire sends them only for a delivery that reports arrivals, and refuses a store module that keeps one without answeringwakes. A store module whose manifest does not listwaits.logkeeps no arrivals: listen-fire refuses a delivery that reports some before keeping anything of it (503arrivals_not_kept, so its sender sends it again).waits.logskips a repeat of an arrival it logged within the retention, and queues awake_awaitrow for each wait it answers in its write.waits.notemay name theleafthe noting segment resumed: such a note is kept byparks.finishLeafof that leaf,parks.commitat its address, orparks.commitSegmentthat resumed it, and dropped by its nextparks.claimLeaf, so a crash between a note and the segment's next commit loses no arrival.waits.notemay carry the segment'sfence, refused when the lease is lost.parks.commitSegmentkeeps a segment's joins, its parks, the end of the leaf it resumed and the run's settle in one fenced write, and queues in that write a wake for each wait it parks whose arrivals are unread; listen-fire calls it at the end of every segment that parked, recorded a join, resumed a leaf or settled a race, and fails the run of a store module that does not answer it. A race a resumed arm wins is settled by a later step of its run:parks.commitSegmentqueues it in the arm's write as asettle_racework row (owedRaces) and deletes it in the write of the step that settles it (settledRace);parks.owedRaceslists a run's,joins.deleteFramesdrops those at the frames it deletes, andleases.dropRunStatedrops a run's. A store module must answerparks.owedRaces: listen-fire reads it as each segment ends, and a module that answersmethod_not_implementedfails there, naming the method. The cases every store is held to are undefined.@lf/postgres-storekeeps them from its migration 9. - 2026-10-27, later (within adapter protocol 2):
runs.rerunstarts a failed run's next attempt (listen-fire runs rerun), and a run row may answerattemptandattempts. A failed run's outcome keeps the writes it made (records.markFailedandruns.settlewithfailedalike). A store that answers noattemptorattemptsstill works, its runs each one attempt; one withoutruns.rerunworks for everything butlisten-fire runs rerun.@lf/postgres-storekeeps them from its migration 8, and now keepswriteRecordsas well. - 2026-10-27 (within adapter protocol 2): a run row may answer
finishedAt, and a run's settledoutcomecarrieswriteRecords. Both are optional: a store that answers neither still works, and its runs show no finish time and list no writes. - 2026-10-22 (within adapter protocol 2; listen-fire 0.12.0-rc.9 requires it): a delivery's receipt.
inbound.keepDeliverytakes thedelivery(the events it produced) and answers{ kept },falsefor a redelivery under the sameexternalIdat the same subscription, which writes nothing. Every store module must take the new shape: listen-fire treats anullanswer as malformed.inbound.deliveries,inbound.deliveryandinbound.sweepDeliverieslist, read and forget kept deliveries, andinbound.forgetDeliveryIdclears a kept delivery'sexternalId.runs.startandevents.startRuntakedeliveryId, and a run row answers it.runs.status,runs.completeandrecords.closeRun, served all along, are now in the table. - 2026-10-20 (within adapter protocol 2): a run's settled
outcomemay carryreturned, what its movement returned. No method changed: a store that hands the outcome back as it was given keeps it. - 2026-10-20 (within adapter protocol 2): a crash never tears listen-fire's state.
parks.commit,events.startRunandinbound.keepDeliveryeach keep in one write what listen-fire wrote in several;runs.lapsedfinds the runs whose worker was lost, which listen-fire fails;work.claimDue's rows carryattempts,work.releasesayswhy, andwork.burykeeps a row that gave up dead, so a failing row is no longer retried forever. - 2026-10-19 (within adapter protocol 2):
processes.listandprocesses.swap, the list of module processes the hosts on a store started, written by compare-and-set on its version so two lfs on one store never overwrite each other's entries. listen-fire used to keep it in an inbound checkpoint row. - 2026-10-19 (within adapter protocol 2): the methods ride adapter protocol 2's HTTP, as every module's do; the stdin/stdout JSON-RPC wire is retired. A lost lease is the error code
run_lease_lost(data: { runId }). A store module may be remote (url:). - 2026-10-18 (within adapter protocol 2):
work.claimDuetakeskinds, for drain pools (listen-fire drain --kinds). - 2026-10-17 (within adapter protocol 2): the store slot's wire, and process modules filling the store slot.