Expressions: one tree, two evaluators

Expressions: one tree, two evaluators

Status: design; built in milestone M4 (roadmap, M4). Used by M5 (atomic updates), M7 (computed fields, relationship traversal) and M8 (policies). Nothing on this page exists as code yet. The semantics where SQL and JavaScript differ are open: ADR-0012 is Proposed and must be ruled before M4. Before designing the translator, M4 reads Greffon, the one project with the same design, and reports what to copy (ADR-0056).

Related: overview, how Mesh uses MX, build pipeline, action lifecycle, data layer, extension host.

What an expression is

An entity file holds small functions. From the reference file of ADR-0050:

read :overdue
  filter=() => &isOverdue
  sort
    asc &dueOn
do
  set
    &paidAt=({ input }) => input.paidAt
  when=() => &amount > 10000
    set
      &needsReview=true
computed
  boolean :isOverdue() {
    return &status === :sent && &dueOn < today()
  }
  string :label() {
    return &number + " · " + formatMoney(&total)
  }

Every function receives one object with four keys: self (the record), input (the fields and arguments of the action’s one input section), actor (the caller, a shortcut for context.actor) and context (the action context). Members are written &name, lowering to record reads (ADR-0067); the empty parameter list means no other context is used. MX does not run these functions. It hands each over as a parsed Babel node (the syntax tree of the Babel parser) with a source span (MX project notes, getting-started, section 1). Conversion to Mesh’s tree happens in @meshfw/compiler (ADR-0043).

One rule can be needed in two places. A filter must run in the database so that not every row is loaded. A check on a record already in memory must run in the program. So Mesh turns a function into one tree with two evaluators, as Ash does (ADR-0010; research synthesis, section 2.2).

Translated or plain code: the author’s form decides

ADR-0056:

  • A function whose body is one expression Mesh can translate is translated: an arrow, () => &status === :sent, or a method body that is a single return, as in :isOverdue above. It becomes a tree; it runs in SQL where a query needs it and in memory otherwise.
  • Where SQL is required (a filter, a sort, a policy check, a rollup’s of, or inside another translated expression), the expression must translate. A construct the translator does not support there is a build error at that node, reported in the editor through the contracts’ analyze hook and again by the build. Using a computed field that runs in memory there is a build error that names the field and the part that could not be translated.
  • A computed field whose single expression cannot be translated is not an error: :label above calls the helper formatMoney on &total, so it runs in memory after the record is loaded. mesh explain shows which computed fields are translated. Nobody writes a second statement to opt out (rulings of 2026-10-04, “Rulings after the review of the user docs (2026-10-05, lead under delegation)”).
  • Plain code is a body with more than one statement, or a run step. It is emitted as TypeScript by slicing the authored text at MX’s span and runs in memory only.
  • In a check’s that, a when or a set value, an expression that cannot be translated is not an error either: it runs in memory, which makes the action read-then-write, and mesh explain names the expression that caused it (ADR-0054). Only a filter, a sort and a policy require SQL.

The parameter types expose only what translates, so the editor offers &status but not, for example, string methods the translator lacks.

Where the form matters:

Position Rule
filter on a read Must be translated; plain code, or a computed field that runs in memory, is a build error naming the field and the untranslatable part.
check’s that, when Either form, never an error. Plain code, an expression that cannot be translated, or a translated one reading self, makes an update read-then-write (ADR-0054).
set value Either form, never an error. A translated value folds into an atomic UPDATE; one that cannot be translated makes the update read-then-write.
Computed field with a body (M7) A single return that Mesh can translate is translated: usable in filters, sorts and policies, also computed in memory on a loaded record. Otherwise the field runs in memory after load, which is not an error; using it in a filter, a sort, a policy or another translated expression is. mesh explain shows which.
Rollup of="lines.amount" (M7) A path string checked at build time against generated path types; always SQL. The function form, where a path cannot express it, must be translated.
Policy check (M8) On a read, must be translated, because it becomes a query filter. Checks inside a policy combine without order (ADR-0055). On a write, a record-reading check is folded into an atomic statement as a filter, or evaluated on the locked row of a read-then-write action.

An earlier design classified each function by its content: translatable if every construct converted, opaque otherwise. It is superseded: the class was invisible to the author, and a small edit could move a rule from SQL to memory (ADR-0056).

What a translated expression may reference

A translated expression may reference its parameters, registered functions, and calls to imported pure functions that do not read self; such a call is evaluated once in memory before the query and bound as a parameter. A bare captured value (a variable from the file) is a build error. (ADR-0056). A database cannot see a captured value, so a free variable is an error; a call to an imported pure function that does not read self is computed once and sent as a parameter, which is how isStaff(actor) appears in a policy and today() in :isOverdue. This is why an imported function must be pure. The research on expression languages recommends the same rule (“parameters, never closures”) and the same treatment of the current date (expression language, section 6).

The tree and its forms

Each translated expression is written into the generated file twice (roadmap, M4):

What Made when Used by
The tree itself, as a data literal Emitted at build time The data adapter, which compiles it into Drizzle’s query builder when a query runs
The SQL Compiled by the adapter at query time, in data-drizzle The database
The in-memory form, emitted TypeScript Emitted at build time The program, calling the registered functions’ in-memory implementations in runtime

Nothing produces SQL at build time, because queries are assembled at run time from the action’s filter, the caller’s filter and the policies. At build time the adapter is checked instead: every function an entity uses must have a SQL form in the configured adapter, or the build fails.

The in-memory form is emitted rather than interpreted, so it is readable in the generated file (ADR-0003). The tree type lives in runtime, because it crosses the data-layer contract (data layer). The registry of functions and operators lives in model. runtime imports nothing from model.

Design taken from the research

The expression-language research found no established project that captures a normal TypeScript arrow and runs it both in memory and as SQL; Greffon, the one young project with the same design, is too new to depend on (ADR-0056). Its list of what to copy shapes this page:

  1. Two interpreters over one tree, so a rule cannot be true in one and false in the other.
  2. A small, enumerated node vocabulary, plus a function-call node with a fixed allow-list.
  3. Relationship traversal (&customer.userId) rewritten into joins as a normalisation pass before SQL generation.
  4. A declared supported subset, checked before anything else, with errors at a source span.
  5. Parameters, never closures.
  6. Policies folded into the query tree, so indexes still work.

The same section lists pitfalls that bear on ADR-0012: null against undefined, booleans stored as integers on SQLite, dates, string case and LIKE, short-circuit evaluation, and coercion (==, + on strings).

The function registry

Every operator and function an expression may use is registered once, in model (roadmap, M4). A function used in an entity needs an in-memory implementation in runtime and a SQL form in the configured adapter. Extensions add functions through their manifest, supplying both (extension host). One registry exists because Ash’s docs drift from its registries: 39 registered functions, about 30 documented (research synthesis, section 8).

Keeping the evaluators in agreement

Every registered function has one table of inputs and expected outputs, null cases included. The table is run through the in-memory form and through every merged SQL adapter, and all must give the table’s answer (roadmap, M4, test 1; M9, test 5 adds Postgres). The tables cover single functions, not combinations.

What Ash does and where it went wrong

Ash’s expression is a two-stage tree: unresolved call nodes, resolved into operator and function structs when a filter is parsed. The same tree runs in memory (Ash.Filter.Runtime) or compiles to SQL (AshSql.Expr) (Ash runtime internals, sections 5.1 and 5.4). Two failures matter:

  1. Paths that disagree. Bug #2969: a filter added by an action’s own change was lost when a single-record update ran atomically (same file, 12.B item 21). Strictly a lifecycle bug; Mesh’s rule that a step never runs twice, with one plan per action, guards against it (action lifecycle). The shared tables guard the other risk, the two forms giving different answers.
  2. JavaScript semantics forced onto the database. AshPostgres installs SQL functions (ash_elixir_and and others) so && behaves as in Elixir. A filter written with && ran in about 3,400 ms against about 110 ms with and, because the function call defeats the index (same file, 4.2 and 12.B item 17). This bears on ADR-0012.

Ash also sometimes filters in memory when a data layer cannot run an expression (same file, 5.4). Mesh’s in-memory evaluator is never a fallback for a missing database capability (data layer).