Foreign refs
| Subpath | @noy-db/hub (root export — ref / refArray / vault.link) |
| Factory | — always-on kernel; no with*() to opt into |
| Layer | with-lookup |
| Kind | reference — the always-on relational primitives |
| Source | kernel/refs.ts (managed link sets: with-shape/links/) |
What it is
Records in collections often point at each other. Refs are foreign-key style references between collections, declared as metadata. They power delete-time integrity enforcement (strict / warn / cascade), the vault.checkIntegrity() audit, and .join() in the query DSL.
For validating the shape of each record, see Schema.
Declaring refs
A collection declares its references in the refs option. Each key is a top-level field on the record; each value is a ref(target, mode) descriptor naming the target collection:
import { ref } from '@noy-db/hub'
const clients = vault.collection<Client>('clients')
const invoices = vault.collection<Invoice>('invoices', {
refs: {
clientId: ref('clients'), // default mode: 'strict'
categoryId: ref('categories', 'warn'),
parentId: ref('invoices', 'cascade'), // self-reference is allowed
},
})
The signature is ref(target: string, mode: RefMode = 'strict') where RefMode is 'strict' | 'warn' | 'cascade'. A null / undefined field value means "no reference" and is always allowed, in every mode.
| Mode | On put() with missing target | On delete of the target | On dangling .join() read |
|---|---|---|---|
'strict' (default) | Throws RefIntegrityError | Throws RefIntegrityError while referencing records exist | Throws DanglingReferenceError |
'warn' | Succeeds | Succeeds — orphans reported by vault.checkIntegrity() | Joined value is null; one-shot console warning |
'cascade' | Succeeds (unchecked, like warn) | Deletes every referencing record (cycle-safe — mutual cascades terminate) | Joined value is null, silently |
RefIntegrityError carries structured fields — collection, id, field, refTo, refId — so a UI can render "client X cannot be deleted because invoices 1, 2 and 3 reference it" instead of a bare string.
Refs are used by:
query().join(field, { as })— resolve the reference into an attached record; see joinsvault.checkIntegrity()— full ref-graph audit (below)- delete-time enforcement — every
Collection.delete()checks inbound refs before removing the record
Array refs (refArray)
refArray(target, mode) gives a field holding an array of ids the same integrity semantics, applied per element — a lightweight many-to-many (order↔product, post↔tag):
import { refArray } from '@noy-db/hub'
const orders = vault.collection<Order>('orders', {
refs: { productIds: refArray('products', 'warn') },
})
await orders.put('o1', { id: 'o1', productIds: ['p1', 'p2'] })
The signature mirrors ref: refArray(target: string, mode: RefMode = 'strict'). Per-element semantics:
- strict —
put()rejects if any element's target is missing (the error'srefIdis the offending element); deleting a target is blocked while any record's array still contains its id. - warn — both succeed;
vault.checkIntegrity()reports one violation per dangling element. - cascade — deleting a target deletes every record whose array contains its id (cycle-safe, like scalar cascade);
put()is unchecked.
An empty array or a null / undefined field is allowed (no links). Non-array values, or elements that are not strings/numbers, are integrity violations.
Auditing the ref graph
vault.checkIntegrity() walks every collection with declared refs (and every managed link set) and returns the full violation list instead of throwing:
const { violations } = await vault.checkIntegrity()
{
"violations": [
{
"collection": "invoices",
"id": "inv-7",
"field": "categoryId",
"refTo": "categories",
"refId": "cat-99",
"mode": "warn"
}
]
}
Managed many-to-many links
For a full M:N relationship — queryable from both sides, with per-link metadata — declare a managed junction. vault.link(name, spec) registers a dedicated encrypted _links_<name> collection between two endpoint collections; vault.links(name) returns the handle that operates it:
import { ref } from '@noy-db/hub'
vault.link('saleLineLinks', {
a: ref('saleLines'), // or the plain string 'saleLines'
b: ref('purchaseLines'),
onDelete: 'cascade', // default
})
const links = vault.links('saleLineLinks')
await links.connect('s1', 'p1', { matchedQty: 6 }) // validates both endpoints exist
await links.of('s1') // → [{ a: 's1', b: 'p1', meta: { matchedQty: 6 } }]
await links.has('s1', 'p1') // → true
await links.disconnect('s1', 'p1') // idempotent — no-op if absent
await links.list() // → all rows in the set
- Slot-typed —
ais an id in theacollection,bin thebcollection (the two may be the same collection for self-links);of(id)matches either slot.a/baccept a plain collection name or aref(target)descriptor (only itstargetis used — link sets manage their own integrity). onDeletegoverns what happens to link rows when an endpoint record is deleted:'cascade'(default) removes the touching link rows;'strict'throwsLinkIntegrityErrorand blocks the delete;'warn'leaves orphan rows, surfaced byvault.checkIntegrity().- Re-declaring the same link with identical spec is a no-op; a conflicting re-declaration throws.
- Rows are encrypted under their own
_links_<name>data-encryption key, like any other collection; the name is reserved —vault.collection('_links_anything')is rejected.
refArray vs vault.link. Reach for refArray (an id-array on one record) for simple tag-like sets. Reach for vault.link when the links themselves are entities — queryable from both sides, annotatable with metadata, cascading. There is no query-DSL join through a link set: read link rows with of(id) / list() and fetch the endpoint records from their collections. See links for the managed link-set API in depth.
Edge cases
- Refs are intra-vault. A target name containing
/throwsRefScopeErrorat declaration time; for cross-vault reads usedb.queryAcross(). - Ref fields are top-level only — dotted paths are not supported. Target names cannot be empty or start with
_(reserved for internal collections).
See also
- Schema — Standard Schema validation on collections
- Core 01 — Vault & Collection model
- Core 04 — Collection (Querying)
- joins —
.join()over declared refs - links — managed link sets in depth
- Proven by showcase
103-ref-array.