@hot-updater/firebase 1.2.0-rc.12 → 1.2.0-rc.13

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.
@@ -2102,7 +2102,10 @@ const CHANNEL_ID = {
2102
2102
  * every database's migrator creates missing tables and then records the
2103
2103
  * version, and none of them changes an existing table, so changing one
2104
2104
  * needs a migration step of its own first. The schema fence answers 503 on
2105
- * a database whose row differs, until it runs.
2105
+ * a database whose row differs, until it runs. Before a version is released,
2106
+ * its tables change in place in its own migration, and a database one of its
2107
+ * prereleases migrated takes the same change by hand: the fence compares this
2108
+ * row, not columns, so nothing reports it.
2106
2109
  */
2107
2110
  const HOT_UPDATER_SCHEMA_VERSION = "1.2.0";
2108
2111
  const bundles = defineTable({
@@ -2366,6 +2369,10 @@ const channels = defineTable({
2366
2369
  * or `versionCode`. `submitted_to`, `submitted_at_ms` and
2367
2370
  * `submitted_version` record the store track a build went to, when, and
2368
2371
  * the store's label of the build; they stay null for a build no store took.
2372
+ * `store_build` is true on the build a person or a job says the store sells,
2373
+ * and null on every other: deploys patch from it over the scope's newest build.
2374
+ * It is never false, so `byStoreBuild` holds one entry per scope at most, on
2375
+ * every store, and an unmarked row, older than the column or not, holds none.
2369
2376
  */
2370
2377
  const nativeBuilds = defineTable({
2371
2378
  id: {
@@ -2442,6 +2449,10 @@ const nativeBuilds = defineTable({
2442
2449
  type: "string",
2443
2450
  maxLength: 255,
2444
2451
  required: false
2452
+ },
2453
+ store_build: {
2454
+ type: "boolean",
2455
+ required: false
2445
2456
  }
2446
2457
  }, {
2447
2458
  key: ["id"],
@@ -2454,6 +2465,11 @@ const nativeBuilds = defineTable({
2454
2465
  eq: ["scope_key", "build_key"],
2455
2466
  sort: ["id"]
2456
2467
  },
2468
+ byStoreBuild: {
2469
+ eq: ["scope_key", "store_build"],
2470
+ sort: [],
2471
+ unique: true
2472
+ },
2457
2473
  byChannel: {
2458
2474
  eq: ["channel_id"],
2459
2475
  sort: ["id"],
@@ -2674,15 +2690,8 @@ const LEASE_TABLE = "aggregate_lease";
2674
2690
  const LEASE = { id: "compaction" };
2675
2691
  /** How long a compaction holds the lease before another process may take it. */
2676
2692
  const LEASE_MS = 3e4;
2677
- /**
2678
- * Default windows: log mode compacts every 60 seconds, since reads compact
2679
- * first; memory mode flushes every 15, since its window is what a crash
2680
- * loses.
2681
- */
2682
- const WINDOW_MS$1 = {
2683
- log: 6e4,
2684
- memory: 15e3
2685
- };
2693
+ /** A commit compacts once this has passed since the last compaction. */
2694
+ const WINDOW_MS$1 = 6e4;
2686
2695
  /** Log rows a compaction reads from each log table for one group. */
2687
2696
  const LOG_PAGE = 200;
2688
2697
  /**
@@ -2696,8 +2705,6 @@ const GROUPS = {
2696
2705
  };
2697
2706
  /** Tries per write after a conflict or a transient error. */
2698
2707
  const ATTEMPTS = 5;
2699
- /** Aggregate rows a memory buffer holds before a commit waits for a flush. */
2700
- const MAX_BUFFERED = 1e4;
2701
2708
  const log = defineTable({
2702
2709
  id: {
2703
2710
  type: "string",
@@ -2747,7 +2754,7 @@ const valuesOf = ({ deltas, sketches }) => ({
2747
2754
  /**
2748
2755
  * Merges `values` into the change for `key`'s identity as `tx.aggregate`
2749
2756
  * does: deltas add up and sketches merge. One constant `shardBy` keys each
2750
- * identity to one entry; the write picks the shard.
2757
+ * identity to one entry; `compile` writes it to shard 0.
2751
2758
  */
2752
2759
  const merge = (into, model, key, values) => recordAggregate(into, model, Object.fromEntries(shapeOf(model).key.map((field, at) => [field, key[at]])), values, "");
2753
2760
  const mergeAll = (into, changes) => {
@@ -2781,7 +2788,7 @@ const increment = ({ model, deltas }, key) => Object.values(deltas).some((delta)
2781
2788
  /**
2782
2789
  * The guarded write that merges `change` into `current`: insert, patch, or
2783
2790
  * delete at zero. Unlike a transaction's, a gauge may go below zero here: a
2784
- * shard row holds one writer's net changes, and reads sum every shard.
2791
+ * group can hold a loss without the gain before it, and reads sum every shard.
2785
2792
  */
2786
2793
  const rewrite = (change, key, current) => {
2787
2794
  const { model } = change;
@@ -2819,12 +2826,12 @@ const rewrite = (change, key, current) => {
2819
2826
  };
2820
2827
  };
2821
2828
  /**
2822
- * The write for each change on its shard: a blind increment that creates
2823
- * the row for counters, and a guarded rewrite for gauges and sketches, whose
2824
- * rows are read in one batch per table.
2829
+ * The write for each change on shard 0, since one compaction runs at a time:
2830
+ * a blind increment that creates the row for counters, and a guarded rewrite
2831
+ * for gauges and sketches, whose rows are read in one batch per table.
2825
2832
  */
2826
- const compile = async (adapter, changes, shard) => {
2827
- const keys = changes.map(({ model, key }) => [...key.slice(0, -1), shard(model)]);
2833
+ const compile = async (adapter, changes) => {
2834
+ const keys = changes.map(({ key }) => [...key.slice(0, -1), 0]);
2828
2835
  const reads = /* @__PURE__ */ new Map();
2829
2836
  changes.forEach((change, at) => {
2830
2837
  if (rewrites(change)) reads.set(change.model, [...reads.get(change.model) ?? [], at]);
@@ -2878,14 +2885,13 @@ const encode$1 = async (changes) => {
2878
2885
  return zipped !== void 0 && zipped.length < json.length ? `z${zipped}` : json;
2879
2886
  };
2880
2887
  /**
2881
- * Batches changes to aggregates declared `batched`. A transaction records
2882
- * them without writing their rows; once it commits, "memory" mode keeps
2883
- * them in this process until its timer flushes them, and "log" mode has
2884
- * written them as one log row in the same atomic write, which a compaction
2885
- * later merges into the aggregates and deletes, atomically again, so each
2886
- * applies exactly once. One lease row keeps compactions one at a time.
2888
+ * Batches changes to aggregates declared `batched`. A transaction writes
2889
+ * them as one log row in its own atomic write, in place of their rows; a
2890
+ * compaction later merges log rows into the aggregates and deletes them,
2891
+ * atomically again, so each applies exactly once. One lease row keeps
2892
+ * compactions one at a time.
2887
2893
  */
2888
- const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "log", windowMs = WINDOW_MS$1[mode] }, now }) => {
2894
+ const createAggregateBatches = ({ engine, adapter, schema, batching: { windowMs = WINDOW_MS$1 }, now }) => {
2889
2895
  if (!schema.models.has(LEASE_TABLE)) throw new DatabaseTransactionError("An engine that batches aggregates needs aggregateBatchingModule in its schema.");
2890
2896
  const holder = crypto.randomUUID().replaceAll("-", "");
2891
2897
  const table = (name) => schema.models.get(name).table;
@@ -2913,68 +2919,6 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
2913
2919
  }
2914
2920
  return changes;
2915
2921
  };
2916
- /**
2917
- * Writes changes in atomic chunks within `fits`, and returns the ones it
2918
- * could not apply. A write whose outcome is unknown is dropped, never
2919
- * resent, so no change applies twice.
2920
- */
2921
- const apply = async (changes, shard) => {
2922
- let rest = changes;
2923
- let ops = [];
2924
- let read = true;
2925
- for (let failures = 0; rest.length > 0 && failures < ATTEMPTS;) try {
2926
- if (read) ops = await compile(adapter, rest, shard);
2927
- let size = rest.length;
2928
- const chunk = () => ops.slice(0, size).filter(isOp);
2929
- while (size > 1 && !adapter.fits(chunk())) size = Math.ceil(size / 2);
2930
- let result = { ok: true };
2931
- if (chunk().length > 0) result = await adapter.write(chunk()).catch((error) => {
2932
- warn("flush")(error);
2933
- return { ok: true };
2934
- });
2935
- read = !result.ok && !("retry" in result);
2936
- if (result.ok) {
2937
- rest = rest.slice(size);
2938
- ops = ops.slice(size);
2939
- failures = 0;
2940
- } else {
2941
- failures += 1;
2942
- if (!read) await sleep(failures);
2943
- }
2944
- } catch (error) {
2945
- warn("flush")(error);
2946
- return rest;
2947
- }
2948
- return rest;
2949
- };
2950
- let buffer = /* @__PURE__ */ new Map();
2951
- let timer;
2952
- let flushing;
2953
- const shards = /* @__PURE__ */ new Map();
2954
- /** Each process writes its own shard, so flushes of two processes rarely meet. */
2955
- const processShard = (model) => {
2956
- if (!shards.has(model)) shards.set(model, Math.floor(Math.random() * shapeOf(model).shards));
2957
- return shards.get(model);
2958
- };
2959
- const schedule = () => {
2960
- if (timer !== void 0 || buffer.size === 0) return;
2961
- timer = setTimeout(() => {
2962
- timer = void 0;
2963
- flushBuffer();
2964
- }, windowMs);
2965
- timer.unref?.();
2966
- };
2967
- /** Never rejects: what it cannot apply goes back into the buffer. */
2968
- const flushBuffer = () => flushing ??= (async () => {
2969
- clearTimeout(timer);
2970
- timer = void 0;
2971
- const pending = [...buffer.values()];
2972
- buffer = /* @__PURE__ */ new Map();
2973
- mergeAll(buffer, await apply(pending, processShard));
2974
- })().catch(warn("flush")).finally(() => {
2975
- flushing = void 0;
2976
- schedule();
2977
- });
2978
2922
  let nextDue = 0;
2979
2923
  let compacting;
2980
2924
  /** Log rows this process read, by `[log table's index, id]`, for the deletes after a group. */
@@ -3109,7 +3053,7 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3109
3053
  more
3110
3054
  };
3111
3055
  };
3112
- const rowsFor = async (changes) => (await compile(adapter, changes, () => 0)).filter(isOp);
3056
+ const rowsFor = async (changes) => (await compile(adapter, changes)).filter(isOp);
3113
3057
  /**
3114
3058
  * Applies the oldest pending log rows whose merged changes fit in one
3115
3059
  * atomic write with the lease, which lists them as applied; the rows are
@@ -3203,20 +3147,6 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3203
3147
  }))).then((pages) => pages.some((page) => page.rows.length > 0)).finally(() => {
3204
3148
  checking = void 0;
3205
3149
  });
3206
- /** Takes a committed transaction's changes; never rejects, since the transaction already committed. */
3207
- const committed = async (changes) => {
3208
- if (mode === "log") {
3209
- if (now() >= nextDue) await compact(false, GROUPS.commit);
3210
- return;
3211
- }
3212
- mergeAll(buffer, changes.values());
3213
- schedule();
3214
- if (buffer.size >= MAX_BUFFERED) await flushBuffer();
3215
- };
3216
- const flush = async () => {
3217
- await flushBuffer();
3218
- if (mode === "log" && await hasPending()) await compact(true, GROUPS.flush);
3219
- };
3220
3150
  return {
3221
3151
  async transaction(fn) {
3222
3152
  let changes = /* @__PURE__ */ new Map();
@@ -3242,7 +3172,7 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3242
3172
  aggregate
3243
3173
  });
3244
3174
  if (failure) throw failure.error;
3245
- if (mode === "log" && attempt.size > 0) {
3175
+ if (attempt.size > 0) {
3246
3176
  const id = `${now().toString(36).padStart(9, "0")}${holder.slice(0, 8)}${crypto.randomUUID().slice(0, 8)}`;
3247
3177
  const shard = Math.floor(Math.random() * LOG_TABLES.length);
3248
3178
  const merged = mergeAll(/* @__PURE__ */ new Map(), attempt.values());
@@ -3256,24 +3186,18 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3256
3186
  open = false;
3257
3187
  }
3258
3188
  });
3259
- if (changes.size > 0) await committed(changes);
3189
+ if (changes.size > 0 && now() >= nextDue) await compact(false, GROUPS.commit);
3260
3190
  return result;
3261
3191
  },
3262
3192
  async beforeRead(name) {
3263
3193
  if (!isBatched(schema.models.get(name))) return;
3264
- if (buffer.size > 0 || flushing !== void 0) await flushBuffer();
3265
- if (mode === "log") {
3266
- if (await hasPending().catch((error) => {
3267
- warn("read")(error);
3268
- return false;
3269
- })) await compact(true, GROUPS.read);
3270
- }
3194
+ if (await hasPending().catch((error) => {
3195
+ warn("read")(error);
3196
+ return false;
3197
+ })) await compact(true, GROUPS.read);
3271
3198
  },
3272
- flush,
3273
- async dispose() {
3274
- clearTimeout(timer);
3275
- timer = void 0;
3276
- await flush();
3199
+ async flush() {
3200
+ if (await hasPending()) await compact(true, GROUPS.flush);
3277
3201
  }
3278
3202
  };
3279
3203
  };
@@ -4302,13 +4226,10 @@ const createDatabaseEngine = ({ batching, now = Date.now, meter, ...options }) =
4302
4226
  };
4303
4227
  return {
4304
4228
  ...engine,
4305
- /** Applies batched aggregate changes still pending: this process's buffer and the log. */
4229
+ /** Applies the batched aggregate changes still in the log. */
4306
4230
  flush: async () => {
4307
4231
  await batches?.flush();
4308
4232
  },
4309
- dispose: async () => {
4310
- await batches?.dispose();
4311
- },
4312
4233
  database(module) {
4313
4234
  const name = (model) => module.namespace ? `${module.namespace}_${model}` : model;
4314
4235
  return {
@@ -4389,8 +4310,7 @@ const createEngine = (database, { plugins = [], now = Date.now } = {}) => {
4389
4310
  if (module === void 0) throw new HotUpdaterConfigError(`Plugin "${plugin.id}" is not one of this engine's plugins.`);
4390
4311
  return engine.database(module);
4391
4312
  },
4392
- flush: () => engine.flush(),
4393
- dispose: () => engine.dispose()
4313
+ flush: () => engine.flush()
4394
4314
  };
4395
4315
  };
4396
4316
  //#endregion
@@ -4683,7 +4603,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4683
4603
  };
4684
4604
  //#endregion
4685
4605
  //#region ../../packages/server/dist/version.mjs
4686
- const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.12";
4606
+ const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.13";
4687
4607
  //#endregion
4688
4608
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4689
4609
  /**
@@ -5888,8 +5808,22 @@ const pick = (row) => ({
5888
5808
  build_number: row.build_number ?? null,
5889
5809
  submitted_to: row.submitted_to ?? null,
5890
5810
  submitted_at_ms: row.submitted_at_ms ?? null,
5891
- submitted_version: row.submitted_version ?? null
5811
+ submitted_version: row.submitted_version ?? null,
5812
+ store_build: row.store_build === true ? true : null
5892
5813
  });
5814
+ /**
5815
+ * The channels only sandbox builds register on, which no store sells: the
5816
+ * registry's convention, as `@hot-updater/protocol/native-artifact` checks it
5817
+ * for a build's artifact.
5818
+ */
5819
+ const SANDBOX_CHANNEL_PREFIX = "sandbox-";
5820
+ /**
5821
+ * How many older builds of a scope one read takes, looking for one older than
5822
+ * a bundle when a build made after the bundle is the newest.
5823
+ */
5824
+ const OLDER_BUILDS_PAGE = 20;
5825
+ /** How many times a mark runs again after losing its scope's mark to another. */
5826
+ const MARK_ATTEMPTS = 8;
5893
5827
  /** The store track each platform's builds are submitted to. */
5894
5828
  const SUBMISSION_DESTINATIONS = {
5895
5829
  android: "play-internal",
@@ -5966,7 +5900,8 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
5966
5900
  build_number: registration.buildNumber ?? null,
5967
5901
  submitted_to: null,
5968
5902
  submitted_at_ms: null,
5969
- submitted_version: null
5903
+ submitted_version: null,
5904
+ store_build: null
5970
5905
  };
5971
5906
  for (let attempt = 1;; attempt += 1) {
5972
5907
  await insertChannel(db, {
@@ -6004,21 +5939,45 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
6004
5939
  const scopeKey = nativeBuildScopeKey(scope);
6005
5940
  const buildKey = options?.buildKey;
6006
5941
  if (buildKey !== void 0 && !BUILD_KEY.test(buildKey)) throw new NativeBuildInputError("Invalid native build key: it must be nbk1: and a SHA-256.");
6007
- const { rows } = buildKey === void 0 ? await db.findMany("native_builds", {
6008
- index: "byScope",
6009
- where: { scope_key: scopeKey },
6010
- order: "desc",
6011
- limit: 1
6012
- }) : await db.findMany("native_builds", {
6013
- index: "byBuildKey",
6014
- where: {
5942
+ if (options?.storeBuild) {
5943
+ const marked = await db.findOne("native_builds", {
6015
5944
  scope_key: scopeKey,
6016
- build_key: buildKey
6017
- },
6018
- order: "desc",
6019
- limit: 1
6020
- });
6021
- return rows[0] === void 0 ? null : pick(rows[0]);
5945
+ store_build: true
5946
+ });
5947
+ return marked === null ? null : pick(marked);
5948
+ }
5949
+ const read = (limit, before) => {
5950
+ const page = {
5951
+ order: "desc",
5952
+ limit,
5953
+ ...before === void 0 ? {} : { range: { lt: before } }
5954
+ };
5955
+ return buildKey === void 0 ? db.findMany("native_builds", {
5956
+ index: "byScope",
5957
+ where: { scope_key: scopeKey },
5958
+ ...page
5959
+ }) : db.findMany("native_builds", {
5960
+ index: "byBuildKey",
5961
+ where: {
5962
+ scope_key: scopeKey,
5963
+ build_key: buildKey
5964
+ },
5965
+ ...page
5966
+ });
5967
+ };
5968
+ const { rows } = await read(1);
5969
+ const newest = rows[0];
5970
+ if (newest === void 0) return null;
5971
+ const bundleId = options?.builtInOlderThan;
5972
+ if (bundleId === void 0 || newest.min_bundle_id < bundleId) return pick(newest);
5973
+ for (let before = newest.id;;) {
5974
+ const { rows: older } = await read(OLDER_BUILDS_PAGE, before);
5975
+ const usable = older.find(({ min_bundle_id }) => min_bundle_id < bundleId);
5976
+ if (usable !== void 0) return pick(usable);
5977
+ const last = older.at(-1);
5978
+ if (older.length < OLDER_BUILDS_PAGE || last === void 0) return null;
5979
+ before = last.id;
5980
+ }
6022
5981
  },
6023
5982
  async recordNativeBuildSubmission(id, { destination, version }) {
6024
5983
  if (version.trim().length === 0 || version.length > SUBMITTED_VERSION_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build submission: version must be the store's label of the build, up to ${SUBMITTED_VERSION_MAX_LENGTH} characters.`);
@@ -6039,6 +5998,49 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
6039
5998
  };
6040
5999
  });
6041
6000
  },
6001
+ async markNativeBuild(id, { storeBuild }) {
6002
+ for (let attempt = 1;; attempt += 1) try {
6003
+ return await db.transaction(async (tx) => {
6004
+ const row = await tx.findOne("native_builds", { id });
6005
+ if (row === null) return null;
6006
+ const build = pick(row);
6007
+ if (!storeBuild) {
6008
+ if (build.store_build !== null) tx.update("native_builds", row, { store_build: null });
6009
+ return {
6010
+ nativeBuild: {
6011
+ ...build,
6012
+ store_build: null
6013
+ },
6014
+ previous: null
6015
+ };
6016
+ }
6017
+ if (decodeChannelKey(build.channel_id.slice(8)).startsWith(SANDBOX_CHANNEL_PREFIX)) throw new NativeBuildInputError("A build on a sandbox channel can't be the store build.");
6018
+ if (build.store_build !== null) return {
6019
+ nativeBuild: build,
6020
+ previous: null
6021
+ };
6022
+ const holder = await tx.findOne("native_builds", {
6023
+ scope_key: build.scope_key,
6024
+ store_build: true
6025
+ });
6026
+ if (holder !== null) tx.update("native_builds", holder, { store_build: null });
6027
+ tx.update("native_builds", row, { store_build: true });
6028
+ return {
6029
+ nativeBuild: {
6030
+ ...build,
6031
+ store_build: true
6032
+ },
6033
+ previous: holder === null ? null : {
6034
+ ...pick(holder),
6035
+ store_build: null
6036
+ }
6037
+ };
6038
+ });
6039
+ } catch (error) {
6040
+ if (attempt < MARK_ATTEMPTS && error instanceof DatabaseConstraintError && error.reason === "unique") continue;
6041
+ throw error;
6042
+ }
6043
+ },
6042
6044
  async deleteNativeBuild(id) {
6043
6045
  return db.transaction(async (tx) => {
6044
6046
  const row = await tx.findOne("native_builds", { id });
@@ -7121,7 +7123,13 @@ const createAdminRouteHandlers = () => ({
7121
7123
  const url = new URL(request.url);
7122
7124
  const scope = nativeBuildScopeOf(url);
7123
7125
  const buildKey = url.searchParams.get("buildKey");
7124
- return json$2({ data: await refusingInvalid(() => buildKey === null ? core.findNativeBuild(scope) : core.findNativeBuild(scope, { buildKey })) });
7126
+ const builtInOlderThan = url.searchParams.get("builtInOlderThan");
7127
+ const options = {
7128
+ ...buildKey === null ? {} : { buildKey },
7129
+ ...url.searchParams.get("storeBuild") === "true" ? { storeBuild: true } : {},
7130
+ ...builtInOlderThan === null ? {} : { builtInOlderThan }
7131
+ };
7132
+ return json$2({ data: await refusingInvalid(() => Object.keys(options).length === 0 ? core.findNativeBuild(scope) : core.findNativeBuild(scope, options)) });
7125
7133
  }),
7126
7134
  getNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.getNativeBuild(requireRouteParam(params, "id")) })),
7127
7135
  listNativeBuilds: onCore(async (core, request) => {
@@ -7142,6 +7150,12 @@ const createAdminRouteHandlers = () => ({
7142
7150
  };
7143
7151
  return json$2({ data: await refusingInvalid(() => core.recordNativeBuildSubmission(requireRouteParam(params, "id"), submission)) });
7144
7152
  }),
7153
+ markNativeBuild: onCore(async (core, request, params) => {
7154
+ const input = await body(request);
7155
+ if (!isRecord$5(input) || typeof input.storeBuild !== "boolean") throw new HandlerBadRequestError("A native build's store build mark needs storeBuild, true or false.");
7156
+ const mark = { storeBuild: input.storeBuild };
7157
+ return json$2({ data: await refusingInvalid(() => core.markNativeBuild(requireRouteParam(params, "id"), mark)) });
7158
+ }),
7145
7159
  deleteNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.deleteNativeBuild(requireRouteParam(params, "id")) })),
7146
7160
  deleteBundles: onCore(async (core, request) => {
7147
7161
  const { ids } = await body(request);
@@ -7287,6 +7301,11 @@ const ADMIN_ROUTES = [
7287
7301
  path: "/native-builds/:id/submission",
7288
7302
  handler: "recordNativeBuildSubmission"
7289
7303
  },
7304
+ {
7305
+ method: "POST",
7306
+ path: "/native-builds/:id/store-build",
7307
+ handler: "markNativeBuild"
7308
+ },
7290
7309
  {
7291
7310
  method: "DELETE",
7292
7311
  path: "/native-builds/:id",
package/dist/index.cjs CHANGED
@@ -392,51 +392,62 @@ const crc32c = (bytes) => {
392
392
  /** The version IDs the secret's annotation lists. */
393
393
  const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
394
394
  const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
395
- /** The widest line a message has: the CLI prints no wider. */
396
- const LINE_WIDTH = 76;
397
- /** `text`, such as Google's own message, in lines that fit, broken at spaces. */
398
- const wrap = (text) => {
399
- const lines = [];
400
- let line = "";
401
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
402
- lines.push(line);
403
- line = word;
404
- } else line = line ? `${line} ${word}` : word;
405
- if (line) lines.push(line);
406
- return lines;
407
- };
408
395
  /** An error of one line each, for a terminal. */
409
396
  const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
410
- const hint = (response, permission) => {
397
+ /**
398
+ * The one step that can fix the credentials in use, by the order they're
399
+ * tried: signing in with gcloud changes nothing while a variable comes first.
400
+ */
401
+ const signInHint = (credentials) => {
402
+ if (credentials) return [`Check the credentials passed to ${ADAPTER_NAME}().`];
403
+ const variable = ACCESS_TOKEN_ENV_NAMES.find((name) => process.env[name]?.trim()) ?? (process.env["GOOGLE_APPLICATION_CREDENTIALS"]?.trim() ? "GOOGLE_APPLICATION_CREDENTIALS" : void 0);
404
+ if (variable) return [`Fix or unset ${variable}.`];
405
+ return process.env["CI"] ? ["Use google-github-actions/auth."] : ["Sign in:", "gcloud auth application-default login"];
406
+ };
407
+ const hint = (response, permission, signIn) => {
411
408
  const { status, error } = response;
412
409
  if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return ["Enable the Secret Manager API in the project:", "gcloud services enable secretmanager.googleapis.com --project <project>"];
413
- if (status === 401) return [
414
- "Google rejected the access token.",
415
- "Sign in again with gcloud auth application-default login.",
416
- "In CI, check the job's Workload Identity Federation."
417
- ];
418
- if (status === 403) return wrap(`The Google identity needs ${permission}.`);
410
+ if (status === 401) return ["Google rejected the access token.", ...signIn];
411
+ if (status === 403) return [`The identity needs ${permission}.`];
419
412
  if (status === 429) return ["Secret Manager's quota is exhausted: try again later."];
420
413
  return [];
421
414
  };
422
415
  /**
423
- * An error for a failed request on `resource`, a line each: the HTTP
424
- * status, the resource, Google's status and message, which name resources
425
- * but never a value, `notes`, and what to fix.
416
+ * An error for a failed request on `resource`, a line each: the HTTP status,
417
+ * the resource, `notes`, and what to fix, or Google's own status and message,
418
+ * which name resources but never a value, when nothing here says what to fix.
426
419
  */
427
- const requestError = (action, resource, response, permission, notes = []) => {
420
+ const requestErrorOf = (signIn, action, resource, response, permission, notes = []) => {
428
421
  const { status, message } = response.error;
429
- const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
422
+ const fix = hint(response, permission, signIn);
423
+ if (response.status === 401 && notes.length === 0) return failure(fix);
430
424
  return failure([
431
425
  `${ADAPTER_NAME} couldn't ${action} (${response.status}).`,
432
426
  resourceLine(resource),
433
- ...wrap(detail),
434
427
  ...notes,
435
- ...hint(response, permission)
428
+ ...fix.length > 0 ? fix : [[status, message?.slice(0, 300)].filter(Boolean).join(": ")]
436
429
  ]);
437
430
  };
438
431
  /** The line that names a secret: its resource name is a line's worth. */
439
432
  const resourceLine = (secret) => `Secret: ${secret}`;
433
+ let metadataWarningFiltered = false;
434
+ /**
435
+ * Drops the one warning `gcp-metadata` emits when its probe for a Google
436
+ * runtime fails oddly: credentials are about to be reported as missing, and
437
+ * the warning has nothing to act on. It stays filtered for the process, as
438
+ * the library caches the probe; every other warning passes through.
439
+ * `METADATA_SERVER_DETECTION=none` would also silence it, but skips the probe,
440
+ * and with it the credentials of a Google runtime.
441
+ */
442
+ const filterMetadataWarning = () => {
443
+ if (metadataWarningFiltered) return;
444
+ metadataWarningFiltered = true;
445
+ const emitWarning = process.emitWarning;
446
+ process.emitWarning = ((warning, ...rest) => {
447
+ if (rest[0] === "MetadataLookupWarning") return;
448
+ Reflect.apply(emitWarning, process, [warning, ...rest]);
449
+ });
450
+ };
440
451
  /** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
441
452
  const mask = async (value) => {
442
453
  const { registerSecretValue } = await import("@hot-updater/cli-tools");
@@ -472,17 +483,11 @@ const createAccessToken = (credentials, projectId) => {
472
483
  throw error;
473
484
  });
474
485
  const client = await auth;
486
+ filterMetadataWarning();
475
487
  try {
476
488
  return await client.getAccessToken() ?? void 0;
477
489
  } catch (error) {
478
- throw failure([
479
- `${ADAPTER_NAME} found no Google credentials.`,
480
- ...wrap(error instanceof Error ? error.message.split("\n")[0] : String(error)),
481
- "On your machine, run gcloud auth application-default login.",
482
- "In CI, use google-github-actions/auth.",
483
- "Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.",
484
- `Or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to a token.`
485
- ], error);
490
+ throw failure([`${ADAPTER_NAME} found no Google credentials.`, ...signInHint(void 0)], error);
486
491
  }
487
492
  };
488
493
  return async () => {
@@ -504,6 +509,7 @@ const createAccessToken = (credentials, projectId) => {
504
509
  */
505
510
  const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
506
511
  const accessToken = createAccessToken(credentials, projectId);
512
+ const requestError = (action, resource, response, permission, notes) => requestErrorOf(signInHint(credentials), action, resource, response, permission, notes);
507
513
  const projectPath = () => {
508
514
  if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw failure([`${ADAPTER_NAME} can't use the projectId ${JSON.stringify(projectId)}.`, "Set the Google Cloud project's ID or number."]);
509
515
  if (!PREFIX_PATTERN.test(prefix)) throw failure([`${ADAPTER_NAME} can't use the prefix ${JSON.stringify(prefix)}.`, "It takes letters, digits, \"-\", and \"_\"."]);
@@ -617,10 +623,8 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
617
623
  if (missing.length > 0) throw failure([
618
624
  `${ADAPTER_NAME} can't store a value.`,
619
625
  resourceLine(secret),
620
- ...wrap(`The Google identity lacks ${missing.join(", ")} on it.`),
621
- "It adds a version, then destroys the versions it added before.",
622
- "Grant roles/secretmanager.admin, or a custom role with:",
623
- ...wrap("secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, secretmanager.versions.access")
626
+ `The identity lacks ${missing.join(", ")} on it.`,
627
+ "Grant roles/secretmanager.admin."
624
628
  ]);
625
629
  };
626
630
  /**
@@ -713,7 +717,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
713
717
  throw failure([
714
718
  `${ADAPTER_NAME} couldn't ${notListed}.`,
715
719
  resourceLine(secret),
716
- ...wrap(error instanceof Error ? error.message : String(error)),
720
+ error instanceof Error ? error.message : String(error),
717
721
  notDestroyed
718
722
  ], error);
719
723
  }
@@ -727,7 +731,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
727
731
  },
728
732
  describe: (name) => {
729
733
  const id = `${prefix}${name}`;
730
- return `projects/${projectId}/secrets/${id} (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
734
+ return `the secret ${JSON.stringify(id)} in Google Secret Manager (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
731
735
  }
732
736
  };
733
737
  };
package/dist/index.d.cts CHANGED
@@ -11,9 +11,8 @@ type FirebaseDatabaseConfig = AppOptions & {
11
11
  readonly collection?: string;
12
12
  /**
13
13
  * Firestore bills each document write, so Insights' aggregates are
14
- * batched: through log rows a compaction merges (the default), or
15
- * `{ mode: "memory" }` on a long-lived server. `false` commits them with
16
- * each event.
14
+ * batched: through log rows a compaction merges (the default). `false` commits
15
+ * them with each event.
17
16
  */
18
17
  readonly aggregateBatching?: EngineDatabase["aggregateBatching"] | false;
19
18
  };
package/dist/index.d.mts CHANGED
@@ -11,9 +11,8 @@ type FirebaseDatabaseConfig = AppOptions & {
11
11
  readonly collection?: string;
12
12
  /**
13
13
  * Firestore bills each document write, so Insights' aggregates are
14
- * batched: through log rows a compaction merges (the default), or
15
- * `{ mode: "memory" }` on a long-lived server. `false` commits them with
16
- * each event.
14
+ * batched: through log rows a compaction merges (the default). `false` commits
15
+ * them with each event.
17
16
  */
18
17
  readonly aggregateBatching?: EngineDatabase["aggregateBatching"] | false;
19
18
  };
package/dist/index.mjs CHANGED
@@ -391,51 +391,62 @@ const crc32c = (bytes) => {
391
391
  /** The version IDs the secret's annotation lists. */
392
392
  const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
393
393
  const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
394
- /** The widest line a message has: the CLI prints no wider. */
395
- const LINE_WIDTH = 76;
396
- /** `text`, such as Google's own message, in lines that fit, broken at spaces. */
397
- const wrap = (text) => {
398
- const lines = [];
399
- let line = "";
400
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
401
- lines.push(line);
402
- line = word;
403
- } else line = line ? `${line} ${word}` : word;
404
- if (line) lines.push(line);
405
- return lines;
406
- };
407
394
  /** An error of one line each, for a terminal. */
408
395
  const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
409
- const hint = (response, permission) => {
396
+ /**
397
+ * The one step that can fix the credentials in use, by the order they're
398
+ * tried: signing in with gcloud changes nothing while a variable comes first.
399
+ */
400
+ const signInHint = (credentials) => {
401
+ if (credentials) return [`Check the credentials passed to ${ADAPTER_NAME}().`];
402
+ const variable = ACCESS_TOKEN_ENV_NAMES.find((name) => process.env[name]?.trim()) ?? (process.env["GOOGLE_APPLICATION_CREDENTIALS"]?.trim() ? "GOOGLE_APPLICATION_CREDENTIALS" : void 0);
403
+ if (variable) return [`Fix or unset ${variable}.`];
404
+ return process.env["CI"] ? ["Use google-github-actions/auth."] : ["Sign in:", "gcloud auth application-default login"];
405
+ };
406
+ const hint = (response, permission, signIn) => {
410
407
  const { status, error } = response;
411
408
  if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return ["Enable the Secret Manager API in the project:", "gcloud services enable secretmanager.googleapis.com --project <project>"];
412
- if (status === 401) return [
413
- "Google rejected the access token.",
414
- "Sign in again with gcloud auth application-default login.",
415
- "In CI, check the job's Workload Identity Federation."
416
- ];
417
- if (status === 403) return wrap(`The Google identity needs ${permission}.`);
409
+ if (status === 401) return ["Google rejected the access token.", ...signIn];
410
+ if (status === 403) return [`The identity needs ${permission}.`];
418
411
  if (status === 429) return ["Secret Manager's quota is exhausted: try again later."];
419
412
  return [];
420
413
  };
421
414
  /**
422
- * An error for a failed request on `resource`, a line each: the HTTP
423
- * status, the resource, Google's status and message, which name resources
424
- * but never a value, `notes`, and what to fix.
415
+ * An error for a failed request on `resource`, a line each: the HTTP status,
416
+ * the resource, `notes`, and what to fix, or Google's own status and message,
417
+ * which name resources but never a value, when nothing here says what to fix.
425
418
  */
426
- const requestError = (action, resource, response, permission, notes = []) => {
419
+ const requestErrorOf = (signIn, action, resource, response, permission, notes = []) => {
427
420
  const { status, message } = response.error;
428
- const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
421
+ const fix = hint(response, permission, signIn);
422
+ if (response.status === 401 && notes.length === 0) return failure(fix);
429
423
  return failure([
430
424
  `${ADAPTER_NAME} couldn't ${action} (${response.status}).`,
431
425
  resourceLine(resource),
432
- ...wrap(detail),
433
426
  ...notes,
434
- ...hint(response, permission)
427
+ ...fix.length > 0 ? fix : [[status, message?.slice(0, 300)].filter(Boolean).join(": ")]
435
428
  ]);
436
429
  };
437
430
  /** The line that names a secret: its resource name is a line's worth. */
438
431
  const resourceLine = (secret) => `Secret: ${secret}`;
432
+ let metadataWarningFiltered = false;
433
+ /**
434
+ * Drops the one warning `gcp-metadata` emits when its probe for a Google
435
+ * runtime fails oddly: credentials are about to be reported as missing, and
436
+ * the warning has nothing to act on. It stays filtered for the process, as
437
+ * the library caches the probe; every other warning passes through.
438
+ * `METADATA_SERVER_DETECTION=none` would also silence it, but skips the probe,
439
+ * and with it the credentials of a Google runtime.
440
+ */
441
+ const filterMetadataWarning = () => {
442
+ if (metadataWarningFiltered) return;
443
+ metadataWarningFiltered = true;
444
+ const emitWarning = process.emitWarning;
445
+ process.emitWarning = ((warning, ...rest) => {
446
+ if (rest[0] === "MetadataLookupWarning") return;
447
+ Reflect.apply(emitWarning, process, [warning, ...rest]);
448
+ });
449
+ };
439
450
  /** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
440
451
  const mask = async (value) => {
441
452
  const { registerSecretValue } = await import("@hot-updater/cli-tools");
@@ -471,17 +482,11 @@ const createAccessToken = (credentials, projectId) => {
471
482
  throw error;
472
483
  });
473
484
  const client = await auth;
485
+ filterMetadataWarning();
474
486
  try {
475
487
  return await client.getAccessToken() ?? void 0;
476
488
  } catch (error) {
477
- throw failure([
478
- `${ADAPTER_NAME} found no Google credentials.`,
479
- ...wrap(error instanceof Error ? error.message.split("\n")[0] : String(error)),
480
- "On your machine, run gcloud auth application-default login.",
481
- "In CI, use google-github-actions/auth.",
482
- "Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.",
483
- `Or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to a token.`
484
- ], error);
489
+ throw failure([`${ADAPTER_NAME} found no Google credentials.`, ...signInHint(void 0)], error);
485
490
  }
486
491
  };
487
492
  return async () => {
@@ -503,6 +508,7 @@ const createAccessToken = (credentials, projectId) => {
503
508
  */
504
509
  const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
505
510
  const accessToken = createAccessToken(credentials, projectId);
511
+ const requestError = (action, resource, response, permission, notes) => requestErrorOf(signInHint(credentials), action, resource, response, permission, notes);
506
512
  const projectPath = () => {
507
513
  if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw failure([`${ADAPTER_NAME} can't use the projectId ${JSON.stringify(projectId)}.`, "Set the Google Cloud project's ID or number."]);
508
514
  if (!PREFIX_PATTERN.test(prefix)) throw failure([`${ADAPTER_NAME} can't use the prefix ${JSON.stringify(prefix)}.`, "It takes letters, digits, \"-\", and \"_\"."]);
@@ -616,10 +622,8 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
616
622
  if (missing.length > 0) throw failure([
617
623
  `${ADAPTER_NAME} can't store a value.`,
618
624
  resourceLine(secret),
619
- ...wrap(`The Google identity lacks ${missing.join(", ")} on it.`),
620
- "It adds a version, then destroys the versions it added before.",
621
- "Grant roles/secretmanager.admin, or a custom role with:",
622
- ...wrap("secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, secretmanager.versions.access")
625
+ `The identity lacks ${missing.join(", ")} on it.`,
626
+ "Grant roles/secretmanager.admin."
623
627
  ]);
624
628
  };
625
629
  /**
@@ -712,7 +716,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
712
716
  throw failure([
713
717
  `${ADAPTER_NAME} couldn't ${notListed}.`,
714
718
  resourceLine(secret),
715
- ...wrap(error instanceof Error ? error.message : String(error)),
719
+ error instanceof Error ? error.message : String(error),
716
720
  notDestroyed
717
721
  ], error);
718
722
  }
@@ -726,7 +730,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
726
730
  },
727
731
  describe: (name) => {
728
732
  const id = `${prefix}${name}`;
729
- return `projects/${projectId}/secrets/${id} (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
733
+ return `the secret ${JSON.stringify(id)} in Google Secret Manager (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
730
734
  }
731
735
  };
732
736
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/firebase",
3
3
  "type": "module",
4
- "version": "1.2.0-rc.12",
4
+ "version": "1.2.0-rc.13",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "types": "dist/index.d.cts",
@@ -38,10 +38,10 @@
38
38
  "dependencies": {
39
39
  "hono": "4.12.34",
40
40
  "google-auth-library": "10.7.0",
41
- "@hot-updater/cli-tools": "1.2.0-rc.12",
42
- "@hot-updater/plugin-core": "1.2.0-rc.12",
43
- "@hot-updater/server": "1.2.0-rc.12",
44
- "@hot-updater/protocol": "1.2.0-rc.12"
41
+ "@hot-updater/cli-tools": "1.2.0-rc.13",
42
+ "@hot-updater/plugin-core": "1.2.0-rc.13",
43
+ "@hot-updater/server": "1.2.0-rc.13",
44
+ "@hot-updater/protocol": "1.2.0-rc.13"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"
@@ -56,8 +56,8 @@
56
56
  "firebase-tools": "15.29.0",
57
57
  "fkill": "^9.0.0",
58
58
  "mime": "^4.0.4",
59
- "@hot-updater/mock": "1.2.0-rc.12",
60
- "@hot-updater/test-utils": "1.2.0-rc.12"
59
+ "@hot-updater/mock": "1.2.0-rc.13",
60
+ "@hot-updater/test-utils": "1.2.0-rc.13"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "firebase-admin": ">=11.10.0",