Bring your own database
Implement the storage repositories for any database — a worked MongoDB example, Firestore notes, and the leaky-abstraction checklist.
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.
TL;DR
Implement the small find/create/update repositories and assemble them into one
object. Map your rows to the entity shapes;
return null for not-found and throw StorageError only on infrastructure failure. The
only subtlety is the two natural-key upserts — they must
be atomic. This is the production path — you own the schema and its migrations; the
in-memory adapter covers tests and prototyping. Worked
examples below cover MongoDB, Postgres, and Firestore.
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 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:
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
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.
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<UserDoc>;
constructor(db: Db) {
this.col = db.collection<UserDoc>("td_users");
}
async findById(userId: string, environmentId: string): Promise<UserEntity | null> {
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<UserEntity | null> {
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<UserEntity> {
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<UserEntity> {
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<void> {
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 andinsertMany.SearchParams-taking methods (searchByEnvironment,searchByProject) return aPage<T>of{ items, total }: applysearchas a case-insensitive substring, sort bysortBy/sortOrder, and paginate withpage(1-based) andlimit.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)
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).
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<VersionEntity | null> {
try {
const { rows } = await this.pool.query<VersionRow>(
`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<VersionEntity> {
try {
const inserted = await this.pool.query<VersionRow>(
`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<VersionRow>(
`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 before shipping.
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:
async upsertByName(input: UpsertVersionInput): Promise<VersionEntity> {
// 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
BuildRepository has two cascade methods used by version management:
updateStatusByVersion(versionId, status)— set the status on every build of the version and resetstatus_overriddentofalse. Return the count updated.updateStatusByVersionExcludingOverridden(versionId, status)— set the status only on builds whosestatus_overriddenisfalse.
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 for why.
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 FirestoreTimestamps) so they match the entity contract verbatim. - "Find by field" becomes a
where()query with.limit(1); map the first doc or returnnull. - For the upserts, use a
runTransactionthat 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'screateis a no-op overwrite of identical data. createManyis a batched write (writeBatch); collect the generated ids.- Firestore has no
ILIKE; implementsearchas a best-effort prefix range query or fetch-and-filter for small collections.
Leaky-abstraction checklist
Run through this before shipping a custom storage layer:
- Opaque ids — ids are store-assigned
strings;New*inputs never carry anid. Map your native id (_id, document id) to/fromid. - ISO-8601 string timestamps —
created_at/updated_at/*_atarenew Date().toISOString()strings, not native date objects. - JSON metadata —
metadata/propertiesround-trip as plainRecord<string, unknown> | null, not a driver JSON wrapper. -
null, never throw, for not-found —find*returnsnull; only infrastructure failures throwStorageError. - Scope security boundaries —
users.findByIdis scoped byenvironmentId,devices.findByDeviceIdbyenvironmentId, theexistsInEnvironmentchecks byenvironmentId. Honor these filters; they prevent cross-tenant reads. - Denormalize
environment_id— storeenvironment_iddirectly on devices, sessions, and events so the per-environment lookups andexistsInEnvironmentchecks are single-document reads, not joins. - Concurrency-safe upserts —
versions.upsertByNameandbuilds.upsertByVersionBuildPlatformare atomic and return the existing row on conflict (unique index + upsert). - Cascade semantics —
updateStatusByVersionresetsstatus_overridden;updatewith astatussets it. Counts are returned.