-
Notifications
You must be signed in to change notification settings - Fork 10
database query guidelines
Default rule: push filtering, counting, pagination, projection, and simple derivations into the repository query. Do not pull full documents into memory unless the endpoint truly needs them.
| Prefer | Avoid |
|---|---|
Q.count, relation count(), any(), every(), sum(), distinctCount()
|
Loading full rows, then using .length, .some, .every, new Set(...), or manual sums |
Q.page({ take: ... }) at query time |
Loading an unbounded result set and slicing/filtering later |
Q.project(...) with Struct.pick(...) / mapFields(...)
|
Fetching full documents when only a few fields are needed |
Q.projectComputed(..., Q.computed(...)) |
Fetching nested arrays just to derive booleans, counts, ids, weights, or totals |
| One source of truth for a condition | Re-checking the same condition again in JS/TS after the DB already narrowed the set |
If you only need cardinality, ask the database for cardinality.
userRepo.query(
Q.where("resource.spotId", req.spotId),
Q.page({ take: 1 }),
Q.count
)See api/src/<workflow>/PackSpots.Controllers.ts.
Q.relation("items").count()
Q.relation("items").count(Q.where("state._tag", "in", ["picked", "packed", "out-of-stock"]))
Q.relation("items").any(Q.where("state._tag", "packed"))
Q.relation("items").every(Q.where("state._tag", "picked"))See:
api/src/<workflow-a>/Overview.Controllers.tsapi/src/<workflow-b>/Overview.Controllers.tsapi/src/<workflow-c>/Overview.Controllers.ts
const orders = yield* orderRepo.query(...)
const hasPickingItem = orders.some((o) => o.items.some((i) => i.state._tag === "picking"))Problems:
- loads more rows than needed
- loads nested
itemsarrays just to answer an existence question - duplicates query intent in application code
Current example: api/src/<workflow>/PickCarts.Controllers.ts.
- Use
Q.countwhen the response is a count. - Use
Q.page({ take: ... })when the caller does not need the full result set. - Use relation aggregates (
count,any,every,sum,distinctCount) instead of materializing child collections.
If the caller only needs a few fields, only select those fields.
orderRepo.query(
Q.where("state._tag", "in", ["initial", "valid", "packed"]),
Q.project(Order.mapFields(Struct.pick(["carrier", "state"])), "project")
)See api/src/<workflow>/services/Dashboard.ts.
Q.project(WorkflowModels.Order.mapFields(Struct.pick(["id"])), "project")See api/src/<workflow>/services/Reset.ts.
const orders = yield* orderRepo.query(Q.where(...))
return orders.map((o) => ({ id: o.id, state: o.state }))Problems:
- transfers full documents for a small view
- couples the endpoint to fields it does not need
- makes future document growth hurt read performance
- Start from the response shape.
- Encode that shape in
Q.project(...). - Treat full-document reads as the exception, not the default.
If a list view needs derived fields, compute them in the query.
Q.projectComputed(
S.Struct({
articleCount: NonNegativeInt,
allItemsPicked: S.Boolean,
weight: Kilogram,
articleIds: S.Array(ArticleId)
}),
Q.computed({
articleCount: Q.relation("items").count(),
allItemsPicked: Q.relation("items").every(Q.where("state._tag", "picked")),
weight: Q.relation("items").sumExpr(
Q.expr.mul(Q.expr.field("weight.amount"), Q.expr.field("tradeUnit.amount"))
),
articleIds: Q.relation("items").collectDistinct("articleId")
})
)See:
api/src/<workflow-a>/Overview.Controllers.tsapi/src/<workflow-b>/Overview.Controllers.tsapi/src/<workflow-c>/Overview.Controllers.ts
Q.project(S.Struct({ items: ... }), "project").pipe(
Effect.map((rows) => rows.map(({ items, ...row }) => ({
...row,
articleCount: items.length,
articleIds: [...new Set(items.map((i) => i.articleId))]
})))
)Problems:
- ships
itemsonly to throw them away - repeats aggregation logic in every controller
- makes list endpoints scale with child collection size
- Use computed projections for booleans, counts, distinct ids, totals, and simple expressions.
- Keep fallback in-memory code only where the query DSL cannot express the operation yet.
- When a fallback is unavoidable, still project the smallest possible intermediate shape.
A query should narrow the dataset once. Avoid re-validating the same predicate by scanning the returned rows again.
yield* orderRepo
.query(
Q.where("state._tag", "picking"),
Q.and("state.cartId", "includes-any", cartId)
)
.pipe(
Effect.filterOrFail(
(orders) => orders.some((o) => o.items.some((i) => i.state._tag === "picking")),
() => new InvalidStateError("...")
)
)Problems:
- top-level query says
picking - application code then scans every returned order again
- correctness depends on two conditions staying aligned
Encode the child predicate directly in the query and limit the read:
yield* orderRepo
.query(
Q.where("state._tag", "picking"),
Q.and("state.cartId", "includes-any", cartId),
Q.and(Q.whereSome("items", Q.where("state._tag", "picking"))),
Q.page({ take: 1 })
)If the DSL can express the predicate entirely in the query, do that and drop the follow-up scan.
Use Q.projectComputed(...) only when the caller actually needs the derived boolean in the response shape.
A state machine's branches usually carry different fields. Project each branch independently — don't fetch the whole union just because one tag needs an extra field.
const shipmentGetPalletStateProjection = S.Union([
PalletInitialState.mapFields(Struct.pick(["_tag", "dimensions"])),
PalletReadyState.mapFields(Struct.pick(["_tag", "dimensions", "palletLabel"])),
PalletLabelCreatedState.mapFields(Struct.pick(["_tag", "dimensions", "palletLabel"])),
PalletPrintedState.mapFields(Struct.pick(["_tag", "dimensions", "palletLabel"]))
])See api/src/<workflow>/ShipList.Controllers.ts (Get, ReprintLabel, ReprintTransferList).
Each branch lists only the fields the render path reads on that tag. _tag is always picked so union discrimination still works after decode.
- Build the branch list from "what does the consumer read when
_tag === X?", not "what's in the schema." - Always include the discriminator (
_tag). - Inline
S.Union([...])inside the parent projection — no need to export it unless reused.
repo.get(id) reads the full document. If the handler only touches a few fields — or only needs the row to exist — use repo.query(Q.where("id", id), Q.one, Q.project(...)).
// verify shipment exists — no field is read afterward
yield* shipmentRepo.query(
Q.where("id", shipmentId),
Q.one,
Q.project(S.toEncoded(Shipment.mapFields(Struct.pick(["id"]))), "project")
)See api/src/<workflow>/ShipList.Controllers.ts (PrintTransferList).
const shipment = yield* shipmentRepo.query(
Q.where("id", shipmentId),
Q.one,
Q.project(shipmentGetProjection, "project")
)const shipment = yield* shipmentRepo.get(shipmentId)
// only `shipment.cdcAddress.city` and `shipment.state.labelUrl` are used belowProblems:
- pulls every nested array (orderIds, full pallet list, full state) from Cosmos
- runs the full document decoder — including any
S.transformthat fans out to resolvers (e.g.UserFromId→GetUserByIdpercreatedBy) - couples the handler to fields it never reads
- Default to
Q.where("id", x), Q.one, Q.project(...). - Keep
repo.getfor cases that genuinely need the whole document (writes that load → modify → save, or callers that hand the row to a generic renderer).
When the handler's response is the projected shape, decoding the query result into a View only to re-encode it for the wire is wasted work — and any resolver-backed transform (e.g. UserFromId → DB lookup per user id) fires during that decode.
// resources/PackList.ts
export class OrderView extends S.Opaque<OrderView>()(S.Struct({
...Struct.omit(Order.fields, ["state"]),
state: S.Union([...]),
packages: S.Array(S.Union([PackageView, BuildingBlockView])).withConstructorDefault
})) {}
// controllers
List: {
raw: (_) =>
Effect.gen(function*() {
const items = yield* orderRepo.query(
Q.where(...),
Q.project(S.toEncoded(OrderView), "project")
)
return { items }
})
}See api/src/<workflow>/PackList.Controllers.ts and Order.Controllers.ts (Get).
Q.project(S.toEncoded(OrderView), "project") tells Cosmos to return rows already shaped to the encoded OrderView. raw: on the handler returns them straight to the transport — no decode pass, so resolver-backed transforms never run.
const order = yield* orderRepo.query(Q.where("id", id), Q.one)
return { ...order, carrier: Order.carrier(order) }Problems:
- decodes the row into the full
Orderschema — every transform fires (includingUser.resolverfor eachpackage.createdBy) - re-encodes to ship over RPC
- response shape ends up coupled to whatever the full
Orderdecodes to
- If the response shape == the projected shape, pair
Q.project(S.toEncoded(View), "project")withraw:handlers. - If the handler genuinely needs the decoded form (e.g. it inspects branded values or runs domain logic on the row), keep the normal decode path.
- Watch for resolver-backed transforms in the schema (
UserFromId, anything with a.resolver). They are the strongest signal thatraw:+ encoded projection pays off.
A schema may rename a field on encode (createdBy ⇄ createdById in the Cosmos document). mapFields(Struct.pick(...)) produces a new schema and does not carry over the parent's S.encodeKeys mapping. Without re-applying, the projected decoder looks for createdBy in the raw doc, finds nothing, and fails.
BuildingBlockPallet
.to
.mapFields(flow(
Struct.pick(["id", "createdBy", "createdAt", "packSpotId", "state"])
))
.pipe(S.encodeKeys({ createdBy: "createdById" }))See api/src/<workflow>/BuildingBlockPallet.Controllers.ts and ShipList.Controllers.ts.
- Whenever you
mapFields(Struct.pick(...))on a schema withencodeKeys, re-apply the relevant mappings on the projected schema. - Only the keys that survived the
pickneed re-mapping.
Static helpers (Model.render, Model.palletNo, etc.) often only read a couple of fields. Type them as Pick<Model, "fieldA" | "fieldB"> so projected shapes still satisfy them without casts.
static readonly render = (
pallet: Pick<BuildingBlockPallet, "createdBy" | "packSpotId">,
cdcAddress: Address,
palletNo: number
) => ...See api/src/<workflow>/models/packages.ts.
- When a helper is read-only and touches a subset of fields, widen the input to
Pick<...>. - Otherwise the helper forces every caller to pass the full document, defeating the projection.
Before merging a repo query, ask:
- Can this count/existence check stay in the database?
- Can this endpoint page earlier?
- Can I project fewer fields?
- Can I replace in-memory aggregation with
Q.projectComputed(...)? - Am I scanning rows in JS/TS for something the query already knows?
- Is the query result shape exactly the response shape, or at least the smallest useful intermediate shape?
- If the row carries a state union, am I projecting each branch independently?
- Am I calling
repo.getwhen a projectedrepo.query(..., Q.one, Q.project(...))would do — or when the handler only needs existence? - Does the response shape equal the projected shape? If so, use
raw:+Q.project(S.toEncoded(View))so no decode runs (skips resolver fanout likeUserFromId). - Did
mapFields(Struct.pick(...))drop a parentS.encodeKeysmapping I need to re-apply? - Do the static helpers I call on the projected row accept
Pick<...>, or are they forcing the full document?
- Index
- Import Rules
- Resource & Controller Layout
- Command Pattern
- Command Input Validation
- Query Shape: List vs Get
- Database Query Guidelines
- List Layout
- Streams & Progress
- Vue Conventions
- E2E State Pattern
- E2E
- E2E Toast Wait Audit
- Flow Documentation
- (project-local — create
flows/when first workflow lands)