joins
| Subpath | (currently always-core; a dedicated @noy-db/hub/joins subpath is a planned follow-up) |
| Factory | withJoins()(planned) |
| Cluster | A — Read & Query |
What it does
A collection can declare a foreign-key field with ref() — for example an invoice's clientId pointing at the clients collection. .join() is the query-builder method that resolves one of those declared references and attaches the target record onto the result under an alias you choose, so invoice.client.name is available without a second round trip. Joins run inside the hub, after decryption — the storage backend only ever sees ciphertext and never runs the query itself.
You want it for parent-record lookups that follow an existing reference: reports that need the client's name next to each invoice, multi-step navigation (invoice → client → region), or a UI pane that should re-render whenever either the invoice or its client changes. For pairing rows that have no declared reference (every worker × every pay period), use cross-join instead.
Joins are part of the core query builder today — there is no with*() factory to opt into and no bundle cost to avoid. The catalog reserves a dedicated @noy-db/hub/joins subpath and a withJoins() factory (~470 LOC) for a future extraction, but until that lands .join() is simply available on every collection.query().
Opt in
No opt-in is needed for .join() itself. Declare the reference when you create the collection, with ref():
import { createNoydb, ref } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
type Client = { id: string; name: string; tier: 'standard' | 'premium' }
type Invoice = { id: string; amount: number; status: 'draft' | 'open' | 'paid'; clientId: string }
const db = await createNoydb({ store: toMemory(), user: 'alice', secret: 'a-strong-secret' })
const vault = await db.openVault('acme')
const clients = vault.collection<Client>('clients')
const invoices = vault.collection<Invoice>('invoices', {
refs: { clientId: ref('clients') },
})
Usage
Single-FK join
query.join(field, { as }) resolves the declared reference and attaches the target record under as. toArray() is synchronous — it reads the already-decrypted in-memory cache, same as the rest of the query basics terminals:
await clients.put('cli-A', { id: 'cli-A', name: 'Acme Corp', tier: 'premium' })
await invoices.put('inv-1', { id: 'inv-1', amount: 1200, status: 'paid', clientId: 'cli-A' })
const enriched = invoices.query()
.where('status', '==', 'paid')
.join<'client', Client>('clientId', { as: 'client' })
.toArray()
// → 1 row, shaped like:
[
{
"id": "inv-1",
"amount": 1200,
"status": "paid",
"clientId": "cli-A",
"client": { "id": "cli-A", "name": "Acme Corp", "tier": "premium" }
}
]
The Right type parameter (Client above) is optional — omit it and the joined field types as unknown. .join() requires a query built via collection.query(); the join planner picks a nested-loop strategy when the target source supports lookupById (the normal case for a Collection target) or a hash strategy otherwise, auto-selected unless you force one with { strategy: 'hash' }.
Multi-FK chaining
Chain .join() calls to resolve more than one reference on the same left record — each call adds its own alias. A collection's refs are fixed at the point you first open it, so declare every FK you plan to join up front:
type Category = { id: string; label: string }
type FullInvoice = Invoice & { categoryId: string }
const categories = vault.collection<Category>('categories')
const fullInvoices = vault.collection<FullInvoice>('full-invoices', {
refs: { clientId: ref('clients'), categoryId: ref('categories') },
})
await categories.put('cat-1', { id: 'cat-1', label: 'Consulting' })
await fullInvoices.put('inv-2', { id: 'inv-2', amount: 800, status: 'open', clientId: 'cli-A', categoryId: 'cat-1' })
const rows = fullInvoices.query()
.join<'client', Client>('clientId', { as: 'client' })
.join<'category', Category>('categoryId', { as: 'category' })
.toArray()
// → [{ ..., client: { id: 'cli-A', name: 'Acme Corp', tier: 'premium' },
// category: { id: 'cat-1', label: 'Consulting' } }]
Post-join filtering on either alias's fields uses the general-purpose .filter():
invoices.query()
.join<'client', Client>('clientId', { as: 'client' })
.filter(row => row.client?.tier === 'premium')
.toArray()
Dangling references
ref() declares one of three enforcement modes — strict (default), warn, cascade — covered in full on Foreign refs. .join() respects whichever mode the field was declared with when a left record's FK value points at a target id that no longer exists:
strict— throwsDanglingReferenceError.warn— attachesclient: nulland emits a one-shotconsole.warnper unique dangling(field, target, id)triple.cascade— attachesclient: nullsilently (cascade deletes are expected to clean these up; a dangling ref visible at read time is a mid-flight cascade or a pre-existing orphan, not a DSL error).
const looseInvoices = vault.collection<Invoice>('invoices-loose', {
refs: { clientId: ref('clients', 'warn') },
})
await looseInvoices.put('inv-9', { id: 'inv-9', amount: 50, status: 'open', clientId: 'missing' })
const rows = looseInvoices.query()
.join<'client', Client>('clientId', { as: 'client' })
.toArray()
// → [{ id: 'inv-9', amount: 50, status: 'open', clientId: 'missing', client: null }]
// console.warn: one-shot dangling-ref warning for clientId → clients/missing
A FK field that is null or undefined is not a dangling ref — it's "no reference at all", and is always allowed regardless of mode.
Reactive joins
query.live() returns a LiveQuery that re-runs the query and updates .value whenever a source changes. For a joined query it subscribes to the left collection and every join target, deduplicated by collection name — so a client update re-fires an invoice query that joined that client:
const live = await invoices.query()
.join<'client', Client>('clientId', { as: 'client' })
.live()
live.subscribe(() => render(live.value))
await clients.put('cli-A', { id: 'cli-A', name: 'Acme Corporation', tier: 'premium' })
// → live.value[0].client.name is now "Acme Corporation" — the right-side put re-fired the query
live.stop()
Under strict mode, a right-side delete that leaves a dangling ref makes the re-run throw; LiveQuery catches it and stores it on .error instead of tearing down the subscription — check .error after each notification. Always call .stop() when you're done; it tears down every upstream subscription. See live for the reactive primitive itself.
API
query.join(field, opts)
join<As extends string, Right = unknown>(
field: string,
opts: { as: As; strategy?: 'hash' | 'nested'; maxRows?: number }
): Query<T & Record<As, Right | null>>
Resolves the ref() declared on field and attaches the target record under opts.as. Returns a new immutable Query — the original is unchanged. Throws if the query wasn't built via collection.query() (no join context), or if field has no matching refs: { [field]: ref(...) } declaration.
const rows = invoices.query().join<'client', Client>('clientId', { as: 'client' }).toArray()
// → [{ ...invoice, client: Client | null }, ...]
| Option | Type | Default | Purpose |
|---|---|---|---|
as | string | required | Field name the joined record is attached under. |
strategy | 'hash' | 'nested' | auto-selected | Force the join planner's strategy; mainly useful for tests. |
maxRows | number | 50_000 (DEFAULT_JOIN_MAX_ROWS, exported from @noy-db/hub) | Per-leg row ceiling — see below. |
Chain .join() again to resolve additional FKs on the same left record — each call adds one alias to the result type.
Errors
Both are importable from @noy-db/hub:
JoinTooLargeError(codeJOIN_TOO_LARGE) — either side of a join leg exceeds the row ceiling. Carriesside('left' | 'right'),leftRows,rightRows,maxRows.DanglingReferenceError(codeDANGLING_REFERENCE) — astrict-mode ref points at a target id that doesn't exist. Carriesfield,target,refId.
Limits & gotchas
- Row ceiling, checked before allocation.
JoinTooLargeErrorfires at 50,000 rows per side by default; override per-call with{ maxRows }if you've measured the query is safe larger. - Same vault only. A join target must be reachable in the same vault; there is no cross-vault join — use
db.queryAcross()for that (see query basics). ref()required..join(field, ...)throws iffieldhas no matchingrefs: { [field]: ref(...) }declaration on the left collection — it does not infer relationships.- Partition-awareness is plumbed but dormant. Every join leg internally carries a
partitionScopefield that is always'all'today; the executor doesn't read it yet. .live()onstrict-mode joins can error. A right-side delete that leaves a dangling ref makes the live re-run throw; the error surfaces onLiveQuery.errorrather than propagating out of the change handler.
See also
- Foreign refs — declaring
ref()and the three enforcement modes - cross-join — pairing rows with no declared reference between them
- indexing — the indexed nested-loop join strategy uses a collection's index on the join field
- aggregate — joined rows can be grouped and aggregated
- live — the reactive primitive
.live()returns - Query basics —
where/filter/orderByanddb.queryAcross() - Proven by showcase
12-with-joins.