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.
- package/README.md +49 -0
- package/dist/app/index.d.ts +158 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +297 -0
- package/dist/app/index.js.map +1 -0
- package/dist/auth/index.d.ts +146 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +785 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/database/index.d.ts +119 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +965 -0
- package/dist/database/index.js.map +1 -0
- package/dist/firestore/index.d.ts +32 -0
- package/dist/firestore/index.d.ts.map +1 -0
- package/dist/firestore/index.js +53 -0
- package/dist/firestore/index.js.map +1 -0
- package/dist/storage/index.d.ts +178 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +442 -0
- package/dist/storage/index.js.map +1 -0
- package/package.json +52 -0
|
@@ -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
|