@hot-updater/aws 1.1.4 → 1.2.0-rc.0

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.
@@ -247,7 +247,7 @@ const bundleMetadataToRow = (metadata) => {
247
247
  return metadata;
248
248
  };
249
249
  //#endregion
250
- //#region ../../packages/protocol/dist/index.mjs
250
+ //#region ../../packages/protocol/dist/uuid-kZvLA0Ly.mjs
251
251
  const LETTER_DASH_NUMBER$2 = "[a-zA-Z0-9-]";
252
252
  const NUMERIC_IDENTIFIER$2 = String.raw`0|[1-9]\d*`;
253
253
  const NUMERIC_IDENTIFIER_LOOSE$2 = String.raw`\d+`;
@@ -360,128 +360,6 @@ function canonicalizeAppVersion(appVersion) {
360
360
  const version = coerce$1(appVersion);
361
361
  return version ? normalizeFull(version) : null;
362
362
  }
363
- const getManifestStorageUri = (bundle) => bundle.manifestStorageUri;
364
- const getManifestFileHash = (bundle) => bundle.manifestFileHash;
365
- const getAssetBaseStorageUri = (bundle) => bundle.assetBaseStorageUri;
366
- const isBundlePatchArtifact = (value) => {
367
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
368
- const candidate = value;
369
- return typeof candidate.baseBundleId === "string" && typeof candidate.baseFileHash === "string" && typeof candidate.patchFileHash === "string" && typeof candidate.patchStorageUri === "string";
370
- };
371
- const readBundlePatchArray = (patches) => {
372
- if (!Array.isArray(patches)) return [];
373
- return patches.filter(isBundlePatchArtifact);
374
- };
375
- const getBundlePatches = (bundle) => {
376
- const patches = readBundlePatchArray(bundle.patches);
377
- const seenBaseBundleIds = /* @__PURE__ */ new Set();
378
- return patches.filter((patch) => {
379
- if (seenBaseBundleIds.has(patch.baseBundleId)) return false;
380
- seenBaseBundleIds.add(patch.baseBundleId);
381
- return true;
382
- });
383
- };
384
- const getBundlePatch = (bundle, baseBundleId) => {
385
- return getBundlePatches(bundle).find((patch) => patch.baseBundleId === baseBundleId) ?? null;
386
- };
387
- const NUMERIC_COHORT_SIZE = 1e3;
388
- const DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE;
389
- const CUSTOM_COHORT_PATTERN = /^[a-z0-9-]+$/;
390
- function parseNumericCohortValue(cohort) {
391
- if (!/^\d+$/.test(cohort)) return null;
392
- const parsed = Number.parseInt(cohort, 10);
393
- if (Number.isNaN(parsed) || parsed < 1 || parsed > 1e3) return null;
394
- return parsed;
395
- }
396
- function positiveMod(value, modulus) {
397
- return (value % modulus + modulus) % modulus;
398
- }
399
- function hashString(value) {
400
- let hash = 0;
401
- for (let i = 0; i < value.length; i++) {
402
- const char = value.charCodeAt(i);
403
- hash = (hash << 5) - hash + char;
404
- hash |= 0;
405
- }
406
- return hash;
407
- }
408
- function gcd(a, b) {
409
- let x = Math.abs(a);
410
- let y = Math.abs(b);
411
- while (y !== 0) {
412
- const next = x % y;
413
- x = y;
414
- y = next;
415
- }
416
- return x;
417
- }
418
- function modularInverse(value, modulus) {
419
- let t = 0;
420
- let newT = 1;
421
- let r = modulus;
422
- let newR = positiveMod(value, modulus);
423
- while (newR !== 0) {
424
- const quotient = Math.floor(r / newR);
425
- [t, newT] = [newT, t - quotient * newT];
426
- [r, newR] = [newR, r - quotient * newR];
427
- }
428
- if (r > 1) throw new Error(`No modular inverse for ${value} mod ${modulus}`);
429
- return positiveMod(t, modulus);
430
- }
431
- function getRolloutShuffleParameters(bundleId) {
432
- let multiplier = positiveMod(hashString(`${bundleId}:multiplier`), 997);
433
- if (multiplier === 0) multiplier = 1;
434
- while (gcd(multiplier, NUMERIC_COHORT_SIZE) !== 1) {
435
- multiplier = positiveMod(multiplier + 1, NUMERIC_COHORT_SIZE);
436
- if (multiplier === 0) multiplier = 1;
437
- }
438
- const offset = positiveMod(hashString(`${bundleId}:offset`), NUMERIC_COHORT_SIZE);
439
- return {
440
- multiplier,
441
- offset,
442
- inverseMultiplier: modularInverse(multiplier, NUMERIC_COHORT_SIZE)
443
- };
444
- }
445
- function normalizeRolloutCohortCount(rolloutCohortCount) {
446
- if (rolloutCohortCount === null || rolloutCohortCount === void 0) return DEFAULT_ROLLOUT_COHORT_COUNT;
447
- if (rolloutCohortCount <= 0) return 0;
448
- if (rolloutCohortCount >= 1e3) return NUMERIC_COHORT_SIZE;
449
- return Math.floor(rolloutCohortCount);
450
- }
451
- function normalizeCohortValue(cohort) {
452
- const normalized = cohort.trim().toLowerCase();
453
- const numericCohort = parseNumericCohortValue(normalized);
454
- if (numericCohort !== null) return String(numericCohort);
455
- return normalized;
456
- }
457
- function getNumericCohortValue(cohort) {
458
- return parseNumericCohortValue(normalizeCohortValue(cohort));
459
- }
460
- function isNumericCohort(cohort) {
461
- return getNumericCohortValue(cohort) !== null;
462
- }
463
- function isCustomCohort(cohort) {
464
- const normalized = normalizeCohortValue(cohort);
465
- return normalized.length > 0 && normalized.length <= 64 && !/^\d+$/.test(normalized) && CUSTOM_COHORT_PATTERN.test(normalized);
466
- }
467
- function isValidCohort(cohort) {
468
- const normalized = normalizeCohortValue(cohort);
469
- return isNumericCohort(normalized) || isCustomCohort(normalized);
470
- }
471
- function getNumericCohortRolloutPosition(bundleId, cohortValue) {
472
- if (cohortValue < 1 || cohortValue > 1e3) throw new Error(`Invalid numeric cohort: ${cohortValue}`);
473
- const { offset, inverseMultiplier } = getRolloutShuffleParameters(bundleId);
474
- return positiveMod(inverseMultiplier * (cohortValue - 1 - offset), NUMERIC_COHORT_SIZE);
475
- }
476
- function getRolledOutNumericCohorts(bundleId, rolloutCohortCount) {
477
- const normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount);
478
- if (normalizedRolloutCount <= 0) return [];
479
- if (normalizedRolloutCount >= 1e3) return Array.from({ length: NUMERIC_COHORT_SIZE }, (_, index) => index + 1);
480
- const { multiplier, offset } = getRolloutShuffleParameters(bundleId);
481
- return Array.from({ length: normalizedRolloutCount }, (_, position) => positiveMod(multiplier * position + offset, NUMERIC_COHORT_SIZE)).map((zeroBasedCohort) => zeroBasedCohort + 1).sort((left, right) => left - right);
482
- }
483
- const RELEASE_CATALOG_FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
484
- const MAX_COMPILED_CATALOG_BYTES = 262144;
485
363
  const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
486
364
  const MAX_CATALOG_SEGMENT_LENGTH = 255;
487
365
  function assertCatalogSegment(value, name) {
@@ -606,6 +484,130 @@ const NIL_UUID = "00000000-0000-0000-0000-000000000000";
606
484
  const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
607
485
  const isUUIDv7 = (value) => typeof value === "string" && UUID_V7_PATTERN.test(value);
608
486
  //#endregion
487
+ //#region ../../packages/protocol/dist/index.mjs
488
+ const getManifestStorageUri = (bundle) => bundle.manifestStorageUri;
489
+ const getManifestFileHash = (bundle) => bundle.manifestFileHash;
490
+ const getAssetBaseStorageUri = (bundle) => bundle.assetBaseStorageUri;
491
+ const isBundlePatchArtifact = (value) => {
492
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
493
+ const candidate = value;
494
+ return typeof candidate.baseBundleId === "string" && typeof candidate.baseFileHash === "string" && typeof candidate.patchFileHash === "string" && typeof candidate.patchStorageUri === "string";
495
+ };
496
+ const readBundlePatchArray = (patches) => {
497
+ if (!Array.isArray(patches)) return [];
498
+ return patches.filter(isBundlePatchArtifact);
499
+ };
500
+ const getBundlePatches = (bundle) => {
501
+ const patches = readBundlePatchArray(bundle.patches);
502
+ const seenBaseBundleIds = /* @__PURE__ */ new Set();
503
+ return patches.filter((patch) => {
504
+ if (seenBaseBundleIds.has(patch.baseBundleId)) return false;
505
+ seenBaseBundleIds.add(patch.baseBundleId);
506
+ return true;
507
+ });
508
+ };
509
+ const getBundlePatch = (bundle, baseBundleId) => {
510
+ return getBundlePatches(bundle).find((patch) => patch.baseBundleId === baseBundleId) ?? null;
511
+ };
512
+ const NUMERIC_COHORT_SIZE = 1e3;
513
+ const DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE;
514
+ const CUSTOM_COHORT_PATTERN = /^[a-z0-9-]+$/;
515
+ function parseNumericCohortValue(cohort) {
516
+ if (!/^\d+$/.test(cohort)) return null;
517
+ const parsed = Number.parseInt(cohort, 10);
518
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 1e3) return null;
519
+ return parsed;
520
+ }
521
+ function positiveMod(value, modulus) {
522
+ return (value % modulus + modulus) % modulus;
523
+ }
524
+ function hashString(value) {
525
+ let hash = 0;
526
+ for (let i = 0; i < value.length; i++) {
527
+ const char = value.charCodeAt(i);
528
+ hash = (hash << 5) - hash + char;
529
+ hash |= 0;
530
+ }
531
+ return hash;
532
+ }
533
+ function gcd(a, b) {
534
+ let x = Math.abs(a);
535
+ let y = Math.abs(b);
536
+ while (y !== 0) {
537
+ const next = x % y;
538
+ x = y;
539
+ y = next;
540
+ }
541
+ return x;
542
+ }
543
+ function modularInverse(value, modulus) {
544
+ let t = 0;
545
+ let newT = 1;
546
+ let r = modulus;
547
+ let newR = positiveMod(value, modulus);
548
+ while (newR !== 0) {
549
+ const quotient = Math.floor(r / newR);
550
+ [t, newT] = [newT, t - quotient * newT];
551
+ [r, newR] = [newR, r - quotient * newR];
552
+ }
553
+ if (r > 1) throw new Error(`No modular inverse for ${value} mod ${modulus}`);
554
+ return positiveMod(t, modulus);
555
+ }
556
+ function getRolloutShuffleParameters(bundleId) {
557
+ let multiplier = positiveMod(hashString(`${bundleId}:multiplier`), 997);
558
+ if (multiplier === 0) multiplier = 1;
559
+ while (gcd(multiplier, NUMERIC_COHORT_SIZE) !== 1) {
560
+ multiplier = positiveMod(multiplier + 1, NUMERIC_COHORT_SIZE);
561
+ if (multiplier === 0) multiplier = 1;
562
+ }
563
+ const offset = positiveMod(hashString(`${bundleId}:offset`), NUMERIC_COHORT_SIZE);
564
+ return {
565
+ multiplier,
566
+ offset,
567
+ inverseMultiplier: modularInverse(multiplier, NUMERIC_COHORT_SIZE)
568
+ };
569
+ }
570
+ function normalizeRolloutCohortCount(rolloutCohortCount) {
571
+ if (rolloutCohortCount === null || rolloutCohortCount === void 0) return DEFAULT_ROLLOUT_COHORT_COUNT;
572
+ if (rolloutCohortCount <= 0) return 0;
573
+ if (rolloutCohortCount >= 1e3) return NUMERIC_COHORT_SIZE;
574
+ return Math.floor(rolloutCohortCount);
575
+ }
576
+ function normalizeCohortValue(cohort) {
577
+ const normalized = cohort.trim().toLowerCase();
578
+ const numericCohort = parseNumericCohortValue(normalized);
579
+ if (numericCohort !== null) return String(numericCohort);
580
+ return normalized;
581
+ }
582
+ function getNumericCohortValue(cohort) {
583
+ return parseNumericCohortValue(normalizeCohortValue(cohort));
584
+ }
585
+ function isNumericCohort(cohort) {
586
+ return getNumericCohortValue(cohort) !== null;
587
+ }
588
+ function isCustomCohort(cohort) {
589
+ const normalized = normalizeCohortValue(cohort);
590
+ return normalized.length > 0 && normalized.length <= 64 && !/^\d+$/.test(normalized) && CUSTOM_COHORT_PATTERN.test(normalized);
591
+ }
592
+ function isValidCohort(cohort) {
593
+ const normalized = normalizeCohortValue(cohort);
594
+ return isNumericCohort(normalized) || isCustomCohort(normalized);
595
+ }
596
+ function getNumericCohortRolloutPosition(bundleId, cohortValue) {
597
+ if (cohortValue < 1 || cohortValue > 1e3) throw new Error(`Invalid numeric cohort: ${cohortValue}`);
598
+ const { offset, inverseMultiplier } = getRolloutShuffleParameters(bundleId);
599
+ return positiveMod(inverseMultiplier * (cohortValue - 1 - offset), NUMERIC_COHORT_SIZE);
600
+ }
601
+ function getRolledOutNumericCohorts(bundleId, rolloutCohortCount) {
602
+ const normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount);
603
+ if (normalizedRolloutCount <= 0) return [];
604
+ if (normalizedRolloutCount >= 1e3) return Array.from({ length: NUMERIC_COHORT_SIZE }, (_, index) => index + 1);
605
+ const { multiplier, offset } = getRolloutShuffleParameters(bundleId);
606
+ return Array.from({ length: normalizedRolloutCount }, (_, position) => positiveMod(multiplier * position + offset, NUMERIC_COHORT_SIZE)).map((zeroBasedCohort) => zeroBasedCohort + 1).sort((left, right) => left - right);
607
+ }
608
+ const RELEASE_CATALOG_FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
609
+ const MAX_COMPILED_CATALOG_BYTES = 262144;
610
+ //#endregion
609
611
  //#region ../plugin-core/dist/databaseRows.mjs
610
612
  const releaseRowToRelease = (row) => ({
611
613
  id: row.id,
@@ -1925,12 +1927,17 @@ const SETTINGS_TABLE = {
1925
1927
  const ENGINE_SCHEMA_KEY = "schema.engine";
1926
1928
  const CORE_SCHEMA_KEY = "schema.core";
1927
1929
  const stored = ({ key, expected, found }) => `"${key}" is ${found === null ? "missing" : `"${found}"`}; expected "${expected}"`;
1928
- const settingMessage = (adapterName, { key, expected, found }) => `Hot Updater schema setting "${key}" for ${adapterName} is ${found === null ? "missing" : `"${found}"`}; expected "${expected}". Run \`hot-updater db migrate\`.`;
1930
+ /**
1931
+ * How to migrate a database the fence refused: doctor names the command for
1932
+ * it, init again for a deployment init manages, or the database's own
1933
+ * tooling in the server's project.
1934
+ */
1935
+ const MIGRATE_SCHEMA_FIX = "Run `npx hot-updater doctor` in the project whose hot-updater.config.ts names this database: it names the command that migrates it. In a server project, `hot-updater db migrate` migrates the database its server file names.";
1936
+ const settingMessage = (adapterName, { key, expected, found }) => `Hot Updater schema setting "${key}" for ${adapterName} is ${found === null ? "missing" : `"${found}"`}; expected "${expected}". ${MIGRATE_SCHEMA_FIX}`;
1929
1937
  /** The plugin a `schema.<id>` setting belongs to; undefined for core's and the engine's. */
1930
1938
  const pluginOf = ({ key }) => key.startsWith("schema.") && key !== "schema.engine" && key !== CORE_SCHEMA_KEY ? key.slice(7) : void 0;
1931
1939
  const listed = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
1932
- /** How to create plugins' tables when the fence was given no fix. */
1933
- const PLUGIN_TABLES_FIX = "Run `hot-updater db migrate`. A managed server runs only its provider's plugins, and rerunning `hot-updater init --provider <provider>` applies the provider's migrations. Run `hot-updater db` commands in the server's project: they load the server file named on the command line, or src/hotUpdater.* or src/db.*, and don't read hot-updater.config.ts.";
1940
+ const settingsMessage = (adapterName, settings) => `Hot Updater schema settings for ${adapterName} are not migrated: ${settings.map(stored).join(", and ")}. ${MIGRATE_SCHEMA_FIX}`;
1934
1941
  const pluginsMessage = (adapterName, plugins, settings, fix) => `The tables of ${plugins.length === 1 ? "plugin" : "plugins"} ${listed(plugins.map((id) => `"${id}"`))} are not migrated on ${adapterName}: schema ${settings.length === 1 ? "setting" : "settings"} ${settings.map(stored).join(", and ")}. ${fix}`;
1935
1942
  /** The database needs `hot-updater db migrate`, or a new database; the handler answers 503. */
1936
1943
  var HotUpdaterSchemaMigrationRequiredError = class extends Error {
@@ -1947,7 +1954,7 @@ var HotUpdaterSchemaMigrationRequiredError = class extends Error {
1947
1954
  constructor(adapterName, currentVersion, setting, options) {
1948
1955
  const settings = options?.settings ?? (setting === void 0 ? [] : [setting]);
1949
1956
  const plugins = settings.flatMap((mismatch) => pluginOf(mismatch) ?? []);
1950
- super(setting === void 0 ? currentVersion === void 0 ? `Hot Updater database schema is not initialized for ${adapterName}. Run \`hot-updater db migrate\` before using this adapter.` : `Hot Updater v1 cannot migrate schema ${currentVersion} in place. Create a new empty database and run \`hot-updater db migrate\`.` : plugins.length > 0 && plugins.length === settings.length ? pluginsMessage(adapterName, plugins, settings, options?.fix ?? PLUGIN_TABLES_FIX) : settingMessage(adapterName, setting), options?.cause === void 0 ? void 0 : { cause: options.cause });
1957
+ super(setting === void 0 ? currentVersion === void 0 ? `Hot Updater database schema is not initialized for ${adapterName}. ${MIGRATE_SCHEMA_FIX}` : `Hot Updater v1 cannot migrate schema ${currentVersion} in place. Create a new empty database and run \`hot-updater db migrate\`.` : plugins.length > 0 && plugins.length === settings.length ? pluginsMessage(adapterName, plugins, settings, options?.fix ?? MIGRATE_SCHEMA_FIX) : settings.length > 1 && !(setting.key === "schema.engine" && setting.found === null) ? settingsMessage(adapterName, settings) : settingMessage(adapterName, setting), options?.cause === void 0 ? void 0 : { cause: options.cause });
1951
1958
  this.adapterName = adapterName;
1952
1959
  this.currentVersion = currentVersion;
1953
1960
  this.setting = setting;
@@ -1995,352 +2002,502 @@ const checkSchemaFence = async (adapter, adapterName, expected, fix) => {
1995
2002
  found: null
1996
2003
  }, { cause });
1997
2004
  }
1998
- const settings = keys.flatMap((key, position) => {
1999
- const found = rows[position]?.value;
2000
- return found === expected[key] ? [] : [{
2001
- key,
2002
- expected: expected[key],
2003
- found: typeof found === "string" ? found : null
2004
- }];
2005
- });
2006
- const [first] = settings;
2007
- if (first !== void 0) throw new HotUpdaterSchemaMigrationRequiredError(adapterName, void 0, first, {
2008
- settings,
2009
- ...fix === void 0 ? {} : { fix }
2010
- });
2011
- };
2012
- /** The database name a fenced adapter carries, so a server can fence its plugins' rows too. */
2013
- const FENCED = Symbol.for("@hot-updater/server/schema-fence");
2014
- /** The database name an adapter behind the schema fence reports under. */
2015
- const fencedName = (adapter) => adapter[FENCED];
2016
- /**
2017
- * The adapter behind the schema fence: a process's first read or write
2018
- * checks the settings first. Success is kept; a failure is checked again on
2019
- * the next call.
2020
- */
2021
- const withSchemaFence = (adapter, adapterName, expected, fix) => {
2022
- let ready;
2023
- const fence = () => {
2024
- ready ??= checkSchemaFence(adapter, adapterName, expected, fix).catch((error) => {
2025
- ready = void 0;
2026
- throw error;
2027
- });
2028
- return ready;
2029
- };
2030
- return {
2031
- ...adapter,
2032
- [FENCED]: adapterName,
2033
- get: (table, keys) => fence().then(() => adapter.get(table, keys)),
2034
- query: (table, request) => fence().then(() => adapter.query(table, request)),
2035
- write: (ops) => fence().then(() => adapter.write(ops)),
2036
- ...adapter.prune && { prune: (table, before, limit) => fence().then(() => adapter.prune(table, before, limit)) }
2037
- };
2038
- };
2039
- //#endregion
2040
- //#region ../plugin-core/dist/engine/database/readMeter.mjs
2041
- const METER = Symbol.for("@hot-updater/plugin-core/read-meter");
2042
- /** The meter `meterReads` gave `database`, if it did. */
2043
- const readMeterOf = (database) => typeof database === "object" && database !== null ? database[METER] : void 0;
2044
- //#endregion
2045
- //#region ../plugin-core/dist/engine/db/schemaSettings.mjs
2046
- const storedSettings = async (adapter, keys) => {
2047
- const rows = await readSettings(adapter, keys);
2048
- return new Map(keys.map((key, position) => [key, rows[position] ?? null]));
2049
- };
2050
- /** Writes the settings rows after every table exists. */
2051
- const writeSchemaSettings = async (adapter, adapterName, settings) => {
2052
- const stored = await storedSettings(adapter, Object.keys(settings));
2053
- const ops = Object.entries(settings).flatMap(([key, value]) => {
2054
- const row = stored.get(key);
2055
- if (row === null || row === void 0) return [{
2056
- type: "insert",
2057
- table: SETTINGS_TABLE,
2058
- row: {
2059
- key,
2060
- value,
2061
- _v: 0
2062
- }
2063
- }];
2064
- if (row.value === value) return [];
2065
- return [{
2066
- type: "patch",
2067
- table: SETTINGS_TABLE,
2068
- key: [key],
2069
- set: { value },
2070
- guard: { v: Number(row._v) },
2071
- previous: row
2072
- }];
2073
- });
2074
- if (ops.length === 0) return;
2075
- if (!(await adapter.write(ops)).ok) throw new Error(`${adapterName}: the schema settings changed during the migration; run it again.`);
2076
- };
2077
- /** Creates or updates every table, then writes the settings rows last. */
2078
- const migrateSchema = async (adapter, adapterName, tables, settings) => {
2079
- if (adapter.migrations === void 0) throw new Error(`${adapterName} creates its tables with its own tooling; write only the settings rows.`);
2080
- await adapter.migrations.apply([...tables, SETTINGS_TABLE]);
2081
- await writeSchemaSettings(adapter, adapterName, settings);
2082
- };
2083
- //#endregion
2084
- //#region ../plugin-core/dist/engine/core/schema.mjs
2085
- /** A channel's id is `channel:` and its name's channel key, so ids share channel_key's bound. */
2086
- const CHANNEL_ID = {
2087
- type: "string",
2088
- maxLength: 1408,
2089
- ascii: true
2090
- };
2091
- /** Core's schema version: the `schema.core` settings row. */
2092
- const HOT_UPDATER_SCHEMA_VERSION = "1.0.0";
2005
+ const settings = keys.flatMap((key, position) => {
2006
+ const found = rows[position]?.value;
2007
+ return found === expected[key] ? [] : [{
2008
+ key,
2009
+ expected: expected[key],
2010
+ found: typeof found === "string" ? found : null
2011
+ }];
2012
+ });
2013
+ const [first] = settings;
2014
+ if (first !== void 0) throw new HotUpdaterSchemaMigrationRequiredError(adapterName, void 0, first, {
2015
+ settings,
2016
+ ...fix === void 0 ? {} : { fix }
2017
+ });
2018
+ };
2019
+ /** The database name a fenced adapter carries, so a server can fence its plugins' rows too. */
2020
+ const FENCED = Symbol.for("@hot-updater/server/schema-fence");
2021
+ /**
2022
+ * The adapter a fence reads its settings through and the settings it
2023
+ * expects, so a fence over it checks them with its own in one read. Another
2024
+ * key than `FENCED`, whose value another copy of plugin-core reads as the
2025
+ * name.
2026
+ */
2027
+ const FENCED_SETTINGS = Symbol.for("@hot-updater/server/schema-fence-settings");
2028
+ /** The database name an adapter behind the schema fence reports under. */
2029
+ const fencedName = (adapter) => adapter[FENCED];
2030
+ /**
2031
+ * The adapter behind the schema fence: a process's first read or write
2032
+ * checks the settings first. Success is kept; a failure is checked again on
2033
+ * the next call. A fence over another one, as a server's plugins' over
2034
+ * core's, checks the inner fence's settings with its own in that one read,
2035
+ * so its refusal lists every setting missing or different.
2036
+ */
2037
+ const withSchemaFence = (adapter, adapterName, expected, fix) => {
2038
+ const inner = adapter[FENCED_SETTINGS];
2039
+ const settings = inner === void 0 ? {
2040
+ adapter,
2041
+ expected
2042
+ } : {
2043
+ adapter: inner.adapter,
2044
+ expected: {
2045
+ ...inner.expected,
2046
+ ...expected
2047
+ }
2048
+ };
2049
+ let ready;
2050
+ const fence = () => {
2051
+ ready ??= checkSchemaFence(settings.adapter, adapterName, settings.expected, fix).catch((error) => {
2052
+ ready = void 0;
2053
+ throw error;
2054
+ });
2055
+ return ready;
2056
+ };
2057
+ return {
2058
+ ...adapter,
2059
+ [FENCED]: adapterName,
2060
+ [FENCED_SETTINGS]: settings,
2061
+ get: (table, keys) => fence().then(() => adapter.get(table, keys)),
2062
+ query: (table, request) => fence().then(() => adapter.query(table, request)),
2063
+ write: (ops) => fence().then(() => adapter.write(ops)),
2064
+ ...adapter.prune && { prune: (table, before, limit) => fence().then(() => adapter.prune(table, before, limit)) }
2065
+ };
2066
+ };
2067
+ //#endregion
2068
+ //#region ../plugin-core/dist/engine/database/readMeter.mjs
2069
+ const METER = Symbol.for("@hot-updater/plugin-core/read-meter");
2070
+ /** The meter `meterReads` gave `database`, if it did. */
2071
+ const readMeterOf = (database) => typeof database === "object" && database !== null ? database[METER] : void 0;
2072
+ //#endregion
2073
+ //#region ../plugin-core/dist/engine/db/schemaSettings.mjs
2074
+ const storedSettings = async (adapter, keys) => {
2075
+ const rows = await readSettings(adapter, keys);
2076
+ return new Map(keys.map((key, position) => [key, rows[position] ?? null]));
2077
+ };
2078
+ /** Writes the settings rows after every table exists. */
2079
+ const writeSchemaSettings = async (adapter, adapterName, settings) => {
2080
+ const stored = await storedSettings(adapter, Object.keys(settings));
2081
+ const ops = Object.entries(settings).flatMap(([key, value]) => {
2082
+ const row = stored.get(key);
2083
+ if (row === null || row === void 0) return [{
2084
+ type: "insert",
2085
+ table: SETTINGS_TABLE,
2086
+ row: {
2087
+ key,
2088
+ value,
2089
+ _v: 0
2090
+ }
2091
+ }];
2092
+ if (row.value === value) return [];
2093
+ return [{
2094
+ type: "patch",
2095
+ table: SETTINGS_TABLE,
2096
+ key: [key],
2097
+ set: { value },
2098
+ guard: { v: Number(row._v) },
2099
+ previous: row
2100
+ }];
2101
+ });
2102
+ if (ops.length === 0) return;
2103
+ if (!(await adapter.write(ops)).ok) throw new Error(`${adapterName}: the schema settings changed during the migration; run it again.`);
2104
+ };
2105
+ /** Creates or updates every table, then writes the settings rows last. */
2106
+ const migrateSchema = async (adapter, adapterName, tables, settings) => {
2107
+ if (adapter.migrations === void 0) throw new Error(`${adapterName} creates its tables with its own tooling; write only the settings rows.`);
2108
+ await adapter.migrations.apply([...tables, SETTINGS_TABLE]);
2109
+ await writeSchemaSettings(adapter, adapterName, settings);
2110
+ };
2111
+ //#endregion
2112
+ //#region ../plugin-core/dist/engine/core/schema.mjs
2113
+ /** A channel's id is `channel:` and its name's channel key, so ids share channel_key's bound. */
2114
+ const CHANNEL_ID = {
2115
+ type: "string",
2116
+ maxLength: 1408,
2117
+ ascii: true
2118
+ };
2119
+ /**
2120
+ * Core's schema version: the `schema.core` settings row. Bump it when core
2121
+ * adds a table, then regenerate the managed migrations with
2122
+ * HOT_UPDATER_UPDATE_SQL=1 (the D1 and Supabase schema specs), so the next
2123
+ * release's numbered migration carries the change and `hot-updater init`
2124
+ * applies it to managed databases an earlier release created. The official
2125
+ * plugins' `schemaVersion`s follow the same rule. A bump only adds tables:
2126
+ * every database's migrator creates missing tables and then records the
2127
+ * version, and none of them changes an existing table, so changing one
2128
+ * needs a migration step of its own first. The schema fence answers 503 on
2129
+ * a database whose row differs, until it runs.
2130
+ */
2131
+ const HOT_UPDATER_SCHEMA_VERSION = "1.2.0";
2132
+ const bundles = defineTable({
2133
+ id: {
2134
+ type: "string",
2135
+ maxLength: 36
2136
+ },
2137
+ platform: {
2138
+ type: "string",
2139
+ maxLength: 16
2140
+ },
2141
+ git_commit_hash: {
2142
+ type: "string",
2143
+ maxLength: 255,
2144
+ required: false
2145
+ },
2146
+ metadata: { type: "json" },
2147
+ manifest_storage_uri: { type: "string" },
2148
+ manifest_file_hash: { type: "string" },
2149
+ asset_base_storage_uri: { type: "string" }
2150
+ }, {
2151
+ key: ["id"],
2152
+ indexes: {
2153
+ byPlatform: {
2154
+ eq: ["platform"],
2155
+ sort: ["id"]
2156
+ },
2157
+ all: {
2158
+ eq: [],
2159
+ sort: ["id"]
2160
+ }
2161
+ }
2162
+ });
2163
+ const bundlePatches = defineTable({
2164
+ id: {
2165
+ type: "string",
2166
+ maxLength: 255
2167
+ },
2168
+ bundle_id: {
2169
+ type: "string",
2170
+ maxLength: 36,
2171
+ references: {
2172
+ model: "bundles",
2173
+ onDelete: "cascade"
2174
+ }
2175
+ },
2176
+ base_bundle_id: {
2177
+ type: "string",
2178
+ maxLength: 36,
2179
+ references: {
2180
+ model: "bundles",
2181
+ onDelete: "cascade"
2182
+ }
2183
+ },
2184
+ base_file_hash: { type: "string" },
2185
+ patch_file_hash: { type: "string" },
2186
+ patch_storage_uri: { type: "string" },
2187
+ byte_size: { type: "integer" },
2188
+ order_index: { type: "integer" }
2189
+ }, {
2190
+ key: ["id"],
2191
+ indexes: {
2192
+ pair: {
2193
+ eq: ["bundle_id", "base_bundle_id"],
2194
+ sort: [],
2195
+ unique: true
2196
+ },
2197
+ byBundle: {
2198
+ eq: ["bundle_id"],
2199
+ sort: ["order_index"],
2200
+ root: { model: "bundles" }
2201
+ },
2202
+ byBase: {
2203
+ eq: ["base_bundle_id"],
2204
+ sort: ["bundle_id"]
2205
+ }
2206
+ }
2207
+ });
2208
+ const releases = defineTable({
2209
+ id: {
2210
+ type: "string",
2211
+ maxLength: 36
2212
+ },
2213
+ revision: { type: "integer" },
2214
+ scope_key: {
2215
+ type: "string",
2216
+ maxLength: 2048,
2217
+ ascii: true
2218
+ },
2219
+ channel_id: {
2220
+ ...CHANNEL_ID,
2221
+ references: {
2222
+ model: "channels",
2223
+ onDelete: "restrict"
2224
+ }
2225
+ },
2226
+ platform: {
2227
+ type: "string",
2228
+ maxLength: 16
2229
+ },
2230
+ kind: {
2231
+ type: "string",
2232
+ maxLength: 16
2233
+ },
2234
+ bundle_id: {
2235
+ type: "string",
2236
+ maxLength: 36,
2237
+ required: false,
2238
+ references: {
2239
+ model: "bundles",
2240
+ onDelete: "restrict"
2241
+ }
2242
+ },
2243
+ strategy: {
2244
+ type: "string",
2245
+ maxLength: 16
2246
+ },
2247
+ target_app_version: {
2248
+ type: "string",
2249
+ required: false
2250
+ },
2251
+ fingerprint_hash: {
2252
+ type: "string",
2253
+ maxLength: 255,
2254
+ required: false
2255
+ },
2256
+ enabled: { type: "boolean" },
2257
+ should_force_update: { type: "boolean" },
2258
+ message: {
2259
+ type: "string",
2260
+ required: false
2261
+ },
2262
+ rollout_cohort_count: { type: "integer" },
2263
+ target_cohorts: { type: "json" },
2264
+ operation: {
2265
+ type: "string",
2266
+ maxLength: 16
2267
+ },
2268
+ source_release_id: {
2269
+ type: "string",
2270
+ maxLength: 36,
2271
+ required: false,
2272
+ references: {
2273
+ model: "releases",
2274
+ onDelete: "none"
2275
+ }
2276
+ },
2277
+ created_at_ms: { type: "integer" },
2278
+ updated_at_ms: { type: "integer" }
2279
+ }, {
2280
+ key: ["id"],
2281
+ indexes: {
2282
+ byScope: {
2283
+ eq: ["scope_key"],
2284
+ sort: ["id"],
2285
+ root: { model: "release_catalogs" }
2286
+ },
2287
+ byScopeEnabled: {
2288
+ eq: ["scope_key", "enabled"],
2289
+ sort: ["id"],
2290
+ root: { model: "release_catalogs" }
2291
+ },
2292
+ byChannelPlatform: {
2293
+ eq: ["channel_id", "platform"],
2294
+ sort: ["id"]
2295
+ },
2296
+ byChannelPlatformEnabled: {
2297
+ eq: [
2298
+ "channel_id",
2299
+ "platform",
2300
+ "enabled"
2301
+ ],
2302
+ sort: ["id"]
2303
+ },
2304
+ byBundle: {
2305
+ eq: ["bundle_id"],
2306
+ sort: ["id"]
2307
+ },
2308
+ all: {
2309
+ eq: [],
2310
+ sort: ["id"]
2311
+ }
2312
+ }
2313
+ });
2314
+ /** One compiled catalog per scope; the update check reads exactly this row. */
2315
+ const releaseCatalogs = defineTable({
2316
+ scope_key: {
2317
+ type: "string",
2318
+ maxLength: 2048,
2319
+ ascii: true
2320
+ },
2321
+ catalog_id: {
2322
+ type: "string",
2323
+ maxLength: 255
2324
+ },
2325
+ strategy: {
2326
+ type: "string",
2327
+ maxLength: 16
2328
+ },
2329
+ channel_id: {
2330
+ ...CHANNEL_ID,
2331
+ references: {
2332
+ model: "channels",
2333
+ onDelete: "none"
2334
+ }
2335
+ },
2336
+ channel_key: {
2337
+ type: "string",
2338
+ maxLength: 1400,
2339
+ ascii: true
2340
+ },
2341
+ platform: {
2342
+ type: "string",
2343
+ maxLength: 16
2344
+ },
2345
+ fingerprint_hash: {
2346
+ type: "string",
2347
+ maxLength: 255,
2348
+ required: false
2349
+ },
2350
+ generation: { type: "integer" },
2351
+ payload: { type: "string" },
2352
+ catalog_hash: {
2353
+ type: "string",
2354
+ maxLength: 71
2355
+ },
2356
+ byte_size: { type: "integer" },
2357
+ is_tombstone: { type: "boolean" },
2358
+ updated_at_ms: { type: "integer" }
2359
+ }, {
2360
+ key: ["scope_key"],
2361
+ indexes: { all: {
2362
+ eq: [],
2363
+ sort: ["scope_key"]
2364
+ } }
2365
+ });
2366
+ const channels = defineTable({
2367
+ id: CHANNEL_ID,
2368
+ name: {
2369
+ type: "string",
2370
+ maxLength: 255,
2371
+ unique: true
2372
+ }
2373
+ }, {
2374
+ key: ["id"],
2375
+ indexes: { all: {
2376
+ eq: [],
2377
+ sort: ["name"]
2378
+ } }
2379
+ });
2380
+ /**
2381
+ * The native builds `native-build android` and `native-build ios` track, newest first
2382
+ * in each scope: a platform, a channel, and the fingerprint or app version
2383
+ * the build compiled in. `min_bundle_id` names the build's built-in bundle,
2384
+ * a `bundles` row no release publishes. Its references keep no counters, so
2385
+ * adding this table changes no existing one; a channel with builds is
2386
+ * refused deletion through `byChannel`. `build_key` is the native build key
2387
+ * of every input the artifact was built from (`nbk1:` and a SHA-256), which
2388
+ * a build with the same inputs reuses it by; a build registered without
2389
+ * one is never reused. `build_number` is the artifact's `CFBundleVersion`
2390
+ * or `versionCode`. `submitted_to`, `submitted_at_ms` and
2391
+ * `submitted_version` record the store track a build went to, when, and
2392
+ * the store's label of the build; they stay null for a build no store took.
2393
+ */
2394
+ const nativeBuilds = defineTable({
2395
+ id: {
2396
+ type: "string",
2397
+ maxLength: 36
2398
+ },
2399
+ scope_key: {
2400
+ type: "string",
2401
+ maxLength: 2048,
2402
+ ascii: true
2403
+ },
2404
+ platform: {
2405
+ type: "string",
2406
+ maxLength: 16
2407
+ },
2408
+ channel_id: {
2409
+ ...CHANNEL_ID,
2410
+ references: {
2411
+ model: "channels",
2412
+ onDelete: "none"
2413
+ }
2414
+ },
2415
+ fingerprint_hash: {
2416
+ type: "string",
2417
+ maxLength: 255,
2418
+ required: false
2419
+ },
2420
+ app_version: {
2421
+ type: "string",
2422
+ required: false
2423
+ },
2424
+ min_bundle_id: {
2425
+ type: "string",
2426
+ maxLength: 36,
2427
+ unique: true,
2428
+ references: {
2429
+ model: "bundles",
2430
+ onDelete: "none"
2431
+ }
2432
+ },
2433
+ artifact_kind: {
2434
+ type: "string",
2435
+ maxLength: 16
2436
+ },
2437
+ artifact_storage_uri: { type: "string" },
2438
+ byte_size: { type: "integer" },
2439
+ git_commit_hash: {
2440
+ type: "string",
2441
+ maxLength: 255,
2442
+ required: false
2443
+ },
2444
+ created_at_ms: { type: "integer" },
2445
+ build_key: {
2446
+ type: "string",
2447
+ maxLength: 69,
2448
+ ascii: true,
2449
+ required: false
2450
+ },
2451
+ build_number: {
2452
+ type: "string",
2453
+ maxLength: 64,
2454
+ required: false
2455
+ },
2456
+ submitted_to: {
2457
+ type: "string",
2458
+ maxLength: 16,
2459
+ required: false
2460
+ },
2461
+ submitted_at_ms: {
2462
+ type: "integer",
2463
+ required: false
2464
+ },
2465
+ submitted_version: {
2466
+ type: "string",
2467
+ maxLength: 255,
2468
+ required: false
2469
+ }
2470
+ }, {
2471
+ key: ["id"],
2472
+ indexes: {
2473
+ byScope: {
2474
+ eq: ["scope_key"],
2475
+ sort: ["id"]
2476
+ },
2477
+ byBuildKey: {
2478
+ eq: ["scope_key", "build_key"],
2479
+ sort: ["id"]
2480
+ },
2481
+ byChannel: {
2482
+ eq: ["channel_id"],
2483
+ sort: ["id"],
2484
+ root: { model: "channels" }
2485
+ },
2486
+ all: {
2487
+ eq: [],
2488
+ sort: ["id"]
2489
+ }
2490
+ }
2491
+ });
2093
2492
  /** Core's module: always installed, never a plugin. */
2094
2493
  const coreModule = {
2095
2494
  id: "core",
2096
2495
  schema: {
2097
- bundles: defineTable({
2098
- id: {
2099
- type: "string",
2100
- maxLength: 36
2101
- },
2102
- platform: {
2103
- type: "string",
2104
- maxLength: 16
2105
- },
2106
- git_commit_hash: {
2107
- type: "string",
2108
- maxLength: 255,
2109
- required: false
2110
- },
2111
- metadata: { type: "json" },
2112
- manifest_storage_uri: { type: "string" },
2113
- manifest_file_hash: { type: "string" },
2114
- asset_base_storage_uri: { type: "string" }
2115
- }, {
2116
- key: ["id"],
2117
- indexes: {
2118
- byPlatform: {
2119
- eq: ["platform"],
2120
- sort: ["id"]
2121
- },
2122
- all: {
2123
- eq: [],
2124
- sort: ["id"]
2125
- }
2126
- }
2127
- }),
2128
- bundle_patches: defineTable({
2129
- id: {
2130
- type: "string",
2131
- maxLength: 255
2132
- },
2133
- bundle_id: {
2134
- type: "string",
2135
- maxLength: 36,
2136
- references: {
2137
- model: "bundles",
2138
- onDelete: "cascade"
2139
- }
2140
- },
2141
- base_bundle_id: {
2142
- type: "string",
2143
- maxLength: 36,
2144
- references: {
2145
- model: "bundles",
2146
- onDelete: "cascade"
2147
- }
2148
- },
2149
- base_file_hash: { type: "string" },
2150
- patch_file_hash: { type: "string" },
2151
- patch_storage_uri: { type: "string" },
2152
- byte_size: { type: "integer" },
2153
- order_index: { type: "integer" }
2154
- }, {
2155
- key: ["id"],
2156
- indexes: {
2157
- pair: {
2158
- eq: ["bundle_id", "base_bundle_id"],
2159
- sort: [],
2160
- unique: true
2161
- },
2162
- byBundle: {
2163
- eq: ["bundle_id"],
2164
- sort: ["order_index"],
2165
- root: { model: "bundles" }
2166
- },
2167
- byBase: {
2168
- eq: ["base_bundle_id"],
2169
- sort: ["bundle_id"]
2170
- }
2171
- }
2172
- }),
2173
- releases: defineTable({
2174
- id: {
2175
- type: "string",
2176
- maxLength: 36
2177
- },
2178
- revision: { type: "integer" },
2179
- scope_key: {
2180
- type: "string",
2181
- maxLength: 2048,
2182
- ascii: true
2183
- },
2184
- channel_id: {
2185
- ...CHANNEL_ID,
2186
- references: {
2187
- model: "channels",
2188
- onDelete: "restrict"
2189
- }
2190
- },
2191
- platform: {
2192
- type: "string",
2193
- maxLength: 16
2194
- },
2195
- kind: {
2196
- type: "string",
2197
- maxLength: 16
2198
- },
2199
- bundle_id: {
2200
- type: "string",
2201
- maxLength: 36,
2202
- required: false,
2203
- references: {
2204
- model: "bundles",
2205
- onDelete: "restrict"
2206
- }
2207
- },
2208
- strategy: {
2209
- type: "string",
2210
- maxLength: 16
2211
- },
2212
- target_app_version: {
2213
- type: "string",
2214
- required: false
2215
- },
2216
- fingerprint_hash: {
2217
- type: "string",
2218
- maxLength: 255,
2219
- required: false
2220
- },
2221
- enabled: { type: "boolean" },
2222
- should_force_update: { type: "boolean" },
2223
- message: {
2224
- type: "string",
2225
- required: false
2226
- },
2227
- rollout_cohort_count: { type: "integer" },
2228
- target_cohorts: { type: "json" },
2229
- operation: {
2230
- type: "string",
2231
- maxLength: 16
2232
- },
2233
- source_release_id: {
2234
- type: "string",
2235
- maxLength: 36,
2236
- required: false,
2237
- references: {
2238
- model: "releases",
2239
- onDelete: "none"
2240
- }
2241
- },
2242
- created_at_ms: { type: "integer" },
2243
- updated_at_ms: { type: "integer" }
2244
- }, {
2245
- key: ["id"],
2246
- indexes: {
2247
- byScope: {
2248
- eq: ["scope_key"],
2249
- sort: ["id"],
2250
- root: { model: "release_catalogs" }
2251
- },
2252
- byScopeEnabled: {
2253
- eq: ["scope_key", "enabled"],
2254
- sort: ["id"],
2255
- root: { model: "release_catalogs" }
2256
- },
2257
- byChannelPlatform: {
2258
- eq: ["channel_id", "platform"],
2259
- sort: ["id"]
2260
- },
2261
- byChannelPlatformEnabled: {
2262
- eq: [
2263
- "channel_id",
2264
- "platform",
2265
- "enabled"
2266
- ],
2267
- sort: ["id"]
2268
- },
2269
- byBundle: {
2270
- eq: ["bundle_id"],
2271
- sort: ["id"]
2272
- },
2273
- all: {
2274
- eq: [],
2275
- sort: ["id"]
2276
- }
2277
- }
2278
- }),
2279
- release_catalogs: defineTable({
2280
- scope_key: {
2281
- type: "string",
2282
- maxLength: 2048,
2283
- ascii: true
2284
- },
2285
- catalog_id: {
2286
- type: "string",
2287
- maxLength: 255
2288
- },
2289
- strategy: {
2290
- type: "string",
2291
- maxLength: 16
2292
- },
2293
- channel_id: {
2294
- ...CHANNEL_ID,
2295
- references: {
2296
- model: "channels",
2297
- onDelete: "none"
2298
- }
2299
- },
2300
- channel_key: {
2301
- type: "string",
2302
- maxLength: 1400,
2303
- ascii: true
2304
- },
2305
- platform: {
2306
- type: "string",
2307
- maxLength: 16
2308
- },
2309
- fingerprint_hash: {
2310
- type: "string",
2311
- maxLength: 255,
2312
- required: false
2313
- },
2314
- generation: { type: "integer" },
2315
- payload: { type: "string" },
2316
- catalog_hash: {
2317
- type: "string",
2318
- maxLength: 71
2319
- },
2320
- byte_size: { type: "integer" },
2321
- is_tombstone: { type: "boolean" },
2322
- updated_at_ms: { type: "integer" }
2323
- }, {
2324
- key: ["scope_key"],
2325
- indexes: { all: {
2326
- eq: [],
2327
- sort: ["scope_key"]
2328
- } }
2329
- }),
2330
- channels: defineTable({
2331
- id: CHANNEL_ID,
2332
- name: {
2333
- type: "string",
2334
- maxLength: 255,
2335
- unique: true
2336
- }
2337
- }, {
2338
- key: ["id"],
2339
- indexes: { all: {
2340
- eq: [],
2341
- sort: ["name"]
2342
- } }
2343
- }),
2496
+ bundles,
2497
+ bundle_patches: bundlePatches,
2498
+ releases,
2499
+ release_catalogs: releaseCatalogs,
2500
+ channels,
2344
2501
  bundle_totals: defineAggregate({ platform_key: {
2345
2502
  type: "string",
2346
2503
  maxLength: 16
@@ -2351,7 +2508,8 @@ const coreModule = {
2351
2508
  eq: ["platform_key"],
2352
2509
  sort: []
2353
2510
  } }
2354
- })
2511
+ }),
2512
+ native_builds: nativeBuilds
2355
2513
  }
2356
2514
  };
2357
2515
  //#endregion
@@ -3466,14 +3624,14 @@ const createEngineMigrator = (options) => {
3466
3624
  return {
3467
3625
  getVersion,
3468
3626
  getNameVariants: async () => void 0,
3469
- next: async () => await getVersion() === "1.0.0" ? void 0 : { version: HOT_UPDATER_SCHEMA_VERSION },
3627
+ next: async () => await getVersion() === "1.2.0" ? void 0 : { version: HOT_UPDATER_SCHEMA_VERSION },
3470
3628
  previous: async () => void 0,
3471
3629
  up: makeResult,
3472
3630
  down: async () => {
3473
3631
  throw new Error("No previous schema to migrate to.");
3474
3632
  },
3475
3633
  migrateTo: async (version, migrate) => {
3476
- if (version !== "1.0.0") throw new Error(`Invalid version ${version}`);
3634
+ if (version !== "1.2.0") throw new Error(`Invalid version ${version}`);
3477
3635
  return makeResult(migrate);
3478
3636
  },
3479
3637
  migrateToLatest: makeResult
@@ -4205,9 +4363,9 @@ const createDatabaseEngine = ({ batching, now = Date.now, meter, ...options }) =
4205
4363
  */
4206
4364
  const pluginTablesFix = (database) => {
4207
4365
  const { createMigrator, generateSchema } = database;
4208
- const managed = "A managed server runs only its provider's plugins, and rerunning `hot-updater init --provider <provider>` applies the provider's migrations.";
4366
+ const doctor = "Run `npx hot-updater doctor` in the project whose hot-updater.config.ts names this database: it names the command that migrates it.";
4209
4367
  const serverProject = "Run `hot-updater db` commands in the server's project: they load the server file named on the command line, or src/hotUpdater.* or src/db.*, and don't read hot-updater.config.ts.";
4210
- return createMigrator !== void 0 ? `Run \`hot-updater db migrate\`. ${managed} ${serverProject}` : generateSchema !== void 0 ? `Run \`hot-updater db generate\` and apply the file it writes. ${managed} ${serverProject}` : managed;
4368
+ return createMigrator !== void 0 ? `${doctor} In a server project, run \`hot-updater db migrate\`. ${serverProject}` : generateSchema !== void 0 ? `${doctor} On a database \`hot-updater init\` created, that is init again with its saved inputs; for your own server, run \`hot-updater db generate\` and apply the file it writes. ${serverProject}` : `${doctor} On a database \`hot-updater init\` created, that is init again with its saved inputs.`;
4211
4369
  };
4212
4370
  /**
4213
4371
  * The storage engine over a database, as a server runs it: core's tables
@@ -4549,7 +4707,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4549
4707
  };
4550
4708
  //#endregion
4551
4709
  //#region ../../packages/server/dist/version.mjs
4552
- const HOT_UPDATER_SERVER_VERSION = "1.1.4";
4710
+ const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.0";
4553
4711
  //#endregion
4554
4712
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4555
4713
  /**
@@ -5342,8 +5500,8 @@ const baseBundleIdsOf = (row, range, bundleId, limit) => {
5342
5500
  //#endregion
5343
5501
  //#region ../../packages/server/dist/core/reads.mjs
5344
5502
  const PAGE$3 = 500;
5345
- const pick = (fields) => (row) => Object.fromEntries(fields.map((field) => [field, row[field]]));
5346
- const toBundleRow = pick([
5503
+ const pick$1 = (fields) => (row) => Object.fromEntries(fields.map((field) => [field, row[field]]));
5504
+ const toBundleRow = pick$1([
5347
5505
  "id",
5348
5506
  "platform",
5349
5507
  "git_commit_hash",
@@ -5352,7 +5510,7 @@ const toBundleRow = pick([
5352
5510
  "manifest_file_hash",
5353
5511
  "asset_base_storage_uri"
5354
5512
  ]);
5355
- const toPatchRow = pick([
5513
+ const toPatchRow = pick$1([
5356
5514
  "id",
5357
5515
  "bundle_id",
5358
5516
  "base_bundle_id",
@@ -5362,7 +5520,7 @@ const toPatchRow = pick([
5362
5520
  "byte_size",
5363
5521
  "order_index"
5364
5522
  ]);
5365
- const toReleaseRow = pick([
5523
+ const toReleaseRow = pick$1([
5366
5524
  "id",
5367
5525
  "revision",
5368
5526
  "scope_key",
@@ -5383,7 +5541,7 @@ const toReleaseRow = pick([
5383
5541
  "created_at_ms",
5384
5542
  "updated_at_ms"
5385
5543
  ]);
5386
- const toCatalogRow = pick([
5544
+ const toCatalogRow = pick$1([
5387
5545
  "scope_key",
5388
5546
  "catalog_id",
5389
5547
  "strategy",
@@ -5398,7 +5556,7 @@ const toCatalogRow = pick([
5398
5556
  "is_tombstone",
5399
5557
  "updated_at_ms"
5400
5558
  ]);
5401
- const toChannelRow = pick(["id", "name"]);
5559
+ const toChannelRow = pick$1(["id", "name"]);
5402
5560
  /** Every page of one read, in order. */
5403
5561
  const drain$1 = async (read) => {
5404
5562
  const rows = [];
@@ -5410,6 +5568,8 @@ const drain$1 = async (read) => {
5410
5568
  } while (cursor !== void 0);
5411
5569
  return rows;
5412
5570
  };
5571
+ /** A keyset page's range: the keys strictly after `after`, in `order`. */
5572
+ const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
5413
5573
  /** Core's reads, each through one declared index or key. */
5414
5574
  const createCoreReads = (db, storage) => {
5415
5575
  /** A bundle row's patches, exactly as many as its reference counter holds. */
@@ -5434,7 +5594,6 @@ const createCoreReads = (db, storage) => {
5434
5594
  childCount: row._refs_bundle_patches_base_bundle_id ?? 0
5435
5595
  };
5436
5596
  };
5437
- const after = (input) => input.after === void 0 ? {} : { range: (input.order ?? "asc") === "asc" ? { gt: input.after } : { lt: input.after } };
5438
5597
  return {
5439
5598
  /** The update check: one point read of its scope's catalog. */
5440
5599
  async getReleaseCatalog(input) {
@@ -5625,9 +5784,13 @@ const insertBundle = (tx, row) => {
5625
5784
  tx.create("bundles", row);
5626
5785
  countBundle(tx, row.platform, 1);
5627
5786
  };
5628
- const updateBundle = (tx, current, set) => {
5787
+ /**
5788
+ * Updates a bundle; a platform change moves it between platform totals,
5789
+ * except for a native build's built-in bundle, which they never count.
5790
+ */
5791
+ const updateBundle = (tx, current, set, { counted = true } = {}) => {
5629
5792
  tx.update("bundles", current, set);
5630
- if (set.platform !== void 0 && set.platform !== current.platform) {
5793
+ if (counted && set.platform !== void 0 && set.platform !== current.platform) {
5631
5794
  countBundle(tx, current.platform, -1);
5632
5795
  countBundle(tx, set.platform, 1);
5633
5796
  }
@@ -5688,7 +5851,7 @@ const insertChannel = async (db, row) => {
5688
5851
  throw error;
5689
5852
  }
5690
5853
  };
5691
- /** Deletes a channel no release uses: 1 read + 1 write. */
5854
+ /** Deletes a channel no release or native build uses: 1 read, 1 query, 1 write. */
5692
5855
  const deleteChannel = (db, id) => db.transaction(async (tx) => {
5693
5856
  const row = await tx.findOne("channels", { id });
5694
5857
  if (row === null) return {
@@ -5699,10 +5862,243 @@ const deleteChannel = (db, id) => db.transaction(async (tx) => {
5699
5862
  deleted: false,
5700
5863
  reason: "not_empty"
5701
5864
  };
5865
+ const { rows: builds } = await tx.findMany("native_builds", {
5866
+ index: "byChannel",
5867
+ where: { channel_id: id },
5868
+ limit: 1
5869
+ });
5870
+ if (builds.length > 0) return {
5871
+ deleted: false,
5872
+ reason: "not_empty"
5873
+ };
5702
5874
  await tx.delete("channels", row);
5703
5875
  return { deleted: true };
5704
5876
  });
5705
5877
  //#endregion
5878
+ //#region ../../packages/server/dist/core/nativeBuilds.mjs
5879
+ /** The artifacts each platform's build produces. */
5880
+ const ARTIFACT_KINDS = {
5881
+ android: ["apk", "aab"],
5882
+ ios: ["ipa", "app"]
5883
+ };
5884
+ /** `native_builds.scope_key`'s bound in core's schema. */
5885
+ const SCOPE_KEY_MAX_LENGTH = 2048;
5886
+ /** The `git_commit_hash` bound of `bundles` and `native_builds`. */
5887
+ const GIT_COMMIT_HASH_MAX_LENGTH = 255;
5888
+ /**
5889
+ * A native build key: `nbk1:` and a SHA-256, as the CLI computes it. The
5890
+ * format is the CLI's convention, implemented here so the registry stores
5891
+ * and looks up nothing else.
5892
+ */
5893
+ const BUILD_KEY = /^nbk1:[0-9a-f]{64}$/;
5894
+ /** A native build or scope core cannot store or look up: the caller's input. */
5895
+ var NativeBuildInputError = class extends TypeError {
5896
+ name = "NativeBuildInputError";
5897
+ };
5898
+ const pick = (row) => ({
5899
+ id: row.id,
5900
+ scope_key: row.scope_key,
5901
+ platform: row.platform,
5902
+ channel_id: row.channel_id,
5903
+ fingerprint_hash: row.fingerprint_hash ?? null,
5904
+ app_version: row.app_version ?? null,
5905
+ min_bundle_id: row.min_bundle_id,
5906
+ artifact_kind: row.artifact_kind,
5907
+ artifact_storage_uri: row.artifact_storage_uri,
5908
+ byte_size: row.byte_size,
5909
+ git_commit_hash: row.git_commit_hash ?? null,
5910
+ created_at_ms: row.created_at_ms,
5911
+ build_key: row.build_key ?? null,
5912
+ build_number: row.build_number ?? null,
5913
+ submitted_to: row.submitted_to ?? null,
5914
+ submitted_at_ms: row.submitted_at_ms ?? null,
5915
+ submitted_version: row.submitted_version ?? null
5916
+ });
5917
+ /** The store track each platform's builds are submitted to. */
5918
+ const SUBMISSION_DESTINATIONS = {
5919
+ android: "play-internal",
5920
+ ios: "testflight"
5921
+ };
5922
+ /** `native_builds.build_number`'s bound in core's schema. */
5923
+ const BUILD_NUMBER_MAX_LENGTH = 64;
5924
+ /** `native_builds.submitted_version`'s bound in core's schema. */
5925
+ const SUBMITTED_VERSION_MAX_LENGTH = 255;
5926
+ /**
5927
+ * A native build scope's key: the Release Catalog scope key of a
5928
+ * fingerprint, or of the app-version strategy followed by the exact app
5929
+ * version the build compiled in. One key serves both strategies, so one
5930
+ * index finds the newest build of either.
5931
+ */
5932
+ const nativeBuildScopeKey = (scope) => {
5933
+ let scopeKey;
5934
+ try {
5935
+ const channelKey = encodeChannelKey(scope.channel);
5936
+ scopeKey = scope.fingerprintHash === null ? `${createReleaseCatalogScopeKey({
5937
+ channelKey,
5938
+ platform: scope.platform,
5939
+ strategy: "APP_VERSION"
5940
+ })}:${encodeURIComponent(scope.appVersion)}` : createReleaseCatalogScopeKey({
5941
+ channelKey,
5942
+ fingerprintHash: scope.fingerprintHash,
5943
+ platform: scope.platform,
5944
+ strategy: "FINGERPRINT"
5945
+ });
5946
+ } catch (error) {
5947
+ throw new NativeBuildInputError(`Invalid native build scope: ${error instanceof Error ? error.message : String(error)}.`);
5948
+ }
5949
+ if (scopeKey.length > SCOPE_KEY_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build scope: its key is longer than ${SCOPE_KEY_MAX_LENGTH} characters; shorten the channel or app version.`);
5950
+ return scopeKey;
5951
+ };
5952
+ /** Whether `bundleId` is a native build's built-in bundle: one unique read. */
5953
+ const isBuiltInBundle = async (db, bundleId) => await db.findOne("native_builds", { min_bundle_id: bundleId }) !== null;
5954
+ const problemOf = ({ id, minBundleId, platform, channel, fingerprintHash, appVersion, gitCommitHash, buildKey, buildNumber, artifact }) => {
5955
+ if (!isUUIDv7(id)) return "id must be a UUIDv7";
5956
+ if (!isUUIDv7(minBundleId)) return "minBundleId must be the UUIDv7 a release build compiles in";
5957
+ if (platform !== "ios" && platform !== "android") return "platform must be ios or android";
5958
+ if (channel.trim().length === 0) return "channel is required";
5959
+ if (gitCommitHash !== null && gitCommitHash.length > GIT_COMMIT_HASH_MAX_LENGTH) return `gitCommitHash must be at most ${GIT_COMMIT_HASH_MAX_LENGTH} characters`;
5960
+ if (fingerprintHash === null === (appVersion === null)) return "exactly one of fingerprintHash and appVersion is required";
5961
+ if (buildKey !== null && !BUILD_KEY.test(buildKey)) return "buildKey must be nbk1: and a SHA-256, or null";
5962
+ if (buildNumber !== void 0 && buildNumber !== null && (buildNumber.trim().length === 0 || buildNumber.length > BUILD_NUMBER_MAX_LENGTH)) return `buildNumber must be the artifact's build number, up to ${BUILD_NUMBER_MAX_LENGTH} characters, or null`;
5963
+ const kinds = ARTIFACT_KINDS[platform];
5964
+ if (!kinds.includes(artifact.kind)) return `artifact.kind of an ${platform} build must be ${kinds.join(" or ")}`;
5965
+ if (!Number.isSafeInteger(artifact.byteSize) || artifact.byteSize < 0) return "artifact.byteSize must be a byte count";
5966
+ return null;
5967
+ };
5968
+ /** The native build registry: core's `native_builds` and the built-in bundle rows. */
5969
+ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
5970
+ async registerNativeBuild(registration) {
5971
+ const problem = problemOf(registration);
5972
+ if (problem !== null) throw new NativeBuildInputError(`Invalid native build: ${problem}.`);
5973
+ const scopeKey = nativeBuildScopeKey(registration);
5974
+ const channelId = `channel:${encodeChannelKey(registration.channel)}`;
5975
+ const { embeddedBundle } = registration;
5976
+ const row = {
5977
+ id: registration.id,
5978
+ scope_key: scopeKey,
5979
+ platform: registration.platform,
5980
+ channel_id: channelId,
5981
+ fingerprint_hash: registration.fingerprintHash,
5982
+ app_version: registration.appVersion,
5983
+ min_bundle_id: registration.minBundleId,
5984
+ artifact_kind: registration.artifact.kind,
5985
+ artifact_storage_uri: registration.artifact.storageUri,
5986
+ byte_size: registration.artifact.byteSize,
5987
+ git_commit_hash: registration.gitCommitHash,
5988
+ created_at_ms: now(),
5989
+ build_key: registration.buildKey,
5990
+ build_number: registration.buildNumber ?? null,
5991
+ submitted_to: null,
5992
+ submitted_at_ms: null,
5993
+ submitted_version: null
5994
+ };
5995
+ for (let attempt = 1;; attempt += 1) {
5996
+ await insertChannel(db, {
5997
+ id: channelId,
5998
+ name: registration.channel
5999
+ });
6000
+ try {
6001
+ await db.transaction(async (tx) => {
6002
+ tx.create("bundles", {
6003
+ id: registration.minBundleId,
6004
+ platform: registration.platform,
6005
+ git_commit_hash: registration.gitCommitHash,
6006
+ metadata: embeddedBundle.metadata ?? {},
6007
+ manifest_storage_uri: embeddedBundle.manifestStorageUri,
6008
+ manifest_file_hash: embeddedBundle.manifestFileHash,
6009
+ asset_base_storage_uri: embeddedBundle.assetBaseStorageUri
6010
+ });
6011
+ tx.create("native_builds", row);
6012
+ });
6013
+ break;
6014
+ } catch (error) {
6015
+ if (!(error instanceof DatabaseConstraintError)) throw error;
6016
+ if (error.reason === "exists" || error.reason === "unique") throw new DatabaseConstraintError(error.reason, "native_builds");
6017
+ if (attempt < 3 && error.reason === "not_found" && error.model === "channels") continue;
6018
+ throw error;
6019
+ }
6020
+ }
6021
+ return row;
6022
+ },
6023
+ async getNativeBuild(id) {
6024
+ const row = await db.findOne("native_builds", { id });
6025
+ return row === null ? null : pick(row);
6026
+ },
6027
+ async findNativeBuild(scope, options) {
6028
+ const scopeKey = nativeBuildScopeKey(scope);
6029
+ const buildKey = options?.buildKey;
6030
+ if (buildKey !== void 0 && !BUILD_KEY.test(buildKey)) throw new NativeBuildInputError("Invalid native build key: it must be nbk1: and a SHA-256.");
6031
+ const { rows } = buildKey === void 0 ? await db.findMany("native_builds", {
6032
+ index: "byScope",
6033
+ where: { scope_key: scopeKey },
6034
+ order: "desc",
6035
+ limit: 1
6036
+ }) : await db.findMany("native_builds", {
6037
+ index: "byBuildKey",
6038
+ where: {
6039
+ scope_key: scopeKey,
6040
+ build_key: buildKey
6041
+ },
6042
+ order: "desc",
6043
+ limit: 1
6044
+ });
6045
+ return rows[0] === void 0 ? null : pick(rows[0]);
6046
+ },
6047
+ async recordNativeBuildSubmission(id, { destination, version }) {
6048
+ 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.`);
6049
+ return db.transaction(async (tx) => {
6050
+ const row = await tx.findOne("native_builds", { id });
6051
+ if (row === null) return null;
6052
+ const build = pick(row);
6053
+ if (SUBMISSION_DESTINATIONS[build.platform] !== destination) throw new NativeBuildInputError(`Invalid native build submission: an ${build.platform} build is submitted to ${SUBMISSION_DESTINATIONS[build.platform]}, not ${destination}.`);
6054
+ const set = {
6055
+ submitted_to: destination,
6056
+ submitted_at_ms: now(),
6057
+ submitted_version: version
6058
+ };
6059
+ tx.update("native_builds", row, set);
6060
+ return {
6061
+ ...build,
6062
+ ...set
6063
+ };
6064
+ });
6065
+ },
6066
+ async deleteNativeBuild(id) {
6067
+ return db.transaction(async (tx) => {
6068
+ const row = await tx.findOne("native_builds", { id });
6069
+ if (row === null) return {
6070
+ deleted: false,
6071
+ reason: "not_found"
6072
+ };
6073
+ await tx.delete("native_builds", row);
6074
+ const bundle = await tx.findOne("bundles", { id: row.min_bundle_id });
6075
+ if (bundle !== null) await tx.delete("bundles", bundle);
6076
+ return {
6077
+ deleted: true,
6078
+ nativeBuild: pick(row)
6079
+ };
6080
+ });
6081
+ },
6082
+ async listNativeBuilds(input) {
6083
+ const page = {
6084
+ order: input.order ?? "asc",
6085
+ limit: input.limit
6086
+ };
6087
+ const { rows } = input.channelId === void 0 ? await db.findMany("native_builds", {
6088
+ index: "all",
6089
+ where: {},
6090
+ ...page,
6091
+ ...after(input)
6092
+ }) : await db.findMany("native_builds", {
6093
+ index: "byChannel",
6094
+ where: { channel_id: input.channelId },
6095
+ ...page,
6096
+ ...after(input)
6097
+ });
6098
+ return rows.map(pick);
6099
+ }
6100
+ });
6101
+ //#endregion
5706
6102
  //#region ../../packages/server/dist/core/releases.mjs
5707
6103
  const PAGE$1 = 500;
5708
6104
  const fail$1 = (code, message) => {
@@ -6016,6 +6412,7 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
6016
6412
  if (!("bundleId" in deployment) || platforms.has(deployment.bundleId)) continue;
6017
6413
  const row = await db.findOne("bundles", { id: deployment.bundleId });
6018
6414
  if (row === null) throw new DatabaseBundleNotFoundError(deployment.bundleId);
6415
+ if (await isBuiltInBundle(db, deployment.bundleId)) throw new ReleaseManagementError("TARGET_RELEASE_INVALID", `Bundle "${deployment.bundleId}" is a native build's built-in bundle, which no release publishes.`);
6019
6416
  platforms.set(deployment.bundleId, toBundleRow(row).platform);
6020
6417
  }
6021
6418
  const updatedAtMs = now();
@@ -6211,7 +6608,8 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
6211
6608
  id
6212
6609
  };
6213
6610
  const { id: _id, ...set } = bundleToRow(next);
6214
- updateBundle(tx, current, set);
6611
+ const counted = set.platform === current.platform || !await isBuiltInBundle(tx, id);
6612
+ updateBundle(tx, current, set, { counted });
6215
6613
  if (patches !== void 0) await replaceBundlePatches(tx, current, bundleToPatchRows({
6216
6614
  ...next,
6217
6615
  patches
@@ -6222,7 +6620,9 @@ const createCoreOperations = (db, { now = Date.now } = {}) => {
6222
6620
  await db.transaction(async (tx) => {
6223
6621
  for (const id of new Set(ids)) {
6224
6622
  const current = await tx.findOne("bundles", { id });
6225
- if (current !== null) await deleteBundle(tx, current);
6623
+ if (current === null) continue;
6624
+ if (await isBuiltInBundle(tx, id)) throw new DatabaseRowReferencedError();
6625
+ await deleteBundle(tx, current);
6226
6626
  }
6227
6627
  });
6228
6628
  } catch (error) {
@@ -6274,6 +6674,7 @@ const createCoreApi = (db, storage, options = {}) => {
6274
6674
  return {
6275
6675
  ...reads,
6276
6676
  ...createCoreOperations(purgingCachedRoutes(db, options.onCachedRoutesChange), options),
6677
+ ...createNativeBuildApi(db, options),
6277
6678
  /** One read, which checks the schema settings first. */
6278
6679
  ready: async () => {
6279
6680
  await reads.listReleaseCatalogs({ limit: 1 });
@@ -6474,6 +6875,10 @@ const answer = async (operation) => {
6474
6875
  code: "REFERENCED",
6475
6876
  error: error.message
6476
6877
  }, 409);
6878
+ if (error instanceof DatabaseConstraintError && error.model === "native_builds") return json$2({
6879
+ code: "NATIVE_BUILD_EXISTS",
6880
+ error: "A native build with this ID or built-in bundle ID is already registered."
6881
+ }, 409);
6477
6882
  throw error;
6478
6883
  }
6479
6884
  };
@@ -6576,6 +6981,42 @@ const bundleUpdateOf = (value, bundleId) => {
6576
6981
  if (id !== void 0 && id !== bundleId) throw new HandlerBadRequestError("Bundle id mismatch");
6577
6982
  return update;
6578
6983
  };
6984
+ const NATIVE_BUILD_ARTIFACT_KINDS = /* @__PURE__ */ new Set([
6985
+ "apk",
6986
+ "aab",
6987
+ "ipa",
6988
+ "app"
6989
+ ]);
6990
+ /** A native build's scope: `fingerprintHash` or `appVersion`, never both. */
6991
+ const nativeBuildScopeOf = (url) => {
6992
+ const params = url.searchParams;
6993
+ const platform = platformOf(url);
6994
+ const channel = params.get("channel");
6995
+ const fingerprintHash = params.get("fingerprintHash");
6996
+ const appVersion = params.get("appVersion");
6997
+ if (platform === void 0 || !isText$2(channel) || fingerprintHash === null === (appVersion === null) || fingerprintHash === "" || appVersion === "") throw new HandlerBadRequestError("A native build lookup needs platform, channel, and fingerprintHash or appVersion.");
6998
+ return fingerprintHash !== null ? {
6999
+ platform,
7000
+ channel,
7001
+ fingerprintHash,
7002
+ appVersion: null
7003
+ } : {
7004
+ platform,
7005
+ channel,
7006
+ fingerprintHash: null,
7007
+ appVersion
7008
+ };
7009
+ };
7010
+ /** A native build or scope core refuses: a bad request. */
7011
+ const refusingInvalid = async (operation) => {
7012
+ try {
7013
+ return await operation();
7014
+ } catch (error) {
7015
+ if (error instanceof NativeBuildInputError) throw new HandlerBadRequestError(error.message);
7016
+ throw error;
7017
+ }
7018
+ };
7019
+ const isNativeBuildRegistration = (value) => isRecord$5(value) && isText$2(value.id) && isText$2(value.minBundleId) && typeof value.platform === "string" && isPlatform(value.platform) && isText$2(value.channel) && isTextOrNull$1(value.fingerprintHash) && isTextOrNull$1(value.appVersion) && value.fingerprintHash === null !== (value.appVersion === null) && isTextOrNull$1(value.gitCommitHash) && isTextOrNull$1(value.buildKey) && (value.buildNumber === void 0 || isTextOrNull$1(value.buildNumber)) && isRecord$5(value.artifact) && typeof value.artifact.kind === "string" && NATIVE_BUILD_ARTIFACT_KINDS.has(value.artifact.kind) && isText$2(value.artifact.storageUri) && Number.isSafeInteger(value.artifact.byteSize) && value.artifact.byteSize >= 0 && isRecord$5(value.embeddedBundle) && isText$2(value.embeddedBundle.manifestStorageUri) && isText$2(value.embeddedBundle.manifestFileHash) && isText$2(value.embeddedBundle.assetBaseStorageUri) && (value.embeddedBundle.metadata === void 0 || isRecord$5(value.embeddedBundle.metadata));
6579
7020
  const scopeKeyOf = (params) => decodeMaybe(requireRouteParam(params, "scopeKey")) ?? "";
6580
7021
  /** Every admin route's handler, by the name `ADMIN_ROUTES` mounts it under. */
6581
7022
  const createAdminRouteHandlers = () => ({
@@ -6695,6 +7136,37 @@ const createAdminRouteHandlers = () => ({
6695
7136
  }) });
6696
7137
  }),
6697
7138
  preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json$2({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
7139
+ registerNativeBuild: onCore(async (core, request) => {
7140
+ const input = await body(request);
7141
+ if (!isNativeBuildRegistration(input)) throw new HandlerBadRequestError("Invalid native build");
7142
+ return json$2({ data: await refusingInvalid(() => core.registerNativeBuild(input)) }, 201);
7143
+ }),
7144
+ findNativeBuild: onCore(async (core, request) => {
7145
+ const url = new URL(request.url);
7146
+ const scope = nativeBuildScopeOf(url);
7147
+ const buildKey = url.searchParams.get("buildKey");
7148
+ return json$2({ data: await refusingInvalid(() => buildKey === null ? core.findNativeBuild(scope) : core.findNativeBuild(scope, { buildKey })) });
7149
+ }),
7150
+ getNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.getNativeBuild(requireRouteParam(params, "id")) })),
7151
+ listNativeBuilds: onCore(async (core, request) => {
7152
+ const url = new URL(request.url);
7153
+ const input = keyset(url);
7154
+ const channelId = url.searchParams.get("channelId");
7155
+ return page(await core.listNativeBuilds({
7156
+ ...input,
7157
+ ...channelId === null ? {} : { channelId }
7158
+ }), input, ({ id }) => id);
7159
+ }),
7160
+ recordNativeBuildSubmission: onCore(async (core, request, params) => {
7161
+ const input = await body(request);
7162
+ if (!isRecord$5(input) || input.destination !== "testflight" && input.destination !== "play-internal" || !isText$2(input.version)) throw new HandlerBadRequestError("A native build submission needs destination (testflight or play-internal) and version.");
7163
+ const submission = {
7164
+ destination: input.destination,
7165
+ version: input.version
7166
+ };
7167
+ return json$2({ data: await refusingInvalid(() => core.recordNativeBuildSubmission(requireRouteParam(params, "id"), submission)) });
7168
+ }),
7169
+ deleteNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.deleteNativeBuild(requireRouteParam(params, "id")) })),
6698
7170
  deleteBundles: onCore(async (core, request) => {
6699
7171
  const { ids } = await body(request);
6700
7172
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) throw new HandlerBadRequestError("Bundle deletion needs ids");
@@ -6813,6 +7285,36 @@ const ADMIN_ROUTES = [
6813
7285
  method: "GET",
6814
7286
  path: "/base-candidates/:candidateKey",
6815
7287
  handler: "findBaseCandidates"
7288
+ },
7289
+ {
7290
+ method: "POST",
7291
+ path: "/native-builds",
7292
+ handler: "registerNativeBuild"
7293
+ },
7294
+ {
7295
+ method: "GET",
7296
+ path: "/native-builds/latest",
7297
+ handler: "findNativeBuild"
7298
+ },
7299
+ {
7300
+ method: "GET",
7301
+ path: "/native-builds/:id",
7302
+ handler: "getNativeBuild"
7303
+ },
7304
+ {
7305
+ method: "GET",
7306
+ path: "/native-builds",
7307
+ handler: "listNativeBuilds"
7308
+ },
7309
+ {
7310
+ method: "POST",
7311
+ path: "/native-builds/:id/submission",
7312
+ handler: "recordNativeBuildSubmission"
7313
+ },
7314
+ {
7315
+ method: "DELETE",
7316
+ path: "/native-builds/:id",
7317
+ handler: "deleteNativeBuild"
6816
7318
  }
6817
7319
  ];
6818
7320
  //#endregion
@@ -13807,6 +14309,14 @@ const adapterOf = ({ tableName, ...clientConfig }) => {
13807
14309
  };
13808
14310
  };
13809
14311
  /**
14312
+ * The resource this database reaches, without credentials, under the key
14313
+ * `hot-updater doctor` reads to tell the database `hot-updater init` saved
14314
+ * from another (a convention CLAUDE.md documents).
14315
+ */
14316
+ const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
14317
+ /** `target` under {@link DATABASE_TARGET}, to spread into the database. */
14318
+ const marked = (target) => ({ [DATABASE_TARGET]: target });
14319
+ /**
13810
14320
  * Hot Updater's database on one DynamoDB table, through the storage engine.
13811
14321
  * With a distribution, it gives core the CloudFront invalidation core runs
13812
14322
  * after a write that changes what the update-check routes answer.
@@ -13821,6 +14331,7 @@ const dynamoDB = (config) => {
13821
14331
  onCachedRoutesChange: cloudFront?.invalidate,
13822
14332
  aggregateBatching: config.aggregateBatching ?? {}
13823
14333
  }),
14334
+ ...marked(`dynamodb/${config.tableName}`),
13824
14335
  async dispose() {
13825
14336
  await adapter.dispose?.();
13826
14337
  client.destroy();