experimentalsince 0.3.0-pre.6

satellites

Split a record's hot, small fields from its heavy, cold ones into two paired collections, with a joined handle that merges the full record.
Subpath@noy-db/hub/satellites — types (SatelliteSpec, PairingMarker, JoinedHandle) and SatelliteConfigError
Wiringno with*() strategy — pass satelliteOf/fields/joined to vault.collection() and call vault.joined(), both core Vault methods from @noy-db/hub root
Layerwith-shape
ClusterD — 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/hub since 0.3.0-pre.6, on @latest as of 0.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 of fields, joined) is persisted, not session-local: the first declaration writes a pairing marker into the reserved _schemas collection. 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: msgs works fully on its own and knows nothing about msgs_text. Only the vault's pairing registry knows the link.
HandleExampleReadsExistence check
Basemsgs.get(id)Hot fields only — no satellite envelope fetched or decryptednone needed; the base is the authority
Satellitemsgs_text.get(id)Heavy fields only, validated by the satellite's own schemanull if the base is absent or tombstoned, even if a satellite envelope exists
Joinedvault.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>
OptionTypeMeaning
satelliteOfstringPairs this collection to a base collection by shared record id.
fieldsreadonly string[]Routing table — required with satelliteOf; the fields that live on this satellite.
joinedstringRegisters 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

ExportKindPurpose
SatelliteSpectypeOne base↔satellite pair: { base, satellite, fields, joined? }.
PairingMarkertypeThe shape persisted into _schemas/<satellite> — { base, fieldsHash, joined? } — used to detect a divergent re-declaration (R-S9).
SatelliteConfigErrorerror classThrown 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-S1A satellite's fields overlaps the base's fields (best-effort cross-check).
R-S2A 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-S3satelliteOf names a non-existent base, or a base that is itself a satellite (no chains).
R-S4forget() cannot process a synthesized satellite ref through the full purge suite — fails loud, never a partial forget.
R-S5fields omitted, empty, or id-bearing; joined name collides with an existing collection; or fields contradicts derivable schema info.
R-S6A satellite-handle put for an id with no live base record (orphan prevention; best-effort under cross-client concurrency).
R-S7A satellite on a forgetStrategy-covered base declared without perRecordKeys; or adding forget coverage over a base whose existing satellite lacks it.
R-S8crdtMode set on either member of a satellite pair (v1 — a revert can't compensate a merge).
R-S9A declaration whose (satelliteOf, fields hash, joined) disagrees with the persisted pairing marker.

Convergence rules

  1. Existence authority. The base row is the sole authority on record existence. This is enforced on satellite.get/joined get (returns null), satellite list/query (id-filtered against live base ids), withSearch's retrieve() on a satellite (post-filtered), and bundle/pod export (envelope-level filter). It is not enforced on sync push, raw dumpEnvelopes(), or any service reading through the raw adapter directly.
  2. Absent ≡ all-null. The read path never assumes a physically-present satellite envelope — a missing one reads as all-null.
  3. 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).
  4. 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.
  5. 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.
  6. No snapshot consistency under active sync. Pull applies a pair's records in arbitrary order with no cache invalidation, so a joined get racing a pull can merge new hot fields with not-yet-pulled heavy fields. This is by design, not a bug to fix.
  7. Partial-sync filters treat a pair as a unit. push/pull collections allow-lists expand pair-wise via the pairing marker — naming the base implicitly includes its satellite. modifiedSince can 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.
  • crdtMode is 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 with SatelliteConfigError. 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. Use list() or get() on a satellite instead; search/retrieve/similarTo get their own existence post-filter and remain available.
  • Joined reactive APIs are absent. live, subscribe, and query().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/SyncTransaction are not pair-expanded. Pair-unit dirty-entry expansion covers the ordinary push({ collections }) allow-list path only — pushFiltered's per-record predicate and SyncTransaction'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's perRecordKeys classification.
  • 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