@hot-updater/firebase 1.1.4 → 1.2.0-rc.1

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/dist/index.mjs CHANGED
@@ -182,11 +182,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
182
182
  * after deploying the indexes.
183
183
  */
184
184
  const migrateFirebaseDatabase = (config, plugins = []) => migrateCoreSchema(adapterOf(config), "firebaseDatabase", plugins);
185
+ /**
186
+ * The resource this database reaches, without credentials, under the key
187
+ * `hot-updater doctor` reads to tell the database `hot-updater init` saved
188
+ * from another (a convention CLAUDE.md documents).
189
+ */
190
+ const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
185
191
  /** Hot Updater's database in one Firestore collection, through the storage engine. */
186
- const firebaseDatabase = (config) => createEngineDatabase({
187
- name: "firebaseDatabase",
188
- adapter: adapterOf(config),
189
- aggregateBatching: config.aggregateBatching ?? {}
192
+ const firebaseDatabase = (config) => ({
193
+ ...createEngineDatabase({
194
+ name: "firebaseDatabase",
195
+ adapter: adapterOf(config),
196
+ aggregateBatching: config.aggregateBatching ?? {}
197
+ }),
198
+ ...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
190
199
  });
191
200
  //#endregion
192
201
  //#region src/firebaseStorage.ts
@@ -194,7 +203,8 @@ const firebaseDatabase = (config) => createEngineDatabase({
194
203
  const MAX_CACHED_SIGNED_URLS = 1e4;
195
204
  const firebaseStorage = (config) => {
196
205
  const app = getApps().length ? getApp() : initializeApp(config);
197
- const bucket = getStorage(app).bucket(config.storageBucket);
206
+ let opened;
207
+ const bucket = () => opened ??= getStorage(app).bucket(config.storageBucket);
198
208
  const getStorageKey = createStorageKeyBuilder(config.basePath);
199
209
  /**
200
210
  * Signed URLs by object. Without a private key, as in Cloud Functions, each
@@ -205,7 +215,7 @@ const firebaseStorage = (config) => {
205
215
  const signedUrls = /* @__PURE__ */ new Map();
206
216
  const signUrl = async (key) => {
207
217
  const expiresAt = Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3;
208
- const [url] = await bucket.file(key).getSignedUrl({
218
+ const [url] = await bucket().file(key).getSignedUrl({
209
219
  action: "read",
210
220
  expires: expiresAt
211
221
  });
@@ -238,11 +248,11 @@ const firebaseStorage = (config) => {
238
248
  protocol: "gs",
239
249
  async put({ key, body, contentType }) {
240
250
  const storageKey = getStorageKey(key);
241
- const bytes = new Uint8Array(await new Response(body).arrayBuffer());
242
- await bucket.file(storageKey).save(bytes, { metadata: {
251
+ const [{ Readable }, { pipeline }] = await Promise.all([import("node:stream"), import("node:stream/promises")]);
252
+ await pipeline(Readable.fromWeb(body), bucket().file(storageKey).createWriteStream({ metadata: {
243
253
  contentType,
244
254
  cacheControl: "public, max-age=31536000, immutable"
245
- } });
255
+ } }));
246
256
  return { storageUri: createStorageUri({
247
257
  bucket: config.storageBucket,
248
258
  key: storageKey,
@@ -252,7 +262,7 @@ const firebaseStorage = (config) => {
252
262
  async get({ storageUri }) {
253
263
  const { key } = parseAndValidate(storageUri);
254
264
  try {
255
- const file = bucket.file(key);
265
+ const file = bucket().file(key);
256
266
  const [[body], [metadata]] = await Promise.all([file.download(), file.getMetadata()]);
257
267
  const headers = new Headers();
258
268
  if (metadata.contentType) headers.set("content-type", metadata.contentType);
@@ -277,15 +287,351 @@ const firebaseStorage = (config) => {
277
287
  },
278
288
  async exists({ storageUri }) {
279
289
  const { key } = parseAndValidate(storageUri);
280
- const [exists] = await bucket.file(key).exists();
290
+ const [exists] = await bucket().file(key).exists();
281
291
  return { exists };
282
292
  },
283
293
  async delete({ storageUri }) {
284
294
  const { key } = parseAndValidate(storageUri);
285
- await bucket.file(key).delete({ ignoreNotFound: true });
295
+ await bucket().file(key).delete({ ignoreNotFound: true });
286
296
  return { deleted: true };
287
297
  }
288
298
  });
289
299
  };
290
300
  //#endregion
291
- export { firebaseDatabase, firebaseStorage, migrateFirebaseDatabase };
301
+ //#region src/secretManager.ts
302
+ const ADAPTER_NAME = "secretManager";
303
+ const API_URL = "https://secretmanager.googleapis.com/v1";
304
+ const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
305
+ /**
306
+ * Variables an access token is read from as it is: gcloud's, and the one
307
+ * Terraform's Google provider reads, which a job sets from
308
+ * `google-github-actions/auth`'s `access_token` output.
309
+ */
310
+ const ACCESS_TOKEN_ENV_NAMES = ["CLOUDSDK_AUTH_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN"];
311
+ const PROJECT_PATTERN = /^[a-z0-9][a-z0-9.:-]*$/u;
312
+ const SECRET_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/u;
313
+ const PREFIX_PATTERN = /^[A-Za-z0-9_-]*$/u;
314
+ const VERSION_ID_PATTERN = /^[1-9]\d*$/u;
315
+ /**
316
+ * The secret's annotation that lists the versions this adapter added and
317
+ * has not destroyed yet: the only versions it ever destroys.
318
+ */
319
+ const VERSIONS_ANNOTATION = "hot-updater-versions";
320
+ /** What `set` needs on the secret, checked before it adds a version. */
321
+ const SET_PERMISSIONS = [
322
+ "secretmanager.versions.add",
323
+ "secretmanager.versions.access",
324
+ "secretmanager.versions.destroy",
325
+ "secretmanager.secrets.update"
326
+ ];
327
+ /**
328
+ * How long `set` waits, between reads, for the `latest` alias to resolve to
329
+ * the version it added: `latest` is eventually consistent. About 30 seconds.
330
+ */
331
+ const LATEST_RETRY_DELAYS_MS = [
332
+ 250,
333
+ 500,
334
+ 1e3,
335
+ 2e3,
336
+ 4e3,
337
+ 8e3,
338
+ 15e3
339
+ ];
340
+ /** How often `set` re-reads the secret when another writer changed it. */
341
+ const ANNOTATION_ATTEMPTS = 5;
342
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
343
+ const delay = (ms) => new Promise((resolve) => {
344
+ setTimeout(resolve, ms);
345
+ });
346
+ /** The CRC32C (Castagnoli) checksum Secret Manager verifies payloads with. */
347
+ const crc32c = (bytes) => {
348
+ let crc = 4294967295;
349
+ for (const byte of bytes) {
350
+ crc ^= byte;
351
+ for (let bit = 0; bit < 8; bit += 1) crc = crc >>> 1 ^ (crc & 1 ? 2197175160 : 0);
352
+ }
353
+ return (crc ^ 4294967295) >>> 0;
354
+ };
355
+ /** The version IDs the secret's annotation lists. */
356
+ const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
357
+ const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
358
+ const hint = (response, permission) => {
359
+ const { status, error } = response;
360
+ if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return " Enable the Secret Manager API (secretmanager.googleapis.com) in the project: gcloud services enable secretmanager.googleapis.com --project <project>.";
361
+ if (status === 401) return " Google rejected the access token: sign in again with `gcloud auth application-default login`, or check the job's Workload Identity Federation.";
362
+ if (status === 403) return ` The Google identity needs ${permission}.`;
363
+ if (status === 429) return " Secret Manager's quota is exhausted: try again later.";
364
+ return "";
365
+ };
366
+ /**
367
+ * An error for a failed request: the HTTP status, Google's status and
368
+ * message, which name resources but never a value, and what to fix.
369
+ */
370
+ const requestError = (action, response, permission) => {
371
+ const { status, message } = response.error;
372
+ const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
373
+ return /* @__PURE__ */ new Error(`${ADAPTER_NAME} could not ${action} (${response.status}${detail ? ` ${detail}` : ""}).${hint(response, permission)}`);
374
+ };
375
+ /** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
376
+ const mask = async (value) => {
377
+ const { registerSecretValue } = await import("@hot-updater/cli-tools");
378
+ registerSecretValue(value);
379
+ };
380
+ const tokenOf = (result) => {
381
+ if (typeof result === "string") return result;
382
+ if (!result) return void 0;
383
+ if ("token" in result) return result.token ?? void 0;
384
+ if ("access_token" in result) return result.access_token;
385
+ };
386
+ /**
387
+ * Returns a function that resolves an access token with the
388
+ * `cloud-platform` scope: from `credentials`, else from an access token
389
+ * variable, else from Application Default Credentials through
390
+ * `google-auth-library`, which loads on first use. Each token is masked in
391
+ * the CLI's output.
392
+ */
393
+ const createAccessToken = (credentials, projectId) => {
394
+ let auth;
395
+ const resolve = async () => {
396
+ if (typeof credentials === "function") return credentials();
397
+ if (credentials) return tokenOf(await credentials.getAccessToken());
398
+ for (const name of ACCESS_TOKEN_ENV_NAMES) {
399
+ const token = process.env[name]?.trim();
400
+ if (token) return token;
401
+ }
402
+ auth ??= import("google-auth-library").then(({ GoogleAuth }) => new GoogleAuth({
403
+ scopes: [CLOUD_PLATFORM_SCOPE],
404
+ projectId
405
+ })).catch((error) => {
406
+ auth = void 0;
407
+ throw error;
408
+ });
409
+ const client = await auth;
410
+ try {
411
+ return await client.getAccessToken() ?? void 0;
412
+ } catch (error) {
413
+ throw new Error(`${ADAPTER_NAME} found no Google credentials (${error instanceof Error ? error.message.split("\n")[0] : String(error)}). Sign in with \`gcloud auth application-default login\`, use google-github-actions/auth in CI, set GOOGLE_APPLICATION_CREDENTIALS, or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to an access token.`, { cause: error });
414
+ }
415
+ };
416
+ return async () => {
417
+ const token = (await resolve())?.trim();
418
+ if (!token) throw new Error(`${ADAPTER_NAME} got no Google access token.`);
419
+ await mask(token);
420
+ return token;
421
+ };
422
+ };
423
+ /**
424
+ * Secrets from Google Cloud Secret Manager: each name is the secret
425
+ * `<prefix><name>`, read at its `latest` version. Requests go to the REST
426
+ * API, signed by `google-auth-library`, which firebase-admin already
427
+ * installs and which loads on the first read or write, so a config that
428
+ * only uses `firebaseStorage` never loads it.
429
+ *
430
+ * @example
431
+ * secrets: secretManager({ projectId: "my-project" })
432
+ */
433
+ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
434
+ const accessToken = createAccessToken(credentials, projectId);
435
+ const projectPath = () => {
436
+ if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw new Error(`${ADAPTER_NAME} needs the Google Cloud project ID or number as projectId, and ${JSON.stringify(projectId)} is not one.`);
437
+ if (!PREFIX_PATTERN.test(prefix)) throw new Error(`${ADAPTER_NAME}'s prefix takes letters, digits, "-", and "_", and ${JSON.stringify(prefix)} has others.`);
438
+ return `projects/${projectId}`;
439
+ };
440
+ const secretId = (name) => {
441
+ const id = `${prefix}${name}`;
442
+ if (!SECRET_ID_PATTERN.test(id)) throw new Error(`${ADAPTER_NAME} keeps ${JSON.stringify(name)} as the secret ID ${JSON.stringify(id)}, and Secret Manager's IDs take only letters, digits, "-", and "_", up to 255 characters.`);
443
+ return id;
444
+ };
445
+ const request = async (token, method, path, body) => {
446
+ const authorization = `Bearer ${token}`;
447
+ const response = await fetch(`${API_URL}/${path}`, body === void 0 ? {
448
+ method,
449
+ headers: { authorization },
450
+ redirect: "error"
451
+ } : {
452
+ method,
453
+ headers: {
454
+ authorization,
455
+ "content-type": "application/json"
456
+ },
457
+ body: JSON.stringify(body),
458
+ redirect: "error"
459
+ });
460
+ let parsed;
461
+ try {
462
+ parsed = await response.json();
463
+ } catch {
464
+ parsed = void 0;
465
+ }
466
+ return {
467
+ status: response.status,
468
+ ok: response.ok,
469
+ body: parsed,
470
+ error: isRecord(parsed) && isRecord(parsed.error) ? parsed.error : {}
471
+ };
472
+ };
473
+ /** The `latest` version's ID and value, or undefined when there is none. */
474
+ const accessLatest = async (token, secret) => {
475
+ const response = await request(token, "GET", `${secret}/versions/latest:access`);
476
+ if (response.status === 404) return void 0;
477
+ if (!response.ok) throw requestError(`read ${secret}`, response, "roles/secretmanager.secretAccessor on the secret");
478
+ const body = isRecord(response.body) ? response.body : void 0;
479
+ const payload = body?.payload;
480
+ const data = isRecord(payload) ? payload.data : void 0;
481
+ if (typeof data !== "string") throw new Error(`${ADAPTER_NAME} read no payload for ${secret}.`);
482
+ const bytes = Buffer.from(data, "base64");
483
+ const checksum = isRecord(payload) ? payload.dataCrc32c : void 0;
484
+ if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw new Error(`${ADAPTER_NAME} read ${secret} with a checksum that does not match its payload.`);
485
+ const value = bytes.toString("utf8");
486
+ await mask(value);
487
+ return {
488
+ version: versionOf(body?.name),
489
+ value
490
+ };
491
+ };
492
+ /**
493
+ * Whether `latest` resolves to `version`, or a newer one another writer
494
+ * added, within about 30 seconds. While `latest` still names an older
495
+ * version, which may be missing or disabled, it reads again; it never
496
+ * throws, so the version `set` stored is always listed.
497
+ */
498
+ const waitForLatest = async (token, secret, version) => {
499
+ for (const ms of [0, ...LATEST_RETRY_DELAYS_MS]) {
500
+ if (ms > 0) await delay(ms);
501
+ let response;
502
+ try {
503
+ response = await request(token, "GET", `${secret}/versions/latest:access`);
504
+ } catch {
505
+ return false;
506
+ }
507
+ if (response.ok) {
508
+ const latest = versionOf(isRecord(response.body) ? response.body.name : void 0);
509
+ if (Number(latest) >= Number(version)) return true;
510
+ } else if (response.status !== 404 && response.error.status !== "FAILED_PRECONDITION") return false;
511
+ }
512
+ return false;
513
+ };
514
+ const readSecret = async (token, secret) => {
515
+ const response = await request(token, "GET", secret);
516
+ if (response.status === 404) return void 0;
517
+ if (!response.ok) throw requestError(`read ${secret}`, response, "secretmanager.secrets.get on the secret");
518
+ return response.body;
519
+ };
520
+ const getOrCreateSecret = async (token, parent, id) => {
521
+ const secret = `${parent}/secrets/${id}`;
522
+ const existing = await readSecret(token, secret);
523
+ if (existing) return existing;
524
+ const created = await request(token, "POST", `${parent}/secrets?secretId=${encodeURIComponent(id)}`, { replication: { automatic: {} } });
525
+ if (created.ok) return created.body;
526
+ if (created.status === 409) {
527
+ const raced = await readSecret(token, secret);
528
+ if (raced) return raced;
529
+ }
530
+ throw requestError(`create ${secret}`, created, `secretmanager.secrets.create on ${parent}`);
531
+ };
532
+ const assertCanSet = async (token, secret) => {
533
+ const response = await request(token, "POST", `${secret}:testIamPermissions`, { permissions: SET_PERMISSIONS });
534
+ if (!response.ok) throw requestError(`check its permissions on ${secret}`, response, SET_PERMISSIONS.join(", "));
535
+ const held = isRecord(response.body) && Array.isArray(response.body.permissions) ? response.body.permissions : [];
536
+ const missing = SET_PERMISSIONS.filter((name) => !held.includes(name));
537
+ if (missing.length > 0) throw new Error(`${ADAPTER_NAME} stores a value as a new version of ${secret} and destroys the versions it added before, and the Google identity lacks ${missing.join(", ")} on it: grant a custom role with secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, and secretmanager.versions.access on it, or roles/secretmanager.admin.`);
538
+ };
539
+ /**
540
+ * Destroys `version` of `secret`: true once it is destroyed, or already
541
+ * was, false when it could not be, even for a network failure, so it
542
+ * stays listed for the next `set`.
543
+ */
544
+ const destroy = async (token, secret, version) => {
545
+ let response;
546
+ try {
547
+ response = await request(token, "POST", `${secret}/versions/${version}:destroy`, {});
548
+ } catch {
549
+ return false;
550
+ }
551
+ return response.ok || response.status === 404 || response.error.status === "FAILED_PRECONDITION";
552
+ };
553
+ return {
554
+ name: ADAPTER_NAME,
555
+ get: async (names) => {
556
+ const parent = projectPath();
557
+ const secrets = names.map((name) => `${parent}/secrets/${secretId(name)}`);
558
+ const values = Object.create(null);
559
+ if (names.length === 0) return values;
560
+ const token = await accessToken();
561
+ const read = await Promise.all(secrets.map((secret) => accessLatest(token, secret)));
562
+ names.forEach((name, index) => {
563
+ values[name] = read[index]?.value;
564
+ });
565
+ return values;
566
+ },
567
+ /**
568
+ * Adds `value` as the secret's newest version, creating the secret when
569
+ * it is missing, and waits until the `latest` alias resolves to it. Then
570
+ * it destroys the older versions this adapter added, which the secret's
571
+ * `hot-updater-versions` annotation lists: versions added any other way
572
+ * are never touched. The permissions this needs are checked before
573
+ * anything is written. A version that could not be destroyed stays
574
+ * listed for the next `set`. The annotation is updated against the
575
+ * secret's etag, so a concurrent `set` loses no listed version. When
576
+ * `latest` does not resolve to the new version in time, or the
577
+ * annotation cannot be updated, `set` throws, naming the version it
578
+ * stored.
579
+ */
580
+ set: async (name, value) => {
581
+ const parent = projectPath();
582
+ const id = secretId(name);
583
+ const secret = `${parent}/secrets/${id}`;
584
+ if (typeof value !== "string" || value === "") throw new Error(`${ADAPTER_NAME} cannot store an empty ${secret}.`);
585
+ await mask(value);
586
+ const token = await accessToken();
587
+ const current = await getOrCreateSecret(token, parent, id);
588
+ await assertCanSet(token, secret);
589
+ const data = Buffer.from(value, "utf8");
590
+ const added = await request(token, "POST", `${secret}:addVersion`, { payload: {
591
+ data: data.toString("base64"),
592
+ dataCrc32c: String(crc32c(data))
593
+ } });
594
+ const version = versionOf(isRecord(added.body) ? added.body.name : void 0);
595
+ if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError(`add a version to ${secret}`, added, "secretmanager.versions.add on the secret");
596
+ const visible = await waitForLatest(token, secret, version);
597
+ const destroyed = /* @__PURE__ */ new Set();
598
+ if (visible) {
599
+ const older = trackedVersions(current).filter((entry) => Number(entry) < Number(version));
600
+ const results = await Promise.all(older.map((entry) => destroy(token, secret, entry)));
601
+ older.forEach((entry, index) => {
602
+ if (results[index]) destroyed.add(entry);
603
+ });
604
+ }
605
+ const notListed = `list version ${version}, which it stored, in ${secret}'s ${VERSIONS_ANNOTATION} annotation, so the next set will not destroy it`;
606
+ let base = current;
607
+ let updated;
608
+ try {
609
+ for (let attempt = 0; attempt < ANNOTATION_ATTEMPTS; attempt += 1) {
610
+ if (attempt > 0) {
611
+ await delay(LATEST_RETRY_DELAYS_MS[attempt - 1] ?? 0);
612
+ base = await readSecret(token, secret) ?? {};
613
+ }
614
+ const listed = [.../* @__PURE__ */ new Set([version, ...trackedVersions(base)])].filter((entry) => !destroyed.has(entry));
615
+ updated = await request(token, "PATCH", `${secret}?updateMask=annotations`, {
616
+ ...base.etag === void 0 ? {} : { etag: base.etag },
617
+ annotations: {
618
+ ...base.annotations,
619
+ [VERSIONS_ANNOTATION]: listed.join(",")
620
+ }
621
+ });
622
+ if (updated.error.status !== "FAILED_PRECONDITION") break;
623
+ }
624
+ } catch (error) {
625
+ throw new Error(`${ADAPTER_NAME} could not ${notListed} (${error instanceof Error ? error.message : String(error)}).`, { cause: error });
626
+ }
627
+ if (!updated?.ok) throw requestError(notListed, updated, "secretmanager.secrets.update on the secret");
628
+ if (!visible) throw new Error(`${ADAPTER_NAME} stored ${secret} as version ${version}, and its latest alias does not resolve to that version yet, so it kept the versions it added before; the next set destroys them.`);
629
+ },
630
+ describe: (name) => {
631
+ const id = `${prefix}${name}`;
632
+ return `projects/${projectId}/secrets/${id} (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
633
+ }
634
+ };
635
+ };
636
+ //#endregion
637
+ export { firebaseDatabase, firebaseStorage, migrateFirebaseDatabase, secretManager };
@@ -6879,11 +6879,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
6879
6879
  * after deploying the indexes.
6880
6880
  */
6881
6881
  const migrateFirebaseDatabase = (config, plugins = []) => (0, _hot_updater_plugin_core.migrateCoreSchema)(adapterOf(config), "firebaseDatabase", plugins);
6882
+ /**
6883
+ * The resource this database reaches, without credentials, under the key
6884
+ * `hot-updater doctor` reads to tell the database `hot-updater init` saved
6885
+ * from another (a convention CLAUDE.md documents).
6886
+ */
6887
+ const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
6882
6888
  /** Hot Updater's database in one Firestore collection, through the storage engine. */
6883
- const firebaseDatabase = (config) => (0, _hot_updater_plugin_core.createEngineDatabase)({
6884
- name: "firebaseDatabase",
6885
- adapter: adapterOf(config),
6886
- aggregateBatching: config.aggregateBatching ?? {}
6889
+ const firebaseDatabase = (config) => ({
6890
+ ...(0, _hot_updater_plugin_core.createEngineDatabase)({
6891
+ name: "firebaseDatabase",
6892
+ adapter: adapterOf(config),
6893
+ aggregateBatching: config.aggregateBatching ?? {}
6894
+ }),
6895
+ ...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
6887
6896
  });
6888
6897
  //#endregion
6889
6898
  //#region iac/init/index.ts
@@ -6872,11 +6872,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
6872
6872
  * after deploying the indexes.
6873
6873
  */
6874
6874
  const migrateFirebaseDatabase = (config, plugins = []) => migrateCoreSchema(adapterOf(config), "firebaseDatabase", plugins);
6875
+ /**
6876
+ * The resource this database reaches, without credentials, under the key
6877
+ * `hot-updater doctor` reads to tell the database `hot-updater init` saved
6878
+ * from another (a convention CLAUDE.md documents).
6879
+ */
6880
+ const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
6875
6881
  /** Hot Updater's database in one Firestore collection, through the storage engine. */
6876
- const firebaseDatabase = (config) => createEngineDatabase({
6877
- name: "firebaseDatabase",
6878
- adapter: adapterOf(config),
6879
- aggregateBatching: config.aggregateBatching ?? {}
6882
+ const firebaseDatabase = (config) => ({
6883
+ ...createEngineDatabase({
6884
+ name: "firebaseDatabase",
6885
+ adapter: adapterOf(config),
6886
+ aggregateBatching: config.aggregateBatching ?? {}
6887
+ }),
6888
+ ...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
6880
6889
  });
6881
6890
  //#endregion
6882
6891
  //#region iac/init/index.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/firebase",
3
3
  "type": "module",
4
- "version": "1.1.4",
4
+ "version": "1.2.0-rc.1",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "types": "dist/index.d.cts",
@@ -37,10 +37,11 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "hono": "4.12.34",
40
- "@hot-updater/cli-tools": "1.1.4",
41
- "@hot-updater/protocol": "1.1.4",
42
- "@hot-updater/plugin-core": "1.1.4",
43
- "@hot-updater/server": "1.1.4"
40
+ "google-auth-library": "10.7.0",
41
+ "@hot-updater/cli-tools": "1.2.0-rc.1",
42
+ "@hot-updater/protocol": "1.2.0-rc.1",
43
+ "@hot-updater/plugin-core": "1.2.0-rc.1",
44
+ "@hot-updater/server": "1.2.0-rc.1"
44
45
  },
45
46
  "publishConfig": {
46
47
  "access": "public"
@@ -55,8 +56,8 @@
55
56
  "firebase-tools": "15.29.0",
56
57
  "fkill": "^9.0.0",
57
58
  "mime": "^4.0.4",
58
- "@hot-updater/mock": "1.1.4",
59
- "@hot-updater/test-utils": "1.1.4"
59
+ "@hot-updater/mock": "1.2.0-rc.1",
60
+ "@hot-updater/test-utils": "1.2.0-rc.1"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "firebase-admin": ">=11.10.0",