feat(skills): /schema-design 5-phase pipeline
Hub-and-spoke skill that converts "I need a database for app X" into a designed relational schema + first migration + optional seed. Pipeline (5 phases, 9 AskUserQuestion calls total, pure-click after intake): - Phase 1 — batched DB/ORM/scale/style/migration-control click - Phase 2 — entity list + relations matrix (auto-junction tables) - Phase 3 — generate DDL with indexes, FKs, constraints; review/revise loop - Phase 4 — scaffold migrations/ + first timestamped migration + kei-migrate wiring - Phase 5 — optional seed (smoke / rich / test fixtures / skip) Cross-refs the five db-* blocks + the kei-migrate Rust primitive added in commitsf884891anddf85792on this branch. Emits ENV-VAR NAMES only for DATABASE_URL (RULE 0.8 secrets SSoT). Every file ≤ 121 LOC.
This commit is contained in:
parent
df857923d4
commit
c10e169806
6 changed files with 617 additions and 0 deletions
115
skills/schema-design/SKILL.md
Normal file
115
skills/schema-design/SKILL.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
name: schema-design
|
||||
description: Hub-and-spoke pipeline that converts "I need a database for app X" into a designed relational schema, a generated first migration, and optional seed/fixture data — via pure-click decisions across five phases. Emits SQL DDL, a kei-migrate-shaped migrations directory, and a library/ORM pick; never writes production secrets.
|
||||
argument-hint: <one-line app description, e.g. "multi-tenant B2B SaaS, 6-8 entities, Postgres + Drizzle">
|
||||
---
|
||||
|
||||
# Schema-Design — Relational Schema & Migration Pipeline (index)
|
||||
|
||||
You are converting "I need a database for app X" into a concrete, reviewable
|
||||
design: chosen DB + ORM, entity list + relations, SQL DDL with indexes and
|
||||
FKs, a scaffolded migrations directory with the first migration, and (if
|
||||
asked) seed data for tests and dev. Every decision is a click; the only
|
||||
typed inputs are the one-line app description in Phase 1 and the entity
|
||||
list in Phase 2.
|
||||
|
||||
This skill does NOT run migrations or touch production. It produces files
|
||||
under `db/schema.sql`, `migrations/<ts>_init.sql` (+ `.down.sql`), and
|
||||
optionally `db/seed.sql`. Applying them is a separate command (`kei-migrate
|
||||
up`), owned by the project's code-implementer.
|
||||
|
||||
The skill reads the five database blocks heavily — every phase references
|
||||
at least one of them:
|
||||
|
||||
- `_blocks/db-postgres.md` — PG 17 patterns, indexing, pooling.
|
||||
- `_blocks/db-sqlite.md` — single-node / edge pragmas.
|
||||
- `_blocks/db-sqlx.md` — Rust query + migration flow.
|
||||
- `_blocks/db-drizzle.md` — TS schema-first ORM.
|
||||
- `_blocks/db-migration-hygiene.md` — universal up/down + checksum rules.
|
||||
|
||||
Primitive used for scaffolding: `_primitives/_rust/kei-migrate` (universal
|
||||
Postgres / SQLite / MySQL migration runner — create + up + down + status).
|
||||
|
||||
---
|
||||
|
||||
## Pipeline overview (5 phases, ≥5 AskUserQuestion calls)
|
||||
|
||||
| Phase | File | Purpose | AskUserQuestion |
|
||||
|---|---|---|---|
|
||||
| 1 | [phase-1-intake.md](phase-1-intake.md) | DB, ORM, scale, style, migration control | 5× (batched) |
|
||||
| 2 | [phase-2-entities.md](phase-2-entities.md) | Entity list + relations matrix | 1× |
|
||||
| 3 | [phase-3-schema.md](phase-3-schema.md) | Generate DDL + indexes + FKs + constraints; review/revise | 1× |
|
||||
| 4 | [phase-4-migrations.md](phase-4-migrations.md) | Scaffold `migrations/` + first migration + kei-migrate wiring | 1× |
|
||||
| 5 | [phase-5-seed.md](phase-5-seed.md) | Optional seed + test fixtures | 1× |
|
||||
|
||||
Minimum AskUserQuestion count across a full session: **9** (5 in Phase 1 +
|
||||
1 each in Phases 2–5). Exceeds the ≥5 hub-and-spoke contract.
|
||||
|
||||
---
|
||||
|
||||
## Variables the pipeline produces
|
||||
|
||||
| Name | Set in | Meaning |
|
||||
|---|---|---|
|
||||
| `INTAKE` | Phase 1 | one-paragraph app description (verbatim) |
|
||||
| `DB` | Phase 1 | Postgres / SQLite / MySQL |
|
||||
| `ORM` | Phase 1 | none (raw SQL) / Drizzle / SQLx / Prisma / SQLAlchemy |
|
||||
| `SCALE` | Phase 1 | solo-prototype / team-dev / production-multi-replica |
|
||||
| `STYLE` | Phase 1 | schema-first (SQL → types) / code-first (types → SQL) |
|
||||
| `MIGCTL` | Phase 1 | manual / auto-on-deploy / hybrid (manual prod, auto dev) |
|
||||
| `ENTITIES` | Phase 2 | list of entities + fields + relations matrix |
|
||||
| `DDL` | Phase 3 | generated SQL (tables, indexes, FKs, constraints) |
|
||||
| `MIGDIR` | Phase 4 | path of migrations dir + first migration filenames |
|
||||
| `SEED` | Phase 5 | seed-data plan (or "skipped") |
|
||||
|
||||
---
|
||||
|
||||
## Final report (emit after Phase 5)
|
||||
|
||||
```
|
||||
=== SCHEMA-DESIGN REPORT ===
|
||||
App: <first 80 chars of INTAKE>...
|
||||
DB / ORM: <DB> + <ORM> (style: <STYLE>)
|
||||
Scale: <SCALE> migration control: <MIGCTL>
|
||||
Entities: <N> tables, <M> relations
|
||||
Schema: db/schema.sql (<LOC> lines, <I> indexes, <F> FKs, <C> constraints)
|
||||
Migrations: migrations/<ts>_init.sql (+ .down.sql) runner: kei-migrate
|
||||
Seed: <SEED summary or "skipped">
|
||||
Libraries: <ORM pick + driver crate/package, one line>
|
||||
Next: run `kei-migrate up` against dev DB, then hand off to code-implementer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules (apply throughout)
|
||||
|
||||
- **Pure-click contract.** Only the Phase 1 intake paragraph and the Phase 2
|
||||
entity list are typed. Every other decision is an `AskUserQuestion` call.
|
||||
- **RULE 0.8 Secrets SSoT.** The skill never emits a live `DATABASE_URL`
|
||||
value, never writes to `secrets/*.env`, never hard-codes credentials in
|
||||
DDL or seed. It emits ENV-VAR NAMES only; storage path is
|
||||
`<repo>/secrets/db.env` per `domain-has-secrets.md`.
|
||||
- **NO DOWNGRADE.** If the chosen combination is unsafe (e.g.
|
||||
`auto-on-deploy` + multi-replica without leader-election) the skill
|
||||
returns 2–3 constructive alternatives, never "not supported".
|
||||
- **Migration hygiene enforced.** Every migration emitted is
|
||||
timestamp-prefixed, has a `.down.sql` counterpart, and uses
|
||||
`IF NOT EXISTS` / `IF EXISTS` where safe. See `db-migration-hygiene.md`.
|
||||
- **Test-First.** If Phase 5 is selected, seed includes at minimum a
|
||||
smoke-test fixture (one row per entity) to verify schema loads.
|
||||
- **Surgical scope.** Reads the five db-* blocks; writes only to
|
||||
`db/schema.sql`, `migrations/<ts>_init.{sql,down.sql}`, and optionally
|
||||
`db/seed.sql`. Never touches application code.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `_blocks/db-postgres.md`, `_blocks/db-sqlite.md`, `_blocks/db-sqlx.md`,
|
||||
`_blocks/db-drizzle.md`, `_blocks/db-migration-hygiene.md`.
|
||||
- `_primitives/_rust/kei-migrate` — universal migration runner
|
||||
(autodetects Postgres / SQLite / MySQL from `DATABASE_URL`).
|
||||
- `_blocks/domain-has-secrets.md` — DB URL storage convention.
|
||||
- `_blocks/rule-pre-dev-gate.md` — check existing schema before inventing.
|
||||
- Evidence grade [E4] — pipeline mirrors standard relational-modelling
|
||||
practice (Codd 1NF-3NF, surrogate keys, FK-first indexing).
|
||||
92
skills/schema-design/phase-1-intake.md
Normal file
92
skills/schema-design/phase-1-intake.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Phase 1 — Intake (DB, ORM, scale, style, migration control)
|
||||
|
||||
One free-text paragraph, then ONE batched `AskUserQuestion` call with all
|
||||
five click decisions. This is the only phase that accepts typed input.
|
||||
|
||||
## 1a — Ask for the app description
|
||||
|
||||
Emit a regular message (NOT AskUserQuestion):
|
||||
|
||||
> Describe the app in one paragraph: what is it, how many entities (rough
|
||||
> count), any constraint I should know (existing DB, regulated data,
|
||||
> multi-tenant, edge / serverless, expected row counts). Reply in one
|
||||
> message.
|
||||
|
||||
Store the reply verbatim as `INTAKE`.
|
||||
|
||||
## 1b — Batched click (AskUserQuestion, 5 questions in ONE call)
|
||||
|
||||
The UI cap per `AskUserQuestion` call is 4–5 questions; emit all five at
|
||||
once for a smooth click-through.
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Which database engine?",
|
||||
"header": "DB",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "PostgreSQL 17", "description": "Default for multi-user / relational integrity. See _blocks/db-postgres.md"},
|
||||
{"label": "SQLite", "description": "Single-node, edge-friendly, ~100k users ceiling. See _blocks/db-sqlite.md"},
|
||||
{"label": "MySQL / MariaDB", "description": "Existing stack compatibility; kei-migrate supports it"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "ORM / query layer?",
|
||||
"header": "ORM",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "None (raw SQL)", "description": "Hand-written queries; max control, no magic"},
|
||||
{"label": "Drizzle (TS)", "description": "Schema-first or code-first; see _blocks/db-drizzle.md"},
|
||||
{"label": "SQLx (Rust)", "description": "Compile-time checked queries; see _blocks/db-sqlx.md"},
|
||||
{"label": "Prisma (TS)", "description": "Code-first; own migration engine (NOT kei-migrate)"},
|
||||
{"label": "SQLAlchemy (Py)", "description": "Alembic for migrations (NOT kei-migrate); legacy compat"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Target scale?",
|
||||
"header": "Scale",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Solo prototype", "description": "One dev, <1k rows, SQLite OK"},
|
||||
{"label": "Team dev", "description": "Shared dev DB, staging, prod — standard"},
|
||||
{"label": "Production multi-replica", "description": "Leader-election required for migrations; zero-downtime patterns mandatory"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Design style?",
|
||||
"header": "Style",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Schema-first (SQL → types)", "description": "Write DDL, generate types. Default with raw SQL / SQLx / Drizzle schema-first"},
|
||||
{"label": "Code-first (types → SQL)", "description": "Define entities in code, generate DDL. Drizzle code-first / Prisma / SQLAlchemy"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Migration control?",
|
||||
"header": "MigCtl",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Manual (human runs kei-migrate up)", "description": "Safest; recommended for prod"},
|
||||
{"label": "Auto-on-deploy", "description": "CI runs migrations; single-replica only — NO DOWNGRADE warning if multi-replica"},
|
||||
{"label": "Hybrid (manual prod, auto dev)", "description": "Recommended default — dev velocity + prod safety"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store answers as `DB`, `ORM`, `SCALE`, `STYLE`, `MIGCTL`.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `INTAKE` non-empty.
|
||||
- `DB`, `ORM`, `SCALE`, `STYLE`, `MIGCTL` each exactly one label.
|
||||
- If `ORM ∈ {Prisma, SQLAlchemy}` → note in state: "Phase 4 will hand off
|
||||
to that tool's native migration runner (Prisma migrate / Alembic); the
|
||||
kei-migrate scaffold is skipped or wrapped." No downgrade — the skill
|
||||
still emits a working plan.
|
||||
- If `MIGCTL = Auto-on-deploy` AND `SCALE = Production multi-replica` →
|
||||
warn "race condition risk — every replica tries to apply" (see
|
||||
`db-migration-hygiene.md`) and re-ask with the Hybrid option highlighted.
|
||||
82
skills/schema-design/phase-2-entities.md
Normal file
82
skills/schema-design/phase-2-entities.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Phase 2 — Entities + relations matrix
|
||||
|
||||
Collect the entity list (typed) once, then click the relations matrix. This
|
||||
is the second (and last) phase that accepts typed input.
|
||||
|
||||
## 2a — Ask for entities (plain message, NOT AskUserQuestion)
|
||||
|
||||
> List the entities (tables) and for each a short comma-separated field
|
||||
> list. One entity per line, format: `<Entity>: field1, field2, ...`.
|
||||
> Example:
|
||||
>
|
||||
> ```
|
||||
> User: email, name, created_at
|
||||
> Organization: name, plan
|
||||
> Membership: user_id, org_id, role
|
||||
> ```
|
||||
>
|
||||
> 3–15 entities is typical. Keep it short — we'll refine fields in Phase 3.
|
||||
|
||||
Parse the reply into `ENTITIES = [{name, fields: [...]}, ...]`. Validate:
|
||||
- Entity names are `PascalCase` (normalize if user types `user_profile` →
|
||||
`UserProfile`, record normalization in state).
|
||||
- Each entity has ≥1 field.
|
||||
- No duplicate entity names.
|
||||
- If parse fails → re-ask once with a corrected example.
|
||||
|
||||
## 2b — Relations matrix click (AskUserQuestion, multi-select)
|
||||
|
||||
For each UNORDERED PAIR of entities `(A, B)`, ask one multi-select row.
|
||||
Skip pairs the user hasn't mentioned any cross-reference for (heuristic:
|
||||
if `A`'s fields include `b_id` or `B`'s fields include `a_id`, or the
|
||||
user's intake paragraph mentions both).
|
||||
|
||||
Build ONE `AskUserQuestion` call with up to 5 questions. If the entity
|
||||
count yields > 5 candidate pairs, batch into multiple calls (still counts
|
||||
toward the ≥1 AskUserQuestion minimum).
|
||||
|
||||
Per-pair question template:
|
||||
|
||||
```json
|
||||
{
|
||||
"question": "Relation between <A> and <B>?",
|
||||
"header": "<A>↔<B>",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "None", "description": "No direct FK; entities are independent"},
|
||||
{"label": "One-to-one", "description": "A.b_id UNIQUE FK to B.id (or vice versa)"},
|
||||
{"label": "One-to-many (A→B)", "description": "B.a_id FK to A.id; one A has many B"},
|
||||
{"label": "One-to-many (B→A)", "description": "A.b_id FK to B.id; one B has many A"},
|
||||
{"label": "Many-to-many", "description": "Requires a junction table; skill will auto-name it <A><B>"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store the result in `ENTITIES` as `.relations = [{from, to, kind}, ...]`.
|
||||
|
||||
## 2c — Auto-generate junction tables
|
||||
|
||||
For each pair marked `Many-to-many`, append a synthetic entity to
|
||||
`ENTITIES`:
|
||||
|
||||
```
|
||||
<A><B>:
|
||||
<a>_id FK → <A>.id (ON DELETE CASCADE)
|
||||
<b>_id FK → <B>.id (ON DELETE CASCADE)
|
||||
PRIMARY KEY (<a>_id, <b>_id)
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
```
|
||||
|
||||
Names must be deterministic (alphabetical order: `OrganizationUser`, not
|
||||
`UserOrganization`, for pair `(User, Organization)`). Record the rule in
|
||||
state so Phase 3 renders it consistently.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `ENTITIES` has ≥1 entry after parse.
|
||||
- Every relation in `ENTITIES[*].relations` references two distinct
|
||||
existing entities.
|
||||
- Every `Many-to-many` has produced a junction entity.
|
||||
- No entity is orphaned (zero relations AND not mentioned in INTAKE) —
|
||||
warn the user with "Entity X has no relations; keep it?" (NO DOWNGRADE:
|
||||
offer `keep / drop / add relation` as follow-up click).
|
||||
115
skills/schema-design/phase-3-schema.md
Normal file
115
skills/schema-design/phase-3-schema.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# 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 <snake_case_name>` (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 <parent_table>(id) ON DELETE <ACTION>`.
|
||||
- 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_<table>_<col> ON <table>(<col>);`. 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 — <YYYY-MM-DD>
|
||||
-- DB: <DB> ORM: <ORM> Style: <STYLE>
|
||||
-- Entities: <N> Relations: <M>
|
||||
-- Edit freely; Phase 4 will package the first migration from this file.
|
||||
```
|
||||
|
||||
Print the first ~40 lines of the file inline in chat (for review) plus a
|
||||
`<path>:<total lines>` footer. Full contents live on disk.
|
||||
|
||||
## 3f — Review click (AskUserQuestion)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Schema review — accept or revise?",
|
||||
"header": "Review",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Accept — proceed to Phase 4", "description": "Schema is correct; package into a migration"},
|
||||
{"label": "Revise entities (return to Phase 2)", "description": "Add/drop entities or fix a relation"},
|
||||
{"label": "Revise types (edit db/schema.sql)", "description": "Skill will emit a 1-AskUserQuestion per-column-type fix loop"},
|
||||
{"label": "Revise indexes (edit db/schema.sql)", "description": "Skill will emit a 1-AskUserQuestion per-index fix loop"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the user picks Revise, loop the relevant sub-step; then re-emit and
|
||||
re-ask. Max 3 revise loops before the skill asks whether to accept-as-is
|
||||
or escalate to code-implementer handoff (NO DOWNGRADE).
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `db/schema.sql` exists on disk and is non-empty.
|
||||
- Every FK has a companion index.
|
||||
- Every non-junction entity has a PK + `created_at`.
|
||||
- If `ORM = Drizzle` with `STYLE = code-first`: also emit
|
||||
`db/schema.ts` alongside `db/schema.sql` (Drizzle introspection artefact;
|
||||
see `db-drizzle.md`). For `ORM = SQLx`: record in state that Phase 4's
|
||||
migration will also be picked up by `sqlx migrate run`.
|
||||
- Accept click sets `DDL = "db/schema.sql"` and hands off to Phase 4.
|
||||
121
skills/schema-design/phase-4-migrations.md
Normal file
121
skills/schema-design/phase-4-migrations.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Phase 4 — Migration scaffold + first migration + kei-migrate wiring
|
||||
|
||||
Package `db/schema.sql` (from Phase 3) into a proper
|
||||
timestamp-prefixed migration pair under `migrations/`, and emit the
|
||||
`kei-migrate` invocation the user should run.
|
||||
|
||||
## 4a — Create `migrations/` directory (no AskUserQuestion)
|
||||
|
||||
If `migrations/` does not yet exist in the repo, create it. Emit one
|
||||
`.keep` file or rely on the first migration to anchor it.
|
||||
|
||||
If `ORM = Prisma`: the directory is `prisma/migrations/` and the runner is
|
||||
`prisma migrate dev` — skill notes this and skips kei-migrate wiring
|
||||
(reference the handoff but DO NOT overwrite Prisma's own layout).
|
||||
|
||||
If `ORM = SQLAlchemy`: the directory is `alembic/versions/` and the runner
|
||||
is `alembic upgrade head` — same rule, skip kei-migrate wiring.
|
||||
|
||||
For every other `ORM` value (none / Drizzle / SQLx): use `migrations/`
|
||||
with kei-migrate.
|
||||
|
||||
## 4b — Generate timestamp + filename
|
||||
|
||||
- Timestamp format: `YYYYMMDDHHMMSS` (matches `kei-migrate create`'s
|
||||
convention — see `_primitives/_rust/kei-migrate/src/cmd_create.rs`).
|
||||
- Migration name: `init_schema`.
|
||||
- Files:
|
||||
- `migrations/<ts>_init_schema.sql` (up — full DDL from Phase 3)
|
||||
- `migrations/<ts>_init_schema.down.sql` (down — `DROP TABLE` reverse order)
|
||||
|
||||
## 4c — Up migration content
|
||||
|
||||
Copy `db/schema.sql` contents into the up file verbatim, with a one-line
|
||||
header:
|
||||
|
||||
```sql
|
||||
-- kei-migrate: init_schema (generated <YYYY-MM-DD>)
|
||||
-- See db/schema.sql for the schema SSoT.
|
||||
```
|
||||
|
||||
**Do not split one migration per table** — the initial schema ships as ONE
|
||||
migration by convention. Subsequent changes each get their own timestamp.
|
||||
|
||||
## 4d — Down migration content
|
||||
|
||||
Emit `DROP TABLE IF EXISTS <name> CASCADE;` for every entity, in REVERSE
|
||||
dependency order (children before parents — junctions first, then leaf
|
||||
entities, then referenced entities last).
|
||||
|
||||
If any table is flagged `-- IRREVERSIBLE` by the user (e.g. contains
|
||||
critical data once populated), replace the `DROP TABLE` line with:
|
||||
|
||||
```sql
|
||||
-- IRREVERSIBLE: this table holds production data; manual restore required.
|
||||
-- Abort reverse migration.
|
||||
SELECT RAISE(FAIL, 'irreversible: init_schema') ; -- or equivalent per DB
|
||||
```
|
||||
|
||||
See `db-migration-hygiene.md` for the irreversible pattern.
|
||||
|
||||
## 4e — Wire kei-migrate (AskUserQuestion)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Add kei-migrate to the project?",
|
||||
"header": "Runner",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Add to Cargo workspace as path dep", "description": "Rust projects — edit root Cargo.toml members. Skill will NOT edit; emits the snippet for you to paste."},
|
||||
{"label": "Install prebuilt binary (system-wide)", "description": "Any stack — `cargo install --path _primitives/_rust/kei-migrate` once; repo stays tool-agnostic"},
|
||||
{"label": "Use existing runner (Prisma / Alembic / Drizzle-kit / goose / Atlas)", "description": "Skill skips kei-migrate; records the handoff in the report"},
|
||||
{"label": "Decide later", "description": "Files land on disk; runner wiring deferred"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store the answer as `RUNNER`.
|
||||
|
||||
## 4f — Emit the next-step command (inline, no AskUserQuestion)
|
||||
|
||||
Print a fenced code block tailored to `DB` + `RUNNER`:
|
||||
|
||||
```bash
|
||||
# Load DB URL from SSoT (RULE 0.8)
|
||||
set -a && source secrets/db.env && set +a
|
||||
|
||||
# Preview pending migrations
|
||||
kei-migrate --database-url "$DATABASE_URL" --dir migrations status
|
||||
|
||||
# Apply
|
||||
kei-migrate --database-url "$DATABASE_URL" --dir migrations up
|
||||
|
||||
# Revert the latest (dev only!)
|
||||
kei-migrate --database-url "$DATABASE_URL" --dir migrations down 1
|
||||
```
|
||||
|
||||
Reminder (once): `secrets/db.env` must be `chmod 600` and listed in
|
||||
`.gitignore` BEFORE the first write. Template entry:
|
||||
|
||||
```bash
|
||||
# secrets/db.env — chmod 600 before first write
|
||||
DATABASE_URL=
|
||||
```
|
||||
|
||||
No values. RULE 0.8 secrets SSoT.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `migrations/<ts>_init_schema.sql` exists and equals `db/schema.sql` body
|
||||
with the one-line header prepended.
|
||||
- `migrations/<ts>_init_schema.down.sql` exists with DROP statements in
|
||||
reverse dependency order.
|
||||
- Filenames use the `kei-migrate create` timestamp convention.
|
||||
- If `ORM ∈ {Prisma, SQLAlchemy}` — kei-migrate files are NOT created;
|
||||
instead record a one-line handoff in state: "use `<native runner>` —
|
||||
schema.sql is the design SSoT, port it to the native format."
|
||||
- Reminder about `secrets/db.env` emitted exactly once.
|
||||
92
skills/schema-design/phase-5-seed.md
Normal file
92
skills/schema-design/phase-5-seed.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Phase 5 — Optional seed data + test fixtures
|
||||
|
||||
Emit `db/seed.sql` with deterministic, safe-to-re-run seed rows, OR skip
|
||||
this phase entirely. Single `AskUserQuestion` to decide.
|
||||
|
||||
## 5a — Seed decision (AskUserQuestion)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Seed data for dev / tests?",
|
||||
"header": "Seed",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Smoke fixture (1 row per entity)", "description": "Minimal — proves schema loads and FKs resolve. Recommended default."},
|
||||
{"label": "Rich dev seed (5–20 rows per entity)", "description": "Realistic playground for local dev; deterministic IDs from a fixed seed"},
|
||||
{"label": "Test fixtures only (for integration tests)", "description": "Small, labelled datasets keyed by test-case name"},
|
||||
{"label": "Skip — no seed data", "description": "Schema-only delivery; tests will use mocks or runtime factories"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store as `SEED`.
|
||||
|
||||
## 5b — Generate `db/seed.sql` (inline, no AskUserQuestion)
|
||||
|
||||
Rules, regardless of choice (unless Skip):
|
||||
|
||||
- **Idempotent** — every `INSERT` uses `ON CONFLICT DO NOTHING` (PG/MySQL)
|
||||
or `INSERT OR IGNORE` (SQLite). Re-running seed is safe.
|
||||
- **Deterministic PKs** — use explicit IDs (`1`, `2`, ...) not relying on
|
||||
sequences. For UUIDs, use fixed values from a documented seed (e.g.
|
||||
`uuid_generate_v5(...)` or hard-coded dev-only UUIDs).
|
||||
- **No secrets** — no real emails (use `user1@example.test`), no real
|
||||
phone numbers, no real names. RULE 0.8 still applies: nothing in seed
|
||||
should be or look like a production token.
|
||||
- **Respect FK order** — insert parents before children, junctions last.
|
||||
- **One file** — `db/seed.sql`. Not split per entity (ordering matters).
|
||||
|
||||
Smoke-fixture shape:
|
||||
|
||||
```sql
|
||||
-- Generated by /schema-design Phase 5 — <YYYY-MM-DD>
|
||||
-- Smoke fixture: one row per entity, deterministic IDs.
|
||||
|
||||
INSERT INTO users (id, email, name) VALUES (1, 'user1@example.test', 'Seed User')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO organizations (id, name) VALUES (1, 'Seed Org')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO organization_users (user_id, organization_id, role)
|
||||
VALUES (1, 1, 'owner') ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
For "Rich dev seed" — use counted loops in SQL (PG: `generate_series`;
|
||||
SQLite: recursive CTE; MySQL: `WITH RECURSIVE`) to produce 5–20 rows per
|
||||
non-junction entity, with FK references wrapping modulo the parent count.
|
||||
|
||||
For "Test fixtures" — group by test name via SQL comments:
|
||||
|
||||
```sql
|
||||
-- fixture: test_user_signup
|
||||
INSERT INTO users (id, email, name) VALUES (101, 'signup@example.test', 'Signup') ...
|
||||
-- fixture: test_org_invite
|
||||
INSERT INTO users (id, email, name) VALUES (201, 'invitee@example.test', 'Invitee') ...
|
||||
```
|
||||
|
||||
## 5c — Test-First hook (inline)
|
||||
|
||||
If `SEED ≠ Skip`, emit a smoke-test snippet tailored to `DB`:
|
||||
|
||||
```bash
|
||||
# Smoke-test: load schema + seed, assert row counts.
|
||||
kei-migrate --database-url "$DATABASE_URL" --dir migrations up
|
||||
psql "$DATABASE_URL" -f db/seed.sql # or: sqlite3 <file> < db/seed.sql
|
||||
psql "$DATABASE_URL" -c "SELECT count(*) FROM users;"
|
||||
```
|
||||
|
||||
Remind the user: this is a smoke test, not a Test-First contract. Real
|
||||
integration tests live in the project's test suite — see
|
||||
`_blocks/rule-test-first.md`.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- If `SEED = Skip`: `db/seed.sql` is NOT created; state records "seed
|
||||
skipped".
|
||||
- Otherwise: `db/seed.sql` exists, is idempotent, respects FK order, and
|
||||
uses `@example.test` (or equivalent RFC 2606 reserved) emails only.
|
||||
- Smoke-test snippet printed inline in chat (once).
|
||||
- Final report can now be emitted (see `SKILL.md` index).
|
||||
Loading…
Reference in a new issue