nickstire-tidb-ddl
Use when adding or altering a column, table, status value, or index in the nickstire database (apps/nickstire/drizzle/). TiDB rejects several things MySQL allows, and one of them loses the row silentl
SKILL.md.claude/skills/nickstire-tidb-ddl/SKILL.md
GitHubnickstire-tidb-ddl
nickstire runs on TiDB Cloud (MySQL wire protocol, not MySQL semantics). Migrations are hand-applied — there is no auto-migrate. The traps below have each cost a session at least once.
The rule that loses data
TiDB runs with STRICT_TRANS_TABLES: a write whose value does not fit the
column is REJECTED and the row is LOST — not truncated, not defaulted.
Two ways to hit it:
- Out-of-enum write. Any value not in the
ENUM(...)list is rejected. - Over-width VARCHAR.
published_partial(17 chars) went into avarchar(16)and was rejected, wedging rows — see the comment indrizzle/0091_content_runs.sql.
Both are worse inside a failure handler: the handler tries to record
status = 'failed_<reason>', the write is rejected, and the job is left in
its previous state with no error recorded.
Do this instead
- Never
ENUMfor a value set that will grow. UseVARCHAR+ app-level validation.drizzle/0099_revenue_opportunity_queue.sqlstates the standing reason: out-of-enum writes lose the row, and ENUM evolution needs ALTERs. - Size status columns at
varchar(32)or wider.content_runsdoes this deliberately after the 0091 incident. - Before adding a status value, check the column width. Compute the longest value you will ever write, not the longest one today.
Known narrow column
reel_jobs.status is varchar(20) (drizzle/schema.ts:3249) —
the narrowest status column in the schema, and an outlier against the
varchar(32) convention.
Do not size against the column's own doc comment. It lists eight values
topping out at assets_ready (12), which suggests 8 characters of headroom.
The code writes values the comment omits — publish_ambiguous (17) is set at
server/cron/jobs/dailyReelPost.ts:381, alongside assembled, needs_review
and review_ready. Real headroom is 3 characters.
Nothing currently written exceeds the limit (verified 2026-08-03: the longest
status reaching any column is 17 chars). But publish_ambiguous is itself a
failure path, so the margin is thinnest exactly where a rejected write costs
the most — the job is parked with no state recorded. Enumerate the values the
code writes, not the ones the comment lists, and widen the column in the
same migration that adds a longer one.
Shipping schema.ts AHEAD of the hand-applied DDL
Migrations are hand-applied, so there is always a window where the CODE knows about a column the DATABASE does not. Two rules make that window survivable:
- Grep
.from(<table>)for projection-lessselect()reads and pin each to the pre-migration column set. A baredb.select().from(t)enumerates every column in the drizzle definition, so adding a column toschema.tssilently rewrites those queries to name a column prod lacks. Adding the 0110 ledger columns toaudit_logwould have 500'd three such reads (services/adminAudit.ts,services/snapApplications.ts,services/complianceLog.ts) until the DDL landed. - New-column WRITES must be conditional — build the insert values object
and attach each new key only when the caller supplied it, so every
pre-existing call site emits byte-identical SQL.
services/auditTrail.tsdoes this deliberately.
The inverse of this class already cost an outage: the bridge-send guard queried a column that did not exist and failed CLOSED for months (#1485). Same window, opposite direction — the code and the schema disagreeing about what is real.
DDL that TiDB accepts
- Additive, idempotent,
INFORMATION_SCHEMA-guarded — follow the existingscripts/migrations/*pattern. Re-running a migration must be a no-op. - TiDB needs two separate
ALTERs where MySQL allows one combined. Split them. - TiDB rejects
ALTER-add of a STORED generated column. Achieve the same result with an application-side normalized column plus a unique index (this is what the customer-dedup work had to do). - TiDB rejects
CREATE TABLE … AS SELECT—ERROR 1105: 'CREATE TABLE ... SELECT' is not implemented yet(witnessed 2026-08-12 backing up__drizzle_migrations). The backup idiom that DOES work is two statements:CREATE TABLE bak LIKE orig;thenINSERT INTO bak SELECT * FROM orig;. Count the rows in the copy before trusting it — and note this failure mode is benign only because a backup runs BEFORE the mutation it protects; put the backup first so a rejected copy aborts the run instead of orphaning it.
Applying
Prod DDL runs through Railway, which injects the real connection string:
railway run --service MAINnicks-tire-auto -- node <script.cjs>
apps/nickstire/docs/operations/SCHEMA_DRIFT_RUNBOOK.md is the full procedure.
Read prod-db-guard before running anything that
writes — .env binds the production database.
Before writing an ALTER TABLE, prove the table exists in production
(INFORMATION_SCHEMA.TABLES, read-only). Raw SQL in a cron can name a table
no migration ever created and no Drizzle declaration knows: 2026-09-02, the
first apply of 0114 failed on statement 1 with ER_NO_SUCH_TABLE estimates
— the estimate-followup job had queried a phantom for its whole life and the
audit had called it a missing column. The migration was rewritten to its valid
half and the job retired. Do not apply through pnpm db:migrate for a
scoped change: it has no dry run and applies every unrecorded file — use the
scoped runner shape in prod-db-guard ("Applying migrations in scope").
When the DDL ships INSIDE handleRunMigrations, read the live commit before
the one-tap runner. The runner only knows the migration list of the container
that serves the tap. Before tapping, GET /api/health must report
deploy.commit = the merge SHA, and the result's total must match the new
list's length: a stale container reports "none failed" too. Witnessed
2026-10-02: the tap at 15:46Z ran on the old container (62087aa2) while the
merge was still BUILDING and applied nothing; the tap at 15:53Z did the work
(176 steps).
Grouped queries: prove the SQL Drizzle renders
Drizzle renders a column unqualified in SELECT but qualified in GROUP BY,
and TiDB only_full_group_by rejects the pair. It cost NICKSTIRE-1
(2026-09-08) and then the customerIntelligence queries again (2026-10-09 to
2026-10-20, PR 3189); typecheck and unit tests passed both times because no
test rendered the real SQL. For any grouped query, render the real Drizzle
builder over a recording fake mysql2 client and assert the SELECT and
GROUP BY column forms match.
After applying, re-run pnpm run check from apps/nickstire/ — tsc fails
against the schema until the migration is actually applied.