Teardown

Version Management

Manage app versions and builds and drive force-update status from your backend, including the status cascade and the change hooks.

The version & build management services are the dashboard/admin (write) side of force updates. They let your backend read and update the status of app versions and builds — and that status is exactly what the React Native force-updates check resolves against on the next identify. This is the same runtime Teardown's own dashboard backend dogfoods.

These services are the write counterpart to td.services.forceUpdate, which is the read path the ingest router uses to compute version_info for a client.

Optional for a basic ingest server

You don't need any of this to run ingest — identify and events work without it. Add it when you want to drive update status from your own backend instead of the Teardown dashboard.

Versions

td.services.versions reads and updates project_versions. All methods return an AsyncResult whose error is a typed union (VERSION_NOT_FOUND, PROJECT_NOT_FOUND, INVALID_PARAMS, FETCH_FAILED, UPDATE_FAILED).

// Get one
const one = await td.services.versions.getVersionById(projectId, versionId);

// Get many (≤ 100 ids)
const many = await td.services.versions.getVersionsByIds(projectId, [id1, id2]);

// Search (paginated + sortable; guards the project exists first)
const page = await td.services.versions.searchVersionsByProject({
  projectId,
  page: 1,
  limit: 20,
  search: "1.2",
  sortBy: "created_at",   // created_at | updated_at | name | major | minor | patch
  sortOrder: "desc",
});

// Update status and/or notes
const updated = await td.services.versions.updateVersion(
  projectId,
  versionId,
  { status: "UPDATE_REQUIRED", notes: "Critical security fix" },
);

searchVersionsByProject returns { versions, pagination: { page, limit, total, total_pages } }.

Builds

td.services.builds reads and updates version_builds with the same method shapes and error union (BUILD_NOT_FOUND, …).

const build = await td.services.builds.getBuildById(projectId, buildId);

const page = await td.services.builds.searchBuildsByProject({
  projectId, page: 1, limit: 20,
  sortBy: "build_number",   // created_at | updated_at | build_number | platform | name
  sortOrder: "desc",
});

// Updating a build's status marks it "overridden" (see the cascade below)
const updated = await td.services.builds.updateBuild(
  projectId,
  buildId,
  { status: "UPDATE_RECOMMENDED" },
);

The status cascade

Versions and builds both carry a status (SUPPORTED, UPDATE_AVAILABLE, UPDATE_RECOMMENDED, UPDATE_REQUIRED). When you change a version's status, it cascades to that version's builds — but the cascade respects manual per-build overrides:

New version statusApplies toOverrides
Non-SUPPORTED (e.g. UPDATE_REQUIRED)all builds of the versionreset (status_overridden = false) — the version-wide directive wins
SUPPORTEDonly builds not manually overriddenrespected — a build pinned to UPDATE_REQUIRED stays required

Setting a build's status directly (via updateBuild) marks that build status_overridden = true, which is what later spares it from a SUPPORTED version cascade.

A cascade failure is logged, not surfaced — the version update itself still succeeds.

How a client resolves it

On identify, td.services.forceUpdate looks up the (version, build) pair for the device and returns the most restrictive of the two statuses, mapped to the client version_info status (UP_TO_DATE / UPDATE_AVAILABLE / UPDATE_RECOMMENDED / UPDATE_REQUIRED). Build-specific release notes win over version notes. So marking a version UPDATE_REQUIRED from your backend makes the next client identify return update_required — which the React Native SDK surfaces through useForceUpdate().

Status-change hooks

createTeardown accepts two optional hooks that fire only when a status actually changes — never on a notes-only update and never on a no-op same-status write. A SaaS host wires these to a push-notification queue; a self-host can omit them (they default to no-op). The hooks are fire-and-forget: a thrown error is logged, not surfaced to the caller.

import { createTeardown } from "@teardown/server";
import type { VersionStatusChangeEvent, BuildStatusChangeEvent } from "@teardown/server";

const td = createTeardown({
  storage,
  config: { sessionSecret: process.env.SESSION_SECRET! },

  onVersionStatusChange: async (event: VersionStatusChangeEvent) => {
    // e.g. enqueue a push notification to devices on this version
    await queue.enqueue({
      type: "version_status_change",
      projectId: event.projectId,
      versionId: event.versionId,
      versionName: event.versionName,
      oldStatus: event.oldStatus,
      newStatus: event.newStatus,
      sendNotification: event.sendNotification,
    });
  },

  onBuildStatusChange: async (event: BuildStatusChangeEvent) => {
    await queue.enqueue({ type: "build_status_change", /* ...event */ });
  },
});

Event shapes

interface VersionStatusChangeEvent {
  projectId: string;
  versionId: string;
  versionName: string;
  oldStatus: ProjectVersionStatus;
  newStatus: ProjectVersionStatus;
  sendNotification: boolean;   // caller's intent (e.g. a dashboard "notify" toggle); default true
}

interface BuildStatusChangeEvent {
  projectId: string;
  buildId: string;
  buildName: string;
  oldStatus: VersionBuildStatus;
  newStatus: VersionBuildStatus;
  sendNotification: boolean;
}

orgId is intentionally absent from the events — the hook resolves it (e.g. from the project) if it needs it.

Suppressing the notification

updateVersion / updateBuild accept an options argument to control whether the hook treats the change as notification-worthy. The flag is passed straight through to the event's sendNotification:

await td.services.versions.updateVersion(
  projectId,
  versionId,
  { status: "UPDATE_AVAILABLE" },
  { sendNotification: false },   // status still changes + cascades; event.sendNotification is false
);

The status still changes and still cascades; only the hook's sendNotification flag is false, so your handler can decide to skip the push.