Pyric
Navigate

API reference

pyric/sandbox

73 published symbols from pyric

Generated from the TypeScript declarations shipped at this import path.

Classes

PersistenceSchemaError

Extends

  • Error

Constructors

Constructor
new PersistenceSchemaError(message: string): PersistenceSchemaError;
Parameters
ParameterType
messagestring
Returns

PersistenceSchemaError

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
ParameterType
sandboxSandbox
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uid?string
operationContext?OperationContext
Returns

SandboxContextImpl

Properties

PropertyModifierTypeDescription
authreadonly{ token?: Record<string, unknown>; uid: string; }The identity rules evaluate under for operations through this context.
auth.token?publicRecord<string, unknown>-
auth.uidpublicstring-
operationContextreadonlyOperationContextImmutable provenance bound to every operation issued through this handle.
sandboxreadonlySandboxThe 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
ParameterType
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
Returns

SandboxContext

Implementation of

SandboxContext.withAuth

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

PropertyTypeDescription
groupKind?"transaction"Marks worker-split operations that belong to a transaction for activity diagnostics.
listenerId?stringStable host subscription identity used only by activity diagnostics.
listenerLifecycle?"reauthorize"Marks lifecycle caused by transparent auth reauthorization, not app code.

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

PropertyModifierTypeDescription
basereadonlySandboxSnapshotThe snapshot this branch was forked from. Retained so diff and promote can reason about the baseline.
discardedpublicbooleanFlipped by discard; subsequent apply/promote calls throw.
eventsreadonlySandboxEvent[]Write/op events applied to this branch since fork, in order. These are replayed onto the target by promote.
rulesreadonlystringRules the branch was forked with — carried so promote can re-seed a replay target identically.
sandboxreadonlyLocalSandboxThe 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
ParameterType
type"message"
listener(ev: { data: unknown; }) => void
Returns

void

close()
close(): void;
Returns

void

postMessage()
postMessage(message: unknown): void;
Parameters
ParameterType
messageunknown
Returns

void

removeEventListener()
removeEventListener(type: "message", listener: (ev: {
  data: unknown;
}) => void): void;
Parameters
ParameterType
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

PropertyModifierTypeDescription
auth?public{ token?: Record<string, unknown>; uid: string; }Auth identity that was active when the denial fired.
auth.token?publicRecord<string, unknown>-
auth.uidpublicstring-
failedFields?publicstring[]Field paths in request.resource.data that the rule referenced and that failed.
query?public{ limit?: number; offset?: number; orderBy?: string; where?: readonly { field: string; op: string; value: string | number | boolean; }[]; }Machine-readable descriptor of the denied query’s where/orderBy/limit shape (RULES-B11). Populated when a list/query is denied as statically unprovable (“rules are not filters”), so consumers can render the exact query the engine rejected without re-deriving it. Absent for single-doc and non-query denials.
query.limit?readonlynumber-
query.offset?readonlynumber-
query.orderBy?readonlystring-
query.where?readonlyreadonly { field: string; op: string; value: string | number | boolean; }[]-
reasons?publicstring[]Raw simulator reasoning lines (the underlying engine’s debugMessages). Always present on permission-denied. Stable enough for log surfacing; not stable as machine-parseable data.
request?public{ method: "delete" | "get" | "list" | "create" | "update"; path: string; resourceData?: Record<string, unknown>; }Eval-time request shape — what the rule saw on request.*. Lets callers render a “why did this deny” frame (auth, method, path, request.resource.data with sentinels resolved) without re-deriving any of it from out-of-band state.
request.methodpublic"delete" | "get" | "list" | "create" | "update"-
request.pathpublicstring-
request.resourceData?publicRecord<string, unknown>The user’s proposed request.resource.data — pre-resolution. FieldValue.* sentinels are preserved as their marker shapes ({ __type: 'serverTimestamp' }, etc.). The rule engine evaluated against the resolved form; what surfaces here is the caller’s INTENT so consumers see what they tried to write. Absent for reads (no proposed write) and for delete (no payload).
resource?public{ data: Record<string, unknown>; exists: boolean; }Eval-time existing-document snapshot — what the rule saw on resource.data. null data with exists: false mirrors how the rule sees an absent doc. Absent for collection ops (list).
resource.datapublicRecord<string, unknown>-
resource.existspublicboolean-
rule?public{ citation?: string; col?: number; column?: number; expression?: string; file?: string; line?: number; }The rule whose evaluation produced the denial (#370).
rule.citation?publicstring-
rule.col?publicnumber-
rule.column?publicnumber-
rule.expression?publicstring-
rule.file?publicstring-
rule.line?publicnumber-

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

PropertyType
auth?{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
code"permission-denied"
messagestring
reasons?string[]
request?{ method: "delete" | "get" | "list" | "create" | "update"; path: string; resourceData?: Record<string, unknown>; }
request.method"delete" | "get" | "list" | "create" | "update"
request.pathstring
request.resourceData?Record<string, unknown>
resource?{ data: Record<string, unknown>; exists: boolean; }
resource.dataRecord<string, unknown>
resource.existsboolean

EventProvenance

Compatibility provenance carried by sandbox events while producers and consumers migrate to the canonical operationContext.

Properties

PropertyTypeDescription
activity?ActivityEventProvenanceFirestore activity-diagnostics provenance; absent outside that flow.
actor?EventActor-
authLens?AuthLens-
operationContext?OperationContext-
planId?stringSet 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

PropertyTypeDescription
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
error?{ code: "permission-denied"; message: string; reasons?: string[]; }Populated on listener_errored only.
error.code"permission-denied"-
error.messagestring-
error.reasons?string[]-
idstring-
kind"listener_attach" | "listener_detach" | "listener_errored"-
listenerIdstring-
target| { kind: "doc"; path: string; } | { collection: string; kind: "query"; query?: unknown; }-

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

PropertyModifierTypeDescription
[LOCAL_SANDBOX]readonlytrue-
adminreadonlySandboxAdminAdmin-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.
currentUserpublic{ 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?publicRecord<string, unknown>-
currentUser.uidpublicstring-

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

Sandbox.clearPersistence

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

Sandbox.dispose

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
ParameterType
optionsSandboxPersistenceOptions
Returns

Promise<void>

Inherited from

Sandbox.enablePersistence

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
ParameterType
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

Sandbox.enableTabSync

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

Sandbox.flush

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

SandboxEvent[]

Inherited from

Sandbox.history

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
ParameterType
dataSandboxSnapshot
Returns

void

Inherited from

Sandbox.loadSnapshot

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
ParameterType
cb(user: { token?: Record<string, unknown>; uid: string; }) => void
Returns
(): void;
Returns

void

Inherited from

Sandbox.onCurrentUserChanged

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
ParameterType
cb(event: SandboxEvent) => void
Returns
(): void;
Returns

void

Inherited from

Sandbox.onEvent

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
ParameterType
namestring
hooksPersistableService
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

Sandbox.reset

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

Sandbox.resetAll

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
ParameterType
provenanceEventProvenance
fn() => T
Returns

T

Inherited from

Sandbox.runWithProvenance

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

SandboxSnapshot

Inherited from

Sandbox.snapshot

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
ParameterType
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
Returns

SandboxContext

Example
const sandbox = initializeSandbox();
const dbAlice = getFirestore(sandbox.withAuth({ uid: 'alice' }));
const dbAnon  = getFirestore(sandbox.withAuth(null));
Inherited from

Sandbox.withAuth


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

PropertyModifierType
authLensreadonlyAuthLens
planId?readonlystring
sourcereadonlyEventActor

OperationRecord

Properties

PropertyModifierType
atreadonlynumber
authreadonly{ token?: Record<string, unknown>; uid: string; }
auth.token?publicRecord<string, unknown>
auth.uidpublicstring
contextreadonlyOperationContext
eventKindreadonly"request" | "listener" | "operation"
idreadonlystring
methodreadonlystring
path?readonlystring
result?readonly"unsupported" | "error" | "allow" | "deny" | "not-applicable"
rulesreadonlyRulesDisposition
servicereadonlyEventService

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

PropertyTypeDescription
session?{ currentUid: string; mode: "NONE" | "SESSION" | "LOCAL"; restore: void; subscribe: () => void; }Optional: session-level persistence hooks. When provided, the persistence controller uses these to save and restore the CURRENTLY SIGNED-IN user (not the user database — that’s snapshot/restore). The controller calls session.subscribe so it hears every sign-in / sign-out, then writes the uid to the appropriate web-storage slot (determined by session.mode()). On init, the controller reads the stored uid and its storage-derived mode, then calls session.restore(uid, mode) to re-establish both before firing onAuthStateChanged as if the user just signed in. Only active when SandboxPersistenceOptions.sessionStorage is provided; omitting sessionStorage causes the controller to skip session persistence entirely (no fake durability). Auth is the only service that provides session hooks today. The field is on the generic interface so the controller stays service-agnostic — if a second service ever needs session-style semantics it can add its own hooks without changing the controller.
session.currentUidstring-
session.mode"NONE" | "SESSION" | "LOCAL"-
session.restorevoid-
session.subscribe() => void-
subscribe?(onChange: () => void) => () => voidOptional: subscribe to changes in this service’s state. When provided, the persistence controller hooks it up and schedules a debounced flush on each change — ensuring auth-user edits reach the backend promptly, not only on the next Firestore write. Must return an unsubscribe function. The controller unsubscribes on dispose().

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
ParameterType
dataunknown
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
ParameterType
keystring
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
ParameterType
keystring
recordIdsreadonly 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
ParameterType
keystring
recordIdstring
Returns

Promise<unknown>

listRecords()
listRecords(key: string): Promise<string[]>;

List all record ids under key, any order.

Parameters
ParameterType
keystring
Returns

Promise<string[]>

putRecords()
putRecords(key: string, records: ReadonlyMap<string, unknown>): Promise<void>;

Write each [recordId, value] under key, replacing any prior value.

Parameters
ParameterType
keystring
recordsReadonlyMap<string, unknown>
Returns

Promise<void>


PersistenceController

Properties

PropertyModifierType
optionsreadonlyReadonly<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

PropertyModifierTypeDescription
[REMOTE_SANDBOX]readonlytrue-
adminreadonlySandboxAdminAdmin-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.
channelreadonlyRemoteSandboxChannelThe raw worker op/sub relay channel.
currentUserpublic{ 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?publicRecord<string, unknown>-
currentUser.uidpublicstring-
serveUrlreadonlystringBase URL of the pyric dev this handle is attached to (used in error guidance: “open in a browser and retry”).

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

Sandbox.clearPersistence

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

Sandbox.dispose

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
ParameterType
optionsSandboxPersistenceOptions
Returns

Promise<void>

Inherited from

Sandbox.enablePersistence

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
ParameterType
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

Sandbox.enableTabSync

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

Sandbox.flush

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

SandboxEvent[]

Inherited from

Sandbox.history

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
ParameterType
dataSandboxSnapshot
Returns

void

Inherited from

Sandbox.loadSnapshot

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
ParameterType
cb(user: { token?: Record<string, unknown>; uid: string; }) => void
Returns
(): void;
Returns

void

Inherited from

Sandbox.onCurrentUserChanged

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
ParameterType
cb(event: SandboxEvent) => void
Returns
(): void;
Returns

void

Inherited from

Sandbox.onEvent

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
ParameterType
namestring
hooksPersistableService
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

Sandbox.reset

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

Sandbox.resetAll

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
ParameterType
provenanceEventProvenance
fn() => T
Returns

T

Inherited from

Sandbox.runWithProvenance

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

SandboxSnapshot

Inherited from

Sandbox.snapshot

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
ParameterType
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
Returns

SandboxContext

Example
const sandbox = initializeSandbox();
const dbAlice = getFirestore(sandbox.withAuth({ uid: 'alice' }));
const dbAnon  = getFirestore(sandbox.withAuth(null));
Inherited from

Sandbox.withAuth


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 ” guidance when no peer is registered).

Parameters
ParameterType
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
ParameterType
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

PropertyTypeDescription
url?stringExplicit 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

PropertyTypeDescription
pinRequestTime?booleanWhen true (default), re-issue the captured requestTime so serverTimestamp() sentinels resolve to the same value as capture and request.time-gated rules evaluate identically.

ReplayResult

Properties

PropertyTypeDescription
divergencesDivergence[]Field- and path-level differences between original and replayed state, classified.
pathAliasesMap<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.
sandboxLocalSandboxFresh 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:

  • user single op via the data-plane adapter (admin / firestore).
  • batch part of a multi-op batch — shares groupId with siblings.
  • transaction part of a transaction commit — shares groupId.
  • listener a write or deployRules triggered a snapshot listener to re-evaluate. Carries triggeredBy naming 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

PropertyTypeDescription
atnumberWall-clock at op start, ms since epoch.
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
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.
evalMsnumberWall-clock duration of the simulator.simulate(…) call, in ms.
evaluatedRule?EvaluatedRuleInfoThe 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?stringShared across ops in one batch or transaction. Opaque to consumers.
groupKind?"transaction" | "batch"Disambiguates `origin: ‘transaction'
idstringUnique 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.operationsstring[]-
matchedRule.ruleIndexnumber-
method"delete" | "get" | "list" | "create" | "update" | "set"-
origin"user" | "transaction" | "listener" | "batch"-
pathstring-
reasonsstring[]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.dataRecord<string, unknown>-
resourceAfter.existsboolean-
resourceBefore?{ data: Record<string, unknown>; exists: boolean; }Existing document state before the write (or read target for get).
resourceBefore.dataRecord<string, unknown>-
resourceBefore.existsboolean-
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?RulesDispositionCanonical 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.methodstring-
triggeredBy.pathstring-

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

PropertyType
atnumber
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
data?unknown
detail?Record<string, unknown>
groupId?string
groupKind?"transaction" | "batch"
idstring
kind"commit"
methodstring
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; }[]
serviceEventService

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

PropertyType
atnumber
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
detail?Record<string, unknown>
error?{ code?: string; message: string; reasons?: string[]; }
error.code?string
error.messagestring
error.reasons?string[]
idstring
kind"listener"
listenerIdstring
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
serviceEventService
size?number
target{ kind: string; path?: string; query?: unknown; }
target.kindstring
target.path?string
target.query?unknown
triggeredBy?{ method: string; path?: string; }
triggeredBy.methodstring
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

PropertyTypeDescription
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
detail?Record<string, unknown>-
durationMs?number-
groupId?string-
groupKind?"transaction" | "batch"-
idstring-
kind"operation"-
methodstring-
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.dataunknown-
resourceAfter.existsboolean-
resourceBefore?{ data: unknown; exists: boolean; }-
resourceBefore.dataunknown-
resourceBefore.existsboolean-
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?RulesDispositionCanonical 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.
serviceEventService-
triggeredBy?{ method: string; path?: string; }-
triggeredBy.methodstring-
triggeredBy.path?string-

SandboxPersistenceOptions

Controller options. See Sandbox.enablePersistence.

Properties

PropertyTypeDescription
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?numberDebounce 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?PersistenceBackendOverride the backend with an injected implementation. Used by tests and hosts that have their own storage adapter. When set, backend is ignored.
keystringIndexedDB 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.localWebStorageLike-
sessionStorage.sessionWebStorageLike-

SandboxRuntimeErrorEvent

Canonical non-rules operational failure.

Properties

PropertyType
atnumber
auth{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
detail?Record<string, unknown>
error{ code?: string; message: string; }
error.code?string
error.messagestring
idstring
kind"runtime_error"
methodstring
path?string
serviceEventService

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

PropertyTypeDescription
firestoreRecord<string, Record<string, unknown>>Firestore documents, keyed by full path. Always present — empty {} for a fresh or just-reset sandbox. Per-document values are the post-resolution state the keyspace stored.
servicesRecord<string, unknown>Per-service opaque state, keyed by service name (e.g. 'auth'). Each entry is whatever the service’s PersistableService.snapshot() returned. May be {} when no services are registered.

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 beforeafter when both are present.

Properties

PropertyTypeDescription
after?unknownBest-effort serializable snapshot of the state AFTER the mutation. Absent on deletes / sign-outs (nothing remains).
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }Identity in effect when the op ran (the service’s request.auth equivalent). null for admin/anonymous-driven mutations (e.g. sandbox.createUser, an unauthenticated RTDB write).
auth.token?Record<string, unknown>-
auth.uidstring-
before?unknownBest-effort serializable snapshot of the state BEFORE the mutation. Absent when there was no prior state (a create) or it isn’t cheap to capture.
detail?Record<string, unknown>Free-form, service-specific extras a consumer may surface without re-deriving (e.g. storage { size, contentType }, rtdb { committed } for a transaction). Kept loose on purpose — it’s a display hint, not a contract.
idstring-
kind"service_mutation"-
opstringService-scoped operation name. Stable, lowercase, snake/kebab-free: - auth: user_create
path?stringThe thing mutated, in the service’s own addressing scheme: - auth: the user uid (or '*' for a clear-all). Absent for a sign-out with no prior user. - storage: the object fullPath (e.g. avatars/alice.png). - rtdb: the database path (e.g. /rooms/r1/messages), or for a multi-path update the ref path the call targeted.
service"rtdb" | "auth" | "storage" | "messaging" | "ai"Which service performed the mutation. Always one of the non-Firestore services — Firestore rides its own request/write path. (The provenance service field on the stamped event mirrors this; it is set redundantly here so a consumer matching purely on kind still gets the discriminator without reaching into provenance.)

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

PropertyTypeDescription
atnumber-
idstring-
kind"session_boundary"-
phase"reset" | "dispose"-
priorOpCountnumberTotal 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

PropertyTypeDescription
addedCountnumber-
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
idstring-
kind"snapshot_delivery"-
listenerIdstringOpaque listener id assigned at attach time.
modifiedCountnumber-
removedCountnumber-
sample?{ docs: { data: Record<string, unknown>; path: string; }[]; }Doc payloads, in the order the user callback saw them.
sample.docs{ data: Record<string, unknown>; path: string; }[]-
sizenumber1 for doc-kind (exists) / 0 (deleted), n for query-kind.
target| { kind: "doc"; path: string; } | { collection: string; kind: "query"; }-
triggeredBy?{ method: string; path: string; }The user op that triggered this re-eval. Absent on initial fire and on deployRules-driven re-evals.
triggeredBy.methodstring-
triggeredBy.pathstring-

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

PropertyType
auth?{ token?: Record<string, unknown>; uid: string; }
auth.token?Record<string, unknown>
auth.uidstring
code"permission-denied"
messagestring
reasons?string[]
request?{ method: "delete" | "get" | "list" | "create" | "update"; path: string; resourceData?: Record<string, unknown>; }
request.method"delete" | "get" | "list" | "create" | "update"
request.pathstring
request.resourceData?Record<string, unknown>
resource?{ data: Record<string, unknown>; exists: boolean; }
resource.dataRecord<string, unknown>
resource.existsboolean
target| { kind: "doc"; path: string; } | { collection: string; kind: "query"; query?: unknown; }

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

PropertyTypeDescription
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
idstring-
kind"snapshot_suppressed"-
listenerIdstring-
reason"no-op"Why this re-eval was suppressed. v1 only emits 'no-op'.
target| { kind: "doc"; path: string; } | { collection: string; kind: "query"; }-
triggeredBy?{ method: string; path: string; }-
triggeredBy.methodstring-
triggeredBy.pathstring-

TabSyncOptions

Options for sandbox.enableTabSync(options?). All fields are optional; the defaults provide a ready-to-use configuration for browser environments.

Properties

PropertyTypeDescription
channel?BroadcastChannelLikeThe 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?stringA 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
ParameterType
keystring
Returns

string

removeItem()
removeItem(key: string): void;
Parameters
ParameterType
keystring
Returns

void

setItem()
setItem(key: string, value: string): void;
Parameters
ParameterType
keystring
valuestring
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

PropertyTypeDescription
atnumber-
auth{ token?: Record<string, unknown>; uid: string; }-
auth.token?Record<string, unknown>-
auth.uidstring-
autoId?stringMinted 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"-
idstring-
kind"write"-
method"delete" | "create" | "update" | "set"-
nextStateRecord<string, unknown>State AFTER this write. null on delete.
pathstring-
priorStateRecord<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.nanosecondsnumber-
requestTime.secondsnumber-
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

ParameterType
opts?RemoteSandboxFactoryOptions

Returns

RemoteSandbox


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

ParameterType
branchBranch
eventsreadonly SandboxEvent[]

Returns

Branch

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:

  1. restore() returns the raw services blob map so the controller can apply it to late-arriving services.
  2. setServiceRegistrationHook fires on each registration — we immediately apply the saved blob data (if any) AND subscribe the service’s change notifier for future flushes.

Parameters

ParameterType
sandboxSandbox
rawOptionsSandboxPersistenceOptions

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

ParameterTypeDescription
sandboxLocalSandboxThe sandbox to sync. Must expose onEvent, admin, and snapshot.
options?TabSyncOptionsOptional 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

ParameterType
recordsReadonlyMap<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

PersistenceBackend


createMemoryBackend()

function createMemoryBackend(): PersistenceBackend;

Returns

PersistenceBackend


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

ParameterType
recordsIterable<[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

ParameterTypeDescription
branchBranchThe experiment.
targetDiffTargetLive sandbox or a snapshot to compare against.

Returns

Divergence[]


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

ParameterType
branchBranch

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

ParameterTypeDescription
snapshotSandboxSnapshotBaseline state — typically liveSandbox.snapshot().
rules?stringRules 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

Branch


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

ParameterType
_config?SandboxConfig

Returns

LocalSandbox

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

ParameterType
eventSandboxEvent

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

ParameterType
sandboxSandbox

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

ParameterType
eventPick<EventProvenance, "operationContext" | "actor" | "authLens" | "planId">

Returns

OperationContext


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

ParameterType
blobstring

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

ParameterTypeDescription
branchBranchThe experiment to land.
targetLocalSandboxThe 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

ParameterType
io{ clear: Promise<void>; read: Promise<string>; write: Promise<void>; }
io.clear
io.read
io.write

Returns

PersistenceBackend


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

ParameterType
valueunknown

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

ParameterType
eventsreadonly SandboxEvent[]
rulesstring
options?ReplayOptions
originalState?Record<string, DocData>

Returns

ReplayResult


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

ParameterType
eventOperationEvent

Returns

RulesDisposition


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

ParameterType
firestoreRecord<string, Record<string, unknown>>
servicesRecord<string, unknown>
savedAtnumber

Returns

Map<string, BucketRecord | MetaRecord>


toOperationRecord()

function toOperationRecord(event: SandboxEvent): OperationRecord;

Project either traffic event family into the canonical record.

Parameters

ParameterType
eventSandboxEvent

Returns

OperationRecord

References

AuthState

Re-exports AuthState


Sandbox

Re-exports Sandbox


SandboxContext

Re-exports SandboxContext


SandboxError

Re-exports SandboxError