ddlforge 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,227 +1,430 @@
1
1
  # ddlforge
2
2
 
3
- > **Ultra-fast, zero-runtime-dependency PostgreSQL migration lock linter & data-loss prevention engine.**
3
+ > **Ultra-fast PostgreSQL migration lock linter & runtime execution supervisor.**
4
4
  > Built for Node.js 20+ and TypeScript. Designed for high-traffic Prisma, Drizzle, and raw SQL backends.
5
5
 
6
6
  [![Node.js](https://img.shields.io/badge/Node.js-20%2B-green.svg)](https://nodejs.org)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-5%2B-blue.svg)](https://www.typescriptlang.org)
8
- [![Zero Dependencies](https://img.shields.io/badge/Runtime%20Dependencies-0-success.svg)](package.json)
8
+ [![Check: Zero Dependencies](https://img.shields.io/badge/Check%20Runtime%20Deps-0-success.svg)](package.json)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
10
 
11
11
  ---
12
12
 
13
- ## Why ddlforge?
14
-
15
- In PostgreSQL, DDL commands take **table-level locks**. Even if a migration takes only milliseconds to run, if it requests an `ACCESS EXCLUSIVE` or `SHARE` lock while a long-running `SELECT` or transaction is open:
16
- 1. The migration stalls waiting for the lock.
17
- 2. **Every single query behind it is queued up**, including basic reads (`SELECT`).
18
- 3. Connection pools instantly exhaust within seconds, crashing services and taking down production.
19
-
20
- Furthermore, ORMs like **Prisma Migrate** cannot detect column renames automatically: when you rename a field in `schema.prisma`, Prisma generates a `DROP COLUMN` followed by `ADD COLUMN`, permanently deleting all your production data!
21
-
22
- `ddlforge` catches these architectural pitfalls in your local pre-commit hooks and CI pipelines before code ever merges to main.
13
+ ## What is ddlforge?
23
14
 
24
- ### Design Principles
25
- - **Zero Runtime Dependencies**: Pure native Node.js standard library (`node:fs`, `node:path`, `node:process`, `node:child_process`). Air-gap safe.
26
- - **Sub-Second Performance**: Single-pass lexical scanner and linear rule evaluators. Scans 100+ migration files in **<10ms**.
27
- - **Deterministic Exit Codes**: Exits `0` when clean or advisory-only; exits `1` for any blocking violation (table lock or destructive data loss).
15
+ `ddlforge` is a two-mode PostgreSQL migration safety tool:
28
16
 
29
- ---
30
-
31
- ## PostgreSQL Lock Hierarchy Reference
17
+ | Mode | Command | What it does | Dependencies |
18
+ |:-----|:--------|:-------------|:-------------|
19
+ | **Check** (lint) | `ddlforge [paths…]` | Statically analyses SQL files for lock hazards and data-loss patterns. Exits `1` on blockers. | **Zero** — pure Node.js stdlib |
20
+ | **Apply** (execute) | `ddlforge apply <file> --db <url>` | Runs migrations against a live database with per-statement lock-timeout injection, full-jitter exponential retry, and a background queue-avalanche breaker. | Requires `pg` (auto-detected at runtime) |
32
21
 
33
- | Lock Mode | Acquired By | Conflicted Locks | Blocks Reads? | Blocks Writes? | Risk Level |
34
- |:---|:---|:---|:---:|:---:|:---:|
35
- | `ACCESS SHARE` | `SELECT` | `ACCESS EXCLUSIVE` | ❌ | ❌ | Low |
36
- | `ROW SHARE` | `SELECT FOR UPDATE / FOR SHARE` | `EXCLUSIVE`, `ACCESS EXCLUSIVE` | ❌ | ❌ | Low |
37
- | `ROW EXCLUSIVE` | `UPDATE`, `DELETE`, `INSERT` | `SHARE`, `SHARE ROW EXCLUSIVE`, `EXCLUSIVE`, `ACCESS EXCLUSIVE` | ❌ | ❌ | Medium |
38
- | `SHARE UPDATE EXCLUSIVE` | `CREATE INDEX CONCURRENTLY`, `VACUUM`, `ANALYZE`, `VALIDATE CONSTRAINT` | `SHARE UPDATE EXCLUSIVE`, `SHARE`, `SHARE ROW EXCLUSIVE`, `EXCLUSIVE`, `ACCESS EXCLUSIVE` | ❌ | ❌ | **Safe for Concurrency** |
39
- | `SHARE` | `CREATE INDEX` (non-concurrent) | `ROW EXCLUSIVE`, `SHARE ROW EXCLUSIVE`, `EXCLUSIVE`, `ACCESS EXCLUSIVE` | ❌ | **YES** | 🛑 **BLOCKER** |
40
- | `SHARE ROW EXCLUSIVE` | `ADD CONSTRAINT FOREIGN KEY` (without `NOT VALID`) | `ROW EXCLUSIVE`, `SHARE UPDATE EXCLUSIVE`, `SHARE`, `SHARE ROW EXCLUSIVE`, `EXCLUSIVE`, `ACCESS EXCLUSIVE` | ❌ | **YES** | 🛑 **BLOCKER** |
41
- | `EXCLUSIVE` | `REFRESH MATERIALIZED VIEW CONCURRENTLY` | `ROW SHARE`, `ROW EXCLUSIVE`, `SHARE UPDATE EXCLUSIVE`, `SHARE`, `SHARE ROW EXCLUSIVE`, `EXCLUSIVE`, `ACCESS EXCLUSIVE` | Row Share | **YES** | 🛑 **BLOCKER** |
42
- | `ACCESS EXCLUSIVE` | `ALTER TABLE`, `DROP TABLE`, `TRUNCATE`, `ALTER COLUMN SET NOT NULL` | **ALL LOCK MODES** | **YES (SELECT)** | **YES** | 🚨 **CRITICAL DOWNTIME** |
22
+ Both modes share the same zero-dependency SQL lexer. **Installing `pg` is only required for `apply`** `check` always works without it.
43
23
 
44
24
  ---
45
25
 
46
- ## The 7 Core Rules & Zero-Downtime Recipes
47
-
48
- ### 1. `require-concurrent-index` (CREATE INDEX without CONCURRENTLY)
49
- - **Severity**: `BLOCKER`
50
- - **Lock**: `SHARE` lock (blocks all `INSERT`, `UPDATE`, `DELETE` operations on table).
51
- - **Dangerous**:
52
- ```sql
53
- CREATE INDEX idx_users_email ON users(email);
54
- ```
55
- - **Zero-Downtime Fix**:
56
- ```sql
57
- CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
58
- ```
59
-
60
- ---
26
+ ## Why ddlforge?
61
27
 
62
- ### 2. `concurrent-index-in-transaction` (CONCURRENTLY inside Transaction Trap)
63
- - **Severity**: `BLOCKER`
64
- - **Engine Behavior**: PostgreSQL aborts with `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block`.
65
- - **Dangerous**:
66
- ```sql
67
- BEGIN;
68
- CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
69
- COMMIT;
70
- ```
71
- - **Prisma Caveat**: Prisma Migrate wraps all migrations in an implicit transaction by default. Running `CREATE INDEX CONCURRENTLY` in Prisma fails unless you add the `-- prisma:no-transaction` directive.
72
- - **Zero-Downtime Fix**:
73
- ```sql
74
- -- prisma:no-transaction
75
- CREATE INDEX CONCURRENTLY "users_email_idx" ON "users"("email");
76
- ```
28
+ In PostgreSQL, DDL commands take **table-level locks**. Even a millisecond-fast migration can cause a complete service outage:
77
29
 
78
- ---
30
+ 1. The migration waits to acquire an `ACCESS EXCLUSIVE` lock while a long-running `SELECT` holds its own lock.
31
+ 2. **Every query behind it queues up**, including basic reads.
32
+ 3. Connection pools saturate within seconds, crashing all services.
79
33
 
80
- ### 3. `add-column-not-null-without-default` (ADD COLUMN NOT NULL without DEFAULT)
81
- - **Severity**: `BLOCKER`
82
- - **Lock**: `ACCESS EXCLUSIVE` lock.
83
- - **Dangerous**:
84
- ```sql
85
- ALTER TABLE users ADD COLUMN role VARCHAR(50) NOT NULL;
86
- ```
87
- - **Why**: On non-empty tables, PostgreSQL immediately aborts with `ERROR: column "role" contains null values` while holding an `ACCESS EXCLUSIVE` table lock.
88
- - **Zero-Downtime Fix (PG 11+)**:
89
- Provide a static non-volatile default (metadata-only instant operation):
90
- ```sql
91
- ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT 'user' NOT NULL;
92
- ```
93
- Or for dynamic/custom defaults, use the multi-step backfill pattern:
94
- ```sql
95
- -- Step 1: Add column nullable
96
- ALTER TABLE users ADD COLUMN role VARCHAR(50);
97
- -- Step 2: Batched backfill
98
- -- Step 3: Add CHECK constraint NOT VALID & validate
99
- ALTER TABLE users ADD CONSTRAINT chk_role_not_null CHECK (role IS NOT NULL) NOT VALID;
100
- ALTER TABLE users VALIDATE CONSTRAINT chk_role_not_null;
101
- ```
34
+ `ddlforge check` catches these patterns **before code merges**. `ddlforge apply` executes migrations with production-grade safeguards so even risky DDL lands safely.
102
35
 
103
36
  ---
104
37
 
105
- ### 4. `foreign-key-missing-not-valid` (ADD CONSTRAINT FOREIGN KEY without NOT VALID)
106
- - **Severity**: `BLOCKER`
107
- - **Lock**: `SHARE ROW EXCLUSIVE` on referencing table + `SHARE` on referenced table.
108
- - **Dangerous**:
109
- ```sql
110
- ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
111
- ```
112
- - **Zero-Downtime Fix**:
113
- Split into two non-blocking steps:
114
- ```sql
115
- -- Step 1: Add constraint without scanning rows (instant metadata operation)
116
- ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
117
-
118
- -- Step 2: Validate concurrently (takes only SHARE UPDATE EXCLUSIVE, reads & writes proceed!)
119
- ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
120
- ```
38
+ ## Quick Start
121
39
 
122
- ---
40
+ ```bash
41
+ # Lint migrations (zero dependencies required):
42
+ npx ddlforge ./prisma/migrations
123
43
 
124
- ### 5. `prisma-silent-rename-data-loss` (Prisma Silent Field Rename)
125
- - **Severity**: `BLOCKER` (Data-Loss Prevention)
126
- - **Lock**: `ACCESS EXCLUSIVE` + Table Data Loss.
127
- - **Dangerous**:
128
- ```sql
129
- ALTER TABLE "users" DROP COLUMN "old_name";
130
- ALTER TABLE "users" ADD COLUMN "new_name" TEXT NOT NULL;
131
- ```
132
- - **Why**: Prisma Migrate cannot infer renames from `schema.prisma`. It generates a column drop followed by column creation, destroying all production data.
133
- - **Zero-Downtime Fix**:
134
- ```sql
135
- ALTER TABLE "users" RENAME COLUMN "old_name" TO "new_name";
136
- ```
137
- Or use Prisma's `@map("old_name")` in your schema to avoid database column renames.
44
+ # Apply a migration safely to a live database:
45
+ npx ddlforge apply ./migrations/001_add_index.sql --db "$DATABASE_URL"
46
+ ```
138
47
 
139
48
  ---
140
49
 
141
- ### 6. `set-not-null-full-scan` (ALTER COLUMN SET NOT NULL)
142
- - **Severity**: `WARNING` (or `BLOCKER` on PG < 12)
143
- - **Lock**: `ACCESS EXCLUSIVE` lock while scanning entire table for nulls.
144
- - **Dangerous**:
145
- ```sql
146
- ALTER TABLE users ALTER COLUMN email SET NOT NULL;
147
- ```
148
- - **Zero-Downtime Fix (PG 12+)**:
149
- ```sql
150
- -- 1. Add CHECK constraint NOT VALID (instant)
151
- ALTER TABLE users ADD CONSTRAINT chk_users_email_not_null CHECK (email IS NOT NULL) NOT VALID;
152
- -- 2. Validate constraint without blocking writes
153
- ALTER TABLE users VALIDATE CONSTRAINT chk_users_email_not_null;
154
- -- 3. Set NOT NULL (Postgres 12+ skips table scan because validated CHECK constraint exists!)
155
- ALTER TABLE users ALTER COLUMN email SET NOT NULL;
156
- ```
50
+ ## `ddlforge check` Static Lock Linter
157
51
 
158
- ---
52
+ ### Zero-Dependency Design
159
53
 
160
- ### 7. `unbatched-dml` (Unbatched DML in Migration)
161
- - **Severity**: `WARNING` (or `BLOCKER` without WHERE)
162
- - **Lock**: `ROW EXCLUSIVE` lock on touched rows, massive transaction WAL bloat.
163
- - **Dangerous**:
164
- ```sql
165
- UPDATE users SET active = true;
166
- DELETE FROM sessions;
167
- ```
168
- - **Zero-Downtime Fix**:
169
- Perform large backfills out-of-band using batched background workers. If updating a small static/seed table, exempt with an inline comment:
170
- ```sql
171
- -- ddlforge-ignore unbatched-dml
172
- UPDATE subscription_tiers SET active = true WHERE code = 'PRO';
173
- ```
54
+ `ddlforge check` is built on a **pure Node.js single-pass SQL lexer** — no `pg`, no `node-postgres`, no external parsers. It runs air-gap safe in any CI environment without a database connection.
174
55
 
175
- ---
176
-
177
- ## CLI Installation & Usage
56
+ ### Installation
178
57
 
179
- ### Running via npx (Zero Install)
180
58
  ```bash
181
- npx ddlforge ./prisma/migrations
182
- ```
59
+ # Global
60
+ npm install -g ddlforge
183
61
 
184
- ### Local Installation
185
- ```bash
62
+ # Per-project dev dependency
186
63
  npm install --save-dev ddlforge
64
+
65
+ # No install (npx)
66
+ npx ddlforge ./prisma/migrations
187
67
  ```
188
68
 
189
69
  ### CLI Flags
70
+
190
71
  ```text
191
72
  ddlforge [paths...] [flags]
192
73
 
193
74
  ARGUMENTS:
194
- paths Target migration files or directories (e.g. ./prisma/migrations, ./drizzle)
75
+ paths Migration files or directories (e.g. ./prisma/migrations, ./drizzle)
195
76
 
196
77
  FLAGS:
197
78
  --pg <version> Target PostgreSQL version (default: 16)
198
79
  --format <type> Output format: terminal | json | markdown (default: terminal)
199
- --quiet, -q Suppress advisories/warnings and emit blockers only
200
- --changed-only Use git diff to lint only staged or branch-modified migration files
201
- --version, -v Print ddlforge version and exit
202
- --help, -h Print this help message and exit
80
+ --quiet, -q Show blockers only; suppress warnings and advisories
81
+ --changed-only Lint only files changed in the current git branch / PR
82
+ --version, -v Print version and exit
83
+ --help, -h Print help and exit
203
84
  ```
204
85
 
205
86
  ### Examples
87
+
206
88
  ```bash
207
- # Lint all Prisma migrations
89
+ # Scan all Prisma migrations
208
90
  npx ddlforge ./prisma/migrations
209
91
 
210
- # Lint only modified migration files in current git branch / PR
92
+ # Scan only files touched in this branch/PR
211
93
  npx ddlforge --changed-only
212
94
 
213
- # Check Drizzle migrations targeting Postgres 14 with JSON output
95
+ # Drizzle migrations, targeting Postgres 14, JSON output for CI
214
96
  npx ddlforge ./drizzle --pg 14 --format json
215
97
 
216
- # CI Mode: Emit blockers only with exit code 1
98
+ # Blockers-only output, non-zero exit on any blocker
217
99
  npx ddlforge ./migrations --quiet
218
100
  ```
219
101
 
102
+ ### Output formats
103
+
104
+ **Terminal** (default) — colorized, human-readable:
105
+ ```
106
+ BLOCKER CREATE INDEX without CONCURRENTLY [LOCK: SHARE]
107
+ at migrations/001.sql:1:1 (rule: require-concurrent-index)
108
+
109
+ 1 │ CREATE INDEX idx_users_email ON users(email);
110
+
111
+ Why: Acquires a SHARE lock — blocks all INSERT, UPDATE, DELETE…
112
+ Fix:
113
+ CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
114
+ ```
115
+
116
+ **JSON** — machine-readable, CI-parseable:
117
+ ```bash
118
+ npx ddlforge ./migrations --format json | jq '.summary'
119
+ ```
120
+
121
+ **Markdown** — paste directly into GitHub PR comments:
122
+ ```bash
123
+ npx ddlforge --changed-only --format markdown >> $GITHUB_STEP_SUMMARY
124
+ ```
125
+
126
+ ---
127
+
128
+ ## `ddlforge apply` — Runtime Execution Supervisor
129
+
130
+ `ddlforge apply` is a production-grade DDL execution engine. Rather than running `psql` or a raw ORM migration runner, it wraps each statement in a hardened execution loop:
131
+
132
+ ```
133
+ ┌──────────────────── ddlforge apply ──────────────────────────────────────┐
134
+ │ │
135
+ │ Parse SQL file → For each statement: │
136
+ │ 1. BEGIN transaction │
137
+ │ 2. SET LOCAL lock_timeout = '<ms>' │
138
+ │ 3. SET LOCAL statement_timeout = '<ms>' │
139
+ │ 4. Execute statement │
140
+ │ ├─ Success → COMMIT, next statement │
141
+ │ └─ Lock error (55P03 / 57014) │
142
+ │ → ROLLBACK │
143
+ │ → Full-jitter backoff sleep │
144
+ │ → Retry (up to --max-retries) │
145
+ │ │
146
+ │ Background (parallel): │
147
+ │ Lock-Queue Monitor polls pg_locks every 500ms │
148
+ │ If blocked_backends ≥ threshold → pg_cancel_backend() │
149
+ │ → AVALANCHE ABORT (protects connection pool) │
150
+ │ │
151
+ └───────────────────────────────────────────────────────────────────────────┘
152
+ ```
153
+
154
+ ### Prerequisites
155
+
156
+ `ddlforge apply` dynamically imports `pg` at runtime. If it's not installed you'll get clear instructions:
157
+
158
+ ```bash
159
+ npm install pg # npm
160
+ yarn add pg # yarn
161
+ pnpm add pg # pnpm
162
+ ```
163
+
164
+ > **Note:** `ddlforge check` (static linting) **never** imports `pg`. You do not need `pg` unless you use `apply`.
165
+
166
+ ### CLI Flags
167
+
168
+ ```text
169
+ ddlforge apply <file.sql> --db <DATABASE_URL> [flags]
170
+
171
+ ARGUMENTS:
172
+ <file.sql> SQL migration file to execute
173
+
174
+ FLAGS:
175
+ --db <url> PostgreSQL connection URL (required,
176
+ or set DATABASE_URL env var)
177
+ --lock-timeout <ms> Per-statement lock_timeout (default: 3000)
178
+ --statement-timeout <ms> Per-statement statement_timeout (default: 30000)
179
+ --max-retries <n> Max retry attempts on lock timeout (default: 5)
180
+ --dry-run Parse & display statements without executing
181
+ --lock-queue-threshold <n> Blocked-backend count that triggers cancellation
182
+ (default: 1)
183
+ --monitor-poll-ms <ms> Lock-monitor polling interval (default: 500)
184
+ --help, -h Print this help and exit
185
+ ```
186
+
187
+ ### Examples
188
+
189
+ ```bash
190
+ # Basic apply
191
+ ddlforge apply ./migrations/001_add_index.sql --db postgres://localhost/mydb
192
+
193
+ # Use DATABASE_URL env var
194
+ ddlforge apply ./migrations/001_add_index.sql --db "$DATABASE_URL"
195
+
196
+ # Dry run: parse and preview statements without touching the database
197
+ ddlforge apply ./migrations/001_add_index.sql --db "$DATABASE_URL" --dry-run
198
+
199
+ # Custom timeouts and more retries
200
+ ddlforge apply ./migrations/001_add_index.sql \
201
+ --db "$DATABASE_URL" \
202
+ --lock-timeout 5000 \
203
+ --statement-timeout 60000 \
204
+ --max-retries 8
205
+
206
+ # Conservative mode: abort as soon as even 1 backend queues behind us
207
+ ddlforge apply ./migrations/001_add_index.sql \
208
+ --db "$DATABASE_URL" \
209
+ --lock-queue-threshold 1
210
+
211
+ # Lenient mode: allow up to 3 backends to queue before cancelling
212
+ ddlforge apply ./migrations/001_add_index.sql \
213
+ --db "$DATABASE_URL" \
214
+ --lock-queue-threshold 3
215
+ ```
216
+
217
+ ### Live Terminal Output
218
+
219
+ ```
220
+ ddlforge apply /path/to/001_add_index.sql
221
+ lock_timeout: 3000ms
222
+ statement_timeout: 30000ms
223
+ max_retries: 5
224
+ queue_threshold: 1
225
+
226
+ [1/3] CREATE INDEX CONCURRENTLY idx_users_email ON users(email); ✔ (2847ms)
227
+ [2/3] ALTER TABLE users ADD COLUMN bio TEXT; ✔ (12ms)
228
+ [2/3] ALTER TABLE orders ADD CONSTRAINT fk_user … ↺ retry 1 (backoff 183ms)
229
+ [3/3] ALTER TABLE orders ADD CONSTRAINT fk_user … ✔ (31ms)
230
+
231
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
232
+ APPLIED 3/3 statement(s) executed successfully in 3156ms
233
+ ```
234
+
235
+ ---
236
+
237
+ ## Safety Mechanisms Explained
238
+
239
+ ### 1. Session Lock-Timeout Injection
240
+
241
+ For every statement, `ddlforge apply` injects `SET LOCAL` timeouts **inside the transaction**, ensuring they are always scoped to that statement only and automatically reset after commit/rollback:
242
+
243
+ ```sql
244
+ BEGIN;
245
+ SET LOCAL lock_timeout = '3000'; -- abort if lock not acquired in 3s
246
+ SET LOCAL statement_timeout = '30000'; -- abort if statement runs > 30s
247
+ ALTER TABLE users ADD COLUMN bio TEXT;
248
+ COMMIT;
249
+ ```
250
+
251
+ Using `SET LOCAL` (rather than `SET`) guarantees:
252
+ - Timeouts apply only to the current statement, not the whole session.
253
+ - Other concurrent connections are unaffected.
254
+ - A mid-migration crash automatically resets to the session default.
255
+
256
+ ### 2. Full-Jitter Exponential Backoff
257
+
258
+ When a statement fails with a lock error (`55P03: lock_not_available` or `57014: query_canceled`), ddlforge does **not** retry immediately. It sleeps for a randomised interval before trying again.
259
+
260
+ **Algorithm:**
261
+ ```
262
+ sleep = Math.random() × min(maxDelay, baseDelay × 2^attempt)
263
+ ```
264
+
265
+ | Attempt | Base window | Actual sleep (example) |
266
+ |:-------:|:-----------:|:----------------------:|
267
+ | 1 | 500 ms | 217 ms |
268
+ | 2 | 1 000 ms | 843 ms |
269
+ | 3 | 2 000 ms | 1 341 ms |
270
+ | 4 | 4 000 ms | 2 887 ms |
271
+ | 5 | 8 000 ms | 6 104 ms |
272
+
273
+ The full-jitter pattern (rather than deterministic or additive jitter) ensures that **multiple migrations running simultaneously spread their retries apart**, preventing the "retry stampede" that would re-create the exact lock convoy they are trying to escape.
274
+
275
+ ### 3. Background Lock-Queue Monitor (Avalanche Breaker)
276
+
277
+ Before executing each statement, ddlforge spawns a **background monitor** on a separate database connection. It polls `pg_locks` and `pg_stat_activity` every 500 ms:
278
+
279
+ ```sql
280
+ SELECT COUNT(*) AS blocked_count
281
+ FROM pg_locks blocker
282
+ JOIN pg_locks waiter
283
+ ON waiter.relation = blocker.relation
284
+ AND waiter.locktype = blocker.locktype
285
+ AND waiter.pid <> blocker.pid
286
+ WHERE blocker.pid = $1 -- migration's backend PID
287
+ AND blocker.granted = TRUE
288
+ AND waiter.granted = FALSE
289
+ ```
290
+
291
+ If the number of waiting backends reaches `--lock-queue-threshold` (default: **1**), the monitor immediately issues:
292
+
293
+ ```sql
294
+ SELECT pg_cancel_backend($1); -- sends SIGINT to migration PID
295
+ ```
296
+
297
+ This triggers a controlled rollback of the migration statement — which releases the lock — unblocking the entire queue **before connection pools saturate**. The breaker chooses `pg_cancel_backend` (not `pg_terminate_backend`) so the migration client can clean up gracefully and report the event.
298
+
299
+ After cancellation, `ddlforge apply` reports a clear **AVALANCHE ABORT** event and exits with code `1`.
300
+
301
+ **Why this matters:** A single `ALTER TABLE` holding an `ACCESS EXCLUSIVE` lock for even 5 seconds can cause thousands of queries to queue, exhausting all available connections in a busy pool within 10–30 seconds. The avalanche breaker keeps that window under your polling interval.
302
+
220
303
  ---
221
304
 
222
- ## GitHub Actions Workflow
305
+ ## The 7 Core Lint Rules & Zero-Downtime Recipes
223
306
 
224
- Add this copy-paste workflow to `.github/workflows/migration-lint.yml`:
307
+ ### 1. `require-concurrent-index` CREATE INDEX without CONCURRENTLY
308
+
309
+ - **Severity**: `BLOCKER`
310
+ - **Lock**: `SHARE` — blocks all `INSERT`, `UPDATE`, `DELETE`
311
+
312
+ ```sql
313
+ -- ❌ Dangerous
314
+ CREATE INDEX idx_users_email ON users(email);
315
+
316
+ -- ✅ Zero-Downtime Fix
317
+ CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
318
+ ```
319
+
320
+ ---
321
+
322
+ ### 2. `concurrent-index-in-transaction` — CONCURRENTLY inside a Transaction
323
+
324
+ - **Severity**: `BLOCKER`
325
+ - **Engine Behavior**: PostgreSQL raises `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block`.
326
+
327
+ ```sql
328
+ -- ❌ Dangerous
329
+ BEGIN;
330
+ CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
331
+ COMMIT;
332
+
333
+ -- ✅ Zero-Downtime Fix (Prisma)
334
+ -- prisma:no-transaction
335
+ CREATE INDEX CONCURRENTLY "users_email_idx" ON "users"("email");
336
+ ```
337
+
338
+ > **Prisma Caveat:** Prisma Migrate wraps all migrations in an implicit transaction by default. Add `-- prisma:no-transaction` at the top of the migration file to disable this.
339
+
340
+ ---
341
+
342
+ ### 3. `add-column-not-null-without-default` — ADD COLUMN NOT NULL without DEFAULT
343
+
344
+ - **Severity**: `BLOCKER`
345
+ - **Lock**: `ACCESS EXCLUSIVE`
346
+
347
+ ```sql
348
+ -- ❌ Dangerous
349
+ ALTER TABLE users ADD COLUMN role VARCHAR(50) NOT NULL;
350
+
351
+ -- ✅ Zero-Downtime Fix (PG 11+ static default — metadata-only, instant)
352
+ ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT 'user' NOT NULL;
353
+ ```
354
+
355
+ ---
356
+
357
+ ### 4. `foreign-key-missing-not-valid` — ADD CONSTRAINT FOREIGN KEY without NOT VALID
358
+
359
+ - **Severity**: `BLOCKER`
360
+ - **Lock**: `SHARE ROW EXCLUSIVE` on referencing table + `SHARE` on referenced table
361
+
362
+ ```sql
363
+ -- ❌ Dangerous
364
+ ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
365
+
366
+ -- ✅ Zero-Downtime Fix (two-step)
367
+ -- Step 1: instant metadata operation, no row scan
368
+ ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
369
+ -- Step 2: SHARE UPDATE EXCLUSIVE only — reads & writes proceed
370
+ ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
371
+ ```
372
+
373
+ ---
374
+
375
+ ### 5. `prisma-silent-rename-data-loss` — Prisma Silent Field Rename
376
+
377
+ - **Severity**: `BLOCKER` (data-loss prevention)
378
+ - **Effect**: `ACCESS EXCLUSIVE` lock + permanent column data deletion
379
+
380
+ ```sql
381
+ -- ❌ Dangerous — Prisma generates DROP + ADD for any field rename
382
+ ALTER TABLE "users" DROP COLUMN "old_name";
383
+ ALTER TABLE "users" ADD COLUMN "new_name" TEXT NOT NULL;
384
+
385
+ -- ✅ Zero-Downtime Fix
386
+ ALTER TABLE "users" RENAME COLUMN "old_name" TO "new_name";
387
+ -- Or use @map("old_name") in schema.prisma to avoid DB column renames entirely
388
+ ```
389
+
390
+ ---
391
+
392
+ ### 6. `set-not-null-full-scan` — ALTER COLUMN SET NOT NULL
393
+
394
+ - **Severity**: `WARNING` (PG 12+) / `BLOCKER` (PG < 12)
395
+ - **Lock**: `ACCESS EXCLUSIVE` while scanning entire table for nulls
396
+
397
+ ```sql
398
+ -- ❌ Dangerous
399
+ ALTER TABLE users ALTER COLUMN email SET NOT NULL;
400
+
401
+ -- ✅ Zero-Downtime Fix (PG 12+ — check constraint enables fast-path)
402
+ ALTER TABLE users ADD CONSTRAINT chk_email_not_null CHECK (email IS NOT NULL) NOT VALID;
403
+ ALTER TABLE users VALIDATE CONSTRAINT chk_email_not_null;
404
+ ALTER TABLE users ALTER COLUMN email SET NOT NULL;
405
+ ```
406
+
407
+ ---
408
+
409
+ ### 7. `unbatched-dml` — Unbatched DML in Migration
410
+
411
+ - **Severity**: `BLOCKER` (no `WHERE`) / `WARNING` (with `WHERE`)
412
+ - **Effect**: `ROW EXCLUSIVE` on all touched rows, massive WAL bloat, long-running transaction
413
+
414
+ ```sql
415
+ -- ❌ Dangerous
416
+ UPDATE users SET active = true;
417
+
418
+ -- ✅ Fix: batch via background worker, or exempt a known-small table
419
+ -- ddlforge-ignore unbatched-dml
420
+ UPDATE subscription_tiers SET active = true WHERE code = 'PRO';
421
+ ```
422
+
423
+ ---
424
+
425
+ ## GitHub Actions Workflows
426
+
427
+ ### Check (lint) — Zero-Dependency CI
225
428
 
226
429
  ```yaml
227
430
  name: Migration Lock Linter
@@ -236,87 +439,205 @@ jobs:
236
439
  lint-migrations:
237
440
  runs-on: ubuntu-latest
238
441
  steps:
239
- - name: Checkout Code
240
- uses: actions/checkout@v4
442
+ - uses: actions/checkout@v4
241
443
  with:
242
444
  fetch-depth: 0
243
445
 
244
- - name: Setup Node.js
245
- uses: actions/setup-node@v4
446
+ - uses: actions/setup-node@v4
246
447
  with:
247
448
  node-version: 20
248
449
 
249
- - name: Run ddlforge Lock Linter
250
- run: |
251
- npx ddlforge --changed-only --format terminal
450
+ - name: Lint changed migration files
451
+ run: npx ddlforge --changed-only --format terminal
452
+
453
+ - name: Post migration safety report to PR
454
+ if: always()
455
+ run: npx ddlforge --changed-only --format markdown >> $GITHUB_STEP_SUMMARY
252
456
  ```
253
457
 
254
- ### PR Comment Workflow (with Markdown Formatter)
458
+ ### Apply Supervised execution in deployment pipeline
459
+
255
460
  ```yaml
256
- - name: Generate Migration Safety Report
257
- if: always()
461
+ name: Deploy Migrations
462
+
463
+ on:
464
+ push:
465
+ branches: [main]
466
+
467
+ jobs:
468
+ migrate:
469
+ runs-on: ubuntu-latest
470
+ environment: production
471
+ steps:
472
+ - uses: actions/checkout@v4
473
+
474
+ - uses: actions/setup-node@v4
475
+ with:
476
+ node-version: 20
477
+
478
+ - name: Install dependencies
479
+ run: npm ci
480
+
481
+ - name: Lint migrations before applying
482
+ run: npx ddlforge ./migrations --format terminal
483
+
484
+ - name: Apply migrations (supervised)
485
+ env:
486
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
258
487
  run: |
259
- npx ddlforge --changed-only --format markdown > report.md
260
- cat report.md >> $GITHUB_STEP_SUMMARY
488
+ npx ddlforge apply ./migrations/$(date +%Y%m%d)_*.sql \
489
+ --db "$DATABASE_URL" \
490
+ --lock-timeout 5000 \
491
+ --statement-timeout 60000 \
492
+ --max-retries 5 \
493
+ --lock-queue-threshold 1
494
+ ```
495
+
496
+ ---
497
+
498
+ ## PostgreSQL Lock Hierarchy Reference
499
+
500
+ | Lock Mode | Acquired By | Blocks Reads? | Blocks Writes? | Risk |
501
+ |:----------|:------------|:-------------:|:--------------:|:----:|
502
+ | `ACCESS SHARE` | `SELECT` | ❌ | ❌ | Low |
503
+ | `ROW SHARE` | `SELECT FOR UPDATE/SHARE` | ❌ | ❌ | Low |
504
+ | `ROW EXCLUSIVE` | `INSERT`, `UPDATE`, `DELETE` | ❌ | ❌ | Medium |
505
+ | `SHARE UPDATE EXCLUSIVE` | `CREATE INDEX CONCURRENTLY`, `VACUUM`, `ANALYZE` | ❌ | ❌ | ✅ Safe |
506
+ | `SHARE` | `CREATE INDEX` (non-concurrent) | ❌ | **YES** | 🛑 Blocker |
507
+ | `SHARE ROW EXCLUSIVE` | `ADD CONSTRAINT FK` (without `NOT VALID`) | ❌ | **YES** | 🛑 Blocker |
508
+ | `EXCLUSIVE` | `REFRESH MATERIALIZED VIEW CONCURRENTLY` | Partial | **YES** | 🛑 Blocker |
509
+ | `ACCESS EXCLUSIVE` | `ALTER TABLE`, `DROP TABLE`, `TRUNCATE` | **YES** | **YES** | 🚨 Critical |
510
+
511
+ ---
512
+
513
+ ## Inline Directives
514
+
515
+ Suppress specific rules per-statement with inline comments:
516
+
517
+ ```sql
518
+ -- ddlforge-ignore require-concurrent-index
519
+ CREATE INDEX idx_temp ON temporary_cache(key);
520
+
521
+ -- ddlforge-ignore unbatched-dml
522
+ UPDATE plan_limits SET max_seats = 100 WHERE plan_id = 'enterprise';
523
+ ```
524
+
525
+ Disable all rules for a statement:
526
+ ```sql
527
+ -- ddlforge-ignore
528
+ ALTER TABLE legacy_table ADD COLUMN migrated_at TIMESTAMPTZ;
529
+ ```
530
+
531
+ Prisma no-transaction directive (file-level):
532
+ ```sql
533
+ -- prisma:no-transaction
534
+ CREATE INDEX CONCURRENTLY "users_email_idx" ON "users"("email");
261
535
  ```
262
536
 
263
537
  ---
264
538
 
265
539
  ## Programmatic TypeScript API
266
540
 
267
- You can also use `ddlforge` programmatically within your custom deployment scripts:
541
+ ### Static analysis (check)
268
542
 
269
543
  ```typescript
270
544
  import { analyzeSql, MigrationAnalyzer, formatTerminal } from 'ddlforge';
271
545
 
272
- const sql = `
546
+ const result = analyzeSql(`
273
547
  CREATE INDEX idx_users_email ON users(email);
274
- `;
275
-
276
- const result = analyzeSql(sql, {
277
- filePath: 'migrations/001_create_index.sql',
548
+ `, {
549
+ filePath: 'migrations/001.sql',
278
550
  pgVersion: 16,
279
551
  });
280
552
 
281
553
  if (result.hasBlockers) {
282
- console.error(`Found ${result.blockersCount} blocking migration locks!`);
283
554
  console.log(formatTerminal([result]));
284
555
  process.exit(1);
285
556
  }
286
557
  ```
287
558
 
288
- ---
559
+ ### Runtime execution (apply)
289
560
 
290
- ## Inline Directives
561
+ ```typescript
562
+ import { executeMigration } from 'ddlforge/runner/executor';
563
+
564
+ const result = await executeMigration(sql, {
565
+ databaseUrl: process.env.DATABASE_URL!,
566
+ lockTimeout: '3000ms',
567
+ statementTimeout: '30000ms',
568
+ maxRetries: 5,
569
+ lockQueueThreshold: 1,
570
+ dryRun: false,
571
+ onProgress(event) {
572
+ if (event.kind === 'statement-retry') {
573
+ console.log(` ↺ retry ${event.attempt}, backoff ${Math.round(event.retryBackoffMs ?? 0)}ms`);
574
+ }
575
+ if (event.kind === 'avalanche-abort') {
576
+ console.error(` ⚠ AVALANCHE ABORT: ${event.error}`);
577
+ }
578
+ },
579
+ });
291
580
 
292
- You can suppress specific rules per statement using inline comments:
581
+ if (!result.success) {
582
+ console.error(`Migration failed: ${result.error}`);
583
+ process.exit(1);
584
+ }
585
+ ```
293
586
 
294
- ```sql
295
- -- ddlforge-ignore require-concurrent-index
296
- CREATE INDEX idx_temp ON temporary_cache(key);
587
+ ---
297
588
 
298
- -- ddlforge-ignore unbatched-dml
299
- UPDATE plan_limits SET max_seats = 100 WHERE plan_id = 'enterprise';
300
- ```
589
+ ## Architecture
301
590
 
302
- For Prisma migrations containing `CONCURRENTLY`:
303
- ```sql
304
- -- prisma:no-transaction
305
- CREATE INDEX CONCURRENTLY "users_email_idx" ON "users"("email");
591
+ ```
592
+ ddlforge/
593
+ ├── src/
594
+ │ ├── lexer/
595
+ │ │ ├── sqlTokenizer.ts # Zero-dep single-pass SQL lexer + statement splitter
596
+ │ │ └── tokens.ts # Token & Statement type definitions
597
+ │ ├── engine/
598
+ │ │ ├── analyzer.ts # Rule pipeline orchestrator
599
+ │ │ └── locks.ts # PostgreSQL lock level taxonomy
600
+ │ ├── rules/
601
+ │ │ ├── indexConcurrently.ts
602
+ │ │ ├── transactionTrap.ts
603
+ │ │ ├── addColumnNotNull.ts
604
+ │ │ ├── foreignKeyNotValid.ts
605
+ │ │ ├── prismaRenameDropAdd.ts
606
+ │ │ ├── setNotNullFullScan.ts
607
+ │ │ └── unbatchedBackfill.ts
608
+ │ ├── runner/ # apply — runtime supervisor (pg via dynamic import)
609
+ │ │ ├── backoff.ts # Full-jitter exponential backoff
610
+ │ │ ├── locksMonitor.ts # pg_locks queue-avalanche breaker
611
+ │ │ └── executor.ts # Per-statement execution loop
612
+ │ ├── reporters/
613
+ │ │ ├── terminal.ts
614
+ │ │ ├── json.ts
615
+ │ │ └── markdown.ts
616
+ │ └── cli.ts # Argument parser, check + apply dispatch
617
+ └── test/
618
+ ├── analyzer.test.ts # 37 rule & CLI tests
619
+ └── runner.test.ts # 38 backoff, monitor & executor tests
306
620
  ```
307
621
 
308
622
  ---
309
623
 
310
- ## Testing & Verification
624
+ ## Testing
311
625
 
312
- Run the native test suite:
313
626
  ```bash
314
627
  npm test
315
628
  ```
316
629
 
630
+ ```text
631
+ ℹ tests 75
632
+ ℹ suites 19
633
+ ℹ pass 75
634
+ ℹ fail 0
635
+ ℹ duration_ms ~575
636
+ ```
637
+
317
638
  Performance benchmark:
318
639
  ```text
319
- ✔ analyzes 100 migrations in under 100 milliseconds (9.2ms)
640
+ ✔ analyzes 100 migrations in under 100 milliseconds (10.2ms)
320
641
  ```
321
642
 
322
643
  ---