Skip to content

Commit 82b790a

Browse files
authored
feat: enforce descriptive migration names via wrapper script and lint check (#427)
Implements #424. ### Changes - **`scripts/generate-migrations.ts`** — Wrapper around `drizzle-kit generate` that: - Generate a name in `snake_case` - Generates migrations for both SQLite and Postgres configs - **`scripts/lint-migrations.ts`** — Validates all existing migration filenames: - Grandfathers in existing random superhero names - Rejects any new migration that doesn't start with a descriptive verb prefix (`add_`, `create_`, `drop_`, `rename_`, `update_`, `fix_`, `remove_`, `convert_`, `jsonb_`, `auto_`, `test_`, `release_`, etc.) - **`package.json` (root & backend)** — Added scripts: - `generate-migrations` - `lint:migrations` - **`AGENTS.md`** — Added "Migration Naming Convention" section requiring the wrapper script - **`.agents/skills/db-schema-migrations/SKILL.md`** — Updated workflow and checklist to mandate the wrapper - **`CONTRIBUTING.md`** — Updated local validation instructions - **`packages/backend/test/vitest.global-setup.ts`** — Updated test setup to use the wrapper with `--name test_setup` ### Note on workflow files The following workflow updates were also prepared but could not be pushed automatically due to GitHub App permissions (workflows scope required). They should be applied manually: 1. **`.github/workflows/generate-migrations.yml`** — Replace the two `bunx drizzle-kit generate` steps with: ```yaml - name: Generate migrations run: bun run generate-migrations --name auto_schema_update ``` 2. **`.github/workflows/release.yml`** — In the "Check for pending migrations" step, replace: ```bash bunx drizzle-kit generate --config drizzle.config.sqlite.ts 2>&1 | tee /tmp/sqlite-migrate.log bunx drizzle-kit generate --config drizzle.config.postgres.ts 2>&1 | tee /tmp/pg-migrate.log ``` with: ```bash bun run generate-migrations --name release_check 2>&1 | tee /tmp/migrate.log ``` 3. **`.github/workflows/check-no-migrations-in-pr.yml`** — Update the error message to reference `bun run generate-migrations --name <descriptive-name>` instead of `bunx drizzle-kit generate`. > **Note:** Biome does not have a built-in file-naming lint rule. The `lint:migrations` script serves as the enforcement mechanism and can be run in CI or locally.
2 parents 74606a9 + 5ad7df5 commit 82b790a

12 files changed

Lines changed: 478 additions & 22 deletions

File tree

.agents/skills/db-schema-migrations/SKILL.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,27 @@ Always edit the correct dialect subdirectory. When adding a new table, **update
2929
### The Only Correct Workflow
3030

3131
1. Edit schema `.ts` files in `postgres/` or `sqlite/`.
32-
2. Validate locally (optional): `bunx drizzle-kit generate` — verify the SQL looks right, then **discard the output** (do not commit).
32+
2. Validate locally (optional): `bun run generate-migrations` — verify the SQL looks right, then **discard the output** (do not commit). **Never run `drizzle-kit generate` directly.**
3333
3. Commit only the schema `.ts` changes. The pre-commit hook blocks migration artifacts.
3434
4. After the PR merges to `main`, CI auto-generates and commits the migrations.
3535

36+
### Migration Naming
37+
38+
Migrations **must** have a descriptive, semantic name. The wrapper script enforces this:
39+
40+
```bash
41+
bun run generate-migrations # auto-derives name from branch
42+
bun run generate-migrations --name add_quota_checkers # explicit name
43+
```
44+
45+
If `--name` is omitted, the script derives a name from the current git branch (e.g., `pi/issue-424-1779050379120``auto_issue_424`). On the `main` branch, `--name` is required.
46+
47+
This produces files like `0044_add_quota_checkers.sql` or `0044_auto_issue_424.sql` instead of `0044_rare_skullbuster.sql`.
48+
49+
- Use `snake_case` starting with a verb: `add_`, `create_`, `drop_`, `rename_`, `update_`, `fix_`, `remove_`, `convert_`, `jsonb_`, etc.
50+
- Auto-derived names use the `auto_` prefix.
51+
- Do **not** use random or meaningless names. The `lint:migrations` script rejects them.
52+
3653
**Bypasses (maintainers only):**
3754
- Pre-commit hook: `ALLOW_MIGRATIONS=1 git commit`
3855
- PR check: add the `migrations-ok` label to the PR
@@ -42,7 +59,7 @@ Always edit the correct dialect subdirectory. When adding a new table, **update
4259
- [ ] Create the table definition in the appropriate dialect directory.
4360
- [ ] Export the new table from `drizzle/schema/index.ts`.
4461
- [ ] If Postgres, add any new enum values to `postgres/enums.ts` (e.g. `quotaCheckerTypeEnum`).
45-
- [ ] Validate with `bunx drizzle-kit generate` locally if desired — discard output.
62+
- [ ] Validate with `bun run generate-migrations --name <descriptive-name>` locally if desired — discard output.
4663
- [ ] Commit only `.ts` schema files.
4764

4865
## Type Definitions

.github/workflows/check-no-migrations-in-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ jobs:
1919
echo "Community PRs should only modify schema .ts files in drizzle/schema/."
2020
echo "Migrations are auto-generated after merge by CI."
2121
echo ""
22-
echo "To test locally: run 'bunx drizzle-kit generate' but do not commit the output."
22+
echo "To test locally: run 'bun run generate-migrations' but do not commit the output."
2323
echo "If this is a maintainer PR that intentionally includes migrations, add the 'migrations-ok' label."
2424
exit 1

.github/workflows/generate-migrations.yml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,8 @@ jobs:
3333
bun install --frozen-lockfile
3434
cd packages/backend && bun install --frozen-lockfile
3535
36-
- name: Generate SQLite migrations
37-
working-directory: packages/backend
38-
run: bunx drizzle-kit generate --config drizzle.config.sqlite.ts
39-
40-
- name: Generate PostgreSQL migrations
41-
working-directory: packages/backend
42-
run: bunx drizzle-kit generate --config drizzle.config.postgres.ts
36+
- name: Generate migrations
37+
run: bun run generate-migrations --name auto_schema_update
4338

4439
- name: Check for new migrations
4540
id: check

.github/workflows/release.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,10 @@ jobs:
8080
run: cd packages/backend && bun run test
8181

8282
- name: Check for pending migrations
83-
working-directory: packages/backend
8483
run: |
85-
bunx drizzle-kit generate --config drizzle.config.sqlite.ts 2>&1 | tee /tmp/sqlite-migrate.log
86-
bunx drizzle-kit generate --config drizzle.config.postgres.ts 2>&1 | tee /tmp/pg-migrate.log
84+
bun run generate-migrations --name release_check 2>&1 | tee /tmp/migrate.log
8785
88-
if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard drizzle/migrations drizzle/migrations_pg)" ]; then
86+
if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard packages/backend/drizzle/migrations packages/backend/drizzle/migrations_pg)" ]; then
8987
echo "✅ No pending migrations"
9088
else
9189
echo "::error::Pending migrations detected! Wait for generate-migrations workflow."

AGENTS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@
2626

2727
---
2828

29+
## Migration Naming Convention
30+
31+
All database migrations **must** be generated using the wrapper script to ensure descriptive naming:
32+
33+
```bash
34+
bun run generate-migrations
35+
```
36+
37+
**Do not** run `drizzle-kit generate` directly. The wrapper auto-derives a descriptive name from the git branch (e.g., `pi/issue-424-1779050379120``auto_issue_424`). You can also specify a name explicitly:
38+
39+
```bash
40+
bun run generate-migrations --name add_quota_checkers
41+
```
42+
43+
On the `main` branch, `--name` is required (auto-naming is not available). Random names like `rare_skullbuster` are rejected by CI.
44+
45+
The `lint:migrations` script checks all migration files for compliance. Run it locally with:
46+
47+
```bash
48+
bun run lint:migrations
49+
```
50+
51+
---
52+
2953
## Efficiency & Batching
3054

3155
Think ahead and **perform multiple reads, edits, and commands in the same response** whenever they don't depend on each other's results. Minimize round-trips by:

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Plexus uses Drizzle ORM with dual-dialect support (SQLite and PostgreSQL). Schem
2121

2222
1. Edit the schema `.ts` files in the appropriate dialect directories
2323
2. If adding a **new table**, update the index exports in `packages/backend/drizzle/schema/index.ts`
24-
3. **(Optional)** Validate locally by running `bunx drizzle-kit generate` from `packages/backend/` -- this confirms your schema produces valid migration SQL
24+
3. **(Optional)** Validate locally by running `bun run generate-migrations` from `packages/backend/` -- this confirms your schema produces valid migration SQL. The name is auto-derived from your branch; you can also specify `--name <descriptive-name>` explicitly.
2525
4. **Do NOT commit migration artifacts** (`.sql` files, `meta/_journal.json`, snapshot files). Only commit the schema `.ts` files.
2626
5. Open your PR. Migrations are auto-generated by CI after your PR merges to `main`.
2727

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,19 @@
3333
"prep-dev:reset": "bun run scripts/prep-dev.ts --reset",
3434
"prep-dev:save": "bun run scripts/prep-dev.ts --save",
3535
"prep-dev:live": "bun run scripts/prep-dev.ts --live",
36+
"postinstall": "bun run scripts/wrap-drizzle-kit.ts",
3637
"prepare": "if [ -z \"$CI\" ]; then lefthook install; fi",
3738
"format": "bunx biome format --write .",
3839
"format:check": "bunx biome format .",
3940
"lint": "bunx biome lint --write .",
4041
"lint:check": "bunx biome lint .",
42+
"lint:migrations": "bun run scripts/lint-migrations.ts",
4143
"lint:openapi": "bunx @redocly/cli lint docs/openapi/openapi.yaml",
4244
"bundle:openapi": "bunx @redocly/cli bundle docs/openapi/openapi.yaml -o docs/openapi.yaml",
4345
"preview:openapi": "bunx @redocly/cli preview-docs docs/openapi/openapi.yaml",
4446
"sync:openapi": "bun run scripts/sync-openapi.ts",
45-
"sync:openapi:write": "bun run scripts/sync-openapi.ts --write"
47+
"sync:openapi:write": "bun run scripts/sync-openapi.ts --write",
48+
"generate-migrations": "bun run scripts/generate-migrations.ts"
4649
},
4750
"type": "module",
4851
"workspaces": [

packages/backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"test:force-all": "bunx --bun vitest run --config vitest.config.ts",
1111
"test:watch": "bunx --bun vitest --config vitest.config.ts",
1212
"rekey": "bun run src/cli/rekey.ts",
13-
"backup": "bun run src/cli/backup.ts"
13+
"backup": "bun run src/cli/backup.ts",
14+
"generate-migrations": "cd ../.. && bun run scripts/generate-migrations.ts",
15+
"lint:migrations": "cd ../.. && bun run scripts/lint-migrations.ts"
1416
},
1517
"dependencies": {
1618
"@earendil-works/pi-ai": "0.74.0",

packages/backend/test/vitest.global-setup.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,7 @@ export default async function globalSetup() {
5050
process.env.LOG_LEVEL = 'error';
5151

5252
try {
53-
execSync('bunx drizzle-kit generate --config=drizzle.config.sqlite.ts', {
54-
cwd: backendRoot,
55-
stdio: 'pipe',
56-
});
57-
execSync('bunx drizzle-kit generate --config=drizzle.config.postgres.ts', {
53+
execSync('bun run generate-migrations --name test_setup', {
5854
cwd: backendRoot,
5955
stdio: 'pipe',
6056
});

scripts/generate-migrations.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bun
2+
import { $ } from 'bun';
3+
import { parseArgs } from 'util';
4+
import { execSync } from 'node:child_process';
5+
6+
const VALID_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
7+
8+
function showUsage() {
9+
console.error('Usage: bun run generate-migrations [--name <descriptive-name>]');
10+
console.error('');
11+
console.error('If --name is omitted on a non-main branch, the name is derived from');
12+
console.error('the branch name automatically. On main, --name is required.');
13+
console.error('');
14+
console.error('Examples:');
15+
console.error(
16+
' bun run generate-migrations # auto-derives name from branch'
17+
);
18+
console.error(' bun run generate-migrations --name add_user_preferences');
19+
process.exit(1);
20+
}
21+
22+
const { values } = parseArgs({
23+
args: process.argv.slice(2),
24+
options: {
25+
name: { type: 'string' },
26+
},
27+
strict: false,
28+
allowPositionals: true,
29+
});
30+
31+
let name = values.name as string | undefined;
32+
33+
if (!name) {
34+
// Determine current branch
35+
let branch: string;
36+
try {
37+
branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
38+
} catch {
39+
console.error('Error: Could not determine current git branch.');
40+
console.error('Please provide --name explicitly.');
41+
process.exit(1);
42+
}
43+
44+
if (branch === 'main' || branch === 'master') {
45+
console.error('Error: --name is required when running on the main/master branch.');
46+
console.error('Automatic naming is only available on feature branches.');
47+
showUsage();
48+
}
49+
50+
if (branch === 'HEAD') {
51+
console.error('Error: Detached HEAD detected. Please provide --name explicitly.');
52+
showUsage();
53+
}
54+
55+
name = deriveNameFromBranch(branch);
56+
console.log(`No --name provided; derived migration name from branch: ${name}`);
57+
}
58+
59+
if (!VALID_NAME_REGEX.test(name)) {
60+
console.error(`Error: Migration name "${name}" is invalid.`);
61+
console.error('Names must be snake_case: lowercase letters, numbers, and underscores only.');
62+
console.error('They must start with a letter.');
63+
process.exit(1);
64+
}
65+
66+
console.log(`Generating SQLite migrations with name: ${name}`);
67+
await $`cd packages/backend && node node_modules/drizzle-kit/bin.cjs generate --name ${name} --config drizzle.config.sqlite.ts`;
68+
69+
console.log(`Generating Postgres migrations with name: ${name}`);
70+
await $`cd packages/backend && node node_modules/drizzle-kit/bin.cjs generate --name ${name} --config drizzle.config.postgres.ts`;
71+
72+
console.log('Done!');
73+
74+
/**
75+
* Derive a descriptive migration name from a git branch name.
76+
*
77+
* Examples:
78+
* pi/issue-424-1779050379120 → auto_issue_424
79+
* feat/quota-checkers → auto_quota_checkers
80+
* fix/user-index → auto_user_index
81+
* 424-migration-naming → auto_424_migration_naming
82+
*/
83+
function deriveNameFromBranch(branch: string): string {
84+
// Strip common VCS/automation prefixes (pi/, feat/, fix/, feature/, bugfix/, etc.)
85+
let derived = branch.replace(
86+
/^(pi|feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|ci)\//,
87+
''
88+
);
89+
90+
// Strip trailing long numeric hashes (e.g., 1779050379120) likely added by automation
91+
derived = derived.replace(/[-_]?\d{10,}$/, '');
92+
93+
// Replace non-alphanumeric characters with underscores
94+
derived = derived.replace(/[^a-zA-Z0-9]+/g, '_');
95+
96+
// Collapse multiple consecutive underscores
97+
derived = derived.replace(/_+/g, '_');
98+
99+
// Strip leading/trailing underscores
100+
derived = derived.replace(/^_+|_+$/g, '');
101+
102+
// Lowercase
103+
derived = derived.toLowerCase();
104+
105+
// Prefix with auto_ so lint-migrations recognizes it
106+
derived = `auto_${derived}`;
107+
108+
// Safety check: if we ended up with just "auto_" or empty, fall back
109+
if (derived === 'auto_' || derived.length <= 5) {
110+
console.error(`Error: Could not derive a meaningful name from branch "${branch}".`);
111+
console.error('Please provide --name explicitly.');
112+
process.exit(1);
113+
}
114+
115+
return derived;
116+
}

0 commit comments

Comments
 (0)