stable

materialized-views

withMaterializedView() persists a query's result — filter, groupBy, aggregate, join — as a live collection with eager, lazy, or manual refresh.
Subpath@noy-db/hub/materialized-views (also re-exported from @noy-db/hub)
FactorywithMaterializedView()
Layerwith-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 in sources (or registration throws), and moneyFields is UNION-mode-only and requires aggregate in 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).

FieldRequired?Meaning
nameyesStable 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) => stringyesStable id derivation. Explicit — there is no default.
refresh: 'eager' | 'lazy' | 'manual'yesSee refresh modes below.
sources?: string[]only when query() returns an aggregate/groupByExplicit 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 onlyGroup-key field(s) applied to the concatenated mapped-row stream.
aggregate?: ReduceSpecUNION mode onlyReduction applied per group after groupBy.
moneyFields?: Record<string, MoneyDescriptor>UNION mode only, requires aggregateMakes sum/min/max over the named fields exact BigInt arithmetic instead of float.
i18nLocale?: stringWorks in query-form and UNION mode; requires i18nFieldsLocale used to resolve i18nText group-key fields to stable strings before bucketing.
i18nFields?: Record<string, I18nTextDescriptor>Works in query-form and UNION modei18n descriptors for the fields named in i18nLocale resolution.
predicates?: { [name]: { hash, fn } }noDeclared deterministic predicates callable from query() via .wherePredicate(name, ctx?). Not supported on UNION strategies.
output?: { collection?, partition? }noCustom 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?: booleanno, default falseRe-throw row-write failures so a wrapping withTransactions can roll back the source write.
maxRows?: numberno, default 100_000Row-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 with withTransactions + strict: true for 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 — only vault.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 cycle
  • MaterializedViewSourceUnknownError(mv, source) — sources names a collection the vault doesn't know
  • MaterializedViewTooLargeError(mv, rowCount, limit) — maxRows exceeded
  • MaterializedViewConfigError(detail) — invalid strategy shape (see Limits below)

Limits & gotchas

  • query and unionSources are mutually exclusive. A strategy must declare exactly one; registration throws MaterializedViewConfigError if both or neither are set.
  • rowKey has no default. It's required on every strategy, query-form or UNION. Since 0.7.0-pre.14 an absent rowKey and a wrong-typed one are distinct errors: passing a field name — the shape every neighbouring option takes — used to report rowKey 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 groupBy on 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 an undefined key: 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 with FieldNotQueryableError, 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 groupsrefused?
    the declarative top-level groupBy: 'field' (or an array), as the UNION form takesyes
    .groupBy('field') inside a query: callbackno — silent

    The guard runs when the source collection is registered, walks the views already recorded against it, and reads spec.groupBy and 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 — declaring sources does 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: use mode: 'materialized' for anything you group by, and prefer the declarative groupBy when you want the guard to be able to see it.
  • sources is required for aggregates. The dependency analyzer can extract source collections from a plain Query<T> automatically, but not through a groupBy().aggregate() chain — declare sources explicitly or the analyzer won't know what to watch.
  • UNION mode ignores sources for its own arms (dependencies come from unionSources[].collection), but a per-arm join resolves a right-side collection the union dependency set doesn't include — that collection must still be listed in sources or registration throws MaterializedViewConfigError.
  • predicates is not supported on UNION strategies — UNION mode doesn't build a Query<T> chain, so .wherePredicate() has nothing to call into.
  • moneyFields is UNION-only and requires aggregate — the query form has no aggregate field of its own (its groupBy().aggregate() chain runs inside query()), so moneyFields without aggregate throws MaterializedViewConfigError.
  • i18nLocale/i18nFields work on both query-form and UNION views — either resolves i18nText group-key fields to a stable string before bucketing. i18nLocale without i18nFields throws MaterializedViewConfigError.
  • Same-collection views need a partition filter. A view whose output collection equals one of its sources is only allowed when output.partition provably excludes the source filter (== against a different value, != against the value, or an in list that doesn't contain it). Otherwise it's a cycle and vault.openVault throws MaterializedViewCycleError.
  • onEmpty: 'delete' bypasses onDelete guards. 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 queryHash are treated as fresh until the next source write or explicit refreshView.

See also