# Teardown Force Updates SDK Documentation (Complete) --- # Device (/docs/adapters/device) The device adapter reports the app version and device details the SDK needs to evaluate update rules. Pick one. ## Expo [#expo] For Expo projects. Uses `expo-device` and `expo-application`. ```bash npx expo install expo-device expo-application ``` ```ts import { ExpoDeviceAdapter } from "@teardown/force-updates/expo"; new TeardownCore({ // ... deviceAdapter: new ExpoDeviceAdapter(), }); ``` ## react-native-device-info [#react-native-device-info] For bare React Native projects. ```bash npm install react-native-device-info ``` ```ts import { DeviceInfoAdapter } from "@teardown/force-updates/adapters/device-info"; new TeardownCore({ // ... deviceAdapter: new DeviceInfoAdapter(), }); ``` # Adapters (/docs/adapters) Adapters let the SDK talk to your platform's native modules. You always provide **one storage adapter** and **one device adapter**. A notification adapter is optional. | Type | Required? | Options | | --------------------------------------------- | --------- | ------------------------------ | | [Storage](/docs/adapters/storage) | Yes | MMKV, AsyncStorage | | [Device](/docs/adapters/device) | Yes | Expo, react-native-device-info | | [Notifications](/docs/adapters/notifications) | No | Expo, Firebase, Wix | ```ts title="teardown.ts" import { TeardownCore } from "@teardown/force-updates"; import { ExpoDeviceAdapter } from "@teardown/force-updates/expo"; import { MMKVStorageAdapter } from "@teardown/force-updates/adapters/mmkv"; export const teardown = new TeardownCore({ config: { type: "hosted", org_id: "your_org_id", project_id: "your_project_id", api_key: "your_api_key" }, storageAdapter: new MMKVStorageAdapter(), // required deviceAdapter: new ExpoDeviceAdapter(), // required // notificationAdapter: new ExpoNotificationsAdapter(), // optional }); ``` Pick the adapters that match your project (Expo vs. bare React Native) and install only their native modules. Each adapter page has a copy-paste install + setup block. # Notifications (/docs/adapters/notifications) A notification adapter is **optional**. Add one only if you want the SDK to manage push tokens and notification permissions. Pass it as `notificationAdapter`. ## Expo [#expo] ```bash npx expo install expo-notifications ``` ```ts import { ExpoNotificationsAdapter } from "@teardown/force-updates/expo"; new TeardownCore({ // ... notificationAdapter: new ExpoNotificationsAdapter(), }); ``` ## Firebase [#firebase] For apps using React Native Firebase. ```bash npm install @react-native-firebase/messaging ``` ```ts import { FirebaseMessagingAdapter } from "@teardown/force-updates/firebase"; new TeardownCore({ // ... notificationAdapter: new FirebaseMessagingAdapter(), }); ``` ## Wix [#wix] For apps using `react-native-notifications`. ```bash npm install react-native-notifications ``` ```ts import { WixNotificationsAdapter } from "@teardown/force-updates/wix"; new TeardownCore({ // ... notificationAdapter: new WixNotificationsAdapter(), }); ``` When a notification adapter is set, `teardown.notifications` becomes available for requesting permissions and reading the push token. See the [API Reference](/docs/api-reference#notifications). # Storage (/docs/adapters/storage) The storage adapter persists the SDK's identity and version state across launches. Pick one. Keys are namespaced as `teardown:v1:{org}:{project}:{key}`, so the adapter can be shared with the rest of your app. ## MMKV (recommended) [#mmkv-recommended] Fast, synchronous, and encrypted. Requires a custom dev build (not Expo Go). ```bash npm install react-native-mmkv ``` ```ts import { MMKVStorageAdapter } from "@teardown/force-updates/adapters/mmkv"; new TeardownCore({ // ... storageAdapter: new MMKVStorageAdapter(), }); ``` ## AsyncStorage [#asyncstorage] Works everywhere, including Expo Go. Asynchronous under the hood with an in-memory cache for synchronous reads. ```bash npx expo install @react-native-async-storage/async-storage ``` ```ts import { AsyncStorageAdapter } from "@teardown/force-updates/adapters/async-storage"; new TeardownCore({ // ... storageAdapter: new AsyncStorageAdapter(), }); ``` Use **MMKV** for production builds and **AsyncStorage** when you need Expo Go compatibility. # API Reference (/docs/api-reference) Everything is exported from `@teardown/force-updates`. Adapters live under sub-paths (`/expo`, `/firebase`, `/wix`, `/adapters/*`) — see [Adapters](/docs/adapters). ## TeardownCore [#teardowncore] Create one instance and share it via `TeardownProvider`. ```ts new TeardownCore({ // Hosted: { type: "hosted", org_id, project_id, api_key, ingestUrl? } // Self-hosted: { type: "self-hosted", ingestUrl } config: TeardownConfig; storageAdapter: StorageAdapter; // required — see Adapters deviceAdapter: DeviceInfoAdapter; // required — see Adapters environment_slug?: string; // default: "production" notificationAdapter?: NotificationAdapter; lifecycleAdapter?: LifecycleAdapter; // e.g. AppStateLifecycleAdapter forceUpdate?: { checkIntervalMs?: number; // default: 300000 (5 min). -1 disables checks checkOnForeground?: boolean; // default: true (needs lifecycleAdapter) identifyAnonymousDevice?: boolean; // default: false }; }); ``` Instance properties: `identity`, `forceUpdate`, `device`, `events`, `notifications?`, `api`, plus `setLogLevel()` and `shutdown()`. ## Hooks [#hooks] Must be used inside `TeardownProvider`. ### useForceUpdate() [#useforceupdate] ```ts const { versionStatus, // VersionStatus isUpdateAvailable, // boolean isUpdateRecommended, // boolean isUpdateRequired, // boolean releaseNotes, // string | null } = useForceUpdate(); ``` ### useSession() [#usesession] Returns the current `Session | null`, updating when identity changes. ```ts const session = useSession(); ``` ### useTeardown() [#useteardown] Returns `{ core }` — the `TeardownCore` instance for imperative calls. ```ts const { core } = useTeardown(); ``` ## TeardownProvider [#teardownprovider] ```tsx {children} ``` ## Identity [#identity] Access via `teardown.identity`. | Method | Signature | Description | | ----------------------- | ----------------------------------------------------------- | ------------------------------------------------- | | `identify` | `(user?: Persona) => AsyncResult` | Identify a device/user and refresh version status | | `refresh` | `() => AsyncResult` | Re-identify the current persona | | `signOut` | `(options?: SignOutOptions) => AsyncResult` | Sign out, keeping the device ID | | `signOutAll` | `(options?: SignOutOptions) => AsyncResult` | Sign out and reset the device | | `getSessionState` | `() => Session \| null` | Current session | | `getIdentifyState` | `() => IdentifyState` | Current identity state | | `onIdentifyStateChange` | `(listener: (state: IdentifyState) => void) => Unsubscribe` | Subscribe to changes | ```ts await teardown.identity.identify({ user_id: "user_123", email: "ada@example.com" }); await teardown.identity.signOut(); ``` ## Force update [#force-update] Access via `teardown.forceUpdate`. | Method | Signature | | ----------------------- | ------------------------------------------------------------ | | `getVersionStatus` | `() => VersionStatus` | | `onVersionStatusChange` | `(listener: (status: VersionStatus) => void) => Unsubscribe` | Prefer the [`useForceUpdate`](#useforceupdate) hook in React components. ## Device [#device] Access via `teardown.device`. | Method | Signature | | --------------- | -------------------------------------------------- | | `getDeviceId` | `() => Promise` | | `getDeviceInfo` | `() => Promise` | | `reset` | `() => void` (new device ID on next `getDeviceId`) | ## Events [#events] Access via `teardown.events`. ```ts await teardown.events.track({ event_name: "checkout_completed", event_type: "action", // "action" | "screen_view" | "custom" properties: { total: 42 }, }); ``` `trackBatch(events: EventPayload[])` sends multiple at once. ## Notifications [#notifications] Available as `teardown.notifications` only when a `notificationAdapter` is configured. | Method | Signature | | ------------------------ | ---------------------------------------------------------- | | `requestPermissions` | `() => Promise` | | `getToken` | `() => Promise` | | `onTokenChange` | `(listener: (token: string) => void) => Unsubscribe` | | `onNotificationReceived` | `(listener: (n: PushNotification) => void) => Unsubscribe` | | `onNotificationOpened` | `(listener: (n: PushNotification) => void) => Unsubscribe` | | `onDataMessage` | `(listener: (m: DataMessage) => void) => Unsubscribe` | ## Logging [#logging] ```ts teardown.setLogLevel("verbose"); // "none" | "error" | "warn" | "info" | "verbose" ``` ## Key types [#key-types] ```ts type VersionStatus = | { type: "initializing" } | { type: "checking" } | { type: "up_to_date" } | { type: "update_available"; releaseNotes?: string } | { type: "update_recommended"; releaseNotes?: string } | { type: "update_required"; releaseNotes?: string } | { type: "disabled" }; interface Session { session_id: string; device_id: string; user_id: string; token: string; } type IdentifyState = | { type: "unidentified" } | { type: "identifying" } | { type: "identified"; session: Session; version_info: { /* ... */ } }; interface Persona { user_id?: string; email?: string; name?: string; } // AsyncResult is a Promise that resolves to: type Result = | { success: true; data: T } | { success: false; error: string }; ``` # Force Updates (/docs/force-updates) The SDK checks the running version against your dashboard rules on launch (and when the app returns to the foreground). Your UI reacts to a single hook, `useForceUpdate()`. ## Version statuses [#version-statuses] `useForceUpdate()` returns booleans for the common cases, plus the raw `versionStatus` and any `releaseNotes`. | Status | Meaning | What you should do | | --------------------------- | -------------------------- | ------------------------- | | `up_to_date` | Running an allowed version | Nothing | | `update_available` | A newer version exists | Optional nudge | | `update_recommended` | Update is encouraged | Dismissible banner | | `update_required` | Version is blocked | Hard gate — block the app | | `checking` / `initializing` | Still resolving | Show nothing or a splash | | `disabled` | Checks are turned off | Nothing | ```ts const { isUpdateAvailable, // available, recommended, or required isUpdateRecommended, // recommended only isUpdateRequired, // blocked — must update releaseNotes, // string | null versionStatus, // the raw discriminated union } = useForceUpdate(); ``` ## Required: full-screen gate [#required-full-screen-gate] Block the app entirely until the user updates. Wrap your navigator with this. ```tsx title="UpdateGate.tsx" import { useForceUpdate } from "@teardown/force-updates"; import { Linking, Text, TouchableOpacity, View } from "react-native"; export function UpdateGate({ children }: { children: React.ReactNode }) { const { isUpdateRequired, releaseNotes } = useForceUpdate(); if (!isUpdateRequired) return <>{children}; return ( Update required {releaseNotes ?? "A new version is required to keep using the app."} Linking.openURL("https://your-app-store-link")}> Update now ); } ``` ## Recommended: dismissible banner [#recommended-dismissible-banner] Encourage updates without blocking. Render this above your app content. ```tsx title="UpdateBanner.tsx" import { useForceUpdate } from "@teardown/force-updates"; import { useState } from "react"; import { Linking, Text, TouchableOpacity, View } from "react-native"; export function UpdateBanner() { const { isUpdateRecommended, releaseNotes } = useForceUpdate(); const [dismissed, setDismissed] = useState(false); if (!isUpdateRecommended || dismissed) return null; return ( {releaseNotes ?? "A new version is available."} Linking.openURL("https://your-app-store-link")}> Update setDismissed(true)}> Dismiss ); } ``` ## Re-check when the app returns to foreground [#re-check-when-the-app-returns-to-foreground] By default the version is checked on launch. To also re-check whenever the app comes back to the foreground, pass the `AppStateLifecycleAdapter`: ```ts title="teardown.ts" import { TeardownCore, AppStateLifecycleAdapter } from "@teardown/force-updates"; export const teardown = new TeardownCore({ // ...config, adapters lifecycleAdapter: new AppStateLifecycleAdapter(), }); ``` ## Tuning checks [#tuning-checks] Pass a `forceUpdate` config to control how often checks run. ```ts new TeardownCore({ // ... forceUpdate: { checkIntervalMs: 300_000, // minimum time between checks (default: 5 min; -1 disables) checkOnForeground: true, // re-check on foreground (needs lifecycleAdapter) }, }); ``` Which versions are allowed, recommended, or blocked is configured per project in the [dashboard](https://dash.teardown.dev) — no app release needed to change a rule. # Getting Started (/docs/getting-started) Force Updates lets you require, recommend, or block any app version from the [dashboard](https://dash.teardown.dev). The SDK checks the running version on launch and tells your app what to do. This guide uses **Expo**. For a bare React Native app, see the section at the bottom — only the adapters change. Create a project in the [dashboard](https://dash.teardown.dev) and copy your **Org ID**, **Project ID**, and **API key** from project settings. ## 1. Install [#1-install] ```bash npx expo install @teardown/force-updates expo-device expo-application react-native-mmkv ``` MMKV is the recommended storage adapter — it's fast, synchronous, and encrypted. It requires a [custom dev build](https://docs.expo.dev/develop/development-builds/introduction/) (it doesn't run in Expo Go). On Expo Go, use AsyncStorage instead — see below.
Using async storage instead ```bash npx expo install @teardown/force-updates expo-device expo-application @react-native-async-storage/async-storage ``` Then swap the storage adapter in step 2: ```ts title="teardown.ts" import { AsyncStorageAdapter } from "@teardown/force-updates/adapters/async-storage"; // ... storageAdapter: new AsyncStorageAdapter(), ```
## 2. Configure [#2-configure] Create a single `teardown.ts` that builds the client once, then import it anywhere. ```ts title="teardown.ts" import { TeardownCore } from "@teardown/force-updates"; import { ExpoDeviceAdapter } from "@teardown/force-updates/expo"; import { MMKVStorageAdapter } from "@teardown/force-updates/adapters/mmkv"; export const teardown = new TeardownCore({ config: { type: "hosted", org_id: "your_org_id", project_id: "your_project_id", api_key: "your_api_key" }, storageAdapter: new MMKVStorageAdapter(), deviceAdapter: new ExpoDeviceAdapter(), }); ``` ## 3. Wrap your app and gate it [#3-wrap-your-app-and-gate-it] Wrap your app in `TeardownProvider`, then read the version status with `useForceUpdate()` to block the app when an update is required. ```tsx title="App.tsx" import { TeardownProvider, useForceUpdate } from "@teardown/force-updates"; import { Text, View } from "react-native"; import { teardown } from "./teardown"; function UpdateGate({ children }: { children: React.ReactNode }) { const { isUpdateRequired } = useForceUpdate(); if (isUpdateRequired) { return ( A new version is required. Please update to continue. ); } return <>{children}; } export default function App() { return ( ); } ``` That's it. Mark a version as **required** in the dashboard and it will be gated on the next launch. See [Force Updates](/docs/force-updates) for recommended-update banners and the full list of statuses. ## Identify your users (optional) [#identify-your-users-optional] By default the SDK identifies the device anonymously. To tie updates and analytics to a signed-in user, call `identify` after login: ```ts await teardown.identity.identify({ user_id: "user_123", email: "ada@example.com", }); ``` Call `teardown.identity.signOut()` on logout. See the [API Reference](/docs/api-reference#identity) for details. ## Bare React Native [#bare-react-native] Same setup — swap the Expo adapters for the bare adapters and install their native modules. ```bash npm install @teardown/force-updates react-native-device-info react-native-mmkv ``` ```ts title="teardown.ts" import { TeardownCore } from "@teardown/force-updates"; import { DeviceInfoAdapter } from "@teardown/force-updates/adapters/device-info"; import { MMKVStorageAdapter } from "@teardown/force-updates/adapters/mmkv"; export const teardown = new TeardownCore({ config: { type: "hosted", org_id: "your_org_id", project_id: "your_project_id", api_key: "your_api_key" }, storageAdapter: new MMKVStorageAdapter(), deviceAdapter: new DeviceInfoAdapter(), }); ``` See [Adapters](/docs/adapters) for every storage, device, and notification option. # Introduction (/docs/introduction) **Teardown Force Updates** gates stale app versions for React Native and Expo. From the [dashboard](https://dash.teardown.dev) you decide which builds can run — require, recommend, or block any version — and the SDK enforces it on the next launch. No app store release needed to change a rule. The whole integration is three steps: install, configure a client, and wrap your app. [Get started →](/docs/getting-started) ## Docs [#docs] * [Getting Started](/docs/getting-started) — install and ship your first gated release * [Force Updates](/docs/force-updates) — version statuses and update UI patterns * [Adapters](/docs/adapters) — storage, device, and notification options * [API Reference](/docs/api-reference) — the full SDK surface # Advanced (/docs/server/advanced) This page covers the production concerns: caching, observability, atomicity, and running the runtime at scale. It mirrors how Teardown's own ingest and dashboard backends are wired. Jump to the [Deployment checklist](#deployment-checklist) for the must-haves (schema applied, `SESSION_SECRET` set and identical across replicas, optional shared cache). The sections above it explain each piece. Everything except the database and session secret is optional with a safe no-op default. ## Redis caching [#redis-caching] `createTeardown` accepts a `CachePort`. By default it is a no-op (`NullCache`) — the runtime is correct without a cache, just slower (every version/build resolve and session lookup hits the database). Pass a cache to accelerate the hot paths. The port matches `@teardown/redis`'s `CacheInterface` exactly, so a Redis cache is structurally assignable: ```ts import { createTeardown } from "@teardown/server"; import type { CachePort } from "@teardown/server/ports"; import { cache } from "./lib/cache"; // your @teardown/redis instance const td = createTeardown({ storage, cache: cache satisfies CachePort, config: { sessionSecret: process.env.SESSION_SECRET! }, }); ``` What gets cached: * **Resolved version/build ids** — `forceUpdate.getOrCreateVersionBuild` caches `{ versionId, buildId }` per `project:version:build:platform` for 15 minutes (key prefix `ingest:version_build:`). * **Sessions** — the session service caches by device id (`ingest:session:`) and by token (`ingest:session:token:`), with a TTL tracking the token's expiry, re-caching on reuse-extend and invalidating on rotation. Any cache implementing the three methods (`get` / `set` / `del`) works — it does not have to be Redis. ## Logging [#logging] Pass a `LoggerPort` to capture the runtime's structured logs (identify/events flow, push-token decisions, cascade outcomes, hook failures). Each method takes a message and an optional context object. ```ts import type { LoggerPort } from "@teardown/server/ports"; const logger: LoggerPort = { debug: (m, ctx) => console.debug(m, ctx), info: (m, ctx) => console.info(m, ctx), warn: (m, ctx) => console.warn(m, ctx), error: (m, ctx) => console.error(m, ctx), }; const td = createTeardown({ storage, logger, config: { sessionSecret } }); ``` An OpenTelemetry logger whose methods have the `(message, context?)` signature satisfies `LoggerPort` directly. ## Metrics (OpenTelemetry) [#metrics-opentelemetry] The runtime emits counters and histograms through a `MetricsPort` — by default a no-op. Provide an implementation to route them onto OpenTelemetry instruments (or any metrics backend). Attributes carry `{ orgId?, projectId?, environment?, status? }`. ```ts import type { MetricsPort, MetricAttributes } from "@teardown/server/ports"; const metrics: MetricsPort = { counter: (name, value, attrs?: MetricAttributes) => otelCounter(name).add(value, attrs), histogram: (name, value, attrs?: MetricAttributes) => otelHistogram(name).record(value, attrs), }; const td = createTeardown({ storage, metrics, config: { sessionSecret } }); ``` The metric names match the hosted ingest: | Metric | Type | Emitted on | | --------------------------------- | --------- | ------------------------------------ | | `ingest.identify.duration` | histogram | every identify | | `ingest.identify.count` | counter | every identify | | `ingest.identify.session.created` | counter | new session created | | `ingest.identify.user.created` | counter | new user created | | `ingest.identify.device.created` | counter | new device created | | `ingest.events.duration` | histogram | every events batch | | `ingest.events.count` | counter | processed event count | | `ingest.events.batch.size` | histogram | batch size | | `ingest.events.high_volume` | counter | batch over the high-volume threshold | ## Atomicity and the transaction hook [#atomicity-and-the-transaction-hook] The identify flow is **idempotent and individually-atomic** — it relies on the race-safe `upsert*` repository methods, not on a wrapping transaction. So `TeardownStorage.transaction` is optional and the runtime never requires it (the in-memory adapter omits it entirely). If your store supports transactions and you want a unit of work to be all-or-nothing, implement `transaction` to wrap the work and re-expose the transaction handle as a fresh `TeardownStorage`: ```ts transaction: (work) => db.transaction((tx) => work(wrapAsTeardownStorage(tx))), ``` The most important atomicity guarantees — the two natural-key upserts — are enforced at the repository level regardless of `transaction`, so concurrent identify calls for the same app version never create duplicate version/build rows. See [Bring your own database](/docs/server/storage/bring-your-own-database#concurrency-safe-upserts) for how to implement them. ## Multi-tenant and multi-instance [#multi-tenant-and-multi-instance] Because there are no singletons, the runtime scales in both directions: * **Multiple instances** — `@teardown/server` is stateless across requests; all state lives in your database and (optionally) your shared cache. Run as many replicas behind a load balancer as you like. Use a shared Redis `CachePort` so cache hits and session caching are consistent across replicas. The race-safe upserts make concurrent identify calls across replicas safe. * **Multiple tenants** — you can run one runtime for all tenants (tenancy is already enforced per request via the API key → project/org resolution and the environment scoping in storage), or instantiate a separate `createTeardown` per tenant with tenant-specific storage and config. The latter is useful when each tenant has an isolated database. (For a single self-hosted app, pass `tenant: { orgId, projectId }` instead — see [Getting Started](/docs/server/getting-started#go-to-production).) ```ts // One runtime per tenant database const runtimes = new Map(); function runtimeFor(tenant: Tenant): Teardown { let td = runtimes.get(tenant.id); if (!td) { td = createTeardown({ storage: createMyStorage(tenant.databaseUrl), // your TeardownStorage implementation cache, config: { sessionSecret: tenant.sessionSecret }, }); runtimes.set(tenant.id, td); } return td; } ``` ## Session secret and token signing [#session-secret-and-token-signing] The default signer is HS256 via `jose`, keyed on `config.sessionSecret` — the HMAC key the server signs and verifies client session tokens with. Generate one with `openssl rand -base64 32` and store it in your backend's secret manager (never commit it, never ship it to clients). For correctness across a fleet: * Use the **same** `sessionSecret` on every instance that serves the same clients, or sessions signed by one replica will fail verification on another. * Rotate by supplying a custom `TokenSigner` that can verify both the old and new keys during the rollover window. * Tune token lifetime with `config.sessionTokenExpiry` (the JWT `exp`) and the row TTL with `config.sessionTtlMs`; `config.sessionReuseExtendMs` controls how much a reused session's expiry is pushed out on each identify. ## Deployment checklist [#deployment-checklist] * [ ] Your storage's connection points at a database with **your** schema applied (you own the migrations). * [ ] `SESSION_SECRET` is set and identical across replicas (or a custom multi-key signer is wired). * [ ] A shared `CachePort` (e.g. Redis) is configured if you run more than one replica and want session/version caching. * [ ] A `LoggerPort` and `MetricsPort` are wired to your observability stack. * [ ] `td.router({ version, buildId, serviceId })` is passed your build metadata so `GET /` and `GET /health` report it. * [ ] The React Native SDK's `ingestUrl` points at your deployment, and the CORS allowlist (applied automatically by the adapters) covers your clients. # API Reference (/docs/server/api-reference) ## createTeardown [#createteardown] Wire a storage implementation and configuration into the Teardown runtime. ```ts function createTeardown(options: TeardownOptions): Teardown ``` ```ts import { createTeardown } from "@teardown/server"; const td = createTeardown({ storage: createMemoryStorage(), config: { sessionSecret: process.env.SESSION_SECRET! }, }); ``` Throws if `config.sessionSecret` is absent and no custom `signer` is supplied. ### TeardownOptions [#teardownoptions] | Property | Type | Required | Description | | ----------------------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `storage` | `TeardownStorage` | Yes | The pluggable storage layer | | `config` | `TeardownConfig` | No | Runtime configuration | | `tenant` | `TeardownTenant` | No | `{ orgId, projectId }` → single-tenant: skips API-key auth and makes `td-api-key`/`td-org-id`/`td-project-id` unnecessary (hosted-only). Omit for multi-tenant | | `cache` | `CachePort` | No | Cache for version/session lookups. Default: no-op | | `logger` | `LoggerPort` | No | Structured logger. Default: no-op | | `metrics` | `MetricsPort` | No | Metrics sink. Default: no-op | | `clock` | `ClockPort` | No | Clock + uuid source. Default: system | | `signer` | `TokenSigner` | No | Session-token signer. Default: HS256 (jose) using `config.sessionSecret` | | `onVersionStatusChange` | `OnVersionStatusChange` | No | Hook fired on a real version-status change | | `onBuildStatusChange` | `OnBuildStatusChange` | No | Hook fired on a real build-status change | ## Teardown [#teardown] The object returned by `createTeardown`. | Property | Type | Description | | --------------- | ---------------------------------- | --------------------------------------- | | `services` | `TeardownServices` | The wired domain services | | `router(info?)` | `(info?: RuntimeInfo) => TdRouter` | Build the ingest HTTP router | | `storage` | `TeardownStorage` | The storage you passed in | | `config` | `ResolvedConfig` | The configuration with defaults applied | | `cache` | `CachePort` | The resolved cache port | | `logger` | `LoggerPort` | The resolved logger port | | `metrics` | `MetricsPort` | The resolved metrics port | | `clock` | `ClockPort` | The resolved clock port | | `signer` | `TokenSigner` | The resolved token signer | ### SDK\_NAME [#sdk_name] ```ts import { SDK_NAME } from "@teardown/server"; // "@teardown/server" — a stable namespace for cache keys, headers, etc. ``` ## Services [#services] `td.services` exposes eight domain services. For a self-hosted **ingest** server, the only ones the mounted router uses are `identify` and `events` (with `forceUpdate`/`session` behind them) — the rest are for [version management](/docs/server/version-management) and custom integrations. Unless noted, methods return an `AsyncResult` (`{ success: true; data: T } | { success: false; error: string }`); the version/build services return a typed error union instead. | Service | Purpose | | ------------- | --------------------------------------------------------------- | | `identify` | Identify a device/user → session + `version_info` | | `events` | Ingest a batch of events | | `forceUpdate` | Resolve + provision version/build status (the ingest read path) | | `session` | Create / reuse / look up device sessions | | `environment` | Resolve, create, and delete environments | | `pushTokens` | Upsert / invalidate / read a device's push token | | `versions` | Version management (CRUD + status cascade) | | `builds` | Build management (CRUD) | ### identify [#identify] ```ts identify(headers: IdentifyHeaders, request: IdentifyRequest): Promise< | { success: true; data: IdentifyData } | { success: false; error: string } > ``` `IdentifyData` is `{ session_id, device_id, user_id, token, version_info }`. This is the full payload the `POST /v1/identify` route returns. ### events [#events] ```ts processEvents(headers: EventsHeaders, request: EventsRequest): Promise< | { success: true; data: ProcessEventsResult } | { success: false; error: string } > ``` `ProcessEventsResult` is `{ eventIds: string[]; processedCount: number; failedCount: number }`. The handler maps it to the snake\_case wire payload (`event_ids` / `processed_count` / `failed_count`). A batch larger than `config.maxEventsPerBatch` is rejected. A `user_signed_out` event invalidates the device's push token. ### forceUpdate [#forceupdate] | Method | Returns | Description | | ------------------------------------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------- | | `getOrCreateVersionBuild(projectId, versionName, buildNumber, platform)` | `AsyncResult<{ versionId; buildId }>` | Race-safe get-or-create of the version + build; caches the resolved ids for 15 minutes | | `checkVersionStatus(input)` | `AsyncResult` | Resolve the client `version_info` (most-restrictive of version vs build status) | `input` is `{ projectId, versionName, buildNumber, platform }` where `platform` is `"IOS" | "ANDROID"`. ### session [#session] | Method | Returns | Description | | ------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------- | | `getOrCreateSession(deviceId, deviceInfo, context, versionBuildInfo, sdkInfo?)` | `AsyncResult<{ session; token; isExisting }>` | Reuse a valid session (extending its expiry) or create a new one with a signed JWT | | `createSession(deviceId, deviceInfo, claims, versionBuildInfo, sdkInfo?)` | `AsyncResult<{ session; token }>` | Create a new session | | `extendSessionExpiry(sessionId, deviceId)` | `AsyncResult` | Extend a session by `config.sessionReuseExtendMs` | | `getSession(sessionId)` | `AsyncResult` | Look up a session by id | | `getSessionByToken(token)` | `AsyncResult` | Look up a session by token (cache-first) | | `getValidSessionForDevice(deviceId)` | `AsyncResult<{ session; token } \| null>` | The current valid session for a device (verifies the token) | `deviceId` here is the internal device id (`DeviceEntity.id`). ### environment [#environment] | Method | Returns | Description | | ------------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------- | | `getEnvironment(projectId, slug)` | `AsyncResult` | Find an environment by project + slug | | `createEnvironment(projectId, name, slug, type)` | `AsyncResult` | Create an environment (slug unique per project) | | `deleteEnvironment(environmentId)` | `AsyncResult` | Delete an environment (enforces the last-env / last-PRODUCTION / last-DEVELOPMENT rules) | ### pushTokens [#pushtokens] | Method | Returns | Description | | ---------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- | | `upsertPushToken(orgId, projectId, deviceId, notificationsInfo)` | `AsyncResult<{ token; isNew }>` | Upsert the device's push token (skips unless enabled + granted + token present) | | `invalidateToken(deviceId)` | `AsyncResult` | Mark the device's push token(s) invalid | | `getTokenByDeviceId(deviceId)` | `AsyncResult` | Read the device's push token | ### versions [#versions] | Method | Returns | Description | | ----------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | | `getVersionById(projectId, versionId)` | `AsyncResult` | Get one version | | `getVersionsByIds(projectId, versionIds)` | `AsyncResult` | Get many (≤ 100) | | `searchVersionsByProject(params)` | `AsyncResult` | Paginated/sortable search | | `updateVersion(projectId, versionId, data, options?)` | `AsyncResult` | Update status and/or notes (status cascades to builds) | `data` is `{ status?: ProjectVersionStatus; notes?: string | null }`; `options` is `{ sendNotification?: boolean }`. See [Version Management](/docs/server/version-management). ### builds [#builds] | Method | Returns | Description | | ------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------- | | `getBuildById(projectId, buildId)` | `AsyncResult` | Get one build | | `getBuildsByIds(projectId, buildIds)` | `AsyncResult` | Get many (≤ 100) | | `searchBuildsByProject(params)` | `AsyncResult` | Paginated/sortable search | | `updateBuild(projectId, buildId, data, options?)` | `AsyncResult` | Update status and/or notes (marks the build overridden) | ## TeardownConfig [#teardownconfig] ```ts interface TeardownConfig { sessionSecret?: string; // HMAC key signing client session tokens; required unless a custom signer is supplied sessionTokenExpiry?: string; // jose-format, default "15m" sessionTtlMs?: number; // session row TTL, default 900000 (15m) sessionReuseExtendMs?: number;// reuse extension, default 300000 (5m) maxEventsPerBatch?: number; // default 100 } ``` `resolveConfig(config?)` applies the defaults and is exported alongside the `ResolvedConfig` type if you need the resolved values directly. ## Cross-cutting ports [#cross-cutting-ports] All cross-cutting ports are optional, with a no-op or system default. Import their types from `@teardown/server/ports`. ### CachePort [#cacheport] Matches `@teardown/redis`'s `CacheInterface`, so a Redis cache is structurally assignable. Default: a no-op `NullCache`. ```ts interface CachePort { get(key: string): Promise; set(key: string, value: unknown, ttlSeconds?: number): Promise; del(key: string): Promise; } ``` ### LoggerPort [#loggerport] Default: `noopLogger`. ```ts interface LoggerPort { debug(message: string, context?: Record): void; info(message: string, context?: Record): void; warn(message: string, context?: Record): void; error(message: string, context?: Record): void; } ``` ### MetricsPort [#metricsport] Generic counter/histogram sink. Default: `noopMetrics`. Attributes are `{ orgId?, projectId?, environment?, status? }`. ```ts interface MetricsPort { counter(name: string, value: number, attributes?: MetricAttributes): void; histogram(name: string, value: number, attributes?: MetricAttributes): void; } ``` ### ClockPort [#clockport] Injectable time + uuid source (edge-safe and test-deterministic). Default: `systemClock`. ```ts interface ClockPort { now(): Date; uuid(): string; } ``` ### TokenSigner [#tokensigner] Session-JWT signer. Default: an HS256 `jose` signer built from `config.sessionSecret`. ```ts interface TokenSigner { sign(claims: SessionTokenClaims): Promise; verify(token: string): Promise; } interface SessionTokenClaims { sessionId: string; deviceId: string; userId: string; environmentId: string; projectId: string; orgId: string; } ``` `createJoseSigner({ secret, expiry? })` builds the default signer and is exported if you want to construct it explicitly (or run it on a custom secret/expiry): ```ts import { createJoseSigner } from "@teardown/server"; const signer = createJoseSigner({ secret: process.env.SESSION_SECRET!, expiry: "30m" }); const td = createTeardown({ storage, signer }); ``` ## Subpath exports [#subpath-exports] | Import | Contents | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `@teardown/server` | `createTeardown`, `TeardownOptions`, `Teardown`, `TeardownTenant`, `SDK_NAME`, `createJoseSigner`, config + hook types | | `@teardown/server/ports` | `TeardownStorage`, the `*Repository` interfaces, the `*Entity`/`New*`/`*Patch` types, `StorageError`, and the cross-cutting ports | | `@teardown/server/contracts` | The wire-contract DTO types + `validateIdentifyRequest` / `validateEventsRequest` | | `@teardown/server/http` | The router builder, normalized `Td*` primitives, the authenticator, CORS constants | | `@teardown/server/http/fetch` | `toFetchHandler` (Web-standard adapter) | | `@teardown/server/http/elysia` | `teardownElysia` (Elysia plugin) | | `@teardown/server/adapters/memory` | `createMemoryStorage`, `MemorySeed` | # Core Concepts (/docs/server/core-concepts) ## Ports & adapters architecture [#ports--adapters-architecture] `@teardown/server` is a hexagonal (ports & adapters) runtime. The SDK ships the **domain services** and the **HTTP edge**; you supply the **database** through a storage port. Each layer talks only to the layer below it, through interfaces. ``` HTTP adapter (fetch / elysia) ← shipped ▼ HTTP router + handlers (identify/events) ← shipped (reproduces the ingest wire contract) ▼ Domain services (identify/events/...) ← shipped (no DB, no framework, no singletons) ▼ TeardownStorage port (*Repository) ← you implement (or use a shipped adapter) ``` * **HTTP adapters** lower a framework-native request (a Web `Request`, an Elysia `Context`) into a normalized `TdRequest`, run the router, and serialize the `TdResponse`. * **The router + handlers** match method + path, run header extraction, API-key auth, and body validation, then call a domain service and map its result to an HTTP status. * **Domain services** hold all the business logic (find-or-create, session reuse, version-status severity, the build cascade). They perform all I/O through ports. * **The storage port** (`TeardownStorage`) is the database boundary. The runtime never imports a database directly — it calls `storage.users.findById(...)` and friends. ## The two consumption modes [#the-two-consumption-modes] The runtime supports two integration styles, and you can mix them. **Start by mounting the router** — it's the drop-in self-hosted ingest endpoint and what [Getting Started](/docs/server/getting-started) sets up. Reach for **calling services directly** only when you need your own routes/auth or you're building the version & build **management** (write) side. ### Mount the router [#mount-the-router] Let the SDK own the HTTP edge. `td.router()` reproduces the ingest contract, and an adapter serves it. This is the fastest path to a drop-in self-hosted ingest endpoint. ```ts import { toFetchHandler } from "@teardown/server/http/fetch"; Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) }); ``` ### Call services directly [#call-services-directly] Call `td.services.*` from your own routes, with your own auth and validation. This is how the Teardown dashboard backend drives version & build management — it never mounts the ingest router, it calls `td.services.versions` / `td.services.builds`. ```ts const result = await td.services.identify.identify(headers, body); if (result.success) { // result.data: { session_id, device_id, user_id, token, version_info } } const versions = await td.services.versions.searchVersionsByProject({ projectId, page: 1, limit: 20, sortBy: "created_at", sortOrder: "desc", }); ``` The available services are `identify`, `events`, `forceUpdate`, `session`, `environment`, `pushTokens`, `versions`, and `builds`. See the [API Reference](/docs/server/api-reference#services) for every method signature. ## Dependency injection, no singletons [#dependency-injection-no-singletons] There are no module-level `db` / `cache` / `logger` globals. `createTeardown` builds one shared dependency container and hands it to each service constructor. This means: * You can run multiple independent runtimes in one process (e.g. one per tenant, or one per test) with different storage and config. * Every cross-cutting concern — cache, logging, metrics, clock, token signing — is an injectable port with a no-op / system default, so the SDK's *required* dependency surface is just `storage`. * Services are trivially testable against the in-memory storage adapter with a fake clock. ```ts const td = createTeardown({ storage: createMemoryStorage(), config: { sessionSecret: "test-secret" }, clock: { now: () => new Date("2026-01-01T00:00:00Z"), uuid: () => "fixed-uuid" }, }); ``` ## The preserved wire contract [#the-preserved-wire-contract] The mounted router speaks the exact same HTTP contract as Teardown's hosted ingest API, so the React Native SDK works against your server with only an `ingestUrl` change. The routes are: | Method | Path | Purpose | | ------ | -------------- | ---------------------------------------------------------------------- | | `POST` | `/v1/identify` | Identify a device/user, return a session + `version_info` | | `POST` | `/v1/events` | Ingest a batch of events | | `GET` | `/health` | Liveness probe (`{ status: "ok", timestamp, build_id?, service_id? }`) | | `GET` | `/` | Root (`{ message, version }`) | The request/response DTOs come from `@teardown/schemas`, the single source of truth shared with the React Native SDK's generated client. See [Mounting](/docs/server/mounting#requestresponse-contract) for the response envelopes and error codes. ## API-key authentication (hosted / multi-tenant) [#api-key-authentication-hosted--multi-tenant] In the default **hosted/multi-tenant** mode, both `POST /v1/identify` and `POST /v1/events` are guarded by an API-key authenticator that reproduces the hosted ingest behavior: 1. Read `td-api-key` (a leading `Bearer ` prefix is stripped) and `td-project-id` from the request headers. 2. A missing key or missing project id is a `403`. 3. Resolve the publishable key to its `{ key_id, project_id, org_id }` via `storage.apiKeys.findPublishableContext(key)`. An unknown key is a `403` "Invalid API key". 4. **Security:** require the request's `td-project-id` to equal the key's resolved `project_id`. A mismatch is a `403` "API key does not belong to the specified project". The project and org are always taken from the key, never from the header. The authenticator depends only on your storage's `apiKeys` repository, so it works with any database. **Single-tenant self-hosting.** Pass `tenant: { orgId, projectId }` to `createTeardown` and API-key auth is turned off: `td-api-key`, `td-org-id`, and `td-project-id` are no longer required (only `td-environment-slug` and `td-device-id` are), the org/project come from the `tenant`, and no `apiKeys` repository is needed. You can still supply a custom `ApiKeyAuthenticator` to the router for your own scheme (see [Mounting](/docs/server/mounting)). ## Error handling convention [#error-handling-convention] Domain services return a discriminated `AsyncResult` rather than throwing on expected failures: ```ts type AsyncResult = | { success: true; data: T } | { success: false; error: string }; ``` The version/build management services return a typed error union instead (e.g. `{ code: "VERSION_NOT_FOUND"; message }`). Storage repositories follow a different convention — they return `Entity | null` and **throw** a `StorageError` on infrastructure failure. See [Storage](/docs/server/storage#result-and-error-convention) for why the boundaries differ. # Getting Started (/docs/server/getting-started) Run Teardown's **identify**, **event ingestion**, and **force-update** runtime inside your own TypeScript backend. This guide gets a server live with in-memory storage in a few minutes, then shows how to go to production by implementing the repository interfaces against your own database — you own the schema and its migrations. You need a TypeScript runtime with Web-standard `fetch` — Bun, Node 18+, Deno, or an edge runtime. The examples use Bun. ## 1. Install [#1-install] ```bash bun add @teardown/server # or npm install @teardown/server ``` The package ships the runtime and HTTP edge only — no database driver, no ORM. You bring your own database by implementing the storage repositories (see [Go to production](#go-to-production)). ## 2. Generate a session secret [#2-generate-a-session-secret] The server signs a session token for every client it identifies. Generate a strong secret and store it in your backend's environment — never commit it or ship it to clients. ```bash openssl rand -base64 32 ``` ```bash title=".env" SESSION_SECRET= ``` It's the HMAC key the server uses (HS256, via `jose`) to **sign and verify the short-lived session tokens** it issues after a successful `/v1/identify`. The client sends the token back on later requests, and the server verifies it with this secret — so it can trust the session without another database lookup. Keep it secret, keep it **stable**, and use the **same** value on every instance (a token signed by one replica must verify on another). See [Advanced](/docs/server/advanced#session-secret-and-token-signing) for rotation and multi-instance notes. ## 3. Run it [#3-run-it] Create the runtime with in-memory storage and serve the ingest routes. Run it **single-tenant**: pass a `tenant` and the client needs no API-key/org/project headers. The seed just needs one project and one environment so identify can resolve the environment by slug. ```ts title="server.ts" import { createTeardown } from "@teardown/server"; import { createMemoryStorage } from "@teardown/server/adapters/memory"; import { toFetchHandler } from "@teardown/server/http/fetch"; const now = new Date().toISOString(); const storage = createMemoryStorage({ projects: [{ id: "proj_demo", org_id: "org_demo", name: "Demo", slug: "demo", type: "EXPO", status: "ACTIVE", push_notifications_enabled: false, first_session_at: null, created_at: now, updated_at: now, }], environments: [{ id: "env_demo", project_id: "proj_demo", name: "Production", slug: "production", type: "PRODUCTION", created_at: now, updated_at: now, }], }); const td = createTeardown({ storage, // in-memory: try it / tests config: { sessionSecret: process.env.SESSION_SECRET! }, // required tenant: { orgId: "org_demo", projectId: "proj_demo" }, // single-tenant: no api-key / org / project headers }); Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) }); ``` ```bash bun run server.ts # then in another shell: curl http://localhost:4501/health # → { "status": "ok", ... } ``` Your ingest server is live. It speaks the exact same wire contract as Teardown's hosted ingest, so the React Native SDK works against it unchanged. In-memory storage is single-process and non-persistent — everything resets on restart. For production, implement the repository interfaces against your own database (next section). ## 4. Connect the React Native SDK [#4-connect-the-react-native-sdk] Point the client at your server with the **self-hosted** config — no org, project, or API key. ```ts title="teardown.ts" import { TeardownCore } from "@teardown/force-updates"; export const teardown = new TeardownCore({ config: { type: "self-hosted", ingestUrl: "http://localhost:4501" }, // your @teardown/server endpoint environment_slug: "production", // storageAdapter, deviceAdapter, ... }); ``` In single-tenant mode the server reads only `td-environment-slug` and `td-device-id` (the SDK sends both automatically) — never an API key, org, or project. See the [React Native getting started](/docs/getting-started) for the full client setup. ## Go to production [#go-to-production] In-memory is single-process and non-persistent. For production you implement the storage repository interfaces against your own database — **your schema, your migrations**. `@teardown/server` ships no database driver and no migrations. What changes from the snippet above: * Swap `createMemoryStorage(...)` for your own `createMyStorage(...)` (it returns a `TeardownStorage`). * Drop the seed — your projects and environments now live in your database. * Keep `tenant` for a single-tenant server, or omit it for a hosted/multi-tenant deployment (where clients send `td-api-key`/`td-org-id`/`td-project-id` and you implement the `apiKeys` port). ```ts title="server.ts" import { createMyStorage } from "./storage"; // implements TeardownStorage over your DB const td = createTeardown({ storage: createMyStorage(process.env.DATABASE_URL!), config: { sessionSecret: process.env.SESSION_SECRET! }, tenant: { orgId: process.env.TD_ORG_ID!, projectId: process.env.TD_PROJECT_ID! }, }); ``` See [Bring your own database](/docs/server/storage/bring-your-own-database) for worked repository examples (Postgres, MongoDB, Firestore) and the correctness checklist, and [Advanced](/docs/server/advanced) for caching, metrics, and running multiple instances. ## Other runtimes [#other-runtimes] The `fetch` handler runs anywhere Web-standard `Request`/`Response` exists (Hono, Cloudflare Workers, Deno, Next.js route handlers). A first-class Elysia plugin is also shipped: ```ts title="Elysia" import { Elysia } from "elysia"; import { ElysiaErrors } from "@teardown/errors/elysia"; import { teardownElysia } from "@teardown/server/http/elysia"; new Elysia().use(ElysiaErrors).use(teardownElysia(td)).listen(4501); ``` See [Mounting](/docs/server/mounting) for every runtime, Node/Express, CORS, and the request/response contract. ## Common configuration [#common-configuration] Only `storage` is required. The options you'll reach for most: | Option | Default | Description | | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `config.sessionSecret` | — | HMAC secret signing session tokens. Required (or pass a custom `signer`) | | `tenant` | — | `{ orgId, projectId }` to run single-tenant: `td-api-key`/`td-org-id`/`td-project-id` are not required (hosted-only). Omit for multi-tenant | | `config.sessionTokenExpiry` | `"15m"` | Session-token lifetime (jose format) | | `config.maxEventsPerBatch` | `100` | Max events accepted per `/v1/events` batch | | `cache` | no-op | A `CachePort` (e.g. Redis) to accelerate version/session lookups | | `logger` / `metrics` | no-op | Observability ports | See the [API Reference](/docs/server/api-reference) for every option, the `Teardown` object, and all services. ## Next steps [#next-steps] * [Core Concepts](/docs/server/core-concepts) — the architecture and the two consumption modes * [Storage](/docs/server/storage) — use in-memory or implement the repository interfaces for any database * [Version Management](/docs/server/version-management) — drive force-update status from your backend # Server SDK (/docs/server) The Teardown Server SDK (`@teardown/server`) lets you run Teardown's device/user **identify**, **event ingestion**, **force-update / version-status** checks, and **app version & build management** runtime inside *your own* TypeScript backend, backed by *your own* database. It pairs with the [React Native SDK](/docs/introduction): point its `ingestUrl` at a server running `@teardown/server` and the existing client works unchanged. ## Features [#features] * **Self-hosted ingest** - Run the `POST /v1/identify` and `POST /v1/events` wire contract the React Native SDK speaks, in your own service * **Force updates from your backend** - Version-status checks resolved against your data, plus version & build management (status cascade + change hooks) * **Bring your own database** - Implement a small storage interface for any database (Postgres, MongoDB, Firestore, …); you own the schema and migrations. An in-memory adapter ships for tests and prototyping * **Two consumption modes** - Mount a ready-made HTTP router, or call the domain services directly from your own routes and auth * **Runtime-agnostic** - Web-standard `fetch` adapter runs on Bun, Hono, Deno, Cloudflare Workers, and Next.js route handlers; a first-class Elysia plugin is also shipped * **No singletons, full DI** - Everything is wired through `createTeardown`; nothing reads a module-level `db`/`cache`/`logger` * **Type Safety** - Full TypeScript types for the storage ports, entities, configuration, and HTTP contract ## Quick Start [#quick-start] Install the package: ```bash bun add @teardown/server ``` Generate a `SESSION_SECRET` (`openssl rand -base64 32`), wire a storage layer into the runtime, then mount the ingest router: ```ts import { createTeardown } from "@teardown/server"; import { createMemoryStorage } from "@teardown/server/adapters/memory"; import { toFetchHandler } from "@teardown/server/http/fetch"; const td = createTeardown({ storage: createMemoryStorage(), // in-memory for tests; implement the repositories for production config: { sessionSecret: process.env.SESSION_SECRET! }, }); // Bun example — serve the ingest contract the RN SDK speaks: Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) }); ``` The [Getting Started](/docs/server/getting-started) guide walks this end to end — generating the secret, seeding a project/environment, running single-tenant, and going to production by implementing the storage repositories. Then point the React Native SDK at your server with the self-hosted config: ```ts const teardown = new TeardownCore({ config: { type: "self-hosted", ingestUrl: "https://ingest.your-app.com" }, // your @teardown/server endpoint environment_slug: "production", // ...storageAdapter, deviceAdapter }); ``` ## Runtime support [#runtime-support] The primary `fetch` adapter has zero required peer dependencies and runs anywhere Web-standard `Request`/`Response` is available. An Elysia plugin is shipped for Elysia hosts. | Runtime | Adapter | Import | | -------------------------------- | ---------------------------------- | ------------------------------ | | Bun (`Bun.serve`) | `fetch` | `@teardown/server/http/fetch` | | Elysia | Elysia plugin | `@teardown/server/http/elysia` | | Hono | `fetch` | `@teardown/server/http/fetch` | | Cloudflare Workers / Deno / edge | `fetch` | `@teardown/server/http/fetch` | | Next.js route handlers | `fetch` | `@teardown/server/http/fetch` | | Node / Express | `fetch` (via a Web `Request` shim) | `@teardown/server/http/fetch` | ## Documentation [#documentation] * [Getting Started](/docs/server/getting-started) - Install, generate a session secret, run with in-memory storage, connect the client, then implement the repositories for production * [Core Concepts](/docs/server/core-concepts) - Ports & adapters architecture and the two consumption modes * [Storage](/docs/server/storage) - The `TeardownStorage` port, the in-memory adapter, and bringing your own database * [Mounting](/docs/server/mounting) - The HTTP layer, adapters, CORS, and the request/response contract * [Version Management](/docs/server/version-management) - Managing versions/builds and force-update status from your backend * [API Reference](/docs/server/api-reference) - `createTeardown`, the `Teardown` object, services, and ports * [Advanced](/docs/server/advanced) - Caching, logging, metrics, atomicity, and deployment # Mounting (/docs/server/mounting) The HTTP layer reproduces Teardown's ingest wire contract so the React Native SDK works against your server unchanged. This page covers the router, the shipped adapters, CORS, the response envelopes and error codes, and how to mount on each runtime. ## Pick your adapter [#pick-your-adapter] | Runtime | Adapter | Import | | ------------------------------------------------------ | ---------------------------------- | ------------------------------------------------------------ | | Bun, Hono, Deno, Cloudflare Workers, Next.js, any edge | `fetch` | `@teardown/server/http/fetch` → [jump](#the-fetch-adapter) | | Node / Express | `fetch` (via a Web `Request` shim) | `@teardown/server/http/fetch` → [jump](#node--express) | | Elysia | Elysia plugin | `@teardown/server/http/elysia` → [jump](#the-elysia-adapter) | The `fetch` adapter is the default — zero required peers, runs anywhere Web-standard `Request`/`Response` exists. Use the Elysia plugin only on Elysia hosts. ## The router [#the-router] `td.router(info?)` builds a framework-agnostic [`TdRouter`](#normalized-primitives) over four routes: | Method | Path | Handler behaviour | | ------ | -------------- | ----------------------------------------------------------------------------------------------- | | `POST` | `/v1/identify` | Extract headers → authenticate → validate body → `services.identify.identify(headers, body)` | | `POST` | `/v1/events` | Extract headers → authenticate → validate body → `services.events.processEvents(headers, body)` | | `GET` | `/health` | `{ status: "ok", timestamp, build_id?, service_id? }` | | `GET` | `/` | `{ message, version }` | The optional `info` argument supplies runtime/build metadata for the two GET routes: | Field | Type | Surfaced by | Default | | ------------- | -------- | -------------------------- | -------------------------------------- | | `version` | `string` | `GET /` `version` | the `@teardown/server` package version | | `serviceName` | `string` | `GET /` `message` | `"Teardown Ingest API"` | | `buildId` | `string` | `GET /health` `build_id` | `undefined` | | `serviceId` | `string` | `GET /health` `service_id` | `undefined` | ```ts const router = td.router({ version: "1.4.0", buildId: process.env.GIT_SHA }); ``` A request that matches no route returns `404 { success: false, error: { code: "NOT_FOUND", message } }`. ## The fetch adapter [#the-fetch-adapter] `@teardown/server/http/fetch` is the primary, universal adapter. `toFetchHandler(router)` returns `(req: Request) => Promise` using only Web-standard `Request`/`Response`/`URL` — no Node-only APIs. It: * lowers the `Request` into a normalized request (lowercased headers, parsed query, lazy JSON body), * short-circuits `OPTIONS` preflight to `204` with the CORS headers, * runs the router and serializes the result to a JSON `Response` with the status, CORS headers, and `content-type: application/json`. ```ts import { toFetchHandler } from "@teardown/server/http/fetch"; const handler = toFetchHandler(td.router()); ``` ### Bun [#bun] ```ts Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) }); ``` ### Hono [#hono] ```ts import { Hono } from "hono"; const app = new Hono(); const ingest = toFetchHandler(td.router()); app.all("/v1/*", (c) => ingest(c.req.raw)); app.get("/health", (c) => ingest(c.req.raw)); ``` ### Cloudflare Workers / Deno / edge [#cloudflare-workers--deno--edge] ```ts const handler = toFetchHandler(td.router()); export default { fetch(request: Request): Promise { return handler(request); }, }; ``` ### Next.js route handlers [#nextjs-route-handlers] Mount the handler in an App Router catch-all route. Because the handler is a plain `(Request) => Response`, it maps onto the route methods directly: ```ts // app/api/[...td]/route.ts import { toFetchHandler } from "@teardown/server/http/fetch"; import { td } from "@/lib/teardown"; const handler = toFetchHandler(td.router()); export const GET = (req: Request) => handler(req); export const POST = (req: Request) => handler(req); export const OPTIONS = (req: Request) => handler(req); ``` ### Node / Express [#node--express] The `fetch` handler runs on Node 18+. Mount it on Express by shimming the Node request into a Web `Request` and writing the Web `Response` back: ```ts import express from "express"; import { toFetchHandler } from "@teardown/server/http/fetch"; const handler = toFetchHandler(td.router()); const app = express(); app.use(async (req, res) => { const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`; const body = ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body); const request = new Request(url, { method: req.method, headers: req.headers as Record, body, }); const response = await handler(request); res.status(response.status); response.headers.forEach((value, key) => res.setHeader(key, value)); res.send(await response.text()); }); ``` > Mount `express.json()` before this middleware so `req.body` is parsed. ## The Elysia adapter [#the-elysia-adapter] `@teardown/server/http/elysia` ships a first-class Elysia plugin (`elysia` is an optional peer). `teardownElysia(deps)` mounts the four routes and uses Elysia's native TypeBox `headers` / `body` guards to validate the request **first** — producing the `422 ValidationError` the hosted ingest returns when you also mount `@teardown/errors`'s `ElysiaErrors` handler — then delegates to the same handlers the `fetch` adapter uses. Auth failures throw `ForbiddenError` (rendered as `403`). ```ts import { Elysia } from "elysia"; import { ElysiaErrors } from "@teardown/errors/elysia"; import { teardownElysia } from "@teardown/server/http/elysia"; const app = new Elysia().use(ElysiaErrors).use(teardownElysia(td)); app.listen(4501); ``` A `Teardown` instance satisfies the plugin's dependency type structurally, so you pass `td` directly. You can also pass `{ services, storage, clock, auth?, info? }` if you build the pieces yourself. ## CORS [#cors] The adapters apply the same CORS configuration as the hosted ingest so the React Native SDK's browser/preflight behaviour is unchanged: * `access-control-allow-origin: *` * `access-control-allow-methods: GET, POST, OPTIONS` * `access-control-allow-headers`: the `td-*` allowlist (`td-api-key`, `td-org-id`, `td-project-id`, `td-environment-slug`, `td-device-id`, `td-session-id`, `td-sdk-version`, `td-rn-version`) plus `Content-Type` and `Authorization` * `access-control-allow-credentials: true` These constants are exported from `@teardown/server/http` (`CORS_HEADERS`, `CORS_ALLOWED_HEADERS`, `CORS_ALLOWED_METHODS`) if you mount the routes manually and need to apply them yourself. ## Request headers [#request-headers] Both POST routes read the same `td-*` headers the React Native SDK sends. In **single-tenant mode** (`tenant` passed to `createTeardown`) the three hosted-only headers are not read — the org/project come from the `tenant` and no API key is required; the client sends only `td-environment-slug` + `td-device-id`. | Header | Identify | Events | Description | | --------------------- | ----------- | ----------- | ------------------------------------------------------------------------------------ | | `td-api-key` | Hosted only | Hosted only | Publishable API key (`Bearer ` prefix tolerated). Not required in single-tenant mode | | `td-org-id` | Hosted only | Hosted only | Organization id. Not required in single-tenant mode | | `td-project-id` | Hosted only | Hosted only | Project id (must match the key's project). Not required in single-tenant mode | | `td-environment-slug` | Required | Required | Environment slug (resolved to an environment id) | | `td-device-id` | Required | Optional | Client device id | | `td-session-id` | Optional | Optional | Session id | | `td-sdk-version` | Optional | Optional | Client SDK name/version | ## Request/response contract [#requestresponse-contract] ### Success [#success] Identify returns the full session payload: ```json { "success": true, "data": { "session_id": "...", "device_id": "...", "user_id": "...", "token": "...", "version_info": { "status": "UP_TO_DATE", "update": null } } } ``` Events returns the batch result: ```json { "success": true, "data": { "event_ids": ["..."], "processed_count": 3, "failed_count": 0 } } ``` ### Errors [#errors] Failures use `{ success: false, error: { code, message } }` with these codes: | Status | Code | When | | ------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `400` | `MISSING_*` | A required `td-*` header is absent (the code names the header) | | `400` | `VALIDATION_ERROR` | The JSON body failed schema validation (`fetch` adapter / generic hosts) | | `400` | `IDENTIFY_FAILED` | `services.identify.identify` returned a failure | | `400` | `EVENTS_PROCESSING_FAILED` | `services.events.processEvents` returned a failure | | `403` | `FORBIDDEN` | (Hosted mode) missing/invalid API key, or `td-project-id` did not match the key's project. Not raised in single-tenant mode | | `404` | `NOT_FOUND` | No route matched the method + path | > The Elysia adapter validates the body with Elysia's own TypeBox guard *before* the handler, so an invalid body there surfaces as a `422 ValidationError` (matching the hosted ingest) rather than the `400 VALIDATION_ERROR` the `fetch` adapter returns. Both reject the same payloads. ## Normalized primitives [#normalized-primitives] If you write your own adapter, the normalized shapes are exported from `@teardown/server/http`: ```ts interface TdRequest { method: string; path: string; // path only, no query string headers: Record; // lowercased keys query: Record; json(): Promise; // lazily parses + caches the body } interface TdResponse { status: number; body: unknown; // serialized to JSON by the adapter headers?: Record; } interface TdRouter { readonly routes: TdRoute[]; handle(req: TdRequest): Promise; } ``` Lower your framework's request into a `TdRequest`, call `router.handle(req)`, and serialize the `TdResponse` — that is exactly what the `fetch` and Elysia adapters do. ## Custom authenticator [#custom-authenticator] The router builds an API-key authenticator over your `storage.apiKeys` by default. To override it (for example to delegate to an existing auth helper), pass a `TdRouter`'s deps with your own `auth`: ```ts import { buildIngestRouter, createApiKeyAuthenticator } from "@teardown/server/http"; const router = buildIngestRouter({ services: td.services, storage: td.storage, clock: td.clock, auth: myCustomAuthenticator, // implements ApiKeyAuthenticator }); Bun.serve({ port: 4501, fetch: toFetchHandler(router) }); ``` # Bring your own database (/docs/server/storage/bring-your-own-database) `TeardownStorage` is a plain object of per-entity repositories, so you can back the runtime with any database by implementing those interfaces. This page walks through a worked MongoDB repository, the Firestore equivalent notes, and the checklist to keep your implementation correct. Implement the small `find`/`create`/`update` repositories and assemble them into one object. Map your rows to the [entity shapes](/docs/server/storage#entity-conventions); return `null` for not-found and `throw StorageError` only on infrastructure failure. The **only** subtlety is the two natural-key [upserts](#concurrency-safe-upserts) — they must be atomic. This is the production path — you own the schema and its migrations; the [in-memory adapter](/docs/server/storage/in-memory) covers tests and prototyping. Worked examples below cover MongoDB, Postgres, and Firestore. ## The shape of a repository [#the-shape-of-a-repository] Each repository is a small interface of `find` / `create` / `update` methods. You implement them over your database's client, mapping your stored documents to the SDK's [entity shapes](/docs/server/storage#entity-conventions) on the way out, and the `New*` / `*Patch` inputs to your documents on the way in. A complete `TeardownStorage` is just those ten repositories assembled into one object: ```ts import type { TeardownStorage } from "@teardown/server/ports"; export function createMongoStorage(db: Db): TeardownStorage { return { users: new MongoUserRepository(db), devices: new MongoDeviceRepository(db), sessions: new MongoSessionRepository(db), versions: new MongoVersionRepository(db), builds: new MongoBuildRepository(db), pushTokens: new MongoPushTokenRepository(db), events: new MongoEventRepository(db), environments: new MongoEnvironmentRepository(db), apiKeys: new MongoApiKeyRepository(db), projects: new MongoProjectRepository(db), // transaction is optional; omit it unless you need cross-entity atomicity }; } ``` ## Worked example: MongoUserRepository [#worked-example-mongouserrepository] The user repository is representative. Note how it maps Mongo's `_id` to the entity's opaque `id`, returns `null` for "not found", and throws `StorageError` only on infrastructure failure. ```ts import type { Collection, Db } from "mongodb"; import type { NewUser, UserEntity, UserPatch, UserRepository } from "@teardown/server/ports"; import { StorageError } from "@teardown/server/ports"; interface UserDoc { _id: string; environment_id: string; external_user_id: string | null; email: string | null; name: string | null; created_at: string; updated_at: string; } const toEntity = (doc: UserDoc): UserEntity => ({ id: doc._id, environment_id: doc.environment_id, external_user_id: doc.external_user_id, email: doc.email, name: doc.name, created_at: doc.created_at, updated_at: doc.updated_at, }); export class MongoUserRepository implements UserRepository { private readonly col: Collection; constructor(db: Db) { this.col = db.collection("td_users"); } async findById(userId: string, environmentId: string): Promise { try { // Scope by environment_id — this is a security boundary, not just a filter. const doc = await this.col.findOne({ _id: userId, environment_id: environmentId }); return doc ? toEntity(doc) : null; } catch (cause) { throw new StorageError("users.findById failed", { cause }); } } async findByIdentifier( environmentId: string, identifier: { external_user_id?: string | null; email?: string | null }, ): Promise { try { // Match external_user_id when provided, otherwise email. First match or null. const filter = identifier.external_user_id != null ? { environment_id: environmentId, external_user_id: identifier.external_user_id } : { environment_id: environmentId, email: identifier.email ?? null }; const doc = await this.col.findOne(filter); return doc ? toEntity(doc) : null; } catch (cause) { throw new StorageError("users.findByIdentifier failed", { cause }); } } async create(input: NewUser): Promise { try { const now = new Date().toISOString(); const doc: UserDoc = { _id: crypto.randomUUID(), environment_id: input.environment_id, external_user_id: input.external_user_id ?? null, email: input.email ?? null, name: input.name ?? null, created_at: now, updated_at: now, }; await this.col.insertOne(doc); return toEntity(doc); } catch (cause) { throw new StorageError("users.create failed", { cause }); } } async update(userId: string, patch: UserPatch): Promise { try { const doc = await this.col.findOneAndUpdate( { _id: userId }, { $set: { ...patch, updated_at: new Date().toISOString() } }, { returnDocument: "after" }, ); if (!doc) throw new StorageError(`User ${userId} not found`); return toEntity(doc); } catch (cause) { if (cause instanceof StorageError) throw cause; throw new StorageError("users.update failed", { cause }); } } async delete(userId: string): Promise { try { await this.col.deleteOne({ _id: userId }); } catch (cause) { throw new StorageError("users.delete failed", { cause }); } } } ``` The remaining repositories follow the same pattern. A few worth calling out: * `EventRepository.createMany(events)` returns `{ ids: string[] }` in input order — generate one id per event and `insertMany`. * `SearchParams`-taking methods (`searchByEnvironment`, `searchByProject`) return a `Page` of `{ items, total }`: apply `search` as a case-insensitive substring, sort by `sortBy`/`sortOrder`, and paginate with `page` (1-based) and `limit`. * `ApiKeyRepository.findPublishableContext(key)` resolves a publishable key to `{ key_id, project_id, org_id }` (a join from your key collection to its project and org). ## Worked example: Postgres (your own schema) [#worked-example-postgres-your-own-schema] If your database is Postgres, you implement the same repositories over **your own schema** and **manage your own migrations** — `@teardown/server` ships no Postgres adapter and no schema. Use any client (node-postgres, postgres.js, Drizzle, Kysely). The version repository is representative; note the race-safe `upsertByName` (an `INSERT … ON CONFLICT DO NOTHING` followed by a `SELECT`). ```ts import type { Pool } from "pg"; import type { UpsertVersionInput, VersionEntity, VersionRepository } from "@teardown/server/ports"; import { StorageError } from "@teardown/server/ports"; // You own this table and its migration; @teardown/server never creates it. // CREATE TABLE versions ( // id uuid PRIMARY KEY DEFAULT gen_random_uuid(), // project_id text NOT NULL, // name text NOT NULL, // major int NOT NULL, minor int NOT NULL, patch int NOT NULL, // notes text, // status text NOT NULL DEFAULT 'SUPPORTED', // release_at timestamptz NOT NULL DEFAULT now(), // created_at timestamptz NOT NULL DEFAULT now(), // updated_at timestamptz NOT NULL DEFAULT now(), // UNIQUE (project_id, name) -- required for the race-safe upsert // ); type VersionRow = { id: string; project_id: string; name: string; major: number; minor: number; patch: number; notes: string | null; status: VersionEntity["status"]; release_at: Date; created_at: Date; updated_at: Date; }; const toEntity = (r: VersionRow): VersionEntity => ({ id: r.id, project_id: r.project_id, name: r.name, major: r.major, minor: r.minor, patch: r.patch, notes: r.notes, status: r.status, release_at: r.release_at.toISOString(), created_at: r.created_at.toISOString(), updated_at: r.updated_at.toISOString(), }); export class PgVersionRepository implements VersionRepository { constructor(private readonly pool: Pool) {} async findByName(projectId: string, name: string): Promise { try { const { rows } = await this.pool.query( `SELECT * FROM versions WHERE project_id = $1 AND name = $2 LIMIT 1`, [projectId, name], ); return rows[0] ? toEntity(rows[0]) : null; } catch (cause) { throw new StorageError("versions.findByName failed", { cause }); } } // Atomic insert-or-get on (project_id, name): on a concurrent conflict it returns the // existing row instead of throwing. async upsertByName(input: UpsertVersionInput): Promise { try { const inserted = await this.pool.query( `INSERT INTO versions (project_id, name, major, minor, patch, status) VALUES ($1, $2, $3, $4, $5, 'SUPPORTED') ON CONFLICT (project_id, name) DO NOTHING RETURNING *`, [input.project_id, input.name, input.major, input.minor, input.patch], ); if (inserted.rows[0]) return toEntity(inserted.rows[0]); const existing = await this.pool.query( `SELECT * FROM versions WHERE project_id = $1 AND name = $2 LIMIT 1`, [input.project_id, input.name], ); if (!existing.rows[0]) throw new StorageError("versions.upsertByName: row missing after conflict"); return toEntity(existing.rows[0]); } catch (cause) { if (cause instanceof StorageError) throw cause; throw new StorageError("versions.upsertByName failed", { cause }); } } // findById / findByIds / update / searchByProject / countByProject follow the same shape. } ``` Assemble the `Pg*Repository` classes into a `createPgStorage(pool): TeardownStorage`, exactly as the Mongo example does. Apply the schema with **your own** migration tool (Drizzle Kit, node-pg-migrate, Atlas, raw SQL) — `@teardown/server` ships none. New versions/builds start `status: "SUPPORTED"` (builds with `status_overridden: false`). Run the [leaky-abstraction checklist](#leaky-abstraction-checklist) before shipping. ## Concurrency-safe upserts [#concurrency-safe-upserts] Two methods must be atomic insert-or-get under concurrent callers (many identify calls race to create the same version/build). With MongoDB, back them with a unique index and an upsert: ```ts async upsertByName(input: UpsertVersionInput): Promise { // Requires a unique index on { project_id, name }. try { const now = new Date().toISOString(); const doc = await this.col.findOneAndUpdate( { project_id: input.project_id, name: input.name }, { $setOnInsert: { _id: crypto.randomUUID(), project_id: input.project_id, name: input.name, major: input.major, minor: input.minor, patch: input.patch, notes: null, status: "SUPPORTED", release_at: now, created_at: now, updated_at: now, }, }, { upsert: true, returnDocument: "after" }, ); return toVersionEntity(doc!); } catch (cause) { throw new StorageError("versions.upsertByName failed", { cause }); } } ``` `builds.upsertByVersionBuildPlatform` is identical but keyed on `(version_id, build_number, platform)`. New versions and builds always start `status: "SUPPORTED"`, builds with `status_overridden: false`. ### The build status cascade [#the-build-status-cascade] `BuildRepository` has two cascade methods used by version management: * `updateStatusByVersion(versionId, status)` — set the status on **every** build of the version **and reset `status_overridden` to `false`**. Return the count updated. * `updateStatusByVersionExcludingOverridden(versionId, status)` — set the status only on builds whose `status_overridden` is `false`. Also, `builds.update(id, patch)` setting `status` directly must mark the build `status_overridden = true` (unless the patch explicitly includes `status_overridden`). See [Version Management](/docs/server/version-management#the-status-cascade) for why. ## Firestore notes [#firestore-notes] Firestore maps cleanly with a few adjustments: * Use a collection per entity; the document id is the entity's opaque `id`. Store timestamps as ISO-8601 **strings** (not Firestore `Timestamp`s) so they match the entity contract verbatim. * "Find by field" becomes a `where()` query with `.limit(1)`; map the first doc or return `null`. * For the upserts, use a `runTransaction` that reads the natural-key query and writes only if absent, or rely on a deterministic document id derived from the natural key so a second writer's `create` is a no-op overwrite of identical data. * `createMany` is a batched write (`writeBatch`); collect the generated ids. * Firestore has no `ILIKE`; implement `search` as a best-effort prefix range query or fetch-and-filter for small collections. ## Leaky-abstraction checklist [#leaky-abstraction-checklist] Run through this before shipping a custom storage layer: * [ ] **Opaque ids** — ids are store-assigned `string`s; `New*` inputs never carry an `id`. Map your native id (`_id`, document id) to/from `id`. * [ ] **ISO-8601 string timestamps** — `created_at` / `updated_at` / `*_at` are `new Date().toISOString()` strings, not native date objects. * [ ] **JSON metadata** — `metadata` / `properties` round-trip as plain `Record | null`, not a driver JSON wrapper. * [ ] **`null`, never throw, for not-found** — `find*` returns `null`; only infrastructure failures throw `StorageError`. * [ ] **Scope security boundaries** — `users.findById` is scoped by `environmentId`, `devices.findByDeviceId` by `environmentId`, the `existsInEnvironment` checks by `environmentId`. Honor these filters; they prevent cross-tenant reads. * [ ] **Denormalize `environment_id`** — store `environment_id` directly on devices, sessions, and events so the per-environment lookups and `existsInEnvironment` checks are single-document reads, not joins. * [ ] **Concurrency-safe upserts** — `versions.upsertByName` and `builds.upsertByVersionBuildPlatform` are atomic and return the existing row on conflict (unique index + upsert). * [ ] **Cascade semantics** — `updateStatusByVersion` resets `status_overridden`; `update` with a `status` sets it. Counts are returned. # In-memory (/docs/server/storage/in-memory) `createMemoryStorage(seed?)` returns a dependency-free [`TeardownStorage`](/docs/server/storage) backed by plain `Map`s. It is the primary test fake for the domain services (the core of the SDK's TDD strategy) and is handy for local prototyping and demos. > It is single-process and non-persistent — everything is lost when the process exits. Do not use it in production. ## Usage [#usage] ```ts import { createTeardown } from "@teardown/server"; import { createMemoryStorage } from "@teardown/server/adapters/memory"; const td = createTeardown({ storage: createMemoryStorage(), config: { sessionSecret: "test-secret" }, }); ``` ## Seeding [#seeding] Identify and events resolve the request against the `projects` and `environments` you seed. In hosted/multi-tenant mode they also authenticate the API key against `apiKeys`; in single-tenant mode (`tenant` on `createTeardown`) you can omit `apiKeys` entirely. The in-memory store starts empty, so seed the fixtures up front via the optional `MemorySeed`: ```ts import { createMemoryStorage } from "@teardown/server/adapters/memory"; const storage = createMemoryStorage({ projects: [ { id: "proj_1", org_id: "org_1", name: "Demo", slug: "demo", type: "EXPO", status: "ACTIVE", push_notifications_enabled: false, first_session_at: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, ], environments: [ { id: "env_1", project_id: "proj_1", name: "Production", slug: "production", type: "PRODUCTION", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, ], apiKeys: [{ key: "pk_test_123", project_id: "proj_1", org_id: "org_1" }], }); ``` ### MemorySeed [#memoryseed] | Field | Type | Description | | -------------- | --------------------------------------------- | -------------------------------------------------------------------------------- | | `projects` | `ProjectEntity[]` | Projects to seed (looked up by the API-key auth and the management services) | | `environments` | `EnvironmentEntity[]` | Environments to seed (resolved by `environment_slug` during identify/events) | | `apiKeys` | `Array<{ key; key_id?; project_id; org_id }>` | Publishable keys mapped to their project/org. `key_id` defaults to a random uuid | Everything created at runtime (users, devices, sessions, versions, builds, push tokens, events) is generated with random UUID ids and ISO-8601 timestamps, matching the entity conventions. ## Behaviour parity [#behaviour-parity] The in-memory adapter implements the same subtle behaviours a production storage layer must, so it's a faithful stand-in for service-level tests: * `sessions.findValidForDevice` returns the newest non-expired session (newest-first by `created_at`), matching the production query. * `builds.update` setting `status` marks the build `status_overridden = true`, unless an explicit `status_overridden` is also passed (then the explicit value wins). * `builds.updateStatusByVersion` resets `status_overridden = false` on every build (a full cascade supersedes manual overrides); `updateStatusByVersionExcludingOverridden` skips overridden builds. * `versions.upsertByName` / `builds.upsertByVersionBuildPlatform` return the existing row on a natural-key conflict. It intentionally does **not** implement `transaction`, which exercises the runtime's no-mandatory-transaction path. ## Testing with a fixed clock [#testing-with-a-fixed-clock] Combine the in-memory store with an injected `clock` for deterministic assertions on timestamps and generated ids: ```ts const td = createTeardown({ storage: createMemoryStorage(), config: { sessionSecret: "test-secret" }, clock: { now: () => new Date("2026-01-01T00:00:00.000Z"), uuid: () => "00000000-0000-0000-0000-000000000000", }, }); ``` # Storage (/docs/server/storage) The storage layer is the database boundary of the runtime. Everything in this section lives under the `@teardown/server/ports` subpath export. **Pick one:** | Use | Adapter | Setup | | ------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ | | Tests & prototyping | [In-memory](/docs/server/storage/in-memory) | `createMemoryStorage(seed?)` | | Production / any database | [Bring your own](/docs/server/storage/bring-your-own-database) | implement the repositories (you own the schema + migrations) | Most repository methods are plain `find` / `create` / `update`, but the two natural-key **upserts must be atomic** (`versions.upsertByName`, `builds.upsertByVersionBuildPlatform`) — concurrent identify calls for the same app version race on them. See [The two upsert exceptions](#the-two-upsert-exceptions). ## TeardownStorage [#teardownstorage] `TeardownStorage` is the aggregate you pass to `createTeardown`. It is a bag of per-entity repositories plus an optional transaction hook: ```ts import type { TeardownStorage } from "@teardown/server/ports"; interface TeardownStorage { readonly users: UserRepository; readonly devices: DeviceRepository; readonly sessions: SessionRepository; readonly versions: VersionRepository; readonly builds: BuildRepository; readonly pushTokens: PushTokenRepository; readonly events: EventRepository; readonly environments: EnvironmentRepository; readonly apiKeys?: ApiKeyRepository; // optional — only for API-key auth (hosted-style) readonly projects?: ProjectRepository; // optional — only for the management search guard transaction?(work: (tx: TeardownStorage) => Promise): Promise; } ``` Each repository can be implemented independently, and namespaced access reads cleanly (`storage.users.findById(...)`). ## The repositories [#the-repositories] | Repository | Backs | Key methods | | ----------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UserRepository` | `users` | `findById`, `findByIdentifier`, `create`, `update`, `delete` | | `DeviceRepository` | `devices` | `findByDeviceId`, `findById`, `findByIds`, `create`, `update`, `reassignUser`, `existsInEnvironment`, `searchByEnvironment`, `countByProject` | | `SessionRepository` | `sessions` | `create`, `findValidForDevice`, `findByToken`, `findById`, `extendExpiry`, `existsInEnvironment`, `searchByEnvironment`, `countByEnvironment` | | `VersionRepository` | `versions` | `findByName`, `upsertByName`, `findById`, `findByIds`, `update`, `searchByProject`, `countByProject` | | `BuildRepository` | `builds` | `findByVersionBuildPlatform`, `upsertByVersionBuildPlatform`, `findById`, `findByIds`, `update`, `updateStatusByVersion`, `updateStatusByVersionExcludingOverridden`, `searchByProject`, `countByProject` | | `PushTokenRepository` | `push tokens` | `findByDeviceId`, `create`, `update`, `invalidateByDeviceId` | | `EventRepository` | `events` | `createMany` | | `EnvironmentRepository` | `environments` | `findBySlug`, `findById`, `create`, `listByProject`, `countByType`, `delete` | | `ApiKeyRepository` | `api keys` | `findPublishableContext` | | `ProjectRepository` | `projects` | `findById` | The minimum a self-host needs for the **identify + events** flow is `users`, `devices`, `sessions`, `versions`, `builds`, `pushTokens`, `events`, and `environments`. `apiKeys` and `projects` are **optional**: `apiKeys` is used only by the API-key authenticator (hosted/multi-tenant mode — a single-tenant self-host running with `tenant` doesn't need it), and `projects` only by the version/build **management** search guard. Omit them if you don't use those paths. ## Entity conventions [#entity-conventions] The data shapes that cross the storage boundary are DB-agnostic so any database can implement them. The convention is uniform: * **Object keys are `snake_case`**, mirroring the wire DTOs and the Postgres columns (`external_user_id`, `environment_id`, `build_number`). Scalar method *parameters* are `camelCase` per TS idiom (`userId`, `environmentId`); only the data-object keys are `snake_case`. * **Timestamps are ISO-8601 UTC strings** (`new Date().toISOString()`) — the lowest common denominator across Postgres `timestamptz`, Mongo `Date`, and Firestore `Timestamp`. * **Ids are opaque `string`s** assigned by the store. `New*` inputs omit `id`; the store returns the full `*Entity` (with its id). Never assume an id format. * **`metadata` / `properties` are plain JSON** (`Record | null`), never a driver-specific JSON handle. * Enums are plain string-literal unions (e.g. `DevicePlatform`, `ProjectVersionStatus`), decoupled from any ORM or validation library. ### The Entity / New / Patch triad [#the-entity--new--patch-triad] Each entity has up to three shapes: * `*Entity` — the full row returned by the store, including `id`, `created_at`, `updated_at`. * `New*` — the create input. Omits `id` and timestamps; optional columns are optional. * `*Patch` — a partial update. A `Partial>` of the mutable columns; only the supplied keys are written. ```ts import type { UserEntity, NewUser, UserPatch } from "@teardown/server/ports"; interface UserEntity { id: string; environment_id: string; external_user_id: string | null; // external persona id; null = anonymous email: string | null; name: string | null; created_at: string; updated_at: string; } type NewUser = { environment_id: string; external_user_id?: string | null; email?: string | null; name?: string | null }; type UserPatch = Partial>; ``` ## Result and error convention [#result-and-error-convention] Repositories follow a deliberately minimal convention so a Mongo/Firestore implementation stays idiomatic: * Methods **return `Entity | null`, `Entity[]`, `number`, or `void`** for their normal outcomes. "Not found" is `null`, never a thrown error. * On **infrastructure failure** (connection lost, constraint violation, serialization error) a method **throws `StorageError`**. ```ts import { StorageError } from "@teardown/server/ports"; async function findById(id: string): Promise { try { const row = await db.collection("users").findOne({ _id: id }); return row ? toUserEntity(row) : null; } catch (cause) { throw new StorageError("users.findById failed", { cause }); } } ``` The `AsyncResult` envelope you see returned by the domain services is a service-layer concern, kept *out* of the port. The services wrap your repository calls and translate a thrown `StorageError` into a failure result. ## Find-or-create lives in the services [#find-or-create-lives-in-the-services] Repositories expose only primitive `find` / `create` / `update`. The composite "find a user by persona or email, otherwise create one, and merge an anonymous device into the named user" logic lives in the domain services, on top of those primitives. You implement the simple parts; the SDK owns the orchestration. ### The two upsert exceptions [#the-two-upsert-exceptions] There are exactly two race-safe upserts in the port, because versions and builds are created concurrently by many simultaneous identify calls for the same app version: * `VersionRepository.upsertByName(input)` — atomic insert-or-get on the unique `(project_id, name)` key. * `BuildRepository.upsertByVersionBuildPlatform(input)` — atomic insert-or-get on the unique `(version_id, build_number, platform)` key. Both **must be atomic** and, on a conflict, **return the existing row** rather than throwing. With Postgres this is `INSERT ... ON CONFLICT DO NOTHING` followed by a `SELECT`; with MongoDB it is an upsert on a unique index. See [Bring your own database](/docs/server/storage/bring-your-own-database#concurrency-safe-upserts). ## Optional transaction hook [#optional-transaction-hook] `transaction?` is optional. If your store supports transactions you may implement it to wrap a unit of work, and the runtime will use it opportunistically — but it never *requires* it. The identify flow is idempotent and individually-atomic without it (the in-memory adapter omits `transaction` entirely and still passes the full service test suite). ```ts transaction: (work) => db.transaction((tx) => work(wrapAsTeardownStorage(tx))), ``` ## Adapters [#adapters] * [In-memory](/docs/server/storage/in-memory) — `createMemoryStorage(seed?)` for tests and prototyping. * [Bring your own database](/docs/server/storage/bring-your-own-database) — implement the repositories for any store (Postgres, MongoDB, Firestore, …); you own the schema and migrations. # Version Management (/docs/server/version-management) 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](/docs/force-updates) 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. 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 [#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`). ```ts // 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 [#builds] `td.services.builds` reads and updates `version_builds` with the same method shapes and error union (`BUILD_NOT_FOUND`, …). ```ts 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 [#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 status | Applies to | Overrides | | ---------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------- | | Non-`SUPPORTED` (e.g. `UPDATE_REQUIRED`) | **all** builds of the version | **reset** (`status_overridden = false`) — the version-wide directive wins | | `SUPPORTED` | only builds **not** manually overridden | **respected** — 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 [#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()`](/docs/force-updates). ## Status-change hooks [#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. ```ts 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 [#event-shapes] ```ts 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 [#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`: ```ts 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.