Migrating to 0.13

0.13.0 opens a new compatibility key: it reads pins of 0.13.x only. A project pinned to 0.12.x is refused by name until it is upgraded, and listen-fire upgrade moves its pin once every file checks clean. The changelog entry lists each change in a line; this guide shows each one with a program you can run.

One idea runs through the release: what happens outside arrives on an edge. A press arrives on a message's Interactions, a form's answer on its Submissions, a reply on a thread's Replies, a tick on the clock's Ticks. A run waits for one by traversing that edge with await, and a listen starts a run on each by traversing it with listen to, as either reads any other edge. The handbook's reviews and listeners chapters teach the forms below in full.

Contents #

1. How the pin moves #

The order that works:

  1. Run listen-fire upgrade --check and read what it lists.
  2. Replace each callback(…) by hand (section 3).
  3. Run listen-fire upgrade. It respells await first(…) (section 2) and each listen it can (section 8), lists the warnings (sections 4, 5 and 8), and moves the pin when none is left. If it lists a wait or a listen it cannot rewrite, rewrite it by hand (sections 2, 8 and 9) and run it again.
  4. Run listen-fire upgrade --all only once you have read what its remaining fixes change (section 8) and checked each warning: it applies those fixes and moves the pin past the warnings.
  5. Delete the == null guards after waits (section 5).

2. await first(x) is written await x #

A wait pulls exactly one record off one edge, so the bare traversal says it.

BeforeAfter
reply = await first(msg-[:Replies]->)reply = await msg-[:Replies]->

Two waits 0.12 accepted are refused once respelled, and the pin holds until each is rewritten by hand:

BeforeAfter
c = await first(deal-[:Comments]->), where Comments declares no orderc = await new deal-[:Comments]->
reply = await first(ops-[:Messages]->-[:Replies]->)msg = only(ops-[:Messages]->), then if (msg == null) { fail("no message") }, then reply = await msg-[:Replies]->
team = connect `@lf/slack` using team_workspace

function `Who Takes It`(go: <manual-[:Invocation]->>) {
  ops = only(team-[ch:Channels WHERE ch.Name == "ops"]->)
  if (ops == null) { fail("no #ops channel") }
  msg = write ops-[:Messages]-> { Message: "Who can take ${go.Text}? Reply in the thread." }

  r = race(
    () => {
      reply = await msg-[:Replies]->
      return reply.Message
    },
    () => { await sleep(1d) },
  )

  if (r[0] == null) {
    write ops-[:Messages]-> { Message: "Nobody picked up ${go.Text}." }
  }
}

listen to manual-[:Invocations]-> fire `Who Takes It`

3. callback(…) is retired #

Buttons: a callback's id on a control #

Before, each answer was its own callback, its id carried in Block Kit JSON, and the run awaited each one's Called edge.

yes = callback({ })
no  = callback({ })
write approvals-[:Messages]-> { Message: "Approve this?", Blocks: [ … ] }   # one control per id

After, the buttons are a field of the message, and a press arrives on the message's Interactions. The Value of the action pressed is one of the values written, so comparing it with one no button offers is refused.

team = connect `@lf/slack` using team_workspace

function `Approve Or Reject`(go: <manual-[:Invocation]->>) {
  approvals = only(team-[ch:Channels WHERE ch.Name == "approvals"]->)
  if (approvals == null) { fail("no #approvals channel") }
  msg = write approvals-[:Messages]-> {
    Message: "Approve ${go.Text}?"
    Buttons: [{ Text: "Approve", Value: "approve" }, { Text: "Reject", Value: "reject" }]
  }

  r = race(
    () => {
      press = await msg-[:Interactions]->
      return only(press-[:Actions]->)?.Value
    },
    () => { await sleep(1d) },
  )

  if (r[0] == "approve") {
    write msg-[:Replies]-> { Message: "Approved." }
  }
  if (r[0] == null) {
    write msg-[:Replies]-> { Message: "Nobody answered within a day." }
  }
}

listen to manual-[:Invocations]-> fire `Approve Or Reject`

Slack carries buttons from this release. Telegram and WhatsApp do not report taps as Interactions yet: send a form's link there instead (next).

Before, the run handed out a callback's url, and a value the person supplied arrived as the callback's parameter.

booked = callback((day: <date>) => { … })
write ops-[:Messages]-> { Message: "Pick a day: ${booked.url}" }

After, the link is a form from @lf/forms. Its fields are a node you declare, and the answer arrives on the form's Submissions, typed by that node.

team  = connect `@lf/slack` using team_workspace
forms = connect `@lf/forms`

node Booking {
  Day: <date>
}

function `Book A Day`(go: <manual-[:Invocation]->>) {
  ops = only(team-[ch:Channels WHERE ch.Name == "ops"]->)
  if (ops == null) { fail("no #ops channel") }
  form = write forms-[:Forms]-> { Title: "Pick a day for ${go.Text}", Fields: Booking }
  write ops-[:Messages]-> { Message: "Pick a day: ${form.Url}" }

  r = race(
    () => {
      booking = await form-[:Submissions]->
      return booking.Day
    },
    () => { await sleep(2d) },
  )

  if (r[0] != null) {
    write ops-[:Messages]-> { Message: "Booked for ${r[0]}." }
  }
}

listen to manual-[:Invocations]-> fire `Book A Day`

ttl and once: false #

What the callback's body did #

The body ran when the person acted. Write it after the wait instead, in the arm that waited: it sees everything the run holds, as the body did.

4. A wait's WHERE keeps the values it read from the run #

5. What a wait binds is always there #

BeforeAfter
reply = await first(msg-[:Replies]->) then if (reply == null) { fail("no reply") }reply = await msg-[:Replies]-> and nothing more

Nothing refuses the old guard; it is dead code.

The record a wait hangs off must be there when the wait is reached. A wait off a name that may hold no record (d = only(c-[:Deals]->), then await new d-[:Notes]->) is refused (MOV_ABSENT_REQUIRED), as a block's walk off one is: under 0.12 it checked clean and failed the run at the wait. Guard it: if (d != null) { n = await new d-[:Notes]-> }, or end the run first when it is missing (if (d == null) { fail("no deal") }).

6. Runs parked under 0.12 #

A run parked under 0.12 cannot resume on a 0.13 engine: it is refused on resume with "this run parked under listen-fire 0.12 (park state 2); a 0.13 engine resumes park state 3". Let parked runs finish, or cancel them (listen-fire cancel), before upgrading listen-fire. A link a 0.12 callback minted is gone for good: /api/cb/<id> answers 410.

7. Modules: listenable replaces awaitable and watchable #

For module authors only.

8. Listens are traversals #

One name for one hop #

One walk names each hop once: -[c:Channels]->-[c:Messages]-> is refused (MOV_HOP_ALIAS_REPEATED), on a listen, a block or a walk inside an expression. Under 0.12 a later read of c meant the nearest hop to the checker and the first to a listen's judge. Give each hop its own alias.

A later hop's WHERE or ORDER BY may read an earlier hop of the same walk, in a run as on a listen: at-[t:Tables]->-[c:History WHERE c.Id == t.Name]-> filters each Table's History by that Table's own Name. Under 0.12 it checked clean and failed the run.

What the upgrade rewrites #

MOV_LISTEN_SETTINGS_RETIRED carries the rewrite where the module declares how its settings map. There are three outcomes:

Each module's listen #

Each entry shows a 0.12 listen, the 0.13 listen listen-fire upgrade writes for it, and the fired function's parameter. "By hand" marks a listen with no rewrite.

cron #

manual #

@lf/email #

@lf/webhook #

@lf/github #

@lf/gmail #

@lf/granola #

@lf/dealroom #

@lf/evertrace #

@lf/stripe #

@lf/attio #

@lf/affinity #

@lf/telegram and @lf/whatsapp #

@lf/airtable #

@lf/slack #

@lf/system #

suppress_self is gone #

What lf.yaml sets #

A listen sets no numbers. Each comes from the module's declaration, and lf.yaml overrides it:

KeyWhat it setsDefault
pollEvery: { <credential>: 5m }how often a connection's polled edges are polled (a connection with no credential is named by its module)the edge's pollEverySeconds
resolveEvery: { <credential>: 1h }how often a listen's filtered earlier hops are listed againthe hop's membersChange: { relistEverySeconds }
resendWindow: { <module>: 3d }how long a provider's re-send of an arrival is the same arrivalthe module's (Stripe: 3 days)
loopWindow: 20show soon after a run's write an arrival on that record continues its chain20s
loopDepth: 10how many runs one chain may be before its listen is paused10
listenScopeLimit: 500how many records a listen's earlier hops may match before it is suspended500
deliveries: { retainDays: 14 }how long a delivery's request is kept, for listen-fire listens replay14

Many answers: the lunch poll #

callback(…, { once: false }) took many answers in one run. In 0.13 each answer is its own run of a standing listen, and the state lives in a table. The poll is a Slack message with Buttons and a Topic, and every press on a message with that Topic runs count_vote, one row per poll and person:

chat = connect `@lf/slack` using team_slack
office = connect `@lf/airtable` using team_airtable { base: "Office" }

function ask_lunch(t: <cron-[:Ticks]->>) {
  channel = only(chat-[ch:Channels WHERE ch.Name == "office"]->)
  if (channel == null) { fail("no #office channel") }
  write channel-[:Messages]-> {
    Message: "Lunch on Friday?"
    Topic: "lunch-poll"
    Buttons: [{ Text: "In", Value: "in" }, { Text: "Out", Value: "out" }]
  }
}

function count_vote(press: <chat-[:Interaction]->>) {
  poll = only(press-[:Message]->)
  if (poll == null) { fail("a press with no message") }
  answer = only(press-[:Actions]->)?.Value
  if (answer == null) { fail("a press with no answer") }
  person = only(press-[:By]->)
  if (person == null) { fail("a press by no one") }
  write office-[:Lunch]-> {
    unique by (Poll, `Person Id`)
    Poll: poll.Id
    `Person Id`: person.Id
    Person: person.Name
    Answer: answer
  }
}

listen to cron-[t:Ticks WHERE t.Schedule == "0 10 * * 3" AND t.Zone == "Europe/London"]-> fire ask_lunch
listen to chat-[:Channels]->-[m:Messages WHERE m.Topic == "lunch-poll" AND m.Timestamp WITHIN 3d]->-[:Interactions]-> fire count_vote

9. A field is never read through a value that may be missing #

10. Diagnostic code index #

CodeSeverity under 0.13Section
MOV_V5_SPELLING (on await first(…))error, safe fix2
MOV_CALLBACK_RETIREDerror, by hand3
MOV_AWAIT_VALUE_CAPTUREDwarning on an upgrade from 0.12, holds the pin until --all4
MOV_AWAIT_UNORDEREDerror, by hand: write await new on an edge with no declared order2
MOV_AWAIT_BARE_WALKerror, by hand: bind the record the last edge leaves from, then await that edge2
MOV_AWAIT_MAY_HANGwarning on an upgrade from 0.12, holds the pin until --all: race the wait with a sleep5
MOV_LISTEN_SETTINGS_RETIREDerror: a safe fix, a fix --all applies, or by hand8
MOV_ABSENT_REQUIRED (a field read through a value that may be missing)error, by hand: test the value with exists9
MOV_FIELD_MEANING_CHANGEDwarning on an upgrade from 0.12, holds the pin until --all: the field holds something else now (gmail's Labels: names, not ids)8
MOV_ABSENT_REQUIRED (a wait off a record that may be missing)error, by hand: guard the wait with != null5
MOV_LISTEN_EDGE_NOT_LISTENABLE, MOV_LISTEN_HOP_NOT_ITERABLE, MOV_LISTEN_UNWATCHABLE, MOV_LISTEN_IMPURE_WHEREerror, by hand, on a listen written or rewritten as a traversal8
MOV_HOP_ALIAS_REPEATEDerror, by hand: give each hop of a walk its own alias8
MOV_UNBOUNDED_EDGEerror, by hand: narrow the hop by a field its module takes, with literals (-[t:Ticks WHERE t.Schedule == "…"]->)changelog
MOV_FILTER_NOT_TAKENerror, by hand: compare a field only its module evaluates with a literal its module takeschangelog
MOV_OWNED_VALUE_INVALIDerror, by hand: write a value its module accepts (a five-field schedule, a time zone)changelog
MOV_COLLECTION_NOT_READABLEerror, by hand: a collection its module only writes to or announces on is written to or listened to, never readchangelog
MOV_WALK_ROOT_NOT_RECORD, MOV_WALK_OFF_HELD_RECORDerror, by hand: start a walk at a graph record (off a change's row, walk from the live record: c-[:Record]->-[:X]->)changelog