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.clientorhandlers.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-corenpm install --save-dev @hot-updater/test-utils vitestDeclare the schema
A schema maps model names to tables (defineTable) and aggregates
(defineAggregate), both from @hot-updater/plugin-core:
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:
| Option | Meaning |
|---|---|
type | string, integer (safe integers), number, boolean, or json. |
required: false | The field stores null and may be omitted on create. Fields are required by default. |
maxLength | Strings: 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: true | Strings that hold only ASCII: MySQL stores them in single bytes, which fits longer keys. |
unique: true | Adds 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:
onDelete | Deleting a parent that has children | Writing a child |
|---|---|---|
restrict | Refused with DatabaseConstraintError referenced. | The parent must exist. |
cascade | Deletes the children first, in the same transaction. Needs an index whose eq is exactly the reference field. | The parent must exist. |
none | Allowed; 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:
| Metric | Behavior |
|---|---|
counters | Blind increments: a change never reads the row, so concurrent writers do not conflict. |
gauges | Read, 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. |
distinct | HyperLogLog 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:
| Backend | How expired rows go |
|---|---|
| DynamoDB | Every 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. |
| Firestore | Every document of the row carries expireAt, and the collection's TTL policy deletes them, usually within a day of expiry. |
| MongoDB | Every 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, Supabase | During 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:
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 });
},
},
],
};
},
});idnames 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.coreis reserved.insights,apiKeys, andremoteConfigbelong to Hot Updater'sinsights(),apiKeys(), andremoteConfig(): any other plugin that takes one, even a copy such as{ ...insights() }, stops the server or the CLI withHotUpdaterConfigError.namespace: false, optional, keeps the table names asschemadeclares them, without the id prefix. Only such a plugin's id may also use uppercase letters, asapiKeysandremoteConfigdo.schemaVersionis a string that names the version ofschema; see Create the plugin's tables.initruns once, synchronously, whencreateHotUpdaterstarts. It returns{ api, endpoints?, clientAuth? }, and nothing else.initreceivesdb, the typed handle for the plugin's own tables;core, core's reads; andnow(), 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:
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 withroot. It also reads the root row, a row of therootmodel whose key starts the index'seq. 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 whydeletereturns 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
txcalls 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.dbandcorereads are not part of the transaction; in Node.js, calling them insidefnthrowsDatabaseTransactionError. - Use
txonly whilefnruns; afterwards it throws. - A failed
txcall fails the attempt even whenfncatches 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:
| Error | When |
|---|---|
DatabaseConstraintError | The 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. |
DatabaseConflictError | Rows it read kept changing through every attempt. |
DatabaseAmbiguousCommitError | The write may or may not have committed. It is never rerun. |
DatabaseTransactionError | The 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 theeqfields 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:
| Option | Meaning |
|---|---|
index | A declared index of the model. |
where | A value for each eq field, and no other field. {} for eq: []. |
range | Optional 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". |
limit | 1–500 rows. |
cursor | The 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:
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.
| Method | Returns |
|---|---|
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 }:
methodisGET,POST,PUT,PATCH, orDELETE.pathis relative to the handler's mount, such as/feedback/threads/:id. Segments use letters, digits,_,.,~, and-.:namesegments arrive inparamsas they appear in the URL, still percent-encoded.access: "client"mounts the route onhandlers.client, behind the client-route policy: the plugin that providesclientAuth, orclientAccess: "public". Cacheable responses vary by the policy's headers.access: "admin"mounts it onhandlers.admin, which your framework authenticates. Responses are markedcache-control: private, no-store.handler(request, params)returns aResponse. 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:
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.falseanswers 401; a thrown error answers 503 and never falls back to public access.varyHeadersnames the request headers the decision reads. They are added toVaryon cacheable client responses.providesis static, so the types count policy sources. A server runs exactly one plugin that providesclientAuth, and then sets noclientAccess; without one, it setsclientAccess: "public". Any other combination fails to type-check whenpluginsis a literal list, and throwsHotUpdaterConfigErrorat startup.- A plugin that returns
clientAuthwithout declaring it, or declares it without returning it, fails the same way. clientAuthdoes not protecthandlers.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:
- the path you pass, such as
src/hotUpdater.tsbelow, which also loads.env.hotupdaterfrom the working directory; src/hotUpdaterorsrc/dbin the working directory, with a.ts,.mts,.cts,.js,.mjs, or.cjsextension.
npx hot-updater db migrate src/hotUpdater.ts| Database | The plugin's tables |
|---|---|
Kysely, postgres, MongoDB, a custom adapter | db migrate creates them. With Kysely, db generate writes the SQL for review first. |
| Drizzle, Prisma | db generate adds them to the schema file. Apply it with Drizzle Kit or Prisma, then run db migrate for the settings rows. |
| D1 | db generate writes a D1 migration to migrations; apply it with wrangler d1 migrations apply. db migrate applies the same schema through the Cloudflare API. |
| Supabase | db generate writes a migration to supabase/migrations that also lets the apply RPC reach the tables; apply it with supabase db push. |
| DynamoDB, Firestore | Nothing 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:
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'sinstance(with itsendpoints),db(cast it toHotUpdaterDatabaseof the schema), the physicaltables, and theadapter. measureReads(read)reports whatreadreturned 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
nowoption sets the plugin's clock, andsetNowchanges it later. - No retention pass runs, so a call's reads and writes are its own and rows stay past their retention.
- The
adapteroption 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 throughcreateHotUpdater.
To test endpoints over HTTP, run the plugin in createHotUpdater on a memory
database:
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.
Database adapter contract
The DatabaseAdapter interface every database adapter implements, with the rules for tables, values, reads, guarded writes, write limits, retention, and migrations.
Create a client plugin
Observe launches, update checks, downloads, and update failures in the app, send data to your server, and add an API to the HotUpdater instance, with defineClientPlugin.