Query Engine
The query layer provides a consistent API to fetch data across base entities, module extensions, and custom fields. It is available via DI as queryEngine.
When the optional hybrid JSONB index is enabled and populated for a given entity, queries are routed through the index automatically; otherwise the engine falls back to a join-based plan. See JSONB indexing layer for the index design, backfill CLI, and performance tips.
Why
- Build generic list/detail APIs and UI that work for any entity.
- Centralize filtering, pagination, sorting, and selected fields.
- Safely join module-defined extensions and EAV custom fields.
Usage (DI)
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
import { E } from '@open-mercato/core/datamodel/entities'
import { id, email, name } from '@open-mercato/core/datamodel/entities/user'
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
import { SortDir } from '@open-mercato/shared/lib/query/types'
export async function listUsers(container: AppContainer) {
const query = container.resolve<QueryEngine>('queryEngine')
return await query.query(E.auth.user, {
fields: [id, email, name, 'cf:vip'],
includeExtensions: true, // joins registered extensions
includeCustomFields: true, // auto-discovers keys via custom_field_defs (tenant-scoped only)
// Filters: array syntax (legacy)
filters: [
{ field: 'cf:vip', op: 'eq', value: true },
{ field: email, op: 'ilike', value: '%@acme.com' },
],
sort: [{ field: email, dir: SortDir.Asc }],
page: { page: 1, pageSize: 25 },
// REQUIRED: tenant scope
tenantId: 'uuid-string-here',
// OPTIONAL: for base entities and virtual entities
organizationId: 'uuid-string-here',
})
}
Model
entity: anEntityIdstring of the form<module>:<entity>; used to resolve the base table and registered extensions.fields: base columns and/or custom fields prefixed ascf:<key>. Prefer importing base columns from@open-mercato/<pkg>/datamodel/entities/<entity>(e.g.,import { id, email } from '@open-mercato/core/datamodel/entities/user').includeExtensions:trueto include all linked extension entities; orstring[]of extension entity ids.includeCustomFields:trueto include all CFs; orstring[]of keys.filters: support base fields andcf:<key>.- Array syntax:
{ field, op, value }. - Object syntax (Mongo/Medusa style):
filters: {
title: { $ne: null },
email: { $ilike: '%@acme.com' },
'cf:vip': true, // shorthand for $eq
} - Aliases: for filters, you may also use
cf_<key>(e.g.,cf_priority), which is treated the same ascf:<key>. - Both syntaxes can be mixed, the engine normalizes them internally.
- Array syntax:
sort: base fields andcf:<key>. Use generated field constants andSortDir(e.g.,{ field: email, dir: SortDir.Asc }).page: paging options.tenantId: required. All queries must include tenant scope.organizationId: optional. Applied in addition totenantIdfor base entities and virtual entities.withDeleted: include soft-deleted rows whentrue. By default, when a base table has adeleted_atcolumn, queries exclude rows wheredeleted_atis not null.
Typing filters
You can get compile-time help for filters by using the generic Where type from @open-mercato/shared/lib/query/types:
import type { Where } from '@open-mercato/shared/lib/query/types'
// Define a field→type map for your query
type UserFields = {
id: string
email: string
name: string | null
created_at: Date
'cf:vip': boolean
}
const filters: Where<UserFields> = {
email: { $ilike: '%@acme.com' },
name: { $ne: null },
'cf:vip': true,
}
await query.query(E.auth.user, { filters })
If you don’t provide the generic, filters falls back to a permissive shape.
Custom fields integration
- Specify custom fields in the
fieldsarray using thecf:prefix (for example,cf:priority). They will be projected directly into the result objects. - Include custom fields in filters and sorts the same way:
{ field: 'cf:priority', op: 'eq', value: 'High' }. - Because custom fields are scoped per tenant, all queries must supply
tenantId. Organization filters are optional but recommended for back-office pages. - Use the
defineFieldsDSL in your module’sce.tsso that generators surface field metadata to UIs. Custom fields created through the admin UI appear automatically once they are published.
Implementation notes
BasicQueryEngineandHybridQueryEnginerequiretenantId.- Custom fields are tenant-scoped only: discovery and joins filter by
tenant_id;organization_idis not applied for CFs. - Custom (virtual) entity data is filtered by
tenant_idand, if provided, additionally byorganization_id. - Base entities apply
tenant_id(required) and optionalorganization_idwhen the column exists. When the base table exposesdeleted_at, rows with a non-null value are excluded unlesswithDeleted: trueis passed. - When we iterate:
- Read
modules.generated.tsto discoverentityExtensionsand join them. - Join
custom_field_valuesto surfacecf:*fields and filter/sort them efficiently; aggregate when multiple values exist. - Provide per-entity adapters if conventions differ from table naming.
- Read