Teardown

Storage

The TeardownStorage port, the per-entity repository interfaces, the entity conventions, and the result/error convention.

The storage layer is the database boundary of the runtime. Everything in this section lives under the @teardown/server/ports subpath export.

Pick one:

UseAdapterSetup
Tests & prototypingIn-memorycreateMemoryStorage(seed?)
Production / any databaseBring your ownimplement the repositories (you own the schema + migrations)

If you implement your own

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.

TeardownStorage

TeardownStorage is the aggregate you pass to createTeardown. It is a bag of per-entity repositories plus an optional transaction hook:

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?<T>(work: (tx: TeardownStorage) => Promise<T>): Promise<T>;
}

Each repository can be implemented independently, and namespaced access reads cleanly (storage.users.findById(...)).

The repositories

RepositoryBacksKey methods
UserRepositoryusersfindById, findByIdentifier, create, update, delete
DeviceRepositorydevicesfindByDeviceId, findById, findByIds, create, update, reassignUser, existsInEnvironment, searchByEnvironment, countByProject
SessionRepositorysessionscreate, findValidForDevice, findByToken, findById, extendExpiry, existsInEnvironment, searchByEnvironment, countByEnvironment
VersionRepositoryversionsfindByName, upsertByName, findById, findByIds, update, searchByProject, countByProject
BuildRepositorybuildsfindByVersionBuildPlatform, upsertByVersionBuildPlatform, findById, findByIds, update, updateStatusByVersion, updateStatusByVersionExcludingOverridden, searchByProject, countByProject
PushTokenRepositorypush tokensfindByDeviceId, create, update, invalidateByDeviceId
EventRepositoryeventscreateMany
EnvironmentRepositoryenvironmentsfindBySlug, findById, create, listByProject, countByType, delete
ApiKeyRepositoryapi keysfindPublishableContext
ProjectRepositoryprojectsfindById

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

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 strings 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<string, unknown> | 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

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<Pick<...>> of the mutable columns; only the supplied keys are written.
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<Pick<UserEntity, "external_user_id" | "email" | "name">>;

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.
import { StorageError } from "@teardown/server/ports";

async function findById(id: string): Promise<UserEntity | null> {
  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

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

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.

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

transaction: (work) => db.transaction((tx) => work(wrapAsTeardownStorage(tx))),

Adapters

  • In-memorycreateMemoryStorage(seed?) for tests and prototyping.
  • Bring your own database — implement the repositories for any store (Postgres, MongoDB, Firestore, …); you own the schema and migrations.