ddlforge 0.1.1 → 0.2.1
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 +519 -198
- package/dist/src/cli.d.ts +25 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +253 -2
- package/dist/src/cli.js.map +1 -1
- package/dist/src/engine/analyzer.d.ts.map +1 -1
- package/dist/src/engine/analyzer.js.map +1 -1
- package/dist/src/lexer/sqlTokenizer.d.ts.map +1 -1
- package/dist/src/lexer/sqlTokenizer.js.map +1 -1
- package/dist/src/reporters/json.js.map +1 -1
- package/dist/src/reporters/terminal.js.map +1 -1
- package/dist/src/runner/backoff.d.ts +53 -0
- package/dist/src/runner/backoff.d.ts.map +1 -0
- package/dist/src/runner/backoff.js +65 -0
- package/dist/src/runner/backoff.js.map +1 -0
- package/dist/src/runner/executor.d.ts +106 -0
- package/dist/src/runner/executor.d.ts.map +1 -0
- package/dist/src/runner/executor.js +444 -0
- package/dist/src/runner/executor.js.map +1 -0
- package/dist/src/runner/locksMonitor.d.ts +85 -0
- package/dist/src/runner/locksMonitor.d.ts.map +1 -0
- package/dist/src/runner/locksMonitor.js +183 -0
- package/dist/src/runner/locksMonitor.js.map +1 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,227 +1,430 @@
|
|
|
1
1
|
# ddlforge
|
|
2
2
|
|
|
3
|
-
> **Ultra-fast
|
|
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
|
[](https://nodejs.org)
|
|
7
7
|
[](https://www.typescriptlang.org)
|
|
8
|
-
[](package.json)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
59
|
+
# Global
|
|
60
|
+
npm install -g ddlforge
|
|
183
61
|
|
|
184
|
-
|
|
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
|
|
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
|
|
200
|
-
--changed-only
|
|
201
|
-
--version, -v Print
|
|
202
|
-
--help, -h Print
|
|
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
|
-
#
|
|
89
|
+
# Scan all Prisma migrations
|
|
208
90
|
npx ddlforge ./prisma/migrations
|
|
209
91
|
|
|
210
|
-
#
|
|
92
|
+
# Scan only files touched in this branch/PR
|
|
211
93
|
npx ddlforge --changed-only
|
|
212
94
|
|
|
213
|
-
#
|
|
95
|
+
# Drizzle migrations, targeting Postgres 14, JSON output for CI
|
|
214
96
|
npx ddlforge ./drizzle --pg 14 --format json
|
|
215
97
|
|
|
216
|
-
#
|
|
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
|
-
##
|
|
305
|
+
## The 7 Core Lint Rules & Zero-Downtime Recipes
|
|
223
306
|
|
|
224
|
-
|
|
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
|
-
-
|
|
240
|
-
uses: actions/checkout@v4
|
|
442
|
+
- uses: actions/checkout@v4
|
|
241
443
|
with:
|
|
242
444
|
fetch-depth: 0
|
|
243
445
|
|
|
244
|
-
-
|
|
245
|
-
uses: actions/setup-node@v4
|
|
446
|
+
- uses: actions/setup-node@v4
|
|
246
447
|
with:
|
|
247
448
|
node-version: 20
|
|
248
449
|
|
|
249
|
-
- name:
|
|
250
|
-
run:
|
|
251
|
-
|
|
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
|
-
###
|
|
458
|
+
### Apply — Supervised execution in deployment pipeline
|
|
459
|
+
|
|
255
460
|
```yaml
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
541
|
+
### Static analysis (check)
|
|
268
542
|
|
|
269
543
|
```typescript
|
|
270
544
|
import { analyzeSql, MigrationAnalyzer, formatTerminal } from 'ddlforge';
|
|
271
545
|
|
|
272
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
581
|
+
if (!result.success) {
|
|
582
|
+
console.error(`Migration failed: ${result.error}`);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
```
|
|
293
586
|
|
|
294
|
-
|
|
295
|
-
-- ddlforge-ignore require-concurrent-index
|
|
296
|
-
CREATE INDEX idx_temp ON temporary_cache(key);
|
|
587
|
+
---
|
|
297
588
|
|
|
298
|
-
|
|
299
|
-
UPDATE plan_limits SET max_seats = 100 WHERE plan_id = 'enterprise';
|
|
300
|
-
```
|
|
589
|
+
## Architecture
|
|
301
590
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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 (
|
|
640
|
+
✔ analyzes 100 migrations in under 100 milliseconds (10.2ms)
|
|
320
641
|
```
|
|
321
642
|
|
|
322
643
|
---
|