@hot-updater/firebase 1.0.0-rc.2 → 1.0.0-rc.21

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,917 +1,195 @@
1
- import { compareInsightsText, createDatabasePlugin, createStorageKeyBuilder, createStoragePlugin, createStorageUri, isDatabaseMetadataObject, parseStorageUri } from "@hot-updater/plugin-core";
2
- import { createDatabasePluginAdapter } from "@hot-updater/plugin-core/internal";
1
+ import { createEngineDatabase, createKvAdapter, createStorageAdapter, createStorageKeyBuilder, createStorageUri, migrateCoreSchema, parseStorageUri } from "@hot-updater/plugin-core";
3
2
  import { getApp, getApps, initializeApp } from "firebase-admin/app";
4
- import { getFirestore } from "firebase-admin/firestore";
3
+ import { FieldPath, FieldValue, Timestamp, getFirestore } from "firebase-admin/firestore";
4
+ import { createHash } from "node:crypto";
5
5
  import { getStorage } from "firebase-admin/storage";
6
- //#region src/firebaseDatabaseParserShared.ts
7
- var FirebaseDatabaseDataError = class extends Error {
8
- source;
9
- name = "FirebaseDatabaseDataError";
10
- constructor(source) {
11
- super(`Invalid Firebase database data at "${source}".`);
12
- this.source = source;
13
- }
14
- };
15
- const record = (value, source) => {
16
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new FirebaseDatabaseDataError(source);
17
- return value;
18
- };
19
- const property = (value, key) => Reflect.get(value, key);
20
- const string = (value, source) => {
21
- if (typeof value !== "string") throw new FirebaseDatabaseDataError(source);
22
- return value;
23
- };
24
- const nullableString = (value, source) => {
25
- if (value === null || value === void 0) return null;
26
- return string(value, source);
27
- };
28
- const boolean = (value, source) => {
29
- if (typeof value !== "boolean") throw new FirebaseDatabaseDataError(source);
30
- return value;
31
- };
32
- const number = (value, source) => {
33
- if (typeof value !== "number" || !Number.isFinite(value)) throw new FirebaseDatabaseDataError(source);
34
- return value;
35
- };
36
- const byteSize = (value, source) => {
37
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new FirebaseDatabaseDataError(source);
38
- return value;
39
- };
40
- const stringArray = (value, source) => {
41
- if (value === null || value === void 0) return null;
42
- if (!Array.isArray(value)) throw new FirebaseDatabaseDataError(source);
43
- return value.map((item) => string(item, source));
44
- };
45
- const platform = (value, source) => {
46
- if (value === "android" || value === "ios") return value;
47
- throw new FirebaseDatabaseDataError(source);
48
- };
49
- //#endregion
50
- //#region src/firebaseDatabaseParser.ts
51
- const metadata = (value, source) => {
52
- if (!isDatabaseMetadataObject(value)) throw new FirebaseDatabaseDataError(source);
53
- return value;
54
- };
55
- const requiredNullableString = (input, key, source) => {
56
- if (!Object.hasOwn(input, key)) throw new FirebaseDatabaseDataError(source);
57
- return nullableString(property(input, key), source);
58
- };
59
- const parseFirebaseBundleRow = (value, source) => {
60
- const input = record(value, source);
61
- return {
62
- id: string(property(input, "id"), source),
63
- platform: platform(property(input, "platform"), source),
64
- file_hash: string(property(input, "file_hash"), source),
65
- git_commit_hash: nullableString(property(input, "git_commit_hash"), source),
66
- storage_uri: string(property(input, "storage_uri"), source),
67
- archive_byte_size: byteSize(property(input, "archive_byte_size"), source),
68
- metadata: metadata(property(input, "metadata"), source),
69
- manifest_storage_uri: nullableString(property(input, "manifest_storage_uri"), source),
70
- manifest_file_hash: nullableString(property(input, "manifest_file_hash"), source),
71
- asset_base_storage_uri: nullableString(property(input, "asset_base_storage_uri"), source)
72
- };
73
- };
74
- const parseFirebaseChannelRow = (value, source) => {
75
- const input = record(value, source);
76
- return {
77
- id: string(property(input, "id"), source),
78
- name: string(property(input, "name"), source)
79
- };
80
- };
81
- const parseFirebasePatchRow = (value, source) => {
82
- const input = record(value, source);
83
- return {
84
- id: string(property(input, "id"), source),
85
- bundle_id: string(property(input, "bundle_id"), source),
86
- base_bundle_id: string(property(input, "base_bundle_id"), source),
87
- base_file_hash: string(property(input, "base_file_hash"), source),
88
- patch_file_hash: string(property(input, "patch_file_hash"), source),
89
- patch_storage_uri: string(property(input, "patch_storage_uri"), source),
90
- byte_size: byteSize(property(input, "byte_size"), source),
91
- order_index: number(property(input, "order_index"), source)
92
- };
93
- };
94
- const parseFirebaseBundleEventRow = (value, source) => {
95
- const input = record(value, source);
96
- const type = string(property(input, "type"), source);
97
- const fromBundleId = requiredNullableString(input, "from_bundle_id", source);
98
- const updateStrategy = requiredNullableString(input, "update_strategy", source);
99
- 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);
100
- return {
101
- id: string(property(input, "id"), source),
102
- type,
103
- install_id: string(property(input, "install_id"), source),
104
- user_id: nullableString(property(input, "user_id"), source),
105
- username: nullableString(property(input, "username"), source),
106
- from_release_id: requiredNullableString(input, "from_release_id", source),
107
- from_bundle_id: fromBundleId,
108
- to_release_id: requiredNullableString(input, "to_release_id", source),
109
- to_bundle_id: string(property(input, "to_bundle_id"), source),
110
- platform: platform(property(input, "platform"), source),
111
- app_version: string(property(input, "app_version"), source),
112
- channel: string(property(input, "channel"), source),
113
- cohort: string(property(input, "cohort"), source),
114
- update_strategy: updateStrategy,
115
- fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
116
- sdk_version: nullableString(property(input, "sdk_version"), source),
117
- received_at_ms: number(property(input, "received_at_ms"), source)
118
- };
119
- };
120
- const parseFirebaseInsightsInstallationRow = (value, source) => {
121
- const input = record(value, source);
122
- const type = string(property(input, "type"), source);
123
- if (type !== "UPDATE_APPLIED" && type !== "RECOVERED" && type !== "RELEASE_ADOPTED" && type !== "UNCHANGED") throw new FirebaseDatabaseDataError(source);
124
- return {
125
- id: string(property(input, "id"), source),
126
- type,
127
- install_id: string(property(input, "install_id"), source),
128
- user_id: nullableString(property(input, "user_id"), source),
129
- username: nullableString(property(input, "username"), source),
130
- to_bundle_id: string(property(input, "to_bundle_id"), source),
131
- platform: platform(property(input, "platform"), source),
132
- app_version: string(property(input, "app_version"), source),
133
- channel: string(property(input, "channel"), source),
134
- cohort: string(property(input, "cohort"), source),
135
- received_at_ms: number(property(input, "received_at_ms"), source)
136
- };
137
- };
138
- const parseFirebaseApiKeyRow = (value, source) => {
139
- const input = record(value, source);
140
- const role = string(property(input, "role"), source);
141
- if (role !== "client") throw new FirebaseDatabaseDataError(source);
142
- const revokedAt = property(input, "revoked_at_ms");
143
- return {
144
- id: string(property(input, "id"), source),
145
- hash: string(property(input, "hash"), source),
146
- name: string(property(input, "name"), source),
147
- prefix: string(property(input, "prefix"), source),
148
- role,
149
- created_at_ms: number(property(input, "created_at_ms"), source),
150
- revoked_at_ms: revokedAt === null ? null : number(revokedAt, source)
151
- };
152
- };
153
- const parseFirebaseReleaseRow = (value, source) => {
154
- const input = record(value, source);
155
- const kind = string(property(input, "kind"), source);
156
- const strategy = string(property(input, "strategy"), source);
157
- const operation = string(property(input, "operation"), source);
158
- const targetCohorts = stringArray(property(input, "target_cohorts"), source);
159
- if (kind !== "BUNDLE" && kind !== "EMBEDDED" || strategy !== "APP_VERSION" && strategy !== "FINGERPRINT" || operation !== "DEPLOY" && operation !== "PROMOTE" && operation !== "ROLLBACK" || targetCohorts === null) throw new FirebaseDatabaseDataError(source);
160
- return {
161
- id: string(property(input, "id"), source),
162
- revision: number(property(input, "revision"), source),
163
- scope_key: string(property(input, "scope_key"), source),
164
- channel_id: string(property(input, "channel_id"), source),
165
- platform: platform(property(input, "platform"), source),
166
- kind,
167
- bundle_id: nullableString(property(input, "bundle_id"), source),
168
- strategy,
169
- target_app_version: nullableString(property(input, "target_app_version"), source),
170
- fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
171
- enabled: boolean(property(input, "enabled"), source),
172
- should_force_update: boolean(property(input, "should_force_update"), source),
173
- message: nullableString(property(input, "message"), source),
174
- rollout_cohort_count: number(property(input, "rollout_cohort_count"), source),
175
- target_cohorts: targetCohorts,
176
- operation,
177
- source_release_id: nullableString(property(input, "source_release_id"), source),
178
- created_at_ms: number(property(input, "created_at_ms"), source),
179
- updated_at_ms: number(property(input, "updated_at_ms"), source)
180
- };
181
- };
182
- const parseFirebaseReleaseCatalogRow = (value, source) => {
183
- const input = record(value, source);
184
- const strategy = string(property(input, "strategy"), source);
185
- if (strategy !== "APP_VERSION" && strategy !== "FINGERPRINT") throw new FirebaseDatabaseDataError(source);
186
- return {
187
- scope_key: string(property(input, "scope_key"), source),
188
- catalog_id: string(property(input, "catalog_id"), source),
189
- strategy,
190
- channel_id: string(property(input, "channel_id"), source),
191
- channel_key: string(property(input, "channel_key"), source),
192
- platform: platform(property(input, "platform"), source),
193
- fingerprint_hash: nullableString(property(input, "fingerprint_hash"), source),
194
- generation: number(property(input, "generation"), source),
195
- payload: string(property(input, "payload"), source),
196
- catalog_hash: string(property(input, "catalog_hash"), source),
197
- byte_size: number(property(input, "byte_size"), source),
198
- is_tombstone: boolean(property(input, "is_tombstone"), source),
199
- updated_at_ms: number(property(input, "updated_at_ms"), source)
200
- };
201
- };
202
- //#endregion
203
- //#region src/firebaseDatabaseQuery.ts
204
- const compare = (left, right) => {
205
- if (typeof left === "number" && typeof right === "number") return left - right;
206
- return String(left).localeCompare(String(right));
207
- };
208
- const normalizeStringComparison = (actual, expected, mode) => {
209
- if (typeof actual !== "string") return null;
210
- return mode === "insensitive" ? [actual.toLocaleLowerCase(), expected.toLocaleLowerCase()] : [actual, expected];
211
- };
212
- const matchesCondition = (row, condition) => {
213
- const actual = Reflect.get(row, condition.field);
214
- const expected = condition.value;
215
- switch (condition.operator ?? "eq") {
216
- case "eq": {
217
- if (typeof expected !== "string") return actual === expected;
218
- const mode = "mode" in condition ? condition.mode : void 0;
219
- const comparison = normalizeStringComparison(actual, expected, mode);
220
- return comparison !== null && comparison[0] === comparison[1];
221
- }
222
- case "ne": {
223
- if (actual === null || actual === void 0) return false;
224
- if (typeof expected !== "string") return actual !== expected;
225
- const mode = "mode" in condition ? condition.mode : void 0;
226
- const comparison = normalizeStringComparison(actual, expected, mode);
227
- return comparison === null || comparison[0] !== comparison[1];
228
- }
229
- case "gt":
230
- if (actual === null || actual === void 0) return false;
231
- return compare(actual, expected) > 0;
232
- case "gte":
233
- if (actual === null || actual === void 0) return false;
234
- return compare(actual, expected) >= 0;
235
- case "lt":
236
- if (actual === null || actual === void 0) return false;
237
- return compare(actual, expected) < 0;
238
- case "lte":
239
- if (actual === null || actual === void 0) return false;
240
- return compare(actual, expected) <= 0;
241
- case "in":
242
- if (!Array.isArray(expected)) return false;
243
- return expected.some((candidate) => candidate === actual);
244
- case "not_in": {
245
- if (!Array.isArray(expected)) return false;
246
- const values = expected;
247
- return values.length === 0 || actual !== null && actual !== void 0 && values.every((candidate) => candidate !== actual);
248
- }
249
- case "contains": {
250
- if (typeof expected !== "string") return false;
251
- const mode = "mode" in condition ? condition.mode : void 0;
252
- const comparison = normalizeStringComparison(actual, expected, mode);
253
- return comparison?.[0].includes(comparison[1]) ?? false;
254
- }
255
- case "starts_with": {
256
- if (typeof expected !== "string") return false;
257
- const mode = "mode" in condition ? condition.mode : void 0;
258
- const comparison = normalizeStringComparison(actual, expected, mode);
259
- return comparison?.[0].startsWith(comparison[1]) ?? false;
260
- }
261
- case "ends_with": {
262
- if (typeof expected !== "string") return false;
263
- const mode = "mode" in condition ? condition.mode : void 0;
264
- const comparison = normalizeStringComparison(actual, expected, mode);
265
- return comparison?.[0].endsWith(comparison[1]) ?? false;
266
- }
267
- }
268
- };
269
- const matchesFirebaseDatabaseWhere = (row, where) => {
270
- const first = where?.[0];
271
- if (!first) return true;
272
- let result = matchesCondition(row, first);
273
- for (const condition of where.slice(1)) {
274
- const current = matchesCondition(row, condition);
275
- result = condition.connector === "OR" ? result || current : result && current;
276
- }
277
- return result;
278
- };
279
- const queryFirebaseDatabaseRows = (rows, input) => {
280
- const filtered = rows.filter((row) => matchesFirebaseDatabaseWhere(row, input.where));
281
- const orderBy = input.orderBy;
282
- if (orderBy !== void 0) filtered.sort((left, right) => {
283
- for (const clause of orderBy) {
284
- const leftValue = Reflect.get(left, clause.field);
285
- const rightValue = Reflect.get(right, clause.field);
286
- if (leftValue === rightValue) continue;
287
- if (clause.nulls !== void 0 && (leftValue === null || rightValue === null)) {
288
- const nullComparison = leftValue === null ? -1 : 1;
289
- return clause.nulls === "first" ? nullComparison : -nullComparison;
290
- }
291
- const comparison = compare(leftValue, rightValue);
292
- if (comparison !== 0) return clause.direction === "asc" ? comparison : -comparison;
293
- }
294
- return 0;
295
- });
296
- const distinctOn = input.distinctOn;
297
- if (distinctOn === void 0) return filtered.slice(input.offset, input.offset + input.limit);
298
- const seen = /* @__PURE__ */ new Set();
299
- return filtered.filter((row) => {
300
- const key = JSON.stringify(distinctOn.fields.map((field) => Reflect.get(row, field)));
301
- if (seen.has(key)) return false;
302
- seen.add(key);
303
- return true;
304
- }).slice(input.offset, input.offset + input.limit);
305
- };
306
- //#endregion
307
- //#region src/firebaseDatabaseState.ts
308
- var FirebaseDatabaseConstraintError = class extends Error {
309
- constraint;
310
- name = "FirebaseDatabaseConstraintError";
311
- constructor(constraint) {
312
- super(`Firebase database constraint failed: ${constraint}`);
313
- this.constraint = constraint;
314
- }
315
- };
316
- const cloneFirebaseDatabaseSnapshot = (snapshot) => ({
317
- bundles: new Map(snapshot.bundles),
318
- bundlePatches: new Map(snapshot.bundlePatches),
319
- bundleEvents: new Map(snapshot.bundleEvents),
320
- bundleInstallations: new Map(snapshot.bundleInstallations),
321
- channels: new Map(snapshot.channels),
322
- apiKeys: new Map(snapshot.apiKeys),
323
- releaseCatalogs: new Map(snapshot.releaseCatalogs),
324
- releases: new Map(snapshot.releases)
325
- });
326
- const requireUnique = (rows, id, model) => {
327
- if (rows.has(id)) throw new FirebaseDatabaseConstraintError(`${model}.id.unique`);
328
- };
329
- const distinctCount = (rows, fields) => {
330
- if (fields === void 0) return rows.length;
331
- return new Set(rows.map((row) => JSON.stringify(fields.map((field) => Reflect.get(row, field))))).size;
332
- };
333
- const createFirebaseDatabaseState = (snapshot) => ({
334
- async create(input) {
335
- switch (input.model) {
336
- case "bundles":
337
- requireUnique(snapshot.bundles, input.data.id, input.model);
338
- snapshot.bundles.set(input.data.id, input.data);
339
- return input.data;
340
- case "bundle_patches":
341
- requireUnique(snapshot.bundlePatches, input.data.id, input.model);
342
- if (!snapshot.bundles.has(input.data.bundle_id)) throw new FirebaseDatabaseConstraintError("bundle_patches.bundle_id.foreign-key");
343
- if (!snapshot.bundles.has(input.data.base_bundle_id)) throw new FirebaseDatabaseConstraintError("bundle_patches.base_bundle_id.foreign-key");
344
- snapshot.bundlePatches.set(input.data.id, input.data);
345
- return input.data;
346
- case "bundle_events":
347
- requireUnique(snapshot.bundleEvents, input.data.id, input.model);
348
- snapshot.bundleEvents.set(input.data.id, input.data);
349
- return input.data;
350
- case "bundle_installations": {
351
- const current = snapshot.bundleInstallations.get(input.data.install_id);
352
- if (current && input.onConflict === "ignore") return current;
353
- if (current) throw new FirebaseDatabaseConstraintError("bundle_installations.install_id.unique");
354
- snapshot.bundleInstallations.set(input.data.install_id, input.data);
355
- return input.data;
356
- }
357
- case "releases":
358
- requireUnique(snapshot.releases, input.data.id, input.model);
359
- if (!snapshot.channels.has(input.data.channel_id)) throw new FirebaseDatabaseConstraintError("releases.channel_id.foreign-key");
360
- if (input.data.bundle_id !== null && !snapshot.bundles.has(input.data.bundle_id)) throw new FirebaseDatabaseConstraintError("releases.bundle_id.foreign-key");
361
- snapshot.releases.set(input.data.id, input.data);
362
- return input.data;
363
- case "release_catalogs":
364
- if (snapshot.releaseCatalogs.has(input.data.scope_key)) throw new FirebaseDatabaseConstraintError("release_catalogs.scope_key.unique");
365
- snapshot.releaseCatalogs.set(input.data.scope_key, input.data);
366
- return input.data;
367
- case "channels": {
368
- const existing = [...snapshot.channels.values()].find(({ name }) => name === input.data.name);
369
- if (existing && input.onConflict === "ignore") return existing;
370
- requireUnique(snapshot.channels, input.data.id, input.model);
371
- if (existing) throw new FirebaseDatabaseConstraintError("channels.name.unique");
372
- snapshot.channels.set(input.data.id, input.data);
373
- return input.data;
374
- }
375
- case "api_keys": {
376
- const existing = [...snapshot.apiKeys.values()].find(({ hash }) => hash === input.data.hash);
377
- if (existing && input.onConflict === "ignore") return existing;
378
- requireUnique(snapshot.apiKeys, input.data.id, input.model);
379
- if (existing) throw new FirebaseDatabaseConstraintError("api_keys.hash.unique");
380
- snapshot.apiKeys.set(input.data.id, input.data);
381
- return input.data;
382
- }
383
- }
384
- },
385
- async update(input) {
386
- if (input.model === "bundle_installations") {
387
- const current = [...snapshot.bundleInstallations.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
388
- if (!current) return null;
389
- const updated = {
390
- ...current,
391
- ...input.update
392
- };
393
- snapshot.bundleInstallations.set(current.install_id, updated);
394
- return updated;
395
- }
396
- if (input.model === "api_keys") {
397
- const current = [...snapshot.apiKeys.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
398
- if (!current) return null;
399
- const updated = {
400
- ...current,
401
- ...input.update
402
- };
403
- snapshot.apiKeys.set(current.id, updated);
404
- return updated;
405
- }
406
- if (input.model === "releases") {
407
- const current = [...snapshot.releases.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
408
- if (!current) return null;
409
- const updated = {
410
- ...current,
411
- ...input.update
412
- };
413
- snapshot.releases.set(current.id, updated);
414
- return updated;
415
- }
416
- if (input.model === "release_catalogs") {
417
- const current = [...snapshot.releaseCatalogs.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
418
- if (!current) return null;
419
- const updated = {
420
- ...current,
421
- ...input.update
422
- };
423
- snapshot.releaseCatalogs.set(current.scope_key, updated);
424
- return updated;
425
- }
426
- const current = [...snapshot.bundles.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where));
427
- if (!current) return null;
428
- const updated = {
429
- ...current,
430
- ...input.update
431
- };
432
- snapshot.bundles.set(current.id, updated);
433
- return updated;
434
- },
435
- async delete(input) {
436
- if (input.model === "channels") {
437
- for (const row of snapshot.channels.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.channels.delete(row.id);
438
- return;
439
- }
440
- if (input.model === "bundle_patches") {
441
- for (const row of snapshot.bundlePatches.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.bundlePatches.delete(row.id);
442
- return;
443
- }
444
- if (input.model === "releases") {
445
- for (const row of snapshot.releases.values()) if (matchesFirebaseDatabaseWhere(row, input.where)) snapshot.releases.delete(row.id);
446
- return;
447
- }
448
- const removedIds = new Set([...snapshot.bundles.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)).map(({ id }) => id));
449
- for (const id of removedIds) snapshot.bundles.delete(id);
450
- for (const patch of snapshot.bundlePatches.values()) if (removedIds.has(patch.bundle_id) || removedIds.has(patch.base_bundle_id)) snapshot.bundlePatches.delete(patch.id);
451
- },
452
- async count(input) {
453
- switch (input.model) {
454
- case "bundles": return distinctCount([...snapshot.bundles.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
455
- case "bundle_patches": return distinctCount([...snapshot.bundlePatches.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
456
- case "releases": return distinctCount([...snapshot.releases.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
457
- case "bundle_installations": return distinctCount([...snapshot.bundleInstallations.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
458
- case "bundle_events": return distinctCount([...snapshot.bundleEvents.values()].filter((row) => matchesFirebaseDatabaseWhere(row, input.where)), input.distinct);
459
- }
460
- },
461
- async findOne(input) {
462
- switch (input.model) {
463
- case "bundles": return [...snapshot.bundles.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
464
- case "api_keys": return [...snapshot.apiKeys.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
465
- case "channels": return [...snapshot.channels.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
466
- case "bundle_patches": return [...snapshot.bundlePatches.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
467
- case "releases": return [...snapshot.releases.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
468
- case "release_catalogs": return [...snapshot.releaseCatalogs.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
469
- case "bundle_installations": return [...snapshot.bundleInstallations.values()].find((row) => matchesFirebaseDatabaseWhere(row, input.where)) ?? null;
470
- }
471
- },
472
- async findMany(input) {
473
- switch (input.model) {
474
- case "bundles": return queryFirebaseDatabaseRows([...snapshot.bundles.values()], input);
475
- case "bundle_patches": return queryFirebaseDatabaseRows([...snapshot.bundlePatches.values()], input);
476
- case "bundle_events": return queryFirebaseDatabaseRows([...snapshot.bundleEvents.values()], input);
477
- case "bundle_installations": return queryFirebaseDatabaseRows([...snapshot.bundleInstallations.values()], input);
478
- case "channels": return queryFirebaseDatabaseRows([...snapshot.channels.values()], input);
479
- case "api_keys": return queryFirebaseDatabaseRows([...snapshot.apiKeys.values()], input);
480
- case "releases": return queryFirebaseDatabaseRows([...snapshot.releases.values()], input);
481
- case "release_catalogs": return queryFirebaseDatabaseRows([...snapshot.releaseCatalogs.values()], input);
482
- }
483
- }
484
- });
485
- //#endregion
6
+ import { apiKeys } from "@hot-updater/server/plugins/api-keys";
7
+ import { insights } from "@hot-updater/server/plugins/insights";
486
8
  //#region src/firebaseInfrastructureNames.ts
487
- const FIREBASE_V1_COLLECTION_NAMES = {
488
- apiKeys: "hot_updater_v1_api_keys",
489
- bundleEvents: "hot_updater_v1_bundle_events",
490
- bundleInstallations: "hot_updater_v1_bundle_installations",
491
- bundlePatches: "hot_updater_v1_bundle_patches",
492
- bundles: "hot_updater_v1_bundles",
493
- channels: "hot_updater_v1_channels",
494
- releaseCatalogs: "hot_updater_v1_release_catalogs",
495
- releases: "hot_updater_v1_releases",
496
- settings: "hot_updater_v1_private_settings"
497
- };
9
+ /**
10
+ * The one collection the storage engine keeps every item in. It namespaces
11
+ * the data, so item partitions are table names with no prefix.
12
+ */
13
+ const FIREBASE_V1_COLLECTION = "hot_updater_v1";
498
14
  //#endregion
499
- //#region src/firebaseDatabasePersistence.ts
500
- var FirebaseDatabaseAdapterVersionError = class extends Error {
501
- version;
502
- name = "FirebaseDatabaseAdapterVersionError";
503
- constructor(version) {
504
- super(`Unsupported Firebase database adapter version: ${String(version)}`);
505
- this.version = version;
15
+ //#region src/firestoreStore.ts
16
+ /**
17
+ * Firestore's limits: 10 MiB a request and 1 MiB a document; 500 writes a
18
+ * transaction stays a conservative bound. Indexed values over 1,500 bytes are
19
+ * truncated, so longer keys are refused rather than read inconsistently.
20
+ */
21
+ const FIRESTORE_LIMITS = {
22
+ items: 500,
23
+ bytes: 1e7,
24
+ itemBytes: 1e6,
25
+ keyBytes: {
26
+ pk: 1500,
27
+ sk: 1500
506
28
  }
507
29
  };
508
- const createFirebaseDatabaseCollections = (db) => ({
509
- bundles: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundles),
510
- bundlePatches: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundlePatches),
511
- bundleEvents: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundleEvents),
512
- bundleInstallations: db.collection(FIREBASE_V1_COLLECTION_NAMES.bundleInstallations),
513
- channels: db.collection(FIREBASE_V1_COLLECTION_NAMES.channels),
514
- apiKeys: db.collection(FIREBASE_V1_COLLECTION_NAMES.apiKeys),
515
- releaseCatalogs: db.collection(FIREBASE_V1_COLLECTION_NAMES.releaseCatalogs),
516
- releases: db.collection(FIREBASE_V1_COLLECTION_NAMES.releases),
517
- settings: db.collection(FIREBASE_V1_COLLECTION_NAMES.settings)
30
+ /** `getAll` reads at most this many documents a call. */
31
+ const GET_BATCH = 300;
32
+ /** A document's id: a hash of its key, since keys may hold `/` and outgrow Firestore's 1,500 bytes. */
33
+ const firestoreDocumentId = ({ pk, sk }) => createHash("sha256").update(JSON.stringify([pk, sk])).digest("hex");
34
+ /**
35
+ * A document is `{ pk, sk, row }`. Row values that are maps or arrays become
36
+ * JSON text inside a map, since Firestore has no nested arrays and does not
37
+ * keep map key order; `row` is exempt from single-field indexing.
38
+ */
39
+ const encode = (value) => value !== null && typeof value === "object" ? { json: JSON.stringify(value) } : value;
40
+ const decode = (value) => value !== null && typeof value === "object" && "json" in value ? JSON.parse(String(value.json)) : value;
41
+ /**
42
+ * The collection's TTL field: a timestamp beside `pk` and `sk` on every item
43
+ * of a row that expires, which Firestore's TTL policy deletes.
44
+ */
45
+ const FIRESTORE_TTL_FIELD = "expireAt";
46
+ const toDocument = (key, row, expiresAt) => ({
47
+ pk: key.pk,
48
+ sk: key.sk,
49
+ row: Object.fromEntries(Object.entries(row).map(([name, value]) => [name, encode(value)])),
50
+ ...expiresAt === void 0 ? {} : { [FIRESTORE_TTL_FIELD]: Timestamp.fromMillis(expiresAt) }
518
51
  });
519
- const firebaseChannelDocumentId = (name) => `name_${Buffer.from(name, "utf8").toString("base64url")}`;
520
- const firebaseChannelIdDocumentId = (id) => `channel_id_${Buffer.from(id, "utf8").toString("base64url")}`;
521
- const firebaseInstallationDocumentId = (id) => `install_${Buffer.from(id, "utf8").toString("base64url")}`;
522
- const requireFirebaseDocumentKey = (model, documentId, row) => {
523
- if (documentId !== (model === "bundle_installations" ? firebaseInstallationDocumentId(row.install_id) : "id" in row ? row.id : row.scope_key)) throw new FirebaseDatabaseConstraintError(`${model}.id.document-key`);
524
- return row;
525
- };
526
- const documentMap = (model, documents) => {
527
- const rows = /* @__PURE__ */ new Map();
528
- for (const { row } of documents) {
529
- const key = model === "bundle_installations" ? row.install_id : "id" in row ? row.id : row.scope_key;
530
- if (rows.has(key)) throw new FirebaseDatabaseConstraintError(`${model}.id.unique`);
531
- rows.set(key, row);
532
- }
533
- for (const { document, row } of documents) requireFirebaseDocumentKey(model, document.id, row);
534
- return rows;
535
- };
536
- const bundleMap = (snapshot) => documentMap("bundles", snapshot.docs.map((document) => ({
537
- document,
538
- row: parseFirebaseBundleRow(document.data(), `bundles/${document.id}`)
539
- })));
540
- const patchMap = (snapshot) => documentMap("bundle_patches", snapshot.docs.map((document) => ({
541
- document,
542
- row: parseFirebasePatchRow(document.data(), `bundle_patches/${document.id}`)
543
- })));
544
- const channelMap = (snapshot) => {
545
- const rows = /* @__PURE__ */ new Map();
546
- const names = /* @__PURE__ */ new Set();
547
- for (const document of snapshot.docs) {
548
- const row = parseFirebaseChannelRow(document.data(), `channels/${document.id}`);
549
- if (document.id !== firebaseChannelDocumentId(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.document-key");
550
- if (rows.has(row.id)) throw new FirebaseDatabaseConstraintError("channels.id.unique");
551
- if (names.has(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.unique");
552
- rows.set(row.id, row);
553
- names.add(row.name);
554
- }
555
- return rows;
556
- };
557
- const loadFirebaseChannels = async (collections) => [...channelMap(await collections.channels.get()).values()];
558
- const apiKeyMap = (snapshot) => documentMap("api_keys", snapshot.docs.map((document) => ({
559
- document,
560
- row: parseFirebaseApiKeyRow(document.data(), `api_keys/${document.id}`)
561
- })));
562
- const releaseMap = (snapshot) => documentMap("releases", snapshot.docs.map((document) => ({
563
- document,
564
- row: parseFirebaseReleaseRow(document.data(), `releases/${document.id}`)
565
- })));
566
- const releaseCatalogMap = (snapshot) => documentMap("release_catalogs", snapshot.docs.map((document) => ({
567
- document,
568
- row: parseFirebaseReleaseCatalogRow(document.data(), `release_catalogs/${document.id}`)
569
- })));
570
- const toSnapshot = (documents) => {
52
+ const toRow = (data) => Object.fromEntries(Object.entries(data.row ?? {}).map(([name, value]) => [name, decode(value)]));
53
+ const holds = (snapshot, condition) => {
54
+ if (condition === void 0) return true;
55
+ const data = snapshot?.exists ? snapshot.data() : void 0;
56
+ return "v" in condition ? data !== void 0 && data.row?._v === condition.v : data !== void 0 === condition.exists;
57
+ };
58
+ /** gRPC codes a rerun of the whole write can clear: DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED, INTERNAL, UNAVAILABLE. */
59
+ const TRANSIENT = /* @__PURE__ */ new Set([
60
+ 4,
61
+ 8,
62
+ 10,
63
+ 13,
64
+ 14
65
+ ]);
66
+ const codeOf = (error) => error.code;
67
+ /** Firestore reports a missing database as NOT_FOUND; it reads as a missing schema, like SQL's 42P01. */
68
+ const missingDatabase = (error) => {
69
+ throw codeOf(error) === 5 ? Object.assign(new Error(error.message, { cause: error }), { code: "42P01" }) : error;
70
+ };
71
+ /**
72
+ * The key-value store over one Firestore collection of `{ pk, sk, row }`
73
+ * documents, queried through the composite indexes on (pk, sk) in both
74
+ * directions. A write is one transaction that reads only the documents its
75
+ * ops guard.
76
+ */
77
+ const createFirestoreStore = ({ firestore, collection, nativePageSize }) => {
78
+ const items = firestore.collection(collection);
79
+ const reference = (key) => items.doc(firestoreDocumentId(key));
571
80
  return {
572
- bundles: bundleMap(documents[0]),
573
- bundlePatches: patchMap(documents[1]),
574
- bundleEvents: /* @__PURE__ */ new Map(),
575
- bundleInstallations: /* @__PURE__ */ new Map(),
576
- channels: channelMap(documents[2]),
577
- apiKeys: apiKeyMap(documents[3]),
578
- releases: releaseMap(documents[4]),
579
- releaseCatalogs: releaseCatalogMap(documents[5])
580
- };
581
- };
582
- const loadFirebaseDatabaseSnapshot = async (collections) => {
583
- const [bundles, patches, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
584
- collections.bundles.get(),
585
- collections.bundlePatches.get(),
586
- collections.channels.get(),
587
- collections.apiKeys.get(),
588
- collections.releases.get(),
589
- collections.releaseCatalogs.get()
590
- ]);
591
- return toSnapshot([
592
- bundles,
593
- patches,
594
- channels,
595
- apiKeys,
596
- releases,
597
- releaseCatalogs
598
- ]);
599
- };
600
- const loadFirebaseTransactionSnapshot = async (transaction, collections) => {
601
- const [bundles, patches, channels, apiKeys, releases, releaseCatalogs] = await Promise.all([
602
- transaction.get(collections.bundles),
603
- transaction.get(collections.bundlePatches),
604
- transaction.get(collections.channels),
605
- transaction.get(collections.apiKeys),
606
- transaction.get(collections.releases),
607
- transaction.get(collections.releaseCatalogs)
608
- ]);
609
- return toSnapshot([
610
- bundles,
611
- patches,
612
- channels,
613
- apiKeys,
614
- releases,
615
- releaseCatalogs
616
- ]);
617
- };
618
- const persistCollection = ({ transaction, collection, before, after, documentId }) => {
619
- for (const [id, row] of before) if (!after.has(id)) transaction.delete(collection.doc(documentId(row)));
620
- for (const [id, row] of after) if (JSON.stringify(before.get(id)) !== JSON.stringify(row)) transaction.set(collection.doc(documentId(row)), row, { merge: true });
621
- };
622
- const persistFirebaseDatabaseSnapshot = ({ transaction, collections, before, after }) => {
623
- persistCollection({
624
- transaction,
625
- collection: collections.bundles,
626
- before: before.bundles,
627
- after: after.bundles,
628
- documentId: (row) => row.id
629
- });
630
- persistCollection({
631
- transaction,
632
- collection: collections.bundlePatches,
633
- before: before.bundlePatches,
634
- after: after.bundlePatches,
635
- documentId: (row) => row.id
636
- });
637
- persistCollection({
638
- transaction,
639
- collection: collections.channels,
640
- before: before.channels,
641
- after: after.channels,
642
- documentId: (row) => firebaseChannelDocumentId(row.name)
643
- });
644
- for (const [id] of before.channels) if (!after.channels.has(id)) transaction.delete(collections.settings.doc(firebaseChannelIdDocumentId(id)));
645
- 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);
646
- persistCollection({
647
- transaction,
648
- collection: collections.apiKeys,
649
- before: before.apiKeys,
650
- after: after.apiKeys,
651
- documentId: (row) => row.id
652
- });
653
- persistCollection({
654
- transaction,
655
- collection: collections.releases,
656
- before: before.releases,
657
- after: after.releases,
658
- documentId: (row) => row.id
659
- });
660
- persistCollection({
661
- transaction,
662
- collection: collections.releaseCatalogs,
663
- before: before.releaseCatalogs,
664
- after: after.releaseCatalogs,
665
- documentId: (row) => row.scope_key
666
- });
667
- };
668
- const migrateFirebaseDatabase = async (collections) => {
669
- const versionDocument = collections.settings.doc("database_adapter_version");
670
- const version = await versionDocument.get();
671
- const adapterVersion = version.data()?.version;
672
- if (adapterVersion === 4) return;
673
- if (version.exists) throw new FirebaseDatabaseAdapterVersionError(adapterVersion);
674
- if ((await Promise.all([
675
- collections.bundles.limit(1).get(),
676
- collections.bundlePatches.limit(1).get(),
677
- collections.channels.limit(1).get(),
678
- collections.releases.limit(1).get(),
679
- collections.releaseCatalogs.limit(1).get(),
680
- collections.bundleInstallations.limit(1).get(),
681
- collections.bundleEvents.limit(1).get()
682
- ])).some((snapshot) => !snapshot.empty)) throw new FirebaseDatabaseAdapterVersionError("v0");
683
- try {
684
- await versionDocument.create({ version: 4 });
685
- } catch (error) {
686
- if ((await versionDocument.get()).data()?.version !== 4) throw error;
687
- }
688
- };
689
- //#endregion
690
- //#region src/firebaseDatabase.ts
691
- const exactId = (input) => {
692
- if (input.where?.length !== 1) return void 0;
693
- const [condition] = input.where;
694
- return condition.field === "id" && (condition.operator === void 0 || condition.operator === "eq") && typeof condition.value === "string" ? condition.value : void 0;
695
- };
696
- const exactInstallId = (input) => {
697
- const condition = input.where?.find(({ field, operator }) => field === "install_id" && (operator === void 0 || operator === "eq"));
698
- return typeof condition?.value === "string" ? condition.value : void 0;
699
- };
700
- const firestoreOperator = (operator) => {
701
- switch (operator ?? "eq") {
702
- case "eq": return "==";
703
- case "ne": return "!=";
704
- case "gt": return ">";
705
- case "gte": return ">=";
706
- case "lt": return "<";
707
- case "lte": return "<=";
708
- case "in": return "in";
709
- case "not_in": return "not-in";
710
- default: return;
711
- }
712
- };
713
- const applyFirebaseWhere = (initial, where) => {
714
- let query = initial;
715
- for (const condition of where) {
716
- const operator = firestoreOperator(condition.operator);
717
- if (condition.connector === "OR" || operator === void 0) throw new FirebaseDatabaseConstraintError("query.unsupported");
718
- query = query.where(condition.field, operator, condition.value);
719
- }
720
- return query;
721
- };
722
- const firebaseDatabase = (config) => {
723
- const implementation = (() => {
724
- const app = getApps().length ? getApp() : initializeApp(config);
725
- const db = getFirestore(app);
726
- const collections = createFirebaseDatabaseCollections(db);
727
- let migration;
728
- const ensureMigrated = () => {
729
- migration ??= migrateFirebaseDatabase(collections).catch((error) => {
730
- migration = void 0;
731
- throw error;
732
- });
733
- return migration;
734
- };
735
- const mutate = async (operation) => {
736
- await ensureMigrated();
737
- return db.runTransaction(async (transaction) => {
738
- const before = await loadFirebaseTransactionSnapshot(transaction, collections);
739
- const after = cloneFirebaseDatabaseSnapshot(before);
740
- const result = await operation(createFirebaseDatabaseState(after));
741
- persistFirebaseDatabaseSnapshot({
742
- transaction,
743
- collections,
744
- before,
745
- after
746
- });
747
- return result;
748
- });
749
- };
750
- const read = async (operation) => {
751
- await ensureMigrated();
752
- const snapshot = await loadFirebaseDatabaseSnapshot(collections);
753
- return operation(createFirebaseDatabaseState(snapshot));
754
- };
755
- return {
756
- recordInsights: async ({ event, installation }) => {
757
- await ensureMigrated();
758
- await db.runTransaction(async (transaction) => {
759
- const eventReference = collections.bundleEvents.doc(event.id);
760
- const installationReference = collections.bundleInstallations.doc(firebaseInstallationDocumentId(installation.install_id));
761
- const [storedEvent, storedInstallation] = await transaction.getAll(eventReference, installationReference);
762
- if (storedEvent.exists) return;
763
- const current = storedInstallation.exists ? requireFirebaseDocumentKey("bundle_installations", storedInstallation.id, parseFirebaseInsightsInstallationRow(storedInstallation.data(), `bundle_installations/${storedInstallation.id}`)) : null;
764
- transaction.create(eventReference, event);
765
- if (current === null || installation.received_at_ms > current.received_at_ms || installation.received_at_ms === current.received_at_ms && compareInsightsText(installation.id, current.id) > 0) transaction.set(installationReference, installation);
766
- });
767
- },
768
- create: async (input) => {
769
- if (input.model !== "bundle_events" && input.model !== "bundle_installations") return mutate((database) => database.create(input));
770
- await ensureMigrated();
771
- return db.runTransaction(async (transaction) => {
772
- const collection = input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations;
773
- const documentId = input.model === "bundle_events" ? input.data.id : firebaseInstallationDocumentId(input.data.install_id);
774
- const reference = collection.doc(documentId);
775
- const document = await transaction.get(reference);
776
- if (document.exists) {
777
- const row = input.model === "bundle_events" ? requireFirebaseDocumentKey("bundle_events", document.id, parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)) : requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`));
778
- if (input.onConflict === "ignore") return row;
779
- throw new FirebaseDatabaseConstraintError(`${input.model}.id.unique`);
780
- }
781
- transaction.create(reference, input.data);
782
- return input.data;
783
- });
784
- },
785
- update: async (input) => {
786
- if (input.model !== "bundle_installations") return mutate((database) => database.update(input));
787
- const installId = exactInstallId(input);
788
- if (installId === void 0) return mutate((database) => database.update(input));
789
- await ensureMigrated();
790
- return db.runTransaction(async (transaction) => {
791
- const reference = collections.bundleInstallations.doc(firebaseInstallationDocumentId(installId));
792
- const document = await transaction.get(reference);
793
- if (!document.exists) return null;
794
- const current = requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`));
795
- if (!matchesFirebaseDatabaseWhere(current, input.where)) return null;
796
- const updated = {
797
- ...current,
798
- ...input.update
799
- };
800
- transaction.set(reference, updated, { merge: true });
801
- return updated;
802
- });
803
- },
804
- delete: (input) => mutate((database) => database.delete(input)),
805
- count: async (input) => {
806
- if (input.model !== "bundle_installations" && input.model !== "bundle_events") return read((database) => database.count(input));
807
- await ensureMigrated();
808
- let query = applyFirebaseWhere(input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations, input.where ?? []);
809
- if (input.model === "bundle_events") query = query.orderBy("received_at_ms", "desc").orderBy("id", "desc");
810
- return (await query.count().get()).data().count;
811
- },
812
- findOne: async (input) => {
813
- if (input.model === "bundle_installations") {
814
- const installId = exactInstallId(input);
815
- if (installId === void 0) return read((database) => database.findOne(input));
816
- await ensureMigrated();
817
- const document = await collections.bundleInstallations.doc(firebaseInstallationDocumentId(installId)).get();
818
- return document.exists ? requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`)) : null;
81
+ id: "firestore",
82
+ limits: FIRESTORE_LIMITS,
83
+ async get(keys) {
84
+ const found = /* @__PURE__ */ new Map();
85
+ const ids = [...new Set(keys.map(firestoreDocumentId))];
86
+ for (let at = 0; at < ids.length; at += GET_BATCH) {
87
+ const snapshots = await firestore.getAll(...ids.slice(at, at + GET_BATCH).map((id) => items.doc(id))).catch(missingDatabase);
88
+ for (const snapshot of snapshots) if (snapshot.exists) found.set(snapshot.id, toRow(snapshot.data()));
89
+ }
90
+ return keys.map((key) => found.get(firestoreDocumentId(key)) ?? null);
91
+ },
92
+ async query({ pk, gte, lt, order, limit, after }) {
93
+ let query = items.where("pk", "==", pk);
94
+ if (gte !== void 0) query = query.where("sk", ">=", gte);
95
+ if (lt !== void 0) query = query.where("sk", "<", lt);
96
+ query = query.orderBy("sk", order);
97
+ if (after !== void 0) query = query.startAfter(after);
98
+ const size = Math.min(limit, nativePageSize ?? limit);
99
+ const { docs } = await query.limit(size).get().catch(missingDatabase);
100
+ return {
101
+ items: docs.map((document) => ({
102
+ sk: document.get("sk"),
103
+ value: toRow(document.data())
104
+ })),
105
+ more: docs.length === size
106
+ };
107
+ },
108
+ async write(ops) {
109
+ const blind = ops.map((op) => op.type === "add" && (op.condition === void 0 || "exists" in op.condition && op.condition.exists));
110
+ const attempt = (reading) => firestore.runTransaction(async (transaction) => {
111
+ const read = ops.flatMap((op, position) => (op.condition !== void 0 || op.type === "add") && (reading || !blind[position]) ? [position] : []);
112
+ const snapshots = /* @__PURE__ */ new Map();
113
+ if (read.length > 0) {
114
+ const found = await transaction.getAll(...read.map((position) => reference(ops[position].key)));
115
+ read.forEach((position, at) => snapshots.set(position, found[at]));
819
116
  }
820
- const id = exactId(input);
821
- if (id === void 0) return read((database) => database.findOne(input));
822
- await ensureMigrated();
823
- switch (input.model) {
824
- case "bundles": {
825
- const document = await collections.bundles.doc(id).get();
826
- return document.exists ? requireFirebaseDocumentKey("bundles", document.id, parseFirebaseBundleRow(document.data(), `bundles/${document.id}`)) : null;
827
- }
828
- case "bundle_patches": {
829
- const document = await collections.bundlePatches.doc(id).get();
830
- return document.exists ? requireFirebaseDocumentKey("bundle_patches", document.id, parseFirebasePatchRow(document.data(), `bundle_patches/${document.id}`)) : null;
831
- }
832
- case "api_keys": {
833
- const document = await collections.apiKeys.doc(id).get();
834
- return document.exists ? requireFirebaseDocumentKey("api_keys", document.id, parseFirebaseApiKeyRow(document.data(), `api_keys/${document.id}`)) : null;
835
- }
836
- default: return read((database) => database.findOne(input));
117
+ const failed = read.find((position) => !holds(snapshots.get(position), ops[position].condition));
118
+ if (failed !== void 0) {
119
+ if (!reading && blind.slice(0, failed).includes(true)) return null;
120
+ return {
121
+ ok: false,
122
+ failedOp: failed
123
+ };
837
124
  }
838
- },
839
- findMany: async (input) => {
840
- if (input.model === "bundle_events" || input.model === "bundle_installations") {
841
- await ensureMigrated();
842
- const collection = input.model === "bundle_events" ? collections.bundleEvents : collections.bundleInstallations;
843
- let query = applyFirebaseWhere(collection, input.where ?? []);
844
- for (const order of input.orderBy ?? []) {
845
- if (order.nulls !== void 0) throw new FirebaseDatabaseConstraintError("query.unsupported");
846
- query = query.orderBy(order.field, order.direction);
125
+ for (const [position, op] of ops.entries()) {
126
+ const document = reference(op.key);
127
+ if (op.type === "put") transaction.set(document, toDocument(op.key, op.value, op.expiresAt));
128
+ else if (op.type === "delete") transaction.delete(document);
129
+ else if (op.type === "add") {
130
+ if (snapshots.get(position)?.exists === false) {
131
+ const created = { ...op.init };
132
+ for (const [name, delta] of Object.entries(op.by)) created[name] = Number(created[name] ?? 0) + delta;
133
+ transaction.set(document, toDocument(op.key, created, op.expiresAt));
134
+ } else {
135
+ const [[name, delta], ...more] = Object.entries(op.by);
136
+ const ttl = op.expiresAt === void 0 ? [] : [FIRESTORE_TTL_FIELD, Timestamp.fromMillis(op.expiresAt)];
137
+ transaction.update(document, new FieldPath("row", name), FieldValue.increment(delta), ...more.flatMap(([other, by]) => [new FieldPath("row", other), FieldValue.increment(by)]), ...ttl);
138
+ }
847
139
  }
848
- return (await query.offset(input.offset).limit(input.limit).get()).docs.map((document) => input.model === "bundle_events" ? requireFirebaseDocumentKey("bundle_events", document.id, parseFirebaseBundleEventRow(document.data(), `bundle_events/${document.id}`)) : requireFirebaseDocumentKey("bundle_installations", document.id, parseFirebaseInsightsInstallationRow(document.data(), `bundle_installations/${document.id}`)));
849
140
  }
850
- if (input.model === "channels") {
851
- await ensureMigrated();
852
- return queryFirebaseDatabaseRows(await loadFirebaseChannels(collections), input);
141
+ return { ok: true };
142
+ }, { maxAttempts: 5 });
143
+ try {
144
+ try {
145
+ const result = await attempt(false);
146
+ if (result !== null) return result;
147
+ } catch (error) {
148
+ if (codeOf(error) !== 5 || !blind.includes(true)) throw error;
853
149
  }
854
- return read((database) => database.findMany(input));
855
- },
856
- insertChannel: async (input) => {
857
- await ensureMigrated();
858
- return db.runTransaction(async (transaction) => {
859
- const reference = collections.channels.doc(firebaseChannelDocumentId(input.row.name));
860
- const idReference = collections.settings.doc(firebaseChannelIdDocumentId(input.row.id));
861
- const [document, idDocument] = await transaction.getAll(reference, idReference);
862
- if (idDocument.exists) {
863
- const row = parseFirebaseChannelRow(idDocument.data(), `${FIREBASE_V1_COLLECTION_NAMES.settings}/${idDocument.id}`);
864
- if (row.id !== input.row.id || row.name !== input.row.name) throw new FirebaseDatabaseConstraintError("channels.id.registry");
865
- }
866
- if (idDocument.exists && !document.exists) throw new FirebaseDatabaseConstraintError("channels.id.unique");
867
- if (document.exists) {
868
- const row = parseFirebaseChannelRow(document.data(), `channels/${document.id}`);
869
- if (document.id !== firebaseChannelDocumentId(row.name)) throw new FirebaseDatabaseConstraintError("channels.name.document-key");
870
- return {
871
- row,
872
- inserted: false
873
- };
874
- }
875
- transaction.create(reference, input.row);
876
- transaction.create(idReference, input.row);
877
- return {
878
- row: input.row,
879
- inserted: true
880
- };
881
- });
882
- },
883
- deleteChannel: async ({ id }) => {
884
- await ensureMigrated();
885
- return db.runTransaction(async (transaction) => {
886
- const idReference = collections.settings.doc(firebaseChannelIdDocumentId(id));
887
- const idDocument = await transaction.get(idReference);
888
- if (!idDocument.exists) return {
889
- deleted: false,
890
- reason: "not_found"
891
- };
892
- const row = parseFirebaseChannelRow(idDocument.data(), `${FIREBASE_V1_COLLECTION_NAMES.settings}/${idDocument.id}`);
893
- const reference = collections.channels.doc(firebaseChannelDocumentId(row.name));
894
- if (!(await transaction.get(reference)).exists || row.id !== id) throw new FirebaseDatabaseConstraintError("channels.id.registry");
895
- if (!(await transaction.get(collections.releases.where("channel_id", "==", id).limit(1))).empty) return {
896
- deleted: false,
897
- reason: "not_empty"
898
- };
899
- transaction.delete(reference);
900
- transaction.delete(idReference);
901
- return { deleted: true };
902
- });
903
- },
904
- transaction: (callback) => mutate(callback)
905
- };
906
- })();
907
- const adapter = createDatabasePluginAdapter("firebaseDatabase", implementation);
908
- return createDatabasePlugin({
909
- name: "firebaseDatabase",
910
- models: adapter.models,
911
- commit: adapter.commit
912
- });
150
+ return await attempt(true);
151
+ } catch (error) {
152
+ if (TRANSIENT.has(codeOf(error))) return {
153
+ ok: false,
154
+ retry: true
155
+ };
156
+ return missingDatabase(error);
157
+ }
158
+ },
159
+ /** A plain batch, which reads nothing, 500 deletes at a time. */
160
+ async deleteConsumed(keys) {
161
+ for (let at = 0; at < keys.length; at += FIRESTORE_LIMITS.items) {
162
+ const batch = firestore.batch();
163
+ for (const key of keys.slice(at, at + FIRESTORE_LIMITS.items)) batch.delete(reference(key));
164
+ await batch.commit().catch(missingDatabase);
165
+ }
166
+ },
167
+ async dispose() {}
168
+ };
913
169
  };
914
170
  //#endregion
171
+ //#region src/firebaseDatabase.ts
172
+ const adapterOf = ({ collection = FIREBASE_V1_COLLECTION, aggregateBatching: _, ...appOptions }) => {
173
+ const app = getApps().length ? getApp() : initializeApp(appOptions);
174
+ return createKvAdapter({ store: createFirestoreStore({
175
+ firestore: getFirestore(app),
176
+ collection
177
+ }) });
178
+ };
179
+ /**
180
+ * Writes the schema settings of core and `plugins`, the plugins the server
181
+ * runs, which the database checks before its first read. Firestore needs no
182
+ * other setup beyond `firestore.indexes.json`; `hot-updater init` runs it
183
+ * after deploying the indexes.
184
+ */
185
+ const migrateFirebaseDatabase = (config, plugins = []) => migrateCoreSchema(adapterOf(config), "firebaseDatabase", plugins);
186
+ /** Hot Updater's database in one Firestore collection, through the storage engine. */
187
+ const firebaseDatabase = (config) => createEngineDatabase({
188
+ name: "firebaseDatabase",
189
+ adapter: adapterOf(config),
190
+ aggregateBatching: config.aggregateBatching ?? {}
191
+ });
192
+ //#endregion
915
193
  //#region src/firebaseStorage.ts
916
194
  const firebaseStorage = (config) => {
917
195
  const app = getApps().length ? getApp() : initializeApp(config);
@@ -922,7 +200,7 @@ const firebaseStorage = (config) => {
922
200
  if (parsed.bucket !== config.storageBucket) throw new Error(`Bucket name mismatch: expected "${config.storageBucket}", but found "${parsed.bucket}".`);
923
201
  return parsed;
924
202
  };
925
- return createStoragePlugin({
203
+ return createStorageAdapter({
926
204
  name: "firebaseStorage",
927
205
  protocol: "gs",
928
206
  async put({ key, body, contentType }) {
@@ -982,4 +260,11 @@ const firebaseStorage = (config) => {
982
260
  });
983
261
  };
984
262
  //#endregion
985
- export { firebaseDatabase, firebaseStorage };
263
+ //#region src/plugins.ts
264
+ /**
265
+ * The plugins the managed Firebase server runs: Insights, and API keys on every
266
+ * client route. Its Cloud Function imports this list.
267
+ */
268
+ const plugins = [insights(), apiKeys()];
269
+ //#endregion
270
+ export { firebaseDatabase, firebaseStorage, migrateFirebaseDatabase, plugins };