Phần 2 · Chương 2.1
Schema có xương sống
Bạn sẽ tạo ra: Schema đã migrate và tầng repository khóa theo tenant — rò rỉ giữa các tenant thành điều không thể viết ra · khoảng 90 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm · SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)
Phần 1 đắp nền: một workspace, bốn store, một bản giao kèo đường truyền, hai service biết trả lời. Phần 2 là phần mà kế hoạch loạt bài gắn sao — vòng lặp cốt lõi, "nơi loạt bài xứng đáng với lời hứa của nó" — và bạn có thể đoán nó sẽ mở màn bằng việc gửi một tin nhắn. Không phải. Nó mở màn bằng dòng chữ mà chương 0.4 gọi là yêu cầu quan trọng nhất trong toàn bộ tài liệu: FR-TEN-05, không thao tác nào được chạm vào — hay thậm chí để lộ sự tồn tại của — dữ liệu thuộc về tenant khác. Relay là hạ tầng đa tenant; một lần rò rỉ là đủ phá sập niềm tin mà cả sản phẩm dựa vào để bán mình. Vậy nên trước khi tin nhắn đầu tiên được ghi, chúng ta khiến thất bại ấy khó đến mức gần như không viết ra nổi: schema có xương sống, và tầng truy cập dữ liệu có một hình dạng buộc tenant phải đi theo mọi ngả — bằng chính cấu trúc.
Loại bỏ từ thiết kế, không trông chờ test vớt
Driver D4 tuyên bố lập trường: "tenant isolation is a correctness property"
— cách ly tenant thuộc về tính đúng đắn của hệ thống, ngang hàng với thứ tự
tin nhắn, chứ không phải một tính năng hay một bộ lọc. Constitution biến
lập trường ấy thành máy móc, và câu chữ của nó đáng đọc nguyên văn: "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" —
truy cập dữ liệu phải đi qua một tầng repository mà constructor đòi hỏi
environment_id; truy cập kết nối thô bên ngoài tầng đó bị lint cấm; cách
ly được cưỡng chế ở tầng dữ liệu, không phải trong handler.
Schema, từ chính bàn tay của bản kiến trúc
Bản SAD không mô tả schema — nó chính là schema; §6.1 là SQL. Và những
bảng ấy dày đặc ràng buộc — các bản yêu cầu khoác cú pháp SQL: DR-01 là
UNIQUE (channel_id, sequence) (một số sequence, một tin nhắn — chương 2.2
sống nhờ dòng này), DR-02 là UNIQUE (environment_id, external_id) (tên là
duy nhất trong từng tenant, không phải toàn cục — cách ly len cả vào tính
duy nhất), DR-03 là một partial unique index trên idempotency key (cả
chương 2.3 nằm gọn trong đó, gieo mầm từ hôm nay). Tầng dữ liệu của ADR-16
trao cho phần SQL ấy một người anh em song sinh mang kiểu: schema được
định nghĩa bằng TypeScript, và các ràng buộc sống ngay cạnh những bảng sở
hữu chúng — kể cả chiếc partial index của DR-03, ở đây là công dân hạng
nhất trong khi có những công cụ nổi tiếng hơn không tài nào diễn đạt nổi
(bản deep dive gọi thẳng tên).
Hai bảng cần đến những quyết định mà tài liệu không đưa ra, và chúng ta
quyết công khai. Bảng environments của SAD tham chiếu một bảng
applications chưa từng được định nghĩa — chúng ta tạo một bản stub hai cột
và ghi lại rằng các chương tenancy của Phần 3 sở hữu hình dạng thật của nó.
Còn dòng kế hoạch docs/07 lẫn hot-path index trong SAD §6.3 đều nhắc đến một
bảng members mà §6.1 không hề định nghĩa — chúng ta dẫn hình dạng của nó
từ chính các cột của index ấy và ghi quyết định ở đúng nơi nó sống bây giờ:
trong bản định nghĩa schema. Cũng để ý điều applications và environments
không có: cột environment_id. Chúng là mỏ neo tenancy mà toàn bộ điều
khoản được định nghĩa bên trên — mọi thứ phía dưới mới mang xương sống, trực
tiếp hoặc qua đúng một bước foreign key.
flowchart TB
apps["applications<br/>(mỏ neo tenancy — bản stub, Phần 3 sở hữu)"]
envs["environments<br/>CHÍNH LÀ tenant"]
users["users<br/>environment_id NOT NULL"]
channels["channels<br/>environment_id NOT NULL"]
messages["messages<br/>tenant qua channel_id (một bước nhảy)"]
members["members<br/>tenant qua channel_id (một bước nhảy)"]
apps --> envs
envs --> users
envs --> channels
channels --> messages
channels --> members
users -.-> members
note["Constitution I: mọi bản ghi đều mang tenant của nó,<br/>trực tiếp hoặc qua đúng một bước foreign key —<br/>chiếc xương sống chạy xuyên mọi bảng"]
envs ~~~ noteimport { 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),
],
);Một khoản vắng mặt có chủ đích so với SQL của SAD, được giữ nguyên ở đây:
không có default gen_random_uuid() trên các khóa chính, vì SAD không khai
báo cái nào. Id được sinh phía ứng dụng bằng crypto.randomUUID() — đúng
quyết định 1.4 đã đưa ra cho request id, giờ nối dài sang các hàng dữ liệu.
Sinh ra, rồi soát lại — phép kiểm drift tận tay
Schema tồn tại hai lần — một lần là sự thật SQL của §6.1, một lần bằng TypeScript — là cái giá ADR-16 công khai chấp nhận, và mục này là nơi phép hóa giải của nó vận hành. Bản schema TS không hề chạm vào database; chính drizzle-kit biến nó thành một file SQL thuần:
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_tablesLệnh ấy ghi ra migrations/0000_core_tables.sql — và giờ đến phần là một
kỷ luật, không phải một thủ tục: đọc phần SQL vừa sinh đối chiếu với SAD
§6.1, từng dòng một, và ghi lại phán quyết. Phán quyết của chúng ta nằm ngay
đầu file:
-- 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);Phán quyết, gói trong một câu: trùng khớp từng cột với §6.1 ở mọi chỗ §6.1
lên tiếng — đủ mọi CHECK, cả hai UNIQUE của DR-02, DR-01, chiếc partial
index DR-03, cả hai hot-path index của §6.3 — còn các khác biệt đều thuộc về
hình thức (bảng xếp theo bảng chữ cái với foreign key tách thành
ALTER TABLE, định danh trong ngoặc kép, các mặc định của Postgres được
viết tường minh). Hai quyết định được ghi lại thì đi cùng schema.ts, nơi
chúng sống bây giờ.
Migration là kỷ luật
Điều khoản quy trình của constitution rất gọn: "database migrations are
versioned, forward-only" — migration có phiên bản và chỉ tiến, không lùi.
Hãy để ý thứ vắng mặt — không hề có down. Một kịch bản rollback không bao
giờ được chạy thật là tấm chăn an ủi sẽ mục dần; chỉ-tiến nghĩa là sai sót
được sửa bằng migration kế tiếp, đúng cách chúng sẽ được sửa trên
production. drizzle-kit sinh migration cho chúng ta; nó không được quyền
áp dụng — một workspace, một cuốn sổ migration duy nhất, và cuốn sổ ấy là
của chúng ta. Trình chạy vẫn nhỏ đến mức hiểu được trọn vẹn, và nó đi qua
cuộc tái dựng nền móng gần như nguyên vẹn, chỉ đổi đúng hai dòng (package
này giờ nói CommonJS — câu chuyện của 1.4 — nên __dirname và
require.main thế chỗ hai người anh em ESM của chúng):
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 });
}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();
})();
}Loạt bài tu chính chính code của mình — theo thể thức 1.4, hai lần
Muốn chạy được những thứ trên, API service cần các dependency của tầng dữ
liệu và một script migrate. Mà package.json của nó đã được công bố, đúng
từng byte, trong chương 1.4 — cũng chính là chương đã dạy luật chơi cho đúng
khoảnh khắc này: thay đổi trên những file từng được trưng sẽ xuất hiện
dưới dạng diff, đủ nguyên file, + và - cõng phần thay đổi, kiểm chứng
được ở cả hai đầu tag.
{
"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"
}
}Đọc nó bằng con mắt của reviewer. Hai dependency runtime bước vào, ghim
trong phạm vi bản major và nằm gọn trong package: pg (chiếc driver chuẩn mực
nhàm chán nằm dưới đáy mọi thứ) và drizzle-orm (cỗ máy truy vấn sẽ ở yên
bên trong tầng repository — vì sao nó phải ở yên trong đó là chuyện của
mục kế tiếp). Hai công cụ
dev đi kèm: bộ kiểu, và drizzle-kit mà bạn vừa dùng. Script migrate biên
dịch trước — package này chạy JavaScript đã build, nên trình chạy xuất phát
từ dist/ như mọi thứ khác. Còn test:integration trỏ vào một file config
mà phần đuôi mang theo bài học của 1.4 (.mts, vì package này là CommonJS
và vitest từ chối bị require()).
Áp diff vào (hoặc sửa tay), rồi pnpm install, dựng store dậy, và chạy
migration hai lần — lần thứ hai chính là kỷ luật đang hiện hình:
docker compose up -d --wait postgres
pnpm install
pnpm --filter @relay/api migrate
pnpm --filter @relay/api migrate # → migrations: nothing to applyĐáng dừng lại ở hai lựa chọn cấu trúc của trình chạy, không đổi so với ngày
nó được viết. Mỗi file áp dụng trong một transaction riêng, nên migration
hỏng giữa chừng không để lại nửa schema — database hoặc ở trước file đó,
hoặc ở sau, không bao giờ lơ lửng ở giữa. Và sổ sách nằm trong chính
database chứ không phải một dấu mốc trên đĩa: schema_migrations đi cùng
đúng phần dữ liệu nó mô tả, nên mọi môi trường — laptop của bạn, của đồng
đội, production — trả lời câu "tôi đang ở phiên bản nào?" theo cùng một
cách. Khi 2.2 mang migration kế tiếp đến, vẫn câu lệnh này áp dụng đúng phần
chênh lệch, ở mọi nơi.
Tầng repository
Giờ thì chiếc xương sống có thêm người gác. Một file sở hữu mọi truy vấn trong hệ thống, và hình dạng của file ấy chính là bản lập luận:
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);
}
}Hãy nhìn những gì đã trở nên không viết ra nổi. Không có cách nào dựng một
Repository mà không xưng danh tenant — new Repository(db) là lỗi kiểu,
không phải một dòng góp ý trong code review. Không có truy vấn không khóa
nào để mà gọi — điều kiện tenant sống trong file này và chỉ file này, và
mọi truy vấn rời khỏi đây đều mang kiểu từ đầu đến cuối. addMember cho
thấy hai mẫu hình tinh tế cùng lúc: với những bảng mang tenant qua một bước
nhảy, phạm vi khóa cưỡi trên cú JOIN, nên đưa nó một channel id thật của
tenant khác cũng chạm đúng không hàng nào — và nó được viết bằng SQL thô,
vì INSERT … SELECT là chỗ query builder đuối sức, và luật của
ADR-16 cho khoảnh khắc ấy là một hòn đảo bên trong tầng repository, không
bao giờ là vết rò ra ngoài. (Để ý chiếc constructor dùng cú pháp parameter
property — service này đã đánh đổi erasableSyntaxOnly ở 1.4, và đây là
khoản cổ tức nho nhỏ.)
flowchart TB
dbcyl[("một PostgreSQL<br/>chứa hàng của mọi tenant")]
repoA["new Repository(db, envA)<br/>mọi truy vấn: WHERE environment_id = A"]
repoB["new Repository(db, envB)<br/>mọi truy vấn: WHERE environment_id = B"]
codeA["request của tenant A"]
codeB["request của tenant B"]
codeA --> repoA --> dbcyl
codeB --> repoB --> dbcyl
note["Mệnh đề WHERE sống bên trong cánh cửa, một lần duy nhất —<br/>đưa cửa B một id thật của tenant A<br/>và nó mở ra khoảng không"]
dbcyl ~~~ note 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).",
+ },
+ ],
+ },
+ ],
+ },
+ },
);Thử liền tay: thêm import pg from "pg" — hoặc
import { eq } from "drizzle-orm" — vào main.ts của gateway rồi chạy
pnpm lint. Lời cự tuyệt bạn nhận được — kèm trích dẫn constitution ngay
trong thông báo lỗi — chính là điều khoản ấy đang vận hành đúng như văn bản.
Cấm Drizzle bên ngoài tầng repository không phải trò tỉ mẩn của lint; đó là
điều khoản khoanh vùng của ADR-16: cỗ máy truy vấn là chi tiết cài đặt của
repository, không bao giờ là một client trao vào tay handler.
Bộ test vẫn cứ tấn công
Thiết kế loại bỏ xong, vẫn test như thường. Những bài test này cần một
Postgres thật — chứng minh cách ly trên một bản mock thì chẳng chứng minh
được gì — nhưng cửa ải ba câu lệnh đã không cần Docker từ 1.1 và sẽ
tiếp tục như thế. Mẹo nằm ở đúng một ký tự đặt tên: test tích hợp mang đuôi
*.itest.ts, thứ mà mẫu tìm test của làn đơn vị (*.test.ts) đơn giản là
không bao giờ khớp. Chúng có config riêng và một làn riêng, có tên:
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"],
},
});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();
});
});Việc đầu tiên bộ test làm là dựng chốt an toàn — nó từ chối mọi database không phải local, vì một bài test cách ly có quyền truncate bảng thì không bao giờ được gặp một database dùng chung. Rồi nó làm điều trung thực duy nhất: trao cho tenant B những định danh thật của tenant A và chứng kiến mọi phép đọc trả về khoảng không, mọi phép ghi chạm đúng không hàng. Chạy cả hai làn:
pnpm lint && pnpm typecheck && pnpm test
pnpm --filter @relay/api test:integrationflowchart LR
subgraph unit["làn unit — nguyên vẹn từ 1.1, không Docker"]
u1["pnpm lint"]
u2["pnpm typecheck"]
u3["pnpm test<br/>(chỉ *.test.ts)"]
u1 --> u2 --> u3
end
subgraph integration["làn integration — Postgres của compose"]
i1["docker compose up -d --wait postgres"]
i2["pnpm --filter @relay/api migrate"]
i3["pnpm --filter @relay/api test:integration<br/>(chỉ *.itest.ts)"]
i1 --> i2 --> i3
end
unit --> tag["tag part2-ch1"]
integration --> tagĐến lượt bạn
Bài tập chính là công trình: schema, sinh migration, soát lại, tầng repository, bộ test. Rồi tự tấn công thành quả của mình:
- Bình luận hóa một điều kiện
environmentIdtrongrepository.ts— bất kỳ cái nào — rồi chạy làn integration. Xem phép khẳng định nào tóm được nó và nhanh đến đâu. Hồi chuông ấy chính là món lời của việc "vẫn cứ test". - Thêm
import { eq } from "drizzle-orm"vào một file bất kỳ ngoàisrc/dbrồi chạypnpm lint. Đọc to thông báo lỗi; nó trích dẫn constitution lẫn bản ADR. - Đổi một cột trong
schema.ts(thử đổi têndisplay_name), chạydrizzle-kit generate, và đọc thứ nó viết ra — rồi xóa file vừa sinh và hoàn tác. Bạn vừa tổng duyệt phép kiểm drift trên một thay đổi không xứng đáng sống sót qua nó. - Tạo môi trường thứ ba trong bộ test và chứng minh cách ly đúng theo từng cặp — A/C và B/C, chứ không riêng A/B. (Để ý phép chứng minh rẻ đến mức nào một khi khuôn tấn công đã có sẵn.)
Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án: part2-ch1.
Những điều đọng lại
Nếu không đọc gì khác trong chương này, hãy giữ lấy những điều sau:
- Vòng lặp cốt lõi mở màn bằng cách ly vì sự cảnh giác không co giãn — một constructor đòi tenant thắng mười chín mệnh đề WHERE đúng và một mệnh đề bị quên (D4, FR-TEN-05).
- SQL của bản SAD là nguồn, và giờ nó có người anh em song sinh mang kiểu (ADR-16): ràng buộc chính là yêu cầu — DR-01, DR-02, DR-03 gieo hôm nay, gặt ở 2.2–2.4 — và dòng chảy sinh-rồi-soát là thứ giữ cho hai bản schema không trở thành hai database.
- Migration có phiên bản và chỉ tiến — drizzle-kit sinh, trình chạy của chúng ta áp dụng, một cuốn sổ duy nhất; không có đường down, do thiết kế, và chạy lại luôn an toàn.
- Cỗ máy truy vấn sống bên trong tầng repository — truy vấn mang kiểu mà vẫn giữ dáng dấp SQL, đảo SQL thô ở chỗ query builder đuối sức, và một lệnh cấm lint mang hai cái tên để không gì rò qua tường.
- Hai làn, một cửa ải:
*.itest.tsgiữ cho test đơn vị không cần Docker trong khi cách ly được chứng minh trên database thật — kèm chốt an toàn từ chối tấn công bất cứ thứ gì dùng chung.