satellites
| Subpath | @noy-db/hub/satellites — types (SatelliteSpec, PairingMarker, JoinedHandle) and SatelliteConfigError |
| Wiring | no with*() strategy — pass satelliteOf/fields/joined to vault.collection() and call vault.joined(), both core Vault methods from @noy-db/hub root |
| Layer | with-shape |
| Cluster | D — Data Shape |
Preview feature (
status: "experimental"): the shape and error messages below are stable enough to build against, but may still change before the API is marked stable. Ships in@noy-db/hubsince0.3.0-pre.6, on@latestas of0.3.0.
What it is
Every noy-db record is encrypted as one envelope — the ciphertext blob a store actually persists. There is no partial decryption: reading any single field means decrypting the whole envelope. That's fine for small records, but it gets expensive when a collection mixes a few small fields you read constantly (a sender, a subject line, a received date) with one or two large fields you rarely need (a full message body, a long document). Every list view pays to decrypt the large field too, even though it never displays it.
A satellite collection solves this by splitting the record in two at the modeling level: a small, hot base collection holds the fields you read often, and a separate satellite collection — sharing the base's record id — holds the heavy fields. A third, virtual joined handle reads and writes both as if they were one record, for the code paths that do need the full thing:
msgs: msg_id, from, to, subject_short, received_date ← hot, small, listed constantly
msgs_text: msg_id, subject, body ← cold, large, rarely loaded
The model
You declare the pairing where you declare the satellite collection: its fields list is the
routing table that says which fields live on that side of the split.
vault.collection('msgs', { /* schema: from, to, subject_short, received_date */ })
vault.collection('msgs_text', {
satelliteOf: 'msgs', // pairs this collection to 'msgs' by shared record id
fields: ['subject', 'body'], // routing table — explicit, fixed at declaration
joined: 'msgs_full', // optional: registers vault.joined('msgs_full')
/* schema: subject, body */
})
- Exactly one satellite per base in v1 — the shared key is the record id itself, which is what makes every satellite read an O(1) same-id lookup, never a scan.
- The pairing (
satelliteOf, a hash offields,joined) is persisted, not session-local: the first declaration writes a pairing marker into the reserved_schemascollection. A later declaration whose config disagrees with that marker is refused (see Refusal matrix, R-S9) rather than silently splitting records differently across app versions. - No auto-create. A base write never creates a satellite envelope — absent reads as all-null, so there's nothing to create. The satellite envelope is born on its first real satellite or joined write.
- The base is oblivious and standalone:
msgsworks fully on its own and knows nothing aboutmsgs_text. Only the vault's pairing registry knows the link.
| Handle | Example | Reads | Existence check |
|---|---|---|---|
| Base | msgs.get(id) | Hot fields only — no satellite envelope fetched or decrypted | none needed; the base is the authority |
| Satellite | msgs_text.get(id) | Heavy fields only, validated by the satellite's own schema | null if the base is absent or tombstoned, even if a satellite envelope exists |
| Joined | vault.joined('msgs_full') | Base ⊕ satellite, merged by field (two O(1) same-id gets) | absent satellite reads as all-null; null if the base is absent or tombstoned |
Opt in
Satellite pairing isn't a with*() strategy — there's nothing to enable on createNoydb.
You opt a collection in per-declaration by passing satelliteOf/fields/joined to
vault.collection():
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
interface Msg extends Record<string, unknown> {
from?: string
subject?: string
body?: string
}
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'correct horse battery staple',
})
const vault = await db.openVault('inbox')
vault.collection<Msg>('msgs') // base — hot fields only
vault.collection<Msg>('msgs_text', { // satellite — heavy fields
satelliteOf: 'msgs',
fields: ['subject', 'body'],
joined: 'msgs_full', // registers vault.joined('msgs_full')
})
Usage
Read and write the full record through the joined handle
vault.joined() returns a JoinedHandle<T> that merges both legs on read and fans writes out
to both collections:
await vault.collection<Msg>('msgs').put('m1', { from: 'alice@example.com' })
await vault.joined<Msg>('msgs_full').get('m1')
// → { from: 'alice@example.com', subject: null, body: null } (satellite not written yet)
await vault.collection<Msg>('msgs_text').put('m1', { subject: 'Hello', body: 'Full message body…' })
await vault.joined<Msg>('msgs_full').get('m1')
// → { from: 'alice@example.com', subject: 'Hello', body: 'Full message body…' }
await vault.collection<Msg>('msgs').get('m1')
// → { from: 'alice@example.com' } (base read alone — never touches the satellite envelope)
Delete removes both legs, satellite first
Deleting through the base or the joined handle fans out to both collections, satellite leg first — the reverse order from writes — so a crash mid-delete can never leave a base-less satellite behind:
await vault.collection<Msg>('msgs').delete('m1')
await vault.joined<Msg>('msgs_full').get('m1')
// → null
Handle a refused write
Writing to a satellite for an id with no live base record is refused rather than silently creating an orphan:
import { SatelliteConfigError } from '@noy-db/hub/satellites'
try {
await vault.collection<Msg>('msgs_text').put('ghost', { subject: 'orphan', body: '…' })
} catch (err) {
if (err instanceof SatelliteConfigError) {
// → R-S6: satellite "msgs_text" put for "ghost" with no live base record in "msgs" —
// create the base first (or write through the joined handle).
}
}
API reference
vault.collection(name, options)
vault.collection<T>(name: string, options?: { /* satelliteOf, fields, joined, … */ }): Collection<T>
| Option | Type | Meaning |
|---|---|---|
satelliteOf | string | Pairs this collection to a base collection by shared record id. |
fields | readonly string[] | Routing table — required with satelliteOf; the fields that live on this satellite. |
joined | string | Registers vault.joined(joined) for this pair. |
Every other Collection option (schema, perRecordKeys, crdtMode, etc.) is still
accepted alongside these three. Declaring satelliteOf registers name as the satellite half of a pair. A refused
declaration (see Refusal matrix) throws SatelliteConfigError and leaves no side effect — no
registry entry, no pairing marker written. Once registered, the returned handle for the
satellite name is wrapped to enforce existence authority (satellite reads return null when
the base is absent or tombstoned); the handle for the base name is wrapped so delete/
deleteMany fan out to the satellite first.
vault.joined<T>(name)
vault.joined<T extends Record<string, unknown>>(name: string): JoinedHandle<T>
Returns the full-record handle registered by a satellite's joined: option. Throws
SatelliteConfigError if name was never registered.
JoinedHandle<T> (exported from @noy-db/hub/satellites) is deliberately narrow — it is
not a Collection<T>:
interface JoinedHandle<T extends Record<string, unknown> = Record<string, unknown>> {
get(id: string): Promise<T | null>
put(id: string, record: T): Promise<void>
delete(id: string): Promise<void>
list(): Promise<T[]>
describe(): Promise<CollectionDescription> // the @noy-db/ui contract
}
get/list merge base ⊕ satellite by field (absent satellite reads as all-null; null if
the base is absent or tombstoned). put splits the record across both collections by the
satellite's fields routing table and writes base-first. delete removes both collections,
satellite-first. There is no history/diff/revert, and no reactive members (live,
subscribe, query().live()) — calling one throws "not yet implemented" (see Limits &
gotchas).
Other exports of @noy-db/hub/satellites
| Export | Kind | Purpose |
|---|---|---|
SatelliteSpec | type | One base↔satellite pair: { base, satellite, fields, joined? }. |
PairingMarker | type | The shape persisted into _schemas/<satellite> — { base, fieldsHash, joined? } — used to detect a divergent re-declaration (R-S9). |
SatelliteConfigError | error class | Thrown for every refused declaration or refused operation listed in the Refusal matrix below. |
Refusal matrix
Every configuration or write that would produce an unsafe or ambiguous pairing is refused
with SatelliteConfigError rather than silently accepted:
| # | Refused condition |
|---|---|
| R-S1 | A satellite's fields overlaps the base's fields (best-effort cross-check). |
| R-S2 | A joined write's non-satellite keys route to the base, where the base's own validator (if any) rejects unknown fields — throws the base validator's SchemaValidationError, not SatelliteConfigError. |
| R-S3 | satelliteOf names a non-existent base, or a base that is itself a satellite (no chains). |
| R-S4 | forget() cannot process a synthesized satellite ref through the full purge suite — fails loud, never a partial forget. |
| R-S5 | fields omitted, empty, or id-bearing; joined name collides with an existing collection; or fields contradicts derivable schema info. |
| R-S6 | A satellite-handle put for an id with no live base record (orphan prevention; best-effort under cross-client concurrency). |
| R-S7 | A satellite on a forgetStrategy-covered base declared without perRecordKeys; or adding forget coverage over a base whose existing satellite lacks it. |
| R-S8 | crdtMode set on either member of a satellite pair (v1 — a revert can't compensate a merge). |
| R-S9 | A declaration whose (satelliteOf, fields hash, joined) disagrees with the persisted pairing marker. |
Convergence rules
- Existence authority. The base row is the sole authority on record existence. This is
enforced on
satellite.get/joinedget(returnsnull), satellitelist/query(id-filtered against live base ids),withSearch'sretrieve()on a satellite (post-filtered), and bundle/pod export (envelope-level filter). It is not enforced on sync push, rawdumpEnvelopes(), or any service reading through the raw adapter directly. - Absent ≡ all-null. The read path never assumes a physically-present satellite envelope — a missing one reads as all-null.
- Fan-out ordering is a local-store-only guarantee. Joined writes go base-first and pair deletes go satellite-first, so a local crash can only leave the safe direction — but sync push replay does not preserve this order (per-entry error isolation and dedup can transiently expose a base-less satellite remotely, with no local crash at all).
- Delete is logical; forget is cryptographic. A plain delete leaves no tombstone, so v1
does not sweep base-less satellites — existence authority (rule 1) simply makes them
permanently unreachable instead. For
forget(), containment of a resurrected tombstone is observational only: the system detects and reports a satellite whose base record comes back on a higher version after being forgotten, it does not yet prevent it. Satellites will inherit that fix automatically once the underlying tombstone-resurrection gap in sync pull is closed, with no change on the satellites side. - Conflict granularity is per-collection. Two clients' joined writes can converge to base-from-A ⊕ satellite-from-B — a record neither client wrote. This field-group granularity is the joined handle's documented contract; registering a conflict resolver on either pair member registers it for both.
- No snapshot consistency under active sync. Pull applies a pair's records in arbitrary
order with no cache invalidation, so a joined
getracing a pull can merge new hot fields with not-yet-pulled heavy fields. This is by design, not a bug to fix. - Partial-sync filters treat a pair as a unit.
push/pullcollectionsallow-lists expand pair-wise via the pairing marker — naming the base implicitly includes its satellite.modifiedSincecan still split a pair by timestamp.
Limits & gotchas
- Exactly one satellite per base. N-satellites-per-base is a deliberately deferred extension, not something to route around today.
- New collections only — no backfill. There is no machinery to relocate field values out of an existing, populated base collection; v1 applies only to collections declared satellite-paired from their first write.
crdtModeis refused on either pair member (R-S8). Reverting a fan-out's CRDT leg with a stale prior envelope would silently destroy other actors' merged operations.- Satellite
query()refuses withSatelliteConfigError. Query's terminal methods (toArray/first/count) read the in-memory cache synchronously, while existence authority needs an async, undecrypted check — the two shapes don't compose. Uselist()orget()on a satellite instead;search/retrieve/similarToget their own existence post-filter and remain available. - Joined reactive APIs are absent.
live,subscribe, andquery().live()throw "not yet implemented" on the joined handle in v1. - Bundle export degrades leak-on-error, never drop-live-data. If the base-liveness check errors mid-export, the filter includes the satellite envelope rather than excluding it — a leaked dead satellite can be swept later, but a wrongly dropped live record can't be recovered. This also opens a narrow, session-scoped race window: between a satellite's first declaration writing its pairing marker and that write becoming durable, a concurrent export reading the not-yet-marked collection applies no existence filtering at all.
pushFiltered/SyncTransactionare not pair-expanded. Pair-unit dirty-entry expansion covers the ordinarypush({ collections })allow-list path only —pushFiltered's per-record predicate andSyncTransaction's explicit entry list do not implicitly pull in the satellite partner. A caller using either path against a satellite pair is responsible for including both collections itself.
Pairs well with
- forget —
forget()fans out to the satellite via a synthesized ref through the full per-ref purge suite (tombstone, history, index, search index, blobs, sealed material); the satellite inherits the base'sperRecordKeysclassification. - search —
withSearch(['subject', 'body'])lives on the satellite;retrieve()results pass the existence filter. - history — each side keeps its own version stream and ledger entries; the joined handle does not merge them.
See also
- Services overview · Orientation map
- Core 02 — Enclave — the envelope format every satellite/base leg is written as