|
| 1 | +# D1 referenced-table rebuild — detect-and-refuse (#226) |
| 2 | + |
| 3 | +- **Date:** 2026-07-27 |
| 4 | +- **Status:** Design approved; ready for implementation plan |
| 5 | +- **Issue:** [#226](https://github.com/metaobjectsdev/metaobjects/issues/226) — `migrate --dialect d1`: the SQLite table-rebuild's `PRAGMA foreign_keys OFF` is a no-op on remote D1, so a parent-table rebuild fails with `FOREIGN KEY constraint failed` |
| 6 | +- **Surface:** `@metaobjectsdev/migrate-ts` (D1 emitter) + `@metaobjectsdev/cli`. TS-only — D1 is a TS-only dialect; no cross-port impact. |
| 7 | + |
| 8 | +## Problem |
| 9 | + |
| 10 | +On Cloudflare **remote** D1, a generated migration that rebuilds a table which is |
| 11 | +referenced by another table's foreign key fails at apply time with |
| 12 | +`FOREIGN KEY constraint failed`, aborting the migration. The failure is **silent |
| 13 | +until a table holds rows** (an empty DB orphans nothing), so it typically first |
| 14 | +appears against a populated production database, after the migration has been |
| 15 | +generated, reviewed, and committed. |
| 16 | + |
| 17 | +### Why it happens |
| 18 | + |
| 19 | +The D1 emitter delegates the whole rebuild to the SQLite emitter |
| 20 | +(`emit/d1.ts` → `renderSqlite`), inheriting SQLite's 12-step table rebuild |
| 21 | +(`emit/sqlite.ts`, `renderRecreate`): |
| 22 | + |
| 23 | +```sql |
| 24 | +PRAGMA foreign_keys = OFF; |
| 25 | +BEGIN TRANSACTION; |
| 26 | +CREATE TABLE "__new_x" (...); |
| 27 | +INSERT INTO "__new_x" (...) SELECT ... FROM "x"; |
| 28 | +DROP TABLE "x"; |
| 29 | +ALTER TABLE "__new_x" RENAME TO "x"; |
| 30 | +COMMIT; |
| 31 | +PRAGMA foreign_keys = ON; |
| 32 | +PRAGMA foreign_key_check; |
| 33 | +``` |
| 34 | + |
| 35 | +The D1 safety pass (`emit/d1-safety-pass.ts`) strips `BEGIN`/`COMMIT` but passes the |
| 36 | +`PRAGMA foreign_keys` statements through verbatim. On remote D1 the whole batch runs |
| 37 | +inside D1's **implicit transaction**, and SQLite **ignores `PRAGMA foreign_keys` |
| 38 | +while a transaction is open** — so the `OFF` never takes effect. `DROP TABLE "x"` |
| 39 | +then runs with FK enforcement live; when `x` is a **referenced parent** with existing |
| 40 | +child rows, the drop is rejected. |
| 41 | + |
| 42 | +A rebuild is triggered by any recreate-triggering change on the referenced table: |
| 43 | +adding/removing a `CHECK`, changing a column's type / nullability / default, adding or |
| 44 | +removing a foreign key, or a `field.enum @values` change (which lowers to a `CHECK` |
| 45 | +change). See the recreate-triggering kinds in `emit/sqlite.ts` (`add-check`, |
| 46 | +`drop-check`, `change-column-type`, `change-column-nullable`, `change-column-default`, |
| 47 | +`add-fk`, `drop-fk`). |
| 48 | + |
| 49 | +### Why it is D1-specific |
| 50 | + |
| 51 | +Local SQLite is correct: its emitted `PRAGMA foreign_keys = OFF` precedes `BEGIN`, so |
| 52 | +it takes effect before the transaction opens. Postgres does not use the rebuild |
| 53 | +recipe. Only D1's stripped-`BEGIN` / single-implicit-transaction apply model exposes |
| 54 | +the defect. Remote is applied via `wrangler d1 migrations apply <binding> --remote`, |
| 55 | +which hands the whole migration file to D1 as one implicit transaction. |
| 56 | + |
| 57 | +## Scope decision: detect-and-refuse, not auto-fix |
| 58 | + |
| 59 | +The auto-fix ("cascade" — rebuild the referencing children without their FK, rebuild |
| 60 | +the parent, restore the children's FK) was prototyped and **works**, but it is |
| 61 | +substantial, engine-quirk-dependent machinery (reverse FK-graph, two strict ordering |
| 62 | +rules, self-referential special-casing, and a genuine dead-end for multi-table |
| 63 | +cycles) for a narrow case on a single niche dialect. See |
| 64 | +[Appendix A](#appendix-a--prototype-findings) for the prototype evidence. |
| 65 | + |
| 66 | +The bug's actually-dangerous property is that it fails **silently, against |
| 67 | +production**. Detect-and-refuse removes that property completely at a fraction of the |
| 68 | +cost: turn a silent runtime failure into a **loud generation-time failure** with a |
| 69 | +clear workaround. This matches the issue's own guidance ("a loud generation-time |
| 70 | +failure is far better than a runtime failure against production") and the D1 safety |
| 71 | +pass's existing philosophy of refusing statement sequences it knows cannot apply |
| 72 | +(`ATTACH`/`DETACH`/`VACUUM`). |
| 73 | + |
| 74 | +The cascade auto-fix is recorded as a possible future enhancement (a follow-up issue), |
| 75 | +not built here. |
| 76 | + |
| 77 | +## Design |
| 78 | + |
| 79 | +### Detection |
| 80 | + |
| 81 | +In the D1 emit path, before returning the emitted SQL, compute: |
| 82 | + |
| 83 | +1. **The rebuilt-table set** — the tables that will go through `renderRecreate`, |
| 84 | + derived from `changes` by selecting the recreate-triggering kinds and grouping by |
| 85 | + table (the same partition `renderSqlite` already performs internally; the D1 path |
| 86 | + computes it from the structured `changes`, not by parsing emitted SQL). |
| 87 | +2. **Referenced-ness** — a rebuilt table `T` is *refused* when any foreign key in the |
| 88 | + schema targets `T` (`FkDescriptor.refTable === T`), whether from another table or |
| 89 | + from `T` itself (a self-referential FK hits the same wall — the temp table |
| 90 | + references the original name, so the `DROP` of the referenced original still |
| 91 | + fails). |
| 92 | + |
| 93 | +If any rebuilt table is referenced, **throw** a dedicated, structured error and emit |
| 94 | +nothing. If none is, the D1 output is produced exactly as today (`renderSqlite` + |
| 95 | +`applyD1SafetyPass`) — **byte-identical** for every migration that does not rebuild a |
| 96 | +referenced table. |
| 97 | + |
| 98 | +The check is a direct scan of the schema's foreign keys — no reverse graph, no |
| 99 | +topological sort, no dependency-closure. |
| 100 | + |
| 101 | +**Detection data source (over-refuse bias).** A *missed* refusal reintroduces the |
| 102 | +exact silent-prod-failure this change exists to kill, so detection errs toward |
| 103 | +**over-refusing**: a rebuilt table is refused if it is referenced in the **target |
| 104 | +(expected) schema** *or* by a currently-existing child in the actual schema. The |
| 105 | +implementation plan pins the exact source against the diff inputs available to the D1 |
| 106 | +emitter (`expectedSchema`, and the actual-schema FK information available at migrate |
| 107 | +time); when the available data is ambiguous, refuse. Over-refusing costs a D1 user a |
| 108 | +hand-written migration in a rare case; under-refusing costs a silent production |
| 109 | +failure. |
| 110 | + |
| 111 | +### The error |
| 112 | + |
| 113 | +A dedicated error type (sibling to `D1UnsupportedStatementError`) carrying the |
| 114 | +refused table and its referencing table(s), with a message that names the cause and |
| 115 | +the workaround. Example rendered message: |
| 116 | + |
| 117 | +``` |
| 118 | +Cannot rebuild table "parent" on Cloudflare D1 — it is referenced by a foreign key |
| 119 | +from "child". D1 applies migrations inside an implicit transaction where |
| 120 | +`PRAGMA foreign_keys = OFF` is a no-op, so dropping the referenced table during the |
| 121 | +rebuild fails with "FOREIGN KEY constraint failed". |
| 122 | +
|
| 123 | +The rebuild was triggered by a change to "parent" (a CHECK, column type/nullability/ |
| 124 | +default, foreign key, or enum-values change). To apply it on D1, hand-write this |
| 125 | +migration: rebuild "child" to temporarily drop its foreign key, rebuild "parent", |
| 126 | +then rebuild "child" to restore the foreign key. Or make the change on an |
| 127 | +unreferenced table. |
| 128 | +``` |
| 129 | + |
| 130 | +### Where it lives |
| 131 | + |
| 132 | +- Detection + the throw live in the D1 emitter (`emit/d1.ts`), operating on the |
| 133 | + structured `changes` + schema inputs it already receives — not on the emitted SQL |
| 134 | + string, and not in the safety pass (which is string-level and lacks FK-graph |
| 135 | + awareness). |
| 136 | +- A small helper enumerates the rebuilt-table set from `changes` and tests |
| 137 | + referenced-ness against the schema's `FkDescriptor`s. |
| 138 | +- No change to `emit/sqlite.ts` behavior, `emit/postgres.ts`, or the local-SQLite / |
| 139 | + Postgres paths. If a shared "which kinds trigger a recreate" predicate is convenient, |
| 140 | + it is extracted read-only from the existing SQLite logic without changing that |
| 141 | + logic. |
| 142 | + |
| 143 | +### CLI behavior |
| 144 | + |
| 145 | +`meta migrate --dialect d1` surfaces the thrown error as a clear generation-time |
| 146 | +failure and writes no migration file (the existing emit-error path). `meta verify |
| 147 | +--dialect d1` is unaffected — it reports drift (that the change is needed) but does not |
| 148 | +emit, so it continues to work; the user learns the change exists from `verify` and |
| 149 | +learns it must be hand-written from `migrate`. |
| 150 | + |
| 151 | +## Testing |
| 152 | + |
| 153 | +- **Unit (`emit/d1`):** |
| 154 | + - throws on a rebuild of a table referenced by another table's FK; |
| 155 | + - throws on a rebuild of a self-referentially-referenced table; |
| 156 | + - does **not** throw on a rebuild of an unreferenced (leaf) table; |
| 157 | + - does **not** throw on non-rebuild changes (`add-column`, `create-table`, |
| 158 | + `add-index`, …); |
| 159 | + - the thrown error names the refused table and its referencer. |
| 160 | +- **Byte-identical guard:** for a migration with no referenced-table rebuild, the D1 |
| 161 | + output equals the pre-change output (no-churn snapshot). |
| 162 | +- **Integration (libSQL, real engine — mirrors `test/integration/sqlite-fk-convergence.test.ts`):** |
| 163 | + reproduce remote D1 by wrapping the emitted batch in **one explicit transaction** |
| 164 | + with `foreign_keys = ON` at the connection level (SQLite ignores `PRAGMA |
| 165 | + foreign_keys` inside a transaction — identical to remote D1's implicit transaction). |
| 166 | + - documents the defect: the *old* naive D1 output for a referenced-parent rebuild |
| 167 | + fails with `FOREIGN KEY constraint failed` under that model; |
| 168 | + - proves no over-refusal: a *leaf*-table rebuild's D1 output applies cleanly under |
| 169 | + the same model, data is intact, and a re-diff is **EMPTY** (the migrate-engine |
| 170 | + doctrine: emit → apply to a real engine → re-diff empty). |
| 171 | + |
| 172 | +## Non-goals |
| 173 | + |
| 174 | +- Auto-migrating a referenced-table rebuild on D1 (the cascade) — deferred to a |
| 175 | + follow-up issue. |
| 176 | +- Any change to local SQLite or Postgres emitters. |
| 177 | +- Multi-table foreign-key cycles — subsumed by the refuse (they are referenced tables). |
| 178 | + |
| 179 | +## Appendix A — prototype findings |
| 180 | + |
| 181 | +Throwaway libSQL prototypes (single connection, the rebuild wrapped in one explicit |
| 182 | +transaction with `foreign_keys = ON`, faithfully modelling remote D1) established: |
| 183 | + |
| 184 | +1. **The defect reproduces** deterministically: a referenced-parent rebuild fails at |
| 185 | + `DROP TABLE <parent>` with `FOREIGN KEY constraint failed`; `PRAGMA foreign_keys = |
| 186 | + OFF` is confirmed a no-op inside the transaction. |
| 187 | +2. **`PRAGMA defer_foreign_keys = ON` does not rescue a parent rebuild** (it defers |
| 188 | + row-level checks, not the drop-of-a-referenced-table guard) — though it does rescue |
| 189 | + a pure child rebuild. There is no cheap PRAGMA-only fix. |
| 190 | +3. **A cascade does work** (rebuild referencing children without their FK → rebuild |
| 191 | + parent → restore), validated for single-parent, transitive (`g→c→p`), multi-child, |
| 192 | + and self-referential graphs with data intact and FKs re-enforced — but only via |
| 193 | + temp-name FK rewriting plus two strict ordering rules (referrers-first `DROP`, |
| 194 | + parents-first `RENAME`). Multi-table cycles have no reliable atomic sequence. |
| 195 | + |
| 196 | +These findings are the justification for choosing detect-and-refuse: the auto-fix is |
| 197 | +real but disproportionate, and the same libSQL-in-one-transaction technique becomes |
| 198 | +the integration gate above. |
0 commit comments