pyric-admin 0.1.0-alpha.24 → 0.1.0-alpha.26

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.
Files changed (128) hide show
  1. package/README.md +1 -1
  2. package/dist/admin.d.ts +64 -12
  3. package/dist/admin.d.ts.map +1 -1
  4. package/dist/admin.js +53 -8
  5. package/dist/admin.js.map +1 -1
  6. package/dist/app/index.d.ts +14 -1
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/app/index.js +58 -16
  9. package/dist/app/index.js.map +1 -1
  10. package/dist/app-check/index.d.ts +13 -1
  11. package/dist/app-check/index.d.ts.map +1 -1
  12. package/dist/app-check/index.js +14 -9
  13. package/dist/app-check/index.js.map +1 -1
  14. package/dist/auth/index.d.ts.map +1 -1
  15. package/dist/auth/index.js +11 -0
  16. package/dist/auth/index.js.map +1 -1
  17. package/dist/data-connect/index.d.ts +23 -1
  18. package/dist/data-connect/index.d.ts.map +1 -1
  19. package/dist/data-connect/index.js +44 -7
  20. package/dist/data-connect/index.js.map +1 -1
  21. package/dist/database/index.d.ts +24 -19
  22. package/dist/database/index.d.ts.map +1 -1
  23. package/dist/database/index.js +133 -99
  24. package/dist/database/index.js.map +1 -1
  25. package/dist/deferred.d.ts +37 -13
  26. package/dist/deferred.d.ts.map +1 -1
  27. package/dist/deferred.js +57 -15
  28. package/dist/deferred.js.map +1 -1
  29. package/dist/eventarc/index.d.ts +13 -1
  30. package/dist/eventarc/index.d.ts.map +1 -1
  31. package/dist/eventarc/index.js +15 -9
  32. package/dist/eventarc/index.js.map +1 -1
  33. package/dist/extensions/index.d.ts +13 -1
  34. package/dist/extensions/index.d.ts.map +1 -1
  35. package/dist/extensions/index.js +15 -9
  36. package/dist/extensions/index.js.map +1 -1
  37. package/dist/firestore/index.d.ts +6 -0
  38. package/dist/firestore/index.d.ts.map +1 -1
  39. package/dist/firestore/index.js +25 -3
  40. package/dist/firestore/index.js.map +1 -1
  41. package/dist/functions/index.d.ts +13 -1
  42. package/dist/functions/index.d.ts.map +1 -1
  43. package/dist/functions/index.js +15 -9
  44. package/dist/functions/index.js.map +1 -1
  45. package/dist/installations/index.d.ts +13 -1
  46. package/dist/installations/index.d.ts.map +1 -1
  47. package/dist/installations/index.js +15 -9
  48. package/dist/installations/index.js.map +1 -1
  49. package/dist/instance-id/index.d.ts +13 -1
  50. package/dist/instance-id/index.d.ts.map +1 -1
  51. package/dist/instance-id/index.js +15 -9
  52. package/dist/instance-id/index.js.map +1 -1
  53. package/dist/machine-learning/index.d.ts +19 -1
  54. package/dist/machine-learning/index.d.ts.map +1 -1
  55. package/dist/machine-learning/index.js +23 -9
  56. package/dist/machine-learning/index.js.map +1 -1
  57. package/dist/messaging/index.d.ts.map +1 -1
  58. package/dist/messaging/index.js +1 -0
  59. package/dist/messaging/index.js.map +1 -1
  60. package/dist/phone-number-verification/index.d.ts +12 -1
  61. package/dist/phone-number-verification/index.d.ts.map +1 -1
  62. package/dist/phone-number-verification/index.js +14 -9
  63. package/dist/phone-number-verification/index.js.map +1 -1
  64. package/dist/project-management/index.d.ts +21 -1
  65. package/dist/project-management/index.d.ts.map +1 -1
  66. package/dist/project-management/index.js +23 -9
  67. package/dist/project-management/index.js.map +1 -1
  68. package/dist/remote-config/index.d.ts +21 -1
  69. package/dist/remote-config/index.d.ts.map +1 -1
  70. package/dist/remote-config/index.js +24 -9
  71. package/dist/remote-config/index.js.map +1 -1
  72. package/dist/security-rules/index.d.ts +23 -1
  73. package/dist/security-rules/index.d.ts.map +1 -1
  74. package/dist/security-rules/index.js +27 -9
  75. package/dist/security-rules/index.js.map +1 -1
  76. package/dist/storage/deferred-members.d.ts +14 -0
  77. package/dist/storage/deferred-members.d.ts.map +1 -0
  78. package/dist/storage/deferred-members.js +55 -0
  79. package/dist/storage/deferred-members.js.map +1 -0
  80. package/dist/storage/delete-files.d.ts +13 -0
  81. package/dist/storage/delete-files.d.ts.map +1 -0
  82. package/dist/storage/delete-files.js +39 -0
  83. package/dist/storage/delete-files.js.map +1 -0
  84. package/dist/storage/file-query.d.ts +52 -0
  85. package/dist/storage/file-query.d.ts.map +1 -0
  86. package/dist/storage/file-query.js +94 -0
  87. package/dist/storage/file-query.js.map +1 -0
  88. package/dist/storage/get-files.d.ts +15 -0
  89. package/dist/storage/get-files.d.ts.map +1 -0
  90. package/dist/storage/get-files.js +33 -0
  91. package/dist/storage/get-files.js.map +1 -0
  92. package/dist/storage/index.d.ts +77 -13
  93. package/dist/storage/index.d.ts.map +1 -1
  94. package/dist/storage/index.js +359 -160
  95. package/dist/storage/index.js.map +1 -1
  96. package/dist/storage/remote-names.d.ts +19 -0
  97. package/dist/storage/remote-names.d.ts.map +1 -0
  98. package/dist/storage/remote-names.js +26 -0
  99. package/dist/storage/remote-names.js.map +1 -0
  100. package/package.json +2 -2
  101. package/src/admin.ts +58 -8
  102. package/src/app/index.ts +71 -19
  103. package/src/app-check/index.ts +27 -9
  104. package/src/auth/auth.test.ts +27 -0
  105. package/src/auth/index.ts +8 -0
  106. package/src/data-connect/index.ts +70 -9
  107. package/src/database/database.test.ts +1 -0
  108. package/src/database/index.ts +182 -108
  109. package/src/deferred.ts +78 -15
  110. package/src/eventarc/index.ts +32 -9
  111. package/src/extensions/index.ts +32 -9
  112. package/src/firestore/index.ts +30 -3
  113. package/src/functions/index.ts +32 -9
  114. package/src/installations/index.ts +33 -10
  115. package/src/instance-id/index.ts +33 -9
  116. package/src/machine-learning/index.ts +46 -9
  117. package/src/messaging/index.ts +1 -0
  118. package/src/phone-number-verification/index.ts +31 -9
  119. package/src/project-management/index.ts +48 -8
  120. package/src/remote-config/index.ts +51 -8
  121. package/src/security-rules/index.ts +54 -9
  122. package/src/storage/deferred-members.ts +67 -0
  123. package/src/storage/delete-files.ts +40 -0
  124. package/src/storage/file-query.ts +135 -0
  125. package/src/storage/get-files.ts +40 -0
  126. package/src/storage/index.ts +484 -180
  127. package/src/storage/remote-names.ts +34 -0
  128. package/src/storage/storage.test.ts +78 -5
@@ -40,11 +40,12 @@
40
40
  * backing rule state to expose.
41
41
  *
42
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).
43
+ * keyed by the `Sandbox` instance, with one tree per RTDB instance
44
+ * (the instance a database URL names). `sandbox.reset()` wipes every
45
+ * tree via the sandbox's `session_boundary` event.
46
+ * Successive `getDatabase(app)` calls for the same sandbox and instance
47
+ * return the same handle (matches firebase-admin's one database per
48
+ * app and URL).
48
49
  *
49
50
  * - **Remote sandbox arm** (sandbox target whose `Sandbox` carries the
50
51
  * `pyric/sandbox` remote brand — a Node-side handle onto the
@@ -52,7 +53,9 @@
52
53
  * `connectRemoteSandbox()`) — every `Reference` data operation routes
53
54
  * through the handle's worker-relay channel (`rtdb.get/set/update/
54
55
  * remove/push` ops with `actAs: { mode: 'admin' }` pinned — firebase-
55
- * admin's rules-bypass semantics), NOT into the process-local tree:
56
+ * admin's rules-bypass semantics, and the instance the database
57
+ * URL names on every op and value subscription), NOT into the
58
+ * process-local tree:
56
59
  * a local tree on a remote handle would be private server-side data
57
60
  * the browser never sees. Differences from the local arm, both
58
61
  * deliberate upgrades:
@@ -78,6 +81,16 @@ import {
78
81
  type Sandbox,
79
82
  } from 'pyric/sandbox';
80
83
 
84
+ import {
85
+ createDatabaseInstanceRegistry,
86
+ isCustomDatabaseHost,
87
+ isDefaultDatabaseInstance,
88
+ parseDatabaseLocationUrl,
89
+ parseDatabaseUrl,
90
+ type DatabaseInstance,
91
+ type DatabaseInstanceRegistry,
92
+ } from 'pyric/sandbox/internal';
93
+
81
94
  import {
82
95
  ADMIN_APP_TARGET,
83
96
  getApp,
@@ -85,6 +98,12 @@ import {
85
98
  } from '../app/index.js';
86
99
  import { assertAdminAppActive } from '../app/lifecycle.js';
87
100
  import { increment, serverTimestamp } from 'pyric/database';
101
+ import { valueAtChildPath, valueChildren } from 'pyric/database/internal';
102
+ import { deferredExport } from 'pyric/app/internal';
103
+
104
+ /** `firebase-admin/database`'s `enableLogging`. The sandbox does not model
105
+ * the client logger, so it fails with an error naming it. */
106
+ export const enableLogging = deferredExport('pyric-admin/database', 'enableLogging');
88
107
 
89
108
  /** Mirror of `firebase-admin/database`'s `ServerValue` constant and sentinel constructors. */
90
109
  export const ServerValue: {
@@ -154,20 +173,22 @@ type AdminEventType = EventType;
154
173
  *
155
174
  * Signature mirrors `firebase-admin/database`'s `getDatabase(app?)`.
156
175
  *
157
- * - `getDatabase()` — default database for the DEFAULT app (resolved
176
+ * - `getDatabase()`: default database for the DEFAULT app (resolved
158
177
  * through `pyric-admin/app`'s registry, exactly like firebase-admin's
159
178
  * no-arg `getDatabase()`; throws `app/no-app` when no default app has
160
179
  * been initialized). Works for local and remote sandbox apps.
161
- * - `getDatabase(app)` — default database for the app.
162
- * - `getDatabase(app, url)` — legacy Pyric-only compatibility form. New
163
- * code should use the upstream-shaped {@link getDatabaseWithUrl} export.
180
+ * - `getDatabase(app)`: the database the app's `databaseURL` option names,
181
+ * else the default instance.
182
+ * - `getDatabase(app, url)`: the database `url` names, as
183
+ * {@link getDatabaseWithUrl} selects it.
164
184
  *
165
- * The sandbox brand returns the local or remote `Database` backed by the
166
- * per-`Sandbox` state described in the module-level docs.
185
+ * Each RTDB instance has its own data: one `Database` per sandbox and
186
+ * instance, backed by the per-`Sandbox` state described in the module-level
187
+ * docs. A URL that does not parse as a database URL throws the SDK's error.
167
188
  */
168
189
  export function getDatabase(
169
190
  app?: PyricAdminApp,
170
- _url?: string,
191
+ url?: string,
171
192
  ): AdminDatabase {
172
193
  if (app === undefined) {
173
194
  // No-arg mirror of firebase-admin's `getDatabase()` — resolve the
@@ -176,7 +197,7 @@ export function getDatabase(
176
197
  }
177
198
  assertAdminAppActive(app);
178
199
  if (app[ADMIN_APP_TARGET] === 'sandbox') {
179
- return getSandboxDatabase(app.sandbox);
200
+ return getSandboxDatabase(app.sandbox, adminDatabaseInstance(app, url));
180
201
  }
181
202
  throw new TypeError(
182
203
  'pyric-admin/database: getDatabase expected a PyricAdminApp ' +
@@ -185,19 +206,47 @@ export function getDatabase(
185
206
  }
186
207
 
187
208
  /**
188
- * Returns the {@link AdminDatabase} service selected by an upstream-shaped
189
- * database URL.
209
+ * Returns the {@link AdminDatabase} service for the RTDB instance `url` names.
190
210
  *
191
211
  * This is the exact `firebase-admin/database` argument order used by the
192
- * Firebase Functions SDK: `getDatabaseWithUrl(url, app?)`. The first Pyric
193
- * Functions slice has one shared RTDB instance, so the URL selects that
194
- * instance rather than creating a second sandbox database.
212
+ * Firebase Functions SDK: `getDatabaseWithUrl(url, app?)`.
195
213
  */
196
214
  export function getDatabaseWithUrl(
197
- _url: string,
215
+ url: string,
198
216
  app?: PyricAdminApp,
199
217
  ): AdminDatabase {
200
- return getDatabase(app);
218
+ if (typeof url !== 'string' || url === '') {
219
+ throw invalidDatabaseUrl();
220
+ }
221
+ return getDatabase(app, url);
222
+ }
223
+
224
+ /** firebase-admin's error for a missing or empty database URL. */
225
+ function invalidDatabaseUrl(): Error {
226
+ return Object.assign(
227
+ new Error('Database URL must be a valid, non-empty URL string.'),
228
+ { code: 'database/invalid-argument' },
229
+ );
230
+ }
231
+
232
+ /**
233
+ * The instance an admin database targets: `url`, else the app's `databaseURL`
234
+ * option, parsed as the production SDK parses it. `undefined` is the default
235
+ * instance, which the instance named `<projectId>-default-rtdb` or, for a
236
+ * project created before about 2020, `<projectId>` also is when the app's
237
+ * options name the project.
238
+ */
239
+ function adminDatabaseInstance(
240
+ app: PyricAdminApp,
241
+ url: string | undefined,
242
+ ): DatabaseInstance | undefined {
243
+ if (url === '') throw invalidDatabaseUrl();
244
+ const source = url ?? app.options.databaseURL;
245
+ if (source === undefined) return undefined;
246
+ const instance = parseDatabaseUrl(source);
247
+ const projectId = app.options.projectId;
248
+ const isProjectDefault = isDefaultDatabaseInstance(instance.name, projectId);
249
+ return isProjectDefault ? undefined : instance;
201
250
  }
202
251
 
203
252
  // ─── Sandbox backend ─────────────────────────────────────────────────
@@ -218,54 +267,61 @@ type JsonValue =
218
267
  | { [key: string]: JsonValue };
219
268
 
220
269
  /**
221
- * Per-sandbox state. One instance per `Sandbox`; the WeakMap below
222
- * keys this off the `Sandbox` reference so `sandbox.reset()` can wipe
223
- * everything in one swap. The `Database` handle returned to consumers
224
- * holds onto the `SandboxState` directly so reads / writes don't
225
- * re-resolve through the WeakMap on every op.
270
+ * One RTDB instance's in-memory tree. The `Database` handle returned to
271
+ * consumers holds onto the `SandboxState` directly so reads / writes don't
272
+ * re-resolve through the registry on every op.
226
273
  */
227
274
  interface SandboxState {
228
275
  root: Record<string, JsonValue>;
229
276
  }
230
277
 
231
- /** One backend per `Sandbox`. Successive `getDatabase(app)` calls for
232
- * the same sandbox return handles that share data — matches
233
- * firebase-admin's singleton-per-app semantics. */
234
- const stateBySandbox = new WeakMap<Sandbox, SandboxState>();
235
-
236
- function getOrCreateState(sandbox: Sandbox): SandboxState {
237
- let state = stateBySandbox.get(sandbox);
238
- if (state !== undefined) return state;
239
- state = { root: {} };
240
- stateBySandbox.set(sandbox, state);
241
- // Wire `sandbox.reset()` → wipe the tree. `session_boundary` fires
242
- // before the env swap, so consumer code that observes a reset sees
278
+ /** One instance's tree and the one `Database` handle over it, built on first use. */
279
+ interface LocalInstance {
280
+ state: SandboxState;
281
+ db?: AdminDatabase;
282
+ }
283
+
284
+ /** One registry of instances per `Sandbox`. Successive `getDatabase(app)`
285
+ * calls for the same sandbox and instance return the same handle, and
286
+ * each instance keeps its own tree, as firebase-admin keeps one database
287
+ * per URL. */
288
+ const localInstancesBySandbox = new WeakMap<Sandbox, DatabaseInstanceRegistry<LocalInstance>>();
289
+
290
+ function localInstances(sandbox: Sandbox): DatabaseInstanceRegistry<LocalInstance> {
291
+ const existing = localInstancesBySandbox.get(sandbox);
292
+ if (existing !== undefined) return existing;
293
+ const registry: DatabaseInstanceRegistry<LocalInstance> = createDatabaseInstanceRegistry({
294
+ create: () => ({ state: { root: {} } }),
295
+ });
296
+ localInstancesBySandbox.set(sandbox, registry);
297
+ // Wire `sandbox.reset()` → wipe every instance's tree. `session_boundary`
298
+ // fires before the env swap, so consumer code that observes a reset sees
243
299
  // the freshly-cleared tree on the next read. `dispose` also fires a
244
300
  // boundary; treat it the same (the sandbox is being torn down — any
245
301
  // in-flight handle on the tree gets an empty view).
246
302
  sandbox.onEvent((event) => {
247
303
  if (event.kind === 'session_boundary') {
248
- state!.root = {};
304
+ for (const [, entry] of registry.entries()) entry.state.root = {};
249
305
  }
250
306
  });
251
- return state;
307
+ return registry;
252
308
  }
253
309
 
254
- /** Build (or reuse) the sandbox Database handle for `sandbox`.
310
+ /** Build (or reuse) the sandbox Database handle for `sandbox` and `instance`
311
+ * (`undefined` is the default instance).
255
312
  *
256
313
  * REMOTE handles dispatch here, BEFORE any local state is touched: a
257
314
  * remote sandbox must never get a `SandboxState` (a private local tree)
258
315
  * or a `sandbox.onEvent` wire-up (which throws on remote handles). */
259
- function getSandboxDatabase(sandbox: Sandbox): AdminDatabase {
316
+ function getSandboxDatabase(sandbox: Sandbox, instance: DatabaseInstance | undefined): AdminDatabase {
260
317
  if (isRemoteSandbox(sandbox)) {
261
- return getRemoteDatabase(sandbox);
318
+ return getRemoteDatabase(sandbox, instance);
262
319
  }
263
- const state = getOrCreateState(sandbox);
264
- return buildSandboxDatabase(state);
265
- }
266
-
267
- function buildSandboxDatabase(state: SandboxState): AdminDatabase {
268
- return buildDatabaseShell((db, path) => buildSandboxRef(state, db, path));
320
+ const registry = localInstances(sandbox);
321
+ const entry = registry.getOrCreate(registry.keyOf(instance));
322
+ const state = entry.state;
323
+ entry.db ??= buildDatabaseShell(instance, (handle, path) => buildSandboxRef(state, handle, path));
324
+ return entry.db;
269
325
  }
270
326
 
271
327
  /**
@@ -275,18 +331,26 @@ function buildSandboxDatabase(state: SandboxState): AdminDatabase {
275
331
  * local emulator).
276
332
  */
277
333
  function buildDatabaseShell(
334
+ instance: DatabaseInstance | undefined,
278
335
  refFactory: (db: AdminDatabase, path: string) => AdminReference,
279
336
  ): AdminDatabase {
280
337
  const db = {
281
338
  ref(path?: string): AdminReference {
282
339
  return refFactory(db as unknown as AdminDatabase, path ?? '/');
283
340
  },
341
+ /** As the SDK parses it: the URL names a location, and for a
342
+ * `firebaseio.com` database its host must be this database's host. */
284
343
  refFromURL(url: string): AdminReference {
285
- // Best-effort: strip the `https://<host>` prefix and treat the
286
- // remainder as a path. The sandbox has no notion of multi-database
287
- // hosts, so the host portion is ignored.
288
- const u = url.replace(/^https?:\/\/[^/]+/, '');
289
- return refFactory(db as unknown as AdminDatabase, u || '/');
344
+ const location = parseDatabaseLocationUrl(url);
345
+ if (instance !== undefined) {
346
+ const expectedHost = parseDatabaseLocationUrl(instance.url).host;
347
+ if (!isCustomDatabaseHost(expectedHost) && location.host !== expectedHost) {
348
+ throw new Error(
349
+ `FIREBASE FATAL ERROR: refFromURL: Host name does not match the current database: (found ${location.host} but expected ${expectedHost}) `,
350
+ );
351
+ }
352
+ }
353
+ return refFactory(db as unknown as AdminDatabase, location.path);
290
354
  },
291
355
  // Admin-only metadata methods — not modeled in the sandbox. The
292
356
  // sandbox is rule-bypass by construction; surfacing rule JSON would
@@ -757,6 +821,9 @@ function buildSandboxSnap(
757
821
  const segs = pathSegments(path);
758
822
  const key = segs.length === 0 ? null : segs[segs.length - 1]!;
759
823
  const exists = val !== null;
824
+ // An array value still has children: `val()` renders integer keys as an
825
+ // array, and the SDK iterates them as '0', '1', ... (#1110).
826
+ const children = valueChildren(val);
760
827
  const snap = {
761
828
  key,
762
829
  get ref(): AdminReference {
@@ -770,35 +837,21 @@ function buildSandboxSnap(
770
837
  },
771
838
  child(p: string): AdminDataSnapshot {
772
839
  const childSegs = pathSegments(p);
773
- let cur: JsonValue = val;
774
- for (const s of childSegs) {
775
- if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
776
- cur = null;
777
- break;
778
- }
779
- cur = (cur as Record<string, JsonValue>)[s] ?? null;
780
- }
781
- return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), cur);
840
+ const childVal = valueAtChildPath(val, childSegs) as JsonValue;
841
+ return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), childVal);
782
842
  },
783
843
  hasChild(p: string): boolean {
784
844
  return snap.child(p).exists();
785
845
  },
786
846
  hasChildren(): boolean {
787
- return (
788
- val !== null &&
789
- typeof val === 'object' &&
790
- !Array.isArray(val) &&
791
- Object.keys(val as Record<string, JsonValue>).length > 0
792
- );
847
+ return children.length > 0;
793
848
  },
794
849
  numChildren(): number {
795
- if (val === null || typeof val !== 'object' || Array.isArray(val)) return 0;
796
- return Object.keys(val as Record<string, JsonValue>).length;
850
+ return children.length;
797
851
  },
798
852
  forEach(cb: (child: AdminDataSnapshot) => boolean | void): boolean {
799
- if (val === null || typeof val !== 'object' || Array.isArray(val)) return false;
800
- for (const [k, v] of Object.entries(val as Record<string, JsonValue>)) {
801
- const childSnap = buildSandboxSnap(refAt, joinPath([...segs, k]), v);
853
+ for (const { key: childKey, value } of children) {
854
+ const childSnap = buildSandboxSnap(refAt, joinPath([...segs, childKey]), value as JsonValue);
802
855
  if (cb(childSnap) === true) return true;
803
856
  }
804
857
  return false;
@@ -845,30 +898,60 @@ interface RemoteWireSnapshot {
845
898
  }
846
899
 
847
900
  /**
848
- * Per-remote-handle state: the relay channel plus the `on('value')`
849
- * listener registry (`path → callback → detach`) that `off()` consults.
901
+ * Per-remote-handle, per-instance state: the relay channel, the instance
902
+ * every op and subscription names (`undefined` is the default instance),
903
+ * and the `on('value')` listener registry (`path → callback → detach`) that
904
+ * `off()` consults.
850
905
  */
851
906
  interface RemoteDbState {
852
907
  channel: RemoteSandboxChannel;
908
+ instance: string | undefined;
853
909
  listeners: Map<string, Map<unknown, () => void>>;
854
910
  }
855
911
 
856
- /** One `Database` per remote handle — successive `getDatabase(app)` calls
857
- * share the listener registry (matches the local arm's singleton-per-
858
- * sandbox semantics). Keyed off the handle object; the data itself lives
859
- * in the browser worker. */
860
- const remoteDbBySandbox = new WeakMap<Sandbox, AdminDatabase>();
861
-
862
- function getRemoteDatabase(sandbox: RemoteSandbox): AdminDatabase {
863
- let db = remoteDbBySandbox.get(sandbox);
864
- if (db !== undefined) return db;
912
+ /** One `Database` per remote handle and instance. Successive
913
+ * `getDatabase(app)` calls for an instance share the listener registry
914
+ * (matches the local arm's singleton-per-instance semantics). Keyed off
915
+ * the handle object; the data itself lives in the host. */
916
+ const remoteDbsBySandbox = new WeakMap<Sandbox, DatabaseInstanceRegistry<{ db?: AdminDatabase }>>();
917
+
918
+ function getRemoteDatabase(sandbox: RemoteSandbox, instance: DatabaseInstance | undefined): AdminDatabase {
919
+ let registry = remoteDbsBySandbox.get(sandbox);
920
+ if (registry === undefined) {
921
+ registry = createDatabaseInstanceRegistry({ create: () => ({}) });
922
+ remoteDbsBySandbox.set(sandbox, registry);
923
+ }
924
+ const entry = registry.getOrCreate(registry.keyOf(instance));
925
+ if (entry.db !== undefined) return entry.db;
865
926
  const state: RemoteDbState = {
866
927
  channel: sandbox.channel,
928
+ instance: instance?.name,
867
929
  listeners: new Map(),
868
930
  };
869
- db = buildDatabaseShell((dbHandle, path) => buildRemoteRef(state, dbHandle, path));
870
- remoteDbBySandbox.set(sandbox, db);
871
- return db;
931
+ entry.db = buildDatabaseShell(instance, (dbHandle, path) => buildRemoteRef(state, dbHandle, path));
932
+ return entry.db;
933
+ }
934
+
935
+ /** An RTDB op this arm relays, without the instance and lens {@link relayRtdbOp} adds. */
936
+ type RemoteRtdbOp = { method: `rtdb.${string}`; path: string } & Record<string, unknown>;
937
+
938
+ /** Relay `op` on the state's instance, under firebase-admin's rules-bypass lens. */
939
+ function relayRtdbOp(state: RemoteDbState, op: RemoteRtdbOp): Promise<unknown> {
940
+ return state.channel.op({ ...op, instance: state.instance, actAs: REMOTE_ADMIN_LENS });
941
+ }
942
+
943
+ /** Subscribe to the value at `path` on the state's instance, under the admin lens. */
944
+ function subscribeRtdbValue(
945
+ state: RemoteDbState,
946
+ path: string,
947
+ onSnap: (value: unknown) => void,
948
+ onError: (err: Error & { code: string }) => void,
949
+ ): () => void {
950
+ return state.channel.subscribe(
951
+ { target: { service: 'rtdb', instance: state.instance, path }, actAs: REMOTE_ADMIN_LENS },
952
+ onSnap,
953
+ onError,
954
+ );
872
955
  }
873
956
 
874
957
  /**
@@ -910,20 +993,18 @@ function buildRemoteRef(
910
993
 
911
994
  /** Set `value` at this path. `null` deletes. Relays `rtdb.set`. */
912
995
  async set(value: unknown): Promise<void> {
913
- await state.channel.op({
996
+ await relayRtdbOp(state, {
914
997
  method: 'rtdb.set',
915
998
  path: canonical,
916
999
  value: value ?? null,
917
- actAs: REMOTE_ADMIN_LENS,
918
1000
  });
919
1001
  },
920
1002
 
921
1003
  /** Read this path (`rtdb.get`). Resolves to a `DataSnapshot`. */
922
1004
  async get(): Promise<AdminDataSnapshot> {
923
- const wire = (await state.channel.op({
1005
+ const wire = (await relayRtdbOp(state, {
924
1006
  method: 'rtdb.get',
925
1007
  path: canonical,
926
- actAs: REMOTE_ADMIN_LENS,
927
1008
  })) as RemoteWireSnapshot;
928
1009
  return snapFromWire(wire);
929
1010
  },
@@ -943,8 +1024,9 @@ function buildRemoteRef(
943
1024
  return new Promise<AdminDataSnapshot>((resolve, reject) => {
944
1025
  let detach: (() => void) | null = null;
945
1026
  let settled = false;
946
- detach = state.channel.subscribe(
947
- { target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS },
1027
+ detach = subscribeRtdbValue(
1028
+ state,
1029
+ canonical,
948
1030
  (value) => {
949
1031
  if (settled) return;
950
1032
  settled = true;
@@ -971,20 +1053,18 @@ function buildRemoteRef(
971
1053
  'pyric-admin/database sandbox: update expected an object.',
972
1054
  );
973
1055
  }
974
- await state.channel.op({
1056
+ await relayRtdbOp(state, {
975
1057
  method: 'rtdb.update',
976
1058
  path: canonical,
977
1059
  values: values as Record<string, unknown>,
978
- actAs: REMOTE_ADMIN_LENS,
979
1060
  });
980
1061
  },
981
1062
 
982
1063
  /** Delete the subtree at this path (`rtdb.remove`). */
983
1064
  async remove(): Promise<void> {
984
- await state.channel.op({
1065
+ await relayRtdbOp(state, {
985
1066
  method: 'rtdb.remove',
986
1067
  path: canonical,
987
- actAs: REMOTE_ADMIN_LENS,
988
1068
  });
989
1069
  },
990
1070
 
@@ -1003,14 +1083,7 @@ function buildRemoteRef(
1003
1083
  const write: Promise<void> =
1004
1084
  value === undefined
1005
1085
  ? Promise.resolve()
1006
- : state.channel
1007
- .op({
1008
- method: 'rtdb.push',
1009
- path: canonical,
1010
- key: id,
1011
- value,
1012
- actAs: REMOTE_ADMIN_LENS,
1013
- })
1086
+ : relayRtdbOp(state, { method: 'rtdb.push', path: canonical, key: id, value })
1014
1087
  .then(() => undefined);
1015
1088
  // Surface completion without forcing the caller to await: `.then`'s
1016
1089
  // rejection handler also keeps a fire-and-forget push from becoming
@@ -1069,8 +1142,9 @@ function buildRemoteRef(
1069
1142
  typeof cancelCallbackOrContext === 'function'
1070
1143
  ? (cancelCallbackOrContext as (err: Error) => unknown)
1071
1144
  : undefined;
1072
- const detach = state.channel.subscribe(
1073
- { target: { service: 'rtdb', path: canonical }, actAs: REMOTE_ADMIN_LENS },
1145
+ const detach = subscribeRtdbValue(
1146
+ state,
1147
+ canonical,
1074
1148
  (value) => {
1075
1149
  callback(snapFromWire(value as RemoteWireSnapshot));
1076
1150
  },
package/src/deferred.ts CHANGED
@@ -1,20 +1,40 @@
1
1
  /**
2
- * The `firebase-admin` subpaths the sandbox does not mirror.
2
+ * Unmodeled `firebase-admin/*` subpaths and their `App`-bound service handles.
3
3
  *
4
4
  * Under `pyric sandbox`, every `firebase-admin/*` import resolves to the same
5
- * subpath of `pyric-admin`. For a service the sandbox does not model, that
6
- * subpath is a deferred entry: it resolves and every export links, so a module
7
- * graph that imports the service still loads, and using any export throws a
8
- * `PyricDeferredApiError` that names the subpath. The import is never passed
9
- * through to the real `firebase-admin`, which would reach production from
10
- * inside the sandbox.
5
+ * subpath of `pyric-admin`. Many server codebases initialize unmodeled services
6
+ * unconditionally at module load (`export const appCheck = getAppCheck(app)`)
7
+ * while never invoking methods on that handle on sandboxed request paths.
8
+ *
9
+ * Mirroring upstream's container contract keeps module initialization faithful
10
+ * without ever reaching production Firebase:
11
+ * - `get<Service>(app?)` resolves the active `PyricAdminApp` (throwing
12
+ * `FirebaseAppError` `app/no-app` before `initializeApp()`, or
13
+ * `app/app-deleted` after `deleteApp()`), caches one handle per app, and
14
+ * exposes `.app`.
15
+ * - Every method on the returned handle is wired via `defineDeferredMembers` so
16
+ * calling it throws synchronously or returns a rejected `Promise` with
17
+ * `PyricDeferredApiError` naming `<Class>.<method>` and `pyric-admin/<subpath>`.
18
+ * - Helper classes, enums, and direct `new <Service>()` calls throw
19
+ * `PyricDeferredApiError` naming the subpath.
11
20
  */
12
- import { deferredEntry, type DeferredApi } from 'pyric/app/internal';
21
+ import {
22
+ deferredEntry,
23
+ defineDeferredMembers,
24
+ PyricDeferredApiError,
25
+ type DeferredApi,
26
+ type DeferredMembers,
27
+ } from 'pyric/app/internal';
28
+ import { getApp, type PyricAdminApp } from './app/index.js';
29
+ import { assertAdminAppActive } from './app/lifecycle.js';
30
+
31
+ export { PyricDeferredApiError };
13
32
 
14
- export { PyricDeferredApiError } from 'pyric/app/internal';
33
+ /** Private construction token so `new <Service>()` throws while `get<Service>(app)` succeeds. */
34
+ export const DEFERRED_SERVICE_TOKEN: unique symbol = Symbol('pyric.admin.deferredService');
15
35
 
16
- /** The message every export of a deferred `firebase-admin` subpath throws. */
17
- function deferredAdminMessage(subpath: string): string {
36
+ /** The message every unmodeled `firebase-admin` export raises when called or constructed directly. */
37
+ export function deferredAdminMessage(subpath: string): string {
18
38
  return (
19
39
  `pyric: 'firebase-admin/${subpath}' is not mirrored by the local sandbox, so its calls fail here. ` +
20
40
  'Imports resolve so module graphs load. Code that needs this service runs against Firebase outside ' +
@@ -23,11 +43,54 @@ function deferredAdminMessage(subpath: string): string {
23
43
  }
24
44
 
25
45
  /**
26
- * The export bag for one deferred `firebase-admin` subpath. Each entry
27
- * destructures the names the real subpath exports off it.
28
- *
29
- * @param subpath - The subpath without the `firebase-admin/` prefix.
46
+ * The export bag for non-service exports of one deferred `firebase-admin`
47
+ * subpath (helper classes, error classes, enum-like constants).
30
48
  */
31
49
  export function deferredAdminEntry(subpath: string): Record<string, DeferredApi> {
32
50
  return deferredEntry(subpath, deferredAdminMessage(subpath));
33
51
  }
52
+
53
+ /** Resolve the default or explicit admin `App` and assert it has not been deleted. */
54
+ export function resolveActiveAdminApp(app?: PyricAdminApp): PyricAdminApp {
55
+ const resolved = app ?? getApp();
56
+ assertAdminAppActive(resolved);
57
+ return resolved;
58
+ }
59
+
60
+ /** Guard direct `new <Service>()` construction while allowing `wireDeferredService` to mint handles. */
61
+ export function guardDeferredServiceApp(
62
+ subpath: string,
63
+ className: string,
64
+ args: readonly unknown[],
65
+ ): PyricAdminApp {
66
+ if (args[0] !== DEFERRED_SERVICE_TOKEN) {
67
+ throw new PyricDeferredApiError(subpath, className, deferredAdminMessage(subpath));
68
+ }
69
+ return args[1] as PyricAdminApp;
70
+ }
71
+
72
+ /**
73
+ * Attach deferred member stubs onto `ctor.prototype` and return a per-app
74
+ * cached `get<Service>(app?)` factory matching `firebase-admin`'s
75
+ * `firebaseApp.getOrInitService(...)`.
76
+ */
77
+ export function wireDeferredService<T extends object>(
78
+ ctor: { prototype: T },
79
+ subpath: string,
80
+ owner: string,
81
+ members: DeferredMembers,
82
+ ): (app?: PyricAdminApp) => T {
83
+ defineDeferredMembers(ctor.prototype, `pyric-admin/${subpath}`, owner, members);
84
+ const instances = new WeakMap<PyricAdminApp, T>();
85
+ return function getDeferredService(app?: PyricAdminApp): T {
86
+ const resolved = resolveActiveAdminApp(app);
87
+ const existing = instances.get(resolved);
88
+ if (existing !== undefined) return existing;
89
+ const created = new (ctor as unknown as new (...ctorArgs: unknown[]) => T)(
90
+ DEFERRED_SERVICE_TOKEN,
91
+ resolved,
92
+ );
93
+ instances.set(resolved, created);
94
+ return created;
95
+ };
96
+ }
@@ -1,14 +1,37 @@
1
1
  /**
2
- * `pyric-admin/eventarc`: a deferred entry for `firebase-admin/eventarc`.
3
- *
4
- * The sandbox does not model Eventarc. Every value `firebase-admin/eventarc`
5
- * exports is present so imports link; using one throws `PyricDeferredApiError`.
6
- * See `../deferred.ts`.
2
+ * `pyric-admin/eventarc`: deferred service handle for `firebase-admin/eventarc`.
7
3
  */
8
- import { deferredAdminEntry } from '../deferred.js';
4
+ import type { PyricAdminApp } from '../app/index.js';
5
+ import {
6
+ deferredAdminEntry,
7
+ guardDeferredServiceApp,
8
+ wireDeferredService,
9
+ } from '../deferred.js';
9
10
 
10
11
  export { PyricDeferredApiError } from '../deferred.js';
11
12
 
12
- export const {
13
- Channel, Eventarc, getEventarc,
14
- } = deferredAdminEntry('eventarc');
13
+ export class Eventarc {
14
+ readonly #app: PyricAdminApp;
15
+
16
+ /** @internal */
17
+ constructor(...args: unknown[]) {
18
+ this.#app = guardDeferredServiceApp('eventarc', 'Eventarc', args);
19
+ }
20
+
21
+ get app(): PyricAdminApp {
22
+ return this.#app;
23
+ }
24
+
25
+ declare channel: (...args: unknown[]) => never;
26
+ }
27
+
28
+ export const getEventarc: (app?: PyricAdminApp) => Eventarc = wireDeferredService(
29
+ Eventarc,
30
+ 'eventarc',
31
+ 'Eventarc',
32
+ {
33
+ sync: ['channel'],
34
+ },
35
+ );
36
+
37
+ export const { Channel } = deferredAdminEntry('eventarc');