stable

Foreign refs

Foreign-key references between collections: `ref()`/`refArray()` with strict/warn/cascade integrity and the `checkIntegrity()` audit. Always-on kernel.
Subpath@noy-db/hub (root export — ref / refArray / vault.link)
Factory— always-on kernel; no with*() to opt into
Layerwith-lookup
Kindreference — the always-on relational primitives
Sourcekernel/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.

ModeOn put() with missing targetOn delete of the targetOn dangling .join() read
'strict' (default)Throws RefIntegrityErrorThrows RefIntegrityError while referencing records existThrows DanglingReferenceError
'warn'SucceedsSucceeds — 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 joins
  • vault.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's refId is 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"
    }
  ]
}

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 — a is an id in the a collection, b in the b collection (the two may be the same collection for self-links); of(id) matches either slot. a / b accept a plain collection name or a ref(target) descriptor (only its target is used — link sets manage their own integrity).
  • onDelete governs what happens to link rows when an endpoint record is deleted: 'cascade' (default) removes the touching link rows; 'strict' throws LinkIntegrityError and blocks the delete; 'warn' leaves orphan rows, surfaced by vault.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 / throws RefScopeError at declaration time; for cross-vault reads use db.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