@absolutejs/absolute 0.20.0-beta.86 → 0.20.0-beta.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10636,7 +10636,7 @@ __export(exports_config, {
10636
10636
  import { readFileSync as readFileSync11 } from "fs";
10637
10637
  import { resolve as resolve12 } from "path";
10638
10638
  import { createHash as createHash4, createPublicKey, X509Certificate } from "crypto";
10639
- var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, DEFAULT_UPDATE_HEALTH_FAILURE_RATE = 0.2, DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS = 20, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field) => {
10639
+ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, DEFAULT_UPDATE_HEALTH_FAILURE_RATE = 0.2, DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS = 20, DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE = 0.05, DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES = 60, MINUTE_MS, DEFAULT_UPDATE_ROLLOUT_STAGES, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field) => {
10640
10640
  const root = resolve12(projectRoot);
10641
10641
  const path = resolve12(root, value);
10642
10642
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -10844,6 +10844,42 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10844
10844
  throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
10845
10845
  health = { failureRate, minimumReports, secretEnv };
10846
10846
  }
10847
+ const configuredRollout = config.updates.server?.rollout;
10848
+ let rollout;
10849
+ if (configuredRollout !== undefined && configuredRollout !== false) {
10850
+ if (!health)
10851
+ throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
10852
+ if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
10853
+ throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
10854
+ const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
10855
+ const stages = configuredStages.map((stage, index) => {
10856
+ const previousStage = configuredStages[index - 1];
10857
+ const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
10858
+ const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
10859
+ const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
10860
+ const observationMs = observationMinutes * MINUTE_MS;
10861
+ if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
10862
+ throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
10863
+ if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
10864
+ throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
10865
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
10866
+ throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
10867
+ if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
10868
+ throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
10869
+ return {
10870
+ maximumFailureRate,
10871
+ minimumReports,
10872
+ observationMs,
10873
+ rollout: stage.rollout
10874
+ };
10875
+ });
10876
+ if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
10877
+ throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
10878
+ rollout = {
10879
+ automatic: configuredRollout.automatic ?? false,
10880
+ stages
10881
+ };
10882
+ }
10847
10883
  if (autoMount) {
10848
10884
  const manifest = new URL(updates.manifestUrl);
10849
10885
  if (manifest.origin !== productionOrigin)
@@ -10880,6 +10916,7 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10880
10916
  autoMount,
10881
10917
  expoCodeSigningKeys,
10882
10918
  ...health ? { health } : {},
10919
+ ...rollout ? { rollout } : {},
10883
10920
  registryModule
10884
10921
  };
10885
10922
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
@@ -10973,6 +11010,12 @@ var init_config = __esm(() => {
10973
11010
  UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
10974
11011
  UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
10975
11012
  ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
11013
+ MINUTE_MS = 60 * 1000;
11014
+ DEFAULT_UPDATE_ROLLOUT_STAGES = [
11015
+ { minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
11016
+ { minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
11017
+ { minimumReports: 100, observationMinutes: 0, rollout: 1 }
11018
+ ];
10976
11019
  HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/;
10977
11020
  EXPO_RESERVED_ROUTE_PREFIXES = new Set([
10978
11021
  "_expo",
@@ -12302,6 +12345,7 @@ import {
12302
12345
  createHmac,
12303
12346
  createPrivateKey,
12304
12347
  createPublicKey as createPublicKey2,
12348
+ randomUUID,
12305
12349
  sign,
12306
12350
  timingSafeEqual,
12307
12351
  verify,
@@ -12438,7 +12482,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12438
12482
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
12439
12483
  const appId = text2(value.appId, "channel appId");
12440
12484
  const channel = text2(value.channel, "channel");
12441
- if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
12485
+ if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.promotionId !== undefined && (typeof value.promotionId !== "string" || !HASH.test(value.promotionId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
12442
12486
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
12443
12487
  return {
12444
12488
  ...value.activationId ? { activationId: value.activationId } : {},
@@ -12447,6 +12491,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12447
12491
  channel,
12448
12492
  ...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
12449
12493
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
12494
+ ...value.promotionId ? { promotionId: value.promotionId } : {},
12450
12495
  promotedAt: value.promotedAt,
12451
12496
  ...value.releaseId ? { releaseId: value.releaseId } : {},
12452
12497
  rollout: value.rollout
@@ -12469,7 +12514,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12469
12514
  return true;
12470
12515
  const value = createHash5("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
12471
12516
  return value / 4294967296 < input.rollout;
12472
- }, base64Url = (value) => Buffer.from(value).toString("base64url"), healthPromotionId = (channel) => digest(new TextEncoder().encode(`${channel.appId}\x00${channel.channel}\x00${channel.releaseId ?? "embedded"}\x00${channel.promotedAt}`)), finiteMetric = (value, field) => {
12517
+ }, base64Url = (value) => Buffer.from(value).toString("base64url"), healthPromotionId = (channel) => channel.promotionId ?? digest(new TextEncoder().encode(`${channel.appId}\x00${channel.channel}\x00${channel.releaseId ?? "embedded"}\x00${channel.promotedAt}`)), finiteMetric = (value, field) => {
12473
12518
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
12474
12519
  throw new MobileUpdateRegistryError(`Mobile update health ${field} is invalid`);
12475
12520
  return value;
@@ -12490,10 +12535,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12490
12535
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
12491
12536
  const clock = options.clock ?? (() => new Date);
12492
12537
  const health = options.health;
12538
+ const rollout = options.rollout;
12493
12539
  if (health && health.secret.length < 32)
12494
12540
  throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
12495
12541
  if (health && !options.store.list)
12496
12542
  throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
12543
+ if (rollout && (!health || !options.store.list))
12544
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
12545
+ if (rollout && (rollout.stages.length === 0 || rollout.stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || !Number.isSafeInteger(stage.minimumReports) || stage.minimumReports < 1 || !Number.isSafeInteger(stage.observationMs) || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= rollout.stages[index - 1].rollout)))
12546
+ throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
12497
12547
  const minimumReports = health?.autoPause?.minimumReports ?? 20;
12498
12548
  const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
12499
12549
  if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
@@ -12506,6 +12556,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12506
12556
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
12507
12557
  const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
12508
12558
  const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
12559
+ const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
12560
+ const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
12509
12561
  const channelKey = (appId, channel) => {
12510
12562
  if (!APP_ID.test(appId) || !NAME.test(channel))
12511
12563
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -12534,6 +12586,81 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12534
12586
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
12535
12587
  return value;
12536
12588
  };
12589
+ const listPrefix = async (value) => {
12590
+ const objects = [];
12591
+ const cursors = new Set;
12592
+ let cursor;
12593
+ do {
12594
+ const page = await options.store.list({
12595
+ ...cursor ? { cursor } : {},
12596
+ prefix: value
12597
+ });
12598
+ objects.push(...page.objects);
12599
+ if (!page.truncated)
12600
+ break;
12601
+ if (!page.cursor || cursors.has(page.cursor))
12602
+ throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
12603
+ cursors.add(page.cursor);
12604
+ cursor = page.cursor;
12605
+ } while (true);
12606
+ return objects;
12607
+ };
12608
+ const readVerifiedObject = async (key, label) => {
12609
+ const bytes = await options.store.get(key);
12610
+ if (!bytes)
12611
+ return null;
12612
+ const head = await options.store.head(key);
12613
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
12614
+ throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
12615
+ return decode(bytes);
12616
+ };
12617
+ const parseRolloutPlan = (value, promotionId, releaseId) => {
12618
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
12619
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12620
+ const stages = value.stages.map((stage) => {
12621
+ if (!object3(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
12622
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12623
+ return {
12624
+ maximumFailureRate: stage.maximumFailureRate,
12625
+ minimumReports: stage.minimumReports,
12626
+ observationMs: stage.observationMs,
12627
+ rollout: stage.rollout
12628
+ };
12629
+ });
12630
+ if (stages.length === 0 || stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || stage.minimumReports < 1 || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= stages[index - 1].rollout))
12631
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12632
+ return {
12633
+ automatic: value.automatic,
12634
+ createdAt: value.createdAt,
12635
+ format: 1,
12636
+ promotionId,
12637
+ releaseId,
12638
+ stages
12639
+ };
12640
+ };
12641
+ const initializeRollout = async (channel, signal) => {
12642
+ if (!rollout || !channel.releaseId)
12643
+ return;
12644
+ if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
12645
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
12646
+ const promotionId = healthPromotionId(channel);
12647
+ const plan = {
12648
+ automatic: rollout.automatic ?? false,
12649
+ createdAt: channel.promotedAt,
12650
+ format: 1,
12651
+ promotionId,
12652
+ releaseId: channel.releaseId,
12653
+ stages: rollout.stages.map((stage) => ({ ...stage }))
12654
+ };
12655
+ const bytes = json(plan);
12656
+ await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
12657
+ cacheControl: "no-store",
12658
+ contentType: "application/json",
12659
+ maxBytes: bytes.byteLength,
12660
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
12661
+ signal
12662
+ });
12663
+ };
12537
12664
  const signHealthToken = (payload) => {
12538
12665
  if (!health)
12539
12666
  return null;
@@ -12572,12 +12699,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12572
12699
  if (await options.store.head(tombstoneKey(appId, releaseId)))
12573
12700
  throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
12574
12701
  };
12575
- const writeChannel = async (input, signal) => {
12702
+ const writeChannel = async (input, signal, beforeWrite) => {
12703
+ const promotedAt = clock().toISOString();
12576
12704
  const value = {
12577
12705
  ...input,
12578
12706
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
12579
- promotedAt: clock().toISOString()
12707
+ promotedAt,
12708
+ promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID()}`))
12580
12709
  };
12710
+ await beforeWrite?.(value);
12581
12711
  const bytes = json(value);
12582
12712
  await options.store.put(channelKey(value.appId, value.channel), bytes, {
12583
12713
  cacheControl: "no-cache",
@@ -12592,10 +12722,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12592
12722
  });
12593
12723
  return value;
12594
12724
  };
12725
+ const rolloutContext = async (channel) => {
12726
+ if (!options.store.list || !channel.releaseId)
12727
+ return null;
12728
+ const promotionId = healthPromotionId(channel);
12729
+ const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
12730
+ if (storedPlan === null)
12731
+ return null;
12732
+ const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
12733
+ const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
12734
+ if (initialStage < 0)
12735
+ throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
12736
+ let currentStage = initialStage;
12737
+ let enteredAt = plan.createdAt;
12738
+ const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
12739
+ for (const item of advances) {
12740
+ const value = await readVerifiedObject(item.key, "rollout advancement");
12741
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || !Number.isSafeInteger(value.stage) || Number(value.stage) < initialStage || Number(value.stage) >= plan.stages.length || value.rollout !== plan.stages[Number(value.stage)].rollout || !iso(value.createdAt) || typeof value.failureRate !== "number" || !Number.isSafeInteger(value.terminalReports))
12742
+ throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
12743
+ if (Number(value.stage) >= currentStage) {
12744
+ currentStage = Number(value.stage);
12745
+ enteredAt = value.createdAt;
12746
+ }
12747
+ }
12748
+ const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
12749
+ const parsedControls = [];
12750
+ for (const item of controls) {
12751
+ const value = await readVerifiedObject(item.key, "rollout control");
12752
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || typeof value.id !== "string" || value.action !== "pause" && value.action !== "resume" && value.action !== "cancel" || !iso(value.createdAt) || value.resumedPauseIds !== undefined && (!Array.isArray(value.resumedPauseIds) || value.resumedPauseIds.some((id) => typeof id !== "string")))
12753
+ throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
12754
+ parsedControls.push(value);
12755
+ }
12756
+ const cancelled = parsedControls.some(({ action }) => action === "cancel");
12757
+ const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
12758
+ const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
12759
+ const operatorPaused = activePauseIds.length > 0;
12760
+ const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
12761
+ return {
12762
+ cancelled,
12763
+ activePauseIds,
12764
+ channel,
12765
+ currentStage,
12766
+ enteredAt,
12767
+ fleetPaused,
12768
+ operatorPaused,
12769
+ plan,
12770
+ promotionId,
12771
+ rollout: plan.stages[currentStage].rollout
12772
+ };
12773
+ };
12774
+ const writeRolloutControl = async (channel, action, signal) => {
12775
+ const context = await rolloutContext(channel);
12776
+ if (!context)
12777
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12778
+ if (context.cancelled)
12779
+ throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
12780
+ if (action === "resume" && context.fleetPaused)
12781
+ throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
12782
+ const releaseId = channel.releaseId;
12783
+ if (!releaseId)
12784
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
12785
+ const createdAt = clock().toISOString();
12786
+ const id = randomUUID();
12787
+ const event = {
12788
+ action,
12789
+ createdAt,
12790
+ format: 1,
12791
+ id,
12792
+ promotionId: context.promotionId,
12793
+ releaseId,
12794
+ ...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
12795
+ };
12796
+ const bytes = json(event);
12797
+ await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
12798
+ cacheControl: "no-store",
12799
+ contentType: "application/json",
12800
+ maxBytes: bytes.byteLength,
12801
+ metadata: { action, sha256: digest(bytes) },
12802
+ signal
12803
+ });
12804
+ };
12595
12805
  const promoteUpdate = async (input) => {
12596
12806
  input.signal?.throwIfAborted();
12597
12807
  if (input.rollout <= 0 || input.rollout > 1)
12598
12808
  throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
12809
+ if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
12810
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
12599
12811
  await assertNotMarked(input.appId, input.releaseId);
12600
12812
  const release = await readManifest(input.appId, input.releaseId);
12601
12813
  if (!release || release.manifest.channel !== input.channel)
@@ -12607,7 +12819,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12607
12819
  ...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
12608
12820
  releaseId: input.releaseId,
12609
12821
  rollout: input.rollout
12610
- }, input.signal);
12822
+ }, input.signal, (channel) => initializeRollout(channel, input.signal));
12611
12823
  return {
12612
12824
  appId: input.appId,
12613
12825
  channel: input.channel,
@@ -12620,14 +12832,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12620
12832
  const channel = await readChannel(input.appId, input.channel);
12621
12833
  if (!channel?.releaseId)
12622
12834
  return { status: "empty" };
12835
+ const rolloutState = await rolloutContext(channel);
12623
12836
  let selected = rolloutMember({
12624
12837
  appId: input.appId,
12625
12838
  channel: input.channel,
12626
12839
  installationId: input.installationId,
12627
12840
  releaseId: channel.releaseId,
12628
- rollout: channel.rollout
12841
+ rollout: rolloutState?.rollout ?? channel.rollout
12629
12842
  }) ? channel.releaseId : channel.fallbackReleaseId;
12630
- if (health && selected === channel.releaseId && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId)))
12843
+ if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
12631
12844
  selected = channel.fallbackReleaseId;
12632
12845
  if (!selected)
12633
12846
  return { status: "empty" };
@@ -12671,23 +12884,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12671
12884
  if (!channel || !releaseId || channel.releaseId !== releaseId)
12672
12885
  return null;
12673
12886
  const promotionId = healthPromotionId(channel);
12674
- const prefix2 = `${healthRoot(input.appId, promotionId, releaseId)}/events/`;
12675
- const objects = [];
12676
- const cursors = new Set;
12677
- let cursor;
12678
- do {
12679
- const page = await options.store.list({
12680
- ...cursor ? { cursor } : {},
12681
- prefix: prefix2
12682
- });
12683
- objects.push(...page.objects);
12684
- if (!page.truncated)
12685
- break;
12686
- if (!page.cursor || cursors.has(page.cursor))
12687
- throw new MobileUpdateRegistryError("Mobile update health storage returned an invalid cursor");
12688
- cursors.add(page.cursor);
12689
- cursor = page.cursor;
12690
- } while (true);
12887
+ const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
12691
12888
  const installations = new Set;
12692
12889
  const byKind = new Map([...HEALTH_KINDS].map((kind) => [
12693
12890
  kind,
@@ -12726,6 +12923,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12726
12923
  ]);
12727
12924
  const terminals = new Set([...byKind.get("activated"), ...failures]);
12728
12925
  const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
12926
+ const rolloutState = await rolloutContext(channel);
12729
12927
  return {
12730
12928
  activated: byKind.get("activated").size,
12731
12929
  appId: input.appId,
@@ -12734,17 +12932,107 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12734
12932
  downloadFailed: byKind.get("download-failed").size,
12735
12933
  failureRate,
12736
12934
  failures: failures.size,
12737
- paused: Boolean(await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
12935
+ paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
12738
12936
  promotionId,
12739
12937
  quarantined: byKind.get("quarantined").size,
12740
12938
  releaseId,
12741
12939
  reportedInstallations: installations.size,
12742
12940
  rolledBack: byKind.get("rolled-back").size,
12743
- rollout: channel.rollout,
12941
+ rollout: rolloutState?.rollout ?? channel.rollout,
12744
12942
  terminalReports: terminals.size,
12745
12943
  transfer
12746
12944
  };
12747
12945
  };
12946
+ const inspectUpdateRollout = async (input) => {
12947
+ const channel = await readChannel(input.appId, input.channel);
12948
+ if (!channel?.releaseId)
12949
+ return null;
12950
+ const context = await rolloutContext(channel);
12951
+ if (!context)
12952
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12953
+ const healthReport = await inspectUpdateHealth(input);
12954
+ if (!healthReport)
12955
+ return null;
12956
+ const paused = context.fleetPaused || context.operatorPaused;
12957
+ const complete = context.currentStage === context.plan.stages.length - 1;
12958
+ return {
12959
+ ...healthReport,
12960
+ automatic: context.plan.automatic,
12961
+ currentStage: context.currentStage,
12962
+ enteredAt: context.enteredAt,
12963
+ ...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
12964
+ ...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
12965
+ status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
12966
+ };
12967
+ };
12968
+ const advanceRollout = async (input, strict) => {
12969
+ input.signal?.throwIfAborted();
12970
+ const channel = await readChannel(input.appId, input.channel);
12971
+ if (!channel?.releaseId)
12972
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
12973
+ const context = await rolloutContext(channel);
12974
+ if (!context)
12975
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12976
+ const report = await inspectUpdateRollout(input);
12977
+ if (!report)
12978
+ throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
12979
+ const nextStage = context.plan.stages[context.currentStage + 1];
12980
+ if (!nextStage)
12981
+ return report;
12982
+ if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
12983
+ throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
12984
+ if (report.status !== "active") {
12985
+ if (strict)
12986
+ throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
12987
+ return report;
12988
+ }
12989
+ const gate = context.plan.stages[context.currentStage];
12990
+ const observedMs = clock().getTime() - Date.parse(context.enteredAt);
12991
+ const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
12992
+ if (blocked) {
12993
+ if (strict)
12994
+ throw new MobileUpdateRegistryError(`Mobile update rollout needs ${gate.minimumReports} terminal reports, at most ${(gate.maximumFailureRate * 100).toFixed(1)}% failures, and ${gate.observationMs}ms observation at the current stage`);
12995
+ return report;
12996
+ }
12997
+ const stage = context.currentStage + 1;
12998
+ const event = {
12999
+ createdAt: clock().toISOString(),
13000
+ failureRate: report.failureRate,
13001
+ format: 1,
13002
+ promotionId: context.promotionId,
13003
+ releaseId: channel.releaseId,
13004
+ rollout: nextStage.rollout,
13005
+ stage,
13006
+ terminalReports: report.terminalReports
13007
+ };
13008
+ const bytes = json(event);
13009
+ await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
13010
+ cacheControl: "no-store",
13011
+ contentType: "application/json",
13012
+ maxBytes: bytes.byteLength,
13013
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
13014
+ signal: input.signal
13015
+ });
13016
+ return await inspectUpdateRollout(input);
13017
+ };
13018
+ const advanceUpdateRollout = (input) => advanceRollout(input, true);
13019
+ const reconcileUpdateRollout = async (input) => {
13020
+ const report = await inspectUpdateRollout(input);
13021
+ if (!report || !report.automatic)
13022
+ return report;
13023
+ return advanceRollout(input, false);
13024
+ };
13025
+ const rolloutControl = (action) => async (input) => {
13026
+ input.signal?.throwIfAborted();
13027
+ const channel = await readChannel(input.appId, input.channel);
13028
+ if (!channel?.releaseId)
13029
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
13030
+ await writeRolloutControl(channel, action, input.signal);
13031
+ return await inspectUpdateRollout(input);
13032
+ };
13033
+ const pauseUpdateRollout = rolloutControl("pause");
13034
+ const resumeUpdateRollout = rolloutControl("resume");
13035
+ const cancelUpdateRollout = rolloutControl("cancel");
12748
13036
  const recordUpdateHealth = async (input) => {
12749
13037
  const payload = verifyHealthToken(input.token);
12750
13038
  if (payload.appId !== input.appId || payload.channel !== input.channel || payload.installationId !== input.installationId || payload.releaseId !== input.releaseId || payload.runtimeFingerprint !== input.runtimeFingerprint || !HEALTH_KINDS.has(input.kind) || input.reason !== undefined && input.reason !== "boot-interrupted" && input.reason !== "boot-timeout")
@@ -12799,6 +13087,11 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12799
13087
  });
12800
13088
  report = { ...report, paused: true };
12801
13089
  }
13090
+ if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
13091
+ return await reconcileUpdateRollout({
13092
+ appId: input.appId,
13093
+ channel: input.channel
13094
+ }) ?? report;
12802
13095
  return report;
12803
13096
  };
12804
13097
  const retentionValues = (input) => {
@@ -13044,6 +13337,14 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13044
13337
  };
13045
13338
  return {
13046
13339
  ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
13340
+ ...rollout ? {
13341
+ advanceUpdateRollout,
13342
+ cancelUpdateRollout,
13343
+ inspectUpdateRollout,
13344
+ pauseUpdateRollout,
13345
+ reconcileUpdateRollout,
13346
+ resumeUpdateRollout
13347
+ } : {},
13047
13348
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
13048
13349
  pruneUpdates,
13049
13350
  publishUpdate: async (input) => {
@@ -13657,6 +13958,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13657
13958
  return;
13658
13959
  if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
13659
13960
  throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
13961
+ }, verifyRolloutModule = (config, module) => {
13962
+ if (!config.updateServer?.rollout)
13963
+ return;
13964
+ if (typeof module.registry.advanceUpdateRollout !== "function" || typeof module.registry.cancelUpdateRollout !== "function" || typeof module.registry.inspectUpdateRollout !== "function" || typeof module.registry.pauseUpdateRollout !== "function" || typeof module.registry.reconcileUpdateRollout !== "function" || typeof module.registry.resumeUpdateRollout !== "function")
13965
+ throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
13660
13966
  }, expoSigningOptions = (config) => {
13661
13967
  if (!config.updates?.expoCodeSigning)
13662
13968
  return;
@@ -13691,6 +13997,7 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13691
13997
  if (options.production) {
13692
13998
  await verifyDurableModule(module);
13693
13999
  verifyHealthModule(config, module);
14000
+ verifyRolloutModule(config, module);
13694
14001
  }
13695
14002
  const manifest = new URL(updates.manifestUrl);
13696
14003
  if (!manifest.pathname.endsWith("/update.json"))
@@ -13711,6 +14018,7 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13711
14018
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
13712
14019
  await verifyDurableModule(module);
13713
14020
  verifyHealthModule(config, module);
14021
+ verifyRolloutModule(config, module);
13714
14022
  if (config.engine === "expo")
13715
14023
  expoSigningOptions(config);
13716
14024
  return module.metadata;
@@ -13782,17 +14090,25 @@ export default createMobileUpdateRegistry({
13782
14090
  `;
13783
14091
  }, renderAbsoluteMobileUpdateRegistry = (options) => {
13784
14092
  const source = renderAbsoluteMobileUpdateRegistryBase(options);
13785
- if (!options.health)
13786
- return source;
13787
- const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
13788
- const health = ` health: {
14093
+ let generated = "";
14094
+ if (options.health) {
14095
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
14096
+ generated += ` health: {
13789
14097
  autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
13790
14098
  secret: ${secret}
13791
14099
  },
13792
14100
  `;
14101
+ }
14102
+ if (options.rollout)
14103
+ generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
14104
+ `, `
14105
+ `)},
14106
+ `;
14107
+ if (!generated)
14108
+ return source;
13793
14109
  return source.replace(`export default createMobileUpdateRegistry({
13794
14110
  `, `export default createMobileUpdateRegistry({
13795
- ${health}`);
14111
+ ${generated}`);
13796
14112
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
13797
14113
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
13798
14114
  if (!options.force) {
@@ -13805,6 +14121,7 @@ ${health}`);
13805
14121
  await mkdir6(dirname12(path2), { recursive: true });
13806
14122
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
13807
14123
  ...options.health ? { health: options.health } : {},
14124
+ ...options.rollout ? { rollout: options.rollout } : {},
13808
14125
  publicKeys: options.publicKeys,
13809
14126
  storage: options.storage
13810
14127
  }));
@@ -45217,5 +45534,5 @@ export {
45217
45534
  wrapPageHandlerWithStreamingSlots
45218
45535
  };
45219
45536
 
45220
- //# debugId=CC09B0266A0CC45B64756E2164756E21
45537
+ //# debugId=95E12D596BC2404464756E2164756E21
45221
45538
  //# sourceMappingURL=index.js.map