pyric-admin 0.1.0-alpha.7

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