shadow
| Subpath | @noy-db/hub/shadow |
| Factory | withShadow() |
| Layer | with-fork |
| Cluster | F — 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()
| Method | Returns | Purpose |
|---|---|---|
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:
| Method | Returns | Purpose |
|---|---|---|
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.name | string | The underlying collection's name, captured at construction. |
Write methods all reject with ReadOnlyFrameError, naming the attempted operation:
| Method | Throws |
|---|---|
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/shadowandwithShadow()
Pairs well with
- history —
vault.at(timestamp)returns aVaultInstant, 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
- Vaults & collections — the vault/collection model this service extends
- history — versioning, the audit ledger, and
vault.at(timestamp) - Services overview · Orientation map
- Proven by showcase
16-with-shadow.