Teardown

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 and insertMany.
  • SearchParams-taking methods (searchByEnvironment, searchByProject) return a Page<T> 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)

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 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 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 Firestore Timestamps) 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

Run through this before shipping a custom storage layer:

  • Opaque ids — ids are store-assigned strings; New* inputs never carry an id. Map your native id (_id, document id) to/from id.
  • ISO-8601 string timestampscreated_at / updated_at / *_at are new Date().toISOString() strings, not native date objects.
  • JSON metadatametadata / properties round-trip as plain Record<string, unknown> | null, not a driver JSON wrapper.
  • null, never throw, for not-foundfind* returns null; only infrastructure failures throw StorageError.
  • Scope security boundariesusers.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 upsertsversions.upsertByName and builds.upsertByVersionBuildPlatform are atomic and return the existing row on conflict (unique index + upsert).
  • Cascade semanticsupdateStatusByVersion resets status_overridden; update with a status sets it. Counts are returned.