The build-time pipeline

The build-time pipeline

Status: stages 1 to 3, the checks step, stage 7 and stage 8 are built (M1: PR #12, #15, #16, #17); M2 added input validators and the generated-import preflight to stage 7 (PR #21). Expression conversion (part of stage 3) and stage 6 arrive in M4, stages 4 and 5 in M6. The Jig port splits stage 7’s emitters into views and templates before M2 resumes (ADR-0061).

The code still uses the old names

The code on main reads the M1 vocabulary (resource, attribute="title" type="string", the domain= attribute), takes the configuration key resources instead of domain, writes generated/-style output under the configured folder, and emits through TypeScript emitters, not Jig templates. The stages below describe the design after the realignment task and the Jig port (ADR-0064); where a paragraph describes code as built, the mechanism is unchanged and only the names differ.

Why a pipeline

The pipeline is what makes Mesh a framework rather than a library (roadmap, section 3, compiler row). It is also where Ash’s worst extension problems showed up: transformers ordered by module name with contradictions dropped silently, and verifier errors that only print warnings (research synthesis, section 2.1). Extensions plug in at named points (section 16 of the same file).

The pipeline is run by mesh build and lives in @meshfw/compiler. Its output is committed files (see generated-code-and-guard.md).

The eight stages

# Stage Consumes Produces Milestone Extension point
1 Load .mesh.mx source text under the configured domain folder Declarations with source positions M1 None: MX is core (ADR-0043)
2 Check structure Declarations The same, or a structural error M1 Vocabulary, types
3 Build model Checked declarations One plain-data document per entity; from M4 each expression’s tree (or its plain-code form) is part of it M1, M4 Core
4 Transform The model A rewritten model M6 Model transforms
5 Verify (the checks step; before M6 the M1 checks) The model Nothing, or a stopped build M6 Verifiers
6 Compile expressions (produce the two forms) The model’s trees and classes The tree literals and in-memory forms M4 Expression functions
7 Emit The model Files under .mesh/, each from a view and a Jig template M1, M2, M4, M6 Emitters
8 Guard Freshly emitted and committed files Pass, or a named difference M1 Core

(Stage names, purposes and extension points: research synthesis, section 16, where stage 1’s extension point was “front end”; that slot was dropped by ADR-0043. Milestones: roadmap, M1, M4, M5, M6. The “consumes” and “produces” columns for stages 2 to 5 are inferred; the roadmap has no data-flow table.)

1. Load

Built in M1 (PR #12). Project configuration is one trusted executable file, mesh.config.ts, whose default export is defineConfig({ domain, output, data, extensions }) from @meshfw/compiler (re-exported by meshfw). domain and output are required: domain primarily names the domain folder, src/domain by convention (ADR-0057), whose .mesh.mx files are discovered recursively (ADR-0051), including dot-prefixed paths and without silently excluded folders; a relative glob or non-empty relative file list can select a subset. output is a relative output directory, .mesh by convention (ADR-0058). These names and forms follow the user page configuration; the M1 code calls the first key resources and reads plain .mx files. Paths resolve from the config file; input backslashes are normalised to / before resolution. Drive and UNC absolute forms are rejected regardless of host. Domain and output paths must stay inside both the lexical and canonical project root: symlinks cannot point outside it, including the nearest existing ancestor of an output directory that has not been created. The output directory itself must not be a symlink: lstat checks it before canonicalisation and reports its relative path for inside, outside, dangling and cyclic links alike. Glob branches cannot contain parent traversal components. Entity file paths are deduplicated and sorted. An unmatched glob, an unreadable file, an unknown field or a wrong-typed field is an error; required fields have no silent defaults. Optional data remains opaque in M1. Optional extensions must be an array; its reference and individual opaque elements are carried untouched through the resolved config without execution. M2 interprets the data adapter and makes it required; M6 interprets the enabled extensions (roadmap, M1).

loadConfig(projectRoot) resolves the project; loadProject(config) reads its files and calls buildModel({ root, files }). Callers with source text can call buildModel directly. Both build entry points return a model document plus diagnostics, with a null document on any error; neither emits files. compiler calls MX’s parseData with directly imported contracts, structural: "reject", unknownTags: "reject" and imports: "pass" in the realigned call so entity and helper imports reach the model (see how Mesh uses MX). The module an entity belongs to is the folder under domain that holds its file; no attribute names it. Source positions use project-relative paths, 1-based lines, 0-based columns and UTF-16 offsets.

2. Check structure

Built in M1 (PR #12). MX does most of this: unknown tags, wrong attribute types, wrong nesting and missing required attributes are MX errors raised inside parseData (roadmap, M1). Rules that are Mesh’s own: exactly one entity per file, because MX has no root cardinality; and every :name unique within its scope (attributes, relationships and computed fields of one entity share a scope; actions share one; checks within one validate share one) (ADR-0050).

3. Build model

Built in M1 (PR #12) for the M1 vocabulary. The model is plain, JSON-serialisable data with source positions kept (roadmap, M1; section 3, model row). After the realignment task it holds, per entity: the name, table, the module (from the folder), the attributes with their type tag, primary-key, unique, nullable, default, shape rules (min, max, match) and on for timestamps; the actions with their type, name, input (members and typed arguments), validate and do; auto and on:load; and, from later milestones, relationships, computed fields and policies (ADR-0050, ADR-0052, ADR-0053).

Checks built in M1 need v4 realignment: entity identity follows import paths rather than a global name index; duplicate names within a scope, a missing primary key (uuid :id primary-key, with no opt-out), and missing member references still fail. The one input section rejects duplicate names and options on member lines (ADR-0067). The builder runs findNonJsonValue before returning a document and reports every incompatible leaf at the positioned value that owns it. An unexpected tree shape after a passing contract check becomes a named, positioned Mesh diagnostic rather than an exception. When M6 lands, these checks become the Verify stage. The model types are in @meshfw/model (in M1 code: Resource, ModelDocument, SourcePosition, Diagnostic): absent optional values are null, never undefined, so a document survives a JSON round trip unchanged. The attribute type names live in the registry there (ADR-0037); with syntax v4 each is also a tag name, so the attribute-type contracts are generated from the registry (ADR-0050). model imports nothing from MX, compiler or Drizzle, and a test scans its sources for such imports.

From M4, stage 3 also converts each function from MX’s Babel node. A function whose body is one expression (an arrow, or a method body that is a single return) is converted to an expression tree when Mesh can translate it. Where SQL is required (a filter, a sort, a policy, a rollup’s of), a construct the translator does not support is an error at that node, and so is using a computed field that runs in memory, naming the field and the part that could not be translated. Elsewhere an expression that cannot be translated is not an error: a computed field runs in memory, and a check, when or set value runs in memory and makes the action read-then-write, which mesh explain reports with the expression that caused it (ADR-0054). Anything else is kept as plain code. The tree or the plain-code form is part of the model, so transforms and verifiers see it (ADR-0056; roadmap, M4). Conversion runs in compiler. A translated expression may reference its parameters (self, input, actor, context), 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).

Every tool reads this one model. Ash’s map-based state shows the approach scales to 20+ extensions (research synthesis, section 8, “Copy from Ash”).

4. Transform

Extensions rewrite the model in named phases. A cycle between phases is a hard error and the resolved order is printed (research synthesis, section 16; roadmap, M6). Reason: Ash orders transformers by before?/after? against module names and silently drops contradictions (section 2.1). A transform may write to another extension’s part of the model only through a contribution point the owner publishes and the contributor declares; anything else is a build error (ADR-0020).

5. Verify

The M1 checks step is built (PR #12); it collects errors across entities before returning. M6 turns it into the extensible Verify stage. Read-only checks across entities. A failure stops the build. A strictness setting for verifiers is out of scope for M6 (roadmap, M6), so every verifier failure is an error. Examples: a policy’s actions= names real actions (M8); an entity loaded through a relationship has on:load or an auto read (ADR-0052).

6. Compile expressions (produce the two forms)

Stage 6 only produces the two forms of each translated expression; conversion happened in stage 3 (ADR-0010, ADR-0056; roadmap, M4). Each translated expression is written into the generated file twice: as the tree itself, a data literal that the data adapter compiles into Drizzle’s builder when a query runs, and as its in-memory form, emitted TypeScript. Plain code (a body that is not one expression) is emitted as TypeScript by slicing the authored text at MX’s span. The build checks that every function used has a SQL form in the configured adapter (detail in expressions). What “equal” means where SQL and JavaScript disagree is open: ADR-0012 is Proposed and must be ruled before M4 starts.

Errors raised by the checks step, which is the Verify stage from M6 (roadmap, M4): plain code where SQL is required (a filter, a read policy, a rollup’s of) (M4, M7, M8); an atomic action against an adapter lacking the “atomic expressions” capability (M5). The write strategy itself is inferred, never an error (ADR-0054).

How an expression contributed by an extension gets a tree is not decided.

7. Emit

Each emitter is two halves (ADR-0061): a TypeScript view, a pure function of the model and the configuration that returns a typed object holding exactly what one file needs, and a Jig template that renders the view. All decisions (naming, input plans, imports) are in the view and are unit-tested there. The rendered text passes through an established formatter pinned to an exact version (roadmap, M1). mesh export generators copies Mesh’s templates into the project; for each template, a project copy is used when it exists. M1 built the emitters as TypeScript functions producing text; the Jig port splits them before M2 resumes.

What is emitted, by milestone: model.json and one types file per entity (M1); action functions, validators and the Drizzle schema (M2); the tree literals and in-memory forms (M4, stage 6); the contracts module (M6), when core and data-adapter emitters also register through the interface extensions use. The output of mesh explain is printed by that command; only the example’s output is committed and guarded (M5). Migrations are written by mesh migrate generate (M9), a separate command.

Built in M1 (task m1-emit), the half of this stage that writes: generateFiles({ document, config }) runs every emitter and returns the whole tree as text, sorted by path and with nothing written; writeGeneratedFiles(files, config) writes that text under the configured output folder, inspecting every target with lstat from the output directory down, refusing symlinks, non-regular targets and non-directory ancestors before any write. Each target is replaced via an exclusively created same-directory temporary file with mode 0644, then renamed into place, rather than truncating a possibly hard-linked or unreadable existing inode. Failed writes or renames clean up their temporary file. This is atomic per file, not across the whole tree. An emitter is a pure function of the model and the configuration; the interface is marked unstable until M6, when the extension host registers emitters through it. What is emitted, the path rule, the input shapes, the naming rules and the pinned formatter are in generated code and the guard.

Before stage 7 writes anything, mesh build resolves the bare package specifiers in every emitter’s optional requires list with Bun.resolveSync(specifier, projectRoot). Missing imports are collected into diagnostics at mesh.config.ts:1:1, each naming the package and its bun add fix; no generated file is written on failure. This preflight belongs to emitting, not a ninth stage. mesh build --check and mesh inspect skip it because they do not run the generated application. The core validator emitter requires zod (ADR-0062); it writes schemas as text without importing the library itself.

8. Guard

Built in M1 (task m1-cli). mesh build --check regenerates in memory and compares bytes without writing: missing, changed and stray files or symlinks fail, naming each path. mesh build replaces produced files without deleting existing strays, then runs the full guard comparison (readability, bytes, missing files, strays and symlinks), so a successful build leaves a tree that passes the guard. mesh inspect [entity] (the entity’s name as written, mesh inspect Todo) prints the same model JSON serialisation without writing. The commands use mesh.config.ts in the current directory, with no upward search; meshfw re-exports defineConfig. verify runs the guard over the blog example (PR #17). Detail in generated-code-and-guard.md.

The not-implemented rule

The tag contracts accept the whole vocabulary before the compiler handles it. A tag or attribute the contracts accept but the compiler does not yet implement is a build error naming it and the milestone that will (ADR-0018). Reason: ignoring it is the silent fallback the roadmap forbids (roadmap, section 2, principle 2). The single milestone table is packages/compiler/src/support.ts; its implemented-tag list also names every consumed attribute, and a coverage test compares these lists with the contracts, so a contract addition cannot be silently ignored. The builder walks MX’s tree in source order and reports unsupported tags at their tag positions, without descending into an already unsupported subtree. After the realignment task the table reads: sort and pagination in M3; filter, validate (check) and the do steps in M4 and M5; typed input arguments in M5; relationships, computed and on:load in M7; policies in M8. Each milestone removes entries as it implements their semantics.

What a build error looks like

Every build error names the file, the line and the fix (roadmap, section 2, principle 2). Two sources feed that:

  • MX diagnostics carry severity, message, line, column and file. Example for a misspelt root tag in the M1 vocabulary: `<resourse>` is not a known tag: it has no contract in `customTags`; did you mean `<resource>`? at line 1 (MX project notes, updates, entry of 2026-10-04 00:32).
  • Mesh’s own errors (duplicate entity or :name, unknown member in input, not-implemented tag) use the positions kept in the model. Messages and stable diagnostic codes are pinned by the compiler tests, together with their exact source positions (M1, acceptance test 4).

MX stops at the first error per file (mx-integration.md); the compiler preserves its message and position and continues through the other files. Every Mesh check runs for every file MX parsed, regardless of errors in other files or other Mesh checks. An unsupported subtree does not suppress entity-name, attribute, action or primary-key checks. Only an MX error in that file prevents those checks because there is no tree. Any error makes the returned document null; diagnostics remain available to the caller. Mesh-built filesystem messages retain structured error codes and relative file identities without embedding machine-absolute paths. The same relative-path rule applies to model path fields and every diagnostic’s position.file. Exceptions thrown while loading executable mesh.config.ts are external text: the diagnostic keeps the config’s relative position and appends the exception’s name and message verbatim, even if that text contains an absolute path. Mesh does not scrub or reinterpret exception prose.

Determinism

Same input, same bytes (roadmap, M1). The formatter is pinned to an exact version, and acceptance test 2 builds twice and compares. Without determinism the guard would fail on harmless differences. File ordering and key order in model.json are not stated in the roadmap.