stable

shadow

Read-only views of a live vault: `vault.frame()` delegates reads and throws on writes — for screen-sharing, demos, compliance review, and test harnesses.
Subpath@noy-db/hub/shadow
FactorywithShadow()
Layerwith-fork
ClusterF — Snapshot & Portability
LOC cost~129 (off-bundle when not opted in)

What it does

This service adds a read-only view of a vault — noy-db's end-to-end-encrypted document store (see Vaults & collections). Once enabled, vault.frame() returns a VaultFrame: every read method on it delegates to the underlying live collection (same validation, locale handling, and caching as the real thing), while every write method throws ReadOnlyFrameError instead of touching data. A frame is a view, not a snapshot — it always reflects the vault's current state at the moment you read, not the state at the moment frame() was called. Because it's opt-in, the ~129 lines behind it never reach your application bundle unless you import withShadow.

When you need it

  • Screen-sharing a live vault during a demo, without risking an accidental edit
  • Compliance review where the reviewer must not be able to change data
  • Test harnesses that want to assert a code path is read-only
  • Wrapping a vault before passing it into an untrusted or lower-trust callsite

Prerequisites

  • None — withShadow() alone enables read-only frames.
  • For frame history() / getVersion(): historyStrategy: withHistory() — see history.

Opt in

import { createNoydb } from '@noy-db/hub'
import { withShadow } from '@noy-db/hub/shadow'

const db = await createNoydb({
  user: 'firm',
  secret: 'firm-secret-2026',   // omit `store` to use the built-in in-memory store
  shadowStrategy: withShadow(),
})
const vault = await db.openVault('books')

Usage

Reading through a frame

interface Invoice { amount: number; status: string }
const invoices = vault.collection<Invoice>('invoices')
await invoices.put('inv-1', { amount: 1000, status: 'sent' })

const frame = vault.frame()
const invoice = await frame.collection<Invoice>('invoices').get('inv-1')
// → { amount: 1000, status: 'sent' }

const all = await frame.collection<Invoice>('invoices').list()
// → [{ amount: 1000, status: 'sent' }]

const names = await frame.collections()
// → ['invoices']

A frame always tracks the live vault — it is never stale:

const payments = vault.collection<Invoice>('payments')
const paymentsFrame = vault.frame()

await paymentsFrame.collection<Invoice>('payments').list()
// → []  (nothing written yet)

await payments.put('pay-1', { amount: 750, status: 'pending' })
await paymentsFrame.collection<Invoice>('payments').list()
// → [{ amount: 750, status: 'pending' }]   — the frame sees the write that happened after it was created

Writes throw ReadOnlyFrameError

await frame.collection<Invoice>('invoices')
  .put('inv-1', { amount: 2000, status: 'paid' })
// → throws ReadOnlyFrameError: "Cannot put() on a vault frame — frames are read-only
//   presentations of the current vault"

Every write method throws the same way, with the attempted operation named in the message: put, delete, update, putMany, deleteMany, revert.

History reads still work

History reads are allowed through a frame — a past version is data, not a live mutation:

const history = await frame.collection<Invoice>('invoices').history('inv-1')
// → [{ version: 1, timestamp: '2026-07-08T09:12:00.000Z', userId: 'firm',
//      record: { amount: 1000, status: 'sent' } }]

const v1 = await frame.collection<Invoice>('invoices').getVersion('inv-1', 1)
// → { amount: 1000, status: 'sent' }

history() and getVersion() are only meaningful when the vault also has withHistory() enabled; otherwise they throw the same way the live collection's methods do.

API reference

VaultFrame — returned by vault.frame()

MethodReturnsPurpose
frame.collection<T>(name)CollectionFrame<T>Read-only view of one collection.
frame.collections()Promise<string[]>List every collection name visible in the underlying vault.

CollectionFrame<T> — returned by frame.collection(name)

Reads delegate to the underlying Collection<T> and behave identically to calling them directly:

MethodReturnsPurpose
frameCollection.get(id, locale?)Promise<T | null>Read one record.
frameCollection.list(locale?)Promise<T[]>Read every record in the collection.
frameCollection.query()Query<T>Chainable query builder (.where(), .toArray(), .first(), .count(), .aggregate(), …) — no write surface, so it's safe to expose directly.
frameCollection.query(predicate)T[]Inline-predicate overload, same as calling it on the live collection.
frameCollection.history(id, options?)Promise<HistoryEntry<T>[]>Prior versions, newest first — requires withHistory().
frameCollection.getVersion(id, version)Promise<T | null>One specific past version — requires withHistory().
frameCollection.namestringThe underlying collection's name, captured at construction.

Write methods all reject with ReadOnlyFrameError, naming the attempted operation:

MethodThrows
frameCollection.put(id, record)ReadOnlyFrameError('put')
frameCollection.delete(id)ReadOnlyFrameError('delete')
frameCollection.update(id, patch)ReadOnlyFrameError('update')
frameCollection.revert(id, version)ReadOnlyFrameError('revert')
frameCollection.putMany(entries)ReadOnlyFrameError('putMany')
frameCollection.deleteMany(ids)ReadOnlyFrameError('deleteMany')

Behavior when NOT opted in

  • vault.frame() throws, pointing at @noy-db/hub/shadow and withShadow()

Pairs well with

  • history — vault.at(timestamp) returns a VaultInstant, the point-in-time cousin of a frame: same read-only contract (writes throw), but it reads a fixed past moment instead of the vault's current state

Edge cases & limits

  • A frame is a JavaScript-layer guard, not a cryptographic boundary — the underlying keyring's decryption keys are unchanged. A hostile caller in the same process could still reach the live vault directly and write to it; use a frame to prevent accidental writes in a read-scoped code path, not as a security boundary against a hostile co-tenant.
  • Frames are cheap to construct — create one per code path that should be read-only rather than sharing one broadly.

See also