Building Relay

Part 2 · Chapter 2.1

Schema with a spine

You will produce: A migrated schema and a tenant-scoped repository layer — cross-tenant leaks made inexpressible · about 90 minutes including the exercise

Source: SRS — Software Requirements Specification · SAD — Software Architecture Document

Part 1 built ground: a workspace, four stores, a wire contract, two services that answer. Part 2 is the part the tutorial plan stars — the core loop, "where the tutorial earns its premise" — and you might expect it to open by sending a message. It doesn't. It opens with the line chapter 0.4 called the most important requirement in the document: FR-TEN-05, no operation may touch — or even reveal the existence of — another tenant's data. Relay is multi-tenant infrastructure; one isolation failure destroys the trust the entire product is sold on. So before the first message is written, we make that failure hard to even express: the schema gets its spine, and data access gets a shape that carries the tenant everywhere, by construction.

Designed out, not tested out

Driver D4 states the stance: "tenant isolation is a correctness property" — not a feature, not a filter, a property of the system like message ordering. The constitution turns it into machinery, and its words are worth reading exactly: "Data access MUST go through a repository layer whose constructors require an environment_id; raw connection access outside that layer is lint-forbidden. Isolation is enforced in data access, not in handlers."

The schema, from the architecture's own hand

The SAD doesn't describe the schema — it is the schema; §6.1 is SQL. And those tables are full of constraints that are requirements wearing SQL syntax: DR-01 is UNIQUE (channel_id, sequence) (one sequence number, one message — 2.2 lives on this), DR-02 is UNIQUE (environment_id, external_id) (names are unique per tenant, not globally — isolation even in uniqueness), DR-03 is a partial unique index on idempotency keys (2.3's whole chapter, planted now). ADR-16's data layer gives that SQL a typed twin: the schema is defined in TypeScript, and the constraints live beside the tables that own them — including DR-03's partial index, which is a first-class citizen here and famously inexpressible in some more popular tools (the deep dive names names).

Two tables need decisions the documents don't make, and we make them out loud. The SAD's environments references an applications table it never defines — we create a two-column stub and record that Part 3's tenancy chapters own its real shape. And the docs/07 row and SAD §6.3's hot-path index both name a members table that §6.1 never defines — we derive its shape from that index's own columns and record the decision where it lives now: in the schema definition. Note also what applications and environments don't have: an environment_id. They are the tenancy anchors the whole clause is defined over — everything below them carries the spine, directly or through a single foreign-key hop.

flowchart TB
    apps["applications<br/>(tenancy anchor — stub, Part 3 owns it)"]
    envs["environments<br/>THE tenant"]
    users["users<br/>environment_id NOT NULL"]
    channels["channels<br/>environment_id NOT NULL"]
    messages["messages<br/>tenant via channel_id (one hop)"]
    members["members<br/>tenant via channel_id (one hop)"]
    apps --> envs
    envs --> users
    envs --> channels
    channels --> messages
    channels --> members
    users -.-> members
    note["Constitution I: every record carries its tenant,<br/>directly or through a single foreign-key hop —<br/>the spine runs through every table"]
    envs ~~~ note
The spine: environments is the tenant; users and channels carry it directly; messages and members inherit it through one foreign-key hop — the exact allowance constitution I grants.
services/api/src/db/schema.ts
import { sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  integer,
  jsonb,
  pgTable,
  primaryKey,
  text,
  timestamp,
  unique,
  uniqueIndex,
  uuid,
} from "drizzle-orm/pg-core";
 
// The TS twin of SAD §6.1 (ADR-16). The schema now exists twice — once as
// the SAD's SQL truth, once here — and that drift risk is checked, not
// assumed away: drizzle-kit GENERATES the migration SQL from these
// definitions, and the generated SQL is reviewed against §6.1 before the
// runner applies it. The four tenant-bearing tables reproduce §6.1
// column-for-column, constraints and DR citations included. Deliberately
// absent, with named arrivals: message_edits (edit chapter), outbox
// (ADR-06's chapter), emoji/media tables (their parts), messages
// partitioning (SAD growth note -> retention chapter).
 
// DECISION (chapter 2.1): the SAD's environments table references
// applications(id) but never defines the table. This stub satisfies the
// foreign key; the real application lifecycle belongs to Part 3's tenancy
// chapters.
export const applications = pgTable("applications", {
  id: uuid("id").primaryKey(),
  name: text("name").notNull(),
});
 
export const environments = pgTable(
  "environments",
  {
    id: uuid("id").primaryKey(),
    applicationId: uuid("application_id")
      .notNull()
      .references(() => applications.id),
    kind: text("kind").notNull(),
    // envelope-encrypted (NFR-SEC-02)
    signingSecret: text("signing_secret").notNull(),
    retentionDays: integer("retention_days"),
    quotaConfig: jsonb("quota_config").notNull().default({}),
  },
  (t) => [check("environments_kind_check", sql`${t.kind} IN ('development','production')`)],
);
 
export const users = pgTable(
  "users",
  {
    id: uuid("id").primaryKey(),
    environmentId: uuid("environment_id")
      .notNull()
      .references(() => environments.id),
    externalId: text("external_id").notNull(),
    displayName: text("display_name"),
    avatarUrl: text("avatar_url"),
    metadata: jsonb("metadata").notNull().default({}),
    bannedAt: timestamp("banned_at", { withTimezone: true }),
  },
  (t) => [unique("users_environment_id_external_id_unique").on(t.environmentId, t.externalId)], // DR-02
);
 
export const channels = pgTable(
  "channels",
  {
    id: uuid("id").primaryKey(),
    environmentId: uuid("environment_id")
      .notNull()
      .references(() => environments.id),
    externalId: text("external_id").notNull(),
    type: text("type").notNull(),
    name: text("name"),
    metadata: jsonb("metadata").notNull().default({}),
    lastSequence: bigint("last_sequence", { mode: "number" }).notNull().default(0), // ADR-03
    archivedAt: timestamp("archived_at", { withTimezone: true }),
  },
  (t) => [
    unique("channels_environment_id_external_id_unique").on(t.environmentId, t.externalId), // DR-02
    check("channels_type_check", sql`${t.type} IN ('public','private')`),
  ],
);
 
export const messages = pgTable(
  "messages",
  {
    id: uuid("id").primaryKey(),
    channelId: uuid("channel_id")
      .notNull()
      .references(() => channels.id),
    sequence: bigint("sequence", { mode: "number" }).notNull(),
    userId: uuid("user_id").references(() => users.id),
    // NULL => tombstone
    text: text("text"),
    metadata: jsonb("metadata").notNull().default({}),
    attachments: jsonb("attachments"),
    idempotencyKey: text("idempotency_key"),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    editedAt: timestamp("edited_at", { withTimezone: true }),
    deletedAt: timestamp("deleted_at", { withTimezone: true }),
  },
  (t) => [
    unique("messages_channel_id_sequence_unique").on(t.channelId, t.sequence), // DR-01
    // DR-03: idempotency enforced at the storage layer — the partial unique
    // index Prisma could not express is a first-class schema citizen here.
    uniqueIndex("messages_idem")
      .on(t.channelId, t.idempotencyKey)
      .where(sql`${t.idempotencyKey} IS NOT NULL`),
    // Hot-path index (SAD §6.3): history pagination as a pure
    // index-order scan (FR-MSG-09).
    index("messages_channel_seq").on(t.channelId, t.sequence.desc()),
  ],
);
 
// DECISION (chapter 2.1): the docs/07 row and SAD §6.3's hot-path index
// both reference a members table that §6.1 never defines. This shape is
// anchored to that index; membership roles arrive with the channel
// semantics chapters.
export const members = pgTable(
  "members",
  {
    channelId: uuid("channel_id")
      .notNull()
      .references(() => channels.id),
    userId: uuid("user_id")
      .notNull()
      .references(() => users.id),
    joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    primaryKey({ columns: [t.channelId, t.userId] }),
    // Hot-path index (SAD §6.3): the resume path's "which channels am I in".
    index("members_user_channel").on(t.userId, t.channelId),
  ],
);

One deliberate omission from the SAD's SQL, preserved here: no gen_random_uuid() defaults on the primary keys, because the SAD declares none. Ids are generated app-side with crypto.randomUUID() — the same decision 1.4 made for request ids, now extended to rows.

Generated, then reviewed — the drift check in person

The schema existing twice — once as §6.1's SQL truth, once as TypeScript — is ADR-16's stated trade-off, and this section is where its mitigation runs. The TS schema does not go near the database directly; drizzle-kit turns it into a plain SQL file:

services/api/drizzle.config.ts
import { defineConfig } from "drizzle-kit";
 
// drizzle-kit's job here is GENERATION only: it turns src/db/schema.ts into
// plain SQL files under migrations/, which are reviewed against SAD §6.1
// and applied by src/db/migrate.ts — our runner, our single ledger. Its
// migrator and its journal table are deliberately unused (ADR-16).
export default defineConfig({
  dialect: "postgresql",
  schema: "./src/db/schema.ts",
  out: "./migrations",
});
pnpm --filter @relay/api exec drizzle-kit generate --name core_tables

That writes migrations/0000_core_tables.sql — and now comes the part that is a discipline, not a formality: read the generated SQL against SAD §6.1, line by line, and record the verdict. Ours is recorded at the top of the file itself:

services/api/migrations/0000_core_tables.sql
-- 0000_core_tables (chapter 2.1): GENERATED by drizzle-kit from
-- src/db/schema.ts, then reviewed against SAD §6.1 — the ADR-16 drift
-- check. Review disposition: column-for-column identical to §6.1 for the
-- four tenant-bearing tables (CHECKs, DR-01/02 UNIQUEs, DR-03 partial
-- unique index, §6.3 hot-path indexes all present); differences are
-- formal only (alphabetical table order with FKs as ALTER TABLE, quoted
-- identifiers, explicit ON DELETE NO ACTION and DESC NULLS LAST — all
-- Postgres defaults spelled out). `applications` (stub) and `members`
-- remain this chapter's recorded decisions; see the DECISION comments in
-- schema.ts. Applied by src/db/migrate.ts — versioned, forward-only.
 
CREATE TABLE "applications" (
	"id" uuid PRIMARY KEY NOT NULL,
	"name" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "channels" (
	"id" uuid PRIMARY KEY NOT NULL,
	"environment_id" uuid NOT NULL,
	"external_id" text NOT NULL,
	"type" text NOT NULL,
	"name" text,
	"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
	"last_sequence" bigint DEFAULT 0 NOT NULL,
	"archived_at" timestamp with time zone,
	CONSTRAINT "channels_environment_id_external_id_unique" UNIQUE("environment_id","external_id"),
	CONSTRAINT "channels_type_check" CHECK ("channels"."type" IN ('public','private'))
);
--> statement-breakpoint
CREATE TABLE "environments" (
	"id" uuid PRIMARY KEY NOT NULL,
	"application_id" uuid NOT NULL,
	"kind" text NOT NULL,
	"signing_secret" text NOT NULL,
	"retention_days" integer,
	"quota_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
	CONSTRAINT "environments_kind_check" CHECK ("environments"."kind" IN ('development','production'))
);
--> statement-breakpoint
CREATE TABLE "members" (
	"channel_id" uuid NOT NULL,
	"user_id" uuid NOT NULL,
	"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "members_channel_id_user_id_pk" PRIMARY KEY("channel_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "messages" (
	"id" uuid PRIMARY KEY NOT NULL,
	"channel_id" uuid NOT NULL,
	"sequence" bigint NOT NULL,
	"user_id" uuid,
	"text" text,
	"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
	"attachments" jsonb,
	"idempotency_key" text,
	"created_at" timestamp with time zone DEFAULT now() NOT NULL,
	"edited_at" timestamp with time zone,
	"deleted_at" timestamp with time zone,
	CONSTRAINT "messages_channel_id_sequence_unique" UNIQUE("channel_id","sequence")
);
--> statement-breakpoint
CREATE TABLE "users" (
	"id" uuid PRIMARY KEY NOT NULL,
	"environment_id" uuid NOT NULL,
	"external_id" text NOT NULL,
	"display_name" text,
	"avatar_url" text,
	"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
	"banned_at" timestamp with time zone,
	CONSTRAINT "users_environment_id_external_id_unique" UNIQUE("environment_id","external_id")
);
--> statement-breakpoint
ALTER TABLE "channels" ADD CONSTRAINT "channels_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "environments" ADD CONSTRAINT "environments_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "members" ADD CONSTRAINT "members_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "members_user_channel" ON "members" USING btree ("user_id","channel_id");--> statement-breakpoint
CREATE UNIQUE INDEX "messages_idem" ON "messages" USING btree ("channel_id","idempotency_key") WHERE "messages"."idempotency_key" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "messages_channel_seq" ON "messages" USING btree ("channel_id","sequence" DESC NULLS LAST);

The verdict, in one sentence: column-for-column identical to §6.1 where §6.1 speaks — every CHECK, both DR-02 uniques, DR-01, the DR-03 partial index, both §6.3 hot-path indexes — and the differences are formal (alphabetical table order with foreign keys as ALTER TABLE, quoted identifiers, Postgres defaults spelled out). The two recorded decisions ride along in schema.ts where they now live.

Migrations as discipline

The constitution's workflow clause is compact: "database migrations are versioned, forward-only." Notice what's absent — there is no down. A rollback script you never run in anger is a comfort blanket that rots; forward-only means mistakes are fixed by the next migration, the way they will be in production. drizzle-kit generates our migrations; it does not get to apply them — one workspace, one migration ledger, and it is ours. The runner is small enough to understand whole, and it survived the re-foundation untouched in all but two lines (this package speaks CommonJS now — 1.4's story — so __dirname and require.main replace their ESM counterparts):

services/api/src/db/client.ts
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import pg from "pg";
 
import * as schema from "./schema";
 
// The one place a connection is born. DATABASE_URL defaults to the compose
// stack's dev credentials (chapter 1.2) — override it if your host ports
// are remapped. Everything outside src/db is lint-forbidden from importing
// pg OR drizzle-orm at all (constitution I: isolation lives in data
// access) — Drizzle is the query engine INSIDE the repository layer, never
// a client handed to handlers (ADR-16).
 
export const DEFAULT_DATABASE_URL =
  "postgres://relay:relay@localhost:5432/relay";
 
export function createPool(): pg.Pool {
  return new pg.Pool({
    connectionString: process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL,
  });
}
 
export type Db = NodePgDatabase<typeof schema>;
 
export function createDb(pool: pg.Pool): Db {
  return drizzle({ client: pool, schema });
}
services/api/src/db/migrate.ts
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
 
import type pg from "pg";
 
import { createPool } from "./client";
 
// Migrations as discipline (constitution: versioned, forward-only). There is
// no down path — not missing, absent by design. Files apply in filename
// order, each inside a transaction, each recorded; a re-run is a no-op.
// drizzle-kit GENERATES these files from src/db/schema.ts; this runner —
// not drizzle-kit's migrator — is the only thing that APPLIES them, so the
// workspace has exactly one migration ledger: schema_migrations.
 
const MIGRATIONS_DIR = join(__dirname, "..", "..", "migrations");
 
export async function migrate(pool: pg.Pool): Promise<string[]> {
  await pool.query(
    `CREATE TABLE IF NOT EXISTS schema_migrations (
       version    TEXT PRIMARY KEY,
       applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
     )`,
  );
 
  const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
  const applied: string[] = [];
 
  for (const file of files) {
    const done = await pool.query("SELECT 1 FROM schema_migrations WHERE version = $1", [file]);
    if ((done.rowCount ?? 0) > 0) continue;
 
    const sql = await readFile(join(MIGRATIONS_DIR, file), "utf8");
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      await client.query(sql);
      await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [file]);
      await client.query("COMMIT");
      applied.push(file);
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }
  return applied;
}
 
// This package compiles to CommonJS (ADR-15's dialect), so the "am I the
// entry file?" check is require.main — import.meta does not exist here.
if (require.main === module) {
  void (async () => {
    const pool = createPool();
    const applied = await migrate(pool);
    console.log(
      applied.length === 0
        ? "migrations: nothing to apply"
        : `migrations: applied ${applied.join(", ")}`,
    );
    await pool.end();
  })();
}

The series amends its own code — the 1.4 form, twice

To run any of this, the API service needs its data-layer dependencies and a migrate script. Its package.json was published, byte-for-byte, in chapter 1.4 — which is also the chapter that taught the rule for this exact moment: changes to previously fenced files appear as diffs, whole file, + and - carrying the change, checkable against both chapters' tags.

services/api/package.json
 {
   "name": "@relay/api",
   "private": true,
   "version": "0.0.0",
   "type": "commonjs",
   "scripts": {
     "build": "nest build",
     "dev": "nest start --watch",
     "start": "node dist/main.js",
     "typecheck": "tsc --noEmit",
-    "test": "vitest run"
+    "test": "vitest run",
+    "migrate": "nest build && node dist/db/migrate.js",
+    "test:integration": "vitest run --config vitest.integration.config.mts"
   },
   "dependencies": {
     "@nestjs/common": "^11.1.28",
     "@nestjs/core": "^11.1.28",
     "@nestjs/platform-express": "^11.1.28",
     "@relay/protocol": "workspace:*",
     "@relay/service-kit": "workspace:*",
+    "drizzle-orm": "^0.45.2",
+    "pg": "^8.22.0",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.2"
   },
   "devDependencies": {
     "@nestjs/cli": "^11.0.24",
     "@nestjs/testing": "^11.1.28",
     "@swc/core": "^1.15.47",
+    "@types/pg": "^8.20.3",
+    "drizzle-kit": "^0.31.10",
     "unplugin-swc": "^1.5.9"
   }
 }

Read it like a reviewer. Two runtime dependencies enter, pinned within their majors and package-local: pg (the boring driver underneath everything) and drizzle-orm (the query engine that stays inside the repository layer — that confinement is the next section). Two dev tools ride along: the types, and drizzle-kit, which you just used. The migrate script compiles first — this package runs built JavaScript, so the runner runs from dist/ like everything else. And test:integration points at a config whose extension carries 1.4's lesson (.mts, because this package is CommonJS and vitest refuses to be require()d).

Apply the diff (or edit by hand), then pnpm install, bring up the store, and run the migration twice — the second run is the discipline showing:

docker compose up -d --wait postgres
pnpm install
pnpm --filter @relay/api migrate
pnpm --filter @relay/api migrate   # → migrations: nothing to apply

Worth pausing on the runner's two structural choices, unchanged from the day it was written. Each file applies inside its own transaction, so a migration that fails halfway leaves no half-schema behind — the database is either before the file or after it, never in between. And the bookkeeping is a table in the database itself, not a marker on disk: schema_migrations travels with the data it describes, so every environment — your laptop, a teammate's, production — answers "what version am I?" the same way. When 2.2 ships the next migration, this same command applies exactly the delta, everywhere.

The repository layer

Now the spine gets its enforcement. One file owns every query in the system, and its shape is the argument:

services/api/src/db/repository.ts
import { randomUUID } from "node:crypto";
 
import { and, asc, eq, sql } from "drizzle-orm";
 
import type { Db } from "./client";
import { channels, members, users } from "./schema";
 
// The repository layer — the ONE place data access lives (ADR-04's single
// writer, constitution I). Two surfaces with a bright line between them:
//
//   createEnvironment — the ADMIN surface. It creates tenants, so it is the
//   only operation here that is not tenant-scoped. It also inserts a stub
//   application row to satisfy environments' NOT NULL foreign key (recorded
//   decision: the real application lifecycle belongs to Part 3).
//
//   Repository — everything else. The constructor REQUIRES an
//   environment_id; every query is scoped by it HERE, in one home — never
//   at call sites. Cross-tenant reads return null/empty: no data, and no
//   reveal that the foreign id even exists (FR-TEN-05).
//
// Drizzle is the query engine inside this layer (ADR-16): queries keep
// their SQL shape and gain end-to-end types. Where the builder falls short,
// a raw SQL island is permitted — inside the layer, never outside it.
//
// All primary keys are generated app-side (crypto.randomUUID) — the SAD's
// SQL declares no id defaults, and the migration adds none.
 
export interface Environment {
  id: string;
  kind: "development" | "production";
}
 
export async function createEnvironment(
  db: Db,
  { name, kind = "development" }: { name: string; kind?: Environment["kind"] },
): Promise<Environment> {
  const applicationId = randomUUID();
  const environmentId = randomUUID();
  // The admin surface writes through the same Db handle but carries no
  // tenant scope — it is the operation that MINTS the scope.
  await db.execute(sql`INSERT INTO applications (id, name) VALUES (${applicationId}, ${name})`);
  await db.execute(
    sql`INSERT INTO environments (id, application_id, kind, signing_secret)
        VALUES (${environmentId}, ${applicationId}, ${kind}, ${randomUUID()})`,
  );
  return { id: environmentId, kind };
}
 
export interface UserRow {
  id: string;
  external_id: string;
  display_name: string | null;
}
 
export interface ChannelRow {
  id: string;
  external_id: string;
  type: "public" | "private";
  name: string | null;
}
 
export class Repository {
  // Constructor parameter properties — the shorthand chapter 1.4 released
  // for this service when ADR-15 spent erasableSyntaxOnly on decorator
  // metadata. The guarantee still holds in the gateway and every package.
  constructor(
    private readonly db: Db,
    private readonly environmentId: string,
  ) {}
 
  async createUser(externalId: string, displayName?: string): Promise<UserRow> {
    const id = randomUUID();
    await this.db.insert(users).values({
      id,
      environmentId: this.environmentId,
      externalId,
      displayName: displayName ?? null,
    });
    return { id, external_id: externalId, display_name: displayName ?? null };
  }
 
  async getUserByExternalId(externalId: string): Promise<UserRow | null> {
    const rows = await this.db
      .select({ id: users.id, external_id: users.externalId, display_name: users.displayName })
      .from(users)
      .where(and(eq(users.environmentId, this.environmentId), eq(users.externalId, externalId)));
    return rows[0] ?? null;
  }
 
  async createChannel(
    externalId: string,
    type: ChannelRow["type"],
    name?: string,
  ): Promise<ChannelRow> {
    const id = randomUUID();
    await this.db.insert(channels).values({
      id,
      environmentId: this.environmentId,
      externalId,
      type,
      name: name ?? null,
    });
    return { id, external_id: externalId, type, name: name ?? null };
  }
 
  async getChannelByExternalId(externalId: string): Promise<ChannelRow | null> {
    const rows = await this.db
      .select({
        id: channels.id,
        external_id: channels.externalId,
        type: sql<ChannelRow["type"]>`${channels.type}`,
        name: channels.name,
      })
      .from(channels)
      .where(
        and(eq(channels.environmentId, this.environmentId), eq(channels.externalId, externalId)),
      );
    return rows[0] ?? null;
  }
 
  async listChannels(): Promise<ChannelRow[]> {
    return this.db
      .select({
        id: channels.id,
        external_id: channels.externalId,
        type: sql<ChannelRow["type"]>`${channels.type}`,
        name: channels.name,
      })
      .from(channels)
      .where(eq(channels.environmentId, this.environmentId))
      .orderBy(asc(channels.externalId));
  }
 
  /** Membership joins live in channel-land, so the tenant scope rides the
   * channel: the double-scoped SELECT below is what makes a foreign channel
   * id useless. INSERT ... SELECT is where the builder falls short — this
   * is the layer's one raw SQL island, permitted by ADR-16 and kept inside
   * the wall like everything else. */
  async addMember(channelId: string, userId: string): Promise<boolean> {
    const result = await this.db.execute(
      sql`INSERT INTO members (channel_id, user_id)
          SELECT c.id, u.id FROM channels c, users u
          WHERE c.id = ${channelId} AND c.environment_id = ${this.environmentId}
            AND u.id = ${userId} AND u.environment_id = ${this.environmentId}`,
    );
    return (result.rowCount ?? 0) > 0;
  }
 
  async listMembers(channelId: string): Promise<string[]> {
    const rows = await this.db
      .select({ user_id: members.userId })
      .from(members)
      .innerJoin(channels, eq(channels.id, members.channelId))
      .where(and(eq(members.channelId, channelId), eq(channels.environmentId, this.environmentId)))
      .orderBy(asc(members.joinedAt));
    return rows.map((r) => r.user_id);
  }
 
  async channelsForUser(userId: string): Promise<string[]> {
    const rows = await this.db
      .select({ channel_id: members.channelId })
      .from(members)
      .innerJoin(users, eq(users.id, members.userId))
      .where(and(eq(members.userId, userId), eq(users.environmentId, this.environmentId)));
    return rows.map((r) => r.channel_id);
  }
}

Look at what became inexpressible. There is no way to construct a Repository without naming a tenant — new Repository(db) is a type error, not a code review comment. There is no unscoped query to call — the tenant condition lives in this file and only this file, and every query that leaves it is typed end to end. addMember shows two subtler patterns at once: for tables that carry their tenant through a hop, the scope rides the JOIN, so handing it a real channel id from another tenant touches exactly zero rows — and it is written as raw SQL, because INSERT … SELECT is where the builder falls short, and ADR-16's rule for that moment is an island inside the layer, never a leak outside it. (The constructor, note, uses the parameter-property shorthand — this service traded erasableSyntaxOnly away in 1.4, and this is the small dividend.)

flowchart TB
    dbcyl[("one PostgreSQL<br/>rows from every tenant")]
    repoA["new Repository(db, envA)<br/>every query: WHERE environment_id = A"]
    repoB["new Repository(db, envB)<br/>every query: WHERE environment_id = B"]
    codeA["tenant A's requests"]
    codeB["tenant B's requests"]
    codeA --> repoA --> dbcyl
    codeB --> repoB --> dbcyl
    note["The WHERE lives inside the door, once —<br/>hand door B a real id from tenant A<br/>and it opens onto nothing"]
    dbcyl ~~~ note
Two doors into one database. Each Repository instance is keyed to its tenant at construction; a foreign id — even a real one — opens onto nothing.
eslint.config.mjs
 import eslint from "@eslint/js";
 import tseslint from "typescript-eslint";
 
 // One lint config for the whole workspace (ADR-01's consequence made literal).
 export default tseslint.config(
   { ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"] },
   eslint.configs.recommended,
   ...tseslint.configs.recommended,
+  {
+    // Isolation lives in data access, not in handlers (constitution I):
+    // only the repository layer may touch the driver.
+    files: ["**/*.ts"],
+    ignores: ["services/api/src/db/**"],
+    rules: {
+      "no-restricted-imports": [
+        "error",
+        {
+          paths: [
+            {
+              name: "pg",
+              message:
+                "Raw database access is forbidden outside services/api/src/db (constitution I).",
+            },
+            {
+              name: "drizzle-orm",
+              message:
+                "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+            },
+          ],
+          patterns: [
+            {
+              group: ["drizzle-orm/*"],
+              message:
+                "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+            },
+          ],
+        },
+      ],
+    },
+  },
 );

Try it: add import pg from "pg" — or import { eq } from "drizzle-orm" — to the gateway's main.ts and run pnpm lint. The refusal you get — with the constitution cited in the error message — is the clause working as written. Drizzle being banned outside the layer is not lint trivia; it is ADR-16's confinement clause: the query engine is the repository's implementation detail, never a client handed to handlers.

The suite that attacks anyway

Designed out, then tested anyway. These tests need a real Postgres — isolation proven against a mock proves nothing — but the three-command gate has been Docker-free since 1.1 and stays that way. The trick is one character of naming: integration tests are *.itest.ts, which the unit lane's include (*.test.ts) simply never matches. They get their own config and their own named lane:

services/api/vitest.integration.config.mts
import { defineConfig } from "vitest/config";
 
// The integration lane (chapter 2.1). *.itest.ts files are invisible to the
// unit lane's include on purpose: `pnpm test` stays Docker-free, and this
// config is what `pnpm --filter @relay/api test:integration` runs against
// the compose Postgres. (.mts because this package compiles to CommonJS —
// a .ts config would be loaded as CJS, which vitest refuses.)
export default defineConfig({
  test: {
    include: ["src/**/*.itest.ts"],
  },
});
services/api/src/db/repository.itest.ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
import { createDb, createPool, DEFAULT_DATABASE_URL, type Db } from "./client";
import { migrate } from "./migrate";
import { createEnvironment, Repository, type Environment } from "./repository";
 
// The isolation suite: attack the repository with FOREIGN tenant ids and
// prove the leak inexpressible (FR-TEN-05, NFR-SEC-09, constitution I).
// Requires the compose Postgres — this file is *.itest.ts precisely so the
// Docker-free unit lane never collects it.
 
// Guardrail: integration tests run against the LOCAL compose stack only.
const url = new URL(process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL);
if (!["localhost", "127.0.0.1"].includes(url.hostname)) {
  throw new Error(
    `integration tests refuse non-local databases (got host "${url.hostname}") — never point this suite at a shared database`,
  );
}
 
const pool = createPool();
const db: Db = createDb(pool);
let envA: Environment;
let envB: Environment;
let repoA: Repository;
let repoB: Repository;
 
beforeAll(async () => {
  await migrate(pool);
  // Deterministic ground: this suite owns these tables in the dev database.
  await pool.query(
    "TRUNCATE members, messages, channels, users, environments, applications CASCADE",
  );
  envA = await createEnvironment(db, { name: "tenant-a" });
  envB = await createEnvironment(db, { name: "tenant-b" });
  repoA = new Repository(db, envA.id);
  repoB = new Repository(db, envB.id);
});
 
afterAll(async () => {
  await pool.end();
});
 
describe("tenant isolation is structural (FR-TEN-05)", () => {
  it("a foreign external_id resolves to nothing — not even an existence hint", async () => {
    await repoA.createUser("tuan", "Tuan");
    expect(await repoA.getUserByExternalId("tuan")).not.toBeNull();
    expect(await repoB.getUserByExternalId("tuan")).toBeNull();
  });
 
  it("channel reads and lists are scoped by construction", async () => {
    await repoA.createChannel("support", "public", "Support");
    expect(await repoB.getChannelByExternalId("support")).toBeNull();
    expect(await repoB.listChannels()).toEqual([]);
    expect((await repoA.listChannels()).map((c) => c.external_id)).toContain(
      "support",
    );
  });
 
  it("membership writes with foreign ids affect zero rows", async () => {
    const user = await repoA.getUserByExternalId("tuan");
    const channel = await repoA.getChannelByExternalId("support");
    expect(await repoA.addMember(channel!.id, user!.id)).toBe(true);
    // B holds A's REAL ids — and still cannot write or read through them.
    expect(await repoB.addMember(channel!.id, user!.id)).toBe(false);
    expect(await repoB.listMembers(channel!.id)).toEqual([]);
    expect(await repoB.channelsForUser(user!.id)).toEqual([]);
    expect(await repoA.listMembers(channel!.id)).toEqual([user!.id]);
  });
 
  it("uniqueness is per-tenant (DR-02): both tenants may own the same external_id", async () => {
    await expect(repoB.createUser("tuan", "A different Tuan")).resolves.toBeTruthy();
    await expect(repoA.createUser("tuan", "Duplicate in A")).rejects.toThrow();
  });
});

The suite's first act is a guardrail — it refuses any database that isn't local, because an isolation test that truncates tables must never meet a shared database. Then it does the only honest thing: gives tenant B tenant A's real identifiers and watches every read return nothing and every write touch zero rows. Run both lanes:

pnpm lint && pnpm typecheck && pnpm test
pnpm --filter @relay/api test:integration
flowchart LR
    subgraph unit["unit lane — unchanged since 1.1, no Docker"]
      u1["pnpm lint"]
      u2["pnpm typecheck"]
      u3["pnpm test<br/>(*.test.ts only)"]
      u1 --> u2 --> u3
    end
    subgraph integration["integration lane — compose Postgres"]
      i1["docker compose up -d --wait postgres"]
      i2["pnpm --filter @relay/api migrate"]
      i3["pnpm --filter @relay/api test:integration<br/>(*.itest.ts only)"]
      i1 --> i2 --> i3
    end
    unit --> tag["tag part2-ch1"]
    integration --> tag
The gate, now two named lanes: the Docker-free lane every chapter has walked since 1.1, and the integration lane that runs the schema and the isolation suite against the compose Postgres.

Your turn

The exercise is the build: schema, generation, review, layer, suite. Then attack your own work:

  1. Comment out one environmentId condition in repository.ts — any one — and run the integration lane. Watch which assertions catch it and how fast. That alarm is what "tested anyway" buys.
  2. Add import { eq } from "drizzle-orm" to any file outside src/db and run pnpm lint. Read the error message aloud; it cites the constitution and the ADR.
  3. Change one column in schema.ts (rename display_name, say), run drizzle-kit generate, and read what it wrote — then delete the generated file and revert. You have just rehearsed the drift check on a change that didn't deserve to survive it.
  4. Create a third environment in the suite and prove isolation is pairwise — A/C and B/C, not just A/B. (Notice how cheap the proof is once the attack pattern exists.)

If you are stuck, the tag holds the answer key: part2-ch1.

Takeaways

If you read nothing else in this chapter, keep these:

  • The core loop opens with isolation because vigilance doesn't scale — a constructor that demands a tenant beats nineteen correct WHERE clauses and one forgotten one (D4, FR-TEN-05).
  • The SAD's SQL is the source, and now it has a typed twin (ADR-16): constraints are requirements — DR-01, DR-02, DR-03 planted now, harvested in 2.2–2.4 — and the generate→review flow is what keeps the two schemas from becoming two databases.
  • Migrations are versioned and forward-only — generated by drizzle-kit, applied by our runner, one ledger; there is no down path, by design, and re-running is always safe.
  • The query engine lives inside the layer — typed queries that keep their SQL shape, raw islands where the builder falls short, and a lint ban with two names on it so nothing leaks past the wall.
  • Two lanes, one gate: *.itest.ts keeps unit tests Docker-free while isolation is proven against a real database — with a guardrail that refuses to attack anything shared.