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:
| Use | Adapter | Setup |
|---|---|---|
| Tests & prototyping | In-memory | createMemoryStorage(seed?) |
| Production / any database | Bring your own | implement 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
| 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
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 arecamelCaseper TS idiom (userId,environmentId); only the data-object keys aresnake_case. - Timestamps are ISO-8601 UTC strings (
new Date().toISOString()) — the lowest common denominator across Postgrestimestamptz, MongoDate, and FirestoreTimestamp. - Ids are opaque
strings assigned by the store.New*inputs omitid; the store returns the full*Entity(with its id). Never assume an id format. metadata/propertiesare 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, includingid,created_at,updated_at.New*— the create input. Omitsidand timestamps; optional columns are optional.*Patch— a partial update. APartial<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, orvoidfor their normal outcomes. "Not found" isnull, 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-memory —
createMemoryStorage(seed?)for tests and prototyping. - Bring your own database — implement the repositories for any store (Postgres, MongoDB, Firestore, …); you own the schema and migrations.