Migrating to 0.12

This guide is for movements written before 0.12, the integer versions 1–4 (Quiet Heron, Bright Otter, Steady Lynx, Luminous Owl). From 0.12 a movement's pin is the release version it was written against, and a release reads every pin that shares its compatibility key (0.12). The changelog entry lists each change in a line; this guide carries the full detail. Every rule here that says "under 0.12" applies only to an automation pinned to 0.12. The integer versions 1 to 4 keep their old behaviour exactly (proved by their conformance corpora).

Section 2 respells the whole surface; nothing in it changes what a program means. The four changes after it share one idea: there is no engine-side store and there are no meta edges. Everything a movement reads or writes is an edge or a record that a system declares, and a plugin is a function.

Preview. 0.12 ships as a preview in the 0.12.0 pre-release. Existing automations keep their integer pins (1 to 4) and run unchanged; 0.12 is opt-in, through the explicit upgrade only.

Contents #

1. How the pin moves #

2. The everyday spelling #

Upgrading #

Core words, lower case #

BeforeAfter
MAP(xs, { concurrency: 2 }, (r) => …)map(xs, (r) => …, { concurrency: 2 }) (settings last)
FILTER(xs, (t) => …)filter(xs, (t) => …)
REDUCE(xs, 0, (acc, t) => …)reduce(xs, (acc, t) => …, 0) (TypeScript's order)
COUNT FIRST ONLY COALESCE MEMBERScount first only coalesce members
SORT(rows, `score`, DESC)sort(rows, (r) => r.score, { order: "desc" })
AI(prompt, "careful")ai(prompt, { tier: "careful" })
ERROR("…")fail("…")
callback(f(x))callback(() => f(x))
await until(c, every: 1h)await until(c, { every: 1h })
movement f() { … }function f() { … }

exists, extract, extractOne, all, race, sleep keep their spelling.

Namespaces #

BeforeAfter
TRIM LOWER UPPER LENGTH SPLIT CONCAT JOINText.trim Text.lower Text.upper Text.length Text.split Text.concat Text.join
TOSTRING(v), CHUNKS(t, …)Text.from(v), Text.chunks(t, …)
TEXT.REGEX_EXTRACT TEXT.SLUG TEXT.PAIRSText.regexExtract Text.slug Text.pairs
TEXT.SERIALISE(v, "JSON")Json.stringify(v)
ABS ROUND FLOOR CEIL, NUMBER.FORMATNumber.abs Number.round Number.floor Number.ceil, Number.format
TONUMBER(t), NUMBER(v)Number.parse(v)
DATE(v), DATE.PARSE DATE.ADD_DAYS DATE.FORMAT DATE.TODAYDate.from(v), Date.parse Date.addDays Date.format Date.today
DATETIME(v), DATETIME.AT, DATE.FORMAT_ISODateTime.from(v), DateTime.at, DateTime.toIso
CURRENCY.GET_NUMBER_FROM_FIGURE and its familyCurrency.numberFromFigure Currency.codeFromFigure Currency.formatFigure
URL.HOST(t)Url.host(t)
SUM AVG MIN MAX LAST COLLECT MULTI GROUPBY KEYBYList.sum List.avg List.min List.max List.last List.from List.compact List.groupBy List.keyBy
x IN [..], xs CONTAINS vList.includes(xs, v)
FILE(content, "text"), READ(f)File.from(content, "text"), File.read(f)
FILE(content, "pdf")pdf.render(content), from the @lf/pdf plugin (import { `@lf/pdf` as pdf } from plugins); File.from(content, "pdf") is refused with this rewrite (not marked safe: the import is yours to add)
AT(xs, 0), AT(d, "k"), AT(d, key)xs[0], d["k"], d[key] (the upgrade writes d["k"]: on a record, d.k would read the field where AT refused; on a dict, d.k reads the same key)

Operators, conditionals and presence #

# before
if NOT ISNULL(x) AND (a OR b) { … }
line = IF EXISTS(domain) THEN "https://${domain}" ELSE "" END
if ISNULL(ch) { ERROR("no channel") }
if EXISTS(msg-[:Attachments]->) { … }
ok = TRUE

# after
if (x != null && (a || b)) { … }
line = domain != null ? "https://${domain}" : ""
if (ch == null) { fail("no channel") }
if (exists(msg-[:Attachments]->)) { … }
ok = true

Walk brackets #

# before
crm-[c:Companies WHERE `Categories` == "Customer" ORDER BY `Created At` DESC LIMIT 10]->
exists(m-[:Attachments WHERE `Name` CONTAINS "pdf"]->)

# after
crm-[c:Companies WHERE c.Categories == "Customer" ORDER BY c.`Created At` DESC LIMIT 10]->
exists(m-[a:Attachments WHERE a.Name CONTAINS "pdf"]->)

Systems and imports #

# before
import { attio, email } from adapters
import { acme_main as acme } from credentials

crm   = attio(credentials: acme, dry_run: true)
inbox = email()

# after
import { `@lf/attio` as attio, `@lf/email` as email } from adapters
import { acme_main as acme } from credentials

crm   = connect attio using acme { dryRun: true }
inbox = connect email

3. write … bind is retired #

# before
write crm-[:Companies]-> bind deal { Name: deal.Name }

# after: the CRM holds the link, and the next write finds the record by it
write crm-[:Companies]-> { unique by (`Deal ID`), `Deal ID`: deal.id, Name: deal.Name }

4. # hops are retired; a plugin is a function #

FormUnder 0.12Write instead
-[:#name]->, -[#name]->, -[:`#name`]->MOV_META_HOP_RETIREDan edge the record has, or the plugin called as a function
-[f:#resources]->MOV_META_HOP_RETIRED-[f:_resources]-> off an extraction's result, or the system's file edge (section 6)
-[t:#transform { plugin: "…" }]->MOV_TRANSFORM_HOP_RETIRED (since 3)the plugin imported and called
-[#linked …]->MOV_LINKED_HOP_RETIRED (since 3)the write's handle, or a link stored in the system
# before
pages = msg-[u:#transform { plugin: "vc_url_retrieval" }]->

# after
import { vcUrlRetrieval } from plugins
pages = vcUrlRetrieval(msg.Body)

5. delete only where the system declares it #

6. _resources only on an extraction's result #

SystemWrite instead of x-[f:_resources]->
Email, Gmail, WhatsAppx-[f:Attachments]-> { … f.File … }
Slackx-[f:Files]-> { … f.File … }
Knowledge graphx-[f:Resources]-> { … f.URL … } (fields Name, URL, Type)
# before (an extraction's result: unchanged)
deck = extract from [msg-[:Attachments]->.File] { … }
deck-[f:_resources]-> { write drive-[:files]-> { name: f.name, data: f.file } }

# before (a record: refused under 0.12)
msg-[f:_resources]-> { … }

# after
msg-[f:Attachments]-> { … f.File … }

7. A file is never joined into text #

# before (refused under 0.12)
write crm-[:notes]-> { title: "Attached: ${f}" }

# after: the file's text
write crm-[:notes]-> { title: "Attached: ${coalesce(File.read(f), "")}" }

# after: the file itself, into a field that holds files
write crm-[:notes]-> { title: "Attached", upload: f }

8. A declared return takes what the body may return #

9. A value that may be a record is no record parameter #

10. A record a combinator arm hands back reads as itself #

r = await parallel([
  () => {
    a = write crm-[:companies]-> { name: "Acme" }
    return a
  },
  () => 2
])
write crm-[:notes]-> { title: AT(r, 0).name }   # 4 and earlier: null

Under 0.12 the same join is r = all(() => { … }, () => 2), and r[0].name reads "Acme".

11. A write handle's unsent file is not a file #

# before (refused under 0.12)
h = write d-[:docs]-> { name: "a" }
text = File.read(h.doc)

# after: the file off the record's edge
text = File.read(first(h-[:Files]->).File)

12. A closure is not data #

# refused under 0.12: closures kept as data
fs = [label, () => { return "x" }]   # MOV_CLOSURE_NOT_DATA
box = { g: label }                   # MOV_CLOSURE_NOT_DATA

# instead: a variable, or arguments
g = label
v = g()
r = all(label, () => { return "x" })

13. A value that is a closure is called #

a = () => { return made() }
g = a
v = g()
write crm-[:notes]-> { title: v }   # a record: MOV_WRITE_FIELD_TYPE

No methods #

14. A closure's type is written <(params) => <return>> #

function run(handler: <(c: <crm-[:company]->>) => <text>>) { … }   # a parameter
function pick(): <() => <text>> { … }                               # a declared return

15. parallel is retired for all; combinators take arguments and no await #

# before
r = await parallel([a, () => { … }])

# after
r = all(a, () => { … })

16. The extraction keyword is retired #

# before
found = extract "careful" from [m.Body] {
  node company: "each company named" {
    name: <text> "its name"
    website: "its website"
  }
}

# after
node FoundShape: "the relevant information in the content" {
  node company: "each company named" {
    name: <text> "its name"
    website: <text> "its website"
  }
}

found = extractOne([m.Body], FoundShape, { tier: "careful" })

17. A value that may be one of several records reads only what every one offers #

One rule, TypeScript's union, for every place a value can be one of several records: an IF, a COALESCE, a function's returns, a list literal's members, MULTI.

who = COALESCE(lead, co)          # People | Companies
who.Name                          # both have Name → typed (union of the field types)
who.Domains                       # only Companies → refused, naming the member that lacks it
write who { Name: "Acme" }        # allowed, value checked against Name in every member
write who { Domains: ["a.com"] }  # refused: People has no Domains

18. Arithmetic needs a number that is there #

A number computed from a value that may be absent is refused (MOV_ABSENT_REQUIRED), as TypeScript refuses n + 1 on number | undefined.

n = Number.parse(t)                  # number | absent
m = n + 1                            # refused: n may be absent
m = coalesce(n, 0) + 1               # a fallback
if (n != null) { m = n + 1 }         # a guard (or a guard clause that leaves)
m = n != null ? n + 1 : 0            # a conditional
s = List.sum(List.compact(ys))       # a list that may hold absent members: drop them first
s = List.sum(filter(ys, (y) => y != null))   # or keep only the present ones

19. Diagnostic code index #

CodeWhere
MOV_CALL_ARG_MAYBE_RECORDA value that may be a record is no record parameter (a value parameter, a collection op's member and a built-in's argument included)
MOV_CALL_ARITYA value that is a closure is called (a call of one of several closures names every arity)
MOV_CALL_ARG_TYPEA closure's type is written undefined (a closure that does not fit a closure type); A value that is a closure is called (two of several closures whose parameters share no argument); A value that may be a record is no record parameter (a collection op's member against its function's parameter)
MOV_CALL_CLOSURE_RETURNSA value that is a closure is called (warning under 3 and 4: a later closure may hand back a record the first does not)
MOV_CALL_NOT_MOVEMENTA value that is a closure is called (a value that may not be a closure, or whose type nothing shows)
MOV_CLOSURE_NOT_DATAA closure is not data (a closure as a list element, a record or map value, a field, an op's or a combinator's answer, or a type's list element or key; since 0.12)
MOV_COMBINATOR_ARM_NOT_FUNCTIONundefined and undefined (an argument that is no function)
MOV_COMBINATOR_ARMS_ARE_ARGUMENTSundefined and undefined (arms in a list; the fix drops the brackets; since 0.12)
MOV_COMBINATOR_NO_AWAITundefined and undefined (await race(…), await all(…); the fix drops the await; since 0.12)
MOV_DELETE_NOT_DECLAREDundefined only where the system declares it
MOV_COUNT_COUNTS_EMPTYOperators, conditionals and presence (warning, on a move up across 0.12: a count of a list that may hold an empty item)
MOV_DELETE_NOT_RECORDundefined only where the system declares it
MOV_EXPR_CLOSURE_POSITIONA closure is not data (under the integer versions 1 to 4; under 0.12 a closure written in a list or map is MOV_CLOSURE_NOT_DATA)
MOV_EXTRACT_KEYWORD_RETIREDThe extraction keyword is retired (the rewrite to extractOne over a declared shape where it is mechanical; none for a through […] stage)
MOV_FUNCTION_UNKNOWNNo methods (a dotted call of no built-in, box.g() included; 0.12)
MOV_FILE_IN_TEXTA file is never joined into text
MOV_HANDLE_FILE_NOT_SENTA write handle's unsent file is not a file
MOV_META_HOP_RETIREDundefined hops are retired
MOV_PARALLEL_RETIREDundefined is retired for undefined (with the all rewrite for arms written down)
MOV_ABSENT_REQUIREDThe extraction keyword is retired (a block's walk off a record that may be absent: an extractOne answer, a match, an unguarded pick; since 0.12, an upgrade warning on a move up across 0.12); arithmetic on a value that may be absent
MOV_PRESENCE_TEST_ON_TEXTThe extraction keyword is retired (a null test on a <text> field of an extract or extractOne record; since 0.12, an upgrade warning on a move up across 0.12)
MOV_PRESENCE_OF_RETURNEDOperators, conditionals and presence (warning, on a move up across 0.12: a null test on a block's returned records)
MOV_IN_MEMORY_HOP_ORDERWalk brackets (warning, on a move up across 0.12: ORDER BY or LIMIT on a hop over records the run already holds, which 4 ignored)
MOV_RECEIPT_RECORD_SLOTA record a combinator arm hands back reads as itself (warning, on a move up across 0.12)
MOV_RETURN_TYPEA declared return takes what the body may return (a body's returns that may hold a record or file; since 0.12)
MOV_RESOURCES_NOT_EXTRACTEDundefined only on an extraction's result
MOV_UNKNOWN_PROPERTYA value that may be one of several records reads only what every one offers (a field the type does not have, read off one; warning under 3 and 4)
MOV_TRANSFORM_HOP_RETIREDundefined hops are retired (since 3; under 0.12 the message shows the function form)
MOV_V5_IMPORT_NEEDS_ALIASThe everyday spelling (a package of plugins imported without as; the fix adds the name)
MOV_V5_NOT_YETThe everyday spelling (an old spelling whose rewrite needs a person, such as a SORT key that is not one field)
MOV_V5_SPELLINGThe everyday spelling (an old spelling under 0.12; the fix is the rewrite)
MOV_V5_UNKNOWN_MEMBERThe everyday spelling (a namespace member that does not exist, with a did-you-mean)
MOV_WRITE_BIND_RETIREDundefined is retired