materialized-views
| Subpath | @noy-db/hub/materialized-views (also re-exported from @noy-db/hub) |
| Factory | withMaterializedView() |
| Layer | with-formula |
What it does
A materialized view takes a query — a filter, a groupBy + aggregate, a join, or a union of
several source collections — and persists its result as a normal, queryable
collection that is kept fresh as the underlying data changes. Instead of re-running an expensive aggregation on every
read, you register the query once with withMaterializedView() and read the precomputed output
collection like any other.
Reach for it when a read is expensive to compute on the fly (a groupBy + sum over a large
collection, a filtered subset you query often) and you'd rather pay the cost on write. Each row
written to the output travels through the vault's normal encrypt-then-store pipeline, so a
materialized view is encrypted the same way as everything else in the vault — the storage backend
never sees which rows came from a query versus a direct put.
Prerequisites
- None beyond the per-strategy config — but note two config rules that read like prerequisites: a per-arm
join's right-side collection must be listed insources(or registration throws), andmoneyFieldsis UNION-mode-only and requiresaggregatein the same strategy.
Opt in
import { createNoydb, withMaterializedView } from '@noy-db/hub'
import { sum, count } from '@noy-db/hub/reduce'
type Compensation = { id: string; clientId: string; taxAmount: number }
const pnd1 = withMaterializedView<{ clientId: string; tax: number; count: number }>({
name: 'pnd1',
query: (db) =>
db.collection<Compensation>('compensations')
.query()
.groupBy('clientId')
.aggregate({ tax: sum('taxAmount'), count: count() }),
rowKey: (row) => row.clientId,
sources: ['compensations'], // required when query() returns an aggregate
refresh: 'eager',
})
const db = await createNoydb({
store: /* your NoydbStore */,
user: 'alice',
secret: 'a-strong-secret',
materializedViewStrategies: [pnd1],
})
materializedViewStrategies is the only way to register a materialized view — without it, no
withMaterializedView() strategy runs.
Usage
Registering a query-form view and reading its output
The query callback runs at registration and again on every refresh. Its result — a plain
Query<T>, or a groupBy().aggregate() chain — is read out through the normal collection API,
under the output collection name (name by default):
const vault = await db.openVault('demo')
await vault.collection<Compensation>('compensations').put('c-1', {
id: 'c-1', clientId: 'acme', taxAmount: 120,
})
await vault.collection<Compensation>('compensations').put('c-2', {
id: 'c-2', clientId: 'acme', taxAmount: 80,
})
const row = await vault.collection('pnd1').get('acme')
// → { clientId: 'acme', tax: 200, count: 2, _materializedFrom: { mvName: 'pnd1', ... } }
UNION form — combining several source collections
unionSources reads rows from several sibling collections, projects each through a per-arm map
into one unified row shape, concatenates the streams, then optionally groupBy + aggregates the
result. It is mutually exclusive with query — a strategy declares exactly one.
import { withMaterializedView } from '@noy-db/hub'
import { sum } from '@noy-db/hub/reduce'
withMaterializedView<{ period: string; vat: number }>({
name: 'monthlyVat',
unionSources: [
{ collection: 'taxReceipts', map: r => ({ period: r.issuedAt.slice(0, 7), vat: r.vatAmount }) },
{ collection: 'creditNotes', map: r => ({ period: r.issuedAt.slice(0, 7), vat: -r.vatAmount }) },
],
groupBy: 'period',
aggregate: { vat: sum('vat') },
rowKey: row => row.period,
refresh: 'eager',
})
A single-arm union is also valid — it lets you map → group → aggregate over one collection with a
computed bucket key (e.g. a month sliced from a date field), which the query form's stored-field
groupBy can't express.
Triggering a manual refresh
For refresh: 'manual' views, only an explicit call re-materializes:
const { written, deleted, failed } = await vault.refreshView('pnd1')
// → { written: 12, deleted: 1, failed: 0 }
Throws a plain Error if name isn't a registered view; it's a no-op
({ written: 0, deleted: 0, failed: 0 }) if the vault has no materialized-view strategies
registered at all.
API reference
withMaterializedView(spec)
Registers a materialized view. Throws ValidationError for a missing name or rowKey, or an
invalid refresh value; throws MaterializedViewConfigError for invalid UNION-form combinations
(see Limits below).
| Field | Required? | Meaning |
|---|---|---|
name | yes | Stable identity. Default output collection name. |
query: (db) => Query<TRow> | yes (unless unionSources) | Materialized query, run at registration and on every refresh. |
unionSources: UnionSource[] | yes (unless query) | Sibling collections to read + a per-arm map into the unified row shape. Mutually exclusive with query. |
rowKey: (row) => string | yes | Stable id derivation. Explicit — there is no default. |
refresh: 'eager' | 'lazy' | 'manual' | yes | See refresh modes below. |
sources?: string[] | only when query() returns an aggregate/groupBy | Explicit dependencies; a plain Query<T> is auto-analyzed. Not needed for the union's own arm collections (those come from unionSources[].collection), but any collection listed here is added to the dependency set — required for a per-arm join's right-side collection, or registration throws MaterializedViewConfigError. |
groupBy?: string | string[] | UNION mode only | Group-key field(s) applied to the concatenated mapped-row stream. |
aggregate?: ReduceSpec | UNION mode only | Reduction applied per group after groupBy. |
moneyFields?: Record<string, MoneyDescriptor> | UNION mode only, requires aggregate | Makes sum/min/max over the named fields exact BigInt arithmetic instead of float. |
i18nLocale?: string | Works in query-form and UNION mode; requires i18nFields | Locale used to resolve i18nText group-key fields to stable strings before bucketing. |
i18nFields?: Record<string, I18nTextDescriptor> | Works in query-form and UNION mode | i18n descriptors for the fields named in i18nLocale resolution. |
predicates?: { [name]: { hash, fn } } | no | Declared deterministic predicates callable from query() via .wherePredicate(name, ctx?). Not supported on UNION strategies. |
output?: { collection?, partition? } | no | Custom output collection name; partition is the same-collection cycle-break discriminator. |
onEmpty?: 'delete' | 'keep' | no, default 'delete' | What to do when a refresh yields zero rows for a previously-emitted key. |
strict?: boolean | no, default false | Re-throw row-write failures so a wrapping withTransactions can roll back the source write. |
maxRows?: number | no, default 100_000 | Row-count ceiling; throws MaterializedViewTooLargeError before any writes when exceeded. |
refresh
eager— every write or delete on a source collection re-runs the query inside the same transaction, for every view that depends on it. Outputs land synchronously. Pairs withwithTransactions+strict: truefor atomic source-and-view updates.lazy— source writes and deletes mark the view stale (in memory, not persisted across a vault close). The next read (.get(id)or.list()) on the view's output collection triggers the re-materialize before returning. Good for expensive views with read-light workloads.manual— onlyvault.refreshView(name)triggers re-materialization. Useful for time-dependent queries whose predicate context changes externally, or very expensive views that opt out of the source-write hook entirely.
vault.refreshView(name)
refreshView(name: string): Promise<{ written: number; deleted: number; failed: number }>
Runs the executor for the named view and returns the write/tombstone/failure counts.
Declared deterministic predicates
Some filters can't be expressed as .where(field, op, value) — date arithmetic, multi-field
conditions, references to external time. predicates declares named functions the query()
callback can invoke via .wherePredicate(name, ctx?):
import { withMaterializedView } from '@noy-db/hub'
type Invoice = { id: string; status: string; dueDate: string }
withMaterializedView<Invoice>({
name: 'overdue',
predicates: {
isOverdue: {
hash: 'is-overdue-v1',
fn: (inv, ctx) => inv.status === 'open' && inv.dueDate < (ctx as { asOf: string }).asOf,
},
},
query: (db) => db.collection<Invoice>('invoices').query()
.wherePredicate('isOverdue', { asOf: '2026-05-20' }),
rowKey: (r) => r.id,
refresh: 'eager',
})
The predicate's hash plus a canonical-JSON hash of the ctx argument both fold into the view's
queryHash (stamped into _materializedFrom, below). Bumping hash — because the function's
meaning changed — or changing ctx forces a refresh on the next visit. Consumer responsibility:
bump hash whenever the predicate's semantics change; failing to do so after a non-equivalent
change leaves stale rows around until the next explicit refresh. Calling .wherePredicate() on a
Query<T> that wasn't produced by an MV declaring that predicate throws
.wherePredicate("name"): no predicates registered on this Query.
_materializedFrom
Every materialized row carries:
{
"_materializedFrom": {
"mvName": "pnd1",
"queryHash": "9f2a1c7e4b0d3f8a1e6c5b2d7f4a9e0c1b3d6f8a2e5c7b0d4f1a9e3c6b8d0f2a",
"sourceVersions": { "compensations": 7 },
"materializedAt": "2026-05-21T00:00:00.000Z"
}
}
Lives inside the encrypted payload, not the unencrypted envelope — the storage backend cannot infer the view's dependency graph from listing records.
Errors
All extend NoydbError, importable from @noy-db/hub/materialized-views:
MaterializedViewCycleError(path[])— the MV graph contains a cycleMaterializedViewSourceUnknownError(mv, source)—sourcesnames a collection the vault doesn't knowMaterializedViewTooLargeError(mv, rowCount, limit)—maxRowsexceededMaterializedViewConfigError(detail)— invalid strategy shape (see Limits below)
Limits & gotchas
queryandunionSourcesare mutually exclusive. A strategy must declare exactly one; registration throwsMaterializedViewConfigErrorif both or neither are set.rowKeyhas no default. It's required on every strategy, query-form or UNION. Since0.7.0-pre.14an absentrowKeyand a wrong-typed one are distinct errors: passing a field name — the shape every neighbouring option takes — used to reportrowKey is required, which names absence and sends you looking for a missing line. It now says it must be a function, names the type it received, and shows the fix.- A
groupByon a VIRTUAL computed field is refused at registration — but the coverage is partial, and the boundary is worth knowing (noy-db#1269). A virtual field is evaluated on read and never stored, so the MV pipeline read the stored row, found nothing, and bucketed every row under anundefinedkey: a well-formed aggregate carrying a wrong number, on the one path where nobody re-checks the arithmetic.query().where()already refused the same field withFieldNotQueryableError, so the two halves of what reads as one query layer disagreed.
What decides coverage is where the grouping is expressed — not which source form you use:how the view groups refused? the declarative top-level groupBy: 'field'(or an array), as the UNION form takesyes .groupBy('field')inside aquery:callbackno — silent
The guard runs when the source collection is registered, walks the views already recorded against it, and readsspec.groupByand nothing else. A.groupBy()call inside a query callback lives in the plan the callback returns and never populates that field, so there is nothing for the guard to compare — declaringsourcesdoes not change this. The check also only sees views already registered against that source, the same ordering the neighbouring tiers+crdt guard has.
So the residue is real and worth knowing: usemode: 'materialized'for anything you group by, and prefer the declarativegroupBywhen you want the guard to be able to see it. sourcesis required for aggregates. The dependency analyzer can extract source collections from a plainQuery<T>automatically, but not through agroupBy().aggregate()chain — declaresourcesexplicitly or the analyzer won't know what to watch.- UNION mode ignores
sourcesfor its own arms (dependencies come fromunionSources[].collection), but a per-armjoinresolves a right-side collection the union dependency set doesn't include — that collection must still be listed insourcesor registration throwsMaterializedViewConfigError. predicatesis not supported on UNION strategies — UNION mode doesn't build aQuery<T>chain, so.wherePredicate()has nothing to call into.moneyFieldsis UNION-only and requiresaggregate— the query form has noaggregatefield of its own (itsgroupBy().aggregate()chain runs insidequery()), somoneyFieldswithoutaggregatethrowsMaterializedViewConfigError.i18nLocale/i18nFieldswork on both query-form and UNION views — either resolvesi18nTextgroup-key fields to a stable string before bucketing.i18nLocalewithouti18nFieldsthrowsMaterializedViewConfigError.- Same-collection views need a partition filter. A view whose output collection equals one of
its sources is only allowed when
output.partitionprovably excludes the source filter (==against a different value,!=against the value, or aninlist that doesn't contain it). Otherwise it's a cycle andvault.openVaultthrowsMaterializedViewCycleError. onEmpty: 'delete'bypassesonDeleteguards. Tombstoning a row that dropped out of the result set goes through the same system-internal delete path as derivations, so a guard you registered for human writes doesn't block housekeeping.- Lazy staleness is in-memory only. A vault close-and-reopen loses pending stale bits; rows
with a matching
queryHashare treated as fresh until the next source write or explicitrefreshView.
See also
- derivations — the row-level sibling: projects one source record into typed outputs, instead of materializing a whole query
- overlay-views — lets an operator override specific rows of a materialized view's output without losing the underlying computation
- aggregate — the
groupBy+ reducer machinery a query-form view'squerybuilds on - joins — the
ref()-driven.join()that per-arm UNION joins reuse - transactions — required for
strict: truerollback - Services overview · Orientation map
- Proven by showcases
81-with-mv-eager,82-with-mv-lazy, and86-with-union-mv.