# Phase 3 — Generate SQL DDL (tables, indexes, FKs, constraints) Emit a full `db/schema.sql` file based on `DB`, `ORM`, `STYLE`, and `ENTITIES`. Then ONE `AskUserQuestion` to review/revise. ## 3a — Pick primary-key strategy (inline, no AskUserQuestion — deterministic) - Postgres 17: `id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY` (SQL-standard, PG 10+; avoid legacy `SERIAL`). See `db-postgres.md`. - SQLite: `id INTEGER PRIMARY KEY` (rowid alias; autoincrement discouraged unless monotonic guarantee required). See `db-sqlite.md`. - MySQL: `id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY`. If the user's fields include `id` already, respect it; otherwise prepend the surrogate PK. Record the choice in state for Phase 4 traceability. ## 3b — Per-entity DDL generation rules For each entity in `ENTITIES`: 1. **CREATE TABLE**: `CREATE TABLE IF NOT EXISTS ` (plural unless user named it singular — default to pluralize: `User` → `users`, `Organization` → `organizations`; junction `OrganizationUser` → `organization_users`). 2. **Columns**: infer SQL type from field name + heuristics: - `*_id`, `id` → `BIGINT` (Postgres/MySQL) / `INTEGER` (SQLite). - `*_at`, `created_at`, `updated_at` → `TIMESTAMPTZ` (PG) / `DATETIME` (SQLite) / `TIMESTAMP` (MySQL), `DEFAULT now()` (PG) / `DEFAULT CURRENT_TIMESTAMP` (others). - `email`, `*_email` → `TEXT` / `VARCHAR(320)` (RFC 5321 limit) + `NOT NULL` + `CHECK (email LIKE '%@%')` (cheap sanity, real validation is app-side). - `*_count`, `*_amount` → `INTEGER` or `NUMERIC(18,2)` (money); default `0`. - `is_*`, `has_*` → `BOOLEAN NOT NULL DEFAULT false`. - Freeform → `TEXT NOT NULL` unless user said "optional". 3. **Timestamps default**: unless user opts out, add `created_at` + `updated_at` to every non-junction entity. 4. **Soft-delete**: if `SCALE = Production multi-replica`, add `deleted_at TIMESTAMPTZ NULL` + partial index (PG only) on `WHERE deleted_at IS NULL`. ## 3c — Foreign keys For each `relations` entry (kind ≠ `None`): - `REFERENCES (id) ON DELETE `. - Default `ON DELETE` action: junction → `CASCADE`; one-to-one → `CASCADE` from owning side; one-to-many → `RESTRICT` (safer default; explicit cascade is a product decision). See `db-migration-hygiene.md`. - **Mandatory FK index**: every FK column gets `CREATE INDEX IF NOT EXISTS idx__ON
();`. Unindexed FKs = join explosion (see `db-postgres.md` "Forbidden"). ## 3d — Indexes + constraints - Unique: `email`, `slug`, `username`, any field user marked `UNIQUE` → `UNIQUE` column constraint. - Composite indexes: for junction tables, PK is composite; add a reverse-order index if both directions of lookup are common. - Check constraints: `CHECK (status IN (...))` for enum-ish text fields; `CHECK (amount >= 0)` for non-negative numerics. - Triggers: if `updated_at` present AND `DB = Postgres`, emit a `CREATE OR REPLACE FUNCTION set_updated_at()` + `CREATE TRIGGER` per table. SQLite uses `AFTER UPDATE` triggers; MySQL uses `ON UPDATE CURRENT_TIMESTAMP` in the column definition. ## 3e — Emit the file Write `db/schema.sql` with a top-level comment: ```sql -- Generated by /schema-design — -- DB: ORM: Style: