10. Repositories, queries & projections
Reading data: the repository container and its find operations, the natural-key reads getBy / findBy over a declared unique (…) key, the restricted "queryable subset" a where clause admits, reusable criterion predicate specifications, Repo.run(<Criterion>) and retrieval query bundles with sort/loads, the paged/envelope/option return shapes, the ignoring capability-filter bypass, and — the read-model surface that replaced the removed view — projection in its two flavours: query-time (from … where … join … group by … select …, computed on every read) and folded (keyed by + on(e: Event) { … }, a materialized read-model table). Reach for it when you need to know exactly what SQL a read lowers to on each backend, why a where is rejected, or which read shape (find / criterion / retrieval / projection) to pick.
Grammar:
Repository,FindDecl,IgnoringClause(fragment),Criterion,Retrieval,RetrievalLiteral,SortItem,LoadPath,Projection,ProjectionMember,ProjectionOn,ProjectionJoin,ProjectionSelect,QueryHandler· Validators:loom.find-where-not-queryable/-unknown-field/-column-column,loom.find-reserved-name,loom.duplicate-find,loom.find-param-unbound,loom.repo-key-not-unique,loom.repo-key-incomplete,loom.list-find-removed,loom.policy-read-gate-not-current-user,loom.index-suggestion,loom.findall-*,loom.criterion-*(incl.loom.criterion-retrieval-name-clash),loom.retrieval-*,loom.filter-bypass-*,loom.ignoring-clause-placement,loom.projection-*(30 codes),loom.field-mask-projection-source,loom.read-context-repo-write,loom.paged-query-handler-shape,loom.paged-param-reserved(queryable oracle:firstNonQueryableNode+firstNonQueryablePredicateinsrc/ir/validate/checks/shared.ts) · Docs:../criterion.md,../scaffold-macros.md(scaffoldPaged,scaffoldDashboard)
All multi-backend examples below are generated from one scratch system (Catalog context: Product, Customer, Order with softDeletable) once per backend pin; output is excerpted.
repository & find
A repository binds reads to one aggregate (repository Name for Agg). It contains find operations — each find name(params): T [where <expr>] [ignoring …]. The return type T is an ordinary type reference: a single aggregate, an option T?, a collection T[], or a transport carrier (T paged / T envelope / T option). Every repository also gets two reads for free without declaring them: a by-primary-key lookup (findById / getById) and a paged, sortable load-all (all(page, pageSize, sort, dir) — auto-findAll is derived in enrichment, phase ⑥, and is paged by default), so a bare repository Foo for Foo { } is already queryable. A declared find may not take the name of a built-in repository verb of the language — getById, findById, getBy, findBy, findAll, run, or a write verb (save, insert, update, delete, add, remove, commit) — because Repo.<name>(…) already means the built-in (loom.find-reserved-name); nor may it repeat itself (loom.duplicate-find). A declared find all is allowed and replaces the auto-paged load-all. No name is reserved because of a target backend: each emitter escapes a find whose rendered method would collide with one it emits itself (on .NET, getByIdAsync → GetByIdAsyncFind).
aggregate Product with crudish { sku: string name: string price: money }
repository Products for Product {
find bySku(s: string): Product? where this.sku == s
}
bySku returns Product? (zero-or-one):
// db/repositories/product-repository.ts — Drizzle
async bySku(s: string): Promise<Product | null> {
const rootRows = await this.db.select().from(schema.products).where(eq(schema.products.sku, s)).limit(1);
if (rootRows.length === 0) return null;
return Product._rehydrate({ id: Ids.ProductId(rootRows[0]!.id), sku: rootRows[0]!.sku, name: rootRows[0]!.name, price: new Decimal(rootRows[0]!.price), version: rootRows[0]!.version });
}
// free reads
async findById(id: Ids.ProductId): Promise<Product | null> { /* … */ }
async all(page: number, pageSize: number, sort: string, dir: string): Promise<{ items: Product[]; page: number; pageSize: number; total: number; totalPages: number }> { /* … */ }
// Infrastructure/Repositories/ProductRepository.cs — EF Core
public async Task<Product?> BySku(string s, CancellationToken cancellationToken = default)
{
var result = await _db.Products.Where(x => x.Sku == s).FirstOrDefaultAsync(cancellationToken);
return result;
}
// features/products/ProductJpaRepository.java — JPQL per find
@Query("select e from Product e where e.sku = :s")
Product bySku(@Param("s") String s);
# app/db/repositories/product_repository.py — SQLAlchemy
async def by_sku(self, s: str) -> Product | None:
row = (await self._session.execute(select(ProductRow).where((ProductRow.sku == s)))).scalars().first()
if row is None:
return None
return await self._hydrate(row)
# lib/d/catalog/product_repository.ex — Ecto
def by_sku(s) do
query = from(record in D.Catalog.Product, where: record.sku == ^s)
{:ok, Repo.one(query)}
end
The comparison binds one column against one value (a parameter, literal, or enum value) on every backend — that constraint is what makes the clause selectable, and it is enforced (see the queryable subset below).
A find without where — key lookup by parameter name
A find with no where filters on the fields its parameters name: each parameter
equals the field of the same name, or the field whose <field>Id spelling it is
(siteId → site). The pairing is resolved once in enrichment and stored on
the find, so every backend filters on the same keys. A parameter that names no
field is refused (loom.find-param-unbound) — it would otherwise be dropped
from the query, and the find would answer every lookup with an arbitrary row.
repository Products for Product {
find bySku(sku: string): Product? // ≡ where this.sku == sku
find bySkuOops(s: string): Product? // loom.find-param-unbound: 's' names no field
}
// node — product-repository.ts
async bySku(sku: string): Promise<Product | null> {
const rootRows = await this.db.select().from(schema.products).where(eq(schema.products.sku, sku)).limit(1);
List finds are removed
A find returning a list is an error — loom.list-find-removed:
repository Products for Product {
find pricey(floor: money): Product[] where this.price >= floor
}
error loom.list-find-removed
repository find 'pricey' returns a list, and list finds are removed — declare a 'criterion'
(run it with Repo.run(<Criterion>(args))) or a 'retrieval' instead, and put it on the wire with
'scaffoldPaged(of: <it>)' on the context plus 'scaffoldPagedApi(of: <it>)' on the api when a
client must call it; 'node scripts/codemod-list-find-to-criterion.mjs <path>' rewrites it.
The list read is a criterion, run through the repository:
repository Products for Product { }
criterion ProductsPricey(floor: money) of Product = this.price >= floor
// … let xs = Products.run(ProductsPricey(floor))
Every find returning T[] or T paged gets it, in every context, except find all(…) — the author's override of the repository's list endpoint (criterion.md). A unique-key reconstitution find returning T / T? stays fine — provided the key really is unique: a single-row find keyed by equality on fields that no unique (…) covers gets the advisory loom.single-row-find-not-unique (declare unique (number) beside find byNumber(number: string): Ticket?). The compiler-synthesised reads (auto-findAll, scaffoldPaged's paged find) are exempt. The replacements are §criterion, §Repo.run / paged queryHandler and §projection. A query that filters an un-indexed column — a find … where, a criterion body or a retrieval's where — also gets an advisory loom.index-suggestion pointing at the resource … { index: [Agg.col] } hatch (chapter 14).
node scripts/codemod-list-find-to-criterion.mjs <path> makes that migration (D-LIST-FIND-REMOVED): one criterion per list find beside its repository, named <Plural><Find>, with the find's where as its body (or, for a find written without where, the parameter-name convention spelled out — this.customerId == customerId); each Repo.<find>(args) call becomes Repo.run(<Criterion>(args)). A parameterless, filterless list find read every row, so it becomes the repository's find all. When a test e2e reads the find's route (api.<plural>.<find>(…)), the codemod keeps it on the wire as a routed queryHandler List<Criterion> over Repo.run on the same path (each parameter a {token}), and repoints the read to api.<context>.list<Criterion>(…). What it cannot rewrite — a page reading the route, a find with no api in the same source — it lists.
Natural-key reads — getBy / findBy
A read by a natural key needs no find. When the aggregate declares the key unique (…), the repository answers it through two contract verbs (D-REPO-KEY-LOOKUP):
aggregate Product with crudish, softDeletable {
sku: string region: string name: string
unique (sku)
unique (region, name)
}
repository Products for Product { }
let p = Products.getBy(sku: s) // Product — a miss answers the standard 404
if let q = Products.findBy(name: n, region: r) { … } // Product? — any argument order
let a = Products.getBy(sku: s) ignoring * // past the softDeletable filter
Products.findBy(name: n) // loom.repo-key-incomplete: (region, name) misses 'region'
Products.findBy(price: x) // loom.repo-key-not-unique, with a did-you-mean over the declared keys
The rules:
- The named arguments must equal one declared
unique (…)tuple of the aggregate (inherited tuples count), in any order — every column present. A subset of a tuple isloom.repo-key-incomplete(so is a positional argument); a set no tuple covers isloom.repo-key-not-unique, which lists the declared keys and suggests the closest one. getByyields the row: a miss raises not-found, answered as the standard ProblemDetails 404 ("not_found").findByyieldsT?: it is anif letsource, and a plainletbinds it asT?(read through a null test or narrowed byif let, like anyT?read).- Capability filters (soft-delete, tenancy) apply as on every read; a trailing
ignoring …widens one call, on aletor anif letsource.
Each backend emits one repository method per used (verb, tuple, ignoring stance), named from the tuple's columns in declared order — getBySku, findByRegionAndName, getBySkuIgnoringAll — with a WHERE on exactly those columns. The method is derived from the calls in enrichment (phase ⑥), not declared, and has no HTTP route of its own; expose it with a routed queryHandler. Each backend renders the name through its own escaping (GetBySku on .NET, get_by_sku on Python and Elixir):
// db/repositories/product-repository.ts
async getBySku(sku: string): Promise<Product> {
const rootRows = await this.db.select().from(schema.products).where(and(eq(schema.products.sku, sku), not(eq(schema.products.isDeleted, true)))).orderBy(asc(schema.products.id)).limit(1);
if (rootRows.length === 0) throw new AggregateNotFoundError("not_found");
// …rehydrate
}
async findByRegionAndName(region: string, name: string): Promise<Product | null> { /* …returns null on a miss */ }
async getBySkuIgnoringAll(sku: string): Promise<Product> {
const rootRows = await this.db.select().from(schema.products).where(eq(schema.products.sku, sku)).orderBy(asc(schema.products.id)).limit(1);
// …
}
// http/workflows.ts — the call site
const p = await products.getBySku(sku);
// Infrastructure/Repositories/ProductRepository.cs — the soft-delete filter is EF's HasQueryFilter
public async Task<Product> GetBySku(string sku, CancellationToken cancellationToken = default)
{
var result = await _db.Products.Where(x => x.Sku == sku).FirstOrDefaultAsync(cancellationToken) ?? throw new AggregateNotFoundException("not_found");
return result;
}
public async Task<Product> GetBySkuIgnoringAll(string sku, CancellationToken cancellationToken = default)
{
var result = await _db.Products.IgnoreQueryFilters().Where(x => x.Sku == sku).FirstOrDefaultAsync(cancellationToken) ?? throw new AggregateNotFoundException("not_found");
return result;
}
// ProductJpaRepository.java
@Query("select e from Product e where e.sku = :sku")
Product getBySku(@Param("sku") String sku);
// ProductRepositoryImpl.java
public Product getBySku(String sku) {
var result = jpa.getBySku(sku);
if (result == null) throw new AggregateNotFoundException("not_found");
return result;
}
# app/db/repositories/product_repository.py
async def get_by_sku(self, sku: str) -> Product:
row = (await self._session.execute(select(ProductRow).where(and_((ProductRow.sku == sku), not_(ProductRow.is_deleted))))).scalars().first()
if row is None:
raise AggregateNotFoundError("not_found")
return await self._hydrate(row)
# lib/elixir_api/catalog/product_repository.ex
def get_by_sku(sku) do
query = from(record in ElixirApi.Catalog.Product, where: (record.sku == ^sku) and (not record.is_deleted))
case Repo.one(query) do
nil -> {:error, :not_found}
found -> {:ok, found}
end
end
node scripts/codemod-find-to-findby.mjs [--add-unique] <path> migrates every single-row find (T? / T option, T / T envelope, T or <Error> — a miss is T?) to one of three targets, deleting the declaration:
- a key equal to a declared
unique (…)tuple →Repo.findBy(<col>: arg, …)/Repo.getBy(…), carrying the find'signoring(as dofindByIdandfind(C);getByIddoes not); - the aggregate's
id→ the built-inRepo.findById(arg)/Repo.getById(arg); - any other key or queryable
where→ acriterion <Plural><Find>(params) of <Agg> = <where>beside the repository (an existing criterion is reused when thewhereis exactly one), read withRepo.find(<Criterion>(args))— the first match,T?.
A clause-less find binds its parameters to the fields they name, so it counts as that equality conjunction. A find is migrated only when every call site can take its target: a T? read (findBy, findById, find(C), each optionally widened with ignoring) is accepted as an if let source, a let (it binds T?) or in a domain-service expression; and a must-exist (T) find over a non-unique key becomes if let … else { notFound } where the call site is a let the rest of its block can move under. Other call sites are reported and the find stays, as are a T or <Error> call site that matches on the error variant, a must-exist by-id find with ignoring (getById cannot carry it), a where that reads currentUser, a find with neither where nor parameters, and a find whose route a client reads. --add-unique declares the key instead of writing a criterion; it never runs by default.
The queryable subset
A find … where (and a retrieval where, a projection where, a capability filter) is not a general expression — it must lower to SQL. The oracle firstNonQueryableNode admits exactly: comparisons (== != < <= > >=), && || !, parenthesised groups, this.<column> (or the projection alias o.<column>) and one-level this.<vo>.<sub> flattened value-object refs, currentUser.<field>, parameter refs, literals and enum values, a named criterion reference, membership over a reference collection (this.<refColl>.contains(x) — an EXISTS subquery), and the bool-returning queryable intrinsics with the column as receiver (this.path.startsWith(p) → a prefix filter). Everything richer is rejected — lambdas, collection projections (.count / .first), arithmetic, value-object construction, calls, ternaries, match, conversions, and column-vs-column comparisons.
repository Orders for Order {
// rejected: `.count` on a list is a projection needing a subquery
find busy(): Order? where this.lines.count > 0
}
error loom.find-where-not-queryable
repository 'Orders' find 'busy': where-clause is not queryable (collection projection
'.count' on a list). Allowed: comparisons, &&/||/!, parens, 'this.<column>' /
'this.<vo>.<sub>' refs, parameter refs, literals.
Sibling codes for the same position: loom.find-where-unknown-field (a this.<x> that isn't a real column), loom.find-where-column-column (both sides of a comparison are columns — eq() needs one column and one value), and the loom.retrieval-where-* / loom.projection-where-not-queryable / loom.criterion-not-selectable twins for the other three positions. There is no per-backend escape hatch: rejecting at the IR layer means no backend silently emits broken SQL, and as of wave C2 packet 2n there is no per-ADAPTER one either — every relational adapter lowers the same subset, and the per-adapter descriptor table that once claimed otherwise is gone.
The oracle comes in two halves, because a predicate and a value are different questions. firstNonQueryableNode asks "could this node reach SQL at all", which is right for a comparison OPERAND. firstNonQueryablePredicate (same file) asks the PREDICATE question: every leaf must TEST a column — a comparison with a column-rooted operand, a bare boolean column (this.active, this.flags.active), a .contains(<value>) membership, a bool-returning queryable intrinsic (this.path.startsWith(p)), or an authorization sentinel — combined with && / || / !. So where true, where f (a bool parameter), where currentUser.isAdmin and where 1 == 1 are refused: every relational lowerer's output vocabulary is <fn>(<column>, <value>), with no bare-value position for them to land in.
Return shapes
find return |
Cardinality | Method shape |
|---|---|---|
T |
exactly one (404 ProblemDetails on miss) | getById-style |
T? |
zero-or-one | returns T | null |
T[] |
collection — find all only (loom.list-find-removed otherwise) |
returns a list |
T paged |
a page of T — find all only |
takes page/pageSize/sort/dir, returns { items, page, pageSize, total, totalPages } |
T envelope |
{ id, ts, body } wrapper |
see Type system → carriers |
T option / T or <Error> |
the absence shape | untagged: T at 200, 404 / the error's status on miss — chapter 9 |
A paged read auto-gains page/pageSize/sort/dir parameters and a total/totalPages count query (the same shape the auto-findAll and a paged queryHandler use). Those four names are therefore reserved on a paged read: a declared parameter spelled page, pageSize, sort or dir — compared in snake form, so page_size and Dir too — on a paged find all, a paged queryHandler, or a criterion a paged queryHandler runs is refused (loom.paged-param-reserved), since it would be a second value for the same query key and a duplicate parameter in every generated signature. Rename it (pageNo, sortKey, …):
repository Orders for Order { }
criterion OrdersForCustomer(c: string) of Order = this.customer == c
queryHandler ListForCustomer(c: string): Order paged {
let r = Orders.run(OrdersForCustomer(c))
return r
}
The paged run reads the repository's synthesised findAllBy<Criterion> page method. Order carries softDeletable, so every read below also ANDs the capability filter not is_deleted (chapter 11):
async findAllByOrdersForCustomer(c: string, page: number, pageSize: number, sort: string, dir: string):
Promise<{ items: Order[]; page: number; pageSize: number; total: number; totalPages: number }> {
const offset = (page - 1) * pageSize;
const sortColumn = sortColumns[sort] ?? schema.orders.id;
const orderBy = dir === "desc" ? desc(sortColumn) : asc(sortColumn);
const countRows = await this.db.select({ value: count() }).from(schema.orders).where(and(eq(schema.orders.customer, c), not(eq(schema.orders.isDeleted, true))));
const total = Number(countRows[0]?.value ?? 0);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
const rootRows = await this.db.select().from(schema.orders).where(and(eq(schema.orders.customer, c), not(eq(schema.orders.isDeleted, true)))).orderBy(orderBy).limit(pageSize).offset(offset);
// …
return { items, page, pageSize, total, totalPages };
}
public async Task<Paged<Order>> FindAllByOrdersForCustomer(string c, int page, int pageSize, string sort, string dir, CancellationToken cancellationToken = default)
{
var offset = (page - 1) * pageSize;
var sortColumn = sort switch { "customer" => "Customer", "ref" => "Ref", "total" => "Total", "status" => "Status", "placedAt" => "PlacedAt", _ => "Id" };
var total = await _db.Orders.Where(x => x.Customer == c).CountAsync(cancellationToken); // IsDeletedFilter applied by EF
var totalPages = pageSize > 0 ? (int)System.Math.Ceiling((double)total / pageSize) : 0;
var ordered = dir == "desc" ? _db.Orders.Where(x => x.Customer == c).OrderByDescending(e => EF.Property<object>(e, sortColumn)) : /* …OrderBy */;
var items = await ordered.Skip(offset).Take(pageSize).ToListAsync(cancellationToken);
return new Paged<Order>(items, page, pageSize, total, totalPages);
}
// OrderJpaRepository: @Query("select e from Order e where e.customer = :c") Page<Order> findAllByOrdersForCustomer(@Param("c") String c, Pageable pageable);
public Paged<Order> findAllByOrdersForCustomer(String c, int page, int pageSize, String sort, String dir) {
String __sortField = java.util.List.of("id", "customer", "ref", "total", "status", "placedAt").contains(sort) ? sort : "id";
Sort __sort = Sort.by("desc".equals(dir) ? Sort.Direction.DESC : Sort.Direction.ASC, __sortField);
var result = jpa.findAllByOrdersForCustomer(c, PageRequest.of(page - 1, pageSize, __sort));
return new Paged<>(result.getContent(), page, pageSize, (int) result.getTotalElements(), result.getTotalPages());
}
async def find_all_by_orders_for_customer(self, c: str, page: int, page_size: int, sort: str, dir: str) -> PagedResult[Order]:
offset = (page - 1) * page_size
_sort_attr = getattr(OrderRow, _sort_columns.get(sort, "id"))
_order = _sort_attr.desc() if dir == "desc" else _sort_attr.asc()
total = (await self._session.execute(
select(func.count()).select_from(OrderRow).where(and_((OrderRow.customer == c), not_(OrderRow.is_deleted))))).scalar_one()
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
# …select(OrderRow).where(…).order_by(_order).limit(page_size).offset(offset)
def find_all_by_orders_for_customer(c, page \\ 1, page_size \\ 20, sort \\ "id", dir \\ "asc") do
query = from(record in D.Catalog.Order, where: (record.customer == ^c) and (not record.is_deleted))
total = Repo.aggregate(query, :count, :id)
offset = (page - 1) * page_size
sort_col = case sort do "customer" -> :customer "ref" -> :ref "total" -> :total "status" -> :status "placedAt" -> :placed_at _ -> :id end
dir_atom = if dir == "desc", do: :desc, else: :asc
items = query |> order_by([record], [{^dir_atom, field(record, ^sort_col)}]) |> limit(^page_size) |> offset(^offset) |> Repo.all()
{:ok, %{items: items, page: page, pageSize: page_size, total: total, totalPages: if(page_size > 0, do: ceil(total / page_size), else: 0)}}
end
Gating a read — policy { read X requires … }
A find carries no authorization gate. Who may read an aggregate is one line in its context's policy block, and it gates every read of the aggregate — the list and by-id routes, each find's route, the audit history read. It runs before the query, so it may only read currentUser (and constants) — this.<field> there is loom.policy-read-gate-not-current-user; use where to scope rows and the read gate to allow/deny the caller. The enforcement story (403, denyByDefault, the scaffolded List page inheriting the gate) is in chapter 17 and ../auth.md.
repository Orders for Order {
find mine(c: string): Order? where this.code == c
}
policy { read Order requires currentUser.role == "agent" }
// node — http/order.routes.ts, GET /orders/mine
if (!(currentUser.role === "agent")) throw new ForbiddenError("Forbidden: find mine");
A projection keeps its own header gate (projection P requires <expr> { … }).
criterion
A criterion is a named, parameterised, pure boolean predicate over a candidate type (the Specification pattern). of <Agg> names the candidate; inside the body, bare field names (and this) resolve against it — the same convention as invariant/derived. of <Agg> as <alias> binds an explicit candidate name instead (an alias that shadows a parameter is loom.criterion-alias-collision); of bool is an ambient predicate with no candidate — those two are the only admitted targets (loom.criterion-unsupported-target), and a criterion may only filter the aggregate it is declared of (loom.criterion-target-mismatch when a find/retrieval/filter on another aggregate names it). Criteria compose with && || ! (the boolean operators are the composition), must be pure (loom.criterion-impure), acyclic (loom.criterion-cycle), and called with the right arity (loom.criterion-arity).
criterion ActiveOrder of Order = status != OrderStatus.Closed
criterion HighValue(min: money) of Order = total >= min
criterion Confirmed of Order as o = o.status == OrderStatus.Confirmed
A criterion reference is inlined wherever a boolean expression is expected (a projection where, an invariant, a find where composed with other terms) — it produces exactly the same lowered SQL as the hand-written filter. But when a find's, retrieval's or filter's predicate is exactly one named criterion, it reifies: the backend emits a named predicate object the query consumes. The rule is simply "if it has a name." See ../criterion.md for the full validation table.
The reification target differs per backend — this is HighValue reified:
// db/repositories/order-repository.ts — module-level predicate function
const highValueCriterion = (min: Decimal) => gte(schema.orders.total, min);
// …consumed by runBigActive / findAllByHighValue below
// Domain/Criteria/HighValueCriterion.cs — Criterion<T> with both a runtime
// check and a query-side expression tree
public sealed class HighValueCriterion : Criterion<Order>
{
private readonly decimal min;
public HighValueCriterion(decimal min) { this.min = min; }
public override bool IsSatisfiedBy(Order candidate) => candidate.Total >= min;
public Expression<Func<Order, bool>> ToExpression() => candidate => candidate.Total >= min;
}
// domain/criteria/OrderCriteria.java — a Specification<T> factory per criterion
public static Specification<Order> HighValue(BigDecimal min) {
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.<BigDecimal>get("total"), min);
}
# SQLAlchemy is non-reifying: the predicate inlines at each call site
# select(OrderRow).where(and_((OrderRow.total >= min), not_(OrderRow.is_deleted)))
# Ecto is non-reifying too: the predicate inlines into each query module
# from(record in D.Catalog.Order, where: record.total >= ^min)
retrieval
A retrieval is a named query bundle — a where predicate plus the shaping a real query carries: sort ordering and (reserved) loads fetch paths. Its where is a selection position with the same queryable-subset contract as a find — a bare predicate, a criterion, or a composition of both (loom.retrieval-where-not-queryable / -unknown-field / -column-column; a sort on a non-field is loom.retrieval-sort-unknown-field). The block form carries where: / sort: / loads: slots; the single-line form retrieval Name of Agg = <expr> is the bare-predicate shorthand. A retrieval may not share its name with a criterion in the same context (loom.criterion-retrieval-name-clash, an error): Repo.run(<Name>), scaffoldPaged(of: <Name>) and a where: <Name> each resolve the bare name, so a name that meant both would have to pick one silently.
retrieval BigActive(min: money) of Order {
where: HighValue(min)
sort: [total desc, ref asc]
}
A retrieval is a domain declaration: it has no route and no requires of its own. To put one on the wire, expose it with scaffoldPaged(of: <Retrieval>, requires?: <Policy>) + scaffoldPagedApi(of: <Retrieval>) — a paged route whose default order is the retrieval's sort: (§paged queryHandler).
A retrieval lowers to a run<Name> repository method that consumes the reified criterion and applies the sort. Paging is a call-site argument (Repo.run(BigActive(m), page: { offset: 0, limit: 100 })), never part of the declaration:
async runBigActive(min: Decimal, page?: { offset?: number; limit?: number }): Promise<Order[]> {
let query = this.db.select().from(schema.orders)
.where(and(highValueCriterion(min), not(eq(schema.orders.isDeleted, true))))
.orderBy(desc(schema.orders.total), asc(schema.orders.ref)).$dynamic();
// if (page?.limit !== undefined) query = query.limit(page.limit); …offset
}
// Domain/Orders/BigActiveSpec.cs — an Ardalis Specification bundling the criterion + sort
public sealed class BigActiveSpec : Specification<Order>
{
public BigActiveSpec(decimal min)
{
Query.Where(new HighValueCriterion(min).ToExpression()).OrderByDescending(x => x.Total).ThenBy(x => x.Ref);
}
}
// OrderRepository.RunBigActiveAsync(decimal min, (int? offset, int? limit)? page = null, FilterBypass bypass = default, …)
// var __q = _db.Orders.AsQueryable(); if (bypass.All) __q = __q.IgnoreQueryFilters(); …
public List<Order> runBigActive(BigDecimal min, Integer offset, Integer limit) {
return jpa.findAll(OrderCriteria.HighValue(min),
new OffsetLimitPageRequest(offset, limit, Sort.by(Sort.Order.desc("total"), Sort.Order.asc("ref")))).getContent();
}
async def run_big_active(self, min: Decimal, offset: int | None = None, limit: int | None = None) -> list[Order]:
query = select(OrderRow).where(and_((OrderRow.total >= min), not_(OrderRow.is_deleted))).order_by(OrderRow.total.desc(), OrderRow.ref.asc())
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
# lib/d/catalog/retrievals/big_active.ex
def run(min, opts \\ []) do
query = from(record in D.Catalog.Order, where: record.total >= ^min)
query = if opts[:ignore_all_filters] || "softDeletable" in (opts[:ignore_filters] || []), do: query, else: where(query, [record], not record.is_deleted)
query = if opts[:limit], do: limit(query, ^opts[:limit]), else: query
query = if opts[:offset], do: offset(query, ^opts[:offset]), else: query
query = order_by(query, [record], [desc: record.total, asc: record.ref])
{:ok, Repo.all(query)}
end
Repo.run(retrieval { where: <Criterion> sort: […] }, page?) is the anonymous, call-site twin accepted from workflow / handler bodies (RetrievalLiteral in the grammar) — its where: must be a criterion reference in this release. See ../workflow.md and ../criterion.md.
loads: shaping
loads: declares structural fetch paths through the candidate — e.g. loads: [this.lines[].product], a flat dotted LoadPath with [] marking "across the collection" (mirrors contains). A leading this. is admitted and stripped.
Honest gap: explicit loads: is not shipped — a retrieval with one is rejected (loom.retrieval-loads-unsupported: "explicit 'loads:' is not supported yet — retrievals load the whole aggregate. (Per-operation autoload is planned.)"). Retrievals load the whole aggregate; treat loads: as reserved surface.
Repo.run & the paged queryHandler
Repo.run(<Criterion | Retrieval>(args)) is the read-only port a handler body reads through — the criterion-driven replacement for a list find. A queryHandler that returns <Agg> paged over it is the ergonomic paged list read (what scaffoldPaged expands to): enrichment synthesises a paged find on the aggregate's repository — findAllBy<Criterion> for a criterion, page<Retrieval> for a retrieval — which is not auto-exposed (the handler's route is the exposure). The handler's return is auto-projected to <Agg>Response paged, and page/pageSize/sort/dir come from the paged infra, never from handler params. Over a retrieval, its where filters and its sort: is the default order: the route's sort defaults to "", which the paged find reads as "the declared terms, then id" (a whitelisted sort column still wins). Emitted on all five backends. The body is the handler's requires gates (each a 403 before the read) followed by let r = Repo.run(<read>(args)) at the top level, then return r; anything else is refused with loom.paged-query-handler-shape. A sorted retrieval over an aggregate that pages in memory — event-log, shape: document/embedded, an inheritance subtype — is refused under the same code (#sorted-retrieval-shape) rather than silently left unordered.
queryHandler ListBig(min: money): Order paged {
let r = Orders.run(HighValue(min))
return r
}
api CatalogApi from Sales { route GET "/big" -> Catalog.ListBig }
// http/catalogApi-routes.ts
request: { query: z.object({ min: moneySchema, page: z.coerce.number().int().min(1).max(1000000).default(1), pageSize: z.coerce.number().int().min(1).max(500).default(20), sort: z.string().default("id"), dir: z.string().default("asc") }) },
// …
const result = await orders.findAllByHighValue(min, query.page, query.pageSize, query.sort, query.dir);
return httpCtx.json({ ...result, items: result.items.map((__e) => orders.toWire(__e)) }, 200);
// Application/Orders/Queries/ListBigHandler.cs
public async ValueTask<Paged<OrderResponse>> Handle(ListBigQuery query, CancellationToken cancellationToken)
{
var domain = await _orders.FindAllByHighValue(query.Min, query.Page, query.PageSize, query.Sort, query.Dir, cancellationToken);
return new Paged<OrderResponse>(domain.Items.Select(d => new OrderResponse(/* … */)).ToList(), domain.Page, domain.PageSize, domain.Total, domain.TotalPages);
}
// application/workflows/ListBigHandler.java
public Paged<Order> handle(BigDecimal min, int page, int pageSize, String sort, String dir) {
return ordersRepository.findAllByHighValue(min, page, pageSize, sort, dir);
}
# app/application/list_big.py
async def list_big(session: AsyncSession, min: Decimal, page: int, page_size: int, sort: str, dir: str) -> dict[str, object]:
orders = OrderRepository(session, make_dispatcher(session))
result = await orders.find_all_by_high_value(min, page, page_size, sort, dir)
return {"items": [orders.to_wire(__e) for __e in result.items], "page": result.page, "pageSize": result.page_size, "total": result.total, "totalPages": result.total_pages}
# lib/d_web/controllers/catalog_api_routes_controller.ex
def list_big(conn, params) do
with {:ok, page_arg} <- page_param(params, "page", 1, 1000000),
{:ok, page_size_arg} <- page_param(params, "pageSize", 20, 500),
{:ok, result} <- D.Catalog.find_all_by_high_value_order(params["min"], page_arg, page_size_arg, Map.get(params, "sort", "id"), Map.get(params, "dir", "asc")) do
json(conn, %{result | items: Enum.map(result.items, &serialize/1)})
end
end
The inline twin inside a handler / workflow body is Repo.findAll(<Criterion>(args), page: { offset: 0, limit: N }) (optionally … ignoring …, see below): the criterion must exist on that aggregate with the right arity (loom.findall-unknown-criterion / -criterion-mismatch / -criterion-arity), and in a workflow body the page: bound is mandatory — an unbounded list read is loom.findall-no-page.
A read position (an api GET/HEAD route, a queryHandler, a reading service) may not reach the mutating repository face — a read-method route bound to a mutating commandHandler is loom.read-context-repo-write. Domain-service reads have their own rules (loom.domain-service-read-unsupported, -cross-context-read — chapter 23), and a repository read inside a test expect(...) must be let-bound first (loom.integration-find-must-bind — chapter 18).
ignoring — capability-filter bypass
A trailing ignoring clause on a read skips the query-filters a capability contributed (soft-delete row hiding, tenancy scoping, a filter capability), keyed on the capability name. ignoring * bypasses every capability filter on the aggregate; ignoring A, B bypasses exactly those. ignoring is a soft keyword (fields/params named ignoring keep parsing). All five backends honour it:
repository Orders for Order {
find all(): Order[] ignoring softDeletable
}
async all(): Promise<Order[]> {
const rootRows = await this.db.select().from(schema.orders); // no `not(isDeleted)` term
// …
}
public async Task<List<Order>> All(CancellationToken cancellationToken = default)
{
var result = await _db.Orders.IgnoreQueryFilters(["IsDeletedFilter"]).ToListAsync(cancellationToken);
return result;
}
public List<Order> findAll() {
var __session = em.unwrap(org.hibernate.Session.class);
__session.disableFilter("softDeletable");
try { return jpa.findAll(); } finally { __session.enableFilter("softDeletable"); }
}
async def all(self) -> list[Order]:
rows = (await self._session.execute(select(OrderRow))).scalars().all() # no not_(is_deleted)
return [await self._hydrate(row) for row in rows]
def all() do
query = from(record in D.Catalog.Order)
{:ok, Repo.all(query)}
end
ignoring is read back only in these positions; anywhere else it parses, binds to the wrong expression, is never read back — and is an error (loom.ignoring-clause-placement):
| Position | Spelling |
|---|---|
a repository find |
find recent(): Order[] where … ignoring softDeletable |
a query-time projection's where slot — before join / group by / select |
from Order as o ignoring softDeletable group by … |
an inline read bound by a let (findAll, run, getBy, findBy) |
let xs = Orders.findAll(Big()) ignoring * |
an if let source (find(<Criterion>), findBy) |
if let o = Orders.find(ByRef(r)) ignoring softDeletable { … } |
a single-row read in a domainService operation (find(<Criterion>), findById, getBy, findBy) |
return Orders.findById(id) ignoring * != null |
Widened single-row reads
find(<Criterion>) and findById normally run against methods every other caller shares — the criterion's findAllBy<C> retrieval and the by-id load — so a widened read gets a repository method of its own, named for the read and its stance: findFirst<Criterion>Ignoring<Caps> / findByIdIgnoring<Caps> (…IgnoringAll for ignoring *). Like the natural-key methods it is derived from the calls in enrichment and has no HTTP route. A criterion read keeps find(C)'s meaning — the first match, in id order — even where the predicate matches many rows.
criterion ProductsInRegion(region: string) of Product = this.region == region
commandHandler ArchiveFirstInRegion(region: string): int {
if let p = Products.find(ProductsInRegion(region)) ignoring softDeletable { p.archive() }
return 0
}
domainService ProductQueries {
operation everExisted(id: Product id): bool {
return Products.findById(id) ignoring softDeletable != null
}
}
async findFirstProductsInRegionIgnoringSoftDeletable(region: string): Promise<Product | null> {
const rootRows = await this.db.select().from(schema.products)
.where(productsInRegionCriterion(region)).orderBy(asc(schema.products.id)).limit(1);
…
}
// the plain read still filters: runFindAllByProductsInRegion … where(and(productsInRegionCriterion(region), not(eq(schema.products.isDeleted, true))))
public async Task<Product?> FindByIdIgnoringSoftDeletable(ProductId id, CancellationToken cancellationToken = default)
{
var result = await _db.Products.IgnoreQueryFilters(["IsDeletedFilter"]).Where(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
…
}
@Query("select e from Product e where e.region = :region order by e.id")
java.util.List<Product> findFirstProductsInRegionIgnoringSoftDeletable(@Param("region") String region, Pageable pageable);
// ProductRepositoryImpl: the promoted softDeletable @Filter is disabled around the read
__session.disableFilter("softDeletable");
try {
var result = jpa.findFirstProductsInRegionIgnoringSoftDeletable(region, PageRequest.of(0, 1)).stream().findFirst().orElse(null);
…
} finally {
__session.enableFilter("softDeletable");
}
async def find_by_id_ignoring_soft_deletable(self, id: ProductId) -> Product | None:
row = (await self._session.execute(select(ProductRow).where((ProductRow.id == id)).execution_options(loom_ignoring=frozenset({"softDeletable"})))).scalars().first()
…
def find_first_products_in_region_ignoring_soft_deletable(region) do
query = from(record in ElixirApi.Catalog.Product, where: record.region == ^region)
{:ok, Repo.one(from(record in query, order_by: [asc: record.id], limit: 1))}
end
Naming a capability the aggregate does not implement is loom.filter-bypass-unknown-capability; naming one that contributes no filter (ignoring auditable) is loom.filter-bypass-no-filter; an ignoring over a workflow or projection source has nothing to bypass (loom.projection-workflow-source-ignoring-no-effect / loom.projection-source-ignoring-no-effect). See chapter 11 for the filter capability that produces these predicates.
projection — the read model
A projection is a named read model served at GET /projections/<snake_name> (and /{key} for a keyed one). It comes in two flavours, decided by which clauses the body carries — never both (loom.projection-query-and-fold-invalid):
| Flavour | Body | Storage | Serves |
|---|---|---|---|
| query-time | from <Source> [as <alias>] [where …] [ignoring …] [join …]* [group by …] [select …] |
none — computed on every read | a list of rows (or one object for the whole-table aggregation) |
| folded | keyed by <field> + on(e: <Event>) [by <expr>] { … } folds |
a materialized <proj> table, one row per key |
the row list + /{key} |
Both may declare row fields (name: type), take parameters (projection OrdersInRegion(region: string) { … }), and carry a requires gate (currentUser-only — loom.projection-gate-not-current-user). The wire row is the declared fields (wireShape), so every backend serves the same JSON.
Query-time — from … select …
The per-row read: from names the source, where is a criterion position over it (the queryable subset), each join <Agg> as <c> on <idRef> is a by-id follow (the reference's identity only — an arbitrary join key is rejected), and select fills each declared field from the source alias and join aliases. The query itself is the source aggregate's repository (a synthesised source find carrying the where and the capability filters); the joins are an app-level bulk load by id after it.
projection OrderWithCustomer {
orderId: Order id ref: string customerName: string
from Order as o
join Customer as c on o.customerId
select orderId = o.id, ref = o.ref, customerName = c.name
}
// http/query-projections.ts — GET /projections/order_with_customer
const OrderWithCustomerRow = z.object({ orderId: z.string(), ref: z.string(), customerName: z.string() }).openapi("OrderWithCustomerRow");
// …
const repo = new OrderRepository(db, events);
const rows = await repo.orderWithCustomer(); // synthesised source find (+ capability filters)
const customerRepo = new CustomerRepository(db, events);
const customerById = new Map((await customerRepo.findManyByIds(rows.map((r) => r.customerId))).map((a) => [a.id as string, a]));
const projected = rows.map((r) => ({ orderId: r.id as string, ref: r.ref, customerName: customerById.get(r.customerId as string)!.name }));
// Application/Projections/OrderWithCustomerQpHandler.cs
var domain = await _repo.OrderWithCustomer(cancellationToken);
var customerById = (await _customerRepo.FindManyByIdsAsync(domain.Select(d => d.CustomerId).ToList(), cancellationToken)).ToDictionary(__a => __a.Id);
return domain.Select(d => new OrderWithCustomerRow(d.Id.Value, d.Ref, customerById[d.CustomerId].Name)).ToList();
// application/views/CatalogQueryProjections.java
public List<OrderWithCustomerRow> orderWithCustomer() {
var customerById = customersRepository.findAll().stream().collect(Collectors.toMap(__a -> __a.id().value(), __a -> __a));
return ordersRepository.orderWithCustomer().stream()
.map(a -> new OrderWithCustomerRow(a.id().value(), a.ref(), customerById.get(a.customerId().value()).name()))
.toList();
}
# app/http/query_projections_routes.py
rows = await repo.order_with_customer()
customer_by_id = {str(a.id): a for a in await customer_repo.find_many_by_ids([r.customer_id for r in rows])}
return [{"orderId": r.id, "ref": r.ref, "customerName": customer_by_id[str(r.customer_id)].name} for r in rows]
# lib/d/catalog/query_projections/order_with_customer.ex
rows = from(record in D.Catalog.Order, where: not record.is_deleted) |> Repo.all()
customer_by_id = from(row in D.Catalog.Customer, where: row.id in ^Enum.map(rows, fn record -> record.customer_id end)) |> Repo.all() |> Map.new(&{&1.id, &1})
Enum.map(rows, fn record -> %{orderId: record.id, ref: record.ref, customerName: Map.get(customer_by_id, record.customer_id).name} end)
A select that names nothing resolvable is loom.projection-select-unresolved; a where outside the queryable subset is loom.projection-where-not-queryable.
Shorthand — the select-less form
Omit both the row fields and the select, and the row is the source aggregate's own full wire shape — each filtered row serialized through the aggregate's own domain→wire mapper, exactly as its list route does. This is the replacement for the removed view X = A where P:
projection ActiveOrders {
from Order as o where ActiveOrder
}
// http/query-projections.ts (node) — GET /projections/active_orders
const ActiveOrdersRow = z.object({ id: z.string(), customer: z.string(), ref: z.string(), total: z.string(), status: z.enum([…]), placedAt: z.string(), customerId: z.string(), isDeleted: z.boolean(), deletedAt: z.string().nullish(), version: z.number().int() }).openapi("ActiveOrdersRow");
const rows = await repo.activeOrders(); // where: status != "Closed" AND not isDeleted
const projected = rows.map((r) => repo.toWire(r)); // the aggregate's own mapper
Every backend does the same through its own serializer — .NET domain.Select(d => new ActiveOrdersRow(d.Id.Value, d.Customer, …)), Java .map(a -> new ActiveOrdersRow(a.id().value(), …)), Python [repo.to_wire(r) for r in rows], Elixir Enum.map(rows, &serialize/1). Aggregate source only — a select-less projection over a workflow or projection source is loom.projection-shorthand-nonaggregate; row fields with no select to fill them is the different error loom.projection-fields-without-select.
Whole-table aggregation — the singleton
A select made only of aggregations (count() / sum(col) / avg(col) / min(col) / max(col)) is a whole-table aggregation: one SQL query, no rows rehydrated, and the read returns one object rather than a list. Aggregation arguments must be bare source columns (sum(o.total), never sum(o.total + o.tax) — loom.projection-aggregate-arg-not-columnar). count takes no argument — it counts rows, never a column's values — and the others take exactly one column; any other argument count is loom.projection-aggregate-arity (to count only the rows where a condition holds, narrow them with a where in a projection of their own).
projection OrderVolume {
total: int revenue: money
from Order as o
where Confirmed
select total = count(), revenue = sum(o.total)
}
const OrderVolumeResponse = OrderVolumeRow.openapi("OrderVolumeResponse"); // one object, not an array
const [row] = await db.select({ total: count(), revenue: sum(schema.orders.total) }).from(schema.orders).where(and(eq(schema.orders.status, "Confirmed"), not(eq(schema.orders.isDeleted, true))));
const projected = { total: Number(row?.total ?? 0), revenue: new Decimal(row?.revenue ?? 0).toFixed(4) };
var agg = await _db.Orders.AsNoTracking().Where(o => o.Status == OrderStatus.Confirmed)
.GroupBy(_ => 1)
.Select(g => new { Total = g.Count(), Revenue = g.Sum(o => o.Total) })
.FirstOrDefaultAsync(cancellationToken);
return new OrderVolumeRow(agg?.Total ?? 0, (agg?.Revenue ?? 0m).ToString("F4", CultureInfo.InvariantCulture));
Object[] r = (Object[]) entityManager.createQuery("select count(e), sum(e.total) from Order e where e.status = com.loom.d.domain.enums.OrderStatus.Confirmed").getSingleResult();
return new OrderVolumeRow(((Number) r[0]).intValue(), r[1] == null ? "0.0000" : new java.math.BigDecimal(r[1].toString()).setScale(4, java.math.RoundingMode.HALF_UP).toPlainString());
row = (await session.execute(select(func.count(), func.sum(OrderRow.total)).select_from(OrderRow).where(and_((OrderRow.status == OrderStatus.Confirmed), not_(OrderRow.is_deleted))))).one()
return {"total": int(row[0] or 0), "revenue": money_str(Decimal(row[1] or 0))}
row = from(record in D.Catalog.Order, where: (record.status == ^"Confirmed") and (not record.is_deleted), select: %{total: count(record.id), revenue: sum(record.total)}) |> Repo.one()
%{total: row.total || 0, revenue: __money_wire(row.revenue || 0)}
Note the per-field wire coercions every backend applies identically: a money aggregate rides the wire as a 4-decimal string, count zero-defaults over an empty table.
Grouped — group by
Mixing an aggregate select with a per-row select is a GROUP BY — one row per distinct value of the per-row column. Declare the grouping explicitly with group by <col>, … (between where/join and select); the mix without the clause is loom.projection-groupby-missing.
projection SalesByStatus {
status: OrderStatus orders: int revenue: money
from Order as o
group by o.status
select status = o.status, orders = count(), revenue = sum(o.total)
}
The read happens in SQL and returns the list shape, ordered by the grouping
columns, ascending, by their stored column value — so it is deterministic across
backends. For an ENUM key that means the lexicographic order of the member name,
not the order the members are declared in: an enum column is stored as TEXT on every
backend, so enum OrderStatus { Draft Confirmed Cancelled } grouped on comes back
Cancelled, Confirmed, Draft. (A projection has no order by clause yet; a read
that wants declaration order selects an explicit rank column.)
const rows = await db.select({ status: schema.orders.status, orders: count(), revenue: sum(schema.orders.total) }).from(schema.orders).where(not(eq(schema.orders.isDeleted, true))).groupBy(schema.orders.status).orderBy(schema.orders.status);
const projected = rows.map((r) => ({ status: r.status, orders: Number(r.orders ?? 0), revenue: new Decimal(r.revenue ?? 0).toFixed(4) }));
var groups = await _db.Orders.AsNoTracking()
.GroupBy(o => new { o.Status })
.Select(g => new { g.Key.Status, Orders = g.Count(), Revenue = g.Sum(o => o.Total) })
.OrderBy(x => x.Status)
.ToListAsync(cancellationToken);
List<Object[]> rows = entityManager.createQuery("select e.status, count(e), sum(e.total) from Order e group by e.status order by e.status").getResultList();
select(OrderRow.status, func.count(), func.sum(OrderRow.total)).select_from(OrderRow).where(not_(OrderRow.is_deleted)).group_by(OrderRow.status).order_by(OrderRow.status)
from(record in D.Catalog.Order, where: not record.is_deleted, group_by: record.status, order_by: record.status, select: %{status: record.status, orders: count(record.id), revenue: sum(record.total)})
The shape discipline (each its own diagnostic):
- Aggregate
fromsource required — workflow/projection sources and folded projections cannot be grouped (loom.projection-groupby-source-invalid). - At least one aggregate
select— agroup bywith only per-row selects is just DISTINCT (loom.projection-groupby-no-aggregate). - Per-row selects must be grouping columns (
loom.projection-groupby-select-not-grouped). - Grouping keys are source columns — optionally bucketed by the one supported transform,
startOfDay()on adatetime(GroupKeyTransforminsrc/ir/util/projection-aggregate.ts); any other computed key (arithmetic, another intrinsic) isloom.projection-groupby-key-not-columnar, and a keyselectmust repeat the grouping expression exactly (select day = o.placedAtagainstgroup by o.placedAt.startOfDay()is per-row, not per-group). - No
join, nokeyed by— a join is an app-level by-id load after the query (loom.projection-groupby-join-invalid), and grouped rows are the groups, not id-keyed entities (loom.projection-groupby-keyed-invalid).
The transform renders as the same date_trunc('day', …) in SELECT, GROUP BY and ORDER BY on every backend — a daily series:
projection DailyRevenue {
day: datetime revenue: money
from Order as o
group by o.placedAt.startOfDay()
select day = o.placedAt.startOfDay(), revenue = sum(o.total)
}
const rows = await db.select({ day: sql`date_trunc('day', ${schema.orders.placedAt})`.mapWith(schema.orders.placedAt), revenue: sum(schema.orders.total) })
.from(schema.orders).where(not(eq(schema.orders.isDeleted, true)))
.groupBy(sql`date_trunc('day', ${schema.orders.placedAt})`).orderBy(sql`date_trunc('day', ${schema.orders.placedAt})`);
from(record in D.Catalog.Order, where: not record.is_deleted,
group_by: fragment("date_trunc('day', ?)", record.placed_at),
order_by: fragment("date_trunc('day', ?)", record.placed_at),
select: %{day: fragment("date_trunc('day', ?)", record.placed_at), revenue: sum(record.total)})
No paging, no order by. A projection read returns the whole row list — there is no paged carrier or sort clause on a projection (the grammar reserves order by for a later slice; no loom.projection-paged-* code exists). Grouped reads are ordered by their grouping columns; for a paged list, use a paged queryHandler (above).
Sources — aggregate, workflow, projection
from may name an aggregate (read through its repository — the default and only source that supports join, ignoring, the shorthand, and the direct-table arms), a workflow (read through its persisted instance rows — it must be state-backed and observable, i.e. have a single id-shaped correlation field: loom.projection-workflow-source-not-observable, -eventsourced-invalid; no join: -join-invalid), or another materialized projection (a folded one with a table — a query-time source has nothing to read from: loom.projection-source-not-materialized; never itself: -source-self; no join: -source-join-invalid).
workflow Fulfil {
orderId: Order id
attempts: int
create(p: OrderConfirmed) by p.orderRef { attempts := 1 }
}
projection ActiveFulfils {
orderId: Order id attempts: int
from Fulfil as f where f.attempts > 0
select orderId = f.orderId, attempts = f.attempts
}
The read goes straight to the workflow's instance-state table (no repository, no capability filters):
// http/query-projections.ts — GET /projections/active_fulfils
const rows = await db.select().from(schema.fulfils).where(gt(schema.fulfils.attempts, 0));
const projected = rows.map((r) => ({ orderId: r.orderId, attempts: r.attempts }));
# lib/d/catalog/query_projections/active_fulfils.ex — "Source workflow: Fulfil (saga instance state)"
rows = from(record in D.Catalog.Workflows.FulfilState, where: record.attempts > 0) |> Repo.all()
Enum.map(rows, fn record -> %{orderId: record.order_id, attempts: record.attempts} end)
The source has to have columns
The two direct-table arms — the whole-table aggregation and the grouped read — name columns on the source aggregate's own table, because the arithmetic happens in SQL. Three source shapes have no such columns, and the read is refused on every backend (loom.projection-columnless-source): persistedAs: eventLog (no state table), shape: document (the table is (id, data, version); refused per column named — count() alone is fine), and a TPC abstract base (no table of its own). The way out is a different read — the per-row arm, or a folded projection.
One more condition bites the surviving document count(): a capability-filtered document source (loom.projection-document-source-capability-filtered — the filter predicates name tenant_id / is_deleted, columns the jsonb triple lacks; waive them with ignoring or store relationally). Java used to be a second one — its aggregation ran JPQL through the EntityManager and a document aggregate has no JPA entity to name — but since M-T4.2 it runs the same query natively against the (id, data, version) table, so all five backends aggregate a document source alike. scaffoldDashboard still skips document aggregates, on every backend, because only id is nameable there (../scaffold-macros.md).
Folded — keyed by + on(e: Event)
A projection with no query clauses and one or more on(e: <Event>) { … } handlers is a materialized read model: a table with one row per keyed by key, updated by a pure fold on each event. keyed by must name a declared id-shaped field (loom.projection-key-unknown / -key-not-id); the routing key is e.<key> unless by <expr> says otherwise (an event without the key field and no by is loom.projection-event-unkeyed). The grammar makes keyed by optional — but a fold without it has no key to route by and is refused by that same loom.projection-event-unkeyed; the keyless ("singleton") projection is the query-time whole-table aggregation above, not a fold. Each event type folds in exactly one handler (loom.projection-duplicate-on), and the body is a pure fold — := assignments and let only; an emit, a call, a guard or a return is loom.projection-fold-impure (use a workflow on(e) reactor instead). An on(e: E) handler IS the subscription (D-PROJECTION-IMPLICIT-SUB): a fold whose event no channel carries still dispatches in-process on all five backends, so no carrying channel is required. The warning that once demanded one, loom.projection-event-uncarried, was retired with the decision; folding an event that carries a mask unless field would launder the masked value into an unredacted row, so it is refused too (loom.field-mask-projection-source — see chapter 17).
event OrderConfirmed { orderRef: Order id, at: datetime }
channel Lifecycle { carries: OrderConfirmed delivery: broadcast retention: ephemeral }
projection OrderBoard keyed by orderRef {
orderRef: Order id
confirmedAt: datetime
on(e: OrderConfirmed) { orderRef := e.orderRef confirmedAt := e.at }
}
// http/projections.ts — load-or-allocate the row by key, fold, upsert
export async function foldOrderConfirmedIntoOrderBoard(db, e: Events.OrderConfirmed): Promise<void> {
const __key = e.orderRef;
const state = (await loadOrderBoard(db, __key)) ?? { orderRef: __key };
state.orderRef = e.orderRef;
state.confirmedAt = e.at;
await saveOrderBoard(db, state); // insert … onConflictDoUpdate({ target: schema.orderBoards.orderRef, set: state })
}
// GET /projections/order_board → OrderBoardListResponse; GET /projections/order_board/{key} → OrderBoardResponse (404 on miss)
// Application/Workflows/OrderBoardOnOrderConfirmedHandler.cs folds into
// Infrastructure/Persistence/Projections/OrderBoardRow.cs; served by Api/CatalogProjectionsController.cs
// infrastructure/repositories/OrderBoardRowRepository.java + api/CatalogProjectionsController.java
# app/http/projections_routes.py — the fold + GET /projections/order_board[/{key}]
# lib/d/catalog/projections/order_board/on_order_confirmed.ex
def handle(%D.Catalog.Events.OrderConfirmed{} = event) do
key = event.order_ref
state = case D.Repo.get(D.Catalog.Projections.OrderBoardRow, key) do
nil -> %D.Catalog.Projections.OrderBoardRow{order_ref: key}
existing -> existing
end
{:ok, _} = D.Repo.insert_or_update(Ecto.Changeset.change(state, %{confirmed_at: event.at |> then(&(&1 && DateTime.truncate(&1, :second)))}))
:ok
end
The projection tracks events, not rows: an aggregate update that emits nothing leaves the read model untouched, and deleting the aggregate does not delete the fold row.
Backend gates
Every projection shape above emits on all five backends, so none carries a per-backend support gate. There is no per-feature docs/projections.md; this chapter and ../scaffold-macros.md (scaffoldDashboard) are the reference.