pyric-admin 0.1.0-alpha.11 → 0.1.0-alpha.13

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,1187 @@
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
+
74
+ import {
75
+ isRemoteSandbox,
76
+ type RemoteSandbox,
77
+ type RemoteSandboxChannel,
78
+ type Sandbox,
79
+ } from 'pyric/sandbox';
80
+
81
+ import {
82
+ ADMIN_APP_TARGET,
83
+ getApp,
84
+ type PyricAdminApp,
85
+ } from '../app/index.js';
86
+ import { assertAdminAppActive } from '../app/lifecycle.js';
87
+
88
+ /** Mirror-owned structural types for the implemented admin RTDB surface. */
89
+ export type EventType = 'value' | 'child_added' | 'child_changed' | 'child_removed' | 'child_moved';
90
+ export interface Database {
91
+ ref(path?: string): Reference;
92
+ refFromURL(url: string): Reference;
93
+ goOffline(): void;
94
+ goOnline(): void;
95
+ getRules(): Promise<string>;
96
+ getRulesJSON(): Promise<object>;
97
+ setRules(source: string | object): Promise<void>;
98
+ readonly app: unknown;
99
+ }
100
+ export interface DataSnapshot {
101
+ readonly key: string | null;
102
+ readonly ref: Reference;
103
+ val(): unknown;
104
+ exists(): boolean;
105
+ child(path: string): DataSnapshot;
106
+ hasChild(path: string): boolean;
107
+ hasChildren(children?: string[]): boolean;
108
+ numChildren(): number;
109
+ forEach(action: (child: DataSnapshot) => boolean | void): boolean;
110
+ exportVal(): unknown;
111
+ getPriority(): string | number | null;
112
+ toJSON(): unknown;
113
+ }
114
+ export interface Reference {
115
+ readonly key: string | null;
116
+ readonly parent: Reference | null;
117
+ readonly root: Reference;
118
+ readonly database: Database;
119
+ readonly ref: Reference;
120
+ toString(): string;
121
+ get(): Promise<DataSnapshot>;
122
+ once(eventType: EventType, successCallback?: (snapshot: DataSnapshot) => unknown, failureCallback?: (error: Error) => unknown): Promise<DataSnapshot>;
123
+ set(value: unknown, onComplete?: (error: Error | null) => void): Promise<void>;
124
+ update(values: object, onComplete?: (error: Error | null) => void): Promise<void>;
125
+ remove(onComplete?: (error: Error | null) => void): Promise<void>;
126
+ push(value?: unknown, onComplete?: (error: Error | null) => void): ThenableReference;
127
+ child(path: string): Reference;
128
+ on(eventType: EventType, callback: (snapshot: DataSnapshot, previousChildKey?: string | null) => unknown, cancelCallback?: (error: Error) => unknown): unknown;
129
+ off(eventType?: EventType, callback?: (snapshot: DataSnapshot, previousChildKey?: string | null) => unknown): void;
130
+ [key: string]: unknown;
131
+ }
132
+ export type ThenableReference = Reference & PromiseLike<Reference>;
133
+ export type Query = Reference;
134
+ export interface OnDisconnect { [key: string]: unknown }
135
+
136
+ type AdminDatabase = Database;
137
+ type AdminReference = Reference;
138
+ type AdminDataSnapshot = DataSnapshot;
139
+ type AdminThenableReference = ThenableReference;
140
+ type AdminEventType = EventType;
141
+
142
+ /**
143
+ * Returns the {@link AdminDatabase} service for the supplied app.
144
+ *
145
+ * Signature mirrors `firebase-admin/database`'s `getDatabase(app?)`.
146
+ *
147
+ * - `getDatabase()` — default database for the DEFAULT app (resolved
148
+ * through `pyric-admin/app`'s registry, exactly like firebase-admin's
149
+ * no-arg `getDatabase()`; throws `app/no-app` when no default app has
150
+ * been initialized). Works for local and remote sandbox apps.
151
+ * - `getDatabase(app)` — default database for the app.
152
+ * - `getDatabase(app, url)` — legacy Pyric-only compatibility form. New
153
+ * code should use the upstream-shaped {@link getDatabaseWithUrl} export.
154
+ *
155
+ * The sandbox brand returns the local or remote `Database` backed by the
156
+ * per-`Sandbox` state described in the module-level docs.
157
+ */
158
+ export function getDatabase(
159
+ app?: PyricAdminApp,
160
+ _url?: string,
161
+ ): AdminDatabase {
162
+ if (app === undefined) {
163
+ // No-arg mirror of firebase-admin's `getDatabase()` — resolve the
164
+ // '[DEFAULT]' app from the registry (throws app/no-app on a miss).
165
+ app = getApp();
166
+ }
167
+ assertAdminAppActive(app);
168
+ if (app[ADMIN_APP_TARGET] === 'sandbox') {
169
+ return getSandboxDatabase(app.sandbox);
170
+ }
171
+ throw new TypeError(
172
+ 'pyric-admin/database: getDatabase expected a PyricAdminApp ' +
173
+ '(initialize via `initializeApp` from pyric-admin/app).',
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Returns the {@link AdminDatabase} service selected by an upstream-shaped
179
+ * database URL.
180
+ *
181
+ * This is the exact `firebase-admin/database` argument order used by the
182
+ * Firebase Functions SDK: `getDatabaseWithUrl(url, app?)`. The first Pyric
183
+ * Functions slice has one shared RTDB instance, so the URL selects that
184
+ * instance rather than creating a second sandbox database.
185
+ */
186
+ export function getDatabaseWithUrl(
187
+ _url: string,
188
+ app?: PyricAdminApp,
189
+ ): AdminDatabase {
190
+ return getDatabase(app);
191
+ }
192
+
193
+ // ─── Sandbox backend ─────────────────────────────────────────────────
194
+ //
195
+ // Minimal in-memory RTDB for the admin-shape surface. Single nested
196
+ // JSON tree (`SandboxState.root`); writes mutate it, reads walk it.
197
+ // No rules engine (admin sandbox writes are rule-bypass — matches
198
+ // firebase-admin's behavior of bypassing rules), no listeners (deferred,
199
+ // see module-level "Not implemented" note), no queries (deferred).
200
+
201
+ /** Minimum JSON value shape stored in the in-memory tree. */
202
+ type JsonValue =
203
+ | null
204
+ | boolean
205
+ | number
206
+ | string
207
+ | JsonValue[]
208
+ | { [key: string]: JsonValue };
209
+
210
+ /**
211
+ * Per-sandbox state. One instance per `Sandbox`; the WeakMap below
212
+ * keys this off the `Sandbox` reference so `sandbox.reset()` can wipe
213
+ * everything in one swap. The `Database` handle returned to consumers
214
+ * holds onto the `SandboxState` directly so reads / writes don't
215
+ * re-resolve through the WeakMap on every op.
216
+ */
217
+ interface SandboxState {
218
+ root: Record<string, JsonValue>;
219
+ }
220
+
221
+ /** One backend per `Sandbox`. Successive `getDatabase(app)` calls for
222
+ * the same sandbox return handles that share data — matches
223
+ * firebase-admin's singleton-per-app semantics. */
224
+ const stateBySandbox = new WeakMap<Sandbox, SandboxState>();
225
+
226
+ function getOrCreateState(sandbox: Sandbox): SandboxState {
227
+ let state = stateBySandbox.get(sandbox);
228
+ if (state !== undefined) return state;
229
+ state = { root: {} };
230
+ stateBySandbox.set(sandbox, state);
231
+ // Wire `sandbox.reset()` → wipe the tree. `session_boundary` fires
232
+ // before the env swap, so consumer code that observes a reset sees
233
+ // the freshly-cleared tree on the next read. `dispose` also fires a
234
+ // boundary; treat it the same (the sandbox is being torn down — any
235
+ // in-flight handle on the tree gets an empty view).
236
+ sandbox.onEvent((event) => {
237
+ if (event.kind === 'session_boundary') {
238
+ state!.root = {};
239
+ }
240
+ });
241
+ return state;
242
+ }
243
+
244
+ /** Build (or reuse) the sandbox Database handle for `sandbox`.
245
+ *
246
+ * REMOTE handles dispatch here, BEFORE any local state is touched: a
247
+ * remote sandbox must never get a `SandboxState` (a private local tree)
248
+ * or a `sandbox.onEvent` wire-up (which throws on remote handles). */
249
+ function getSandboxDatabase(sandbox: Sandbox): AdminDatabase {
250
+ if (isRemoteSandbox(sandbox)) {
251
+ return getRemoteDatabase(sandbox);
252
+ }
253
+ const state = getOrCreateState(sandbox);
254
+ return buildSandboxDatabase(state);
255
+ }
256
+
257
+ function buildSandboxDatabase(state: SandboxState): AdminDatabase {
258
+ return buildDatabaseShell((db, path) => buildSandboxRef(state, db, path));
259
+ }
260
+
261
+ /**
262
+ * The `Database`-level shell shared by the local and remote sandbox arms —
263
+ * everything except how a `Reference` is built. Rules metadata isn't
264
+ * modeled on either arm; connection toggles are no-ops (the sandbox IS the
265
+ * local emulator).
266
+ */
267
+ function buildDatabaseShell(
268
+ refFactory: (db: AdminDatabase, path: string) => AdminReference,
269
+ ): AdminDatabase {
270
+ const db = {
271
+ ref(path?: string): AdminReference {
272
+ return refFactory(db as unknown as AdminDatabase, path ?? '/');
273
+ },
274
+ refFromURL(url: string): AdminReference {
275
+ // Best-effort: strip the `https://<host>` prefix and treat the
276
+ // remainder as a path. The sandbox has no notion of multi-database
277
+ // hosts, so the host portion is ignored.
278
+ const u = url.replace(/^https?:\/\/[^/]+/, '');
279
+ return refFactory(db as unknown as AdminDatabase, u || '/');
280
+ },
281
+ // Admin-only metadata methods — not modeled in the sandbox. The
282
+ // sandbox is rule-bypass by construction; surfacing rule JSON would
283
+ // require a parallel rules store that has no users yet.
284
+ getRules(): Promise<string> {
285
+ throw new Error(
286
+ 'pyric-admin/database sandbox: getRules not implemented',
287
+ );
288
+ },
289
+ getRulesJSON(): Promise<object> {
290
+ throw new Error(
291
+ 'pyric-admin/database sandbox: getRulesJSON not implemented',
292
+ );
293
+ },
294
+ setRules(_source: string | object | Buffer): Promise<void> {
295
+ throw new Error(
296
+ 'pyric-admin/database sandbox: setRules not implemented',
297
+ );
298
+ },
299
+ useEmulator(_host: string, _port: number): void {
300
+ // No-op — the sandbox IS a local emulator. Accept the call so
301
+ // consumer code that calls `useEmulator` unconditionally compiles.
302
+ },
303
+ goOffline(): void {
304
+ // No-op — sandbox has no network connection to drop.
305
+ },
306
+ goOnline(): void {
307
+ // No-op — sandbox has no network connection to reopen.
308
+ },
309
+ // `app` is required on the firebase-admin Database interface; the
310
+ // sandbox doesn't carry a firebase-admin App, so we stub it. The
311
+ // load-bearing data-plane methods above don't read it.
312
+ app: undefined,
313
+ };
314
+ return db as unknown as AdminDatabase;
315
+ }
316
+
317
+ // ─── Path utilities ──────────────────────────────────────────────────
318
+
319
+ /** Path segments that must never be walked or written: because the tree is
320
+ * backed by plain JS objects, a segment named `__proto__` (or, as
321
+ * defence-in-depth, `constructor`/`prototype`) would reach the shared
322
+ * `Object.prototype` and let a write pollute it process-wide (a path
323
+ * arrives via JSON/MCP transports that preserve `__proto__` as a genuine
324
+ * own key). Real RTDB stores a server-side tree with no such reserved
325
+ * keys, so rejecting them is a sandbox-only safety constraint, not a
326
+ * parity regression. Twin of the `pyric/database` DataTree guard (#760). */
327
+ const UNSAFE_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
328
+
329
+ /** Normalise a path to non-empty segments. `'/'` → `[]`.
330
+ * Throws if any segment is a prototype-pollution vector. */
331
+ function pathSegments(path: string): string[] {
332
+ if (path === '' || path === '/') return [];
333
+ const segs = path.split('/').filter((s) => s.length > 0);
334
+ for (const seg of segs) {
335
+ if (UNSAFE_SEGMENTS.has(seg)) {
336
+ throw new Error(
337
+ `Invalid RTDB path segment '${seg}': the keys __proto__, prototype, ` +
338
+ 'and constructor are reserved and cannot appear in a path.',
339
+ );
340
+ }
341
+ }
342
+ return segs;
343
+ }
344
+
345
+ /** Join segments back into a `/`-prefixed canonical path. `[]` → `'/'`. */
346
+ function joinPath(segments: string[]): string {
347
+ if (segments.length === 0) return '/';
348
+ return '/' + segments.join('/');
349
+ }
350
+
351
+ /** Deep-clone a JSON value so stored state doesn't share identity with
352
+ * caller-held references. */
353
+ function cloneJson<T extends JsonValue>(v: T): T {
354
+ if (v === null || typeof v !== 'object') return v;
355
+ if (Array.isArray(v)) return v.map((x) => cloneJson(x as JsonValue)) as T;
356
+ const out: Record<string, JsonValue> = {};
357
+ for (const [k, val] of Object.entries(v as Record<string, JsonValue>)) {
358
+ out[k] = cloneJson(val);
359
+ }
360
+ return out as T;
361
+ }
362
+
363
+ /** Read the value at `path` in `root`. `null` for absent paths. */
364
+ function readPath(root: Record<string, JsonValue>, path: string): JsonValue {
365
+ const segs = pathSegments(path);
366
+ let node: JsonValue = root;
367
+ for (const seg of segs) {
368
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
369
+ return null;
370
+ }
371
+ const obj = node as { [key: string]: JsonValue };
372
+ // Own-property check only: `seg in obj` would follow inherited keys
373
+ // (e.g. an unvalidated `__proto__`) into the object prototype.
374
+ if (!Object.hasOwn(obj, seg)) return null;
375
+ node = obj[seg]!;
376
+ }
377
+ return cloneJson(node);
378
+ }
379
+
380
+ /** Write `value` at `path`. `null` deletes. Trims empty ancestor objects. */
381
+ function writePath(
382
+ root: Record<string, JsonValue>,
383
+ path: string,
384
+ value: JsonValue,
385
+ ): void {
386
+ const segs = pathSegments(path);
387
+ if (segs.length === 0) {
388
+ // Root write — clear all keys and replace.
389
+ for (const k of Object.keys(root)) delete root[k];
390
+ if (value === null) return;
391
+ if (typeof value !== 'object' || Array.isArray(value)) {
392
+ throw new Error(
393
+ 'pyric-admin/database sandbox: root write must be an object (or null to clear).',
394
+ );
395
+ }
396
+ Object.assign(root, cloneJson(value) as Record<string, JsonValue>);
397
+ return;
398
+ }
399
+ // Walk to parent, creating intermediate objects as needed.
400
+ let cursor: Record<string, JsonValue> = root;
401
+ for (let i = 0; i < segs.length - 1; i++) {
402
+ const k = segs[i]!;
403
+ // Own-property read only: bare `cursor[k]` would resolve an unvalidated
404
+ // `__proto__` segment to the shared object prototype.
405
+ const next = Object.hasOwn(cursor, k) ? cursor[k] : undefined;
406
+ if (
407
+ next === undefined ||
408
+ next === null ||
409
+ typeof next !== 'object' ||
410
+ Array.isArray(next)
411
+ ) {
412
+ const fresh: Record<string, JsonValue> = {};
413
+ cursor[k] = fresh;
414
+ cursor = fresh;
415
+ } else {
416
+ cursor = next as Record<string, JsonValue>;
417
+ }
418
+ }
419
+ const lastKey = segs[segs.length - 1]!;
420
+ if (value === null) {
421
+ delete cursor[lastKey];
422
+ trimEmptyAncestors(root, segs);
423
+ } else {
424
+ cursor[lastKey] = cloneJson(value);
425
+ }
426
+ }
427
+
428
+ /** Remove now-empty object ancestors after a delete. RTDB invariant:
429
+ * "Empty nodes don't exist". */
430
+ function trimEmptyAncestors(
431
+ root: Record<string, JsonValue>,
432
+ segs: string[],
433
+ ): void {
434
+ for (let depth = segs.length - 1; depth >= 1; depth--) {
435
+ const parentSegs = segs.slice(0, depth);
436
+ const lastKey = segs[depth - 1]!;
437
+ let parent: Record<string, JsonValue> = root;
438
+ for (let i = 0; i < parentSegs.length - 1; i++) {
439
+ const next = parent[parentSegs[i]!];
440
+ if (
441
+ next === undefined ||
442
+ next === null ||
443
+ typeof next !== 'object' ||
444
+ Array.isArray(next)
445
+ ) {
446
+ return;
447
+ }
448
+ parent = next as Record<string, JsonValue>;
449
+ }
450
+ const child = parent[lastKey];
451
+ if (
452
+ child !== undefined &&
453
+ child !== null &&
454
+ typeof child === 'object' &&
455
+ !Array.isArray(child)
456
+ ) {
457
+ const obj = child as Record<string, JsonValue>;
458
+ if (Object.keys(obj).length === 0) {
459
+ delete parent[lastKey];
460
+ continue;
461
+ }
462
+ }
463
+ return;
464
+ }
465
+ }
466
+
467
+ // ─── Push-id generator ────────────────────────────────────────────────
468
+ //
469
+ // Lifted from `pyric/database/sandbox/push-id.ts` — the algorithm
470
+ // matches firebase-js-sdk's published `nextPushId` exactly so a sandbox-
471
+ // minted key is shape-compatible with a real `push(ref).key`. Inlined
472
+ // here so `pyric-admin/database` doesn't need to import an internal
473
+ // path from `pyric` (which isn't exported as a public subpath).
474
+
475
+ const PUSH_CHARS =
476
+ '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
477
+
478
+ let lastPushTime = 0;
479
+ const lastRandChars: number[] = new Array(12).fill(0);
480
+
481
+ function generatePushId(now: number = Date.now()): string {
482
+ const duplicateTime = now === lastPushTime;
483
+ lastPushTime = now;
484
+
485
+ const timeStampChars: string[] = new Array(8);
486
+ let ts = now;
487
+ for (let i = 7; i >= 0; i--) {
488
+ timeStampChars[i] = PUSH_CHARS.charAt(ts % 64);
489
+ ts = Math.floor(ts / 64);
490
+ }
491
+ if (ts !== 0) {
492
+ throw new Error('RTDB push-id: timestamp overflow.');
493
+ }
494
+ let id = timeStampChars.join('');
495
+
496
+ if (!duplicateTime) {
497
+ for (let i = 0; i < 12; i++) {
498
+ lastRandChars[i] = Math.floor(Math.random() * 64);
499
+ }
500
+ } else {
501
+ let i: number;
502
+ for (i = 11; i >= 0 && lastRandChars[i] === 63; i--) {
503
+ lastRandChars[i] = 0;
504
+ }
505
+ if (i < 0) {
506
+ for (let j = 0; j < 12; j++) {
507
+ lastRandChars[j] = Math.floor(Math.random() * 64);
508
+ }
509
+ } else {
510
+ lastRandChars[i] = (lastRandChars[i] ?? 0) + 1;
511
+ }
512
+ }
513
+
514
+ for (let i = 0; i < 12; i++) {
515
+ id += PUSH_CHARS.charAt(lastRandChars[i]!);
516
+ }
517
+ return id;
518
+ }
519
+
520
+ // ─── Reference ────────────────────────────────────────────────────────
521
+
522
+ /** The sentinel thrown by listener / transaction / query / priority
523
+ * methods on the sandbox `Reference`. Documented in the module-level
524
+ * comment under "Not implemented". */
525
+ function notImplemented(method: string): Error {
526
+ return new Error(
527
+ `pyric-admin/database sandbox: ${method} not implemented`,
528
+ );
529
+ }
530
+
531
+ /** Build a sandbox `Reference` at `path`. The returned object satisfies
532
+ * the load-bearing subset of `firebase-admin/database`'s `Reference`
533
+ * shape; listener / query / transaction methods throw the "not
534
+ * implemented" sentinel. */
535
+ function buildSandboxRef(
536
+ state: SandboxState,
537
+ db: AdminDatabase,
538
+ path: string,
539
+ ): AdminReference {
540
+ const canonical = joinPath(pathSegments(path));
541
+ const segs = pathSegments(canonical);
542
+ const key = segs.length === 0 ? null : segs[segs.length - 1]!;
543
+
544
+ const ref = {
545
+ key,
546
+ get parent(): AdminReference | null {
547
+ if (segs.length === 0) return null;
548
+ return buildSandboxRef(state, db, joinPath(segs.slice(0, -1)));
549
+ },
550
+ get root(): AdminReference {
551
+ return buildSandboxRef(state, db, '/');
552
+ },
553
+ get path(): string {
554
+ return canonical;
555
+ },
556
+ toString(): string {
557
+ return `sandbox://rtdb${canonical}`;
558
+ },
559
+
560
+ // ─── Data-plane methods (implemented) ────────────────────────────
561
+
562
+ /** Set `value` at this path. `null` deletes. */
563
+ async set(value: unknown): Promise<void> {
564
+ writePath(state.root, canonical, value as JsonValue);
565
+ },
566
+
567
+ /** Read this path. Resolves to a {@link DataSnapshot}-shaped value. */
568
+ async get(): Promise<AdminDataSnapshot> {
569
+ const val = readPath(state.root, canonical);
570
+ return buildSandboxSnap((p) => buildSandboxRef(state, db, p), canonical, val);
571
+ },
572
+
573
+ /** `once(eventType)` — admin-shape one-shot read. Only `'value'` is
574
+ * supported in the sandbox (the only event type that doesn't
575
+ * require a listener registry). Mirrors firebase-admin's
576
+ * `Reference#once('value')` for the common get-style usage. */
577
+ async once(
578
+ eventType: AdminEventType,
579
+ _successCb?: unknown,
580
+ _failureCb?: unknown,
581
+ _context?: unknown,
582
+ ): Promise<AdminDataSnapshot> {
583
+ if (eventType !== 'value') {
584
+ throw notImplemented(`once('${eventType}')`);
585
+ }
586
+ const val = readPath(state.root, canonical);
587
+ return buildSandboxSnap((p) => buildSandboxRef(state, db, p), canonical, val);
588
+ },
589
+
590
+ /** Shallow merge: each key in `values` replaces the corresponding
591
+ * child at this path. `null` values delete. */
592
+ async update(values: object): Promise<void> {
593
+ if (values === null || typeof values !== 'object') {
594
+ throw new TypeError(
595
+ 'pyric-admin/database sandbox: update expected an object.',
596
+ );
597
+ }
598
+ for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
599
+ const subSegs = [...segs, ...pathSegments(k)];
600
+ writePath(state.root, joinPath(subSegs), v as JsonValue);
601
+ }
602
+ },
603
+
604
+ /** Delete the subtree at this path. Equivalent to `set(null)`. */
605
+ async remove(): Promise<void> {
606
+ writePath(state.root, canonical, null);
607
+ },
608
+
609
+ /** Mint a 20-char push id, optionally writing `value` at the new
610
+ * child. Returns a Reference at the new child path. The shape
611
+ * matches firebase-admin's `ThenableReference` — `.then()` resolves
612
+ * once the (synchronous) write completes; the underlying ref is
613
+ * available synchronously via the returned object's own methods. */
614
+ push(value?: unknown, onComplete?: (err: Error | null) => void): AdminThenableReference {
615
+ const id = generatePushId();
616
+ const childPath = joinPath([...segs, id]);
617
+ if (value !== undefined) {
618
+ try {
619
+ writePath(state.root, childPath, value as JsonValue);
620
+ } catch (err) {
621
+ if (onComplete) onComplete(err as Error);
622
+ throw err;
623
+ }
624
+ }
625
+ if (onComplete) onComplete(null);
626
+ const childRef = buildSandboxRef(state, db, childPath);
627
+ // ThenableReference: the ref plus a `.then()` that resolves to it.
628
+ // Returning a Reference with a tacked-on `.then` satisfies the
629
+ // firebase-admin shape for the common `push(value).key` usage.
630
+ // CRITICAL: the promise must resolve with a PLAIN (non-thenable)
631
+ // ref — resolving with the thenable itself would make promise
632
+ // resolution unwrap it forever (`await push(...)` would spin).
633
+ const resolvedRef = buildSandboxRef(state, db, childPath);
634
+ const thenable = childRef as AdminReference & PromiseLike<AdminReference>;
635
+ (thenable as unknown as { then: PromiseLike<AdminReference>['then'] }).then = (
636
+ onFulfilled,
637
+ onRejected,
638
+ ) => Promise.resolve(resolvedRef).then(onFulfilled, onRejected);
639
+ (thenable as unknown as { catch: <U>(onRejected: (reason: unknown) => U | PromiseLike<U>) => Promise<AdminReference | U> }).catch = (
640
+ onRejected,
641
+ ) => Promise.resolve(resolvedRef).catch(onRejected);
642
+ return thenable as AdminThenableReference;
643
+ },
644
+
645
+ /** Relative ref builder. `child(parent, 'sub/path')` returns a ref
646
+ * at `<parent>/sub/path`. */
647
+ child(p: string): AdminReference {
648
+ const absSegs = [...segs, ...pathSegments(p)];
649
+ return buildSandboxRef(state, db, joinPath(absSegs));
650
+ },
651
+
652
+ // ─── Not implemented in the sandbox backend ──────────────────────
653
+
654
+ on(_eventType: AdminEventType, ..._rest: unknown[]): never {
655
+ throw notImplemented('on');
656
+ },
657
+ off(_eventType?: AdminEventType, ..._rest: unknown[]): never {
658
+ throw notImplemented('off');
659
+ },
660
+ onDisconnect(): never {
661
+ throw notImplemented('onDisconnect');
662
+ },
663
+ transaction(..._args: unknown[]): never {
664
+ throw notImplemented('transaction');
665
+ },
666
+ setPriority(..._args: unknown[]): never {
667
+ throw notImplemented('setPriority');
668
+ },
669
+ setWithPriority(..._args: unknown[]): never {
670
+ throw notImplemented('setWithPriority');
671
+ },
672
+ // Query builders — `orderByChild`/`equalTo`/`limitToFirst`/… aren't
673
+ // modeled. Calling any of them returns a Query that immediately
674
+ // throws on `get`/`on`. Keeping these as throwers (rather than
675
+ // pretending they work) surfaces the limitation up-front.
676
+ orderByChild(..._args: unknown[]): never {
677
+ throw notImplemented('orderByChild');
678
+ },
679
+ orderByKey(..._args: unknown[]): never {
680
+ throw notImplemented('orderByKey');
681
+ },
682
+ orderByValue(..._args: unknown[]): never {
683
+ throw notImplemented('orderByValue');
684
+ },
685
+ orderByPriority(..._args: unknown[]): never {
686
+ throw notImplemented('orderByPriority');
687
+ },
688
+ startAt(..._args: unknown[]): never {
689
+ throw notImplemented('startAt');
690
+ },
691
+ startAfter(..._args: unknown[]): never {
692
+ throw notImplemented('startAfter');
693
+ },
694
+ endAt(..._args: unknown[]): never {
695
+ throw notImplemented('endAt');
696
+ },
697
+ endBefore(..._args: unknown[]): never {
698
+ throw notImplemented('endBefore');
699
+ },
700
+ equalTo(..._args: unknown[]): never {
701
+ throw notImplemented('equalTo');
702
+ },
703
+ limitToFirst(..._args: unknown[]): never {
704
+ throw notImplemented('limitToFirst');
705
+ },
706
+ limitToLast(..._args: unknown[]): never {
707
+ throw notImplemented('limitToLast');
708
+ },
709
+ isEqual(other: unknown): boolean {
710
+ return (
711
+ other !== null &&
712
+ typeof other === 'object' &&
713
+ (other as { path?: string }).path === canonical
714
+ );
715
+ },
716
+ toJSON(): object {
717
+ return { path: canonical };
718
+ },
719
+
720
+ // Required by the firebase-admin `Reference` interface but not
721
+ // load-bearing in the sandbox. Stubbed as the database handle so
722
+ // consumer code that reads `ref.database` doesn't crash.
723
+ get database(): AdminDatabase {
724
+ return db;
725
+ },
726
+ // `ref` on a Reference is itself (matches firebase-admin).
727
+ get ref(): AdminReference {
728
+ return ref as unknown as AdminReference;
729
+ },
730
+ };
731
+
732
+ return ref as unknown as AdminReference;
733
+ }
734
+
735
+ // ─── DataSnapshot ─────────────────────────────────────────────────────
736
+
737
+ /** Build a sandbox `DataSnapshot` for the value at `path`. Implements
738
+ * the load-bearing subset of firebase-admin's `DataSnapshot` shape.
739
+ * Backend-agnostic: the snapshot is a pure (path, value) view; `refAt`
740
+ * supplies backend-appropriate `Reference`s (local tree or remote), so
741
+ * the local and remote arms share one snapshot implementation. */
742
+ function buildSandboxSnap(
743
+ refAt: (path: string) => AdminReference,
744
+ path: string,
745
+ val: JsonValue,
746
+ ): AdminDataSnapshot {
747
+ const segs = pathSegments(path);
748
+ const key = segs.length === 0 ? null : segs[segs.length - 1]!;
749
+ const exists = val !== null;
750
+ const snap = {
751
+ key,
752
+ get ref(): AdminReference {
753
+ return refAt(path);
754
+ },
755
+ exists(): boolean {
756
+ return exists;
757
+ },
758
+ val(): unknown {
759
+ return val;
760
+ },
761
+ child(p: string): AdminDataSnapshot {
762
+ const childSegs = pathSegments(p);
763
+ let cur: JsonValue = val;
764
+ for (const s of childSegs) {
765
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
766
+ cur = null;
767
+ break;
768
+ }
769
+ cur = (cur as Record<string, JsonValue>)[s] ?? null;
770
+ }
771
+ return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), cur);
772
+ },
773
+ hasChild(p: string): boolean {
774
+ return snap.child(p).exists();
775
+ },
776
+ hasChildren(): boolean {
777
+ return (
778
+ val !== null &&
779
+ typeof val === 'object' &&
780
+ !Array.isArray(val) &&
781
+ Object.keys(val as Record<string, JsonValue>).length > 0
782
+ );
783
+ },
784
+ numChildren(): number {
785
+ if (val === null || typeof val !== 'object' || Array.isArray(val)) return 0;
786
+ return Object.keys(val as Record<string, JsonValue>).length;
787
+ },
788
+ forEach(cb: (child: AdminDataSnapshot) => boolean | void): boolean {
789
+ if (val === null || typeof val !== 'object' || Array.isArray(val)) return false;
790
+ for (const [k, v] of Object.entries(val as Record<string, JsonValue>)) {
791
+ const childSnap = buildSandboxSnap(refAt, joinPath([...segs, k]), v);
792
+ if (cb(childSnap) === true) return true;
793
+ }
794
+ return false;
795
+ },
796
+ toJSON(): unknown {
797
+ return val;
798
+ },
799
+ // RTDB priority isn't modeled — return `null`, matching the SDK
800
+ // default for a node without an explicit priority.
801
+ getPriority(): string | number | null {
802
+ return null;
803
+ },
804
+ exportVal(): unknown {
805
+ // No priorities → `exportVal()` matches `val()`. The SDK's
806
+ // exportVal includes `.priority` when set; we have none.
807
+ return val;
808
+ },
809
+ };
810
+ return snap as unknown as AdminDataSnapshot;
811
+ }
812
+
813
+ // ─── Remote sandbox arm (remote sandbox, slice 1) ─────────────────────
814
+ //
815
+ // The app's `Sandbox` is a Node-side handle onto the browser-hosted
816
+ // SharedWorker sandbox (`pyric/sandbox`'s remote brand). Every data
817
+ // operation relays over the handle's worker channel with
818
+ // `actAs: { mode: 'admin' }` pinned — firebase-admin's rules-bypass
819
+ // semantics against the ONE tree the app + Studio + agents share. There
820
+ // is deliberately NO local state here: a `WeakMap` tree keyed off a
821
+ // remote handle would be private server-side data the browser never sees
822
+ // (exactly the failure the remote sandbox exists to avoid). No
823
+ // `session_boundary` wiring either — there is nothing local to wipe, and
824
+ // `onEvent` throws on remote handles by design.
825
+
826
+ /** firebase-admin's rules-bypass lens, pinned on every relayed operation. */
827
+ const REMOTE_ADMIN_LENS = { mode: 'admin' } as const;
828
+
829
+ /** Wire shape of an RTDB snapshot as the worker host serializes it. */
830
+ interface RemoteWireSnapshot {
831
+ key: string | null;
832
+ exists: boolean;
833
+ value: unknown;
834
+ size: number;
835
+ }
836
+
837
+ /**
838
+ * Per-remote-handle state: the relay channel plus the `on('value')`
839
+ * listener registry (`path → callback → detach`) that `off()` consults.
840
+ */
841
+ interface RemoteDbState {
842
+ channel: RemoteSandboxChannel;
843
+ listeners: Map<string, Map<unknown, () => void>>;
844
+ }
845
+
846
+ /** One `Database` per remote handle — successive `getDatabase(app)` calls
847
+ * share the listener registry (matches the local arm's singleton-per-
848
+ * sandbox semantics). Keyed off the handle object; the data itself lives
849
+ * in the browser worker. */
850
+ const remoteDbBySandbox = new WeakMap<Sandbox, AdminDatabase>();
851
+
852
+ function getRemoteDatabase(sandbox: RemoteSandbox): AdminDatabase {
853
+ let db = remoteDbBySandbox.get(sandbox);
854
+ if (db !== undefined) return db;
855
+ const state: RemoteDbState = {
856
+ channel: sandbox.channel,
857
+ listeners: new Map(),
858
+ };
859
+ db = buildDatabaseShell((dbHandle, path) => buildRemoteRef(state, dbHandle, path));
860
+ remoteDbBySandbox.set(sandbox, db);
861
+ return db;
862
+ }
863
+
864
+ /**
865
+ * Build a remote `Reference` at `path`. Same load-bearing surface as the
866
+ * local arm's {@link buildSandboxRef} — plus working `on('value')` /
867
+ * `off()` (the channel relays the worker's RTDB value subscription).
868
+ * Transactions / queries / priorities / `onDisconnect` throw the same
869
+ * "not implemented" sentinel as the local arm.
870
+ */
871
+ function buildRemoteRef(
872
+ state: RemoteDbState,
873
+ db: AdminDatabase,
874
+ path: string,
875
+ ): AdminReference {
876
+ const canonical = joinPath(pathSegments(path));
877
+ const segs = pathSegments(canonical);
878
+ const key = segs.length === 0 ? null : segs[segs.length - 1]!;
879
+ const refAt = (p: string): AdminReference => buildRemoteRef(state, db, p);
880
+ const snapFromWire = (wire: RemoteWireSnapshot): AdminDataSnapshot =>
881
+ buildSandboxSnap(refAt, canonical, (wire.value ?? null) as JsonValue);
882
+
883
+ const ref = {
884
+ key,
885
+ get parent(): AdminReference | null {
886
+ if (segs.length === 0) return null;
887
+ return refAt(joinPath(segs.slice(0, -1)));
888
+ },
889
+ get root(): AdminReference {
890
+ return refAt('/');
891
+ },
892
+ get path(): string {
893
+ return canonical;
894
+ },
895
+ toString(): string {
896
+ return `sandbox://rtdb${canonical}`;
897
+ },
898
+
899
+ // ─── Data-plane methods (relayed worker ops) ─────────────────────
900
+
901
+ /** Set `value` at this path. `null` deletes. Relays `rtdb.set`. */
902
+ async set(value: unknown): Promise<void> {
903
+ await state.channel.op({
904
+ method: 'rtdb.set',
905
+ path: canonical,
906
+ value: value ?? null,
907
+ actAs: REMOTE_ADMIN_LENS,
908
+ });
909
+ },
910
+
911
+ /** Read this path (`rtdb.get`). Resolves to a `DataSnapshot`. */
912
+ async get(): Promise<AdminDataSnapshot> {
913
+ const wire = (await state.channel.op({
914
+ method: 'rtdb.get',
915
+ path: canonical,
916
+ actAs: REMOTE_ADMIN_LENS,
917
+ })) as RemoteWireSnapshot;
918
+ return snapFromWire(wire);
919
+ },
920
+
921
+ /** One-shot read via the channel's value subscription: the initial
922
+ * snapshot resolves the promise, then the subscription detaches.
923
+ * Only `'value'` is supported (parity with the local arm). */
924
+ once(
925
+ eventType: AdminEventType,
926
+ _successCb?: unknown,
927
+ _failureCb?: unknown,
928
+ _context?: unknown,
929
+ ): Promise<AdminDataSnapshot> {
930
+ if (eventType !== 'value') {
931
+ throw notImplemented(`once('${eventType}')`);
932
+ }
933
+ return new Promise<AdminDataSnapshot>((resolve, reject) => {
934
+ let detach: (() => void) | null = null;
935
+ let settled = false;
936
+ detach = state.channel.subscribe(
937
+ { target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS },
938
+ (value) => {
939
+ if (settled) return;
940
+ settled = true;
941
+ resolve(snapFromWire(value as RemoteWireSnapshot));
942
+ if (detach) detach();
943
+ },
944
+ (err) => {
945
+ if (settled) return;
946
+ settled = true;
947
+ reject(err);
948
+ if (detach) detach();
949
+ },
950
+ );
951
+ if (settled) detach();
952
+ });
953
+ },
954
+
955
+ /** Relays `rtdb.update` — the worker applies the FULL multi-path
956
+ * update semantics (`pyric/database`), an upgrade over the
957
+ * local arm's shallow per-key merge. `null` values delete. */
958
+ async update(values: object): Promise<void> {
959
+ if (values === null || typeof values !== 'object') {
960
+ throw new TypeError(
961
+ 'pyric-admin/database sandbox: update expected an object.',
962
+ );
963
+ }
964
+ await state.channel.op({
965
+ method: 'rtdb.update',
966
+ path: canonical,
967
+ values: values as Record<string, unknown>,
968
+ actAs: REMOTE_ADMIN_LENS,
969
+ });
970
+ },
971
+
972
+ /** Delete the subtree at this path (`rtdb.remove`). */
973
+ async remove(): Promise<void> {
974
+ await state.channel.op({
975
+ method: 'rtdb.remove',
976
+ path: canonical,
977
+ actAs: REMOTE_ADMIN_LENS,
978
+ });
979
+ },
980
+
981
+ /**
982
+ * Mint a 20-char push id CLIENT-side and relay `rtdb.push` carrying it
983
+ * (the worker-protocol contract) — so the returned
984
+ * `ThenableReference.key` is available synchronously, exactly like the
985
+ * local arm and firebase-admin. `.then()` settles when the relayed
986
+ * write commits (or immediately when no value was supplied — a bare
987
+ * `push()` performs no write, matching upstream); a write failure
988
+ * rejects the thenable and reaches `onComplete`.
989
+ */
990
+ push(value?: unknown, onComplete?: (err: Error | null) => void): AdminThenableReference {
991
+ const id = generatePushId();
992
+ const childPath = joinPath([...segs, id]);
993
+ const write: Promise<void> =
994
+ value === undefined
995
+ ? Promise.resolve()
996
+ : state.channel
997
+ .op({
998
+ method: 'rtdb.push',
999
+ path: canonical,
1000
+ key: id,
1001
+ value,
1002
+ actAs: REMOTE_ADMIN_LENS,
1003
+ })
1004
+ .then(() => undefined);
1005
+ // Surface completion without forcing the caller to await: `.then`'s
1006
+ // rejection handler also keeps a fire-and-forget push from becoming
1007
+ // an unhandled rejection (the failure still reaches `onComplete` and
1008
+ // any `.then()`/`await` on the returned thenable).
1009
+ write.then(
1010
+ () => {
1011
+ if (onComplete) onComplete(null);
1012
+ },
1013
+ (err: Error) => {
1014
+ if (onComplete) onComplete(err);
1015
+ },
1016
+ );
1017
+ const childRef = refAt(childPath);
1018
+ // CRITICAL: settle with a PLAIN (non-thenable) ref — resolving with
1019
+ // the thenable itself would make promise resolution unwrap it
1020
+ // forever (`await push(...)` would spin). Same guard as local arm.
1021
+ const resolvedRef = refAt(childPath);
1022
+ const thenable = childRef as AdminReference & PromiseLike<AdminReference>;
1023
+ (thenable as unknown as { then: PromiseLike<AdminReference>['then'] }).then = (
1024
+ onFulfilled,
1025
+ onRejected,
1026
+ ) => write.then(() => resolvedRef).then(onFulfilled, onRejected);
1027
+ (thenable as unknown as { catch: <U>(onRejected: (reason: unknown) => U | PromiseLike<U>) => Promise<AdminReference | U> }).catch = (
1028
+ onRejected,
1029
+ ) => write.then(() => resolvedRef).catch(onRejected);
1030
+ return thenable as AdminThenableReference;
1031
+ },
1032
+
1033
+ /** Relative ref builder — pure local path manipulation. */
1034
+ child(p: string): AdminReference {
1035
+ return refAt(joinPath([...segs, ...pathSegments(p)]));
1036
+ },
1037
+
1038
+ // ─── Value listeners (relayed worker subscription) ────────────────
1039
+
1040
+ /**
1041
+ * `on('value', callback)` — routed through the channel's RTDB value
1042
+ * subscription: the callback fires with the initial snapshot and on
1043
+ * every subsequent change (including changes made by the browser app,
1044
+ * Studio, or agents — one shared tree). A subscription-establishment
1045
+ * failure routes to `cancelCallback` when one is supplied. Other
1046
+ * event types (`child_added`, …) still throw "not implemented" —
1047
+ * the worker relays only value subscriptions today.
1048
+ */
1049
+ on(
1050
+ eventType: AdminEventType,
1051
+ callback: (snap: AdminDataSnapshot, prevChildKey?: string | null) => unknown,
1052
+ cancelCallbackOrContext?: ((err: Error) => unknown) | object | null,
1053
+ _context?: object | null,
1054
+ ): (snap: AdminDataSnapshot, prevChildKey?: string | null) => unknown {
1055
+ if (eventType !== 'value') {
1056
+ throw notImplemented(`on('${eventType}')`);
1057
+ }
1058
+ const cancelCallback =
1059
+ typeof cancelCallbackOrContext === 'function'
1060
+ ? (cancelCallbackOrContext as (err: Error) => unknown)
1061
+ : undefined;
1062
+ const detach = state.channel.subscribe(
1063
+ { target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS },
1064
+ (value) => {
1065
+ callback(snapFromWire(value as RemoteWireSnapshot));
1066
+ },
1067
+ (err) => {
1068
+ detachListener(state, canonical, callback);
1069
+ if (cancelCallback) cancelCallback(err);
1070
+ else console.error(`pyric-admin/database: on('value') subscription failed at ${canonical}:`, err);
1071
+ },
1072
+ );
1073
+ let atPath = state.listeners.get(canonical);
1074
+ if (atPath === undefined) {
1075
+ atPath = new Map();
1076
+ state.listeners.set(canonical, atPath);
1077
+ }
1078
+ // Re-registering the same callback replaces the prior registration
1079
+ // (detach it first so the old worker subscription doesn't leak).
1080
+ atPath.get(callback)?.();
1081
+ atPath.set(callback, detach);
1082
+ return callback;
1083
+ },
1084
+
1085
+ /**
1086
+ * Detach value listeners at this path: `off('value', callback)` removes
1087
+ * that registration; `off()` / `off('value')` removes all of them.
1088
+ * Unknown callbacks and other event types are no-ops (nothing else can
1089
+ * be registered on the remote arm).
1090
+ */
1091
+ off(
1092
+ eventType?: AdminEventType,
1093
+ callback?: (snap: AdminDataSnapshot, prevChildKey?: string | null) => unknown,
1094
+ _context?: object | null,
1095
+ ): void {
1096
+ if (eventType !== undefined && eventType !== 'value') return;
1097
+ if (callback !== undefined) {
1098
+ detachListener(state, canonical, callback);
1099
+ return;
1100
+ }
1101
+ const atPath = state.listeners.get(canonical);
1102
+ if (atPath === undefined) return;
1103
+ for (const detach of atPath.values()) detach();
1104
+ state.listeners.delete(canonical);
1105
+ },
1106
+
1107
+ // ─── Not implemented on the remote arm (parity with local) ────────
1108
+
1109
+ onDisconnect(): never {
1110
+ throw notImplemented('onDisconnect');
1111
+ },
1112
+ transaction(..._args: unknown[]): never {
1113
+ throw notImplemented('transaction');
1114
+ },
1115
+ setPriority(..._args: unknown[]): never {
1116
+ throw notImplemented('setPriority');
1117
+ },
1118
+ setWithPriority(..._args: unknown[]): never {
1119
+ throw notImplemented('setWithPriority');
1120
+ },
1121
+ orderByChild(..._args: unknown[]): never {
1122
+ throw notImplemented('orderByChild');
1123
+ },
1124
+ orderByKey(..._args: unknown[]): never {
1125
+ throw notImplemented('orderByKey');
1126
+ },
1127
+ orderByValue(..._args: unknown[]): never {
1128
+ throw notImplemented('orderByValue');
1129
+ },
1130
+ orderByPriority(..._args: unknown[]): never {
1131
+ throw notImplemented('orderByPriority');
1132
+ },
1133
+ startAt(..._args: unknown[]): never {
1134
+ throw notImplemented('startAt');
1135
+ },
1136
+ startAfter(..._args: unknown[]): never {
1137
+ throw notImplemented('startAfter');
1138
+ },
1139
+ endAt(..._args: unknown[]): never {
1140
+ throw notImplemented('endAt');
1141
+ },
1142
+ endBefore(..._args: unknown[]): never {
1143
+ throw notImplemented('endBefore');
1144
+ },
1145
+ equalTo(..._args: unknown[]): never {
1146
+ throw notImplemented('equalTo');
1147
+ },
1148
+ limitToFirst(..._args: unknown[]): never {
1149
+ throw notImplemented('limitToFirst');
1150
+ },
1151
+ limitToLast(..._args: unknown[]): never {
1152
+ throw notImplemented('limitToLast');
1153
+ },
1154
+ isEqual(other: unknown): boolean {
1155
+ return (
1156
+ other !== null &&
1157
+ typeof other === 'object' &&
1158
+ (other as { path?: string }).path === canonical
1159
+ );
1160
+ },
1161
+ toJSON(): object {
1162
+ return { path: canonical };
1163
+ },
1164
+ get database(): AdminDatabase {
1165
+ return db;
1166
+ },
1167
+ get ref(): AdminReference {
1168
+ return ref as unknown as AdminReference;
1169
+ },
1170
+ };
1171
+
1172
+ return ref as unknown as AdminReference;
1173
+ }
1174
+
1175
+ /** Remove one `on('value')` registration (and its worker subscription). */
1176
+ function detachListener(
1177
+ state: RemoteDbState,
1178
+ path: string,
1179
+ callback: unknown,
1180
+ ): void {
1181
+ const atPath = state.listeners.get(path);
1182
+ const detach = atPath?.get(callback);
1183
+ if (atPath === undefined || detach === undefined) return;
1184
+ atPath.delete(callback);
1185
+ if (atPath.size === 0) state.listeners.delete(path);
1186
+ detach();
1187
+ }