Teardown

Core Concepts

Understand the ports & adapters architecture, the two consumption modes, dependency injection, and the preserved wire contract.

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 runtime supports two integration styles, and you can mix them.

Which one?

Start by mounting the router — it's the drop-in self-hosted ingest endpoint and what 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

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.

import { toFetchHandler } from "@teardown/server/http/fetch";

Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) });

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.

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 for every method signature.

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.
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 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:

MethodPathPurpose
POST/v1/identifyIdentify a device/user, return a session + version_info
POST/v1/eventsIngest a batch of events
GET/healthLiveness 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 for the response envelopes and error codes.

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).

Error handling convention

Domain services return a discriminated AsyncResult rather than throwing on expected failures:

type AsyncResult<T> =
  | { 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 for why the boundaries differ.