Migrating to Dancing Wren (5)

Language version 5, Dancing Wren, follows Luminous Owl (4). The changelog entry lists each change in a line; this guide carries the full detail. Every rule here that says "under Dancing Wren (5)" applies only to an automation pinned to version 5. Quiet Heron (1), Bright Otter (2), Steady Lynx (3) and Luminous Owl (4) keep their old behaviour exactly (proved by the version 4 conformance corpus).

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. Dancing Wren ships as a preview in the 0.12.0 pre-release. Existing automations keep their version 1 to 4 pins and run unchanged; version 5 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 5Write 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 5)
msg-[f:_resources]-> { … }

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

7. A file is never joined into text #

# before (refused under 5)
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 5 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 5)
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 5: 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 5)
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 5)
MOV_COMBINATOR_NO_AWAITundefined and undefined (await race(…), await all(…); the fix drops the await; since 5)
MOV_DELETE_NOT_DECLAREDundefined only where the system declares it
MOV_COUNT_COUNTS_EMPTYOperators, conditionals and presence (warning, on a move up across 5: 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 4 and earlier; under 5 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; version 5)
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 5, an upgrade warning on a move up across 5); 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 5, an upgrade warning on a move up across 5)
MOV_PRESENCE_OF_RETURNEDOperators, conditionals and presence (warning, on a move up across 5: a null test on a block's returned records)
MOV_IN_MEMORY_HOP_ORDERWalk brackets (warning, on a move up across 5: 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 5)
MOV_RETURN_TYPEA declared return takes what the body may return (a body's returns that may hold a record or file; since 5)
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 5 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 5; 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