Traversal blocks — doing something per related position
Traversal blocks — doing something per related position #
Do something per related position by following a path with a block: any position-valued path followed by a block runs the block once per position the path yields. Two attachments → the block runs twice; none → it doesn't run at all. That is the language's only repetition — there is no loop construct.
blocks #
msg-[a:Attachments]-> {
write drive-[:file]-> {
Name: a.Name
Data: a.Data
}
}
- The head is a traversal:
-[:edge]->relative to the enclosing position, or rooted at any in-scope name (deals-[c:Companies]->, chaining hops as usual). - Head the block at an EXPRESSION that ends in a record, and skip the binding:
rows[0]-[c:company]-> { … }walks exactly as naming the call first and hopping off the name does. The expression ends at the first-[outside its own brackets, so a hop written inside it belongs to it (only(found-[c:company]->)-[f:founder]->). - The bracket-alias (
a,c) names this iteration's position inside the block. Aliases are lexically scoped to their block. - A
WHEREfilter on the hop narrows which positions the block sees:msg-[f:attachments WHERE f.Content Type== "application/pdf"]-> { … }. Inside it, a field is read through the hop's own alias, and a bare name is an outer variable. - Pick one member of an edge that holds several kinds by testing its label in the
WHERE:o-[le:List EntriesWHERE le.listName == "Deal Pipeline" AND le.Deal Created>= cutoff]-> { … }. The wholeWHEREnarrows the hop to that list, whatever order its tests are in, so the list's own fields read in theWHEREand offle. Without that test the hop carries only what every list shares, and reading one list's field is refused, naming the test to add. - Blocks nest: a block over companies can contain a block over each company's rounds. With nesting, write the inner record as a linked write from the enclosing handle so the structure lands connected (see the writes chapter).
A handle is already a position — "continue from the company I just wrote" needs no construct: expressions rooted at the handle read its own fields directly, and traversing further edges onward from it walks just like any other position (co-[n:Notes]-> { … }, "${co-[:Notes]->.Content}").
query-a-graph #
A constructed instance can root a traversal directly. The first hop names a collection, and the bracket takes the full query surface: WHERE to filter, ORDER BY to sort by a field of the hop target, LIMIT to take the top N.
# The ten most recent Customer companies in the CRM, one block run each:
crm-[c:Companies WHERE c.Categories == "Customer" ORDER BY c.`Created At` DESC LIMIT 10]-> {
…
}
# The same surface works in expressions and conditions:
Message: "Top company: ${crm-[c:Companies ORDER BY c.`Team Size` DESC LIMIT 1]->.Name}"
if (exists(crm-[c:Companies WHERE c.Domains CONTAINS m.sender_domain]->)) { … }
# Sort by something one hop along — the bracket's alias names the record being ranked:
crm-[p:People ORDER BY p-[:Company]->.Name]-> { … }
ORDER BYnames a field of the records the hop yields, through the alias (backticked only if the name has spaces);ASCis the default,DESCreverses. Records with an empty sort field sort last either way.- The sort key can be any expression over the record being ranked, not just one of its fields: a short path through a single reference (
p-[:Company]->.Name), or a value built from its fields. A key that reads a field the source can sort by is done at the source; anything else is read record by record here, and you get a note saying so. A key that answers with several values, or one that callsai, is refused — a record needs one value to be ranked by. LIMIT nkeeps the first n after sorting, so pair it with anORDER BYunless the relationship is ordered by nature (see record-order below). On a multi-hop path it bounds which records the NEXT hop continues from.- A source that can filter, sort, or limit in its own query API does the work there; where it can't, the platform does it over the records the hop yields. The answer is identical either way — what changes is how much the source has to hand over. The one exception is a record missing the field a test reads: run here,
!=keeps it (a missing value is never equal to one that is there), but a source doing the test in its own query may treat a missing field its own way. - When part of a
WHEREnarrows at the source and part of it doesn't, you get a note saying which test is left over and running here. It is a cost, not a mistake: the records are the same, the fetch is bigger. Move the leftover test onto a field the source can search if the fetch matters. Where nothing in theWHEREnarrows at the source, the hop is refused instead — that is the whole collection coming over the wire, which is a different thing. - A collection a source can search but not sort gives you the same kind of note about the
ORDER BY: it runs here, over everything theWHERElet through — unless it names the order the source already answers in (a mailbox'sDateDESC), which is done at the source,LIMITand all, with no note. The order is the one you asked for; the fetch is bigger. Narrow theWHEREfurther if it matters. - A collection whose source can't narrow at all gives you the same kind of note about the
WHEREitself: it runs here, over the whole collection fetched first. The records are the WHERE's answer either way; the fetch is bigger. - An instance-rooted hop with no
WHEREand noLIMITreads the collection whole, page after page. Nothing caps it, so scope it unless you genuinely mean every record.
record-order #
# Some relationships are ordered by nature — a channel hands its messages back
# oldest-first, an email its attachments in the order it carried them.
Message: "${Text.join(ch-[m:Messages LIMIT 20]->.Text, "
")}"
# Everywhere else, say what you mean.
Message: "${Text.join(crm-[c:Companies ORDER BY c.`Created At` DESC LIMIT 5]->.Name, ", ")}"
# One record, however they come back.
channel = only(chat-[ch:Channels WHERE ch.Name == "alerts"]->)
A relationship either hands its records back in an order that means something or it doesn't, and the difference decides which aggregates may read it.
Text.join,firstandList.lastread a sequence. Over a relationship with no order and noORDER BYthey are refused while you write, with the fixes named.only(<walk>)is the one that matched — the spelling for a lookup. It hands back that record (or that value), and fails the run if there turns out to be more than one. It makes no claim about order, so it is what aWHEREnarrowing to a single record wants.count,List.sum,List.avg,List.min,List.maxandList.fromgive the same answer whatever the order, so they read either.LIMIT nwith noORDER BYmeans some n of them, and is refused for the same reason — except over a relationship ordered by nature, where "the latest twenty" is exactly what it says.
Which relationships are ordered is the source's own fact, and each one says: chat messages and thread replies by time, a curated list's entries by when they were added, a document's sections and an email's attachments by their place in it, answers and callback fires by when they landed.
what-a-source-can-filter #
Expect the filter surface to differ per source. A CRM might filter companies by a categorised field at its query API but not by a long free-text one; a record's handful of related rows can be filtered on anything, because the set is already small. Each source declares — per relationship and per field — what it can do:
- Filter or sort by something the source can't, and you get an error while authoring, with the reason ("this source can't filter Companies by that field — it isn't filterable at the source"). You re-shape the automation then and there — never a silent, ruinous full read at run time.
- A filter that needs in-app judgement —
ai(...)orexists(...)— can run only over a bounded relationship (a record's few related rows), where judging each one in-app is cheap. Over an unbounded collection it's blocked: the platform can't be told to read millions and judge each. Narrow to a bounded relationship first, then judge. WITHINfilters by recency —c.Created AtWITHIN 30dkeeps records from the last 30 days (ddays,hhours,wweeks).
What you can write against a source is the overlap of the language and that source's own abilities; you learn where that overlap ends while authoring, not in production.
what-a-block-hands-back #
Hand a value out of a block with return, and bind the block to collect what its iterations returned:
names = mentioned-[c:company]-> {
return c.name
}
Message: "Logged ${Text.join(names, ", ")}"
return runs once per position the head yields, so the binding is all of them together. What that collection is follows from what you returned:
- a plain value — a name, a number, a composed line — gives a list, one entry per iteration, ready for
Text.join,count, or an options list; - a record — a write's handle, a traversed position, a graph — gives those records themselves, walked and read like any other position.
orgs = mentioned-[c:company]-> {
return write crm-[:Companies]-> { unique by (`Name`), Name: c.name }
}
Message: "Logged ${count(orgs)} companies"
returnmay sit anywhere in the body, including inside anifarm — that arm'sreturnis the block's. Every iteration hands back the same kind of thing.- The list is in the head's order: order the head (or head it on a relationship ordered by nature) whenever you
Text.joinwhat comes back. - A block that ran zero times hands back an empty list, so
count(names)is0rather than an error. - To hand back several things at once, return a dict (
{ name: c.name, size: c.size }) or a graph; the entries then read by name. - A body with no
returnhands nothing back: leave it unbound and it runs for its effects.
Pitfalls #
- Reaching for a loop. "For each attachment…" is a traversal-headed block. If you're trying to enumerate, you're describing an edge to traverse.
- Counting the wrong thing.
countover a source walk (count(msg-[:Attachments]->)) counts what the source holds;countover a bound block (count(orgs)) counts what the block returned. Both run — make sure you aggregate the one you mean. - Reaching inside a block from outside it. A name bound in the body — the head's alias
aincluded — does not exist after the closing brace.returnwhat the rest of the automation needs.