← Back to home

22. Macros & the with clause

A macro is a compile-time with <Macro>(...) clause that splices AST into its host declaration before lowering — it expands to ordinary .ddd you could have written by hand. The most instructive "output" of a macro is therefore the expanded source, not the target language: every macro has a source-equivalent, and the unfold refactor ejects it verbatim. This chapter covers the with invocation and its argument forms, the thirteen stdlib macros, the built-in capabilities they compose with, the defineMacro authoring surface, the macro validation gates, and unfold.

Grammar: WithClause, MacroCall, MacroArg, MacroArgName, MacroArgValue · Pipeline: macro expansion is phase ② (AST→AST), before scope/link · Validators: loom.unknown-macro, loom.macro-target-mismatch, loom.macro-arg-*, loom.macro-threw, loom.macro-escapes-host, loom.macro-non-ast-result, loom.capability-host-invalid · Source: src/macros/ · Docs: ../scaffold-macros.md, ../macro-api.md

Macros expand at AST phase ②, so a synthesised member is indistinguishable from a hand-written one by the time scope resolution, validation, lowering, and codegen run. That is why the examples below pair the written .ddd with its expanded .ddd (a macro tab group) — and, where the macro feeds backend output, one backend tab showing what the expansion ultimately emits.

The with clause

with attaches one or more macro calls to a host declaration's head. It is admitted on exactly four hosts — aggregate, context, ui, and api — matching the four macro target kinds (MacroTarget in src/macros/api/define.ts). Multiple calls are comma-separated and applied left to right, so they compose:

aggregate Order with softDeletable, softDelete {   // capability, then macro
  reference: string
}
WithClause: 'with' calls+=MacroCall (',' calls+=MacroCall)*;
MacroCall:  name=ID ('(' (args+=MacroArg (',' args+=MacroArg)*)? ')')?;

A bare name (with crudish) is the zero-arg form; the parentheses are optional. The same clause carries capability names (softDeletable) and macro names (softDelete) interchangeably — the expander resolves each against the macro registry first, then the capability inventory (the built-in prelude plus any capability declared in the workspace; see Capabilities). A capability is a pure domain-state mixin, so it is rejected on a ui or api host (loom.capability-host-invalid).

Argument forms

A macro arg is name: value. The grammar admits five value shapes (MacroArgValue); the arg name is a MacroArgName — LooseName plus requires — so soft keywords like aggregates: / contexts: work as parameter names, and so does the gate parameter crudish / softDelete take (with crudish(requires: <Policy>)). requires is admissible in this position ONLY; it stays a hard keyword everywhere a statement or a header gate clause can start.

Form Syntax Example Used by
string "…" of: "Order" project-local macros
bool true / false updateOnly: true crudish
int 42 depth: 2 project-local macros
ref bare ID of: Sales scaffoldSubdomain(of:)
ref-list [ID, ID, …] subdomains: [Sales] scaffold(subdomains:)

Ref / ref-list values are bare identifiers, not Langium cross-references — macro expansion runs before linking, so the expander resolves them against a document-wide inventory it builds itself (MacroArgValue in ddd.langium). A macro declares each param's kind and, optionally, a default. A param is optional when it has a default, is a refList (it defaults to []), or is a ref marked optional; anything else is required and its absence raises loom.macro-arg-missing. The stdlib only exercises bool / ref / ref-list; string and int are grammar-supported for project-local macros.

ui WebApp with scaffold(subdomains: [Sales], aggregates: [Product]) { }
//                      └─ ref-list ───────┘  └─ ref-list ───────┘
aggregate Order with crudish(updateOnly: true) { reference: string }
//                           └─ bool ────────┘

The stdlib

Thirteen macros ship in src/macros/stdlib/, registered at toolchain boot by loadStdlibMacros(). Grouped by target:

Macro Target Params Emits
crudish aggregate updateOnly: bool = false update(...) + canonical create(...) + destroy {}
softDelete aggregate — softDelete() / restore() operations
softDeleteByDefault context — implements softDeletable + with softDelete on every child aggregate
scaffoldDashboard context — one singleton query-time projection per aggregate (row count + a sum per numeric/money field, and a per-day series when a date field exists)
scaffoldHandlers context — the contract records (command/query/response) + a commandHandler/queryHandler per operation, create, find and get-by-id read
scaffoldPaged context of: Criterion | Retrieval, requires?: Policy a paged queryHandler over the read-only port (Repo.run(<read>)), optionally gated
scaffoldApi api of: Subdomain one route per handler scaffoldHandlers emits for the subdomain
scaffoldPagedApi api of: Criterion | Retrieval the GET …/projections/<read> route binding to scaffoldPaged's handler
scaffold ui subdomains:, contexts:, aggregates:, workflows: (all ref-lists) Home / WorkflowsIndex singletons + delegates to the composers below
scaffoldSubdomain ui of: Subdomain one scaffoldContext per context in the subdomain
scaffoldContext ui of: BoundedContext one scaffoldAggregate / scaffoldWorkflow per member
scaffoldAggregate ui of: Aggregate List / New / Detail pages for the aggregate (one area)
scaffoldWorkflow ui of: Workflow the workflow Form page, plus read-only instance pages when the workflow is observable

scaffoldView is gone — it was removed with the view declaration (replaced by projection; see Projections).

auto-paged-table is not a with macro. stdlib/auto-paged-table.ts is an always-on AST pass the expander runs after with expansion (autoPagePagedTables(model), expander.ts). It rewrites a hand-written Table over a paged read into the shape the scaffold emits — page state, paged: true, serverPaged, totalPages — because page state is a structural declaration the body walker cannot add. A Table that already carries page:/serverPaged:, a QueryView that already declares paged:, or a read that already passes arguments is left byte-identical.

Macros vs. capabilities

They ride the same with clause and are easy to conflate, but they are different mechanisms:

Macro Capability
Defined in TypeScript (defineMacro) .ddd (capability X { … }) or the built-in prelude
Can emit any AST member fields, filter, stamp — a pure mixin
Hosts aggregate / context / ui / api aggregate / context only
Takes args yes no

Five capabilities ship built in (src/macros/prelude.ts); a workspace capability of the same name shadows the prelude one:

Capability Adds
auditable createdAt / updatedAt / createdBy / updatedBy (all managed) + the onCreate / onUpdate stamp rules
softDeletable isDeleted (internal) + deletedAt? (managed) + filter !this.isDeleted
tenantOwned tenantId + dataKey (both internal) + the principal-claim stamp + the tenant read filter (Tenancy)
tenantRegistry parent: Self id? (immutable) + materialized dataKey: string? (managed) — the tenant tree
versioned version: int token = 1 — the optimistic-concurrency marker

crudish

with crudish on an aggregate inspects the host's field list and emits a generated update(...) operation (one parameter per writable update field, body assigning each to its field), plus a canonical create(...) factory and a destroy {} terminator. "Writable update field" = a property whose access modifier admits external writes — managed / token / internal are dropped, immutable is dropped from update but kept by create (settable once, at creation), secret stays on both. Fields contributed by another capability/macro (e.g. createdAt from auditable) and any field targeted by a stamp onCreate / stamp onUpdate are skipped regardless of access.

Pass updateOnly: true to suppress create/destroy — for composing with a macro or capability that owns the create/delete lifecycle.

aggregate Product with crudish {
  name: string
  price: decimal
  sku: string immutable        // create-only: dropped from update
  createdAt: datetime managed  // server-seeded: dropped from both
}
aggregate Product {
  name: string
  price: decimal
  sku: string immutable
  createdAt: datetime managed

  operation update(name: string, price: decimal) {   // sku, createdAt absent
    name := name
    price := price
  }

  create(name: string, price: decimal, sku: string) {  // immutable sku kept
    name := name
    price := price
    sku := sku
  }

  destroy {}
}

The name := name RHS resolves to the parameter, not the field — Loom name resolution prefers params over fields when shadowed (the right semantics here). The expanded source above is the literal output of the unfold refactor.

The generated update operation lowers exactly like a hand-written one: a domain method plus a POST /{id}/update route whose request body is the writable-field set.

// domain/product.ts — operation update lowered to a method
public update(name: string, price: number): void {
  this._name = name;
  this._price = price;
  this._assertInvariants();
}
// http/product.routes.ts — request schema is the writable update fields
const UpdateProductRequest = z.object({
  name: z.string(),
  price: z.number(),
}).openapi("UpdateProductRequest");
// … POST /{id}/update → repo.findById → aggregate.update(body.name, body.price) → repo.save

softDelete / softDeletable

Soft delete is split deliberately: the state + read filter ship as the built-in capability softDeletable (isDeleted: bool, deletedAt: datetime?, and filter !this.isDeleted); the softDelete macro adds only the two operations. A capability is a pure mixin, so compose them — with softDeletable, softDelete.

context Sales {
  aggregate Order with softDeletable, softDelete { reference: string }
}
context Sales {
  aggregate Order with softDeletable {   // the capability stays — unfold ejects one call
    reference: string

    operation softDelete() {
      isDeleted := true
      deletedAt := now()
    }

    operation restore() {
      isDeleted := false
      deletedAt := null
    }
  }
}

The capability's isDeleted / deletedAt fields and its !this.isDeleted read filter are woven in at lowering, so every generated read path — findById, findManyByIds, the paged all — gains the predicate and soft-deleted rows never surface:

// db/repositories/order-repository.ts — every read carries the capability filter
const rootRows = await this.db.select().from(schema.orders)
  .where(not(eq(schema.orders.isDeleted, true)))
  .orderBy(orderBy).limit(pageSize).offset(offset);
// domain/order.ts — the two operations
public softDelete(): void { this._isDeleted = true; this._deletedAt = new Date(); }
public restore(): void   { this._isDeleted = false; this._deletedAt = null; }
-- db/migrations/…_initial.sql — the capability fields are real columns
"is_deleted" BOOLEAN NOT NULL,
"deleted_at" TIMESTAMP WITH TIME ZONE NULL,

softDeleteByDefault

softDeleteByDefault is the context-level convenience: it applies implements softDeletable to the context (fanning the state + filter to every aggregate) and invokes softDelete on each child aggregate. Unfolding it one level shows that fan-out as explicit per-aggregate clauses:

context Billing with softDeleteByDefault {
  aggregate Invoice { number: string }
  repository Invoices for Invoice { }
}
context Billing {
  aggregate Invoice with softDelete { number: string }   // ← ops added per child
  repository Invoices for Invoice { }

  implements softDeletable                               // ← fans capability to every aggregate
}

scaffold family — UI

The UI scaffold family synthesises pages from a domain. It is composable end to end: the top-level scaffold (target ui) fans out to per-element composers, which fan out to leaves. scaffoldAggregate / scaffoldWorkflow are the leaves; everything above delegates via invokeMacro. An abstract aggregate base is skipped by the leaf (it owns no table/routes — only its concrete extends subtypes get pages), and a workflow that is event-triggered-only gets no form page (it has no command route to bind).

Unfold is one level only — scaffold(subdomains: [Sales]) does not flatten to pages; it reveals the next composer down plus the singleton pages emitted directly. Drill into a single scaffoldAggregate to materialise just its pages as source while leaving the rest of the UI under the macro.

ui WebApp with scaffold(subdomains: [Sales]) { }
ui WebApp with scaffoldSubdomain(of: Sales) {
  page Home {                                  // ← singleton, emitted directly
    route: "/"
    body: Stack(
      Heading("Welcome", level: 2),
      Text("Pick a section from the sidebar to start, or jump straight in below."),
      Stack(
        Card(
          Heading("3 aggregates", level: 4),
          Text("Manage records of each kind from the sidebar.")
        )
      ),
      testid: "home"
    )
    menu {
      hidden: true
    }
  }
}

The WorkflowsIndex singleton appears only when at least one command-surfaced workflow exists, and Home's KPI row grows a Stat tile per aggregate whose context also carries scaffoldDashboard.

Generating the full system materialises the leaf pages — a Home plus per-aggregate list / new / detail (a detail-only pair when the aggregate has no create):

web_app/src/pages/home.tsx
web_app/src/pages/products/list.tsx     ← useAllProducts(), Table, "New product" button, pager
web_app/src/pages/products/new.tsx
web_app/src/pages/products/detail.tsx
// pages/products/list.tsx (excerpt) — the List page binds the aggregate's paged findAll hook
export default function ProductList() {
  const productAll = useAllProducts({ page: pageNum, pageSize: 10, sort: sortKey, dir: sortDir });
  return (
    <Stack data-testid="products-list">
      {/* breadcrumbs, title, "New product" → navigate("/products/new") */}
      {/* loading skeletons, error alert, empty state, then a <Table> of rows + pager */}
    </Stack>
  );
}

The synthesised pages carry their full walker-stdlib body (not a placeholder), so unfold ejects real, editable .ddd. A page's kind is derived on demand from its role-scoped name + area — there is no stamped origin. Override-by-name lets you replace any scaffolded page (Home included) by writing one with the same name explicitly. See UI pages for the page DSL the scaffolds emit.

scaffold family — application & transport

The same "one macro emits, its pair binds to it" split covers the handler and route layers, so a route can never target a handler the macro didn't emit.

scaffoldHandlers (target context) emits the API contract records first, then the handlers that realise it:

context Catalog with scaffoldHandlers {
  aggregate Product {
    name: string
    price: money
    operation rename(newName: string) { name := newName }
  }
  repository Products for Product { }
}
response ProductResponse {
  name: string,
  price: money,
  version: int
}

query GetProductQuery {
  productId: Product id
}

command RenameProductCommand {
  newName: string
}

queryHandler GetProduct(query: GetProductQuery): ProductResponse {
  let o = Products.getById(query.productId)
  return o
}

commandHandler RenameProduct(productId: Product id, cmd: RenameProductCommand) {
  let o = Products.getById(productId)
  o.rename(cmd.newName)
}

scaffoldPaged(of: <Criterion | Retrieval>) (target context) is the ergonomic paged read — one queryHandler over the read-only port, and the way a named read reaches the wire (a criterion / retrieval has no route of its own). The macro reads its argument's kind:

context Catalog with scaffoldPaged(of: Named) {
  aggregate Product { name: string  price: money }
  repository Products for Product { }
  criterion Named(needle: string) of Product = this.name == needle
}
queryHandler ListProductByNamed(needle: string): Product paged {
  let r = Products.run(Named(needle))
  return r
}

Over a retrieval, the retrieval's where filters the page and its sort: is the page's default order. requires: <Policy> names a parameterless function-form policy and becomes the handler's first statement (a handler has no header gate), the same rule as crudish(requires:). Under denyByDefault an ungated scaffolded route is refused like any other ungated handler (loom.default-deny-ungated):

context Catalog with scaffoldPaged(of: Cheapest, requires: Staff) {
  aggregate Product { name: string  price: money }
  repository Products for Product { }
  policy Staff(): bool = currentUser.role == "staff"
  criterion Named(needle: string) of Product = this.name == needle
  retrieval Cheapest(needle: string) of Product { where: Named(needle)  sort: [price asc] }
}
api CatalogApi with scaffoldPagedApi(of: Cheapest) from Sales { }
queryHandler ListProductByCheapest(needle: string): Product paged {
  requires Staff()
  let r = Products.run(Cheapest(needle))
  return r
}
// on CatalogApi:
route GET "/products/projections/cheapest" -> Catalog.ListProductByCheapest
// http/catalogApi-routes.ts — the route's `sort` defaults to "", i.e. the declared order
request: { query: z.object({ needle: z.string(), page: …, pageSize: …, sort: z.string().default(""), dir: z.string().default("asc") }) },
…
if (!(currentUser.role === "staff")) throw new ForbiddenError("Forbidden: Staff()");
const products = new ProductRepository(db, events);
const result = await products.pageCheapest(needle, query.page, query.pageSize, query.sort, query.dir);

// db/repositories/product-repository.ts — a whitelisted `sort` wins, else the declared terms + `id`
const orderBy = Object.hasOwn(sortColumns, sort)
  ? [dir === "desc" ? desc(sortColumns[sort]!) : asc(sortColumns[sort]!)]
  : [asc(schema.products.price), asc(schema.products.id)];

scaffoldApi(of: <Subdomain>) and scaffoldPagedApi(of: <Criterion | Retrieval>) are the matching api-target macros — they emit the routes (POST for writes, GET for reads; /projections/<read> for the paged one) that bind to those handlers:

subdomain Sales {
  context Catalog with scaffoldHandlers {      // emits the handlers …
    aggregate Product { name: string }
    repository Products for Product { }
  }
}
api SalesApi with scaffoldApi(of: Sales) from Sales   // … and this emits the routes onto them

Both macros serve exactly the same aggregates (one shared predicate) and both expand after every aggregate-level with clause, so members spliced by with crudish (create / update / canonical destroy) get handlers and routes. An aggregate with no repository in the context cannot be loaded by id, so neither macro emits for it; when it declares a write surface (an operation, a create, a destroy, or the ones with crudish supplies) scaffoldHandlers raises the warning loom.scaffold-aggregate-unserved, naming the aggregate and the repository <Plural> for <Agg> { } that would serve it. A repository-less aggregate is legitimate (e.g. a tenant registry the API never exposes), so it is a warning, not an error.

scaffoldDashboard (target context) emits one singleton query-time projection per aggregate — <Agg>Totals (a row count plus a sum per numeric/money field, aggregated in SQL) and, when the aggregate carries a date field, a per-day <Agg>Series. Aggregates it cannot aggregate over a real table (abstract bases, persistedAs: eventLog streams, shape: document blobs) are skipped. The ui-side scaffold derives the same projection names from _dashboard-shared.ts, so a Home Stat tile can only bind a projection this macro emitted:

// http/query-projections.ts — the emitted singleton projection's wire row
const ProductTotalsRow = z.object({
  rowCount: z.number().int(),
  priceSum: z.string(),
}).openapi("ProductTotalsRow");
const ProductTotalsResponse = ProductTotalsRow.openapi("ProductTotalsResponse");

Audit — the built-in capability auditable

Not a macro. audit / auditable no longer exist as with macros. Audit ships as the built-in capability auditable — apply it via the capability surface (implements "auditable"), not a with clause.

The capability adds the four canonical fields (createdAt, updatedAt, createdBy: User id, updatedBy: User id, all managed) and the context-level onCreate / onUpdate stamping rules (createdAt := now(), createdBy := currentUser, etc.). Fields + the implements opt-in are per-aggregate; the stamp rules are a context-level concern, so they live in the capability, not on each aggregate. See Capabilities.

Validation gates

Every macro diagnostic is raised by the expander in phase ②, so it points at your with clause, not at synthesised source:

Code Fires when
loom.unknown-macro the name is neither a registered macro nor a capability (the message lists the available macros); a second form covers a macro that invokeMacros an unknown child
loom.macro-target-mismatch with scaffold on an aggregate — the macro's target ≠ the host kind
loom.macro-arg-unknown an arg name the macro doesn't declare (the message lists the declared params)
loom.macro-arg-duplicate the same arg name twice in one call
loom.macro-arg-missing a required param omitted (no default, not a refList, not an optional ref)
loom.macro-arg-kind-mismatch crudish(updateOnly: "yes") — value shape ≠ declared kind
loom.macro-arg-unresolved-ref a ref / ref-list element naming a declaration that doesn't exist
loom.macro-threw the expand function threw (two forms: direct, and invoked-from-parent)
loom.macro-non-ast-result expand returned something that isn't an AST member or capability node
loom.macro-escapes-host an emitted node targets a destination outside the host's subtree — a macro may only modify its host or its descendants
loom.capability-host-invalid a capability name in a with clause on a ui or api host
loom.scaffold-aggregate-unserved warning — scaffoldHandlers on a context holding a repository-less aggregate that declares operations / create / destroy (e.g. with crudish); neither scaffold macro emits for it
loom.scaffold-unexpanded an unresolved scaffold primitive survives into IR validation
aggregate A with scaffold { name: string }
// error: Macro 'scaffold' targets 'ui' but was invoked on a 'aggregate'.  [loom.macro-target-mismatch]

aggregate A with crudish(foo: true) { name: string }
// error: Unknown argument 'foo' for macro 'crudish'.  Declared parameters: updateOnly.
//                                                          [loom.macro-arg-unknown]

ui W with scaffoldAggregate(of: Nope) { }
// error: Macro 'scaffoldAggregate' requires argument 'of' (kind=ref).   [loom.macro-arg-missing]
// error: Argument 'of' to macro 'scaffoldAggregate' references unknown Aggregate 'Nope'.
//                                                          [loom.macro-arg-unresolved-ref]

ignoring placement

ignoring <Capability> bypasses a capability read filter (the softDeletable case above). It rides PostfixExpr, which is admissible anywhere an expression is — so it parses cleanly in positions where nothing reads it back and the filter silently stays applied. loom.ignoring-clause-placement refuses those: the clause has exactly three homes — a repository find … ignoring …, a query-time projection's where slot (before join / group by / select), and an inline read bound by a let.

projection Counts { status: string  n: int
  from Order as o
  group by o.status ignoring softDeletable        // ← DROPPED
  select status = o.status, n = count() }
// error: 'ignoring softDeletable' sits in a position that DROPS it.  [loom.ignoring-clause-placement]

Authoring a macro (defineMacro)

A macro is a TypeScript module that default-exports a defineMacro({ … }) call. It declares a name, a target host kind, optional typed params, and an expand({ target, args, invokeMacro }) function returning the AST fragments to splice in. The host AST node is target; inspect it (its field list, members) and return members built only from the factories in src/macros/api/ — those tag each node with origin metadata so validator diagnostics on synthesised members resolve back to the user's with clause.

// the shape every stdlib + project-local macro follows
export default defineMacro({
  name: "crudish",
  target: "aggregate",                       // "aggregate" | "context" | "ui" | "api"
  apiVersion: 1,
  params: { updateOnly: { kind: "bool", default: false } },
  expand({ target, args }) {
    const fields = writableUpdateFields(target).filter((f) => f.type != null);
    const params = fields.map((f) => param(f.name, cloneType(f.type)));
    const body = fields.map((f) => assignStmt(f.name, nameRef(f.name)));
    return [operation("update", params, body), /* … */];
  },
});

The API exposes typed AST factories (operation, param, primType, boolLit, assignStmt, nameRef, commandHandler, route, singletonProjection, page, area, …) and host-inspection helpers (writableUpdateFields, writableCreateFields, aggregatesIn, workflowsIn, apiReadFields). A context-, ui-, or api-level macro fans work outward with invokeMacro(childName, { target }) — the composition pattern scaffold and softDeleteByDefault use. Inside-out invocation (an aggregate macro reaching up to a context) is refused by the expander's splice-time descendant check (loom.macro-escapes-host). The full factory catalogue is ../macro-api.md.

Honest gap — project-local discovery. The defineMacro surface and the registry are built for project-local .loom/macros/*.ts modules (the registry doc-comment describes the intended .ts → .js → load path), but bootMacros only loads the stdlib — there is no wired filesystem-discovery loader for .loom/macros/ in the CLI/LSP boot path today. A new macro must be registered in code (registerMacro / the stdlib barrel). Treat custom-macro authoring as the stdlib-extension path, not a drop-in plugin directory, until discovery lands.

unfold — eject the expanded source

unfold rewrites a with X(...) clause into its expanded .ddd in place, proving macros are demonstrably sugar. It is reachable as the LSP code action (VS Code "Unfold macro 'X'") and as the transport-neutral toolkit call unfoldMacro(source, macro, on) (src/api/refactor.ts, exposed as the loom_unfold_macro agent tool) — it returns edits, it does not apply them. There is no dedicated ddd unfold CLI subcommand.

It is one level only: a composer's invokeMacro(child, { target }) calls are not executed — each becomes a with <child>(...) clause on its target (the explicit fan-out you saw under scaffold and softDeleteByDefault), and you drill further by unfolding those children. The macro's directly-returned nodes are printed through the structural printer (src/language/print/) and inserted before the host's closing }, and the host's with clause is rewritten atomically (the unfolded call removed, any new with child entries spliced in, the whole clause stripped if it ends up empty). The printer's round-trip guarantee means unfolded output re-parses to a working program.

A ui-target macro also offers per-page unfolds — "Unfold page 'Orders / Detail'" ejects one scaffolded page as source and leaves its siblings generated, relying on the same override-by-name merge an explicit page <Name> uses. That variant is insert-only: the with clause is not rewritten.

// before — cursor on `crudish`, run "Unfold macro 'crudish'"
aggregate Product with crudish { name: string  price: decimal }

↓

// after — the with clause is gone, the operations are now plain source
aggregate Product {
  name: string
  price: decimal

  operation update(name: string, price: decimal) {
    name := name
    price := price
  }

  create(name: string, price: decimal) {
    name := name
    price := price
  }

  destroy {}
}

Two limits. Unfold resolves the host kind from the AST node and only handles aggregate / ui / context — the two api-target macros (scaffoldApi, scaffoldPagedApi) offer no code action. And because unfold re-runs expand against the already-expanded document, a macro that skips work it sees present (scaffoldDashboard's "this projection already exists" guard) ejects nothing; read its source-equivalent from ../scaffold-macros.md instead.

Cross-references