links
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — link-set fields are always available. |
What it does
A link set is a real many-to-many relationship between two collections
— the kind of thing a relational database would model with a join table. vault.link(name, spec)
declares it; vault.links(name) gives you a handle to read and write its rows. Under the hood each
link set is its own dedicated collection, _links_<name>, encrypted under its own
per-collection key exactly like any other collection — the storage backend
never sees plaintext link data. It stays out of your JavaScript bundle until first use: the storage
engine behind the handle loads lazily on the first connect/disconnect/has/of/list call.
Reach for a link set when the relationship itself needs to be queried from either side or carry
its own data (a match quantity, a role, a timestamp). For the simpler case — one record holding an
array of ids it points at — the lighter-weight refArray
field descriptor is usually enough and doesn't need a separate collection.
Setup
There is no with*() strategy to enable — link sets are part of the always-on core, reachable from
any open vault. Declare one by name and endpoints, then get its handle:
import { createNoydb, ref } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({ store: toMemory(), user: 'alice', secret: 'alice-pass-2026-strong' })
const vault = await db.openVault('firm')
vault.link('saleLineLinks', {
a: ref('saleLines'), // or the plain string 'saleLines'
b: ref('purchaseLines'),
onDelete: 'cascade', // default; also 'strict' | 'warn'
})
const links = vault.links('saleLineLinks')
a and b are the two endpoint collections (they may be the same collection, for a self-link).
Each accepts a plain collection name or a ref(target) descriptor — only its target is used, since
a link set enforces its own integrity rather than reusing ref-mode checks. Re-declaring the same name
with an identical spec is a no-op; declaring it again with a different a, b, or onDelete throws
ValidationError. vault.links(name) throws ValidationError if name was never declared.
Usage
Connect two records and query from either side
const sales = vault.collection('saleLines')
const purchases = vault.collection('purchaseLines')
await sales.put('s1', { id: 's1', label: 'sale 1' })
await purchases.put('p1', { id: 'p1', label: 'purchase 1' })
await links.connect('s1', 'p1', { matchedQty: 6 }) // throws LinkEndpointError if either id is missing
await links.has('s1', 'p1')
// → true
await links.of('s1')
// → [{ a: 's1', b: 'p1', meta: { matchedQty: 6 } }]
await links.of('p1')
// → [{ a: 's1', b: 'p1', meta: { matchedQty: 6 } }] — matched on slot b, same row
await links.list()
// → every row in the set: [{ a: 's1', b: 'p1', meta: { matchedQty: 6 } }]
await links.disconnect('s1', 'p1') // idempotent — no-op if the link is already gone
There is no query-DSL .join() through a link set (.join() walks declared ref fields — see
Joins). Read link rows with of(id) / list(), then fetch the endpoint records
from their own collections.
Choose what happens when an endpoint is deleted
onDelete on the link spec governs every row touching the deleted record:
vault.link('strictLinks', { a: 'saleLines', b: 'purchaseLines', onDelete: 'strict' })
const strict = vault.links('strictLinks')
await strict.connect('s1', 'p1')
await sales.delete('s1')
// → throws LinkIntegrityError: Cannot delete "saleLines"/"s1": 1 link(s) in "strictLinks"
// still reference it (onDelete: 'strict').
With onDelete: 'warn' the delete is allowed and the orphan row is left in place for an audit pass:
vault.link('warnLinks', { a: 'saleLines', b: 'purchaseLines', onDelete: 'warn' })
const warn = vault.links('warnLinks')
await warn.connect('s1', 'p1')
await sales.delete('s1') // succeeds — 'warn' never blocks the delete
const { violations } = await vault.checkIntegrity()
{
"violations": [
{ "collection": "_links_warnLinks", "id": "s1|p1", "field": "a", "refTo": "saleLines", "refId": "s1", "mode": "warn" }
]
}
vault.checkIntegrity() walks every declared ref field and every link set in the vault and
returns the full list of dangling references instead of throwing — see
Foreign refs for the ref-only side of the same
audit.
API reference
Declaring and reaching a link set
| Export | Signature | Purpose |
|---|---|---|
vault.link(name, spec) | (name: string, spec: { a: string | RefDescriptor; b: string | RefDescriptor; onDelete?: LinkOnDelete }) => void | Declare (or idempotently re-declare) a link set. Throws ValidationError on a conflicting re-declaration, or if a/b isn't a simple collection name (no /, not _-prefixed). |
vault.links(name) | (name: string) => LinkSetHandle | Get the cached handle for a declared link set. Throws ValidationError if name was never passed to link(). |
vault.checkIntegrity() | () => Promise<{ violations: RefViolation[] }> | Audit every declared ref field and every link set; returns violations instead of throwing. |
LinkSetHandle (what vault.links(name) returns)
| Method | Signature | Purpose |
|---|---|---|
connect | (aId: string, bId: string, meta?: Record<string, unknown>) => Promise<void> | Create (or overwrite the metadata of) the link (aId, bId). Validates both endpoints exist first; throws LinkEndpointError otherwise. |
disconnect | (aId: string, bId: string) => Promise<void> | Remove the link. Idempotent — no-op if it doesn't exist. |
has | (aId: string, bId: string) => Promise<boolean> | Whether the link (aId, bId) exists. |
of | (id: string) => Promise<LinkRow[]> | Every link touching id on either endpoint. |
list | () => Promise<LinkRow[]> | Every row in the set. |
Types
| Export | Shape | Purpose |
|---|---|---|
LinkSpec | { a: string; b: string; onDelete?: LinkOnDelete } | The resolved declaration stored by link() (after any ref() descriptor is reduced to its target). |
LinkOnDelete | 'cascade' | 'strict' | 'warn' | What happens to a link's rows when one of its endpoint records is deleted. |
LinkRow | { a: string; b: string; meta?: Record<string, unknown> } | One link tuple, as returned by of() / list(). |
Errors
| Export | When it's thrown |
|---|---|
LinkEndpointError | connect() was called with an aId or bId that has no matching record in its endpoint collection. Carries .link, .endpoint, .missingId. |
LinkIntegrityError | An onDelete: 'strict' link blocked the delete of an endpoint record that still has touching rows. Carries .link, .endpoint, .id, .count. |
Limits & gotchas
onDeletedefaults to'cascade'if omitted — deleting an endpoint record silently removes every link row that touches it. Set'strict'or'warn'explicitly wherever silent cascading data loss would be a surprise.- Endpoints must be simple collection names — no
/(link sets are intra-vault only) and no leading_(reserved for internal collections).link()validates this at declaration time, not at first use. - No query-DSL join through a link set.
.join()walks declaredreffields; a link set's rows are read withof(id)/list()only. - A conflicting re-declaration throws. Calling
link()again for the same name with a differenta,b, oronDeletethrowsValidationError— an identical re-declaration is a safe no-op. _links_<name>is a reserved collection name.vault.collection('_links_anything')throwsReservedCollectionNameError; onlyvault.link()/vault.links()may touch that namespace.
See also
- Foreign refs —
ref()/refArray(), and how link sets compare to a simple id-array field - Joins —
.join()over declaredreffields (not link sets) - Vault & Collection model · Enclave
- Services overview · Orientation map
- Proven by showcase
107-vault-link.