pyric-admin 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ /**
2
+ * `pyric-admin/database` — sandbox mirror for the admin-shape RTDB surface.
3
+ *
4
+ * Mirrors `firebase-admin/database` for the admin-shape RTDB surface,
5
+ * selected by the sandbox brand on {@link PyricAdminApp}. The local path is
6
+ * an in-memory RTDB implementing this load-bearing data-plane subset:
7
+ *
8
+ * - `Database.ref(path?)` returns a {@link Reference}.
9
+ * - `Reference#set(value)` writes into the in-memory tree.
10
+ * - `Reference#get()` reads; returns a `DataSnapshot`-shaped
11
+ * `{ exists(), val(), key, child(), forEach(), … }`.
12
+ * - `Reference#update(values)` merges children (a `null` value
13
+ * removes the corresponding child).
14
+ * - `Reference#remove()` deletes the subtree.
15
+ * - `Reference#push(value?)` mints a 20-char push id and writes
16
+ * the value at the new child path.
17
+ * - `Reference#child(path)` returns a relative ref.
18
+ *
19
+ * **Not implemented (sandbox backend only):**
20
+ *
21
+ * - Listeners — `on('value' | 'child_added' | …)`,
22
+ * `onDisconnect`, `off` — throw a clear "not implemented" error.
23
+ * The modular `pyric/database` surface has full listener
24
+ * support; the admin-shape sandbox surface defers them until
25
+ * a user actually needs the chainable admin listener shape.
26
+ * - Transactions — `Reference#transaction(updater)` throws
27
+ * "not implemented". The modular `pyric/database` surface has
28
+ * `runTransaction`; the admin-shape variant lands when needed.
29
+ * - Query builders — `orderByChild`/`equalTo`/`limitToFirst`/…
30
+ * on `Reference` throw "not implemented".
31
+ * - Multi-path atomic updates (root-level
32
+ * `update({ '/a': v1, '/b': v2 })`) — supported only as shallow
33
+ * merge at the ref's path. The modular surface has the full
34
+ * multi-path variant.
35
+ * - `Reference#setPriority` / `setWithPriority` — RTDB priority
36
+ * semantics aren't modeled; calls throw "not implemented".
37
+ * - `Database.getRules` / `setRules` / `getRulesJSON` — admin-
38
+ * only metadata. Sandbox writes are rule-bypass (matches the
39
+ * firebase-admin behavior of bypassing rules), so there's no
40
+ * backing rule state to expose.
41
+ *
42
+ * Sandbox state lives on the underlying `Sandbox` via a `WeakMap`
43
+ * keyed by the `Sandbox` instance — `sandbox.reset()` wipes it via
44
+ * the sandbox's `session_boundary` event with `phase: 'reset'`.
45
+ * Successive `getDatabase(app)` calls for the same sandbox return
46
+ * handles that share data (matches firebase-admin's
47
+ * singleton-per-app semantics).
48
+ *
49
+ * - **Remote sandbox arm** (sandbox target whose `Sandbox` carries the
50
+ * `pyric/sandbox` remote brand — a Node-side handle onto the
51
+ * browser-hosted SharedWorker sandbox, built by `@pyric/cli`'
52
+ * `connectRemoteSandbox()`) — every `Reference` data operation routes
53
+ * through the handle's worker-relay channel (`rtdb.get/set/update/
54
+ * remove/push` ops with `actAs: { mode: 'admin' }` pinned — firebase-
55
+ * admin's rules-bypass semantics), NOT into the process-local tree:
56
+ * a local tree on a remote handle would be private server-side data
57
+ * the browser never sees. Differences from the local arm, both
58
+ * deliberate upgrades:
59
+ *
60
+ * - `on('value')` / `once('value')` WORK (routed through the
61
+ * channel's RTDB value subscription; other event types still
62
+ * throw "not implemented").
63
+ * - `update()` relays to the worker's full multi-path update
64
+ * (`pyric/database` semantics) rather than the local
65
+ * arm's shallow per-key merge.
66
+ * - Server-side writes run through the real worker RTDB backend,
67
+ * so they emit `SandboxEvent`s into the unified stream (visible
68
+ * to Studio/agents) and fire the app's live listeners.
69
+ *
70
+ * `push()` keeps its sync `.key`: the client mints the push id and
71
+ * sends it with the `rtdb.push` op (the worker-protocol contract).
72
+ */
73
+ import { isRemoteSandbox, } from 'pyric/sandbox';
74
+ import { ADMIN_APP_TARGET, getApp, } from '../app/index.js';
75
+ import { assertAdminAppActive } from '../app/lifecycle.js';
76
+ /**
77
+ * Returns the {@link AdminDatabase} service for the supplied app.
78
+ *
79
+ * Signature mirrors `firebase-admin/database`'s `getDatabase(app?)`.
80
+ *
81
+ * - `getDatabase()` — default database for the DEFAULT app (resolved
82
+ * through `pyric-admin/app`'s registry, exactly like firebase-admin's
83
+ * no-arg `getDatabase()`; throws `app/no-app` when no default app has
84
+ * been initialized). Works for local and remote sandbox apps.
85
+ * - `getDatabase(app)` — default database for the app.
86
+ * - `getDatabase(app, url)` — legacy Pyric-only compatibility form. New
87
+ * code should use the upstream-shaped {@link getDatabaseWithUrl} export.
88
+ *
89
+ * The sandbox brand returns the local or remote `Database` backed by the
90
+ * per-`Sandbox` state described in the module-level docs.
91
+ */
92
+ export function getDatabase(app, _url) {
93
+ if (app === undefined) {
94
+ // No-arg mirror of firebase-admin's `getDatabase()` — resolve the
95
+ // '[DEFAULT]' app from the registry (throws app/no-app on a miss).
96
+ app = getApp();
97
+ }
98
+ assertAdminAppActive(app);
99
+ if (app[ADMIN_APP_TARGET] === 'sandbox') {
100
+ return getSandboxDatabase(app.sandbox);
101
+ }
102
+ throw new TypeError('pyric-admin/database: getDatabase expected a PyricAdminApp ' +
103
+ '(initialize via `initializeApp` from pyric-admin/app).');
104
+ }
105
+ /**
106
+ * Returns the {@link AdminDatabase} service selected by an upstream-shaped
107
+ * database URL.
108
+ *
109
+ * This is the exact `firebase-admin/database` argument order used by the
110
+ * Firebase Functions SDK: `getDatabaseWithUrl(url, app?)`. The first Pyric
111
+ * Functions slice has one shared RTDB instance, so the URL selects that
112
+ * instance rather than creating a second sandbox database.
113
+ */
114
+ export function getDatabaseWithUrl(_url, app) {
115
+ return getDatabase(app);
116
+ }
117
+ /** One backend per `Sandbox`. Successive `getDatabase(app)` calls for
118
+ * the same sandbox return handles that share data — matches
119
+ * firebase-admin's singleton-per-app semantics. */
120
+ const stateBySandbox = new WeakMap();
121
+ function getOrCreateState(sandbox) {
122
+ let state = stateBySandbox.get(sandbox);
123
+ if (state !== undefined)
124
+ return state;
125
+ state = { root: {} };
126
+ stateBySandbox.set(sandbox, state);
127
+ // Wire `sandbox.reset()` → wipe the tree. `session_boundary` fires
128
+ // before the env swap, so consumer code that observes a reset sees
129
+ // the freshly-cleared tree on the next read. `dispose` also fires a
130
+ // boundary; treat it the same (the sandbox is being torn down — any
131
+ // in-flight handle on the tree gets an empty view).
132
+ sandbox.onEvent((event) => {
133
+ if (event.kind === 'session_boundary') {
134
+ state.root = {};
135
+ }
136
+ });
137
+ return state;
138
+ }
139
+ /** Build (or reuse) the sandbox Database handle for `sandbox`.
140
+ *
141
+ * REMOTE handles dispatch here, BEFORE any local state is touched: a
142
+ * remote sandbox must never get a `SandboxState` (a private local tree)
143
+ * or a `sandbox.onEvent` wire-up (which throws on remote handles). */
144
+ function getSandboxDatabase(sandbox) {
145
+ if (isRemoteSandbox(sandbox)) {
146
+ return getRemoteDatabase(sandbox);
147
+ }
148
+ const state = getOrCreateState(sandbox);
149
+ return buildSandboxDatabase(state);
150
+ }
151
+ function buildSandboxDatabase(state) {
152
+ return buildDatabaseShell((db, path) => buildSandboxRef(state, db, path));
153
+ }
154
+ /**
155
+ * The `Database`-level shell shared by the local and remote sandbox arms —
156
+ * everything except how a `Reference` is built. Rules metadata isn't
157
+ * modeled on either arm; connection toggles are no-ops (the sandbox IS the
158
+ * local emulator).
159
+ */
160
+ function buildDatabaseShell(refFactory) {
161
+ const db = {
162
+ ref(path) {
163
+ return refFactory(db, path ?? '/');
164
+ },
165
+ refFromURL(url) {
166
+ // Best-effort: strip the `https://<host>` prefix and treat the
167
+ // remainder as a path. The sandbox has no notion of multi-database
168
+ // hosts, so the host portion is ignored.
169
+ const u = url.replace(/^https?:\/\/[^/]+/, '');
170
+ return refFactory(db, u || '/');
171
+ },
172
+ // Admin-only metadata methods — not modeled in the sandbox. The
173
+ // sandbox is rule-bypass by construction; surfacing rule JSON would
174
+ // require a parallel rules store that has no users yet.
175
+ getRules() {
176
+ throw new Error('pyric-admin/database sandbox: getRules not implemented');
177
+ },
178
+ getRulesJSON() {
179
+ throw new Error('pyric-admin/database sandbox: getRulesJSON not implemented');
180
+ },
181
+ setRules(_source) {
182
+ throw new Error('pyric-admin/database sandbox: setRules not implemented');
183
+ },
184
+ useEmulator(_host, _port) {
185
+ // No-op — the sandbox IS a local emulator. Accept the call so
186
+ // consumer code that calls `useEmulator` unconditionally compiles.
187
+ },
188
+ goOffline() {
189
+ // No-op — sandbox has no network connection to drop.
190
+ },
191
+ goOnline() {
192
+ // No-op — sandbox has no network connection to reopen.
193
+ },
194
+ // `app` is required on the firebase-admin Database interface; the
195
+ // sandbox doesn't carry a firebase-admin App, so we stub it. The
196
+ // load-bearing data-plane methods above don't read it.
197
+ app: undefined,
198
+ };
199
+ return db;
200
+ }
201
+ // ─── Path utilities ──────────────────────────────────────────────────
202
+ /** Path segments that must never be walked or written: because the tree is
203
+ * backed by plain JS objects, a segment named `__proto__` (or, as
204
+ * defence-in-depth, `constructor`/`prototype`) would reach the shared
205
+ * `Object.prototype` and let a write pollute it process-wide (a path
206
+ * arrives via JSON/MCP transports that preserve `__proto__` as a genuine
207
+ * own key). Real RTDB stores a server-side tree with no such reserved
208
+ * keys, so rejecting them is a sandbox-only safety constraint, not a
209
+ * parity regression. Twin of the `pyric/database` DataTree guard (#760). */
210
+ const UNSAFE_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
211
+ /** Normalise a path to non-empty segments. `'/'` → `[]`.
212
+ * Throws if any segment is a prototype-pollution vector. */
213
+ function pathSegments(path) {
214
+ if (path === '' || path === '/')
215
+ return [];
216
+ const segs = path.split('/').filter((s) => s.length > 0);
217
+ for (const seg of segs) {
218
+ if (UNSAFE_SEGMENTS.has(seg)) {
219
+ throw new Error(`Invalid RTDB path segment '${seg}': the keys __proto__, prototype, ` +
220
+ 'and constructor are reserved and cannot appear in a path.');
221
+ }
222
+ }
223
+ return segs;
224
+ }
225
+ /** Join segments back into a `/`-prefixed canonical path. `[]` → `'/'`. */
226
+ function joinPath(segments) {
227
+ if (segments.length === 0)
228
+ return '/';
229
+ return '/' + segments.join('/');
230
+ }
231
+ /** Deep-clone a JSON value so stored state doesn't share identity with
232
+ * caller-held references. */
233
+ function cloneJson(v) {
234
+ if (v === null || typeof v !== 'object')
235
+ return v;
236
+ if (Array.isArray(v))
237
+ return v.map((x) => cloneJson(x));
238
+ const out = {};
239
+ for (const [k, val] of Object.entries(v)) {
240
+ out[k] = cloneJson(val);
241
+ }
242
+ return out;
243
+ }
244
+ /** Read the value at `path` in `root`. `null` for absent paths. */
245
+ function readPath(root, path) {
246
+ const segs = pathSegments(path);
247
+ let node = root;
248
+ for (const seg of segs) {
249
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
250
+ return null;
251
+ }
252
+ const obj = node;
253
+ // Own-property check only: `seg in obj` would follow inherited keys
254
+ // (e.g. an unvalidated `__proto__`) into the object prototype.
255
+ if (!Object.hasOwn(obj, seg))
256
+ return null;
257
+ node = obj[seg];
258
+ }
259
+ return cloneJson(node);
260
+ }
261
+ /** Write `value` at `path`. `null` deletes. Trims empty ancestor objects. */
262
+ function writePath(root, path, value) {
263
+ const segs = pathSegments(path);
264
+ if (segs.length === 0) {
265
+ // Root write — clear all keys and replace.
266
+ for (const k of Object.keys(root))
267
+ delete root[k];
268
+ if (value === null)
269
+ return;
270
+ if (typeof value !== 'object' || Array.isArray(value)) {
271
+ throw new Error('pyric-admin/database sandbox: root write must be an object (or null to clear).');
272
+ }
273
+ Object.assign(root, cloneJson(value));
274
+ return;
275
+ }
276
+ // Walk to parent, creating intermediate objects as needed.
277
+ let cursor = root;
278
+ for (let i = 0; i < segs.length - 1; i++) {
279
+ const k = segs[i];
280
+ // Own-property read only: bare `cursor[k]` would resolve an unvalidated
281
+ // `__proto__` segment to the shared object prototype.
282
+ const next = Object.hasOwn(cursor, k) ? cursor[k] : undefined;
283
+ if (next === undefined ||
284
+ next === null ||
285
+ typeof next !== 'object' ||
286
+ Array.isArray(next)) {
287
+ const fresh = {};
288
+ cursor[k] = fresh;
289
+ cursor = fresh;
290
+ }
291
+ else {
292
+ cursor = next;
293
+ }
294
+ }
295
+ const lastKey = segs[segs.length - 1];
296
+ if (value === null) {
297
+ delete cursor[lastKey];
298
+ trimEmptyAncestors(root, segs);
299
+ }
300
+ else {
301
+ cursor[lastKey] = cloneJson(value);
302
+ }
303
+ }
304
+ /** Remove now-empty object ancestors after a delete. RTDB invariant:
305
+ * "Empty nodes don't exist". */
306
+ function trimEmptyAncestors(root, segs) {
307
+ for (let depth = segs.length - 1; depth >= 1; depth--) {
308
+ const parentSegs = segs.slice(0, depth);
309
+ const lastKey = segs[depth - 1];
310
+ let parent = root;
311
+ for (let i = 0; i < parentSegs.length - 1; i++) {
312
+ const next = parent[parentSegs[i]];
313
+ if (next === undefined ||
314
+ next === null ||
315
+ typeof next !== 'object' ||
316
+ Array.isArray(next)) {
317
+ return;
318
+ }
319
+ parent = next;
320
+ }
321
+ const child = parent[lastKey];
322
+ if (child !== undefined &&
323
+ child !== null &&
324
+ typeof child === 'object' &&
325
+ !Array.isArray(child)) {
326
+ const obj = child;
327
+ if (Object.keys(obj).length === 0) {
328
+ delete parent[lastKey];
329
+ continue;
330
+ }
331
+ }
332
+ return;
333
+ }
334
+ }
335
+ // ─── Push-id generator ────────────────────────────────────────────────
336
+ //
337
+ // Lifted from `pyric/database/sandbox/push-id.ts` — the algorithm
338
+ // matches firebase-js-sdk's published `nextPushId` exactly so a sandbox-
339
+ // minted key is shape-compatible with a real `push(ref).key`. Inlined
340
+ // here so `pyric-admin/database` doesn't need to import an internal
341
+ // path from `pyric` (which isn't exported as a public subpath).
342
+ const PUSH_CHARS = '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
343
+ let lastPushTime = 0;
344
+ const lastRandChars = new Array(12).fill(0);
345
+ function generatePushId(now = Date.now()) {
346
+ const duplicateTime = now === lastPushTime;
347
+ lastPushTime = now;
348
+ const timeStampChars = new Array(8);
349
+ let ts = now;
350
+ for (let i = 7; i >= 0; i--) {
351
+ timeStampChars[i] = PUSH_CHARS.charAt(ts % 64);
352
+ ts = Math.floor(ts / 64);
353
+ }
354
+ if (ts !== 0) {
355
+ throw new Error('RTDB push-id: timestamp overflow.');
356
+ }
357
+ let id = timeStampChars.join('');
358
+ if (!duplicateTime) {
359
+ for (let i = 0; i < 12; i++) {
360
+ lastRandChars[i] = Math.floor(Math.random() * 64);
361
+ }
362
+ }
363
+ else {
364
+ let i;
365
+ for (i = 11; i >= 0 && lastRandChars[i] === 63; i--) {
366
+ lastRandChars[i] = 0;
367
+ }
368
+ if (i < 0) {
369
+ for (let j = 0; j < 12; j++) {
370
+ lastRandChars[j] = Math.floor(Math.random() * 64);
371
+ }
372
+ }
373
+ else {
374
+ lastRandChars[i] = (lastRandChars[i] ?? 0) + 1;
375
+ }
376
+ }
377
+ for (let i = 0; i < 12; i++) {
378
+ id += PUSH_CHARS.charAt(lastRandChars[i]);
379
+ }
380
+ return id;
381
+ }
382
+ // ─── Reference ────────────────────────────────────────────────────────
383
+ /** The sentinel thrown by listener / transaction / query / priority
384
+ * methods on the sandbox `Reference`. Documented in the module-level
385
+ * comment under "Not implemented". */
386
+ function notImplemented(method) {
387
+ return new Error(`pyric-admin/database sandbox: ${method} not implemented`);
388
+ }
389
+ /** Build a sandbox `Reference` at `path`. The returned object satisfies
390
+ * the load-bearing subset of `firebase-admin/database`'s `Reference`
391
+ * shape; listener / query / transaction methods throw the "not
392
+ * implemented" sentinel. */
393
+ function buildSandboxRef(state, db, path) {
394
+ const canonical = joinPath(pathSegments(path));
395
+ const segs = pathSegments(canonical);
396
+ const key = segs.length === 0 ? null : segs[segs.length - 1];
397
+ const ref = {
398
+ key,
399
+ get parent() {
400
+ if (segs.length === 0)
401
+ return null;
402
+ return buildSandboxRef(state, db, joinPath(segs.slice(0, -1)));
403
+ },
404
+ get root() {
405
+ return buildSandboxRef(state, db, '/');
406
+ },
407
+ get path() {
408
+ return canonical;
409
+ },
410
+ toString() {
411
+ return `sandbox://rtdb${canonical}`;
412
+ },
413
+ // ─── Data-plane methods (implemented) ────────────────────────────
414
+ /** Set `value` at this path. `null` deletes. */
415
+ async set(value) {
416
+ writePath(state.root, canonical, value);
417
+ },
418
+ /** Read this path. Resolves to a {@link DataSnapshot}-shaped value. */
419
+ async get() {
420
+ const val = readPath(state.root, canonical);
421
+ return buildSandboxSnap((p) => buildSandboxRef(state, db, p), canonical, val);
422
+ },
423
+ /** `once(eventType)` — admin-shape one-shot read. Only `'value'` is
424
+ * supported in the sandbox (the only event type that doesn't
425
+ * require a listener registry). Mirrors firebase-admin's
426
+ * `Reference#once('value')` for the common get-style usage. */
427
+ async once(eventType, _successCb, _failureCb, _context) {
428
+ if (eventType !== 'value') {
429
+ throw notImplemented(`once('${eventType}')`);
430
+ }
431
+ const val = readPath(state.root, canonical);
432
+ return buildSandboxSnap((p) => buildSandboxRef(state, db, p), canonical, val);
433
+ },
434
+ /** Shallow merge: each key in `values` replaces the corresponding
435
+ * child at this path. `null` values delete. */
436
+ async update(values) {
437
+ if (values === null || typeof values !== 'object') {
438
+ throw new TypeError('pyric-admin/database sandbox: update expected an object.');
439
+ }
440
+ for (const [k, v] of Object.entries(values)) {
441
+ const subSegs = [...segs, ...pathSegments(k)];
442
+ writePath(state.root, joinPath(subSegs), v);
443
+ }
444
+ },
445
+ /** Delete the subtree at this path. Equivalent to `set(null)`. */
446
+ async remove() {
447
+ writePath(state.root, canonical, null);
448
+ },
449
+ /** Mint a 20-char push id, optionally writing `value` at the new
450
+ * child. Returns a Reference at the new child path. The shape
451
+ * matches firebase-admin's `ThenableReference` — `.then()` resolves
452
+ * once the (synchronous) write completes; the underlying ref is
453
+ * available synchronously via the returned object's own methods. */
454
+ push(value, onComplete) {
455
+ const id = generatePushId();
456
+ const childPath = joinPath([...segs, id]);
457
+ if (value !== undefined) {
458
+ try {
459
+ writePath(state.root, childPath, value);
460
+ }
461
+ catch (err) {
462
+ if (onComplete)
463
+ onComplete(err);
464
+ throw err;
465
+ }
466
+ }
467
+ if (onComplete)
468
+ onComplete(null);
469
+ const childRef = buildSandboxRef(state, db, childPath);
470
+ // ThenableReference: the ref plus a `.then()` that resolves to it.
471
+ // Returning a Reference with a tacked-on `.then` satisfies the
472
+ // firebase-admin shape for the common `push(value).key` usage.
473
+ // CRITICAL: the promise must resolve with a PLAIN (non-thenable)
474
+ // ref — resolving with the thenable itself would make promise
475
+ // resolution unwrap it forever (`await push(...)` would spin).
476
+ const resolvedRef = buildSandboxRef(state, db, childPath);
477
+ const thenable = childRef;
478
+ thenable.then = (onFulfilled, onRejected) => Promise.resolve(resolvedRef).then(onFulfilled, onRejected);
479
+ thenable.catch = (onRejected) => Promise.resolve(resolvedRef).catch(onRejected);
480
+ return thenable;
481
+ },
482
+ /** Relative ref builder. `child(parent, 'sub/path')` returns a ref
483
+ * at `<parent>/sub/path`. */
484
+ child(p) {
485
+ const absSegs = [...segs, ...pathSegments(p)];
486
+ return buildSandboxRef(state, db, joinPath(absSegs));
487
+ },
488
+ // ─── Not implemented in the sandbox backend ──────────────────────
489
+ on(_eventType, ..._rest) {
490
+ throw notImplemented('on');
491
+ },
492
+ off(_eventType, ..._rest) {
493
+ throw notImplemented('off');
494
+ },
495
+ onDisconnect() {
496
+ throw notImplemented('onDisconnect');
497
+ },
498
+ transaction(..._args) {
499
+ throw notImplemented('transaction');
500
+ },
501
+ setPriority(..._args) {
502
+ throw notImplemented('setPriority');
503
+ },
504
+ setWithPriority(..._args) {
505
+ throw notImplemented('setWithPriority');
506
+ },
507
+ // Query builders — `orderByChild`/`equalTo`/`limitToFirst`/… aren't
508
+ // modeled. Calling any of them returns a Query that immediately
509
+ // throws on `get`/`on`. Keeping these as throwers (rather than
510
+ // pretending they work) surfaces the limitation up-front.
511
+ orderByChild(..._args) {
512
+ throw notImplemented('orderByChild');
513
+ },
514
+ orderByKey(..._args) {
515
+ throw notImplemented('orderByKey');
516
+ },
517
+ orderByValue(..._args) {
518
+ throw notImplemented('orderByValue');
519
+ },
520
+ orderByPriority(..._args) {
521
+ throw notImplemented('orderByPriority');
522
+ },
523
+ startAt(..._args) {
524
+ throw notImplemented('startAt');
525
+ },
526
+ startAfter(..._args) {
527
+ throw notImplemented('startAfter');
528
+ },
529
+ endAt(..._args) {
530
+ throw notImplemented('endAt');
531
+ },
532
+ endBefore(..._args) {
533
+ throw notImplemented('endBefore');
534
+ },
535
+ equalTo(..._args) {
536
+ throw notImplemented('equalTo');
537
+ },
538
+ limitToFirst(..._args) {
539
+ throw notImplemented('limitToFirst');
540
+ },
541
+ limitToLast(..._args) {
542
+ throw notImplemented('limitToLast');
543
+ },
544
+ isEqual(other) {
545
+ return (other !== null &&
546
+ typeof other === 'object' &&
547
+ other.path === canonical);
548
+ },
549
+ toJSON() {
550
+ return { path: canonical };
551
+ },
552
+ // Required by the firebase-admin `Reference` interface but not
553
+ // load-bearing in the sandbox. Stubbed as the database handle so
554
+ // consumer code that reads `ref.database` doesn't crash.
555
+ get database() {
556
+ return db;
557
+ },
558
+ // `ref` on a Reference is itself (matches firebase-admin).
559
+ get ref() {
560
+ return ref;
561
+ },
562
+ };
563
+ return ref;
564
+ }
565
+ // ─── DataSnapshot ─────────────────────────────────────────────────────
566
+ /** Build a sandbox `DataSnapshot` for the value at `path`. Implements
567
+ * the load-bearing subset of firebase-admin's `DataSnapshot` shape.
568
+ * Backend-agnostic: the snapshot is a pure (path, value) view; `refAt`
569
+ * supplies backend-appropriate `Reference`s (local tree or remote), so
570
+ * the local and remote arms share one snapshot implementation. */
571
+ function buildSandboxSnap(refAt, path, val) {
572
+ const segs = pathSegments(path);
573
+ const key = segs.length === 0 ? null : segs[segs.length - 1];
574
+ const exists = val !== null;
575
+ const snap = {
576
+ key,
577
+ get ref() {
578
+ return refAt(path);
579
+ },
580
+ exists() {
581
+ return exists;
582
+ },
583
+ val() {
584
+ return val;
585
+ },
586
+ child(p) {
587
+ const childSegs = pathSegments(p);
588
+ let cur = val;
589
+ for (const s of childSegs) {
590
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
591
+ cur = null;
592
+ break;
593
+ }
594
+ cur = cur[s] ?? null;
595
+ }
596
+ return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), cur);
597
+ },
598
+ hasChild(p) {
599
+ return snap.child(p).exists();
600
+ },
601
+ hasChildren() {
602
+ return (val !== null &&
603
+ typeof val === 'object' &&
604
+ !Array.isArray(val) &&
605
+ Object.keys(val).length > 0);
606
+ },
607
+ numChildren() {
608
+ if (val === null || typeof val !== 'object' || Array.isArray(val))
609
+ return 0;
610
+ return Object.keys(val).length;
611
+ },
612
+ forEach(cb) {
613
+ if (val === null || typeof val !== 'object' || Array.isArray(val))
614
+ return false;
615
+ for (const [k, v] of Object.entries(val)) {
616
+ const childSnap = buildSandboxSnap(refAt, joinPath([...segs, k]), v);
617
+ if (cb(childSnap) === true)
618
+ return true;
619
+ }
620
+ return false;
621
+ },
622
+ toJSON() {
623
+ return val;
624
+ },
625
+ // RTDB priority isn't modeled — return `null`, matching the SDK
626
+ // default for a node without an explicit priority.
627
+ getPriority() {
628
+ return null;
629
+ },
630
+ exportVal() {
631
+ // No priorities → `exportVal()` matches `val()`. The SDK's
632
+ // exportVal includes `.priority` when set; we have none.
633
+ return val;
634
+ },
635
+ };
636
+ return snap;
637
+ }
638
+ // ─── Remote sandbox arm (remote sandbox, slice 1) ─────────────────────
639
+ //
640
+ // The app's `Sandbox` is a Node-side handle onto the browser-hosted
641
+ // SharedWorker sandbox (`pyric/sandbox`'s remote brand). Every data
642
+ // operation relays over the handle's worker channel with
643
+ // `actAs: { mode: 'admin' }` pinned — firebase-admin's rules-bypass
644
+ // semantics against the ONE tree the app + Studio + agents share. There
645
+ // is deliberately NO local state here: a `WeakMap` tree keyed off a
646
+ // remote handle would be private server-side data the browser never sees
647
+ // (exactly the failure the remote sandbox exists to avoid). No
648
+ // `session_boundary` wiring either — there is nothing local to wipe, and
649
+ // `onEvent` throws on remote handles by design.
650
+ /** firebase-admin's rules-bypass lens, pinned on every relayed operation. */
651
+ const REMOTE_ADMIN_LENS = { mode: 'admin' };
652
+ /** One `Database` per remote handle — successive `getDatabase(app)` calls
653
+ * share the listener registry (matches the local arm's singleton-per-
654
+ * sandbox semantics). Keyed off the handle object; the data itself lives
655
+ * in the browser worker. */
656
+ const remoteDbBySandbox = new WeakMap();
657
+ function getRemoteDatabase(sandbox) {
658
+ let db = remoteDbBySandbox.get(sandbox);
659
+ if (db !== undefined)
660
+ return db;
661
+ const state = {
662
+ channel: sandbox.channel,
663
+ listeners: new Map(),
664
+ };
665
+ db = buildDatabaseShell((dbHandle, path) => buildRemoteRef(state, dbHandle, path));
666
+ remoteDbBySandbox.set(sandbox, db);
667
+ return db;
668
+ }
669
+ /**
670
+ * Build a remote `Reference` at `path`. Same load-bearing surface as the
671
+ * local arm's {@link buildSandboxRef} — plus working `on('value')` /
672
+ * `off()` (the channel relays the worker's RTDB value subscription).
673
+ * Transactions / queries / priorities / `onDisconnect` throw the same
674
+ * "not implemented" sentinel as the local arm.
675
+ */
676
+ function buildRemoteRef(state, db, path) {
677
+ const canonical = joinPath(pathSegments(path));
678
+ const segs = pathSegments(canonical);
679
+ const key = segs.length === 0 ? null : segs[segs.length - 1];
680
+ const refAt = (p) => buildRemoteRef(state, db, p);
681
+ const snapFromWire = (wire) => buildSandboxSnap(refAt, canonical, (wire.value ?? null));
682
+ const ref = {
683
+ key,
684
+ get parent() {
685
+ if (segs.length === 0)
686
+ return null;
687
+ return refAt(joinPath(segs.slice(0, -1)));
688
+ },
689
+ get root() {
690
+ return refAt('/');
691
+ },
692
+ get path() {
693
+ return canonical;
694
+ },
695
+ toString() {
696
+ return `sandbox://rtdb${canonical}`;
697
+ },
698
+ // ─── Data-plane methods (relayed worker ops) ─────────────────────
699
+ /** Set `value` at this path. `null` deletes. Relays `rtdb.set`. */
700
+ async set(value) {
701
+ await state.channel.op({
702
+ method: 'rtdb.set',
703
+ path: canonical,
704
+ value: value ?? null,
705
+ actAs: REMOTE_ADMIN_LENS,
706
+ });
707
+ },
708
+ /** Read this path (`rtdb.get`). Resolves to a `DataSnapshot`. */
709
+ async get() {
710
+ const wire = (await state.channel.op({
711
+ method: 'rtdb.get',
712
+ path: canonical,
713
+ actAs: REMOTE_ADMIN_LENS,
714
+ }));
715
+ return snapFromWire(wire);
716
+ },
717
+ /** One-shot read via the channel's value subscription: the initial
718
+ * snapshot resolves the promise, then the subscription detaches.
719
+ * Only `'value'` is supported (parity with the local arm). */
720
+ once(eventType, _successCb, _failureCb, _context) {
721
+ if (eventType !== 'value') {
722
+ throw notImplemented(`once('${eventType}')`);
723
+ }
724
+ return new Promise((resolve, reject) => {
725
+ let detach = null;
726
+ let settled = false;
727
+ detach = state.channel.subscribe({ target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS }, (value) => {
728
+ if (settled)
729
+ return;
730
+ settled = true;
731
+ resolve(snapFromWire(value));
732
+ if (detach)
733
+ detach();
734
+ }, (err) => {
735
+ if (settled)
736
+ return;
737
+ settled = true;
738
+ reject(err);
739
+ if (detach)
740
+ detach();
741
+ });
742
+ if (settled)
743
+ detach();
744
+ });
745
+ },
746
+ /** Relays `rtdb.update` — the worker applies the FULL multi-path
747
+ * update semantics (`pyric/database`), an upgrade over the
748
+ * local arm's shallow per-key merge. `null` values delete. */
749
+ async update(values) {
750
+ if (values === null || typeof values !== 'object') {
751
+ throw new TypeError('pyric-admin/database sandbox: update expected an object.');
752
+ }
753
+ await state.channel.op({
754
+ method: 'rtdb.update',
755
+ path: canonical,
756
+ values: values,
757
+ actAs: REMOTE_ADMIN_LENS,
758
+ });
759
+ },
760
+ /** Delete the subtree at this path (`rtdb.remove`). */
761
+ async remove() {
762
+ await state.channel.op({
763
+ method: 'rtdb.remove',
764
+ path: canonical,
765
+ actAs: REMOTE_ADMIN_LENS,
766
+ });
767
+ },
768
+ /**
769
+ * Mint a 20-char push id CLIENT-side and relay `rtdb.push` carrying it
770
+ * (the worker-protocol contract) — so the returned
771
+ * `ThenableReference.key` is available synchronously, exactly like the
772
+ * local arm and firebase-admin. `.then()` settles when the relayed
773
+ * write commits (or immediately when no value was supplied — a bare
774
+ * `push()` performs no write, matching upstream); a write failure
775
+ * rejects the thenable and reaches `onComplete`.
776
+ */
777
+ push(value, onComplete) {
778
+ const id = generatePushId();
779
+ const childPath = joinPath([...segs, id]);
780
+ const write = value === undefined
781
+ ? Promise.resolve()
782
+ : state.channel
783
+ .op({
784
+ method: 'rtdb.push',
785
+ path: canonical,
786
+ key: id,
787
+ value,
788
+ actAs: REMOTE_ADMIN_LENS,
789
+ })
790
+ .then(() => undefined);
791
+ // Surface completion without forcing the caller to await: `.then`'s
792
+ // rejection handler also keeps a fire-and-forget push from becoming
793
+ // an unhandled rejection (the failure still reaches `onComplete` and
794
+ // any `.then()`/`await` on the returned thenable).
795
+ write.then(() => {
796
+ if (onComplete)
797
+ onComplete(null);
798
+ }, (err) => {
799
+ if (onComplete)
800
+ onComplete(err);
801
+ });
802
+ const childRef = refAt(childPath);
803
+ // CRITICAL: settle with a PLAIN (non-thenable) ref — resolving with
804
+ // the thenable itself would make promise resolution unwrap it
805
+ // forever (`await push(...)` would spin). Same guard as local arm.
806
+ const resolvedRef = refAt(childPath);
807
+ const thenable = childRef;
808
+ thenable.then = (onFulfilled, onRejected) => write.then(() => resolvedRef).then(onFulfilled, onRejected);
809
+ thenable.catch = (onRejected) => write.then(() => resolvedRef).catch(onRejected);
810
+ return thenable;
811
+ },
812
+ /** Relative ref builder — pure local path manipulation. */
813
+ child(p) {
814
+ return refAt(joinPath([...segs, ...pathSegments(p)]));
815
+ },
816
+ // ─── Value listeners (relayed worker subscription) ────────────────
817
+ /**
818
+ * `on('value', callback)` — routed through the channel's RTDB value
819
+ * subscription: the callback fires with the initial snapshot and on
820
+ * every subsequent change (including changes made by the browser app,
821
+ * Studio, or agents — one shared tree). A subscription-establishment
822
+ * failure routes to `cancelCallback` when one is supplied. Other
823
+ * event types (`child_added`, …) still throw "not implemented" —
824
+ * the worker relays only value subscriptions today.
825
+ */
826
+ on(eventType, callback, cancelCallbackOrContext, _context) {
827
+ if (eventType !== 'value') {
828
+ throw notImplemented(`on('${eventType}')`);
829
+ }
830
+ const cancelCallback = typeof cancelCallbackOrContext === 'function'
831
+ ? cancelCallbackOrContext
832
+ : undefined;
833
+ const detach = state.channel.subscribe({ target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS }, (value) => {
834
+ callback(snapFromWire(value));
835
+ }, (err) => {
836
+ detachListener(state, canonical, callback);
837
+ if (cancelCallback)
838
+ cancelCallback(err);
839
+ else
840
+ console.error(`pyric-admin/database: on('value') subscription failed at ${canonical}:`, err);
841
+ });
842
+ let atPath = state.listeners.get(canonical);
843
+ if (atPath === undefined) {
844
+ atPath = new Map();
845
+ state.listeners.set(canonical, atPath);
846
+ }
847
+ // Re-registering the same callback replaces the prior registration
848
+ // (detach it first so the old worker subscription doesn't leak).
849
+ atPath.get(callback)?.();
850
+ atPath.set(callback, detach);
851
+ return callback;
852
+ },
853
+ /**
854
+ * Detach value listeners at this path: `off('value', callback)` removes
855
+ * that registration; `off()` / `off('value')` removes all of them.
856
+ * Unknown callbacks and other event types are no-ops (nothing else can
857
+ * be registered on the remote arm).
858
+ */
859
+ off(eventType, callback, _context) {
860
+ if (eventType !== undefined && eventType !== 'value')
861
+ return;
862
+ if (callback !== undefined) {
863
+ detachListener(state, canonical, callback);
864
+ return;
865
+ }
866
+ const atPath = state.listeners.get(canonical);
867
+ if (atPath === undefined)
868
+ return;
869
+ for (const detach of atPath.values())
870
+ detach();
871
+ state.listeners.delete(canonical);
872
+ },
873
+ // ─── Not implemented on the remote arm (parity with local) ────────
874
+ onDisconnect() {
875
+ throw notImplemented('onDisconnect');
876
+ },
877
+ transaction(..._args) {
878
+ throw notImplemented('transaction');
879
+ },
880
+ setPriority(..._args) {
881
+ throw notImplemented('setPriority');
882
+ },
883
+ setWithPriority(..._args) {
884
+ throw notImplemented('setWithPriority');
885
+ },
886
+ orderByChild(..._args) {
887
+ throw notImplemented('orderByChild');
888
+ },
889
+ orderByKey(..._args) {
890
+ throw notImplemented('orderByKey');
891
+ },
892
+ orderByValue(..._args) {
893
+ throw notImplemented('orderByValue');
894
+ },
895
+ orderByPriority(..._args) {
896
+ throw notImplemented('orderByPriority');
897
+ },
898
+ startAt(..._args) {
899
+ throw notImplemented('startAt');
900
+ },
901
+ startAfter(..._args) {
902
+ throw notImplemented('startAfter');
903
+ },
904
+ endAt(..._args) {
905
+ throw notImplemented('endAt');
906
+ },
907
+ endBefore(..._args) {
908
+ throw notImplemented('endBefore');
909
+ },
910
+ equalTo(..._args) {
911
+ throw notImplemented('equalTo');
912
+ },
913
+ limitToFirst(..._args) {
914
+ throw notImplemented('limitToFirst');
915
+ },
916
+ limitToLast(..._args) {
917
+ throw notImplemented('limitToLast');
918
+ },
919
+ isEqual(other) {
920
+ return (other !== null &&
921
+ typeof other === 'object' &&
922
+ other.path === canonical);
923
+ },
924
+ toJSON() {
925
+ return { path: canonical };
926
+ },
927
+ get database() {
928
+ return db;
929
+ },
930
+ get ref() {
931
+ return ref;
932
+ },
933
+ };
934
+ return ref;
935
+ }
936
+ /** Remove one `on('value')` registration (and its worker subscription). */
937
+ function detachListener(state, path, callback) {
938
+ const atPath = state.listeners.get(path);
939
+ const detach = atPath?.get(callback);
940
+ if (atPath === undefined || detach === undefined)
941
+ return;
942
+ atPath.delete(callback);
943
+ if (atPath.size === 0)
944
+ state.listeners.delete(path);
945
+ detach();
946
+ }
947
+ //# sourceMappingURL=index.js.map