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
| Combination | Supported | Action |
|---|---|---|
| v0 native app → v0 infrastructure | Yes | Keep the old endpoint running while those binaries need OTA updates. |
| v0 native app → v1 infrastructure | No | v1 does not expose the v0 app-version or fingerprint routes. |
| v1 native app → v0 infrastructure | No | Release Catalog and artifact routes are required. |
| v1 setup → v0 database layout or endpoint | No | Use a separate v1 database namespace and endpoint. |
| v1 native app → new v1 infrastructure | Yes | This 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/skillsThen 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-updaterUpgrade the build adapter you already use (choose one):
npm install --save-dev @hot-updater/bareFor 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/cloudflareOther 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 --helpUse 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 initSave 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 expoUse 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 configuration | v1 change |
|---|---|
compressStrategy, releaseChannel | Remove both. Set the native channel through channel set or the Expo plugin. |
AWS s3Database | Use 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 supabaseAnonKey | Use 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 plugins | Import 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:
| Provider | v1 app baseURL |
|---|---|
| AWS | The new CloudFront distribution URL, without /api/check-update. |
| Cloudflare | The new Worker's URL, without /api/check-update. |
| Firebase | The new hot-updater-v1 function URL, without /api/check-update. |
| Supabase | https://<ref>.supabase.co/functions/v1/hot-updater-v1. |
| Custom server | The 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:
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:
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:
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.pemThis 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:
{
"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.comThe 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.0For 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 system | iOS | Android |
|---|---|---|
| React Native CLI | npx react-native run-ios --mode Release | npx react-native run-android --mode Release |
| Expo | npx expo run:ios --configuration Release | npx expo run:android --variant release |
| Rock | npx rock run:ios --configuration Release | npx 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.0For 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 surface | v1 replacement |
|---|---|
Unified createHandler, createHotUpdater().handler, HandlerOptions | createHotUpdater().handlers.client and .admin. Mount the admin handler behind framework authentication before the broader client mount. |
toNodeHandler(hotUpdater) from @hot-updater/server/node | Import 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 getChannels | hotUpdater.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], storagePlugins | storage: adapter in createHotUpdater, required: one storage adapter, the one hot-updater.config.ts uploads with. |
Database/storage () => plugin thunks | Pass 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 authentication | Choose 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 basePath | Let the framework own the mount path and use that client root as the app's baseURL. |
Server cwd, runtime request-context parameters | Remove these settings. Capture credentials, SDK clients and runtime bindings when constructing adapters. Handlers take one Request. |
standaloneRepository.baseUrl pointing at a shared client root | Use 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:
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:
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:
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 --yesPrisma: 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 --yesUse 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 --yesMongoDB 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.tsSave 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 surface | Replacement |
|---|---|
@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: s3Database | dynamoDB; S3 stores artifacts. |
@hot-updater/aws: s3LambdaEdgeStorage | s3Storage. |
@hot-updater/aws: withCloudFrontSignedUrl | s3Storage({ getDownloadUrl: cloudFrontDownloadUrl(...) }); see S3 download URLs. |
@hot-updater/cloudflare/worker: context-derived database/storage | d1Database(binding) and r2Storage({ bucket, bucketName, accountId, credentials }) with native bindings; the R2 credentials presign download URLs. See Worker storage. |
@hot-updater/cloudflare/worker: verifyJwtSignedUrl | None: devices download from the presigned R2 URLs that r2Storage returns. |
@hot-updater/supabase: root supabaseEdgeFunctionDatabase and supabaseEdgeFunctionStorage | supabaseDatabase and supabaseStorage from @hot-updater/supabase/edge. |
@hot-updater/js: the package | None: a storage adapter's getDownloadUrl returns the URL devices download from. |
@hot-updater/js, @hot-updater/postgres: getUpdateInfo | Release Catalog compilation and exact reads, then device selection; disable the public Bundle for rollback. |
@hot-updater/plugin-core: createBlobDatabasePlugin | A 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, createUniversalStoragePlugin | createStorageAdapter with flat operations. |
@hot-updater/plugin-core: createRequestUpdateBundleResolver, getRequestUpdateBundleSeeds | None: 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 assumption | v1 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 directly | Delete 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.