Classes
PersistenceSchemaError
Extends
Error
Constructors
Constructor
new PersistenceSchemaError(message: string): PersistenceSchemaError;
Parameters
| Parameter | Type |
|---|---|
message | string |
Returns
Overrides
Error.constructor
SandboxContextImpl
Identity-bearing handle on a Sandbox. A
(sandbox, auth, operationContext)
tuple — cheap to create, immutable, freely shareable. Service
factories require a SandboxContext; bare Sandbox is a type
error so every call site states identity explicitly.
Constructed via Sandbox.withAuth(auth) or chained via
SandboxContext.withAuth(auth). The concrete class is exported
from pyric/sandbox for instanceof routing in service
factories; consumers don’t construct it directly.
Implements
Constructors
Constructor
new SandboxContextImpl(
sandbox: Sandbox,
auth: {
token?: Record<string, unknown>;
uid: string;
},
operationContext?: OperationContext): SandboxContextImpl;
Parameters
| Parameter | Type |
|---|---|
sandbox | Sandbox |
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid? | string |
operationContext? | OperationContext |
Returns
Properties
| Property | Modifier | Type | Description |
|---|---|---|---|
auth | readonly | { token?: Record<string, unknown>; uid: string; } | The identity rules evaluate under for operations through this context. |
auth.token? | public | Record<string, unknown> | - |
auth.uid | public | string | - |
operationContext | readonly | OperationContext | Immutable provenance bound to every operation issued through this handle. |
sandbox | readonly | Sandbox | The data foundation this context operates against. |
Methods
withAuth()
withAuth(auth: {
token?: Record<string, unknown>;
uid: string;
}): SandboxContext;
Derive a sibling context on the same sandbox with different auth. Replaces auth and its lens while preserving the operation source and optional plan identity.
Parameters
| Parameter | Type |
|---|---|
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
Returns
Implementation of
Interfaces
ActivityEventProvenance
Provenance consumed only by the firestore activity diagnostics. Bundled in one optional record so the shared operation type carries a single activity-owned seam rather than loose per-feature fields.
Properties
Branch
An isolated, in-memory experiment seeded from a SandboxSnapshot.
A branch owns its own LocalSandbox (fully isolated from the source —
separate LocalEnvironment, separate event history) plus the
accumulated SandboxEvents applied to it via apply. The
applied events are what promote replays onto the target.
Properties
| Property | Modifier | Type | Description |
|---|---|---|---|
base | readonly | SandboxSnapshot | The snapshot this branch was forked from. Retained so diff and promote can reason about the baseline. |
discarded | public | boolean | Flipped by discard; subsequent apply/promote calls throw. |
events | readonly | SandboxEvent[] | Write/op events applied to this branch since fork, in order. These are replayed onto the target by promote. |
rules | readonly | string | Rules the branch was forked with — carried so promote can re-seed a replay target identically. |
sandbox | readonly | LocalSandbox | The branch’s own sandbox. Inspect it directly (branch.sandbox.snapshot()) or read docs via branch.sandbox.admin.getDocument(path). |
BroadcastChannelLike
Minimal interface that BroadcastChannel satisfies. Provided as an
injectable seam so tests can run without a real browser channel.
The real BroadcastChannel global satisfies this interface out of the
box — pass it directly:
sandbox.enableTabSync({
channel: new BroadcastChannel('pyric:tabsync'),
});
For SSR / Node environments, the default channel construction is guarded
(typeof BroadcastChannel !== 'undefined') so the call site doesn’t need
to branch — a missing global just means no sync, which is fine for server
renders that only care about initial data.
Methods
addEventListener()
addEventListener(type: "message", listener: (ev: {
data: unknown;
}) => void): void;
Parameters
| Parameter | Type |
|---|---|
type | "message" |
listener | (ev: { data: unknown; }) => void |
Returns
void
close()
close(): void;
Returns
void
postMessage()
postMessage(message: unknown): void;
Parameters
| Parameter | Type |
|---|---|
message | unknown |
Returns
void
removeEventListener()
removeEventListener(type: "message", listener: (ev: {
data: unknown;
}) => void): void;
Parameters
| Parameter | Type |
|---|---|
type | "message" |
listener | (ev: { data: unknown; }) => void |
Returns
void
DenialContext
Structured denial context emitted alongside a permission-denied
error. Real Firebase strips this server-side for security; the
sandbox can expose it because it’s a development tool.
auth and reasons are populated whenever the sandbox raises a
permission-denied error. rule (line + expression) requires
source-position tracking in the rules AST and is deferred — see
design rationale “Open questions” for the follow-up.
failedFields will be filled in once the evaluator surfaces field-
reference traces.
Properties
DenialEvent
Eval-time payload emitted to Sandbox.onDenial subscribers.
Mirrors the structured fields DenialContext carries (request
resource+reasons+auth) so a host environment that wants to surface denials independent of try/catch behavior gets the same frame either way.
Properties
EventProvenance
Compatibility provenance carried by sandbox events while producers and
consumers migrate to the canonical operationContext.
Properties
| Property | Type | Description |
|---|---|---|
activity? | ActivityEventProvenance | Firestore activity-diagnostics provenance; absent outside that flow. |
actor? | EventActor | - |
authLens? | AuthLens | - |
operationContext? | OperationContext | - |
planId? | string | Set when the op is part of an agent plan. |
service? | EventService | - |
ListenerLifecycleEvent
Listener lifecycle event — attach, detach, or errored. Errored
supersedes the prior onSnapshotError channel; error is populated
on the errored phase only.
Properties
LocalSandbox
An in-process sandbox created by initializeSandbox.
Service controls whose implementation requires synchronous access to local state accept this type. Remote worker handles remain Sandboxs, but are deliberately not assignable to this local-only interface.
Extends
Properties
| Property | Modifier | Type | Description |
|---|---|---|---|
[LOCAL_SANDBOX] | readonly | true | - |
admin | readonly | SandboxAdmin | Admin-plane access (rule-bypass reads). Identity-agnostic by design — admin reads aren’t gated on auth, so they live on the sandbox, not on a context. See SandboxAdmin. |
currentUser | public | { token?: Record<string, unknown>; uid: string; } | Current authenticated user across the sandbox. Mutated by pyric/auth’s signInAnonymously / signInWithEmailAndPassword / signOut / sandbox.setUser. Read per-call by service factories (e.g. a future getFirestore(sandbox) overload) so they see auth state changes without re-binding handles. Defaults to null (anonymous / signed out). Independent of withAuth({uid}) — withAuth still produces a frozen SandboxContext that carries its own identity for the runner’s test code (the existing pattern: explicit identity per service call). currentUser exists for the pyric/auth mirror, where consumer app code drives identity through a stateful Auth handle rather than naming it per call. |
currentUser.token? | public | Record<string, unknown> | - |
currentUser.uid | public | string | - |
Methods
clearPersistence()
clearPersistence(): Promise<void>;
Wipe the persisted blob for this sandbox’s key. In-memory state
is left intact — call reset() if you want both. Useful for
“sign out and forget” flows.
No-op when persistence is not enabled.
Returns
Promise<void>
Inherited from
dispose()
dispose(): void;
Tear down listener registries on this sandbox’s environment without
replacing it. Use this when you’re about to discard the sandbox
itself (e.g. runner.reseed() builds a fresh sandbox rather than
calling reset()) and want to drop callback references on the
outgoing instance defensively. Idempotent. Does not touch data.
Returns
void
Inherited from
enablePersistence()
enablePersistence(options: SandboxPersistenceOptions): Promise<void>;
Persist the sandbox’s data to a backend and restore it on next
enablePersistence call. The default 'indexedDB' backend turns
the sandbox into the host page’s local Firestore — writes flush
automatically and a fresh initializeSandbox() rehydrates from
the prior session.
Restoration happens before the promise resolves; awaiting this call is sufficient to guarantee in-memory state matches the persisted blob.
Idempotent across the same key — calling twice in one process
is a no-op on the second call. Different keys are rejected as an
error (a sandbox can persist to at most one backend at a time).
Listener semantics: every write event the sandbox emits triggers
a debounced flush (default 250ms). Browser hosts additionally
flush on beforeunload so a page navigation doesn’t lose the
tail of the debounce window.
See SandboxPersistenceOptions for backend selection and tuning.
Parameters
| Parameter | Type |
|---|---|
options | SandboxPersistenceOptions |
Returns
Promise<void>
Inherited from
enableTabSync()
enableTabSync(options?: TabSyncOptions): () => void;
Enable cross-tab realtime sync via BroadcastChannel. A write in
this tab will propagate to every OTHER tab of the same origin that
also called enableTabSync, causing their onSnapshot listeners to
re-evaluate — restoring production’s cross-client realtime behavior.
Opt-in, OFF by default. Firestore only (RTDB is a follow-on).
Returns a disable function. Calling it removes the onEvent
subscription, the channel message listener, and closes the channel
(when it was created internally). After disable, no further propagation
occurs in either direction.
Multi-writer note: concurrent writes from two tabs to the same doc produce last-write-wins divergence — there is no conflict resolution. The intended model is one active writer (one user, one tab) with observers in other tabs; this covers the overwhelming majority of local development scenarios.
Parameters
| Parameter | Type |
|---|---|
options? | TabSyncOptions |
Returns
(): void;
Returns
void
See
TabSyncOptions for channel injection (tests) and originId.
Example
// In every tab that should participate in realtime:
const sandbox = initializeSandbox();
const disableSync = sandbox.enableTabSync();
// Later, to stop syncing:
disableSync();
Inherited from
flush()
flush(): Promise<void>;
Force a snapshot to the configured persistence backend right now. Useful before a manual navigation, or in tests that need deterministic ordering against the debounce window. Resolves once the write hits the backend.
Throws if persistence is not enabled.
Returns
Promise<void>
Inherited from
history()
history(): SandboxEvent[];
Every SandboxEvent this sandbox has emitted since init or
the last reset(). Returns a defensive copy.
Use this for replay: hand the array to replay(events, rules)
from pyric/sandbox and the engine re-issues every
captured write against a fresh sandbox.
Unlike onEvent (live stream from the moment of subscribe),
history() returns every event the sandbox has seen — useful
for consumers that attach late (e.g., loading a saved session
before subscribing) or that need a snapshot at a particular moment.
reset() and dispose() each append a closing session_boundary
event; reset() then clears the history. Consumers that took a
snapshot before reset retain the boundary in their copy.
Returns
Inherited from
loadSnapshot()
loadSnapshot(data: SandboxSnapshot): void;
CLOBBER-restore the sandbox’s entire state from a prior snapshot:
reset() (clears firestore + the signed-in session), then rebuild firestore
from data and restore each registered service. This is a TOTAL replace —
documents absent from data do NOT survive — and is the counterpart to
snapshot. It is what makes “transfer (clobber) one instance’s data
into another” and named-branch switching possible.
Fires a session_boundary (reset phase), re-evaluates live listeners against
the loaded state, and the next persistence flush writes the loaded state.
Services present in data but not currently registered are skipped (a
snapshot taken via snapshot always includes every registered
service, so this only affects cross-instance imports from a sandbox that had
a service this one lacks).
Parameters
| Parameter | Type |
|---|---|
data | SandboxSnapshot |
Returns
void
Inherited from
onCurrentUserChanged()
onCurrentUserChanged(cb: (user: {
token?: Record<string, unknown>;
uid: string;
}) => void): () => void;
Subscribe to currentUser changes. Fires on every mutation —
sign-in, sign-out, user swap. Does NOT fire on subscribe.
Survives reset() and dispose() only as a no-op: a disposed
sandbox emits nothing further; a reset sandbox clears
currentUser to null (and fires the change) before swapping
the env.
Returns an unsubscribe function. Listener errors are swallowed — subscribers are observational, the sandbox does not propagate their errors.
Parameters
| Parameter | Type |
|---|---|
cb | (user: { token?: Record<string, unknown>; uid: string; }) => void |
Returns
(): void;
Returns
void
Inherited from
onEvent()
onEvent(cb: (event: SandboxEvent) => void): () => void;
Subscribe to every event the sandbox emits — see SandboxEvent
for the discriminated-union shape. One subscription covers
request/denial/snapshot-error/listener-lifecycle/session-boundary;
filter on event.kind to recover individual streams.
Replaces the prior three-channel surface (onRequest / onDenial
/ onSnapshotError) — see issue #307. Filter cookbook:
- All denials:
event.kind === 'request' && event.result === 'deny' - Stream errors:
event.kind === 'listener_errored' - Per-op traffic:
event.kind === 'request'
Survives sandbox.reset() — the subscription is held on the
sandbox, not on the underlying environment. A session_boundary
event with phase: 'reset' fires before the env swap so consumers
can segment their stream.
Returns an unsubscribe function. Listener errors are swallowed so a faulty subscriber can’t change rule semantics or hide other events. Both synchronous throws and rejected Promises from async callbacks are silently discarded — subscribers are observational, the sandbox doesn’t await them and doesn’t propagate their errors.
Parameters
| Parameter | Type |
|---|---|
cb | (event: SandboxEvent) => void |
Returns
(): void;
Returns
void
Inherited from
registerPersistableService()
registerPersistableService(name: string, hooks: PersistableService): () => void;
Register a service (auth, storage, …) as a persistence participant.
The sandbox calls hooks.snapshot() on every flush and
hooks.restore(data) on restore. If hooks.subscribe is provided,
the persistence controller subscribes and schedules a debounced
flush on each change — so auth-user edits flush promptly, not only
on the next Firestore write.
Returns an unregister function — call it if the service is torn
down before the sandbox is disposed (uncommon in practice; the
sandbox’s dispose() clears the registry anyway).
Throws failed-precondition when a service with the same name is
already registered — the auth package registers 'auth' once when
getAuth(sandbox) first creates a backend, so accidental double-
registration is a caller bug, not a no-op.
Advanced / internal API. Service packages (auth, storage) call this when they first attach to a sandbox. Consumer app code should not need to call this directly.
Parameters
| Parameter | Type |
|---|---|
name | string |
hooks | PersistableService |
Returns
(): void;
Returns
void
Inherited from
Sandbox.registerPersistableService
reset()
reset(): void;
Reset the underlying environment to a fresh state — wipes data, rules, and any service-specific configuration.
Snapshot listeners attached to the OLD environment are dropped at
the swap — they can’t survive because their target docs have been
wiped. onEvent subscribers DO survive — the registry lives on
the sandbox, and a session_boundary event with phase: 'reset'
fires before the swap so subscribers know the rollover happened.
Existing SandboxContexts continue to work — their sandbox
reference is stable; subsequent operations resolve to the new env.
Returns
void
Inherited from
resetAll()
resetAll(): Promise<{
errors: string[];
}>;
Reset the WHOLE sandbox: reset (Firestore env + signed-in
session), then clear every registered persistable service that
provides a PersistableService.reset hook — auth users, the
RTDB tree, storage objects. This is the one sandbox-owned “wipe
everything” path: because it iterates the service registry, a new
service that registers with a reset hook is cleared automatically,
and a consumer (Pyric Studio’s reset) cannot forget one.
Service resets may be async (storage clears IndexedDB stores); the
returned promise resolves when every service has finished clearing.
A service whose reset throws is isolated (others still clear) and
REPORTED in the returned errors (as name: message) — a reset that
leaves data behind must never look successful to the caller.
Returns
Promise<{
errors: string[];
}>
Inherited from
runWithProvenance()?
optional runWithProvenance<T>(provenance: EventProvenance, fn: () => T): T;
Run fn with ambient EventProvenance defaults: every event
emitted SYNCHRONOUSLY during fn that doesn’t already carry a
provenance field (on the event itself or via an explicit per-emit
override) is stamped with these values instead of the global
defaults. This is the mechanical “who issued this op” seam the
serve worker uses to tag Studio-issued ops (actor: { kind: 'studio' }) and to stamp the auth lens an op ran under
(authLens) — declared by the caller that issues the op, never
inferred from the op’s shape.
SYNCHRONOUS WINDOW: the ambient values apply only until fn
returns (for an async fn, its synchronous prefix — which covers
the local environment’s rules eval + event emission, since those
run before the op’s promise is handed back). Work an op DEFERS
(snapshot-listener deliveries and re-evals drain on a microtask,
off-stack) is intentionally OUTSIDE the window: a listener re-eval
belongs to the listener’s owner, not to whoever’s write triggered
it. Nested calls stack — the innermost window wins per field, and
each window restores the previous one on exit (including on throw).
OPTIONAL because remote sandbox proxies can’t provide an ambient
emit window (events are emitted in the worker they front). Callers
spell sandbox.runWithProvenance?.(prov, fn) ?? fn().
Type Parameters
| Type Parameter |
|---|
T |
Parameters
| Parameter | Type |
|---|---|
provenance | EventProvenance |
fn | () => T |
Returns
T
Inherited from
snapshot()
snapshot(): SandboxSnapshot;
Capture a snapshot of every service’s state. For v1 with only
Firestore, the return value carries a firestore key mapping doc
paths to data. Future services will add their own keys.
Returns
Inherited from
withAuth()
withAuth(auth: {
token?: Record<string, unknown>;
uid: string;
}): SandboxContext;
Derive a context bound to this sandbox under the given auth identity. Operations through services attached to the returned context evaluate rules under that identity. Many contexts can coexist for one sandbox; data is shared.
null is anonymous; an AuthState object names the user (and
optional custom claims). Passing undefined is a deliberate
error — say withAuth(null) for anonymous so the call site is
unambiguous.
Parameters
| Parameter | Type |
|---|---|
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
Returns
Example
const sandbox = initializeSandbox();
const dbAlice = getFirestore(sandbox.withAuth({ uid: 'alice' }));
const dbAnon = getFirestore(sandbox.withAuth(null));
Inherited from
OperationContext
Immutable operation provenance, bound where an operation is issued. Source and auth lens are deliberately orthogonal: Studio may evaluate rules as a user, while an app or agent may use an admin lens.
Properties
| Property | Modifier | Type |
|---|---|---|
authLens | readonly | AuthLens |
planId? | readonly | string |
source | readonly | EventActor |
OperationRecord
Properties
| Property | Modifier | Type |
|---|---|---|
at | readonly | number |
auth | readonly | { token?: Record<string, unknown>; uid: string; } |
auth.token? | public | Record<string, unknown> |
auth.uid | public | string |
context | readonly | OperationContext |
eventKind | readonly | "request" | "listener" | "operation" |
id | readonly | string |
method | readonly | string |
path? | readonly | string |
result? | readonly | "unsupported" | "error" | "allow" | "deny" | "not-applicable" |
rules | readonly | RulesDisposition |
service | readonly | EventService |
PersistableService
Contract for a service that can contribute its state to the sandbox
persistence layer. Services (auth, storage, database) register
themselves via Sandbox.registerPersistableService so the
sandbox core stays service-agnostic — the sandbox doesn’t know what
auth or storage look like; it just calls snapshot() / restore().
subscribe is optional but strongly recommended: without it, a
service’s changes (e.g. new users created via auth) only reach the
persisted blob on the next Firestore write. With subscribe, the
controller debounces a flush on every user-DB change — same latency
as Firestore writes.
Properties
Methods
reset()?
optional reset(): void | Promise<void>;
Optional: clear this service’s state to empty. Called by
Sandbox.resetAll after the Firestore environment swap, so a
single sandbox-owned call wipes EVERY registered service (auth users,
RTDB tree, storage objects) — a consumer (Pyric Studio’s reset) can’t
forget a service it never knew about. May be async (storage clears an
IndexedDB store); resetAll awaits it. A service that omits reset
is skipped — its state deliberately survives resetAll (e.g. the
per-app auth-session hooks, whose signed-in identity the sandbox core
already clears in reset()).
Returns
void | Promise<void>
restore()
restore(data: unknown): void;
Restore previously snapshotted state. Called once during
enablePersistence, AFTER Firestore docs have been restored (so
any service that needs Firestore to be hydrated first can rely on
that ordering). Guard against bad data — the blob came from disk
and may be stale or from a schema migration.
Parameters
| Parameter | Type |
|---|---|
data | unknown |
Returns
void
snapshot()
snapshot(): unknown;
Return a plain-JSON-serializable snapshot of this service’s state.
Called by the persistence controller on every flush. The return
value is stored under the service’s registered name in the
services map of the persisted blob.
Returns
unknown
PersistenceBackend
Backend contract: read/write/list/delete RECORDS under a key. The controller partitions a snapshot into structured-clone bucket records (chunk-format.ts) so the backend stores many small records natively, never one keyspace-sized blob. Record values are structured-clone-safe objects; the backend never interprets them. (v2 and earlier used a single string blob; v3 is record-shaped.)
Methods
clear()
clear(key: string): Promise<void>;
Remove ALL records under key. No-op if none exist.
Parameters
| Parameter | Type |
|---|---|
key | string |
Returns
Promise<void>
deleteRecords()
deleteRecords(key: string, recordIds: readonly string[]): Promise<void>;
Delete the given record ids under key. No-op for ids that don’t exist.
Parameters
| Parameter | Type |
|---|---|
key | string |
recordIds | readonly string[] |
Returns
Promise<void>
estimate()?
optional estimate(): Promise<{
quota: number;
usage: number;
}>;
Best-effort storage usage estimate (bytes used + the quota ceiling), or
null when the backend can’t report it. Surfaced by the metadata API so a
host can show how close the sandbox is to its storage limit. Optional: a
backend that can’t estimate simply omits it.
Returns
Promise<{
quota: number;
usage: number;
}>
getRecord()
getRecord(key: string, recordId: string): Promise<unknown>;
Read one record by id under key. Resolves null when absent.
Parameters
| Parameter | Type |
|---|---|
key | string |
recordId | string |
Returns
Promise<unknown>
listRecords()
listRecords(key: string): Promise<string[]>;
List all record ids under key, any order.
Parameters
| Parameter | Type |
|---|---|
key | string |
Returns
Promise<string[]>
putRecords()
putRecords(key: string, records: ReadonlyMap<string, unknown>): Promise<void>;
Write each [recordId, value] under key, replacing any prior value.
Parameters
| Parameter | Type |
|---|---|
key | string |
records | ReadonlyMap<string, unknown> |
Returns
Promise<void>
PersistenceController
Properties
| Property | Modifier | Type |
|---|---|---|
options | readonly | Readonly<SandboxPersistenceOptions> |
Methods
clear()
clear(): Promise<void>;
Wipe persisted state. In-memory state is untouched.
Returns
Promise<void>
dispose()
dispose(): void;
Detach event subscription + beforeunload listener.
Returns
void
flush()
flush(): Promise<void>;
Force a flush of the current sandbox state to the backend.
Returns
Promise<void>
RemoteSandbox
A branded remote sandbox handle. Structurally a Sandbox — it can
be passed anywhere a Sandbox is accepted (notably
pyric-admin/app’s initializeApp({ sandbox })) — but sync-only members
that cannot be mirrored over the wire (admin, snapshot(),
history(), …) throw a remediating error. Consumers with a remote arm
dispatch on isRemoteSandbox and use channel instead.
Extends
Extended by
Properties
| Property | Modifier | Type | Description |
|---|---|---|---|
[REMOTE_SANDBOX] | readonly | true | - |
admin | readonly | SandboxAdmin | Admin-plane access (rule-bypass reads). Identity-agnostic by design — admin reads aren’t gated on auth, so they live on the sandbox, not on a context. See SandboxAdmin. |
channel | readonly | RemoteSandboxChannel | The raw worker op/sub relay channel. |
currentUser | public | { token?: Record<string, unknown>; uid: string; } | Current authenticated user across the sandbox. Mutated by pyric/auth’s signInAnonymously / signInWithEmailAndPassword / signOut / sandbox.setUser. Read per-call by service factories (e.g. a future getFirestore(sandbox) overload) so they see auth state changes without re-binding handles. Defaults to null (anonymous / signed out). Independent of withAuth({uid}) — withAuth still produces a frozen SandboxContext that carries its own identity for the runner’s test code (the existing pattern: explicit identity per service call). currentUser exists for the pyric/auth mirror, where consumer app code drives identity through a stateful Auth handle rather than naming it per call. |
currentUser.token? | public | Record<string, unknown> | - |
currentUser.uid | public | string | - |
serveUrl | readonly | string | Base URL of the pyric dev this handle is attached to (used in error guidance: “open |
Methods
clearPersistence()
clearPersistence(): Promise<void>;
Wipe the persisted blob for this sandbox’s key. In-memory state
is left intact — call reset() if you want both. Useful for
“sign out and forget” flows.
No-op when persistence is not enabled.
Returns
Promise<void>
Inherited from
dispose()
dispose(): void;
Tear down listener registries on this sandbox’s environment without
replacing it. Use this when you’re about to discard the sandbox
itself (e.g. runner.reseed() builds a fresh sandbox rather than
calling reset()) and want to drop callback references on the
outgoing instance defensively. Idempotent. Does not touch data.
Returns
void
Inherited from
enablePersistence()
enablePersistence(options: SandboxPersistenceOptions): Promise<void>;
Persist the sandbox’s data to a backend and restore it on next
enablePersistence call. The default 'indexedDB' backend turns
the sandbox into the host page’s local Firestore — writes flush
automatically and a fresh initializeSandbox() rehydrates from
the prior session.
Restoration happens before the promise resolves; awaiting this call is sufficient to guarantee in-memory state matches the persisted blob.
Idempotent across the same key — calling twice in one process
is a no-op on the second call. Different keys are rejected as an
error (a sandbox can persist to at most one backend at a time).
Listener semantics: every write event the sandbox emits triggers
a debounced flush (default 250ms). Browser hosts additionally
flush on beforeunload so a page navigation doesn’t lose the
tail of the debounce window.
See SandboxPersistenceOptions for backend selection and tuning.
Parameters
| Parameter | Type |
|---|---|
options | SandboxPersistenceOptions |
Returns
Promise<void>
Inherited from
enableTabSync()
enableTabSync(options?: TabSyncOptions): () => void;
Enable cross-tab realtime sync via BroadcastChannel. A write in
this tab will propagate to every OTHER tab of the same origin that
also called enableTabSync, causing their onSnapshot listeners to
re-evaluate — restoring production’s cross-client realtime behavior.
Opt-in, OFF by default. Firestore only (RTDB is a follow-on).
Returns a disable function. Calling it removes the onEvent
subscription, the channel message listener, and closes the channel
(when it was created internally). After disable, no further propagation
occurs in either direction.
Multi-writer note: concurrent writes from two tabs to the same doc produce last-write-wins divergence — there is no conflict resolution. The intended model is one active writer (one user, one tab) with observers in other tabs; this covers the overwhelming majority of local development scenarios.
Parameters
| Parameter | Type |
|---|---|
options? | TabSyncOptions |
Returns
(): void;
Returns
void
See
TabSyncOptions for channel injection (tests) and originId.
Example
// In every tab that should participate in realtime:
const sandbox = initializeSandbox();
const disableSync = sandbox.enableTabSync();
// Later, to stop syncing:
disableSync();
Inherited from
flush()
flush(): Promise<void>;
Force a snapshot to the configured persistence backend right now. Useful before a manual navigation, or in tests that need deterministic ordering against the debounce window. Resolves once the write hits the backend.
Throws if persistence is not enabled.
Returns
Promise<void>
Inherited from
history()
history(): SandboxEvent[];
Every SandboxEvent this sandbox has emitted since init or
the last reset(). Returns a defensive copy.
Use this for replay: hand the array to replay(events, rules)
from pyric/sandbox and the engine re-issues every
captured write against a fresh sandbox.
Unlike onEvent (live stream from the moment of subscribe),
history() returns every event the sandbox has seen — useful
for consumers that attach late (e.g., loading a saved session
before subscribing) or that need a snapshot at a particular moment.
reset() and dispose() each append a closing session_boundary
event; reset() then clears the history. Consumers that took a
snapshot before reset retain the boundary in their copy.
Returns
Inherited from
loadSnapshot()
loadSnapshot(data: SandboxSnapshot): void;
CLOBBER-restore the sandbox’s entire state from a prior snapshot:
reset() (clears firestore + the signed-in session), then rebuild firestore
from data and restore each registered service. This is a TOTAL replace —
documents absent from data do NOT survive — and is the counterpart to
snapshot. It is what makes “transfer (clobber) one instance’s data
into another” and named-branch switching possible.
Fires a session_boundary (reset phase), re-evaluates live listeners against
the loaded state, and the next persistence flush writes the loaded state.
Services present in data but not currently registered are skipped (a
snapshot taken via snapshot always includes every registered
service, so this only affects cross-instance imports from a sandbox that had
a service this one lacks).
Parameters
| Parameter | Type |
|---|---|
data | SandboxSnapshot |
Returns
void
Inherited from
onCurrentUserChanged()
onCurrentUserChanged(cb: (user: {
token?: Record<string, unknown>;
uid: string;
}) => void): () => void;
Subscribe to currentUser changes. Fires on every mutation —
sign-in, sign-out, user swap. Does NOT fire on subscribe.
Survives reset() and dispose() only as a no-op: a disposed
sandbox emits nothing further; a reset sandbox clears
currentUser to null (and fires the change) before swapping
the env.
Returns an unsubscribe function. Listener errors are swallowed — subscribers are observational, the sandbox does not propagate their errors.
Parameters
| Parameter | Type |
|---|---|
cb | (user: { token?: Record<string, unknown>; uid: string; }) => void |
Returns
(): void;
Returns
void
Inherited from
onEvent()
onEvent(cb: (event: SandboxEvent) => void): () => void;
Subscribe to every event the sandbox emits — see SandboxEvent
for the discriminated-union shape. One subscription covers
request/denial/snapshot-error/listener-lifecycle/session-boundary;
filter on event.kind to recover individual streams.
Replaces the prior three-channel surface (onRequest / onDenial
/ onSnapshotError) — see issue #307. Filter cookbook:
- All denials:
event.kind === 'request' && event.result === 'deny' - Stream errors:
event.kind === 'listener_errored' - Per-op traffic:
event.kind === 'request'
Survives sandbox.reset() — the subscription is held on the
sandbox, not on the underlying environment. A session_boundary
event with phase: 'reset' fires before the env swap so consumers
can segment their stream.
Returns an unsubscribe function. Listener errors are swallowed so a faulty subscriber can’t change rule semantics or hide other events. Both synchronous throws and rejected Promises from async callbacks are silently discarded — subscribers are observational, the sandbox doesn’t await them and doesn’t propagate their errors.
Parameters
| Parameter | Type |
|---|---|
cb | (event: SandboxEvent) => void |
Returns
(): void;
Returns
void
Inherited from
registerPersistableService()
registerPersistableService(name: string, hooks: PersistableService): () => void;
Register a service (auth, storage, …) as a persistence participant.
The sandbox calls hooks.snapshot() on every flush and
hooks.restore(data) on restore. If hooks.subscribe is provided,
the persistence controller subscribes and schedules a debounced
flush on each change — so auth-user edits flush promptly, not only
on the next Firestore write.
Returns an unregister function — call it if the service is torn
down before the sandbox is disposed (uncommon in practice; the
sandbox’s dispose() clears the registry anyway).
Throws failed-precondition when a service with the same name is
already registered — the auth package registers 'auth' once when
getAuth(sandbox) first creates a backend, so accidental double-
registration is a caller bug, not a no-op.
Advanced / internal API. Service packages (auth, storage) call this when they first attach to a sandbox. Consumer app code should not need to call this directly.
Parameters
| Parameter | Type |
|---|---|
name | string |
hooks | PersistableService |
Returns
(): void;
Returns
void
Inherited from
Sandbox.registerPersistableService
reset()
reset(): void;
Reset the underlying environment to a fresh state — wipes data, rules, and any service-specific configuration.
Snapshot listeners attached to the OLD environment are dropped at
the swap — they can’t survive because their target docs have been
wiped. onEvent subscribers DO survive — the registry lives on
the sandbox, and a session_boundary event with phase: 'reset'
fires before the swap so subscribers know the rollover happened.
Existing SandboxContexts continue to work — their sandbox
reference is stable; subsequent operations resolve to the new env.
Returns
void
Inherited from
resetAll()
resetAll(): Promise<{
errors: string[];
}>;
Reset the WHOLE sandbox: reset (Firestore env + signed-in
session), then clear every registered persistable service that
provides a PersistableService.reset hook — auth users, the
RTDB tree, storage objects. This is the one sandbox-owned “wipe
everything” path: because it iterates the service registry, a new
service that registers with a reset hook is cleared automatically,
and a consumer (Pyric Studio’s reset) cannot forget one.
Service resets may be async (storage clears IndexedDB stores); the
returned promise resolves when every service has finished clearing.
A service whose reset throws is isolated (others still clear) and
REPORTED in the returned errors (as name: message) — a reset that
leaves data behind must never look successful to the caller.
Returns
Promise<{
errors: string[];
}>
Inherited from
runWithProvenance()?
optional runWithProvenance<T>(provenance: EventProvenance, fn: () => T): T;
Run fn with ambient EventProvenance defaults: every event
emitted SYNCHRONOUSLY during fn that doesn’t already carry a
provenance field (on the event itself or via an explicit per-emit
override) is stamped with these values instead of the global
defaults. This is the mechanical “who issued this op” seam the
serve worker uses to tag Studio-issued ops (actor: { kind: 'studio' }) and to stamp the auth lens an op ran under
(authLens) — declared by the caller that issues the op, never
inferred from the op’s shape.
SYNCHRONOUS WINDOW: the ambient values apply only until fn
returns (for an async fn, its synchronous prefix — which covers
the local environment’s rules eval + event emission, since those
run before the op’s promise is handed back). Work an op DEFERS
(snapshot-listener deliveries and re-evals drain on a microtask,
off-stack) is intentionally OUTSIDE the window: a listener re-eval
belongs to the listener’s owner, not to whoever’s write triggered
it. Nested calls stack — the innermost window wins per field, and
each window restores the previous one on exit (including on throw).
OPTIONAL because remote sandbox proxies can’t provide an ambient
emit window (events are emitted in the worker they front). Callers
spell sandbox.runWithProvenance?.(prov, fn) ?? fn().
Type Parameters
| Type Parameter |
|---|
T |
Parameters
| Parameter | Type |
|---|---|
provenance | EventProvenance |
fn | () => T |
Returns
T
Inherited from
snapshot()
snapshot(): SandboxSnapshot;
Capture a snapshot of every service’s state. For v1 with only
Firestore, the return value carries a firestore key mapping doc
paths to data. Future services will add their own keys.
Returns
Inherited from
withAuth()
withAuth(auth: {
token?: Record<string, unknown>;
uid: string;
}): SandboxContext;
Derive a context bound to this sandbox under the given auth identity. Operations through services attached to the returned context evaluate rules under that identity. Many contexts can coexist for one sandbox; data is shared.
null is anonymous; an AuthState object names the user (and
optional custom claims). Passing undefined is a deliberate
error — say withAuth(null) for anonymous so the call site is
unambiguous.
Parameters
| Parameter | Type |
|---|---|
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
Returns
Example
const sandbox = initializeSandbox();
const dbAlice = getFirestore(sandbox.withAuth({ uid: 'alice' }));
const dbAnon = getFirestore(sandbox.withAuth(null));
Inherited from
RemoteSandboxChannel
The minimal worker-relay channel a remote sandbox handle carries.
Structural mirror of @pyric/cli/remote’s RemoteSandboxChannel: one
method to dispatch any SharedWorker-protocol op, one to register a
snap-delivering subscription. Payloads are typed openly here (the real
discriminated unions live in @pyric/cli’ worker protocol); callers in
pyric-admin spell the concrete op objects (rtdb.set, auth.listUsers,
…) and pin their own actAs lens — nothing is pinned by the channel.
Methods
op()
op(op: {
method: string;
} & Record<string, unknown>): Promise<unknown>;
Dispatch one worker op. Resolves with the worker’s result value;
rejects with an Error carrying a .code (including the fail-fast
“no browser tab is connected — open
Parameters
| Parameter | Type |
|---|---|
op | { method: string; } & Record<string, unknown> |
Returns
Promise<unknown>
subscribe()
subscribe(
sub: {
target: unknown;
} & Record<string, unknown>,
onSnap: (value: unknown) => void,
onError?: (err: Error & {
code: string;
}) => void): () => void;
Register a worker subscription (e.g. an RTDB value listener:
{ target: { service: 'rtdb', path } }). onSnap receives every snap
value (initial + updates — and re-delivered fresh after peer
replacement); an establishment failure routes to onError instead.
Returns the unsubscribe function.
Parameters
| Parameter | Type |
|---|---|
sub | { target: unknown; } & Record<string, unknown> |
onSnap | (value: unknown) => void |
onError? | (err: Error & { code: string; }) => void |
Returns
(): void;
Returns
void
RemoteSandboxFactoryOptions
Options accepted by the ambient remote-sandbox factory.
Properties
| Property | Type | Description |
|---|---|---|
url? | string | Explicit pyric dev base URL (from PYRIC_SANDBOX=remote:<url>). When omitted the factory discovers the running host itself (the .pyric/serve.json locator protocol). |
ReplayOptions
Properties
ReplayResult
Properties
| Property | Type | Description |
|---|---|---|
divergences | Divergence[] | Field- and path-level differences between original and replayed state, classified. |
pathAliases | Map<string, string> | Maps captured auto-id paths → freshly-minted replay paths. The diff classifier uses this to skip autoid-alias paths when computing field-level differences. |
sandbox | LocalSandbox | Fresh sandbox with the captured writes re-applied. |
RequestEvent
Eval-time payload emitted to Sandbox.onRequest subscribers — one per evaluated op, regardless of outcome.
Issue #307: the playground today only renders denials, but every op
the simulator evaluates is a request worth seeing. This event is the
source of truth; DenialEvent is a filtered projection over
the result === 'deny' subset.
Origin tells the consumer who initiated the eval:
usersingle op via the data-plane adapter (admin / firestore).batchpart of a multi-op batch — sharesgroupIdwith siblings.transactionpart of a transaction commit — sharesgroupId.listenera write ordeployRulestriggered a snapshot listener to re-evaluate. CarriestriggeredBynaming the originating user op (when knowable).
evalMs measures the wall-clock duration of the simulator’s
simulate(...) call. Sub-millisecond is normal for simple rules;
rule-engine-heavy rules (deep boolean chains, many get() calls) can
reach tens of milliseconds; a traffic-monitor validation probe measured
connect-four rules at ~95ms p99. Surface this in your UI when it matters.
Listener throws are swallowed by the dispatcher so a faulty subscriber can’t change rule semantics or hide other events.
See
traffic-monitor-decision.md for the field-by-field rationale.
Properties
| Property | Type | Description |
|---|---|---|
at | number | Wall-clock at op start, ms since epoch. |
auth | { token?: Record<string, unknown>; uid: string; } | - |
auth.token? | Record<string, unknown> | - |
auth.uid | string | - |
detail? | { admin?: boolean; } & Record<string, unknown> | Free-form operation metadata. admin: true marks a rules-bypassing setup/admin operation so fixture tooling can exclude it from protected behavior while still preserving it as replay context. |
evalMs | number | Wall-clock duration of the simulator.simulate(…) call, in ms. |
evaluatedRule? | EvaluatedRuleInfo | The DECIDING rule’s verdict + 1-indexed source line + full sub-expression trace, projected from the simulator’s structured RuleEvaluation (additive: present on `result: ‘allow' |
groupId? | string | Shared across ops in one batch or transaction. Opaque to consumers. |
groupKind? | "transaction" | "batch" | Disambiguates `origin: ‘transaction' |
id | string | Unique within a sandbox process. Useful for React list keys. |
kind | "request" | Discriminator. |
matchedRule? | { operations: string[]; ruleIndex: number; } | Parsed from the simulator’s “Rule #N → …” debug line. Absent when no rule matched (e.g. no allow rules at the path — implicit deny). |
matchedRule.operations | string[] | - |
matchedRule.ruleIndex | number | - |
method | "delete" | "get" | "list" | "create" | "update" | "set" | - |
origin | "user" | "transaction" | "listener" | "batch" | - |
path | string | - |
reasons | string[] | Simulator debug messages — the per-rule trace (Rule #0 (read) → ALLOW). Same shape as DenialEvent.reasons so consumer code can share rendering. |
request? | { resourceData?: Record<string, unknown>; } | Proposed write payload, for create/update/set. Absent on reads + delete. Pre-resolution: FieldValue.* sentinels are preserved as their marker shapes ({ __type: 'serverTimestamp' }, etc.) so the replay engine can re-resolve them. The rule engine evaluated against the resolved form internally; that resolved form lives on WriteSandboxEvent.nextState, not here. |
request.resourceData? | Record<string, unknown> | - |
resourceAfter? | { data: Record<string, unknown>; exists: boolean; } | Projected document state after the write. Absent on reads. |
resourceAfter.data | Record<string, unknown> | - |
resourceAfter.exists | boolean | - |
resourceBefore? | { data: Record<string, unknown>; exists: boolean; } | Existing document state before the write (or read target for get). |
resourceBefore.data | Record<string, unknown> | - |
resourceBefore.exists | boolean | - |
result | "unsupported" | "allow" | "deny" | 'unsupported' fires when the simulator hit an unmodelled feature and the sandbox upgraded it (today: thrown as SimulatorUnsupportedError, surfaced here as a discrete result so the panel can show it distinctly from a real denial). |
rulesDisposition? | RulesDisposition | Canonical statement of whether Security Rules evaluated this request. Added by the sandbox event recorder when an older emitter omits it. |
triggeredBy? | { method: string; path: string; } | For listener re-evals: the originating user op that triggered this re-evaluation. Absent on the initial-snapshot fire. |
triggeredBy.method | string | - |
triggeredBy.path | string | - |
SandboxCommitEvent
Canonical committed mutation event. Unlike operation, this fires only when
state actually changed. Replay and branch tooling should eventually consume
these service adapters instead of filtering Firestore-only write events.
Properties
| Property | Type |
|---|---|
at | number |
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
data? | unknown |
detail? | Record<string, unknown> |
groupId? | string |
groupKind? | "transaction" | "batch" |
id | string |
kind | "commit" |
method | string |
nextState? | unknown |
path? | string |
priorState? | unknown |
replay? | { autoId?: string; requestTime?: number; sentinels?: { field: string; kind: string; }[]; } |
replay.autoId? | string |
replay.requestTime? | number |
replay.sentinels? | { field: string; kind: string; }[] |
service | EventService |
SandboxConfig
Initialization config for a sandbox. All fields are optional; an empty config produces a sandbox with no rules and no seeded data.
No auth field. Identity belongs to SandboxContext, not
the sandbox. Service handles always require an explicit context.
SandboxListenerEvent
Canonical listener lifecycle/delivery event. Firestore’s existing snapshot delivery/lifecycle variants are preserved; this shape gives RTDB and future service listeners the same debuggable surface.
Properties
| Property | Type |
|---|---|
at | number |
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
detail? | Record<string, unknown> |
error? | { code?: string; message: string; reasons?: string[]; } |
error.code? | string |
error.message | string |
error.reasons? | string[] |
id | string |
kind | "listener" |
listenerId | string |
phase | "attach" | "detach" | "delivery" | "suppressed" | "errored" |
reason? | string |
reasons? | string[] |
result? | "unsupported" | "error" | "allow" | "deny" |
rules? | { engine: "firestore" | "rtdb" | "storage"; errorCode?: string; matchedPath?: string; matchedRule?: string; operations?: string[]; pathVariableBindings?: Record<string, string>; reason?: string; ruleIndex?: number; } |
rules.engine | "firestore" | "rtdb" | "storage" |
rules.errorCode? | string |
rules.matchedPath? | string |
rules.matchedRule? | string |
rules.operations? | string[] |
rules.pathVariableBindings? | Record<string, string> |
rules.reason? | string |
rules.ruleIndex? | number |
rulesDisposition? | RulesDisposition |
sample? | unknown |
service | EventService |
size? | number |
target | { kind: string; path?: string; query?: unknown; } |
target.kind | string |
target.path? | string |
target.query? | unknown |
triggeredBy? | { method: string; path?: string; } |
triggeredBy.method | string |
triggeredBy.path? | string |
SandboxOperationEvent
Canonical service operation event. This is the service-neutral successor to
Firestore’s request traffic shape: every user-visible operation can be
represented here, whether it is backed by security rules (Firestore/RTDB/
Storage) or by a service control plane (Auth).
Existing Firestore request events remain for compatibility. New cross-
service consumers should prefer operation because it carries an explicit
service discriminator and does not require RTDB/Storage/Auth to pretend
their state is a Firestore document.
Properties
| Property | Type | Description |
|---|---|---|
at | number | - |
auth | { token?: Record<string, unknown>; uid: string; } | - |
auth.token? | Record<string, unknown> | - |
auth.uid | string | - |
detail? | Record<string, unknown> | - |
durationMs? | number | - |
groupId? | string | - |
groupKind? | "transaction" | "batch" | - |
id | string | - |
kind | "operation" | - |
method | string | - |
origin | "admin" | "user" | "system" | "transaction" | "listener" | "batch" | - |
path? | string | - |
reasons? | string[] | - |
request? | { data?: unknown; query?: unknown; resourceData?: unknown; } | - |
request.data? | unknown | - |
request.query? | unknown | - |
request.resourceData? | unknown | - |
resourceAfter? | { data: unknown; exists: boolean; } | - |
resourceAfter.data | unknown | - |
resourceAfter.exists | boolean | - |
resourceBefore? | { data: unknown; exists: boolean; } | - |
resourceBefore.data | unknown | - |
resourceBefore.exists | boolean | - |
result | "unsupported" | "error" | "allow" | "deny" | "not-applicable" | - |
rules? | { engine: "firestore" | "rtdb" | "storage"; errorCode?: string; matchedPath?: string; matchedRule?: string; operations?: string[]; pathVariableBindings?: Record<string, string>; reason?: string; ruleIndex?: number; } | - |
rules.engine | "firestore" | "rtdb" | "storage" | - |
rules.errorCode? | string | - |
rules.matchedPath? | string | - |
rules.matchedRule? | string | - |
rules.operations? | string[] | - |
rules.pathVariableBindings? | Record<string, string> | - |
rules.reason? | string | - |
rules.ruleIndex? | number | - |
rulesDisposition? | RulesDisposition | Canonical statement of whether Security Rules evaluated this operation. Service emitters may provide it directly; the recorder normalizes legacy operation shapes at the unified stream seam. |
service | EventService | - |
triggeredBy? | { method: string; path?: string; } | - |
triggeredBy.method | string | - |
triggeredBy.path? | string | - |
SandboxPersistenceOptions
Controller options. See Sandbox.enablePersistence.
Properties
| Property | Type | Description |
|---|---|---|
backend? | "memory" | "indexedDB" | Storage backend. indexedDB requires a browser environment; in non-browser hosts (Bun, Node, tests) the controller falls back to memory automatically unless an injectedBackend is supplied. Default: indexedDB. |
flushIntervalMs? | number | Debounce window before write events are flushed to the backend. Buffers rapid bursts (e.g., a batch of seed writes) into one flush. Default: 250ms. |
injectedBackend? | PersistenceBackend | Override the backend with an injected implementation. Used by tests and hosts that have their own storage adapter. When set, backend is ignored. |
key | string | IndexedDB database name (or generic bucket key for other backends). Different keys persist to different storage locations — use one key per logical sandbox if you run several in parallel. |
sessionStorage? | { local: WebStorageLike; session: WebStorageLike; } | Optional web-storage pair for current-session persistence. When provided, the controller reads and writes the signed-in uid here (honoring the auth setPersistence mode) so page reloads restore the signed-in user — exactly like browserLocalPersistence in prod Firebase. When omitted, the user DATABASE still persists (Phase 1), but the CURRENT SESSION is not restored on reload. This is the honest no-fake-durability choice for environments where web storage isn’t available (Bun tests, Node servers, etc.). local maps to localStorage semantics (survives reload + restart); session maps to sessionStorage semantics (survives reload, cleared on tab close). The controller picks which store to write based on the auth setPersistence mode recorded on the backend: LOCAL → local (default; matches Firebase’s default) SESSION → session NONE → neither (uid is not stored) Both storages are read on restore (mode-agnostic — a prior session may have used a different mode). Exactly one store holds the uid at any time; a mode change migrates the uid to the new store. |
sessionStorage.local | WebStorageLike | - |
sessionStorage.session | WebStorageLike | - |
SandboxRuntimeErrorEvent
Canonical non-rules operational failure.
Properties
| Property | Type |
|---|---|
at | number |
auth | { token?: Record<string, unknown>; uid: string; } |
auth.token? | Record<string, unknown> |
auth.uid | string |
detail? | Record<string, unknown> |
error | { code?: string; message: string; } |
error.code? | string |
error.message | string |
id | string |
kind | "runtime_error" |
method | string |
path? | string |
service | EventService |
SandboxSnapshot
Sandbox-level snapshot — a coarse capture of every service’s state
keyed by service name. The firestore key is always present; the
services map holds one entry per registered persistable service
(auth users, future storage objects, etc.). Service-specific
snapshot types live in their service modules; /app keeps the index
structural so it stays decoupled from service implementations.
v2 shape — services was added when the persistable-service registry
landed. Prior { firestore } v1 blobs are treated as having an empty
services map on restore.
Properties
ServiceMutationEvent
Cross-service mutation event — the unified envelope the NON-Firestore
services (auth / storage / rtdb) emit into the single
onEvent/history() stream (Pyric Studio keystone, track T1).
Why a new variant rather than reusing request/write. Firestore’s
existing kinds are tightly coupled to the rules-simulator: RequestEvent
carries result: 'allow'|'deny', evalMs, the simulator’s reasons[],
and matchedRule; WriteSandboxEvent carries Firestore-specific
sentinels, autoId, and a Firestore requestTime Timestamp. Auth user-
DB mutations (no path, no rule eval), Storage object puts, and RTDB tree
writes don’t have those concepts, and bending them into the Firestore
shapes would either lie (synthesize a fake result/requestTime) or
pollute the Firestore consumer contract. So this is ONE small, additive
variant the three services share — Firestore consumers filter on their
existing kinds and never see it. See the design rationale.
It is intentionally generic: op is a free-ish string discriminated by
service, and before/after are best-effort serializable snapshots
(omitted when not meaningful — e.g. a sign-out has no after). Studio’s
data grids / Action Center render service + op + path directly and
diff before→after when both are present.
Properties
SessionBoundaryEvent
Session boundary — emitted before sandbox.reset() swaps the env,
and before sandbox.dispose() tears it down. Lets consumers segment
a persisted event stream into “session N pre-reset” / “session N+1
post-reset” runs.
Properties
| Property | Type | Description |
|---|---|---|
at | number | - |
id | string | - |
kind | "session_boundary" | - |
phase | "reset" | "dispose" | - |
priorOpCount | number | Total events emitted on this sandbox before the boundary. |
SnapshotDeliveryEvent
Snapshot delivered to a onSnapshot listener’s user callback.
Fires AFTER the no-op suppression check — every snapshot_delivery
event corresponds to an actual user-callback invocation. Listener
re-evals that resolved to no-ops emit SnapshotSuppressedEvent
instead.
sample carries best-effort serializable views of the docs the
callback received; consumers truncate before persisting if the
scenario produces large snapshots.
Properties
SnapshotErrorEvent
Eval-time payload emitted to Sandbox.onSnapshotError subscribers.
Stream-level error from a Firestore onSnapshot listener — the
listener has been silently terminated and will deliver no further
snapshots (matches production: a stream error is once-per-stream and
the listener stays “subscribed” from the consumer’s perspective but
receives nothing further). Carries the target so the host UI can
attribute the error to a specific watch.
Currently permission-denied is the only code the sandbox produces
(production also emits unavailable, aborted, resource-exhausted
— none of which have a sandbox analog: no network stream to drop, no
quota, no concurrent transactions to conflict). Documented divergence
from production; new codes can be added if a sandbox-specific
scenario surfaces them.
Properties
SnapshotSuppressedEvent
Listener re-eval that was suppressed before delivery — the re-eval ran but produced no observable change vs the prior snapshot, so the user callback wasn’t invoked.
Useful for “why didn’t my listener fire” debugging. Default UIs should filter these out; only the inspector-style consumer needs them.
Properties
TabSyncOptions
Options for sandbox.enableTabSync(options?). All fields are optional;
the defaults provide a ready-to-use configuration for browser environments.
Properties
| Property | Type | Description |
|---|---|---|
channel? | BroadcastChannelLike | The broadcast channel to use. Defaults to new BroadcastChannel('pyric:tabsync') when omitted and BroadcastChannel is available in the global scope. Pass a custom implementation for tests or Node environments. |
originId? | string | A string that uniquely identifies this tab’s sandbox instance. Used for echo suppression (messages with origin === originId are silently dropped) and for directing state replies to the requesting tab. Defaults to crypto.randomUUID() when available, otherwise a counter + process-uptime string (no Date.now() or Math.random() — those change every call and can collide in fast tests). |
WebStorageLike
Minimal web-storage-like contract the session persistence controller
reads/writes. Matches the localStorage / sessionStorage browser
API subset that pyric dev’s SessionStore already uses, so
browsers pass real storages and tests pass in-memory Map-backed fakes.
Why the minimal subset (get/set/remove) instead of the full
Storage interface: this library targets multiple environments
(browser, Bun, Node) and the full Storage interface carries
length + key() + clear() that aren’t needed here — narrowing the
contract keeps tests simple and Node/Bun hosts from having to
implement a complete polyfill.
Methods
getItem()
getItem(key: string): string;
Parameters
| Parameter | Type |
|---|---|
key | string |
Returns
string
removeItem()
removeItem(key: string): void;
Parameters
| Parameter | Type |
|---|---|
key | string |
Returns
void
setItem()
setItem(key: string, value: string): void;
Parameters
| Parameter | Type |
|---|---|
key | string |
value | string |
Returns
void
WriteSandboxEvent
Committed write — a create/update/set/delete that the rule
engine allowed AND that the keyspace successfully applied. Includes
pre- and post-state so consumers can render diffs and (in a future
sandbox.history() API) reconstruct state by replay.
Fires AFTER the corresponding kind: 'request' event for the same
op. A denied or rolled-back write surfaces as a request-deny only;
write events only fire for committed writes.
sentinels and autoId are placeholders for the eventual replay
engine — v1 of the unified channel leaves them undefined. The shape
is locked so consumers can build against it without churn when
sentinel/auto-id capture lands.
Properties
| Property | Type | Description |
|---|---|---|
at | number | - |
auth | { token?: Record<string, unknown>; uid: string; } | - |
auth.token? | Record<string, unknown> | - |
auth.uid | string | - |
autoId? | string | Minted document ID when this write came from collection.add() / LocalEnvironment.createWithAutoId. The replay engine aliases the path’s last segment to a fresh mint on replay (rather than preserving the original auto-ID). |
data? | Record<string, unknown> | Pre-resolution write payload — FieldValue.* sentinels preserved as marker shapes ({ __type: 'serverTimestamp' }, etc.) so the replay engine can re-resolve them. The rule engine evaluated against the resolved form internally; the resolved form lives on nextState. Absent on delete. |
detail? | { admin?: boolean; } & Record<string, unknown> | Free-form write metadata. admin: true marks a rules-bypassing setup/admin commit so replay can apply it as context without asking candidate rules to permit it. |
groupId? | string | - |
groupKind? | "transaction" | "batch" | - |
id | string | - |
kind | "write" | - |
method | "delete" | "create" | "update" | "set" | - |
nextState | Record<string, unknown> | State AFTER this write. null on delete. |
path | string | - |
priorState | Record<string, unknown> | State BEFORE this write. null for a non-existent doc. |
requestTime | { nanoseconds: number; seconds: number; } | Server time at which the rule engine evaluated this write — pinned per op (or shared across sub-ops in a batch / transaction). The replay engine re-issues this exact value when re-resolving serverTimestamp() sentinels so resolved fields are bit-identical on replay. Shape mirrors the Firestore Web SDK Timestamp ({ seconds, nanoseconds }). |
requestTime.nanoseconds | number | - |
requestTime.seconds | number | - |
sentinels? | { field: string; kind: | "delete" | "serverTimestamp" | "increment" | "arrayUnion" | "arrayRemove"; }[] | FieldValue sentinels (serverTimestamp / increment / arrayUnion / arrayRemove / deleteField → ‘delete’) extracted from the pre-resolution write payload. The replay engine consumes this to re-issue the same sentinels at replay time without consulting resolved values that would have drifted. Path syntax: dotted with bracket-indices (‘a.b[0].c’). Absent when the write contained no sentinels. |
Type Aliases
AuthLens
type AuthLens =
| {
mode: "admin";
}
| {
mode: "as";
token?: Record<string, unknown>;
uid: string;
}
| {
mode: "app-session";
}
| {
mode: "anon";
};
The identity/rules lens an operation actually ran under.
DiffTarget
type DiffTarget = LocalSandbox | SandboxSnapshot;
A reference to diff a branch against: a live sandbox or a bare snapshot.
Divergence
type Divergence =
| {
after: unknown;
before: unknown;
field: string;
kind: "sentinel-drift";
path: string;
sentinelKind: | "serverTimestamp"
| "increment"
| "arrayUnion"
| "arrayRemove"
| "delete";
}
| {
kind: "autoid-alias";
originalPath: string;
replayedPath: string;
}
| {
after: unknown;
before: unknown;
field: string;
kind: "time-drift";
path: string;
}
| {
after: unknown;
before: unknown;
field?: string;
kind: "real-divergence";
path: string;
};
EventActor
type EventActor =
| {
journeyId?: string;
kind: "app";
}
| {
kind: "studio";
}
| {
kind: "agent";
name: string;
}
| {
kind: "app-builder";
}
| {
kind: "unattributed";
};
Who initiated the operation behind an event. Missing source is represented
explicitly as unattributed; it is never silently promoted to app traffic.
EventService
type EventService = "firestore" | "auth" | "storage" | "rtdb" | "messaging" | "ai";
Which sandbox service emitted an event.
RemoteSandboxFactory()
type RemoteSandboxFactory = (opts?: RemoteSandboxFactoryOptions) => RemoteSandbox;
The factory @pyric/cli/register installs at
globalThis[REMOTE_SANDBOX_FACTORY]. SYNCHRONOUS by contract:
initializeApp() is sync in firebase-admin, so the factory must return
the branded handle without awaiting (connection establishment may be
lazy inside the handle’s channel).
Parameters
| Parameter | Type |
|---|---|
opts? | RemoteSandboxFactoryOptions |
Returns
RulesDisposition
type RulesDisposition =
| {
kind: "evaluated";
verdict: "allow" | "deny";
}
| {
kind: "bypassed";
reason: "admin";
}
| {
kind: "not-evaluated";
reason: "no-rules" | "unsupported" | "not-a-rules-operation" | "runtime-error";
};
What happened at the Security Rules seam. Admin is a lens; bypassed is
the rules disposition.
SandboxErrorCode
type SandboxErrorCode =
| "invalid-argument"
| "permission-denied"
| "not-found"
| "already-exists"
| "failed-precondition"
| "aborted"
| "unavailable"
| "unimplemented"
| "not-seeded"
| "rules-not-loaded";
Error codes raised by the sandbox layer.
The first batch matches Firebase / gRPC conventions so existing
if (e.code === 'permission-denied') code from production paths
keeps working. The second batch is sandbox-specific and exists so
agents can distinguish “sandbox doesn’t simulate this” from “your
code is wrong” without parsing message strings.
SandboxEvent
type SandboxEvent =
| RequestEvent
| WriteSandboxEvent
| SnapshotDeliveryEvent
| SnapshotSuppressedEvent
| ListenerLifecycleEvent
| SessionBoundaryEvent
| ServiceMutationEvent
| SandboxOperationEvent
| SandboxCommitEvent
| SandboxListenerEvent
| SandboxRuntimeErrorEvent & EventProvenance;
Discriminated union of every event the sandbox emits to Sandbox.onEvent subscribers.
Issue #307 — replaces the prior three-channel surface
(onRequest / onDenial / onSnapshotError). Filter on kind
to recover the subset each old channel covered:
- request:
kind === 'request' - denial:
kind === 'request' && result === 'deny' - snapshotError:
kind === 'listener_errored'
See the design rationale for the field-by-field rationale.
Variables
REMOTE_SANDBOX
const REMOTE_SANDBOX: unique symbol;
Brand stamped (value true) on every remote sandbox handle.
Symbol.for — registered globally so pyric-admin’s check matches the
stamp even if two copies of pyric end up in one process.
REMOTE_SANDBOX_FACTORY
const REMOTE_SANDBOX_FACTORY: unique symbol;
Well-known global key under which @pyric/cli/register installs the
remote-sandbox factory: globalThis[REMOTE_SANDBOX_FACTORY].
This is the AMBIENT-INIT seam (adoption experience, layer 3): when
pyric-admin/app’s bare initializeApp() sees PYRIC_SANDBOX=remote[:url]
it reads this global and calls the installed RemoteSandboxFactory
to obtain the branded handle — without importing @pyric/cli (which is
a devDependency of the app, not of pyric-admin). Symbol.for so the
installer and the reader agree even across duplicated copies of pyric.
Functions
apply()
function apply(branch: Branch, events: readonly SandboxEvent[]): Branch;
Apply a stream of events to a branch by re-issuing their writes against the branch’s CURRENT state.
This is the same per-write re-issue logic replay() runs (filter to
kind: 'write', honour autoId / pinned requestTime, prefer the
pre-resolution request.resourceData so sentinels re-resolve), but
applied incrementally on the branch’s existing env rather than on a
fresh empty sandbox — so it composes over base docs (e.g. an update
lands on a doc the snapshot seeded) and accumulates across multiple
apply calls. The applied events are folded into branch.events so
promote can reproduce the same sequence on the target.
Parameters
| Parameter | Type |
|---|---|
branch | Branch |
events | readonly SandboxEvent[] |
Returns
the same branch (mutated in place) for chaining.
attachPersistence()
function attachPersistence(sandbox: Sandbox, rawOptions: SandboxPersistenceOptions): Promise<PersistenceController>;
Construct a controller, restore any prior snapshot, and wire the
auto-flush subscription. Returns once restore has completed (callers
can await sandbox.enablePersistence(...) and be sure the in-memory
state reflects the persisted blob).
Late service registration: services (e.g. auth) may register with the
sandbox AFTER this call returns (the user calls enablePersistence
then later getAuth(sandbox) which triggers registerPersistableService).
We handle this in two parts:
restore()returns the rawservicesblob map so the controller can apply it to late-arriving services.setServiceRegistrationHookfires on each registration — we immediately apply the saved blob data (if any) AND subscribe the service’s change notifier for future flushes.
Parameters
| Parameter | Type |
|---|---|
sandbox | Sandbox |
rawOptions | SandboxPersistenceOptions |
Returns
Promise<PersistenceController>
attachTabSync()
function attachTabSync(sandbox: LocalSandbox, options?: TabSyncOptions): () => void;
Attach cross-tab sync to a sandbox. Called by SandboxImpl.enableTabSync;
kept in a separate module so sandbox-impl.ts stays thin.
Returns a disable function: calling it unsubscribes the onEvent listener,
removes the channel message listener, and closes the channel.
Parameters
| Parameter | Type | Description |
|---|---|---|
sandbox | LocalSandbox | The sandbox to sync. Must expose onEvent, admin, and snapshot. |
options? | TabSyncOptions | Optional channel and origin override. |
Returns
(): void;
Returns
void
bundleRecords()
function bundleRecords(records: ReadonlyMap<string, unknown>): string;
Bundle v3 records into one committable JSON string, for single-blob stores
(serve’s exportable .pyric/state file, an HTTP state endpoint). Inverse of
parseBundle. This deliberately collapses the chunking into one blob:
it is the single-artifact EXPORT shape, not the scale path (that is the
record-shaped backend).
Parameters
| Parameter | Type |
|---|---|
records | ReadonlyMap<string, unknown> |
Returns
string
createIndexedDBBackend()
function createIndexedDBBackend(): PersistenceBackend;
Build an IndexedDB-backed PersistenceBackend. Throws synchronously when
called outside a browser (no indexedDB global) so callers can detect the
absence and fall back to memory.
Returns
createMemoryBackend()
function createMemoryBackend(): PersistenceBackend;
Returns
deserializeFromBuckets()
function deserializeFromBuckets(records: Iterable<[string, unknown]>): {
firestore: Record<string, Record<string, unknown>>;
services: Record<string, unknown>;
};
Reassemble a firestore snapshot + services from v3 records (any iterable of [recordId, record] pairs). Rehydrates wrapper types from their markers.
Parameters
| Parameter | Type |
|---|---|
records | Iterable<[string, unknown]> |
Returns
{
firestore: Record<string, Record<string, unknown>>;
services: Record<string, unknown>;
}
firestore
firestore: Record<string, Record<string, unknown>>;
services
services: Record<string, unknown>;
diff()
function diff(branch: Branch, target: DiffTarget): Divergence[];
Structural diff of a branch’s current state against a reference.
Reuses the replay engine’s Divergence result type and mirrors its
doc-level + field-level walk (see diffDocSets). With no captured
write metadata in play, differences surface as real-divergence
(field/doc changed) — the honest classification for “branch vs live”.
Added/removed docs surface as presence divergences (one side
undefined).
Parameters
| Parameter | Type | Description |
|---|---|---|
branch | Branch | The experiment. |
target | DiffTarget | Live sandbox or a snapshot to compare against. |
Returns
discard()
function discard(branch: Branch): void;
Discard a branch: drop its sandbox and mark it spent. The target is never touched (nothing was promoted). Idempotent.
Parameters
| Parameter | Type |
|---|---|
branch | Branch |
Returns
void
fork()
function fork(snapshot: SandboxSnapshot, rules?: string): Branch;
Fork a new branch from a snapshot.
Seeds a fresh sandbox with rules and the snapshot’s Firestore docs
(the same seed({ rules, documents }) path replay uses to stand up a
clean environment). The branch is fully isolated: writes on it never
touch the source sandbox.
Parameters
| Parameter | Type | Description |
|---|---|---|
snapshot | SandboxSnapshot | Baseline state — typically liveSandbox.snapshot(). |
rules? | string | Rules source for the branch. Pass the live rules to reproduce production behaviour, or an edited ruleset to test a rules change in isolation (Studio F4). |
Returns
initializeSandbox()
function initializeSandbox(_config?: SandboxConfig): LocalSandbox;
Create a sandbox.
Identity is not part of init — call sandbox.withAuth(...) to
derive a SandboxContext for service operations. Service-
specific configuration (rules, seed data) happens through service-specific
sandbox controls — for example, setRules(sandbox, source) from
pyric/sandbox/firestore.
Parameters
| Parameter | Type |
|---|---|
_config? | SandboxConfig |
Returns
Example
import { initializeSandbox } from 'pyric/sandbox';
import { getFirestore } from 'pyric-admin/firestore';
const sandbox = initializeSandbox();
const dbAlice = getFirestore(sandbox.withAuth({ uid: 'alice' }));
const dbAnon = getFirestore(sandbox.withAuth(null));
isOperationEvent()
function isOperationEvent(event: SandboxEvent): event is OperationEvent;
Parameters
| Parameter | Type |
|---|---|
event | SandboxEvent |
Returns
event is OperationEvent
isRemoteSandbox()
function isRemoteSandbox(sandbox: Sandbox): sandbox is RemoteSandbox;
Is this sandbox a remote handle? Backend dispatch guard for consumers
(e.g. pyric-admin’s RTDB/Auth sandbox backends) that must route a
remote sandbox’s operations through RemoteSandbox.channel rather
than into process-local state.
Parameters
| Parameter | Type |
|---|---|
sandbox | Sandbox |
Returns
sandbox is RemoteSandbox
operationContextFor()
function operationContextFor(event: Pick<EventProvenance, "operationContext" | "actor" | "authLens" | "planId">): OperationContext;
The canonical context on a recorded event. Old/pre-context events are explicitly unattributed rather than silently asserted to be app traffic.
Parameters
| Parameter | Type |
|---|---|
event | Pick<EventProvenance, "operationContext" | "actor" | "authLens" | "planId"> |
Returns
parseBundle()
function parseBundle(blob: string): Map<string, unknown>;
Parse a v3 bundle blob back into records. Returns an empty map for an unrecognized blob (e.g. a legacy v2 single-blob snapshot); migrate-on-open (a later commit) handles converting a v2 blob to v3 records.
Parameters
| Parameter | Type |
|---|---|
blob | string |
Returns
Map<string, unknown>
promote()
function promote(branch: Branch, target: LocalSandbox): void;
Promote a branch’s mutations onto a target (live) sandbox.
“Honest promote”: it computes the doc-level delta between the branch’s BASE snapshot and its current state — i.e. exactly what the applied events changed — and lands only those mutations on the target through the admin plane:
- docs added/changed on the branch →
admin.setDocument - docs deleted on the branch →
admin.deleteDocument - docs the branch never touched → left untouched on the target
Admin-plane application fires the target’s listeners (matching how persistence restore lands docs), so live UI/handles see the promotion.
The branch is marked discarded afterward — a promoted branch is spent.
Parameters
| Parameter | Type | Description |
|---|---|---|
branch | Branch | The experiment to land. |
target | LocalSandbox | The live sandbox to land it on. |
Returns
void
recordBackendOverBlob()
function recordBackendOverBlob(io: {
clear: Promise<void>;
read: Promise<string>;
write: Promise<void>;
}): PersistenceBackend;
A record-shaped backend over a single-blob store (read/write/clear ONE blob). The whole record set is bundled into that blob, for serve’s committable export file / HTTP state endpoint and any single-key store. It loses chunking’s scale benefit by design (the blob IS the single artifact). The blob is loaded once and cached, so a restore (list + per-record get) costs one read, not one per record.
Parameters
| Parameter | Type |
|---|---|
io | { clear: Promise<void>; read: Promise<string>; write: Promise<void>; } |
io.clear | |
io.read | |
io.write |
Returns
rehydrateDocValue()
function rehydrateDocValue(value: unknown): unknown;
Walk a parsed JSON tree and re-wrap any marker shape back into its real wrapper-class instance. Visits arrays and plain objects recursively. Plain values (and plain objects without a recognized discriminator) pass through.
This is the canonical rehydrate used by BOTH the sandbox persistence serializer and the SharedWorker wire protocol, so the IDB format and the MessagePort wire format are guaranteed identical.
Parameters
| Parameter | Type |
|---|---|
value | unknown |
Returns
unknown
replay()
function replay(
events: readonly SandboxEvent[],
rules: string,
options?: ReplayOptions,
originalState?: Record<string, DocData>): ReplayResult;
Replay a captured SandboxEvent stream on a fresh sandbox.
The originalState snapshot is optional — when provided, the engine
diffs the replayed sandbox’s final state against it and returns
classified divergences. When omitted, divergences is [] (you still
get the replayed sandbox; you can inspect its state manually).
Parameters
| Parameter | Type |
|---|---|
events | readonly SandboxEvent[] |
rules | string |
options? | ReplayOptions |
originalState? | Record<string, DocData> |
Returns
rulesDispositionFor()
function rulesDispositionFor(event: OperationEvent): RulesDisposition;
Normalize the legacy per-service markers exactly once, at the sandbox
stream seam. Consumers must not inspect detail.admin, origin, or the
presence of a trace themselves.
Parameters
| Parameter | Type |
|---|---|
event | OperationEvent |
Returns
serializeToBuckets()
function serializeToBuckets(
firestore: Record<string, Record<string, unknown>>,
services: Record<string, unknown>,
savedAt: number): Map<string, BucketRecord | MetaRecord>;
Partition a firestore doc map into v3 records: one bucket record per occupied
bucket plus the meta record. The returned map’s keys are record ids; values
are structured-clone-safe.
Parameters
| Parameter | Type |
|---|---|
firestore | Record<string, Record<string, unknown>> |
services | Record<string, unknown> |
savedAt | number |
Returns
Map<string, BucketRecord | MetaRecord>
toOperationRecord()
function toOperationRecord(event: SandboxEvent): OperationRecord;
Project either traffic event family into the canonical record.
Parameters
| Parameter | Type |
|---|---|
event | SandboxEvent |
Returns
References
AuthState
Re-exports AuthState
Sandbox
Re-exports Sandbox
SandboxContext
Re-exports SandboxContext
SandboxError
Re-exports SandboxError