@hot-updater/firebase 0.36.7 → 1.0.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
@@ -1,345 +1,845 @@
1
- import { DEFAULT_ROLLOUT_COHORT_COUNT, NIL_UUID, getAssetBaseStorageUri, getBundlePatches, getManifestFileHash, getManifestStorageUri, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, stripBundleArtifactMetadata } from "@hot-updater/core";
2
- import { calculatePagination, createDatabasePlugin, createStorageKeyBuilder, createUniversalStoragePlugin, filterCompatibleAppVersions, getContentType, parseStorageUri, resolveUpdateInfoFromBundles } from "@hot-updater/plugin-core";
1
+ import { createDatabasePlugin, createStorageKeyBuilder, createStoragePlugin, createStorageUri, isDatabaseMetadataObject, parseStorageUri } from "@hot-updater/plugin-core";
2
+ import { createDatabasePluginAdapter } from "@hot-updater/plugin-core/internal";
3
3
  import { getApp, getApps, initializeApp } from "firebase-admin/app";
4
4
  import { getFirestore } from "firebase-admin/firestore";
5
- import fs from "fs/promises";
6
- import path from "path";
7
5
  import { getStorage } from "firebase-admin/storage";
8
- //#region src/firebaseDatabase.ts
9
- const bundleMatchesQueryWhere = (bundle, where) => {
10
- if (!where) return true;
11
- if (where.channel !== void 0 && bundle.channel !== where.channel) return false;
12
- if (where.platform !== void 0 && bundle.platform !== where.platform) return false;
13
- if (where.enabled !== void 0 && bundle.enabled !== where.enabled) return false;
14
- if (where.id?.eq !== void 0 && bundle.id !== where.id.eq) return false;
15
- if (where.id?.gt !== void 0 && bundle.id.localeCompare(where.id.gt) <= 0) return false;
16
- if (where.id?.gte !== void 0 && bundle.id.localeCompare(where.id.gte) < 0) return false;
17
- if (where.id?.lt !== void 0 && bundle.id.localeCompare(where.id.lt) >= 0) return false;
18
- if (where.id?.lte !== void 0 && bundle.id.localeCompare(where.id.lte) > 0) return false;
19
- if (where.id?.in && !where.id.in.includes(bundle.id)) return false;
20
- if (where.targetAppVersionNotNull && bundle.targetAppVersion === null) return false;
21
- if (where.targetAppVersion !== void 0 && bundle.targetAppVersion !== where.targetAppVersion) return false;
22
- if (where.targetAppVersionIn && !where.targetAppVersionIn.includes(bundle.targetAppVersion ?? "")) return false;
23
- if (where.fingerprintHash !== void 0 && bundle.fingerprintHash !== where.fingerprintHash) return false;
24
- return true;
25
- };
26
- const sortBundles = (bundles, orderBy) => {
27
- const direction = orderBy?.direction ?? "desc";
28
- return bundles.slice().sort((a, b) => {
29
- const result = a.id.localeCompare(b.id);
30
- return direction === "asc" ? result : -result;
31
- });
6
+ //#region src/firebaseDatabaseParserShared.ts
7
+ var FirebaseDatabaseDataError = class extends Error {
8
+ name = "FirebaseDatabaseDataError";
9
+ constructor(source) {
10
+ super(`Invalid Firebase database data at "${source}".`);
11
+ this.source = source;
12
+ }
13
+ };
14
+ const record = (value, source) => {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new FirebaseDatabaseDataError(source);
16
+ return value;
17
+ };
18
+ const property = (value, key) => Reflect.get(value, key);
19
+ const string = (value, source) => {
20
+ if (typeof value !== "string") throw new FirebaseDatabaseDataError(source);
21
+ return value;
22
+ };
23
+ const nullableString = (value, source) => {
24
+ if (value === null || value === void 0) return null;
25
+ return string(value, source);
26
+ };
27
+ const boolean = (value, source) => {
28
+ if (typeof value !== "boolean") throw new FirebaseDatabaseDataError(source);
29
+ return value;
30
+ };
31
+ const number = (value, source) => {
32
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new FirebaseDatabaseDataError(source);
33
+ return value;
34
+ };
35
+ const byteSize = (value, source) => {
36
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new FirebaseDatabaseDataError(source);
37
+ return value;
38
+ };
39
+ const stringArray = (value, source) => {
40
+ if (value === null || value === void 0) return null;
41
+ if (!Array.isArray(value)) throw new FirebaseDatabaseDataError(source);
42
+ return value.map((item) => string(item, source));
43
+ };
44
+ const platform = (value, source) => {
45
+ if (value === "android" || value === "ios") return value;
46
+ throw new FirebaseDatabaseDataError(source);
47
+ };
48
+ //#endregion
49
+ //#region src/firebaseDatabaseParser.ts
50
+ const metadata = (value, source) => {
51
+ if (!isDatabaseMetadataObject(value)) throw new FirebaseDatabaseDataError(source);
52
+ return value;
53
+ };
54
+ const requiredNullableString = (input, key, source) => {
55
+ if (!Object.hasOwn(input, key)) throw new FirebaseDatabaseDataError(source);
56
+ return nullableString(property(input, key), source);
57
+ };
58
+ const parseFirebaseBundleRow = (value, source) => {
59
+ const input = record(value, source);
60
+ return {
61
+ id: string(property(input, "id"), source),
62
+ platform: platform(property(input, "platform"), source),
63
+ file_hash: string(property(input, "file_hash"), source),
64
+ git_commit_hash: nullableString(property(input, "git_commit_hash"), source),
65
+ storage_uri: string(property(input, "storage_uri"), source),
66
+ archive_byte_size: byteSize(property(input, "archive_byte_size"), source),
67
+ metadata: metadata(property(input, "metadata"), source),
68
+ manifest_storage_uri: nullableString(property(input, "manifest_storage_uri"), source),
69
+ manifest_file_hash: nullableString(property(input, "manifest_file_hash"), source),
70
+ asset_base_storage_uri: nullableString(property(input, "asset_base_storage_uri"), source)
71
+ };
72
+ };
73
+ const parseFirebaseChannelRow = (value, source) => {
74
+ const input = record(value, source);
75
+ return {
76
+ id: string(property(input, "id"), source),
77
+ name: string(property(input, "name"), source)
78
+ };
79
+ };
80
+ const parseFirebasePatchRow = (value, source) => {
81
+ const input = record(value, source);
82
+ return {
83
+ id: string(property(input, "id"), source),
84
+ bundle_id: string(property(input, "bundle_id"), source),
85
+ base_bundle_id: string(property(input, "base_bundle_id"), source),
86
+ base_file_hash: string(property(input, "base_file_hash"), source),
87
+ patch_file_hash: string(property(input, "patch_file_hash"), source),
88
+ patch_storage_uri: string(property(input, "patch_storage_uri"), source),
89
+ byte_size: byteSize(property(input, "byte_size"), source),
90
+ order_index: number(property(input, "order_index"), source)
91
+ };
92
+ };
93
+ const parseFirebaseBundleEventRow = (value, source) => {
94
+ const input = record(value, source);
95
+ const type = string(property(input, "type"), source);
96
+ const fromBundleId = requiredNullableString(input, "from_bundle_id", source);
97
+ const updateStrategy = requiredNullableString(input, "update_strategy", source);
98
+ if (!((type === "UPDATE_APPLIED" || type === "RECOVERED" || type === "RELEASE_ADOPTED") && typeof fromBundleId === "string" && (updateStrategy === "fingerprint" || updateStrategy === "appVersion") || type === "UNCHANGED" && fromBundleId === null && updateStrategy === null)) throw new FirebaseDatabaseDataError(source);
99
+ return {
100
+ id: string(property(input, "id"), source),
101
+ type,
102
+ install_id: string(property(input, "install_id"), source),
103
+ user_id: nullableString(property(input, "user_id"), source),
104
+ username: nullableString(property(input, "username"), source),
105
+ from_release_id: requiredNullableString(input, "from_release_id", source),
106
+ from_bundle_id: fromBundleId,
107
+ to_release_id: requiredNullableString(input, "to_release_id", source),
108
+ to_bundle_id: string(property(input, "to_bundle_id"), source),
109
+ platform: platform(property(input, "platform"), source),
110
+ app_version: string(property(input, "app_version"), source),
111
+ channel: string(property(input, "channel"), source),
112
+ cohort: string(property(input, "cohort"), source),
113
+ update_strategy: updateStrategy,
114
+ fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
115
+ sdk_version: nullableString(property(input, "sdk_version"), source),
116
+ received_at_ms: number(property(input, "received_at_ms"), source)
117
+ };
118
+ };
119
+ const parseFirebaseApiKeyRow = (value, source) => {
120
+ const input = record(value, source);
121
+ const role = string(property(input, "role"), source);
122
+ if (role !== "client") throw new FirebaseDatabaseDataError(source);
123
+ const revokedAt = property(input, "revoked_at_ms");
124
+ return {
125
+ id: string(property(input, "id"), source),
126
+ hash: string(property(input, "hash"), source),
127
+ name: string(property(input, "name"), source),
128
+ prefix: string(property(input, "prefix"), source),
129
+ role,
130
+ created_at_ms: number(property(input, "created_at_ms"), source),
131
+ revoked_at_ms: revokedAt === null ? null : number(revokedAt, source)
132
+ };
133
+ };
134
+ const parseFirebaseReleaseRow = (value, source) => {
135
+ const input = record(value, source);
136
+ const kind = string(property(input, "kind"), source);
137
+ const strategy = string(property(input, "strategy"), source);
138
+ const operation = string(property(input, "operation"), source);
139
+ const targetCohorts = stringArray(property(input, "target_cohorts"), source);
140
+ if (kind !== "BUNDLE" && kind !== "EMBEDDED" || strategy !== "APP_VERSION" && strategy !== "FINGERPRINT" || operation !== "DEPLOY" && operation !== "PROMOTE" && operation !== "ROLLBACK" || targetCohorts === null) throw new FirebaseDatabaseDataError(source);
141
+ return {
142
+ id: string(property(input, "id"), source),
143
+ revision: number(property(input, "revision"), source),
144
+ scope_key: string(property(input, "scope_key"), source),
145
+ channel_id: string(property(input, "channel_id"), source),
146
+ platform: platform(property(input, "platform"), source),
147
+ kind,
148
+ bundle_id: nullableString(property(input, "bundle_id"), source),
149
+ strategy,
150
+ target_app_version: nullableString(property(input, "target_app_version"), source),
151
+ fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
152
+ enabled: boolean(property(input, "enabled"), source),
153
+ should_force_update: boolean(property(input, "should_force_update"), source),
154
+ message: nullableString(property(input, "message"), source),
155
+ rollout_cohort_count: number(property(input, "rollout_cohort_count"), source),
156
+ target_cohorts: targetCohorts,
157
+ operation,
158
+ source_release_id: nullableString(property(input, "source_release_id"), source),
159
+ created_at_ms: number(property(input, "created_at_ms"), source),
160
+ updated_at_ms: number(property(input, "updated_at_ms"), source)
161
+ };
32
162
  };
33
- const applyFirestoreQueryableFilters = (query, where) => {
34
- let nextQuery = query;
35
- if (where?.channel) nextQuery = nextQuery.where("channel", "==", where.channel);
36
- if (where?.platform) nextQuery = nextQuery.where("platform", "==", where.platform);
37
- if (where?.enabled !== void 0) nextQuery = nextQuery.where("enabled", "==", where.enabled);
38
- if (where?.fingerprintHash !== void 0 && where.fingerprintHash !== null) nextQuery = nextQuery.where("fingerprint_hash", "==", where.fingerprintHash);
39
- if (where?.targetAppVersion !== void 0 && where.targetAppVersion !== null) nextQuery = nextQuery.where("target_app_version", "==", where.targetAppVersion);
40
- if (where?.id?.eq) nextQuery = nextQuery.where("id", "==", where.id.eq);
41
- if (where?.id?.gt) nextQuery = nextQuery.where("id", ">", where.id.gt);
42
- if (where?.id?.gte) nextQuery = nextQuery.where("id", ">=", where.id.gte);
43
- if (where?.id?.lt) nextQuery = nextQuery.where("id", "<", where.id.lt);
44
- if (where?.id?.lte) nextQuery = nextQuery.where("id", "<=", where.id.lte);
45
- return nextQuery;
46
- };
47
- const requiresInMemoryFiltering = (where) => {
48
- return Boolean(where?.id?.in || where?.targetAppVersionIn || where?.targetAppVersionNotNull || where?.targetAppVersion === null || where?.fingerprintHash === null);
49
- };
50
- const chunkValues = (values, size) => {
51
- const chunks = [];
52
- for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size));
53
- return chunks;
54
- };
55
- const convertToBundle = (firestoreData) => {
56
- const rawMetadata = firestoreData.metadata;
57
- const storedPatches = firestoreData.patches;
58
- const patches = storedPatches && Array.isArray(storedPatches) ? storedPatches : getBundlePatches({
59
- metadata: rawMetadata,
60
- patchBaseBundleId: firestoreData.patch_base_bundle_id ?? null,
61
- patchBaseFileHash: firestoreData.patch_base_file_hash ?? null,
62
- patchFileHash: firestoreData.patch_file_hash ?? null,
63
- patchStorageUri: firestoreData.patch_storage_uri ?? null
163
+ const parseFirebaseReleaseCatalogRow = (value, source) => {
164
+ const input = record(value, source);
165
+ const strategy = string(property(input, "strategy"), source);
166
+ if (strategy !== "APP_VERSION" && strategy !== "FINGERPRINT") throw new FirebaseDatabaseDataError(source);
167
+ return {
168
+ scope_key: string(property(input, "scope_key"), source),
169
+ catalog_id: string(property(input, "catalog_id"), source),
170
+ strategy,
171
+ channel_id: string(property(input, "channel_id"), source),
172
+ channel_key: string(property(input, "channel_key"), source),
173
+ platform: platform(property(input, "platform"), source),
174
+ fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
175
+ generation: number(property(input, "generation"), source),
176
+ payload: string(property(input, "payload"), source),
177
+ catalog_hash: string(property(input, "catalog_hash"), source),
178
+ byte_size: number(property(input, "byte_size"), source),
179
+ is_tombstone: boolean(property(input, "is_tombstone"), source),
180
+ updated_at_ms: number(property(input, "updated_at_ms"), source)
181
+ };
182
+ };
183
+ //#endregion
184
+ //#region src/firebaseDatabaseQuery.ts
185
+ const compare = (left, right) => {
186
+ if (typeof left === "number" && typeof right === "number") return left - right;
187
+ return String(left).localeCompare(String(right));
188
+ };
189
+ const normalizeStringComparison = (actual, expected, mode) => {
190
+ if (typeof actual !== "string") return null;
191
+ return mode === "insensitive" ? [actual.toLocaleLowerCase(), expected.toLocaleLowerCase()] : [actual, expected];
192
+ };
193
+ const matchesCondition = (row, condition) => {
194
+ const actual = Reflect.get(row, condition.field);
195
+ const expected = condition.value;
196
+ switch (condition.operator ?? "eq") {
197
+ case "eq": {
198
+ if (typeof expected !== "string") return actual === expected;
199
+ const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
200
+ return comparison !== null && comparison[0] === comparison[1];
201
+ }
202
+ case "ne": {
203
+ if (actual === null || actual === void 0) return false;
204
+ if (typeof expected !== "string") return actual !== expected;
205
+ const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
206
+ return comparison === null || comparison[0] !== comparison[1];
207
+ }
208
+ case "gt":
209
+ if (actual === null || actual === void 0) return false;
210
+ return compare(actual, expected) > 0;
211
+ case "gte":
212
+ if (actual === null || actual === void 0) return false;
213
+ return compare(actual, expected) >= 0;
214
+ case "lt":
215
+ if (actual === null || actual === void 0) return false;
216
+ return compare(actual, expected) < 0;
217
+ case "lte":
218
+ if (actual === null || actual === void 0) return false;
219
+ return compare(actual, expected) <= 0;
220
+ case "in":
221
+ if (!Array.isArray(expected)) return false;
222
+ return expected.some((candidate) => candidate === actual);
223
+ case "not_in": {
224
+ if (!Array.isArray(expected)) return false;
225
+ const values = expected;
226
+ return values.length === 0 || actual !== null && actual !== void 0 && values.every((candidate) => candidate !== actual);
227
+ }
228
+ case "contains": {
229
+ if (typeof expected !== "string") return false;
230
+ const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
231
+ return comparison?.[0].includes(comparison[1]) ?? false;
232
+ }
233
+ case "starts_with": {
234
+ if (typeof expected !== "string") return false;
235
+ const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
236
+ return comparison?.[0].startsWith(comparison[1]) ?? false;
237
+ }
238
+ case "ends_with": {
239
+ if (typeof expected !== "string") return false;
240
+ const comparison = normalizeStringComparison(actual, expected, "mode" in condition ? condition.mode : void 0);
241
+ return comparison?.[0].endsWith(comparison[1]) ?? false;
242
+ }
243
+ }
244
+ };
245
+ const matchesFirebaseDatabaseWhere = (row, where) => {
246
+ const first = where?.[0];
247
+ if (!first) return true;
248
+ let result = matchesCondition(row, first);
249
+ for (const condition of where.slice(1)) {
250
+ const current = matchesCondition(row, condition);
251
+ result = condition.connector === "OR" ? result || current : result && current;
252
+ }
253
+ return result;
254
+ };
255
+ const queryFirebaseDatabaseRows = (rows, input) => {
256
+ const filtered = rows.filter((row) => matchesFirebaseDatabaseWhere(row, input.where));
257
+ const orderBy = input.orderBy;
258
+ if (orderBy !== void 0) filtered.sort((left, right) => {
259
+ for (const clause of orderBy) {
260
+ const leftValue = Reflect.get(left, clause.field);
261
+ const rightValue = Reflect.get(right, clause.field);
262
+ if (leftValue === rightValue) continue;
263
+ if (clause.nulls !== void 0 && (leftValue === null || rightValue === null)) {
264
+ const nullComparison = leftValue === null ? -1 : 1;
265
+ return clause.nulls === "first" ? nullComparison : -nullComparison;
266
+ }
267
+ const comparison = compare(leftValue, rightValue);
268
+ if (comparison !== 0) return clause.direction === "asc" ? comparison : -comparison;
269
+ }
270
+ return 0;
64
271
  });
65
- const primaryPatch = patches[0] ?? null;
272
+ const distinctOn = input.distinctOn;
273
+ if (distinctOn === void 0) return filtered.slice(input.offset, input.offset + input.limit);
274
+ const seen = /* @__PURE__ */ new Set();
275
+ return filtered.filter((row) => {
276
+ const key = JSON.stringify(distinctOn.fields.map((field) => Reflect.get(row, field)));
277
+ if (seen.has(key)) return false;
278
+ seen.add(key);
279
+ return true;
280
+ }).slice(input.offset, input.offset + input.limit);
281
+ };
282
+ //#endregion
283
+ //#region src/firebaseDatabaseState.ts
284
+ var FirebaseDatabaseConstraintError = class extends Error {
285
+ name = "FirebaseDatabaseConstraintError";
286
+ constructor(constraint) {
287
+ super(`Firebase database constraint failed: ${constraint}`);
288
+ this.constraint = constraint;
289
+ }
290
+ };
291
+ const cloneFirebaseDatabaseSnapshot = (snapshot) => ({
292
+ bundles: new Map(snapshot.bundles),
293
+ bundlePatches: new Map(snapshot.bundlePatches),
294
+ bundleEvents: new Map(snapshot.bundleEvents),
295
+ channels: new Map(snapshot.channels),
296
+ apiKeys: new Map(snapshot.apiKeys),
297
+ releaseCatalogs: new Map(snapshot.releaseCatalogs),
298
+ releases: new Map(snapshot.releases)
299
+ });
300
+ const requireUnique = (rows, id, model) => {
301
+ if (rows.has(id)) throw new FirebaseDatabaseConstraintError(`${model}.id.unique`);
302
+ };
303
+ const distinctCount = (rows, fields) => {
304
+ if (fields === void 0) return rows.length;
305
+ return new Set(rows.map((row) => JSON.stringify(fields.map((field) => Reflect.get(row, field))))).size;
306
+ };
307
+ const createFirebaseDatabaseState = (snapshot) => ({
308
+ async create(input) {
309
+ switch (input.model) {
310
+ case "bundles":
311
+ requireUnique(snapshot.bundles, input.data.id, input.model);
312
+ snapshot.bundles.set(input.data.id, input.data);
313
+ return input.data;
314
+ case "bundle_patches":
315
+ requireUnique(snapshot.bundlePatches, input.data.id, input.model);
316
+ if (!snapshot.bundles.has(input.data.bundle_id)) throw new FirebaseDatabaseConstraintError("bundle_patches.bundle_id.foreign-key");
317
+ if (!snapshot.bundles.has(input.data.base_bundle_id)) throw new FirebaseDatabaseConstraintError("bundle_patches.base_bundle_id.foreign-key");
318
+ snapshot.bundlePatches.set(input.data.id, input.data);
319
+ return input.data;
320
+ case "bundle_events":
321
+ requireUnique(snapshot.bundleEvents, input.data.id, input.model);
322
+ snapshot.bundleEvents.set(input.data.id, input.data);
323
+ return input.data;
324
+ case "releases":
325
+ requireUnique(snapshot.releases, input.data.id, input.model);
326
+ if (!snapshot.channels.has(input.data.channel_id)) throw new FirebaseDatabaseConstraintError("releases.channel_id.foreign-key");
327
+ if (input.data.bundle_id !== null && !snapshot.bundles.has(input.data.bundle_id)) throw new FirebaseDatabaseConstraintError("releases.bundle_id.foreign-key");
328
+ snapshot.releases.set(input.data.id, input.data);
329
+ return input.data;
330
+ case "release_catalogs":
331
+ if (snapshot.releaseCatalogs.has(input.data.scope_key)) throw new FirebaseDatabaseConstraintError("release_catalogs.scope_key.unique");
332
+ snapshot.releaseCatalogs.set(input.data.scope_key, input.data);
333
+ return input.data;
334
+ case "channels": {
335
+ const existing = [...snapshot.channels.values()].find(({ name }) => name === input.data.name);
336
+ if (existing && input.onConflict === "ignore") return existing;
337
+ requireUnique(snapshot.channels, input.data.id, input.model);
338
+ if (existing) throw new FirebaseDatabaseConstraintError("channels.name.unique");
339
+ snapshot.channels.set(input.data.id, input.data);
340
+ return input.data;
341
+ }
342
+ case "api_keys": {
343
+ const existing = [...snapshot.apiKeys.values()].find(({ hash }) => hash === input.data.hash);
344
+ if (existing && input.onConflict === "ignore") return existing;
345
+ requireUnique(snapshot.apiKeys, input.data.id, input.model);
346
+ if (existing) throw new FirebaseDatabaseConstraintError("api_keys.hash.unique");
347
+ snapshot.apiKeys.set(input.data.id, input.data);
348
+ return input.data;
349
+ }
350
+ }
351
+ },
352
+ async update(input) {
353
+ if (input.model === "api_keys") {
354
+ const current = [...snapshot.apiKeys.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
355
+ if (!current) return null;
356
+ const updated = {
357
+ ...current,
358
+ ...input.update
359
+ };
360
+ snapshot.apiKeys.set(current.id, updated);
361
+ return updated;
362
+ }
363
+ if (input.model === "releases") {
364
+ const current = [...snapshot.releases.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
365
+ if (!current) return null;
366
+ const updated = {
367
+ ...current,
368
+ ...input.update
369
+ };
370
+ snapshot.releases.set(current.id, updated);
371
+ return updated;
372
+ }
373
+ if (input.model === "release_catalogs") {
374
+ const current = [...snapshot.releaseCatalogs.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
375
+ if (!current) return null;
376
+ const updated = {
377
+ ...current,
378
+ ...input.update
379
+ };
380
+ snapshot.releaseCatalogs.set(current.scope_key, updated);
381
+ return updated;
382
+ }
383
+ const current = [...snapshot.bundles.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
384
+ if (!current) return null;
385
+ const updated = {
386
+ ...current,
387
+ ...input.update
388
+ };
389
+ snapshot.bundles.set(current.id, updated);
390
+ return updated;
391
+ },
392
+ async delete(input) {
393
+ if (input.model === "channels") {
394
+ for (const row of snapshot.channels.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.channels.delete(row.id);
395
+ return;
396
+ }
397
+ if (input.model === "bundle_patches") {
398
+ for (const row of snapshot.bundlePatches.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.bundlePatches.delete(row.id);
399
+ return;
400
+ }
401
+ if (input.model === "releases") {
402
+ for (const row of snapshot.releases.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.releases.delete(row.id);
403
+ return;
404
+ }
405
+ const removedIds = new Set([...snapshot.bundles.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)).map(({ id }) => id));
406
+ for (const id of removedIds) snapshot.bundles.delete(id);
407
+ for (const patch of snapshot.bundlePatches.values()) if (removedIds.has(patch.bundle_id) || removedIds.has(patch.base_bundle_id)) snapshot.bundlePatches.delete(patch.id);
408
+ },
409
+ async count(input) {
410
+ switch (input.model) {
411
+ case "bundles": return distinctCount([...snapshot.bundles.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
412
+ case "bundle_patches": return distinctCount([...snapshot.bundlePatches.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
413
+ case "releases": return distinctCount([...snapshot.releases.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
414
+ }
415
+ },
416
+ async findOne(input) {
417
+ switch (input.model) {
418
+ case "bundles": return [...snapshot.bundles.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
419
+ case "api_keys": return [...snapshot.apiKeys.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
420
+ case "channels": return [...snapshot.channels.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
421
+ case "bundle_patches": return [...snapshot.bundlePatches.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
422
+ case "releases": return [...snapshot.releases.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
423
+ case "release_catalogs": return [...snapshot.releaseCatalogs.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
424
+ }
425
+ },
426
+ async findMany(input) {
427
+ switch (input.model) {
428
+ case "bundles": return queryFirebaseDatabaseRows([...snapshot.bundles.values()], input);
429
+ case "bundle_patches": return queryFirebaseDatabaseRows([...snapshot.bundlePatches.values()], input);
430
+ case "bundle_events": return queryFirebaseDatabaseRows([...snapshot.bundleEvents.values()], input);
431
+ case "channels": return queryFirebaseDatabaseRows([...snapshot.channels.values()], input);
432
+ case "api_keys": return queryFirebaseDatabaseRows([...snapshot.apiKeys.values()], input);
433
+ case "releases": return queryFirebaseDatabaseRows([...snapshot.releases.values()], input);
434
+ case "release_catalogs": return queryFirebaseDatabaseRows([...snapshot.releaseCatalogs.values()], input);
435
+ }
436
+ }
437
+ });
438
+ //#endregion
439
+ //#region src/firebaseInfrastructureNames.ts
440
+ const FIREBASE_V1_COLLECTION_NAMES = {
441
+ apiKeys: "hot_updater_v1_api_keys",
442
+ bundleEvents: "hot_updater_v1_bundle_events",
443
+ bundlePatches: "hot_updater_v1_bundle_patches",
444
+ bundles: "hot_updater_v1_bundles",
445
+ channels: "hot_updater_v1_channels",
446
+ releaseCatalogs: "hot_updater_v1_release_catalogs",
447
+ releases: "hot_updater_v1_releases",
448
+ settings: "hot_updater_v1_private_settings"
449
+ };
450
+ //#endregion
451
+ //#region src/firebaseDatabasePersistence.ts
452
+ var FirebaseDatabaseAdapterVersionError = class extends Error {
453
+ name = "FirebaseDatabaseAdapterVersionError";
454
+ constructor(version) {
455
+ super(`Unsupported Firebase database adapter version: ${String(version)}`);
456
+ this.version = version;
457
+ }
458
+ };
459
+ const createFirebaseDatabaseCollections = (db) => ({
460
+ bundles: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundles),
461
+ bundlePatches: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundlePatches),
462
+ bundleEvents: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundleEvents),
463
+ channels: db.collection(FIREBASE_V1_COLLECTION_NAMES.channels),
464
+ apiKeys: db.collection(FIREBASE_V1_COLLECTION_NAMES.apiKeys),
465
+ releaseCatalogs: db.collection(FIREBASE_V1_COLLECTION_NAMES.releaseCatalogs),
466
+ releases: db.collection(FIREBASE_V1_COLLECTION_NAMES.releases),
467
+ settings: db.collection(FIREBASE_V1_COLLECTION_NAMES.settings)
468
+ });
469
+ const firebaseChannelDocumentId = (name) => `name_${Buffer.from(name, "utf8").toString("base64url")}`;
470
+ const firebaseChannelIdDocumentId = (id) => `channel_id_${Buffer.from(id, "utf8").toString("base64url")}`;
471
+ const requireFirebaseDocumentKey = (model, documentId, row) => {
472
+ if (documentId !== ("id" in row ? row.id : row.scope_key)) throw new FirebaseDatabaseConstraintError(`${model}.id.document-key`);
473
+ return row;
474
+ };
475
+ const documentMap = (model, documents) => {
476
+ const rows = /* @__PURE__ */ new Map();
477
+ for (const { row } of documents) {
478
+ const key = "id" in row ? row.id : row.scope_key;
479
+ if (rows.has(key)) throw new FirebaseDatabaseConstraintError(`${model}.id.unique`);
480
+ rows.set(key, row);
481
+ }
482
+ for (const { document, row } of documents) requireFirebaseDocumentKey(model, document.id, row);
483
+ return rows;
484
+ };
485
+ const bundleMap = (snapshot) => documentMap("bundles", snapshot.docs.map((document) => ({
486
+ document,
487
+ row: parseFirebaseBundleRow(document.data(), `bundles/${document.id}`)
488
+ })));
489
+ const patchMap = (snapshot) => documentMap("bundle_patches", snapshot.docs.map((document) => ({
490
+ document,
491
+ row: parseFirebasePatchRow(document.data(), `bundle_patches/${document.id}`)
492
+ })));
493
+ const eventMap = (snapshot) => documentMap("bundle_events", snapshot.docs.map((document) => ({
494
+ document,
495
+ row: parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)
496
+ })));
497
+ const channelMap = (snapshot) => {
498
+ const rows = /* @__PURE__ */ new Map();
499
+ const names = /* @__PURE__ */ new Set();
500
+ for (const document of snapshot.docs) {
501
+ const row = parseFirebaseChannelRow(document.data(), `channels/${document.id}`);
502
+ if (document.id !== firebaseChannelDocumentId(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.document-key");
503
+ if (rows.has(row.id)) throw new FirebaseDatabaseConstraintError("channels.id.unique");
504
+ if (names.has(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.unique");
505
+ rows.set(row.id, row);
506
+ names.add(row.name);
507
+ }
508
+ return rows;
509
+ };
510
+ const loadFirebaseChannels = async (collections) => [...channelMap(await collections.channels.get()).values()];
511
+ const apiKeyMap = (snapshot) => documentMap("api_keys", snapshot.docs.map((document) => ({
512
+ document,
513
+ row: parseFirebaseApiKeyRow(document.data(), `api_keys/${document.id}`)
514
+ })));
515
+ const releaseMap = (snapshot) => documentMap("releases", snapshot.docs.map((document) => ({
516
+ document,
517
+ row: parseFirebaseReleaseRow(document.data(), `releases/${document.id}`)
518
+ })));
519
+ const releaseCatalogMap = (snapshot) => documentMap("release_catalogs", snapshot.docs.map((document) => ({
520
+ document,
521
+ row: parseFirebaseReleaseCatalogRow(document.data(), `release_catalogs/${document.id}`)
522
+ })));
523
+ const toSnapshot = (documents) => {
66
524
  return {
67
- channel: firestoreData.channel,
68
- enabled: Boolean(firestoreData.enabled),
69
- shouldForceUpdate: Boolean(firestoreData.should_force_update),
70
- fileHash: firestoreData.file_hash,
71
- gitCommitHash: firestoreData.git_commit_hash,
72
- id: firestoreData.id,
73
- message: firestoreData.message,
74
- platform: firestoreData.platform,
75
- targetAppVersion: firestoreData.target_app_version,
76
- storageUri: firestoreData.storage_uri,
77
- fingerprintHash: firestoreData.fingerprint_hash,
78
- metadata: stripBundleArtifactMetadata(rawMetadata),
79
- manifestStorageUri: firestoreData.manifest_storage_uri ?? null,
80
- manifestFileHash: firestoreData.manifest_file_hash ?? null,
81
- assetBaseStorageUri: firestoreData.asset_base_storage_uri ?? null,
82
- patches,
83
- patchBaseBundleId: primaryPatch?.baseBundleId ?? firestoreData.patch_base_bundle_id ?? null,
84
- patchBaseFileHash: primaryPatch?.baseFileHash ?? firestoreData.patch_base_file_hash ?? null,
85
- patchFileHash: primaryPatch?.patchFileHash ?? firestoreData.patch_file_hash ?? null,
86
- patchStorageUri: primaryPatch?.patchStorageUri ?? firestoreData.patch_storage_uri ?? null,
87
- rolloutCohortCount: firestoreData.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
88
- targetCohorts: firestoreData.target_cohorts ?? null
525
+ bundles: bundleMap(documents[0]),
526
+ bundlePatches: patchMap(documents[1]),
527
+ bundleEvents: eventMap(documents[2]),
528
+ channels: channelMap(documents[3]),
529
+ apiKeys: apiKeyMap(documents[4]),
530
+ releases: releaseMap(documents[5]),
531
+ releaseCatalogs: releaseCatalogMap(documents[6])
89
532
  };
90
533
  };
91
- const firebaseDatabase = createDatabasePlugin({
92
- name: "firebaseDatabase",
93
- factory: (config) => {
534
+ const loadFirebaseDatabaseSnapshot = async (collections) => {
535
+ const [bundles, patches, events, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
536
+ collections.bundles.get(),
537
+ collections.bundlePatches.get(),
538
+ collections.bundleEvents.get(),
539
+ collections.channels.get(),
540
+ collections.apiKeys.get(),
541
+ collections.releases.get(),
542
+ collections.releaseCatalogs.get()
543
+ ]);
544
+ return toSnapshot([
545
+ bundles,
546
+ patches,
547
+ events,
548
+ channels,
549
+ apiKeys,
550
+ releases,
551
+ releaseCatalogs
552
+ ]);
553
+ };
554
+ const loadFirebaseTransactionSnapshot = async (transaction, collections) => {
555
+ const [bundles, patches, events, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
556
+ transaction.get(collections.bundles),
557
+ transaction.get(collections.bundlePatches),
558
+ transaction.get(collections.bundleEvents),
559
+ transaction.get(collections.channels),
560
+ transaction.get(collections.apiKeys),
561
+ transaction.get(collections.releases),
562
+ transaction.get(collections.releaseCatalogs)
563
+ ]);
564
+ return toSnapshot([
565
+ bundles,
566
+ patches,
567
+ events,
568
+ channels,
569
+ apiKeys,
570
+ releases,
571
+ releaseCatalogs
572
+ ]);
573
+ };
574
+ const persistCollection = ({ transaction, collection, before, after, documentId }) => {
575
+ for (const [id, row] of before) if (!after.has(id)) transaction.delete(collection.doc(documentId(row)));
576
+ for (const [id, row] of after) if (JSON.stringify(before.get(id)) !== JSON.stringify(row)) transaction.set(collection.doc(documentId(row)), row, { merge: true });
577
+ };
578
+ const persistFirebaseDatabaseSnapshot = ({ transaction, collections, before, after }) => {
579
+ persistCollection({
580
+ transaction,
581
+ collection: collections.bundles,
582
+ before: before.bundles,
583
+ after: after.bundles,
584
+ documentId: (row) => row.id
585
+ });
586
+ persistCollection({
587
+ transaction,
588
+ collection: collections.bundlePatches,
589
+ before: before.bundlePatches,
590
+ after: after.bundlePatches,
591
+ documentId: (row) => row.id
592
+ });
593
+ persistCollection({
594
+ transaction,
595
+ collection: collections.bundleEvents,
596
+ before: before.bundleEvents,
597
+ after: after.bundleEvents,
598
+ documentId: (row) => row.id
599
+ });
600
+ persistCollection({
601
+ transaction,
602
+ collection: collections.channels,
603
+ before: before.channels,
604
+ after: after.channels,
605
+ documentId: (row) => firebaseChannelDocumentId(row.name)
606
+ });
607
+ for (const [id] of before.channels) if (!after.channels.has(id)) transaction.delete(collections.settings.doc(firebaseChannelIdDocumentId(id)));
608
+ for (const [id, row] of after.channels) if (JSON.stringify(before.channels.get(id)) !== JSON.stringify(row)) transaction.set(collections.settings.doc(firebaseChannelIdDocumentId(id)), row);
609
+ persistCollection({
610
+ transaction,
611
+ collection: collections.apiKeys,
612
+ before: before.apiKeys,
613
+ after: after.apiKeys,
614
+ documentId: (row) => row.id
615
+ });
616
+ persistCollection({
617
+ transaction,
618
+ collection: collections.releases,
619
+ before: before.releases,
620
+ after: after.releases,
621
+ documentId: (row) => row.id
622
+ });
623
+ persistCollection({
624
+ transaction,
625
+ collection: collections.releaseCatalogs,
626
+ before: before.releaseCatalogs,
627
+ after: after.releaseCatalogs,
628
+ documentId: (row) => row.scope_key
629
+ });
630
+ };
631
+ const migrateFirebaseDatabase = async (_db, collections) => {
632
+ const versionDocument = collections.settings.doc("database_adapter_version");
633
+ const version = await versionDocument.get();
634
+ const adapterVersion = version.data()?.version;
635
+ if (adapterVersion === 4) return;
636
+ if (version.exists) throw new FirebaseDatabaseAdapterVersionError(adapterVersion);
637
+ if ((await Promise.all([
638
+ collections.bundles.limit(1).get(),
639
+ collections.bundlePatches.limit(1).get(),
640
+ collections.channels.limit(1).get(),
641
+ collections.releases.limit(1).get(),
642
+ collections.releaseCatalogs.limit(1).get()
643
+ ])).some((snapshot) => !snapshot.empty)) throw new FirebaseDatabaseAdapterVersionError("v0");
644
+ try {
645
+ await versionDocument.create({ version: 4 });
646
+ } catch (error) {
647
+ if ((await versionDocument.get()).data()?.version !== 4) throw error;
648
+ }
649
+ };
650
+ //#endregion
651
+ //#region src/firebaseDatabase.ts
652
+ const exactId = (input) => {
653
+ if (input.where?.length !== 1) return void 0;
654
+ const [condition] = input.where;
655
+ return condition.field === "id" && (condition.operator === void 0 || condition.operator === "eq") && typeof condition.value === "string" ? condition.value : void 0;
656
+ };
657
+ const firebaseDatabase = (config) => {
658
+ const adapter = createDatabasePluginAdapter("firebaseDatabase", (() => {
94
659
  const db = getFirestore(getApps().length ? getApp() : initializeApp(config));
95
- const bundlesCollection = db.collection("bundles");
96
- const targetAppVersionsCollection = db.collection("target_app_versions");
660
+ const collections = createFirebaseDatabaseCollections(db);
661
+ let migration;
662
+ const ensureMigrated = () => {
663
+ migration ??= migrateFirebaseDatabase(db, collections).catch((error) => {
664
+ migration = void 0;
665
+ throw error;
666
+ });
667
+ return migration;
668
+ };
669
+ const mutate = async (operation) => {
670
+ await ensureMigrated();
671
+ return db.runTransaction(async (transaction) => {
672
+ const before = await loadFirebaseTransactionSnapshot(transaction, collections);
673
+ const after = cloneFirebaseDatabaseSnapshot(before);
674
+ const result = await operation(createFirebaseDatabaseState(after));
675
+ persistFirebaseDatabaseSnapshot({
676
+ transaction,
677
+ collections,
678
+ before,
679
+ after
680
+ });
681
+ return result;
682
+ });
683
+ };
684
+ const read = async (operation) => {
685
+ await ensureMigrated();
686
+ return operation(createFirebaseDatabaseState(await loadFirebaseDatabaseSnapshot(collections)));
687
+ };
97
688
  return {
98
- async getUpdateInfo(args, context) {
99
- const channel = args.channel ?? "production";
100
- const minBundleId = args.minBundleId ?? NIL_UUID;
101
- if (args._updateStrategy === "appVersion") {
102
- const querySnapshot = await targetAppVersionsCollection.where("platform", "==", args.platform).where("channel", "==", channel).select("target_app_version").get();
103
- const compatibleAppVersions = filterCompatibleAppVersions(Array.from(new Set(querySnapshot.docs.map((doc) => doc.data().target_app_version).filter((version) => Boolean(version)))), args.appVersion);
104
- const bundles = (compatibleAppVersions.length > 0 ? await Promise.all(chunkValues(compatibleAppVersions, 10).map((versions) => bundlesCollection.where("platform", "==", args.platform).where("channel", "==", channel).where("enabled", "==", true).where("id", ">=", minBundleId).where("target_app_version", "in", versions).get())) : []).flatMap((snapshot) => snapshot.docs.map((doc) => convertToBundle(doc.data())));
105
- return resolveUpdateInfoFromBundles({
106
- args: {
107
- ...args,
108
- channel,
109
- minBundleId
110
- },
111
- bundles,
112
- context
113
- });
689
+ create: (input) => mutate((database) => database.create(input)),
690
+ update: (input) => mutate((database) => database.update(input)),
691
+ delete: (input) => mutate((database) => database.delete(input)),
692
+ count: (input) => read((database) => database.count(input)),
693
+ findOne: async (input) => {
694
+ const id = exactId(input);
695
+ if (id === void 0) return read((database) => database.findOne(input));
696
+ await ensureMigrated();
697
+ switch (input.model) {
698
+ case "bundles": {
699
+ const document = await collections.bundles.doc(id).get();
700
+ return document.exists ? requireFirebaseDocumentKey("bundles", document.id, parseFirebaseBundleRow(document.data(), `bundles/${document.id}`)) : null;
701
+ }
702
+ case "bundle_patches": {
703
+ const document = await collections.bundlePatches.doc(id).get();
704
+ return document.exists ? requireFirebaseDocumentKey("bundle_patches", document.id, parseFirebasePatchRow(document.data(), `bundle_patches/${document.id}`)) : null;
705
+ }
706
+ case "api_keys": {
707
+ const document = await collections.apiKeys.doc(id).get();
708
+ return document.exists ? requireFirebaseDocumentKey("api_keys", document.id, parseFirebaseApiKeyRow(document.data(), `api_keys/${document.id}`)) : null;
709
+ }
710
+ default: return read((database) => database.findOne(input));
114
711
  }
115
- const bundles = (await bundlesCollection.where("platform", "==", args.platform).where("channel", "==", channel).where("enabled", "==", true).where("id", ">=", minBundleId).where("fingerprint_hash", "==", args.fingerprintHash).get()).docs.map((doc) => convertToBundle(doc.data()));
116
- return resolveUpdateInfoFromBundles({
117
- args: {
118
- ...args,
119
- channel,
120
- minBundleId
121
- },
122
- bundles,
123
- context
124
- });
125
712
  },
126
- async getBundleById(bundleId) {
127
- const bundleSnap = await bundlesCollection.doc(bundleId).get();
128
- if (!bundleSnap.exists) return null;
129
- return convertToBundle(bundleSnap.data());
713
+ findMany: async (input) => {
714
+ if (input.model !== "channels") return read((database) => database.findMany(input));
715
+ await ensureMigrated();
716
+ return queryFirebaseDatabaseRows(await loadFirebaseChannels(collections), input);
130
717
  },
131
- async getBundles(options) {
132
- const { where, limit, orderBy } = options;
133
- const offset = ("offset" in options ? options.offset : void 0) ?? 0;
134
- let query = applyFirestoreQueryableFilters(bundlesCollection, where);
135
- query = query.orderBy("id", orderBy?.direction === "asc" ? "asc" : "desc");
136
- if (requiresInMemoryFiltering(where)) {
137
- const filteredBundles = sortBundles((await query.get()).docs.map((doc) => convertToBundle(doc.data())).filter((bundle) => bundleMatchesQueryWhere(bundle, where)), orderBy);
138
- const total = filteredBundles.length;
718
+ insertChannel: async (input) => {
719
+ await ensureMigrated();
720
+ return db.runTransaction(async (transaction) => {
721
+ const reference = collections.channels.doc(firebaseChannelDocumentId(input.row.name));
722
+ const idReference = collections.settings.doc(firebaseChannelIdDocumentId(input.row.id));
723
+ const [document, idDocument] = await transaction.getAll(reference, idReference);
724
+ if (idDocument.exists) {
725
+ const row = parseFirebaseChannelRow(idDocument.data(), `${FIREBASE_V1_COLLECTION_NAMES.settings}/${idDocument.id}`);
726
+ if (row.id !== input.row.id || row.name !== input.row.name) throw new FirebaseDatabaseConstraintError("channels.id.registry");
727
+ }
728
+ if (idDocument.exists && !document.exists) throw new FirebaseDatabaseConstraintError("channels.id.unique");
729
+ if (document.exists) {
730
+ const row = parseFirebaseChannelRow(document.data(), `channels/${document.id}`);
731
+ if (document.id !== firebaseChannelDocumentId(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.document-key");
732
+ return {
733
+ row,
734
+ inserted: false
735
+ };
736
+ }
737
+ transaction.create(reference, input.row);
738
+ transaction.create(idReference, input.row);
139
739
  return {
140
- data: filteredBundles.slice(offset, offset + limit),
141
- pagination: calculatePagination(total, {
142
- limit,
143
- offset
144
- })
740
+ row: input.row,
741
+ inserted: true
145
742
  };
146
- }
147
- const total = (await query.get()).size;
148
- if (offset > 0) query = query.offset(offset);
149
- if (limit) query = query.limit(limit);
150
- return {
151
- data: sortBundles((await query.get()).docs.map((doc) => convertToBundle(doc.data())), orderBy),
152
- pagination: calculatePagination(total, {
153
- limit,
154
- offset
155
- })
156
- };
157
- },
158
- async getChannels() {
159
- const querySnapshot = await db.collection("channels").get();
160
- if (querySnapshot.empty) return [];
161
- const channels = /* @__PURE__ */ new Set();
162
- for (const doc of querySnapshot.docs) {
163
- const data = doc.data();
164
- if (data.name) channels.add(data.name);
165
- }
166
- return Array.from(channels);
743
+ });
167
744
  },
168
- async commitBundle({ changedSets }) {
169
- if (changedSets.length === 0) return;
170
- let isTargetAppVersionChanged = false;
171
- await db.runTransaction(async (transaction) => {
172
- const bundlesSnapshot = await transaction.get(bundlesCollection);
173
- const targetVersionsSnapshot = await transaction.get(db.collection("target_app_versions"));
174
- const channelsSnapshot = await transaction.get(db.collection("channels"));
175
- const bundlesMap = {};
176
- for (const doc of bundlesSnapshot.docs) bundlesMap[doc.id] = doc.data();
177
- for (const { operation, data } of changedSets) {
178
- if (data.targetAppVersion) isTargetAppVersionChanged = true;
179
- if (operation === "insert" || operation === "update") {
180
- bundlesMap[data.id] = {
181
- id: data.id,
182
- channel: data.channel,
183
- enabled: data.enabled,
184
- should_force_update: data.shouldForceUpdate,
185
- file_hash: data.fileHash,
186
- git_commit_hash: data.gitCommitHash || null,
187
- message: data.message || null,
188
- platform: data.platform,
189
- target_app_version: data.targetAppVersion,
190
- storage_uri: data.storageUri,
191
- fingerprint_hash: data.fingerprintHash,
192
- metadata: stripBundleArtifactMetadata(data.metadata) ?? {},
193
- manifest_storage_uri: getManifestStorageUri(data),
194
- manifest_file_hash: getManifestFileHash(data),
195
- asset_base_storage_uri: getAssetBaseStorageUri(data),
196
- patches: data.patches ?? null,
197
- patch_base_bundle_id: getPatchBaseBundleId(data),
198
- patch_base_file_hash: getPatchBaseFileHash(data),
199
- patch_file_hash: getPatchFileHash(data),
200
- patch_storage_uri: getPatchStorageUri(data),
201
- rollout_cohort_count: data.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
202
- target_cohorts: data.targetCohorts ?? null
203
- };
204
- const channelRef = db.collection("channels").doc(data.channel);
205
- transaction.set(channelRef, { name: data.channel }, { merge: true });
206
- } else if (operation === "delete") {
207
- if (!bundlesMap[data.id]) throw new Error(`Bundle with id ${data.id} not found`);
208
- delete bundlesMap[data.id];
209
- isTargetAppVersionChanged = true;
210
- }
211
- }
212
- const requiredTargetVersionKeys = /* @__PURE__ */ new Set();
213
- const requiredChannels = /* @__PURE__ */ new Set();
214
- for (const bundle of Object.values(bundlesMap)) {
215
- if (bundle.target_app_version) {
216
- const key = `${bundle.platform}_${bundle.channel}_${bundle.target_app_version}`;
217
- requiredTargetVersionKeys.add(key);
218
- }
219
- requiredChannels.add(bundle.channel);
220
- }
221
- for (const { operation, data } of changedSets) {
222
- const bundleRef = bundlesCollection.doc(data.id);
223
- if (operation === "insert" || operation === "update") {
224
- transaction.set(bundleRef, {
225
- id: data.id,
226
- channel: data.channel,
227
- enabled: data.enabled,
228
- should_force_update: data.shouldForceUpdate,
229
- file_hash: data.fileHash,
230
- git_commit_hash: data.gitCommitHash || null,
231
- message: data.message || null,
232
- platform: data.platform,
233
- target_app_version: data.targetAppVersion || null,
234
- storage_uri: data.storageUri,
235
- fingerprint_hash: data.fingerprintHash,
236
- metadata: stripBundleArtifactMetadata(data.metadata) ?? {},
237
- manifest_storage_uri: getManifestStorageUri(data),
238
- manifest_file_hash: getManifestFileHash(data),
239
- asset_base_storage_uri: getAssetBaseStorageUri(data),
240
- patches: data.patches ?? null,
241
- patch_base_bundle_id: getPatchBaseBundleId(data),
242
- patch_base_file_hash: getPatchBaseFileHash(data),
243
- patch_file_hash: getPatchFileHash(data),
244
- patch_storage_uri: getPatchStorageUri(data),
245
- rollout_cohort_count: data.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
246
- target_cohorts: data.targetCohorts ?? null
247
- }, { merge: true });
248
- if (data.targetAppVersion) {
249
- const versionDocId = `${data.platform}_${data.channel}_${data.targetAppVersion}`;
250
- const targetAppVersionsRef = db.collection("target_app_versions").doc(versionDocId);
251
- transaction.set(targetAppVersionsRef, {
252
- channel: data.channel,
253
- platform: data.platform,
254
- target_app_version: data.targetAppVersion
255
- }, { merge: true });
256
- }
257
- } else if (operation === "delete") transaction.delete(bundleRef);
258
- }
259
- if (isTargetAppVersionChanged) {
260
- for (const targetDoc of targetVersionsSnapshot.docs) if (!requiredTargetVersionKeys.has(targetDoc.id)) transaction.delete(targetDoc.ref);
261
- }
262
- for (const channelDoc of channelsSnapshot.docs) if (!requiredChannels.has(channelDoc.id)) transaction.delete(channelDoc.ref);
745
+ deleteChannel: async ({ id }) => {
746
+ await ensureMigrated();
747
+ return db.runTransaction(async (transaction) => {
748
+ const idReference = collections.settings.doc(firebaseChannelIdDocumentId(id));
749
+ const idDocument = await transaction.get(idReference);
750
+ if (!idDocument.exists) return {
751
+ deleted: false,
752
+ reason: "not_found"
753
+ };
754
+ const row = parseFirebaseChannelRow(idDocument.data(), `${FIREBASE_V1_COLLECTION_NAMES.settings}/${idDocument.id}`);
755
+ const reference = collections.channels.doc(firebaseChannelDocumentId(row.name));
756
+ if (!(await transaction.get(reference)).exists || row.id !== id) throw new FirebaseDatabaseConstraintError("channels.id.registry");
757
+ if (!(await transaction.get(collections.releases.where("channel_id", "==", id).limit(1))).empty) return {
758
+ deleted: false,
759
+ reason: "not_empty"
760
+ };
761
+ transaction.delete(reference);
762
+ transaction.delete(idReference);
763
+ return { deleted: true };
263
764
  });
264
- }
765
+ },
766
+ transaction: (callback) => mutate(callback)
265
767
  };
266
- }
267
- });
768
+ })());
769
+ return createDatabasePlugin({
770
+ name: "firebaseDatabase",
771
+ models: adapter.models,
772
+ commit: adapter.commit
773
+ });
774
+ };
268
775
  //#endregion
269
776
  //#region src/firebaseStorage.ts
270
- const firebaseStorage = createUniversalStoragePlugin({
271
- name: "firebaseStorage",
272
- supportedProtocol: "gs",
273
- factory: (config) => {
274
- const bucket = getStorage(getApps().length ? getApp() : initializeApp(config)).bucket(config.storageBucket);
275
- const getStorageKey = createStorageKeyBuilder(config.basePath);
276
- return {
277
- node: {
278
- async delete(storageUri) {
279
- const { bucket: bucketName, key } = parseStorageUri(storageUri, "gs");
280
- if (bucketName !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${bucketName}".`);
281
- try {
282
- const [files] = await bucket.getFiles({ prefix: key });
283
- await Promise.all(files.map((file) => file.delete()));
284
- } catch (e) {
285
- console.error("Error listing or deleting files:", e);
286
- throw new Error("Bundle Not Found");
287
- }
288
- },
289
- async upload(key, filePath) {
290
- try {
291
- const fileContent = await fs.readFile(filePath);
292
- const contentType = getContentType(filePath);
293
- const storageKey = getStorageKey(key, path.basename(filePath));
294
- await bucket.file(storageKey).save(fileContent, { metadata: {
295
- contentType,
296
- cacheControl: "public, max-age=31536000, immutable"
297
- } });
298
- return { storageUri: `gs://${config.storageBucket}/${storageKey}` };
299
- } catch (error) {
300
- console.error("Error uploading bundle:", error);
301
- if (error instanceof Error) throw new Error(`Failed to upload bundle: ${error.message}`);
302
- throw error;
303
- }
304
- },
305
- async exists(storageUri) {
306
- const { bucket: bucketName, key } = parseStorageUri(storageUri, "gs");
307
- if (bucketName !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${bucketName}".`);
308
- const [exists] = await bucket.file(key).exists();
309
- return exists;
310
- },
311
- async downloadFile(storageUri, filePath) {
312
- const { bucket: bucketName, key } = parseStorageUri(storageUri, "gs");
313
- if (bucketName !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${bucketName}".`);
314
- await fs.mkdir(path.dirname(filePath), { recursive: true });
315
- await bucket.file(key).download({ destination: filePath });
316
- }
317
- },
318
- runtime: {
319
- async readText(storageUri) {
320
- const { bucket: bucketName, key } = parseStorageUri(storageUri, "gs");
321
- if (bucketName !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${bucketName}".`);
322
- try {
323
- const [contents] = await bucket.file(key).download();
324
- return contents.toString("utf8");
325
- } catch (error) {
326
- if (typeof error === "object" && error !== null && "code" in error && error.code === 404) return null;
327
- throw error;
328
- }
329
- },
330
- async getDownloadUrl(storageUri) {
331
- const { key } = parseStorageUri(storageUri, "gs");
332
- if (!key) throw new Error("Invalid Firebase storage URI: missing key");
333
- const [signedUrl] = await bucket.file(key).getSignedUrl({
334
- action: "read",
335
- expires: Date.now() + 3600 * 1e3
336
- });
337
- if (!signedUrl) throw new Error("Failed to generate download URL");
338
- return { fileUrl: signedUrl };
339
- }
777
+ const firebaseStorage = (config) => {
778
+ const bucket = getStorage(getApps().length ? getApp() : initializeApp(config)).bucket(config.storageBucket);
779
+ const getStorageKey = createStorageKeyBuilder(config.basePath);
780
+ const parseAndValidate = (storageUri) => {
781
+ const parsed = parseStorageUri(storageUri, "gs");
782
+ if (parsed.bucket !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${parsed.bucket}".`);
783
+ return parsed;
784
+ };
785
+ return createStoragePlugin({
786
+ name: "firebaseStorage",
787
+ protocol: "gs",
788
+ async put({ key, body, contentType }) {
789
+ const storageKey = getStorageKey(key);
790
+ const bytes = new Uint8Array(await new Response(body).arrayBuffer());
791
+ await bucket.file(storageKey).save(bytes, { metadata: {
792
+ contentType,
793
+ cacheControl: "public, max-age=31536000, immutable"
794
+ } });
795
+ return { storageUri: createStorageUri({
796
+ bucket: config.storageBucket,
797
+ key: storageKey,
798
+ protocol: "gs"
799
+ }) };
800
+ },
801
+ async get({ storageUri }) {
802
+ const { key } = parseAndValidate(storageUri);
803
+ try {
804
+ const file = bucket.file(key);
805
+ const [[body], [metadata]] = await Promise.all([file.download(), file.getMetadata()]);
806
+ const headers = new Headers();
807
+ if (metadata.contentType) headers.set("content-type", metadata.contentType);
808
+ headers.set("content-length", String(metadata.size ?? body.byteLength));
809
+ return { response: new Response(body, { headers }) };
810
+ } catch (error) {
811
+ if (typeof error === "object" && error !== null && "code" in error && error.code === 404) return { response: null };
812
+ throw error;
340
813
  }
341
- };
342
- }
343
- });
814
+ },
815
+ async getDownloadUrl({ storageUri }) {
816
+ const { key } = parseAndValidate(storageUri);
817
+ if (config.cdnUrl) {
818
+ const storageUrl = new URL(storageUri);
819
+ const downloadUrl = new URL(config.cdnUrl);
820
+ downloadUrl.pathname = `${downloadUrl.pathname.replace(/\/+$/, "")}${storageUrl.pathname}`;
821
+ downloadUrl.search = "";
822
+ downloadUrl.hash = "";
823
+ return { url: downloadUrl.toString() };
824
+ }
825
+ const [url] = await bucket.file(key).getSignedUrl({
826
+ action: "read",
827
+ expires: Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3
828
+ });
829
+ if (!url) throw new Error("Failed to generate Firebase download URL");
830
+ return { url };
831
+ },
832
+ async exists({ storageUri }) {
833
+ const { key } = parseAndValidate(storageUri);
834
+ const [exists] = await bucket.file(key).exists();
835
+ return { exists };
836
+ },
837
+ async delete({ storageUri }) {
838
+ const { key } = parseAndValidate(storageUri);
839
+ await bucket.file(key).delete({ ignoreNotFound: true });
840
+ return { deleted: true };
841
+ }
842
+ });
843
+ };
344
844
  //#endregion
345
845
  export { firebaseDatabase, firebaseStorage };