Skip to content

Commit ce6c5e0

Browse files
committed
Add last check for commutative
1 parent 0961126 commit ce6c5e0

File tree

18 files changed

+922
-230
lines changed

18 files changed

+922
-230
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Drizzle ORM beta.16 updates
2+
3+
We've fixed a regression in migrations introduced in `beta.13` that persisted through `beta.15`, and used the opportunity to significantly improve the entire migration infrastructure going forward.
4+
5+
After receiving github issue, we focused on not just fixing the symptoms but rethinking how migrations are tracked, validated, and applied. As we always do with Drizzle we went deeper and redesigned the underlying architecture so it can be stable for all future changes, additions, and edge cases
6+
7+
We went through how we check migrations, how we store them, and how we help developers keep their migration flow reliable. After several iterations of rewriting the `migrate` function in both ORM and Kit, adding migration table versioning, and building a new commutativity check system: here's what changed and why.
8+
9+
## What happened in beta.12–beta.15 and what caused the issue
10+
11+
On `latest` (pre-beta) versions, the migrations folder used a journal-based structure. A `meta/_journal.json` file stored a timestamp for each migration in **milliseconds**. That same millis value was stored in the database's `created_at` column and used to determine which migrations had been applied. Because the journal enforced ordering, we could simply fetch the last applied migration from the database and apply everything after it.
12+
13+
With the new v3 folder structure (introduced in beta), each migration lives in its own folder named `<YYYYMMDDHHmmss>_<name>`. This format only has **second** precision. The new structure also intentionally allows out-of-order migrations (as it should for team workflows), so we switched to reading *all* migrations from the database and comparing against all local migrations.
14+
15+
The problem appeared after `drizzle-kit up` converted the old journal structure to v3 folders. It mapped millis timestamps to the `YYYYMMDDHHmmss_name` format, stripping away the millisecond precision. So when the new migration checker compared what was in the database (millis) to what was on disk (seconds), nothing matched causing migrations to be re-applied on every run.
16+
17+
**This only affected beta users who upgraded from the journal-based format. Users on `latest` were not impacted.**
18+
19+
## How we fixed it
20+
21+
### 1. Versioned migration table
22+
23+
We introduced an internal version number for the migrations table schema. On `beta.16`, the table will automatically upgrade itself from `version_0` to `version_1` the first time you run `migrate`.
24+
25+
**Version 0** (old schema):
26+
| Column | Type |
27+
|---|---|
28+
| `id` | serial |
29+
| `hash` | text |
30+
| `created_at` | bigint (millis) |
31+
32+
**Version 1** (new schema):
33+
| Column | Type | |
34+
|---|---| ---|
35+
| `id` | serial |
36+
| `hash` | text |
37+
| `created_at` | bigint | (legacy) |
38+
| `name` | text |
39+
| `applied_at` | timestamp |
40+
| `version` | integer |
41+
42+
The `name` column stores the full folder name of the migration (e.g. `20250220153045_brave_wolverine`). The `applied_at` column records when the migration was actually executed. For pre-existing migrations that were backfilled during upgrade, `applied_at` is set to `NULL` to distinguish them from newly applied ones.
43+
44+
This versioning system means we can add more fields in the future (like migration state for rollbacks) without any disruption for developers
45+
46+
### 2. Matching by folder name instead of timestamps
47+
48+
All migrations are now checked against the **full folder name**: the combination of a 14-digit UTC timestamp and a name suffix (random or custom). Even if two migrations are generated within the same second, the name suffix guarantees uniqueness.
49+
50+
The detection logic is simple: build a set of `name` values from the database, filter local migrations whose name isn't in that set, and apply those. No more timestamp arithmetic, no more precision mismatches.
51+
52+
### 3. Automatic upgrade with smart backfilling
53+
54+
When `beta.16` detects a `version_0` table, it adds the new columns and backfills the `name` for each existing row using a multi-step matching strategy:
55+
56+
1. **Millis match**: truncate the stored millis to seconds and match against local migration folder timestamps
57+
2. **Hash tiebreaker**: if multiple migrations share the same second, use the SQL hash to pick the right one
58+
3. **Hash-only fallback**: if millis matching fails entirely, fall back to matching by hash alone
59+
60+
This means the upgrade handles all edge cases: normal single-developer projects, teams with closely-timed migrations, and even cases where the old journal data doesn't perfectly align with the new folder names.
61+
62+
### 4. New commutativity checks (`drizzle-kit check`)
63+
64+
When working in teams, multiple developers may generate migrations from the same base schema on different branches. These migrations can conflict in non-obvious ways, for example, two branches both adding a column to the same table, or one renaming a table that another is altering.
65+
66+
We built a new `drizzle-kit check` command that detects these non-commutative migrations. It works by:
67+
68+
- Building a DAG (directed acyclic graph) from snapshot `prevIds` to understand the branch structure
69+
- Finding fork points where branches diverged
70+
- Computing the DDL diff from the parent snapshot to each branch leaf
71+
- Checking for conflicting operations using a comprehensive footprint map that knows which DDL statement types can interfere with each other
72+
73+
This is available for PostgreSQL and MySQL today. If conflicts are found, the report tells you exactly which migrations on which branches are incompatible and what statements are conflicting.
74+
75+
The new folder structure also changed snapshot metadata from `prevId: string` to `prevIds: string[]`, enabling proper DAG representation of migration history across branches.
76+
77+
## Upgrading to beta.16
78+
79+
The upgrade is automatic. When you run `migrate` for the first time on `beta.16`:
80+
81+
1. Drizzle detects your migration table version
82+
2. If it's `version_0`, it adds the new columns and backfills `name` from your local migration files
83+
3. All future migrations are tracked by name
84+
4. No manual steps required
85+
86+
If you're also upgrading your migration **folder structure** from the old journal format, run `drizzle-kit up` first to convert to the v3 folder layout, then `migrate` will handle the rest.
87+
88+
## Upgrading to beta.16 is not fixing my problem
89+
90+
If you're still hitting migration issues after upgrading, please reach out to us directly - drop a message in [Discord](https://discord.gg/7NKUQWP9c8) or open a [GitHub issue](https://github.com/drizzle-team/drizzle-orm/issues/new/choose) with your migration folder structure and the contents of your migrations table. We'll help you sort it out.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Drizzle ORM beta.16 updates
2+
3+
We've fixed a regression in migrations introduced in `beta.13` that persisted through `beta.15`, and used the opportunity to significantly improve the entire migration infrastructure going forward.
4+
5+
After receiving github issue, we focused on not just fixing the symptoms but rethinking how migrations are tracked, validated, and applied. As we always do with Drizzle we went deeper and redesigned the underlying architecture so it can be stable for all future changes, additions, and edge cases
6+
7+
We went through how we check migrations, how we store them, and how we help developers keep their migration flow reliable. After several iterations of rewriting the `migrate` function in both ORM and Kit, adding migration table versioning, and building a new commutativity check system: here's what changed and why.
8+
9+
## What happened in beta.12–beta.15 and what caused the issue
10+
11+
On `latest` (pre-beta) versions, the migrations folder used a journal-based structure. A `meta/_journal.json` file stored a timestamp for each migration in **milliseconds**. That same millis value was stored in the database's `created_at` column and used to determine which migrations had been applied. Because the journal enforced ordering, we could simply fetch the last applied migration from the database and apply everything after it.
12+
13+
With the new v3 folder structure (introduced in beta), each migration lives in its own folder named `<YYYYMMDDHHmmss>_<name>`. This format only has **second** precision. The new structure also intentionally allows out-of-order migrations (as it should for team workflows), so we switched to reading *all* migrations from the database and comparing against all local migrations.
14+
15+
The problem appeared after `drizzle-kit up` converted the old journal structure to v3 folders. It mapped millis timestamps to the `YYYYMMDDHHmmss_name` format, stripping away the millisecond precision. So when the new migration checker compared what was in the database (millis) to what was on disk (seconds), nothing matched causing migrations to be re-applied on every run.
16+
17+
**This only affected beta users who upgraded from the journal-based format. Users on `latest` were not impacted.**
18+
19+
## How we fixed it
20+
21+
### 1. Versioned migration table
22+
23+
We introduced an internal version number for the migrations table schema. On `beta.16`, the table will automatically upgrade itself from `version_0` to `version_1` the first time you run `migrate`.
24+
25+
**Version 0** (old schema):
26+
| Column | Type |
27+
|---|---|
28+
| `id` | serial |
29+
| `hash` | text |
30+
| `created_at` | bigint (millis) |
31+
32+
**Version 1** (new schema):
33+
| Column | Type | |
34+
|---|---| ---|
35+
| `id` | serial |
36+
| `hash` | text |
37+
| `created_at` | bigint | (legacy) |
38+
| `name` | text |
39+
| `applied_at` | timestamp |
40+
| `version` | integer |
41+
42+
The `name` column stores the full folder name of the migration (e.g. `20250220153045_brave_wolverine`). The `applied_at` column records when the migration was actually executed. For pre-existing migrations that were backfilled during upgrade, `applied_at` is set to `NULL` to distinguish them from newly applied ones.
43+
44+
This versioning system means we can add more fields in the future (like migration state for rollbacks) without any disruption for developers
45+
46+
### 2. Matching by folder name instead of timestamps
47+
48+
All migrations are now checked against the **full folder name**: the combination of a 14-digit UTC timestamp and a name suffix (random or custom). Even if two migrations are generated within the same second, the name suffix guarantees uniqueness.
49+
50+
The detection logic is simple: build a set of `name` values from the database, filter local migrations whose name isn't in that set, and apply those. No more timestamp arithmetic, no more precision mismatches.
51+
52+
### 3. Automatic upgrade with smart backfilling
53+
54+
When `beta.16` detects a `version_0` table, it adds the new columns and backfills the `name` for each existing row using a multi-step matching strategy:
55+
56+
1. **Millis match**: truncate the stored millis to seconds and match against local migration folder timestamps
57+
2. **Hash tiebreaker**: if multiple migrations share the same second, use the SQL hash to pick the right one
58+
3. **Hash-only fallback**: if millis matching fails entirely, fall back to matching by hash alone
59+
60+
This means the upgrade handles all edge cases: normal single-developer projects, teams with closely-timed migrations, and even cases where the old journal data doesn't perfectly align with the new folder names.
61+
62+
### 4. New commutativity checks (`drizzle-kit check`)
63+
64+
When working in teams, multiple developers may generate migrations from the same base schema on different branches. These migrations can conflict in non-obvious ways, for example, two branches both adding a column to the same table, or one renaming a table that another is altering.
65+
66+
We built a new `drizzle-kit check` command that detects these non-commutative migrations. It works by:
67+
68+
- Building a DAG (directed acyclic graph) from snapshot `prevIds` to understand the branch structure
69+
- Finding fork points where branches diverged
70+
- Computing the DDL diff from the parent snapshot to each branch leaf
71+
- Checking for conflicting operations using a comprehensive footprint map that knows which DDL statement types can interfere with each other
72+
73+
This is available for PostgreSQL and MySQL today. If conflicts are found, the report tells you exactly which migrations on which branches are incompatible and what statements are conflicting.
74+
75+
The new folder structure also changed snapshot metadata from `prevId: string` to `prevIds: string[]`, enabling proper DAG representation of migration history across branches.
76+
77+
## Upgrading to beta.16
78+
79+
The upgrade is automatic. When you run `migrate` for the first time on `beta.16`:
80+
81+
1. Drizzle detects your migration table version
82+
2. If it's `version_0`, it adds the new columns and backfills `name` from your local migration files
83+
3. All future migrations are tracked by name
84+
4. No manual steps required
85+
86+
If you're also upgrading your migration **folder structure** from the old journal format, run `drizzle-kit up` first to convert to the v3 folder layout, then `migrate` will handle the rest.
87+
88+
## Upgrading to beta.16 is not fixing my problem
89+
90+
If you're still hitting migration issues after upgrading, please reach out to us directly - drop a message in [Discord](https://discord.gg/7NKUQWP9c8) or open a [GitHub issue](https://github.com/drizzle-team/drizzle-orm/issues/new/choose) with your migration folder structure and the contents of your migrations table. We'll help you sort it out.

drizzle-kit/src/cli/commands/check.ts

Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,154 @@
1+
import chalk from 'chalk';
12
import { readFileSync } from 'fs';
3+
import { render } from 'hanji';
4+
import type { MigrationNode, NonCommutativityReport, UnifiedBranchConflict } from 'src/utils/commutativity';
25
import { detectNonCommutative } from 'src/utils/commutativity';
36
import type { Dialect } from '../../utils/schemaValidator';
47
import { prepareOutFolder, validatorForDialect } from '../../utils/utils-node';
58
import { info } from '../views';
69

7-
export const checkHandler = async (out: string, dialect: Dialect) => {
10+
// ─── Shared helpers ──────────────────────────────────────────────────────────
11+
12+
const countLeafs = (conflicts: UnifiedBranchConflict[]): number => {
13+
const ids = new Set<string>();
14+
for (const c of conflicts) {
15+
const lastA = c.branchA.chain[c.branchA.chain.length - 1];
16+
const lastB = c.branchB.chain[c.branchB.chain.length - 1];
17+
if (lastA) ids.add(lastA.id);
18+
if (lastB) ids.add(lastB.id);
19+
}
20+
return ids.size;
21+
};
22+
23+
const headerBadge = (conflicts: UnifiedBranchConflict[]): string => {
24+
const leafCount = countLeafs(conflicts);
25+
return (
26+
`${chalk.white.bgRed(' Non-commutative migrations detected ')} `
27+
+ `Found ${chalk.bold(String(conflicts.length))} conflict${conflicts.length === 1 ? '' : 's'} `
28+
+ `across ${chalk.bold(String(leafCount))} migration${leafCount === 1 ? '' : 's'}`
29+
);
30+
};
31+
32+
const footer = (): string[] => {
33+
return [
34+
'',
35+
chalk.gray('Please refer to our guide on how to resolve such conflicts:'),
36+
chalk.bold.underline.blue('https://orm.drizzle.team/docs/migrations'),
37+
'',
38+
];
39+
};
40+
41+
export const renderSuccess = (): string => {
42+
return `\n[${chalk.green('✓')}] All migrations are commutative. No conflicts detected.\n`;
43+
};
44+
45+
export const renderReportDirectory = (
46+
report: NonCommutativityReport,
47+
): string => {
48+
const { conflicts } = report;
49+
const lines: string[] = ['', headerBadge(conflicts), ''];
50+
51+
// Group conflicts by parentId so we can render N branches per parent
52+
const byParent = new Map<string, UnifiedBranchConflict[]>();
53+
for (const c of conflicts) {
54+
const key = c.parentId;
55+
if (!byParent.has(key)) byParent.set(key, []);
56+
byParent.get(key)!.push(c);
57+
}
58+
59+
const parentEntries = [...byParent.entries()];
60+
61+
for (let p = 0; p < parentEntries.length; p++) {
62+
const [parentId, parentConflicts] = parentEntries[p];
63+
const parentLabel = parentConflicts[0].parentPath ?? parentId;
64+
65+
// Collect all unique branches (dedupe by leaf id)
66+
const branches: { chain: MigrationNode[]; descriptions: string[] }[] = [];
67+
const seenLeafs = new Map<string, number>(); // leaf id -> index in branches
68+
69+
for (const c of parentConflicts) {
70+
for (const branch of [c.branchA, c.branchB]) {
71+
const leafId = branch.chain[branch.chain.length - 1]?.id ?? '';
72+
if (seenLeafs.has(leafId)) {
73+
const idx = seenLeafs.get(leafId)!;
74+
if (
75+
!branches[idx].descriptions.includes(branch.statementDescription)
76+
) {
77+
branches[idx].descriptions.push(branch.statementDescription);
78+
}
79+
} else {
80+
seenLeafs.set(leafId, branches.length);
81+
branches.push({
82+
chain: branch.chain,
83+
descriptions: [branch.statementDescription],
84+
});
85+
}
86+
}
87+
}
88+
89+
// Parent node (root of this group)
90+
lines.push(` ${chalk.white(parentLabel)}`);
91+
92+
for (let b = 0; b < branches.length; b++) {
93+
const branch = branches[b];
94+
const isLastBranch = b === branches.length - 1;
95+
const branchPrefix = isLastBranch ? ' ' : '│ ';
96+
97+
// Render each migration in the chain on its own line
98+
for (let m = 0; m < branch.chain.length; m++) {
99+
const node = branch.chain[m];
100+
const isFirstInChain = m === 0;
101+
const isLeaf = m === branch.chain.length - 1;
102+
103+
const label = isLeaf
104+
? chalk.red.bold(node.path)
105+
: chalk.green(node.path);
106+
107+
if (isFirstInChain) {
108+
// First migration gets the branch connector (├── or └──)
109+
const connector = isLastBranch ? '└──' : '├──';
110+
lines.push(` ${chalk.gray(connector)} ${label}`);
111+
} else {
112+
// Subsequent migrations: plain text under the branch prefix, no connector
113+
lines.push(` ${chalk.gray(branchPrefix)}${label}`);
114+
}
115+
}
116+
117+
// Conflict descriptions beneath the chain with single-dash connectors
118+
for (let d = 0; d < branch.descriptions.length; d++) {
119+
const isLastDesc = d === branch.descriptions.length - 1;
120+
const descConnector = isLastDesc ? '└─' : '├─';
121+
lines.push(
122+
` ${chalk.gray(branchPrefix)}${chalk.gray(descConnector)} ${chalk.yellow('⚠')} ${
123+
chalk.yellow(
124+
branch.descriptions[d],
125+
)
126+
}`,
127+
);
128+
}
129+
}
130+
131+
// Add spacing between parent groups
132+
if (p < parentEntries.length - 1) lines.push('');
133+
}
134+
135+
lines.push(...footer());
136+
return lines.join('\n');
137+
};
138+
139+
// ─── Handler ─────────────────────────────────────────────────────────────────
140+
141+
export const checkHandler = async (
142+
out: string,
143+
dialect: Dialect,
144+
ignoreConflicts?: boolean,
145+
) => {
8146
const { snapshots } = prepareOutFolder(out);
9147
const validator = validatorForDialect(dialect);
10148

11-
// const snapshotsData: PostgresSnapshot[] = [];
12-
13149
for (const snapshot of snapshots) {
14150
const raw = JSON.parse(readFileSync(`./${snapshot}`).toString());
15151

16-
// snapshotsData.push(raw);
17-
18152
const res = validator(raw);
19153
if (res.status === 'unsupported') {
20154
console.log(
@@ -25,28 +159,31 @@ export const checkHandler = async (out: string, dialect: Dialect) => {
25159
process.exit(0);
26160
}
27161
if (res.status === 'malformed') {
28-
// more explanation
29162
console.log(`${snapshot} data is malformed`);
30163
process.exit(1);
31164
}
32165

33166
if (res.status === 'nonLatest') {
34-
console.log(`${snapshot} is not of the latest version, please run "drizzle-kit up"`);
167+
console.log(
168+
`${snapshot} is not of the latest version, please run "drizzle-kit up"`,
169+
);
35170
process.exit(1);
36171
}
37172
}
38173

174+
if (ignoreConflicts) {
175+
return;
176+
}
177+
39178
try {
40179
const response = await detectNonCommutative(snapshots, dialect);
41-
if (response!.conflicts.length > 0) {
42-
console.log('\nNon-commutative migration branches detected:');
43-
for (const c of response!.conflicts) {
44-
console.log(`- Parent ${c.parentId}${c.parentPath ? ` (${c.parentPath})` : ''}`);
45-
console.log(` A: ${c.branchA.headId} (${c.branchA.path})`);
46-
console.log(` B: ${c.branchB.headId} (${c.branchB.path})`);
47-
// for (const r of c.reasons) console.log(` • ${r}`);
48-
}
180+
if (response.conflicts.length === 0) {
181+
// render(renderSuccess());
182+
return;
49183
}
184+
185+
render(renderReportDirectory(response));
186+
process.exit(1);
50187
} catch (e) {
51188
console.error(e);
52189
}

0 commit comments

Comments
 (0)