HotupdaterHot Updater
Start here

Move from v0 to v1

Migrate your app and infrastructure from v0 to v1 while installed v0 apps keep working.

Plan the migration

Prepare a new v1 backend endpoint and keep the existing v0 URL running. v0 apps cannot use a v1 backend, and v1 apps cannot use a v0 backend. Replacing the server behind the existing URL, or redirecting that URL to v1, breaks OTA delivery for installed v0 apps. Ship the new URL only in a new native build containing the v1 SDK.

Run both backends during migration. For example, existing apps keep using https://updates.example.com/api/check-update, while the new native build uses https://updates-v1.example.com. A separate mount path on the same host also works for a custom server, provided the v0 routes still reach the v0 backend. Removing /api/check-update from an existing URL does not upgrade its backend.

Hot Updater v1 is a new infrastructure generation. It separates immutable Bundle artifacts from mutable Release policy and serves compiled Release Catalogs. There is no in-place v0 database or protocol upgrade: provision v1 alongside v0 and keep the old database, artifacts and download URLs available for as long as installed v0 apps need OTA service.

Compatibility boundary

CombinationSupportedAction
v0 native app → v0 infrastructureYesKeep the old endpoint running while those binaries need OTA updates.
v0 native app → v1 infrastructureNov1 does not expose the v0 app-version or fingerprint routes.
v1 native app → v0 infrastructureNoRelease Catalog and artifact routes are required.
v1 setup → v0 database layout or endpointNoUse a separate v1 database namespace and endpoint.
v1 native app → new v1 infrastructureYesThis is the supported target.

Do not deliver v1 @hot-updater/react-native JavaScript to a v0 native binary by OTA. Receipt and rollback behavior requires the v1 native module.

Remove compressStrategy from v1 configuration. The updater chooses between individual manifest files and one tar.br bulk download after checking local reuse. ZIP and gzip OTA archives are not supported. updateStrategy still selects app-version or fingerprint compatibility; it is unrelated to compression. See Per-file or archive.

Ask your agent to migrate

Install the Hot Updater skill from your project:

npx skills@latest add hot-updater/skills

Then ask your coding agent:

$hot-updater Migrate this project from Hot Updater v0 to v1 using the latest stable v1 release.
Inspect the app and existing provider first. Keep v0 endpoints and data available
for installed apps, and provision separate v1 infrastructure. Update the SDK,
build and provider packages, app URL and client authentication, existing signing
setup, and native/OTA build environment variables. Prepare a new native build
and verify OTA delivery, rollback, and Insights in a dedicated test channel.
Report the verified results and any remaining native release steps.

The agent discovers the app root, build system, provider and existing resources. You can name your provider if you already know it; you do not need to collect resource IDs first. Authenticate through provider login or private credential storage when needed. Do not paste tokens or private keys into the conversation.

Complete the migration

Follow the steps below yourself or have the agent execute them. Commands run in the app directory unless a server directory is specified. Replace example URLs, platform, channel and version with your own values. Keep secrets in ignored local files or your provider's secret store.

Use Node.js 20.19+ for the CLI. Generated infrastructure helpers require Node.js 22.18+ or 24+. Have the native toolchain available for each supported platform; OTA verification requires a Release build, not Expo Go or Metro.

1. Preserve v0

Record the v0 public endpoint, provider resource identifiers, configuration, and credential locations. Back up its metadata database. Keep the old resources serving installed v0 binaries throughout adoption. Retain a separate checkout with its v0 package versions, lockfile, hot-updater.config.ts, build variables and .env.hotupdater for any further v0 OTA releases. Keep v0 and v1 CI jobs pointing at their respective configurations; never deploy a v1-built OTA to v0.

2. Install v1 and provision separate infrastructure

After preserving the v0 checkout, install the runtime and CLI in the migration checkout using your existing package manager:

npm install @hot-updater/react-native
npm install --save-dev hot-updater

Upgrade the build adapter you already use (choose one):

npm install --save-dev @hot-updater/bare

For Expo substitute @hot-updater/expo; for Rock use @hot-updater/rock. Upgrade your managed provider package in the same way (choose one):

npm install --save-dev @hot-updater/cloudflare

Other provider choices are @hot-updater/aws, @hot-updater/supabase and @hot-updater/firebase. Update any signing or integration packages already in use too. In a custom server workspace, upgrade @hot-updater/server and its provider packages as runtime dependencies, and hot-updater as a devDependency. In the app, update @hot-updater/standalone if it connects to that server.

Use the same v1 version for hot-updater and all installed @hot-updater/* packages. Check the resolved versions and retain the lockfile; stop if the commands resolve to v0. Replace a direct @hot-updater/core dependency/import with @hot-updater/protocol; remove @hot-updater/js and update its consumers using the replacements below.

npx --no-install hot-updater --version
npx --no-install hot-updater init --help

Use a separate v1 database namespace and endpoint. Firebase and Supabase can reuse the existing project: v1 uses the hot_updater_v1 Firestore collection or hot_updater_v1_* tables and a separate hot-updater-v1 function. Cloudflare needs a separate D1 database and Worker. Keep each provider's v0 resources intact.

Init reads saved resource names from .env.hotupdater and the environment as its inputs, so change the v0 names before running it. For Supabase, set HOT_UPDATER_SUPABASE_FUNCTION_NAME=hot-updater-v1; v0 saved update-server. For Cloudflare, set HOT_UPDATER_CLOUDFLARE_WORKER_NAME to a new name and replace HOT_UPDATER_CLOUDFLARE_D1_DATABASE_ID and HOT_UPDATER_CLOUDFLARE_D1_DATABASE_NAME with a new database's values, or clear them for interactive creation. The default Worker name is still hot-updater. Check CI/process environment variables too; they must not point at v0 resources.

Managed initialization rejects selected v0 resources before mutating them. Self-hosted SQL and MongoDB schema initialization also rejects v0 schema markers; create schema 1.0.0 on empty storage using the selected adapter's schema workflow. Do not erase a marker to force an in-place upgrade.

For AWS, create separate v1 DynamoDB, Lambda and CloudFront resources. Copy the old Lambda name and distribution ID to the v0 deployment configuration, then remove HOT_UPDATER_AWS_LAMBDA_NAME and HOT_UPDATER_CLOUDFRONT_DISTRIBUTION_ID from .env.hotupdater before running init. Init creates the new resources and writes their names to .env.hotupdater, including the new HOT_UPDATER_DYNAMODB_TABLE_NAME. With noninteractive init --from-env-file, supply the new DynamoDB table name yourself.

Storage buckets do not have to be new, but preserve the access policies and objects still needed by v0. Do not prune shared storage using only the v1 database while v0 binaries still need those artifacts.

Choose one provisioning path after preparing those new resource names:

Managed, terminal workflow: run the installed v1 init, select your provider and complete its provisioning prompts using the new resources:

npx hot-updater init

Save the resulting v1 URL and HOT_UPDATER_API_KEY. Init provisions the server; the URL, client key and merged config are the inputs to step 3.

Managed, agent workflow: generate an upgrade scaffold with both selections (for example, Cloudflare and Expo):

npx hot-updater agent infra upgrade --provider cloudflare --build expo

Use aws, supabase or firebase for other providers, and bare or rock for other builds. The command only writes local files. Read its printed COMMON.md, SETUP.md, ENVIRONMENT.md and ordered upgrades/ files, then apply the provider commands there to the separate v1 resources. Merge app/hot-updater.config.ts into the app and run the generated client-credential helper. Do not continue until the new server is deployed and the client key is registered. A scaffold directory alone is not a working backend.

Custom server: complete the custom-server migration below now, including its schema, authenticated mounts and client-key registration, then return to step 3. Do not run managed init against a custom server.

The v1 database starts with an empty Bundle history; v0 policy is not backfilled. Rebuild the desired application revisions with v1 packages and deploy them to v1. Do not import v0 archives or copy v0 policy rows.

Before deploying, merge the generated v1 hot-updater.config.ts and remove obsolete options; init can add replacements beside the old fields:

Existing configurationv1 change
compressStrategy, releaseChannelRemove both. Set the native channel through channel set or the Expo plugin.
AWS s3DatabaseUse dynamoDB with the new table; the deploy identity needs DynamoDB read/write access.
Cloudflare r2Storage({ cloudflareApiToken })Use credentials: { accessKeyId, secretAccessKey } from the R2 S3 credentials. The Cloudflare API token still configures D1.
Supabase supabaseAnonKeyUse supabaseServiceRoleKey in both adapters, from HOT_UPDATER_SUPABASE_SERVICE_ROLE_KEY. v0 init stored a service-role key under HOT_UPDATER_SUPABASE_ANON_KEY; an actual anon key cannot replace it.
Managed configuration without pluginsImport apiKeys, insights and remoteConfig from hot-updater/plugins and set plugins: [apiKeys(), insights(), remoteConfig()] to match the managed server.

3. Update the app URL, client key and build environment

Update the app's HotUpdater.init() configuration to use the v1 server base URL. For example, replace a v0 URL ending in /api/check-update with the new v1 base URL. The SDK appends the Release Catalog and artifact routes itself. Preserve any base path required by your provider or custom server. Use the endpoint created in step 2:

Providerv1 app baseURL
AWSThe new CloudFront distribution URL, without /api/check-update.
CloudflareThe new Worker's URL, without /api/check-update.
FirebaseThe new hot-updater-v1 function URL, without /api/check-update.
Supabasehttps://<ref>.supabase.co/functions/v1/hot-updater-v1.
Custom serverThe new mount of handlers.client, not handlers.admin.

Managed providers require the client API key in requestHeaders["x-api-key"]. The agent registers or reuses that key in the new v1 infrastructure and connects it to the app's build-time configuration. Only the client key belongs in the app; Cloudflare, R2 and other provider credentials remain on the deployment machine or server.

For an Expo app using public environment variables, the resulting setup can look like this:

src/hotUpdater.ts
import { HotUpdater, insights } from "@hot-updater/react-native";

const baseURL = process.env.EXPO_PUBLIC_HOT_UPDATER_BASE_URL;
const clientApiKey = process.env.EXPO_PUBLIC_HOT_UPDATER_API_KEY; 

if (!baseURL || !clientApiKey) {
  throw new Error("Missing Hot Updater URL or client API key");
}

export const hotUpdater = HotUpdater.init({
  baseURL,
  requestHeaders: {
    "x-api-key": clientApiKey,
  },
  plugins: [insights()], // managed servers include Insights
  onError: (error) => console.error("Hot Updater:", error),
});

Have the agent verify that both values reach native builds and OTA bundle builds, including CI/EAS environments. Writing HOT_UPDATER_API_KEY to .env.hotupdater alone does not configure the app runtime. Check the project's environment-loading path and fail the build when required values are missing. An app that skips initialization when its URL is absent can appear healthy while OTA and Insights are inactive.

See the Cloudflare guide for a managed-provider example, or API Key Authentication for a custom server.

Update app lifecycle handlers

HotUpdater keeps only init, which returns the app's HotUpdater instance. Every other method is on that instance: call init once at the top level of a module, export the instance, and replace each HotUpdater.<method> call with hotUpdater.<method>, such as hotUpdater.checkForUpdate({ updateStrategy: "appVersion" }) and hotUpdater.getBundleId().

wrap is on the instance too, and takes only the update flow: updateStrategy, fallbackComponent, onProgress, reloadOnForceUpdate, and onUpdateProcessCompleted. Move baseURL, requestHeaders, requestTimeout, plugins, onError, and onNotifyAppReady to HotUpdater.init.

Keep the authenticated src/hotUpdater.ts module above. For an existing wrap integration, import that instance in the app entry point:

App.tsx
import { HotUpdater } from "@hot-updater/react-native"; 
import { hotUpdater } from "./src/hotUpdater"; 

export default HotUpdater.wrap({ 
export default hotUpdater.wrap({ 
  baseURL: "<your-update-server-url>", 
  updateStrategy: "appVersion",
  fallbackComponent: Splash,
})(App);

Remove updateMode: "auto". For updateMode: "manual", remove wrap, export App directly and call the instance from your own update flow. HotUpdaterOptions becomes HotUpdaterWrapOptions; replace ManualUpdateOptions with the appropriate init or wrap options.

An app that checks from its own flow instead keeps init and calls hotUpdater.checkForUpdate({ updateStrategy: "appVersion" }), as the custom update flow shows. Each init call creates an independent instance, so keep the one call in its own module.

Remove resolver, client authorityId, HotUpdaterResolver, resolver helper types and createDefaultResolver. A custom GraphQL/RPC backend must expose the v1 Catalog, artifact, Insights-event and /version HTTP protocol through an adapter or proxy; configure that endpoint as baseURL.

Update onNotifyAppReady and direct notifyAppReady() consumers for the NotifyAppReadyResult union: STABLE becomes UNCHANGED, applied transitions return UPDATE_APPLIED, and recovery uses fromBundleId/toBundleId instead of crashedBundleId. Optional fromReleaseId/toReleaseId carry selection identities. Handle each status before reading transition fields.

To report to Insights, add the Insights client plugin to plugins in HotUpdater.init. An app without it sends no Insights reports.

The positional updateBundle(bundleId, fileUrl) overload is removed. Prefer updateInfo.updateBundle() from checkForUpdate, which resolves the selected artifact and preserves the transition, or pass the complete parameter object to the direct API.

The check result no longer includes fileUrl, fileHash, manifestUrl, manifestFileHash or changedAssets. Replace custom code that builds an update from those fields with updateInfo.updateBundle().

For progress UI, use progress and per-file byte counts in details.files. Top-level downloadedBytes and totalBytes are removed from the store, fallback props and progress events; artifactType is now "diff" or null.

For BugSnag's default source-map integration, set codeBundleId: hotUpdater.getManifest().bundleId. getBundleId() returns the public update ID, which can change on promotion even when the artifact and source maps are unchanged.

For a manual flow, keep the instance above and call this from your existing update button or foreground check. Initialization alone never checks for updates:

src/checkAndApplyUpdate.ts
import { hotUpdater } from "./hotUpdater";

export async function checkAndApplyUpdate() {
  const update = await hotUpdater.checkForUpdate({
    updateStrategy: "appVersion", // use "fingerprint" if configured
  });
  if (!update) return;
  const applied = await update.updateBundle();
  if (applied) await hotUpdater.reload();
}

Keep error handling and reload timing appropriate to your existing UI. This helper also applies a server-selected rollback; do not filter it to new updates only.

4. Update native configuration and preserve bundle signing

For Expo, replace the old @hot-updater/react-native entry in app.json or app.config.ts plugins with @hot-updater/expo. Keep @hot-updater/react-native installed as the runtime SDK. Preserve the app's channel and other unrelated plugin options.

If the app already uses bundle signing, retain its signer only if it meets the RSA minimum of 2048 bits and export the matching SPKI PEM public key. Weaker or unsupported keys require a replacement through the native-first rotation procedure. Export the configured signer's public key before prebuild:

npx hot-updater keys export-public --output ./keys/public-key.pem

This refuses to overwrite a file. Reuse an existing matching public key; do not regenerate the signer just to bypass that check. A signed Expo app's plugin entry looks like this:

app.json
{
  "expo": {
    "plugins": [
      [
        "@hot-updater/react-native", 
        "@hot-updater/expo", 
        {
          "channel": "production",
          "publicKeyPath": "./keys/public-key.pem"
        }
      ]
    ]
  }
}

For an app without bundle signing, omit publicKeyPath. For a signed app, include keys/public-key.pem in source control and native build inputs while keeping private keys ignored. If keys/ is ignored, replace that rule with keys/* and !keys/public-key.pem so the public file can be included. If .easignore exists, check it includes the public file too. Remove private-key EAS variables and broad !/keys exceptions that existed only for v0 prebuild; keep the private key available to the environment that actually deploys OTA.

The Expo plugin reads publicKeyPath explicitly. Enabling signing in hot-updater.config.ts alone does not embed the public key. For bare React Native, have the agent export the public key to the native projects with hot-updater keys export-public. Verify that the native key matches the deployment signer before building. See Bundle Signing.

Expo's app-plugin configuration owns the native public file. Retaining the same signing key still requires a new native build with the v1 SDK and endpoint. If the key changes, ship that new native trust anchor before serving artifacts signed by the replacement key to that app population.

Migrate Android native configuration

Search native projects for hot_updater_channel, hot_updater_fingerprint_hash and hot_updater_public_key in strings.xml. v1 reads only <meta-data> inside the manifest's <application>: com.hotupdater.CHANNEL, com.hotupdater.FINGERPRINT_HASH and com.hotupdater.PUBLIC_KEY. Old string values are ignored, which can silently reset the channel to production or prevent signed updates from loading.

For bare apps, run hot-updater channel set <channel>, hot-updater fingerprint create when using fingerprints, and hot-updater keys export-public --yes when signing. These commands write the manifest entries. Expo prebuild writes them from the plugin configuration. Remove the old strings and platform.android.stringResourcePaths from hot-updater.config.ts; check any build-flavor overrides as well.

For React Native before 0.82, explicitly set newArchEnabled=false in android/gradle.properties if the app uses the old architecture and the setting is absent. The v1 Android library defaults to the new architecture.

5. Verify infrastructure and build the native app

Choose an unused channel such as v1-migration-test before building. For Expo, set the plugin's channel to that value and run npx expo prebuild. For bare/Rock apps, run npx hot-updater channel set v1-migration-test and install iOS dependencies with pod install from ios/. If using fingerprints, run npx hot-updater fingerprint create after the native/config changes and before building; do not recreate it between this binary and its test updates.

Run from the app directory with the deployed v1 URL:

npx hot-updater doctor --server-base-url https://updates-v1.example.com

The new /version response must report infrastructureGeneration: 1 and the expected server version. Resolve Doctor errors before continuing. For the agent scaffold path, also run the authenticated infrastructure probe (substitute the printed scaffold directory and your actual platform/version):

npx hot-updater doctor --scope infrastructure --infra-dir <scaffold-path> --json \
  --server-base-url https://updates-v1.example.com \
  --platform ios --channel v1-migration-test --app-version 1.0.0

For fingerprint matching, replace --app-version with --fingerprint <hash>. Require exit code 0 and success: true; inspect notChecked for remaining provider checks. The probe checks Catalog access without and with a client key. A passing public /version check alone does not establish authenticated access or device delivery.

Build and install a Release binary with the v1 SDK, URL, client key and signing public key (if enabled). Choose the command matching your build system:

Build systemiOSAndroid
React Native CLInpx react-native run-ios --mode Releasenpx react-native run-android --mode Release
Exponpx expo run:ios --configuration Releasenpx expo run:android --variant release
Rocknpx rock run:ios --configuration Releasenpx rock run:android --variant Release

Use your project's equivalent command for custom schemes/flavors. Confirm the app loads its embedded JavaScript without Metro. Keep this binary installed.

6. Verify OTA on the new native build

First verify a retained v0 binary can still check, download and apply an OTA from the old backend, using the preserved v0 deployment checkout.

For v1, add a visible label such as v1 OTA baseline to the JavaScript UI and deploy from the v1 checkout. These commands assume iOS, the test channel above, and native app version 1.0.0; substitute your actual values:

npx hot-updater deploy -p ios -c v1-migration-test -t 1.0.0

For updateStrategy: "fingerprint", omit -t and preserve the fingerprint embedded in the binary. Use -p android for Android. Keep rollout at 100% on this dedicated test channel and save the printed public ID as the baseline ID. Run the app's update flow and verify the baseline label after restart.

Next change the label and a local image, then run the same deploy command. Do not rebuild/reinstall the native app or change native dependencies. Save this second ID. Run the update flow again; confirm both changes after restart and compare hotUpdater.getBundleId() with the second deployment ID. If bundle diffing is enabled, verify a partial update from the baseline as well.

Disable only the second test update:

npx hot-updater bundle show <second-id>
npx hot-updater bundle disable <second-id>

Run the app's update flow again. It should return to the first OTA baseline. Then disable the first ID and repeat: with no other eligible releases, it should return to the native embedded bundle. An ID change before restart is not proof that the new JavaScript ran; verify the visible screen too.

In the isolated test channel, additionally deploy a controlled startup failure and verify recovery to a working bundle on relaunch. Remove/disable that broken release after the check. With insights() in the client, open npx hot-updater console and verify the application and recovery reports for the test IDs. Repeat on every supported platform.

Record server deployment, native launch, OTA application, rollback and store release separately. An upload at rollout 0 proves no device delivery.

7. Release the new native app

Restore the intended production native channel (Expo plugin or channel set), rerun npx expo prebuild for Expo to write that channel into native projects, then refresh the fingerprint if applicable and build the production native binary. Verify its v1 connection too; the test-channel binary is not the production release. Ship that verified v1 native build before targeting it with production v1 OTA updates. Update CI to use the v1 provider configuration, app build variables, signer and compatibility target. Keep a separate v0 deployment configuration for any OTA releases still needed by installed v0 binaries.

A store-delivered native upgrade can preserve the app's local sandbox, but the v1 native SDK does not read the OTA state v0 left there: the new build launches its built-in bundle, then applies v1 updates. Verify an upgrade from a retained v0 installation without clearing app data.

The endpoints remain parallel during adoption: existing v0 binaries use the v0 endpoint, and new v1 binaries use the v1 endpoint. There is no server-side compatibility bridge between them.

Update custom server and provider integrations

Managed users should apply their provider's v1 scaffold. If you maintain custom server code or plugins, update these boundaries before connecting a v1 app:

v0 surfacev1 replacement
Unified createHandler, createHotUpdater().handler, HandlerOptionscreateHotUpdater().handlers.client and .admin. Mount the admin handler behind framework authentication before the broader client mount.
toNodeHandler(hotUpdater) from @hot-updater/server/nodeImport it from @hot-updater/server and pass the individual handler, such as toNodeHandler(hotUpdater.handlers.admin).
Instance methods such as getBundleById, getBundles, insertBundle, updateBundleById, deleteBundleById and getChannelshotUpdater.core, where the public Bundle ID is a Release ID: getRelease, listReleases, deploy, updateReleasePolicy, deleteRelease and listChannels. getBundle and listBundles read the artifacts.
storages: [adapter], storagePluginsstorage: adapter in createHotUpdater, required: one storage adapter, the one hot-updater.config.ts uploads with.
Database/storage () => plugin thunksPass database and storage objects directly to createHotUpdater and defineConfig. Built-in provider factories already return those objects. List the server's plugins in plugins of both.
Client routes without authenticationChoose a policy: apiKeys() from @hot-updater/server/plugins in plugins requires x-api-key, or the headerName you pass. To keep the v0 behavior, set clientAccess: "public" instead.
routes: { updateCheck, bundles } and the authentication on /api/*Mount the handler surfaces explicitly: handlers.client for update checks, and handlers.admin for Bundle management behind the authentication that guarded /api/*.
Server basePathLet the framework own the mount path and use that client root as the app's baseURL.
Server cwd, runtime request-context parametersRemove these settings. Capture credentials, SDK clients and runtime bindings when constructing adapters. Handlers take one Request.
standaloneRepository.baseUrl pointing at a shared client rootUse the exact admin root, such as https://example.com/hot-updater/admin. Remove custom routes: the repository speaks the admin API's protocol 2.

A v1 server lists the plugins it runs; plugins defaults to none:

src/hotUpdater.ts
import { createHotUpdater } from "@hot-updater/server";
import { kyselyAdapter } from "@hot-updater/server/adapters/kysely";
import { apiKeys, insights } from "@hot-updater/server/plugins";

import { db } from "./db";
import { storage } from "./storage";

export const hotUpdater = createHotUpdater({
  database: kyselyAdapter({ db, provider: "postgresql" }),
  storage,
  plugins: [insights(), apiKeys()],
});

Client routes have one policy: a plugin that provides client authentication, such as apiKeys(), or clientAccess: "public". Startup throws HotUpdaterConfigError when both or neither are set. hotUpdater.api holds each plugin's API by id, such as hotUpdater.api.apiKeys.create({ name }).

The app receives the client API key, never the admin bearer token. Protect the admin mount with middleware and fail startup if its credential is missing. For example, in a separate v1 Hono server, mount the protected admin handler before the broader client handler. Keep the old server/routes deployed:

src/app.ts (v1 server)
import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { hotUpdater } from "./hotUpdater";

const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");

const app = new Hono();
app.use("/hot-updater/admin/*", bearerAuth({ token: adminToken }));
app.mount("/hot-updater/admin", hotUpdater.handlers.admin);
app.mount("/hot-updater", hotUpdater.handlers.client);
export default app;

Use your existing framework's equivalent authentication/mount mechanism if it is not Hono. Deploy this server at the new v1 origin through your existing hosting workflow. For this example, the app uses https://updates-v1.example.com/hot-updater.

In the app's deployment config, keep the build adapter and storage pointing at the same artifact bucket as the v1 server, and replace the repository/plugin fields with the following excerpt. Load the environment using your existing .env.hotupdater setup before reading the token:

hot-updater.config.ts (excerpt)
import { standaloneRepository } from "@hot-updater/standalone";
import { defineConfig } from "hot-updater";
import { apiKeys, insights } from "hot-updater/plugins";

const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");

export default defineConfig({
  // Keep your build, storage, signing and updateStrategy fields here.
  database: standaloneRepository({
    baseUrl: "https://updates-v1.example.com/hot-updater/admin",
    commonHeaders: { Authorization: `Bearer ${adminToken}` },
  }),
  plugins: [insights(), apiKeys()],
});

List exactly the plugins configured on your server, with matching options. Never pass this admin token to the mobile app.

The v0 /app-version/... and /fingerprint/... decision endpoints and Hot-Updater-SDK-Version header contract are removed. The client reads /release-catalogs/app-version/:platform/:channelKey/:appVersion or /release-catalogs/fingerprint/:platform/:channelKey/:fingerprintHash, selects locally, then resolves /artifacts/v1/:targetBundleId/from/:currentBundleId. Channel names are encoded into channelKey; neither Catalog path includes an installation identity. Do not add an API-version prefix to these routes. Persistent Channel management uses the authenticated admin root's /channels[/:id], not /api/bundles/channels.

Initialize custom database schemas

Run these commands in the v1 server workspace, with its environment loaded and database connection pointing at new, empty storage. Use the path to your createHotUpdater module in place of src/hotUpdater.ts.

Drizzle: remove schema from drizzleAdapter({ db, provider, schema }), regenerate the schema and include the generated hot-updater-schema.ts in drizzle.config.ts's schema paths. Confirm its database URL is the new v1 database, then apply it:

npx hot-updater db generate src/hotUpdater.ts --yes
npx drizzle-kit push
npx hot-updater db migrate src/hotUpdater.ts --yes

Prisma: generation merges into prisma/schema.prisma by default. Confirm that schema uses the new v1 datasource, regenerate the client, push the schema, then initialize engine state:

npx hot-updater db generate src/hotUpdater.ts --yes
npx prisma generate
npx prisma db push
npx hot-updater db migrate src/hotUpdater.ts --yes

Use the generated schema file in your ORM's configuration. Schema push alone is insufficient: without db migrate, the server returns 503.

Kysely or MongoDB:

npx hot-updater db migrate src/hotUpdater.ts --yes

MongoDB requires a replica set or sharded cluster for transactional writes. For a local migration test, start mongod --replSet rs0, run rs.initiate() in mongosh once, and include replicaSet=rs0 in the connection string. Do not use a standalone MongoDB process.

After schema initialization, if the server uses apiKeys(), register the new app's client credential:

npx hot-updater api-key create --name "Mobile app" src/hotUpdater.ts

Save the returned plaintext key in the app's build environment for step 3. Only its hash is persisted; it cannot be retrieved later. Do not substitute the admin token or provider credentials. For clientAccess: "public", skip key creation and omit the client-key requirement/header from the app example.

Start the deployed v1 server and check <client-root>/version. Confirm an unauthenticated request to <admin-root>/releases is rejected and the same request with your admin bearer token succeeds. With apiKeys(), Catalog reads without a client key must return 401; use the app test in step 6 to verify successful authenticated delivery. Then return to step 3.

If a proxy, CDN or WAF fronts the new client endpoint, allow the Catalog and artifact routes and forward x-api-key. With apiKeys(), responses vary by that header, so include it in the CDN cache key.

Database and storage implementations

A custom database provider implements a database adapter: batched get, index-range query, atomic write of guarded operations and fits. Build it on the SQL core (createSqlAdapter), the key-value helper (createKvAdapter) or the contract directly, and return it with createEngineDatabase({ name, adapter }) from @hot-updater/plugin-core. The adapter knows nothing about Bundles or Releases: core and its plugins run on the storage engine above it and compile Release Catalogs there. This replaces the factory/double-curried contract and top-level getBundleById, getBundles, getChannels, getUpdateInfo, commitBundle and onUnmount. Provider query DSLs and provider-owned update selection are not extension points. See Custom Database Adapter, and Server Plugins for features beyond core.

LegacyBundle is removed. Delivery fields (channel, enabled, fingerprintHash, message, rolloutCohortCount, shouldForceUpdate, targetAppVersion, targetCohorts) belong to Release policy, while the immutable Bundle row owns artifact data. Release and artifact UUIDv7 IDs are independent; several Releases may reuse one artifact. Channels are persistent identities referenced by channel_id. Represent patches only through Bundle.patches, not patchBaseBundleId, patchBaseFileHash, patchFileHash or patchStorageUri.

Storage adapters implement the flat createStorageAdapter object contract. Replace supportedProtocol with protocol; remove profiles.node, profiles.runtime, lifecycle hooks, runtime contexts and local-file-path inputs. Each operation accepts and returns an object. put consumes a one-shot Web stream; get returns { response }, getDownloadUrl returns { url }, exists returns { exists }, and single-object deletion returns { deleted: true }. Use createStorageUri/parseStorageUri and the storage contract, including optional explicit-key pruning. Fresh v1 assets use content hashes; do not rely on the v0 mutable per-Bundle asset layout or its cleanup fallback.

For standaloneStorage, add the required protocol matching the storage URI scheme and update the service routes to put, get, exists and delete. The old upload, readText and getDownloadUrl routes are not used. The server still needs a storage adapter that produces device download URLs; see Standalone Storage.

Replace removed provider exports

Package and removed surfaceReplacement
@hot-updater/core@hot-updater/protocol, the data formats and pure functions the app and the server share, under its v1 name.
@hot-updater/aws: s3DatabasedynamoDB; S3 stores artifacts.
@hot-updater/aws: s3LambdaEdgeStorages3Storage.
@hot-updater/aws: withCloudFrontSignedUrls3Storage({ getDownloadUrl: cloudFrontDownloadUrl(...) }); see S3 download URLs.
@hot-updater/cloudflare/worker: context-derived database/storaged1Database(binding) and r2Storage({ bucket, bucketName, accountId, credentials }) with native bindings; the R2 credentials presign download URLs. See Worker storage.
@hot-updater/cloudflare/worker: verifyJwtSignedUrlNone: devices download from the presigned R2 URLs that r2Storage returns.
@hot-updater/supabase: root supabaseEdgeFunctionDatabase and supabaseEdgeFunctionStoragesupabaseDatabase and supabaseStorage from @hot-updater/supabase/edge.
@hot-updater/js: the packageNone: a storage adapter's getDownloadUrl returns the URL devices download from.
@hot-updater/js, @hot-updater/postgres: getUpdateInfoRelease Catalog compilation and exact reads, then device selection; disable the public Bundle for rollback.
@hot-updater/plugin-core: createBlobDatabasePluginA database adapter over a store with atomic conditional writes, such as createKvAdapter({ store }), returned with createEngineDatabase. Plain object storage cannot provide those writes.
@hot-updater/plugin-core: createNodeStoragePlugin, createRuntimeStoragePlugin, createUniversalStoragePlugincreateStorageAdapter with flat operations.
@hot-updater/plugin-core: createRequestUpdateBundleResolver, getRequestUpdateBundleSeedsNone: core resolves an artifact with one batch read of both Bundles and one read of their patch.

Update deployment automation

The public CLI keeps the v0 Bundle workflow. The ID printed by deploy is the same ID shown in the Console and returned by hotUpdater.getBundleId():

v0 command or assumptionv1 command or meaning
bundle list --channel ... --target-app-version ...Unchanged; the rows use the Console ID.
bundle show <bundle-id>bundle show <id> with the deploy or Console ID.
bundle update <bundle-id>bundle update <id>
bundle enable <bundle-id>bundle enable <id>
bundle disable <bundle-id>bundle disable <id>
bundle promote <bundle-id>bundle promote <source-id> --target <channel>
bundle delete <bundle-ids...>Delete one disabled public Bundle at a time with bundle delete <id>.
patch --bundle-id ... --base-bundle-id ...patch --artifact-id ... --base-artifact-id ... with the Artifact IDs from Advanced diagnostics.
top-level rollback <channel>Identify the exact ID and run bundle disable <id>.
Delete old immutable bytes directlyDelete the public bundles that use them; the last one deletes the artifact record. Then preview storage prune.

Deploy and promote create independent update IDs. Multiple updates can reuse the same files.

The command names remain familiar, but v0 JSON automation is not schema compatible. In v1, bundle list --json and bundle show --json return raw internal rows; update scripts that depended on the v0 { data, pagination } list wrapper or v0 Bundle DTO fields. bundle update --json returns { attempts, catalog, release }. Delivery fields use snake_case, channel_id is an identity rather than a channel name, and bundle_id is the artifact ID.

Also check scripts that parse human-readable deploy output: the success line is now Deployment successful, followed by ID: <id> (or iOS ID and Android ID), rather than Deployment Successful (<id>).

Replace bare fingerprint validation with doctor, and bare channel inspection with doctor --json (details.native.ios.channel and details.native.android.channel). fingerprint create and channel set remain available. Remove keys export-public --input; configure signing in hot-updater.config.ts instead. build:android now requires EXPERIMENTAL=1.

Promotion reuses uploaded bytes and never copies Storage objects. The target bundle is enabled with a 100% numeric rollout, no target cohorts, and a new rollout seed. Review those settings before confirming, especially when the source bundle had a partial or QA-only rollout.

Use bundle update --dry-run and --expected-revision before automated policy writes. Use doctor and doctor --fix to check and rebuild compiled projections; they do not migrate v0 policy.

Rollback

To roll back a v1 mobile release, publish another native build or restore the previous v1 deployment while keeping its endpoint and database generation. Do not point v1 binaries at v0 infrastructure.

Removing the old v0 infrastructure is an operational decision based on your remaining installed v0 population. Once removed, those binaries lose OTA service but continue to launch their embedded or last installed bundle.

On this page