HotupdaterHot Updater
Create a Plugin

Create a server plugin

Add tables, aggregates, APIs, routes, and client authentication to a self-hosted server with definePlugin.

What a plugin adds

A server plugin runs inside createHotUpdater. It declares its own tables and aggregates, and its init receives a typed database handle on the storage engine that core uses. init returns:

  • an API, exposed as hotUpdater.api.<id> with its types;
  • optional HTTP endpoints on handlers.client or handlers.admin;
  • optionally the client-route policy, clientAuth.

Beside init, an optional cli tells tooling about the credential an app sends and the client plugin it adds; the server never runs it.

Hot Updater's own insights() and apiKeys() plugins use the same contract, and their sources in the repository's plugins/insights and plugins/api-keys are worked references: Insights has derived and multi-valued fields, sharded counters, gauges, and sketches, and a transaction that reads in batches; API keys has a unique field and clientAuth. Plugins run on a custom server; the managed provider servers run their provider's fixed plugins list.

This page builds a feedback plugin: app installations open feedback threads for a bundle, and admins read them.

Install the packages

The authoring API ships in @hot-updater/plugin-core, the kit that extends the server, and @hot-updater/server runs the plugin. The test harness is in @hot-updater/test-utils.

npm install @hot-updater/server @hot-updater/plugin-core
npm install --save-dev @hot-updater/test-utils vitest

Declare the schema

A schema maps model names to tables (defineTable) and aggregates (defineAggregate), both from @hot-updater/plugin-core:

src/feedback/schema.ts
import { defineAggregate, defineTable } from "@hot-updater/plugin-core";

export const feedbackSchema = {
  threads: defineTable(
    {
      id: { type: "string", maxLength: 36 },
      // Generated by the app, so a retried request opens one thread.
      client_ref: { type: "string", maxLength: 64, unique: true },
      bundle_id: { type: "string", maxLength: 36 },
      install_id: { type: "string", maxLength: 255 },
      title: { type: "string", maxLength: 120 },
      created_at_ms: { type: "integer" },
      closed_at_ms: { type: "integer", required: false },
    },
    {
      key: ["id"],
      derived: {
        // Null once closed, which leaves the thread out of the `open` index.
        open_bundle_id: {
          type: "string",
          compute: (row) => (row.closed_at_ms === null ? row.bundle_id : null),
        },
      },
      indexes: {
        byBundle: { eq: ["bundle_id"], sort: ["created_at_ms"] },
        open: { eq: ["open_bundle_id"], sort: ["created_at_ms"] },
      },
    },
  ),
  messages: defineTable(
    {
      id: { type: "string", maxLength: 36 },
      thread_id: {
        type: "string",
        maxLength: 36,
        references: { model: "threads", onDelete: "cascade" },
      },
      body: { type: "string" },
      created_at_ms: { type: "integer" },
    },
    {
      key: ["id"],
      indexes: {
        byThread: {
          eq: ["thread_id"],
          sort: ["created_at_ms"],
          root: { model: "threads" },
        },
      },
    },
  ),
  totals: defineAggregate(
    { bundle_id: { type: "string", maxLength: 36 } },
    {
      key: ["bundle_id"],
      counters: ["threads", "messages"],
      gauges: ["open_threads"],
      shards: 4,
      indexes: { byBundle: { eq: ["bundle_id"], sort: [] } },
    },
  ),
} as const;

export type FeedbackSchema = typeof feedbackSchema;

The server prefixes a plugin's tables with its id: this schema stores feedback_threads, feedback_messages, and feedback_totals. Model and field names start with a lowercase letter and use lowercase letters, digits, and _; index names start with a letter and use letters, digits, and _.

A plugin that sets namespace: false keeps its declared table names instead, as insights(), apiKeys(), and remoteConfig() do for bundle_events, api_keys, and remote_config_versions. It then owns collisions: startup refuses a table name that core or another plugin the server runs already takes.

Tables

defineTable(fields, { key, derived?, indexes?, retention? }) declares a table. Each field has a type and these options:

OptionMeaning
typestring, integer (safe integers), number, boolean, or json.
required: falseThe field stores null and may be omitted on create. Fields are required by default.
maxLengthStrings: the column length on PostgreSQL and MySQL. A MySQL key or index must fit 3,072 bytes at 4 per character, so give indexed strings a length.
ascii: trueStrings that hold only ASCII: MySQL stores them in single bytes, which fits longer keys.
unique: trueAdds a unique index named after the field. findOne can look a row up by it.
references{ model, onDelete }: a reference to a row of a table in the same plugin.

key lists the fields that identify a row; they cannot be optional or json.

An index, an IndexDefinition, is { eq, sort, unique?, root? }. A read binds every eq field and walks the index in sort order, then by the key fields it does not already name. unique: true means the eq values identify at most one row, so the index has no sort fields and findOne can use it. A row whose eq or sort value is null is not in that index. root is described in Write in a transaction.

A derived field is computed from the declared fields on every create and update, stored as a column, and indexed like any field; it cannot be written directly. With multi: true, compute returns up to 16 values and the row is indexed once per value; such a field can appear in eq only, once per index. Null, or an empty list, leaves the row out of indexes on that field. Insights uses a multi-valued bundle_ref for its bundle filters.

A reference targets a single-key table of the same plugin, and the field has that key's type. onDelete decides what deleting the parent does:

onDeleteDeleting a parent that has childrenWriting a child
restrictRefused with DatabaseConstraintError referenced.The parent must exist.
cascadeDeletes the children first, in the same transaction. Needs an index whose eq is exactly the reference field.The parent must exist.
noneAllowed; children keep the value.Not checked.

For restrict and cascade, the parent row keeps a count of its children in the column _refs_<child table>_<field>, which reads return, for example _refs_feedback_messages_thread_id on a thread.

Aggregates

defineAggregate(identity, { key, counters?, gauges?, distinct?, shards?, indexes?, batched?, retention? }) declares rows of metrics keyed by their identity fields. key lists every identity field in declaration order. Each metric is one of:

MetricBehavior
countersBlind increments: a change never reads the row, so concurrent writers do not conflict.
gaugesRead, merged, and written back per shard row under a guard. A gauge that would go below zero reruns the transaction, which fails if it stays negative. A row whose counters and gauges all reach zero is deleted.
distinctHyperLogLog sketches of unique values, merged register by register. Sketches need an aggregate of their own.

shards (1–64, default 1) stores each logical row as up to that many rows, so concurrent writers spread out. Reads with findAggregates return logical rows: counters and gauges summed, sketches merged. Insights uses 8 shards for counters, 16 for sketches, and 32 for gauges.

A transaction changes one shard row with tx.aggregate(model, identity, changes, { shardBy }). shardBy picks the shard with a stable hash of its value. Gauges of a sharded aggregate need it, so the +1 and the later −1 for one value land on one shard; without it, a counter change lands on a random shard. The hash maps a value to a shard by the count, so choose shards before data exists: a new count sends later changes for the same value to another shard row.

A sketch value is a 1,024-character register string. addDistinct adds a value to a sketch, mergeDistinct merges sketches, and countDistinct estimates how many values a sketch holds; all three come from @hot-updater/plugin-core. With shardBy set to the counted value, a repeated value merges into an unchanged sketch and the engine skips the write.

batched: true lets a database that bills each write (DynamoDB and Firestore, by default) apply the aggregate's changes after their transaction commits, merged with other transactions' changes into one write per row. A read of the aggregate applies pending changes first. When it cannot write, it shows the stored rows, which can leave out up to one window of the latest changes. A gauge may then hold a negative value on one shard row, and reads still sum the shards. Declare it on metrics that can lag a transaction, such as analytics, and not on counts a later transaction reads to decide what to write. Other databases write batched aggregates in the transaction.

Retention

retention: { field, days }, a RetentionDefinition, makes a table's or an aggregate's rows expire days after the time in field, an integer field of epoch milliseconds: a declared or derived field of a table, or an identity field of an aggregate, such as a bucket's start. A row whose field is null never expires. Insights keeps raw events and hourly rows for 90 days this way, and daily rows for 13 months, by default; insights({ retention: { rawDays, dailyDays } }) changes them. The days are a runtime value: the tables and indexes are the same for any period, so a plugin may take them as an option.

const events = defineTable(
  { id: { type: "string", maxLength: 36 }, at: { type: "integer" } },
  { key: ["id"], retention: { field: "at", days: 90 } },
);

Each backend deletes expired rows with its cheapest mechanism, with no scheduler:

BackendHow expired rows go
DynamoDBEvery item of the row, its index copies included, carries _ttl (epoch seconds), and the table's TTL deletes them at no write cost, usually within two days of expiry.
FirestoreEvery document of the row carries expireAt, and the collection's TTL policy deletes them, usually within a day of expiry.
MongoDBEvery document of the row carries _expireAt, a Date, and the collection's TTL index deletes it, usually within a minute of expiry.
PostgreSQL, MySQL, SQLite, D1, SupabaseDuring writes: the write that takes the hourly lease in the settings table first deletes up to 500 expired rows a table, oldest first, in one or two statements over an index that starts with the retention field (the adapter creates one when none does). Other servers skip; while a pass still finds a full batch, the next one runs a minute later.

Until then an expired row may still be read, so a read that must not return one bounds its range to the retention window. Deleting an expired row skips cascades and reference counters, so a model with retention takes part in no reference and roots no index. On an aggregate with retention written in its transaction, a gauge change that would take a row retention already deleted below zero is dropped: a value counted in an expired bucket may leave it after the bucket is gone. A batched aggregate's signed shard rows expire with their bucket.

Startup resolves every schema and throws DatabaseSchemaError with every problem it finds: invalid names, optional or json key fields, an undeclared root or reference target, a cascade without its index, sketches mixed with other metrics, a shard count out of range, or retention on a field that is not an integer, over less than a whole day, or on a model in a reference or rooted index. An index that names an undeclared field also fails to type-check.

Write the plugin

definePlugin takes the plugin's id, schemaVersion, schema, and init, and provides when it supplies client authentication:

src/feedback/index.ts
import {
  DatabaseConstraintError,
  definePlugin,
} from "@hot-updater/plugin-core";

import { feedbackSchema } from "./schema";

export interface OpenThread {
  readonly clientRef: string;
  readonly bundleId: string;
  readonly installId: string;
  readonly title: string;
  readonly body: string;
}

const isOpenThread = (value: unknown): value is OpenThread =>
  typeof value === "object" &&
  value !== null &&
  ["clientRef", "bundleId", "installId", "title", "body"].every(
    (key) => typeof Reflect.get(value, key) === "string",
  );

/** Feedback threads that app installations open and admins read. */
export const feedback = () =>
  definePlugin({
    id: "feedback",
    schemaVersion: "1",
    schema: feedbackSchema,
    init: ({ db, core, now }) => {
      const api = {
        /** Opens a thread with its first message; a repeated clientRef returns the same thread. */
        async open(input: OpenThread): Promise<string> {
          const id = crypto.randomUUID();
          try {
            return await db.transaction(async (tx) => {
              const existing = await tx.findOne("threads", {
                client_ref: input.clientRef,
              });
              if (existing !== null) return existing.id;
              const at = now();
              tx.create("threads", {
                id,
                client_ref: input.clientRef,
                bundle_id: input.bundleId,
                install_id: input.installId,
                title: input.title,
                created_at_ms: at,
              });
              tx.create("messages", {
                id: crypto.randomUUID(),
                thread_id: id,
                body: input.body,
                created_at_ms: at,
              });
              tx.aggregate(
                "totals",
                { bundle_id: input.bundleId },
                { threads: 1, messages: 1, open_threads: 1 },
                { shardBy: id },
              );
              return id;
            });
          } catch (error) {
            // A concurrent request opened the same clientRef first.
            if (
              error instanceof DatabaseConstraintError &&
              error.reason === "unique"
            ) {
              const thread = await db.findOne("threads", {
                client_ref: input.clientRef,
              });
              if (thread !== null) return thread.id;
            }
            throw error;
          }
        },

        /** Adds a message to an open thread; false when it is closed or missing. */
        reply: (threadId: string, body: string) =>
          db.transaction(async (tx) => {
            const thread = await tx.findOne("threads", { id: threadId });
            if (thread === null || thread.closed_at_ms !== null) return false;
            tx.create("messages", {
              id: crypto.randomUUID(),
              thread_id: threadId,
              body,
              created_at_ms: now(),
            });
            tx.aggregate(
              "totals",
              { bundle_id: thread.bundle_id },
              { messages: 1 },
              { shardBy: threadId },
            );
            return true;
          }),

        close: (threadId: string) =>
          db.transaction(async (tx) => {
            const thread = await tx.findOne("threads", { id: threadId });
            if (thread === null || thread.closed_at_ms !== null) return false;
            tx.update("threads", thread, { closed_at_ms: now() });
            tx.aggregate(
              "totals",
              { bundle_id: thread.bundle_id },
              { open_threads: -1 },
              { shardBy: threadId },
            );
            return true;
          }),

        /** Deletes a thread; the cascade deletes its messages first. */
        remove: (threadId: string) =>
          db.transaction(async (tx) => {
            const thread = await tx.findOne("threads", { id: threadId });
            if (thread === null) return false;
            await tx.delete("threads", thread);
            if (thread.closed_at_ms === null) {
              tx.aggregate(
                "totals",
                { bundle_id: thread.bundle_id },
                { open_threads: -1 },
                { shardBy: threadId },
              );
            }
            return true;
          }),

        /** One page of a bundle's open threads, newest first. */
        listOpen: (bundleId: string, cursor?: string) =>
          db.findMany("threads", {
            index: "open",
            where: { open_bundle_id: bundleId },
            order: "desc",
            limit: 50,
            ...(cursor === undefined ? {} : { cursor }),
          }),

        /** One page of a thread's messages, oldest first. */
        listMessages: (threadId: string, cursor?: string) =>
          db.findMany("messages", {
            index: "byThread",
            where: { thread_id: threadId },
            limit: 100,
            ...(cursor === undefined ? {} : { cursor }),
          }),

        /** The bundle's counts, summed over the aggregate's shards. */
        async totals(bundleId: string) {
          const { rows } = await db.findAggregates("totals", {
            index: "byBundle",
            where: { bundle_id: bundleId },
            limit: 1,
          });
          return rows[0] ?? null;
        },
      };

      return {
        api,
        endpoints: [
          {
            method: "POST",
            path: "/feedback",
            access: "client",
            handler: async (request) => {
              const input: unknown = await request.json().catch(() => null);
              if (!isOpenThread(input)) {
                return Response.json(
                  { error: "Invalid feedback" },
                  { status: 400 },
                );
              }
              const id = await api.open(input);
              return Response.json({ id }, { status: 201 });
            },
          },
          {
            method: "GET",
            path: "/feedback/threads/:id",
            access: "admin",
            handler: async (_request, params) => {
              const thread = await db.findOne("threads", {
                id: decodeURIComponent(params.id!),
              });
              if (thread === null) {
                return Response.json({ error: "Not found" }, { status: 404 });
              }
              const detail = await core.getBundle(thread.bundle_id);
              return Response.json({ thread, bundle: detail?.bundle ?? null });
            },
          },
        ],
      };
    },
  });
  • id names the plugin's API and prefixes its tables. A plugin id uses lowercase letters, digits, and _, starting with a letter, and each id appears once per server. core is reserved. insights, apiKeys, and remoteConfig belong to Hot Updater's insights(), apiKeys(), and remoteConfig(): any other plugin that takes one, even a copy such as { ...insights() }, stops the server or the CLI with HotUpdaterConfigError.
  • namespace: false, optional, keeps the table names as schema declares them, without the id prefix. Only such a plugin's id may also use uppercase letters, as apiKeys and remoteConfig do.
  • schemaVersion is a string that names the version of schema; see Create the plugin's tables.
  • init runs once, synchronously, when createHotUpdater starts. It returns { api, endpoints?, clientAuth? }, and nothing else.
  • init receives db, the typed handle for the plugin's own tables; core, core's reads; and now(), the server clock (Date.now), which tests can replace.

createHotUpdater checks every plugin at startup and throws HotUpdaterConfigError for a duplicate or invalid id, an unknown key, an async init, a result with other keys or without api, a clientAuth that disagrees with provides, an invalid endpoint, or colliding routes.

Register the plugin

Add the plugin to plugins next to Hot Updater's own:

src/hotUpdater.ts (excerpt)
import { createHotUpdater } from "@hot-updater/server";
import { apiKeys, insights } from "@hot-updater/server/plugins";

import { feedback } from "./feedback";

export const hotUpdater = createHotUpdater({
  database, // Your provider's database, such as kyselyAdapter(...).
  storage, // The adapter hot-updater.config.ts uploads with, such as s3Storage(...).
  plugins: [insights(), apiKeys(), feedback()], 
});

hotUpdater.api.feedback has the API's types, for example hotUpdater.api.feedback.listOpen(bundleId). PluginApis from @hot-updater/plugin-core names the type of hotUpdater.api for a list of plugins, and AnyHotUpdaterPlugin types any plugin in such a list. POST /feedback joins the other client routes behind apiKeys(), and GET /feedback/threads/:id joins the admin routes. Before the first request, create the plugin's tables.

List the plugin in the plugins of hot-updater.config.ts too, beside the server's other plugins, so the CLI and the Console know the server runs it. Import the official factories in that CLI configuration from hot-updater/plugins, and your own plugin from its package or module; see CLI Configuration.

Write in a transaction

db.transaction(fn) runs fn with a handle whose writes commit together, then returns what fn returned. The handle has these methods:

  • tx.findOne(model, lookup) reads by key, by a unique field, or by a unique index. The transaction records every row it reads, and every key it did not find.
  • tx.findMany(model, options) reads only an index with root. It also reads the root row, a row of the root model whose key starts the index's eq. Every write of a row in that index changes the root row's version, so the root row guards the whole range.
  • tx.create(model, row) inserts a row with its declared fields.
  • tx.update(model, row, set) changes non-key fields of a row this transaction read, by key or through a rooted range. Several updates of one row merge.
  • await tx.delete(model, row) deletes a row this transaction read. Cascaded children are deleted first, which is why delete returns a promise.
  • tx.aggregate(...) changes one shard row of an aggregate.

When fn returns, the engine sends every change, and a version check of every other row the transaction read, as one atomic write. If a row it read changed in the meantime, nothing is written and fn runs again with jittered backoff, up to 8 attempts. Rules for fn:

  • Keep side effects other than tx calls out of it: it may run more than once.
  • Write each key once. Creating a key again, or changing a row the transaction created or deleted, throws DatabaseTransactionError.
  • Read and write only through tx. db and core reads are not part of the transaction; in Node.js, calling them inside fn throws DatabaseTransactionError.
  • Use tx only while fn runs; afterwards it throws.
  • A failed tx call fails the attempt even when fn catches its error.

A missing row read by a unique field is not guarded: a concurrent write of the same value fails the commit with unique, which is why open above catches it and reads the winner's thread. Writing a row of a rooted index needs its root row, as a child needs its parent.

transaction throws these errors from @hot-updater/plugin-core:

ErrorWhen
DatabaseConstraintErrorThe current state confirms a constraint. reason, a ConstraintReason, is exists (the key exists), unique (another row holds a unique value), not_found (a referenced parent or root row is missing), referenced (a restrict reference blocks a delete), or too_large (more than the database commits atomically, such as DynamoDB's 100 items). model names the table.
DatabaseConflictErrorRows it read kept changing through every attempt.
DatabaseAmbiguousCommitErrorThe write may or may not have committed. It is never rerun.
DatabaseTransactionErrorThe handle was misused, or a gauge stayed below zero.

Read through declared indexes

Outside transactions, db reads rows without their version column:

  • db.findOne(model, lookup) reads one row by key, by a unique field, or by the eq fields of a unique index.
  • db.findByKeys(model, keys) reads rows by key in one batch, in the keys' order, with null for a missing key.
  • db.findMany(model, options) reads one page of a table's index.
  • db.findAggregates(model, options) reads one page of an aggregate's index.

There is no count, offset, or free-form filter. Declare an index for each query, and keep counts in an aggregate. A page read takes:

OptionMeaning
indexA declared index of the model.
whereA value for each eq field, and no other field. {} for eq: [].
rangeOptional gt or gte, and lt or lte, on the first order field, or on a prefix of the order fields as a tuple.
order"asc" (default) or "desc".
limit1–500 rows.
cursorThe previous page's next.

A page has next only when it is full, so the page after a full last page is empty. A cursor resumes only the read that produced it; with another model, index, where, order, or range, it throws DatabaseCursorError. Rows are ordered by the index's sort fields, then its remaining key fields, with strings in UTF-8 byte order. Read every page like this:

scripts/print-open-threads.ts
import { hotUpdater } from "../src/hotUpdater";

let cursor: string | undefined;
do {
  const page = await hotUpdater.api.feedback.listOpen("bundle-id", cursor);
  for (const thread of page.rows) console.log(thread.title);
  cursor = page.next;
} while (cursor !== undefined);

For an aggregate, limit caps the shard rows a page starts from, so a page can hold fewer logical rows than limit and still return next. A read that does not match its index throws DatabaseQueryError; most such reads, such as findMany on an aggregate, already fail to type-check.

Read core

init's core reads bundles, Releases, Release Catalogs, and channels on the same engine, each through a declared index or key. Plugins never write core.

MethodReturns
getBundle(id)A bundle, its patches, and how many patches start from it.
listBundles({ limit, order?, after?, platform? })One page of bundles with their patches.
countBundles(platform?)The bundle total, from one counter row.
listPatchesFromBase(baseBundleId, { limit, order?, after? })One page of the patches that start from a bundle.
getRelease(id), listReleases({ filter, limit, order?, after? })Releases, through the index the filter names.
latestReleaseId(scopeKey)A scope's newest Release id.
getReleaseCatalog(request), getReleaseCatalogRow(scopeKey), listReleaseCatalogs({ limit, order?, after? })Compiled Release Catalogs: the update check's answer for a platform, channel, and app version or fingerprint, or the stored rows.
listChannels(), findChannelByName(name)Channels.
getArtifactInfo(targetBundleId, currentBundleId, 1), findBaseBundleIds(candidateKey, bundleId, limit)Artifact resolution and auto-patch bases.

after is the last key of the previous page. Call core outside transactions.

findBaseBundleIds takes a base-candidate key: the Release Catalog scope a new bundle's devices update from and, for an app version, its normalized range. targetBaseCandidateKey({ channel, platform, fingerprintHash, appVersion }) from @hot-updater/plugin-core makes one, as the CLI's deploy does, and parseBaseCandidateKey(key) reads the scope and range back, or returns null for any other string. The two are one format: make keys only with the first and read them only with the second.

Serve endpoints

An endpoint is { method, path, access, handler }:

  • method is GET, POST, PUT, PATCH, or DELETE.
  • path is relative to the handler's mount, such as /feedback/threads/:id. Segments use letters, digits, _, ., ~, and -. :name segments arrive in params as they appear in the URL, still percent-encoded.
  • access: "client" mounts the route on handlers.client, behind the client-route policy: the plugin that provides clientAuth, or clientAccess: "public". Cacheable responses vary by the policy's headers.
  • access: "admin" mounts it on handlers.admin, which your framework authenticates. Responses are marked cache-control: private, no-store.
  • handler(request, params) returns a Response. A thrown error answers 500, so return client errors yourself; a schema fence failure answers 503.

A route with the same method and path shape as another route on the same handler, built in or from another plugin, stops startup with HotUpdaterConfigError.

Provide client authentication

A plugin that decides who may use client routes declares provides: { clientAuth: true } and returns clientAuth from init:

src/deviceTokens.ts
import { definePlugin, defineTable } from "@hot-updater/plugin-core";

const sha256 = async (text: string) =>
  Array.from(
    new Uint8Array(
      await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)),
    ),
    (byte) => byte.toString(16).padStart(2, "0"),
  ).join("");

/** Client routes accept a device token whose SHA-256 digest is stored. */
export const deviceTokens = () =>
  definePlugin({
    id: "device_tokens",
    provides: { clientAuth: true },
    schemaVersion: "1",
    schema: {
      tokens: defineTable(
        {
          hash: { type: "string", maxLength: 64 },
          revoked_at_ms: { type: "integer", required: false },
        },
        { key: ["hash"] },
      ),
    },
    init: ({ db }) => ({
      api: {
        /** Stores a token's digest unless it is stored already. */
        register: async (token: string) => {
          const hash = await sha256(token);
          await db.transaction(async (tx) => {
            if ((await tx.findOne("tokens", { hash })) === null) {
              tx.create("tokens", { hash });
            }
          });
        },
      },
      clientAuth: {
        varyHeaders: ["x-device-token"],
        authenticate: async (headers) => {
          const token = headers.get("x-device-token");
          if (token === null) return false;
          const row = await db.findOne("tokens", { hash: await sha256(token) });
          return row !== null && row.revoked_at_ms === null;
        },
      },
    }),
  });
  • authenticate(headers) runs for every client route except /version. false answers 401; a thrown error answers 503 and never falls back to public access.
  • varyHeaders names the request headers the decision reads. They are added to Vary on cacheable client responses.
  • provides is static, so the types count policy sources. A server runs exactly one plugin that provides clientAuth, and then sets no clientAccess; without one, it sets clientAccess: "public". Any other combination fails to type-check when plugins is a literal list, and throws HotUpdaterConfigError at startup.
  • A plugin that returns clientAuth without declaring it, or declares it without returning it, fails the same way.
  • clientAuth does not protect handlers.admin.

apiKeys() works this way, with a unique hash field and one read per request.

The plugin can also tell tooling about the credential an app sends, in cli.clientCredential, as apiKeys() does for its API key: managed init provisions it and prints the app's requestHeaders, the agent scaffold stores it, and hot-updater doctor checks it:

cli: {
  clientCredential: {
    label: "device token", // its name in output
    header: "x-device-token", // one of clientAuth's varyHeaders
    env: "HOT_UPDATER_DEVICE_TOKEN", // where init stores it
    generate: () => crypto.randomUUID(), // saved before it is registered
    provision: async (api, { existing }) => {
      const token = existing ?? crypto.randomUUID();
      await api.register(token); // stores it unless it is stored already
      return token;
    },
  },
},

provision registers existing idempotently, or creates a credential, and returns it. clientCredential needs provides: { clientAuth: true }.

Name the app's client plugin

A plugin that needs code in the app names that client plugin in cli.clientPlugin. insights() names the SDK's insights() client plugin, which sends it the app's events. Managed init adds it to the HotUpdater.init it prints, the agent scaffold asks for it, and hot-updater doctor warns when the app doesn't add the client plugin of a plugin hot-updater.config.ts lists:

cli: {
  clientPlugin: {
    module: "@acme/hot-updater-feedback/client", // the module that exports it
    name: "feedback", // the export, which the app calls as feedback()
  },
},

The name is an identifier the app can import: not a reserved word, App, or HotUpdater. A malformed client plugin, or two plugins that name one export from different modules, fail with HotUpdaterConfigError at startup.

Create the plugin's tables

hot-updater db migrate and hot-updater db generate read the server's plugins. Besides core's tables, which every database has, they create the tables of each plugin the server runs, such as feedback_threads, and write its settings row last: schema.feedback, holding the plugin's schemaVersion. Until that row matches, the schema fence answers 503 for every request, as it does for core's rows, and names the missing setting. A plugin the server does not run has no tables.

Run them with the file that exports the server's hotUpdater. They load the first of:

  1. the path you pass, such as src/hotUpdater.ts below, which also loads .env.hotupdater from the working directory;
  2. src/hotUpdater or src/db in the working directory, with a .ts, .mts, .cts, .js, .mjs, or .cjs extension.
npx hot-updater db migrate src/hotUpdater.ts
DatabaseThe plugin's tables
Kysely, postgres, MongoDB, a custom adapterdb migrate creates them. With Kysely, db generate writes the SQL for review first.
Drizzle, Prismadb generate adds them to the schema file. Apply it with Drizzle Kit or Prisma, then run db migrate for the settings rows.
D1db generate writes a D1 migration to migrations; apply it with wrangler d1 migrations apply. db migrate applies the same schema through the Cloudflare API.
Supabasedb generate writes a migration to supabase/migrations that also lets the apply RPC reach the tables; apply it with supabase db push.
DynamoDB, FirestoreNothing to create: every table is a key prefix in the one table or collection. db migrate writes the settings row.

A D1 or Supabase server runs in a Worker or an Edge Function, which the CLI cannot load. Point the commands at a Node module that passes the same plugins to createHotUpdater, with d1Database from @hot-updater/cloudflare (the REST API) or supabaseDatabase from @hot-updater/supabase.

Hot Updater's migrations create missing tables but do not add or remove columns on existing tables. A schema change such as a new field needs your own migration before db migrate records the new schemaVersion.

For Kysely/MySQL, generated indexes are part of CREATE TABLE IF NOT EXISTS, so adding an index to an existing table also requires your own CREATE INDEX or ALTER TABLE migration. Apply it before recording the new schemaVersion, especially for a unique index: the setting alone does not enforce uniqueness. PostgreSQL and SQLite SQL migrations can create missing indexes separately; Drizzle and Prisma require applying the updated schema with their own tools.

A plugin's tables need to be free of core's and of those of the other plugins the server runs: beside apiKeys(), an api plugin's keys table (api_keys) fails at startup.

Test with the plugin harness

createPluginTestHarness from @hot-updater/test-utils runs one plugin on createEngine from @hot-updater/plugin-core, the storage engine createHotUpdater runs plugins on, over a fresh memory adapter wrapped in verifyAdapter, which checks every adapter call, and in meterReads, which measures what each call reads:

src/feedback/feedback.spec.ts
import type { HotUpdaterDatabase } from "@hot-updater/plugin-core";
import { createPluginTestHarness } from "@hot-updater/test-utils";
import { describe, expect, it } from "vitest";

import { feedback } from "./index";
import type { FeedbackSchema } from "./schema";

const thread = {
  clientRef: "ref-1",
  bundleId: "bundle-1",
  installId: "install-1",
  title: "Crash on launch",
  body: "The app closes after the update.",
};

describe("feedback", () => {
  it("opens one thread per clientRef and reads its messages in one query", async () => {
    const harness = await createPluginTestHarness(feedback(), {
      now: () => 1_000,
    });
    const id = await harness.api.open(thread);
    await expect(harness.api.open(thread)).resolves.toBe(id);

    harness.setNow(() => 2_000);
    await expect(harness.api.reply(id, "Still happens.")).resolves.toBe(true);
    await expect(harness.api.totals("bundle-1")).resolves.toMatchObject({
      threads: 1,
      messages: 2,
      open_threads: 1,
    });

    const measured = await harness.measureReads(() =>
      harness.api.listMessages(id),
    );
    expect(measured.result.rows.map(({ body }) => body)).toEqual([
      thread.body,
      "Still happens.",
    ]);
    expect(measured.engine).toEqual({ calls: 1, rows: 2 });
  });

  it("drops a closed thread from the open list and deletes its messages with it", async () => {
    const harness = await createPluginTestHarness(feedback());
    const db = harness.db as HotUpdaterDatabase<FeedbackSchema>;
    const id = await harness.api.open(thread);

    await expect(harness.api.close(id)).resolves.toBe(true);
    await expect(harness.api.listOpen("bundle-1")).resolves.toEqual({
      rows: [],
    });
    await expect(harness.api.remove(id)).resolves.toBe(true);
    await expect(db.findOne("threads", { id })).resolves.toBeNull();
    await expect(harness.api.listMessages(id)).resolves.toEqual({ rows: [] });
  });
});
  • The harness returns api, the plugin's instance (with its endpoints), db (cast it to HotUpdaterDatabase of the schema), the physical tables, and the adapter.
  • measureReads(read) reports what read returned to callers (engine: calls and rows) and what the adapter read (adapter: batch point reads, keys, queries, and rows), so a test can hold an API to its read budget.
  • The now option sets the plugin's clock, and setNow changes it later.
  • No retention pass runs, so a call's reads and writes are its own and rows stay past their retention.
  • The adapter option runs the plugin on another database adapter, such as a SQL database, after creating the plugin's tables there.
  • The harness passes an empty core. Test an API that reads core through createHotUpdater.

To test endpoints over HTTP, run the plugin in createHotUpdater on a memory database:

src/feedback/endpoints.spec.ts
import { createHotUpdater } from "@hot-updater/server";
import { createMemoryAdapter } from "@hot-updater/plugin-core";
import { createReleaseCatalogTestStorage } from "@hot-updater/test-utils";
import { expect, it } from "vitest";

import { feedback } from "./index";

it("serves POST /feedback on the client handler", async () => {
  const hotUpdater = createHotUpdater({
    database: { name: "memory", adapter: createMemoryAdapter() },
    storage: createReleaseCatalogTestStorage(),
    plugins: [feedback()],
    clientAccess: "public",
  });
  const response = await hotUpdater.handlers.client(
    new Request("https://updates.example.com/feedback", {
      method: "POST",
      body: JSON.stringify({
        clientRef: "ref-1",
        bundleId: "bundle-1",
        installId: "install-1",
        title: "Crash on launch",
        body: "The app closes after the update.",
      }),
    }),
  );
  expect(response.status).toBe(201);
  const { id } = (await response.json()) as { id: string };
  await expect(hotUpdater.api.feedback.listMessages(id)).resolves.toMatchObject(
    { rows: [{ thread_id: id }] },
  );
});

The memory database has no schema fence and needs no migration.

Publish the plugin

Export the factory, such as feedback(), and its API types from your package, and list @hot-updater/plugin-core in peerDependencies: the plugin then catches the error classes the project's engine throws. Give the plugin an id and route paths that name your package, since both must be unique on a server. A plugin with a client side ships it from the same package, from a ./client entry beside ./server, as @hot-updater/plugin-insights does; see Ship both sides from one package and Test and publish a plugin.

On this page