HotupdaterHot Updater
Database Adapters

Standalone Repository

Connect your CLI and Console to a self-hosted Hot Updater server.

Overview

Configure standaloneRepository as the database in hot-updater.config.ts or in a deployed Console's configuration when your metadata lives behind a self-hosted server. It does not open the database itself: it calls the server's admin handler, and the server runs each change on its own database. List the server's plugins beside it in plugins, so the CLI and the Console know which ones the server runs.

See the Self-Hosting (Custom) Guide for complete server implementation instructions using @hot-updater/server with database adapters (Drizzle, Prisma, Kysely, MongoDB) and frameworks (Hono, Express, Elysia).

Installation

Install the adapter in the app workspace. The CLI provides the official plugin factories through hot-updater/plugins:

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

Usage

Configure your CLI to connect to your self-hosted server:

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

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

export default defineConfig({
  build, // Your existing build adapter
  storage, // Your existing storage adapter
  database: standaloneRepository({ 
    baseUrl: "http://localhost:3000/hot-updater/admin", 
    commonHeaders: { 
      Authorization: `Bearer ${adminToken}`, 
    }, 
  }), 
  plugins: [insights(), apiKeys()], // The plugins the server runs
  updateStrategy: "appVersion",
});

Remote storage adapter

For CLI uploads through a separate HTTP storage service, use standaloneStorage. Its configuration and service contract belong to Storage Adapters; the repository below manages database rows through the admin API.

Configuration

standaloneRepository accepts the following options:

interface StandaloneRepositoryConfig {
  baseUrl: string; // Exact root where handlers.admin is mounted
  commonHeaders?: Record<string, string>; // Sent with every admin request
}

Every admin path is fixed relative to baseUrl. The repository is { name, core, fetchAdmin }:

  • core is core's API for Bundles, Releases, Release Catalogs and Channels, sent over the admin API. The CLI's commands and the Console use it.
  • fetchAdmin(path, init?) sends a request to the admin handler with the same headers: a GET, unless init names another method, with init's headers added. The Console reads the server's Insights and edits its Remote Config through it.

Before its first request, core reads GET /version from the admin handler. When /version does not report adminProtocol 2 or later, or has no plugins list because baseUrl is the client mount, the CLI fails with a message to use the path of handlers.admin. When the server answers 503 because its database lacks its schema settings, the CLI says to run hot-updater db migrate where the server's database is configured.

standaloneRepository belongs only in hot-updater.config.ts and Console configuration. createHotUpdater needs the server's own database and throws HotUpdaterConfigError when given this repository.

Secure example

hot-updater.config.ts
const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) {
  throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");
}

database: standaloneRepository({
  baseUrl: "http://localhost:3000/hot-updater/admin",
  commonHeaders: { 
    Authorization: `Bearer ${adminToken}`, 
  }, 
});

The admin handler does not authenticate requests itself. Keep its credential in server-side CLI or Console configuration and match the framework middleware in the server setup.

Alternate authentication

hot-updater.config.ts
database: standaloneRepository({
  baseUrl: process.env.HOT_UPDATER_ADMIN_URL!,
  commonHeaders: {
    "X-Internal-Service-Key": process.env.INTERNAL_SERVICE_KEY!, 
  },
});

Server contract

standaloneRepository is an HTTP client for the admin API, protocol 2. All paths are relative to baseUrl, and every response carries cache-control: private, no-store.

If you use @hot-updater/server, you do not need to implement these endpoints. createHotUpdater() provides this contract through handlers.admin.

Version

GET /version reports the protocol and the ids of the plugins the server runs, sorted:

{
  "adminProtocol": 2,
  "infrastructureGeneration": 1,
  "version": "1.0.0",
  "plugins": ["apiKeys", "insights"]
}

Routes

The types are exported from @hot-updater/plugin-core. A successful JSON response wraps its result in data.

MethodRouteRequestSuccess response
GET/bundlesPage, platform, total=trueBundleDetail[], with next and total
GET/bundles/:idBundleDetail, or 404
PATCH/bundles/:idThe Bundle fields to change204
POST/bundles/delete{ ids: string[] }204
GET/bundles/:id/childrenPageBundlePatchRow[] whose base is the Bundle
GET/bundles/countplatformHow many Bundles: all, or one platform's
GET/bundles/child-countsids=<bundle id>,... (1–100)Each base Bundle's child count, by ID
GET/base-candidates/:candidateKeybefore=<bundle id>, limit (1–100, default 3)Auto-patch base Bundle IDs
POST/releases{ deployments: Deployment[] }201, ReleaseCatalogMutationResult[]
GET/releasesPage and one filter setReleaseRow[]
GET/releases/:idReleaseRow, or 404
PATCH/releases/:id{ patch, expectedRevision? }ReleaseCatalogMutationResult
POST/releases/:id/preflight{ patch, expectedRevision? }ReleaseCatalogMutationPreflight
POST/releases/:id/promote{ targetChannel, action?, expectedRevision? }PromoteReleaseResult
DELETE/releases/:idconfirm=<release id>, optional expectedRevisionReleaseCatalogMutationResult
GET/release-catalogsPageReleaseCatalogRow[]
GET/release-catalogs/:scopeKeyReleaseCatalogRow, or 404
POST/release-catalogs/:scopeKey/rebuildReleaseCatalogRebuildResult
POST/release-catalogs/:scopeKey/preflightReleaseCatalogRebuildPreflight
GET/channelsOptional nameChannelRow[], or the ChannelRow for name or 404
POST/channels{ name }The ChannelRow of that name, created when missing
DELETE/channels/:id204

PATCH /releases/:id takes a ReleasePolicyPatch. promote copies a Bundle Release into targetChannel; action: "move" also disables the source. DELETE /releases/:id deletes a disabled Release and names it twice, in the path and as confirm. PATCH /bundles/:id rejects a body whose id names another Bundle; patches, when given, replaces the Bundle's patches.

Pages and filters

List routes page by key. order is asc or desc (default desc), limit is 1–500 (default 50, at most 100 for /bundles), and cursor is the next value of the previous page. A page carries next only when it is full. GET /bundles?total=true adds total, the number of Bundles, for platform when given.

GET /releases accepts one of the filter sets its indexes serve: none; channelId and platform, optionally with enabled; bundleId; or scopeKey, optionally with enabled. Any other combination answers 400.

Deployments

POST /releases writes each deployment's Bundle with its patches and its Release, with the next Release Catalog of each scope, in one transaction. A deployment names a new Bundle, or bundleId to publish a new Release for a Bundle the server already stores:

interface DeployReleasePolicy {
  channel: string;
  enabled: boolean;
  fingerprintHash: string | null;
  message: string | null;
  rolloutCohortCount?: number; // 0 to 1000
  shouldForceUpdate: boolean;
  targetAppVersion: string | null;
  targetCohorts?: string[];
}

type Deployment =
  | { bundle: Bundle; release: DeployReleasePolicy }
  | { bundleId: string; release: DeployReleasePolicy };

A Bundle holds immutable artifact data; Channel, rollout, targeting, enablement, force-update and message policy belong to its Release:

interface BundlePatchArtifact {
  baseBundleId: string;
  baseFileHash: string;
  byteSize: number;
  patchFileHash: string;
  patchStorageUri: string;
}

interface Bundle {
  id: string;
  platform: "ios" | "android";
  gitCommitHash: string | null;
  metadata?: {
    app_version?: string;
  };
  manifestStorageUri: string;
  manifestFileHash: string;
  assetBaseStorageUri: string;
  patches?: BundlePatchArtifact[] | null;
}

Errors

A refusal answers { "code": "...", "error": "..." }:

StatusCodes
404RELEASE_NOT_FOUND, BUNDLE_NOT_FOUND
409VERSION_CONFLICT, or REFERENCED when a Release still uses a Bundle to delete
400Other Release and Catalog refusals, such as ENABLED_RELEASE and INVALID_SCOPE

DELETE /channels/:id answers 404 with { "data": { "deleted": false, "reason": "not_found" } }, or 409 with the reason not_empty while a Release still uses the Channel. A request that fails validation answers 400 with { "error": "..." }. While the server's database lacks its schema settings, every route that reaches it answers 503.

Insights routes

With the insights() plugin, the admin handler also serves GET /overview, /events, /installations, /installations/:installId and /installations/:installId/events. Without it, the server has no such routes, and each answers 404. These routes read the reports that apps send with the Insights client plugin.

GET /failures?platform=ios&channel=production&releaseId=<id> answers a release's update failures since its first report; add start and end, in epoch milliseconds, for a period of at most 30 days, which a channel without releaseId needs. It answers 400 for a channel without them or for a longer period. See Update failures.

insights() also serves GET /retention, which answers how long it keeps data as { "rawDays": 90, "dailyDays": 400 }.

Minimal @hot-updater/server example

If you want this contract without implementing it yourself, use @hot-updater/server:

import { createHotUpdater } from "@hot-updater/server";
import { apiKeys, insights } from "@hot-updater/server/plugins";

const hotUpdater = createHotUpdater({
  database,
  plugins: [insights(), apiKeys()],
  storage,
});

Mount hotUpdater.handlers.admin at /hot-updater/admin to expose these routes. Protect that mount with framework middleware and configure matching commonHeaders in standaloneRepository. Mounting the handler is the explicit opt-in; there is no built-in admin authenticator.

Through standaloneRepository, the Console manages Bundles, Releases, Channels and Remote Config, and reads the server's Insights overview, events, installations, update failures and retention periods; the CLI's remote-config and insights commands use the same routes. The Console cannot show usage or Release activity, and it does not manage the server's API keys; manage them with hot-updater api-key against the server's createHotUpdater module. A hosted Console must protect its browser session separately and keep the admin bearer token in server-side commonHeaders. Do not put that token in browser or React Native code.

Next steps

On this page