Skip to content

fix(sqlite): preserve cascade dependents during table rebuilds - #6209

Closed
ChrisMGeo wants to merge 1 commit into
drizzle-team:mainfrom
ChrisMGeo:fix/sqlite-d1-cascade-successor
Closed

fix(sqlite): preserve cascade dependents during table rebuilds#6209
ChrisMGeo wants to merge 1 commit into
drizzle-team:mainfrom
ChrisMGeo:fix/sqlite-d1-cascade-successor

Conversation

@ChrisMGeo

Copy link
Copy Markdown

Fixes #5782. Successor to #5074.

Drizzle Kit emits SQLite table-rebuild migrations with PRAGMA foreign_keys=OFF. That pragma is ineffective inside the ORM migrator transaction, and Cloudflare D1 does not allow disabling foreign-key enforcement. With ON DELETE CASCADE, dropping a rebuilt parent silently deletes dependent rows.

This change preserves cascade dependents during generated SQLite and libSQL rebuilds by:

  • deriving cascade dependents from the old schema, which describes the actions that apply before the rebuild
  • backing up only tables that exist before and after the migration
  • using deterministic ordinary backup tables, which work on D1 where temporary tables are not authorized
  • restoring explicit common columns instead of relying on SELECT *, so a dependent table can also gain columns in the same schema diff
  • preserving compatibility with existing statement-combiner call sites

The regression coverage exercises generated migrations and push migrations with populated direct, multi-level, and fan-out cascade relationships, newly added dependents, and changed dependent columns.

Validation:

  • pnpm --filter drizzle-kit exec tsc -p tsconfig.build.json --noEmit
  • pnpm lint
  • 60 targeted SQLite, libSQL, and statement-combiner tests
  • Generated SQL applied to a disposable remote D1 database with a populated cascading child row

Related: #5074, #5784.

@ChrisMGeo
ChrisMGeo force-pushed the fix/sqlite-d1-cascade-successor branch from 6dfcf2b to 350fc3d Compare August 30, 2026 09:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dfcf2bf67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3837 to +3839
`INSERT OR REPLACE INTO ${
quoteSQLiteIdentifier(dep.tableName)
} (${dependentColumns}) SELECT ${dependentColumns} FROM ${quoteSQLiteIdentifier(dep.backupTableName)};`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid replaying backups when foreign keys were disabled

When PRAGMA foreign_keys=OFF takes effect, as it does during the standard SQLite push flow that executes these statements individually, dropping the parent does not remove any dependent rows. This unconditional restore then re-inserts every backed-up row; a dependent table without a primary key or unique constraint gets duplicate rows, while constrained tables undergo unnecessary REPLACE operations and their associated side effects. Restore only rows that were actually cascaded, or keep enforcement enabled while using the backup strategy.

Useful? React with 👍 / 👎.

: SQLiteSquasher.unsquashFK(fk);

if (
data.tableTo !== current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve renamed parents before traversing cascade references

When a table is renamed and also requires rebuilding in the same migration, rootTable uses the new name but fromSchema and its foreign keys still use the old name. The rename statement is emitted before the rebuild, yet this comparison finds no dependents, so in D1 or a transactional migrator where foreign-key enforcement remains enabled, dropping the renamed parent still cascades and permanently deletes its child rows.

Useful? React with 👍 / 👎.

Comment thread drizzle-kit/src/utils/cascade.ts Outdated
const newTable = toSchema.tables[table.name];
if (!newTable) continue;
const oldTable = table;
const columns = Object.keys(oldTable.columns).filter((column) => newTable.columns[column]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve renamed dependent columns in cascade backups

When a dependent column is renamed in the same diff as a parent rebuild, this name-based intersection omits that column even though the rename statement runs before the rebuild. After the parent drop cascades the row, restoration therefore writes NULL or a default instead of the original value, or fails entirely when the renamed column is NOT NULL without a default. The backup column mapping needs to account for resolved column renames rather than matching raw names only.

Useful? React with 👍 / 👎.

Comment thread drizzle-kit/src/utils/cascade.ts Outdated
const newTable = toSchema.tables[table.name];
if (!newTable) continue;
const oldTable = table;
const columns = Object.keys(oldTable.columns).filter((column) => newTable.columns[column]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude generated columns from dependent restores

When a cascade-dependent table has a stored or virtual generated column, this collects that column along with ordinary common columns, and the generated restore later names it explicitly in an INSERT OR REPLACE. SQLite rejects writes to generated columns with cannot INSERT into generated column, so rebuilding the parent fails instead of preserving its dependents. Generated columns should be omitted from the backup/restore column list and allowed to recompute.

Useful? React with 👍 / 👎.

Comment on lines +48 to +52
visited.add(table.name);
result.push({
tableName: table.name,
backupTableName: getCascadeBackupTableName(rootTable, table.name),
columns,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Topologically order dependent restores

For a fan-in cascade graph, marking a table visited and appending it on the first discovered path does not guarantee that all tables it references are restored first. For example, if the root cascades to a and b, c cascades from a, and x cascades from both b and c, the stack traversal can produce a, b, x, c; with enforcement enabled, restoring x fails immediately because c is still absent. The dependents need a foreign-key-safe topological restore order rather than first-discovery order.

Useful? React with 👍 / 👎.

Comment on lines +42 to +43
const newTable = toSchema.tables[table.name];
if (!newTable) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map renamed dependents to their post-rename table names

When a cascade-dependent table is renamed in the same migration as its parent is rebuilt, fromSchema supplies the old dependent name but toSchema is keyed by the new name, so this lookup skips the dependent entirely. Because table renames are emitted before rebuilds and SQLite retains the foreign key through the rename, dropping the parent then cascades rows from the newly named table without any backup. The collector must resolve the dependent rename and use its post-rename name in the generated backup statements.

Useful? React with 👍 / 👎.

@ChrisMGeo
ChrisMGeo force-pushed the fix/sqlite-d1-cascade-successor branch from 350fc3d to 1edd995 Compare August 30, 2026 10:11
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@ChrisMGeo ChrisMGeo closed this Aug 30, 2026
@ChrisMGeo
ChrisMGeo deleted the fix/sqlite-d1-cascade-successor branch August 30, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: SQLite table-rebuild migrations silently wipe child tables for any FK with ON DELETE CASCADE

1 participant