@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.
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  var __require = import.meta.require;
3
3
 
4
- // .angular-partial-tmp-sMuxmM/src/core/streamingSlotRegistrar.ts
4
+ // .angular-partial-tmp-Fz4wlA/src/core/streamingSlotRegistrar.ts
5
5
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
6
6
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
7
7
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  var __require = import.meta.require;
3
3
 
4
- // .angular-partial-tmp-sMuxmM/src/core/streamingSlotRegistrar.ts
4
+ // .angular-partial-tmp-Fz4wlA/src/core/streamingSlotRegistrar.ts
5
5
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
6
6
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
7
7
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -48,7 +48,7 @@ var warnMissingStreamingSlotCollector = (primitiveName) => {
48
48
  getWarningController()?.maybeWarn(primitiveName);
49
49
  };
50
50
 
51
- // .angular-partial-tmp-sMuxmM/src/core/streamingSlotRegistry.ts
51
+ // .angular-partial-tmp-Fz4wlA/src/core/streamingSlotRegistry.ts
52
52
  var STREAMING_SLOT_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotAsyncLocalStorage");
53
53
  var isObjectRecord2 = (value) => Boolean(value) && typeof value === "object";
54
54
  var isAsyncLocalStorage = (value) => isObjectRecord2(value) && ("getStore" in value) && typeof value.getStore === "function" && ("run" in value) && typeof value.run === "function";
package/dist/build.js CHANGED
@@ -27625,7 +27625,7 @@ __export(exports_config, {
27625
27625
  import { readFileSync as readFileSync35 } from "fs";
27626
27626
  import { resolve as resolve44 } from "path";
27627
27627
  import { createHash as createHash5, createPublicKey, X509Certificate } from "crypto";
27628
- 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) => {
27628
+ 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) => {
27629
27629
  const root = resolve44(projectRoot);
27630
27630
  const path = resolve44(root, value);
27631
27631
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -27833,6 +27833,42 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
27833
27833
  throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
27834
27834
  health = { failureRate, minimumReports, secretEnv };
27835
27835
  }
27836
+ const configuredRollout = config.updates.server?.rollout;
27837
+ let rollout;
27838
+ if (configuredRollout !== undefined && configuredRollout !== false) {
27839
+ if (!health)
27840
+ throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
27841
+ if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
27842
+ throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
27843
+ const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
27844
+ const stages = configuredStages.map((stage, index) => {
27845
+ const previousStage = configuredStages[index - 1];
27846
+ const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
27847
+ const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
27848
+ const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
27849
+ const observationMs = observationMinutes * MINUTE_MS;
27850
+ if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
27851
+ throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
27852
+ if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
27853
+ throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
27854
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
27855
+ throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
27856
+ if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
27857
+ throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
27858
+ return {
27859
+ maximumFailureRate,
27860
+ minimumReports,
27861
+ observationMs,
27862
+ rollout: stage.rollout
27863
+ };
27864
+ });
27865
+ if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
27866
+ throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
27867
+ rollout = {
27868
+ automatic: configuredRollout.automatic ?? false,
27869
+ stages
27870
+ };
27871
+ }
27836
27872
  if (autoMount) {
27837
27873
  const manifest = new URL(updates.manifestUrl);
27838
27874
  if (manifest.origin !== productionOrigin)
@@ -27869,6 +27905,7 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
27869
27905
  autoMount,
27870
27906
  expoCodeSigningKeys,
27871
27907
  ...health ? { health } : {},
27908
+ ...rollout ? { rollout } : {},
27872
27909
  registryModule
27873
27910
  };
27874
27911
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
@@ -27962,6 +27999,12 @@ var init_config = __esm(() => {
27962
27999
  UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
27963
28000
  UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
27964
28001
  ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
28002
+ MINUTE_MS = 60 * 1000;
28003
+ DEFAULT_UPDATE_ROLLOUT_STAGES = [
28004
+ { minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
28005
+ { minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
28006
+ { minimumReports: 100, observationMinutes: 0, rollout: 1 }
28007
+ ];
27965
28008
  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])?))*$/;
27966
28009
  EXPO_RESERVED_ROUTE_PREFIXES = new Set([
27967
28010
  "_expo",
@@ -28494,6 +28537,7 @@ import {
28494
28537
  createHmac,
28495
28538
  createPrivateKey,
28496
28539
  createPublicKey as createPublicKey2,
28540
+ randomUUID,
28497
28541
  sign,
28498
28542
  timingSafeEqual,
28499
28543
  verify,
@@ -28630,7 +28674,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28630
28674
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
28631
28675
  const appId = text2(value.appId, "channel appId");
28632
28676
  const channel = text2(value.channel, "channel");
28633
- 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))
28677
+ 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))
28634
28678
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
28635
28679
  return {
28636
28680
  ...value.activationId ? { activationId: value.activationId } : {},
@@ -28639,6 +28683,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28639
28683
  channel,
28640
28684
  ...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
28641
28685
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
28686
+ ...value.promotionId ? { promotionId: value.promotionId } : {},
28642
28687
  promotedAt: value.promotedAt,
28643
28688
  ...value.releaseId ? { releaseId: value.releaseId } : {},
28644
28689
  rollout: value.rollout
@@ -28661,7 +28706,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28661
28706
  return true;
28662
28707
  const value = createHash6("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
28663
28708
  return value / 4294967296 < input.rollout;
28664
- }, 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) => {
28709
+ }, 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) => {
28665
28710
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
28666
28711
  throw new MobileUpdateRegistryError(`Mobile update health ${field} is invalid`);
28667
28712
  return value;
@@ -28682,10 +28727,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28682
28727
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
28683
28728
  const clock = options.clock ?? (() => new Date);
28684
28729
  const health = options.health;
28730
+ const rollout = options.rollout;
28685
28731
  if (health && health.secret.length < 32)
28686
28732
  throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
28687
28733
  if (health && !options.store.list)
28688
28734
  throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
28735
+ if (rollout && (!health || !options.store.list))
28736
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
28737
+ 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)))
28738
+ throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
28689
28739
  const minimumReports = health?.autoPause?.minimumReports ?? 20;
28690
28740
  const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
28691
28741
  if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
@@ -28698,6 +28748,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28698
28748
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
28699
28749
  const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
28700
28750
  const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
28751
+ const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
28752
+ const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
28701
28753
  const channelKey = (appId, channel) => {
28702
28754
  if (!APP_ID.test(appId) || !NAME.test(channel))
28703
28755
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -28726,6 +28778,81 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28726
28778
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
28727
28779
  return value;
28728
28780
  };
28781
+ const listPrefix = async (value) => {
28782
+ const objects = [];
28783
+ const cursors = new Set;
28784
+ let cursor;
28785
+ do {
28786
+ const page = await options.store.list({
28787
+ ...cursor ? { cursor } : {},
28788
+ prefix: value
28789
+ });
28790
+ objects.push(...page.objects);
28791
+ if (!page.truncated)
28792
+ break;
28793
+ if (!page.cursor || cursors.has(page.cursor))
28794
+ throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
28795
+ cursors.add(page.cursor);
28796
+ cursor = page.cursor;
28797
+ } while (true);
28798
+ return objects;
28799
+ };
28800
+ const readVerifiedObject = async (key, label) => {
28801
+ const bytes = await options.store.get(key);
28802
+ if (!bytes)
28803
+ return null;
28804
+ const head = await options.store.head(key);
28805
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
28806
+ throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
28807
+ return decode(bytes);
28808
+ };
28809
+ const parseRolloutPlan = (value, promotionId, releaseId) => {
28810
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
28811
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28812
+ const stages = value.stages.map((stage) => {
28813
+ if (!object3(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
28814
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28815
+ return {
28816
+ maximumFailureRate: stage.maximumFailureRate,
28817
+ minimumReports: stage.minimumReports,
28818
+ observationMs: stage.observationMs,
28819
+ rollout: stage.rollout
28820
+ };
28821
+ });
28822
+ 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))
28823
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28824
+ return {
28825
+ automatic: value.automatic,
28826
+ createdAt: value.createdAt,
28827
+ format: 1,
28828
+ promotionId,
28829
+ releaseId,
28830
+ stages
28831
+ };
28832
+ };
28833
+ const initializeRollout = async (channel, signal) => {
28834
+ if (!rollout || !channel.releaseId)
28835
+ return;
28836
+ if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
28837
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
28838
+ const promotionId = healthPromotionId(channel);
28839
+ const plan = {
28840
+ automatic: rollout.automatic ?? false,
28841
+ createdAt: channel.promotedAt,
28842
+ format: 1,
28843
+ promotionId,
28844
+ releaseId: channel.releaseId,
28845
+ stages: rollout.stages.map((stage) => ({ ...stage }))
28846
+ };
28847
+ const bytes = json(plan);
28848
+ await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
28849
+ cacheControl: "no-store",
28850
+ contentType: "application/json",
28851
+ maxBytes: bytes.byteLength,
28852
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
28853
+ signal
28854
+ });
28855
+ };
28729
28856
  const signHealthToken = (payload) => {
28730
28857
  if (!health)
28731
28858
  return null;
@@ -28764,12 +28891,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28764
28891
  if (await options.store.head(tombstoneKey(appId, releaseId)))
28765
28892
  throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
28766
28893
  };
28767
- const writeChannel = async (input, signal) => {
28894
+ const writeChannel = async (input, signal, beforeWrite) => {
28895
+ const promotedAt = clock().toISOString();
28768
28896
  const value = {
28769
28897
  ...input,
28770
28898
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
28771
- promotedAt: clock().toISOString()
28899
+ promotedAt,
28900
+ promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID()}`))
28772
28901
  };
28902
+ await beforeWrite?.(value);
28773
28903
  const bytes = json(value);
28774
28904
  await options.store.put(channelKey(value.appId, value.channel), bytes, {
28775
28905
  cacheControl: "no-cache",
@@ -28784,10 +28914,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28784
28914
  });
28785
28915
  return value;
28786
28916
  };
28917
+ const rolloutContext = async (channel) => {
28918
+ if (!options.store.list || !channel.releaseId)
28919
+ return null;
28920
+ const promotionId = healthPromotionId(channel);
28921
+ const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
28922
+ if (storedPlan === null)
28923
+ return null;
28924
+ const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
28925
+ const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
28926
+ if (initialStage < 0)
28927
+ throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
28928
+ let currentStage = initialStage;
28929
+ let enteredAt = plan.createdAt;
28930
+ const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
28931
+ for (const item of advances) {
28932
+ const value = await readVerifiedObject(item.key, "rollout advancement");
28933
+ 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))
28934
+ throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
28935
+ if (Number(value.stage) >= currentStage) {
28936
+ currentStage = Number(value.stage);
28937
+ enteredAt = value.createdAt;
28938
+ }
28939
+ }
28940
+ const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
28941
+ const parsedControls = [];
28942
+ for (const item of controls) {
28943
+ const value = await readVerifiedObject(item.key, "rollout control");
28944
+ 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")))
28945
+ throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
28946
+ parsedControls.push(value);
28947
+ }
28948
+ const cancelled = parsedControls.some(({ action }) => action === "cancel");
28949
+ const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
28950
+ const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
28951
+ const operatorPaused = activePauseIds.length > 0;
28952
+ const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
28953
+ return {
28954
+ cancelled,
28955
+ activePauseIds,
28956
+ channel,
28957
+ currentStage,
28958
+ enteredAt,
28959
+ fleetPaused,
28960
+ operatorPaused,
28961
+ plan,
28962
+ promotionId,
28963
+ rollout: plan.stages[currentStage].rollout
28964
+ };
28965
+ };
28966
+ const writeRolloutControl = async (channel, action, signal) => {
28967
+ const context = await rolloutContext(channel);
28968
+ if (!context)
28969
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
28970
+ if (context.cancelled)
28971
+ throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
28972
+ if (action === "resume" && context.fleetPaused)
28973
+ throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
28974
+ const releaseId = channel.releaseId;
28975
+ if (!releaseId)
28976
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
28977
+ const createdAt = clock().toISOString();
28978
+ const id = randomUUID();
28979
+ const event = {
28980
+ action,
28981
+ createdAt,
28982
+ format: 1,
28983
+ id,
28984
+ promotionId: context.promotionId,
28985
+ releaseId,
28986
+ ...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
28987
+ };
28988
+ const bytes = json(event);
28989
+ await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
28990
+ cacheControl: "no-store",
28991
+ contentType: "application/json",
28992
+ maxBytes: bytes.byteLength,
28993
+ metadata: { action, sha256: digest(bytes) },
28994
+ signal
28995
+ });
28996
+ };
28787
28997
  const promoteUpdate = async (input) => {
28788
28998
  input.signal?.throwIfAborted();
28789
28999
  if (input.rollout <= 0 || input.rollout > 1)
28790
29000
  throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
29001
+ if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
29002
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
28791
29003
  await assertNotMarked(input.appId, input.releaseId);
28792
29004
  const release = await readManifest2(input.appId, input.releaseId);
28793
29005
  if (!release || release.manifest.channel !== input.channel)
@@ -28799,7 +29011,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28799
29011
  ...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
28800
29012
  releaseId: input.releaseId,
28801
29013
  rollout: input.rollout
28802
- }, input.signal);
29014
+ }, input.signal, (channel) => initializeRollout(channel, input.signal));
28803
29015
  return {
28804
29016
  appId: input.appId,
28805
29017
  channel: input.channel,
@@ -28812,14 +29024,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28812
29024
  const channel = await readChannel(input.appId, input.channel);
28813
29025
  if (!channel?.releaseId)
28814
29026
  return { status: "empty" };
29027
+ const rolloutState = await rolloutContext(channel);
28815
29028
  let selected = rolloutMember({
28816
29029
  appId: input.appId,
28817
29030
  channel: input.channel,
28818
29031
  installationId: input.installationId,
28819
29032
  releaseId: channel.releaseId,
28820
- rollout: channel.rollout
29033
+ rollout: rolloutState?.rollout ?? channel.rollout
28821
29034
  }) ? channel.releaseId : channel.fallbackReleaseId;
28822
- if (health && selected === channel.releaseId && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId)))
29035
+ if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
28823
29036
  selected = channel.fallbackReleaseId;
28824
29037
  if (!selected)
28825
29038
  return { status: "empty" };
@@ -28863,23 +29076,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28863
29076
  if (!channel || !releaseId || channel.releaseId !== releaseId)
28864
29077
  return null;
28865
29078
  const promotionId = healthPromotionId(channel);
28866
- const prefix2 = `${healthRoot(input.appId, promotionId, releaseId)}/events/`;
28867
- const objects = [];
28868
- const cursors = new Set;
28869
- let cursor;
28870
- do {
28871
- const page = await options.store.list({
28872
- ...cursor ? { cursor } : {},
28873
- prefix: prefix2
28874
- });
28875
- objects.push(...page.objects);
28876
- if (!page.truncated)
28877
- break;
28878
- if (!page.cursor || cursors.has(page.cursor))
28879
- throw new MobileUpdateRegistryError("Mobile update health storage returned an invalid cursor");
28880
- cursors.add(page.cursor);
28881
- cursor = page.cursor;
28882
- } while (true);
29079
+ const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
28883
29080
  const installations = new Set;
28884
29081
  const byKind = new Map([...HEALTH_KINDS].map((kind) => [
28885
29082
  kind,
@@ -28918,6 +29115,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28918
29115
  ]);
28919
29116
  const terminals = new Set([...byKind.get("activated"), ...failures]);
28920
29117
  const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
29118
+ const rolloutState = await rolloutContext(channel);
28921
29119
  return {
28922
29120
  activated: byKind.get("activated").size,
28923
29121
  appId: input.appId,
@@ -28926,17 +29124,107 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28926
29124
  downloadFailed: byKind.get("download-failed").size,
28927
29125
  failureRate,
28928
29126
  failures: failures.size,
28929
- paused: Boolean(await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
29127
+ paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
28930
29128
  promotionId,
28931
29129
  quarantined: byKind.get("quarantined").size,
28932
29130
  releaseId,
28933
29131
  reportedInstallations: installations.size,
28934
29132
  rolledBack: byKind.get("rolled-back").size,
28935
- rollout: channel.rollout,
29133
+ rollout: rolloutState?.rollout ?? channel.rollout,
28936
29134
  terminalReports: terminals.size,
28937
29135
  transfer
28938
29136
  };
28939
29137
  };
29138
+ const inspectUpdateRollout = async (input) => {
29139
+ const channel = await readChannel(input.appId, input.channel);
29140
+ if (!channel?.releaseId)
29141
+ return null;
29142
+ const context = await rolloutContext(channel);
29143
+ if (!context)
29144
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
29145
+ const healthReport = await inspectUpdateHealth(input);
29146
+ if (!healthReport)
29147
+ return null;
29148
+ const paused = context.fleetPaused || context.operatorPaused;
29149
+ const complete = context.currentStage === context.plan.stages.length - 1;
29150
+ return {
29151
+ ...healthReport,
29152
+ automatic: context.plan.automatic,
29153
+ currentStage: context.currentStage,
29154
+ enteredAt: context.enteredAt,
29155
+ ...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
29156
+ ...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
29157
+ status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
29158
+ };
29159
+ };
29160
+ const advanceRollout = async (input, strict) => {
29161
+ input.signal?.throwIfAborted();
29162
+ const channel = await readChannel(input.appId, input.channel);
29163
+ if (!channel?.releaseId)
29164
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
29165
+ const context = await rolloutContext(channel);
29166
+ if (!context)
29167
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
29168
+ const report = await inspectUpdateRollout(input);
29169
+ if (!report)
29170
+ throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
29171
+ const nextStage = context.plan.stages[context.currentStage + 1];
29172
+ if (!nextStage)
29173
+ return report;
29174
+ if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
29175
+ throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
29176
+ if (report.status !== "active") {
29177
+ if (strict)
29178
+ throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
29179
+ return report;
29180
+ }
29181
+ const gate = context.plan.stages[context.currentStage];
29182
+ const observedMs = clock().getTime() - Date.parse(context.enteredAt);
29183
+ const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
29184
+ if (blocked) {
29185
+ if (strict)
29186
+ 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`);
29187
+ return report;
29188
+ }
29189
+ const stage = context.currentStage + 1;
29190
+ const event = {
29191
+ createdAt: clock().toISOString(),
29192
+ failureRate: report.failureRate,
29193
+ format: 1,
29194
+ promotionId: context.promotionId,
29195
+ releaseId: channel.releaseId,
29196
+ rollout: nextStage.rollout,
29197
+ stage,
29198
+ terminalReports: report.terminalReports
29199
+ };
29200
+ const bytes = json(event);
29201
+ await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
29202
+ cacheControl: "no-store",
29203
+ contentType: "application/json",
29204
+ maxBytes: bytes.byteLength,
29205
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
29206
+ signal: input.signal
29207
+ });
29208
+ return await inspectUpdateRollout(input);
29209
+ };
29210
+ const advanceUpdateRollout = (input) => advanceRollout(input, true);
29211
+ const reconcileUpdateRollout = async (input) => {
29212
+ const report = await inspectUpdateRollout(input);
29213
+ if (!report || !report.automatic)
29214
+ return report;
29215
+ return advanceRollout(input, false);
29216
+ };
29217
+ const rolloutControl = (action) => async (input) => {
29218
+ input.signal?.throwIfAborted();
29219
+ const channel = await readChannel(input.appId, input.channel);
29220
+ if (!channel?.releaseId)
29221
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
29222
+ await writeRolloutControl(channel, action, input.signal);
29223
+ return await inspectUpdateRollout(input);
29224
+ };
29225
+ const pauseUpdateRollout = rolloutControl("pause");
29226
+ const resumeUpdateRollout = rolloutControl("resume");
29227
+ const cancelUpdateRollout = rolloutControl("cancel");
28940
29228
  const recordUpdateHealth = async (input) => {
28941
29229
  const payload = verifyHealthToken(input.token);
28942
29230
  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")
@@ -28991,6 +29279,11 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28991
29279
  });
28992
29280
  report = { ...report, paused: true };
28993
29281
  }
29282
+ if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
29283
+ return await reconcileUpdateRollout({
29284
+ appId: input.appId,
29285
+ channel: input.channel
29286
+ }) ?? report;
28994
29287
  return report;
28995
29288
  };
28996
29289
  const retentionValues = (input) => {
@@ -29236,6 +29529,14 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29236
29529
  };
29237
29530
  return {
29238
29531
  ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
29532
+ ...rollout ? {
29533
+ advanceUpdateRollout,
29534
+ cancelUpdateRollout,
29535
+ inspectUpdateRollout,
29536
+ pauseUpdateRollout,
29537
+ reconcileUpdateRollout,
29538
+ resumeUpdateRollout
29539
+ } : {},
29239
29540
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
29240
29541
  pruneUpdates,
29241
29542
  publishUpdate: async (input) => {
@@ -29849,6 +30150,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29849
30150
  return;
29850
30151
  if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
29851
30152
  throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
30153
+ }, verifyRolloutModule = (config, module) => {
30154
+ if (!config.updateServer?.rollout)
30155
+ return;
30156
+ 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")
30157
+ throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
29852
30158
  }, expoSigningOptions = (config) => {
29853
30159
  if (!config.updates?.expoCodeSigning)
29854
30160
  return;
@@ -29883,6 +30189,7 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29883
30189
  if (options.production) {
29884
30190
  await verifyDurableModule(module);
29885
30191
  verifyHealthModule(config, module);
30192
+ verifyRolloutModule(config, module);
29886
30193
  }
29887
30194
  const manifest = new URL(updates.manifestUrl);
29888
30195
  if (!manifest.pathname.endsWith("/update.json"))
@@ -29903,6 +30210,7 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29903
30210
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
29904
30211
  await verifyDurableModule(module);
29905
30212
  verifyHealthModule(config, module);
30213
+ verifyRolloutModule(config, module);
29906
30214
  if (config.engine === "expo")
29907
30215
  expoSigningOptions(config);
29908
30216
  return module.metadata;
@@ -29974,17 +30282,25 @@ export default createMobileUpdateRegistry({
29974
30282
  `;
29975
30283
  }, renderAbsoluteMobileUpdateRegistry = (options) => {
29976
30284
  const source = renderAbsoluteMobileUpdateRegistryBase(options);
29977
- if (!options.health)
29978
- return source;
29979
- const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
29980
- const health = ` health: {
30285
+ let generated = "";
30286
+ if (options.health) {
30287
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
30288
+ generated += ` health: {
29981
30289
  autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
29982
30290
  secret: ${secret}
29983
30291
  },
29984
30292
  `;
30293
+ }
30294
+ if (options.rollout)
30295
+ generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
30296
+ `, `
30297
+ `)},
30298
+ `;
30299
+ if (!generated)
30300
+ return source;
29985
30301
  return source.replace(`export default createMobileUpdateRegistry({
29986
30302
  `, `export default createMobileUpdateRegistry({
29987
- ${health}`);
30303
+ ${generated}`);
29988
30304
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
29989
30305
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
29990
30306
  if (!options.force) {
@@ -29997,6 +30313,7 @@ ${health}`);
29997
30313
  await mkdir13(dirname31(path2), { recursive: true });
29998
30314
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
29999
30315
  ...options.health ? { health: options.health } : {},
30316
+ ...options.rollout ? { rollout: options.rollout } : {},
30000
30317
  publicKeys: options.publicKeys,
30001
30318
  storage: options.storage
30002
30319
  }));
@@ -37599,5 +37916,5 @@ export {
37599
37916
  devBuild
37600
37917
  };
37601
37918
 
37602
- //# debugId=C5E73CF77BAB352E64756E2164756E21
37919
+ //# debugId=EAFB7DB04FFF880464756E2164756E21
37603
37920
  //# sourceMappingURL=build.js.map