| 1 | --- |
| 2 | title: How we migrated 50+ providers to reconcile in an afternoon |
| 3 | date: 2026-05-06 |
| 4 | draft: true |
| 5 | excerpt: One prompt, fifty resources, run in parallel. Tests were the ground truth. The engine got the same treatment. |
| 6 | --- |
| 7 | |
| 8 | [Part 1](/blog/2026-05-04-reconcile) covered why we replaced |
| 9 | `create` and `update` with a single `reconcile` function. |
| 10 | This post is about the part that surprised us: how |
| 11 | unreasonably fast it was to actually do the migration. |
| 12 | |
| 13 | Alchemy has 50+ provider files in [`packages/alchemy/src/`](https://github.com/alchemy-run/alchemy/tree/main/packages/alchemy/src). |
| 14 | The old shape was two functions per resource — `create` and |
| 15 | `update` — both written defensively against each other's |
| 16 | edge cases. The new shape is one function. Rewriting every |
| 17 | provider by hand would have been a multi-week slog. |
| 18 | |
| 19 | It took one afternoon, with a single AI prompt fanned out |
| 20 | across the codebase in parallel. The existing tests were |
| 21 | the ground truth. |
| 22 | |
| 23 | ## Why `reconcile` is easier to *generate* than `create` + `update` |
| 24 | |
| 25 | The reason this worked isn't that the model got smarter. It's |
| 26 | that `reconcile` is a strictly easier function to write |
| 27 | correctly — for a human or an AI — than the pair it |
| 28 | replaced. |
| 29 | |
| 30 | To write `create` correctly you need to hold in your head: |
| 31 | |
| 32 | - What happens if the resource half-exists from a prior |
| 33 | crash? (probably need to catch `AlreadyExists` and read |
| 34 | what's there) |
| 35 | - What happens if it fully exists and we're adopting? (need |
| 36 | an ownership check) |
| 37 | - What does `update` assume `create` left behind? (because |
| 38 | if `create` skips a step, `update` will silently skip it |
| 39 | too) |
| 40 | |
| 41 | To write `update` correctly you need to hold in your head: |
| 42 | |
| 43 | - What if `olds` is stale because someone clicked in the |
| 44 | console? (need to query live state instead of trusting it) |
| 45 | - What if a previous `update` half-completed? (need every |
| 46 | step to be independently idempotent) |
| 47 | - What did `create` guarantee about the starting state? |
| 48 | (because every assumption you make has to match) |
| 49 | |
| 50 | `reconcile` collapses both into one mental model: |
| 51 | **read live state, compute the delta, apply the delta.** |
| 52 | There's no other function whose behavior you have to |
| 53 | predict. There's no postcondition to maintain across calls. |
| 54 | Each sync step is independently idempotent — crash, re-run, |
| 55 | converge. |
| 56 | |
| 57 | The AI doesn't have to reason about which function it's in |
| 58 | or what the other one already did. There is no other one. |
| 59 | |
| 60 | ## Tests were the ground truth |
| 61 | |
| 62 | Every provider already had test coverage: |
| 63 | [`test/AWS/S3/Bucket.test.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/test/AWS/S3/Bucket.test.ts), |
| 64 | [`test/AWS/DynamoDB/Table.test.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/test/AWS/DynamoDB/Table.test.ts), |
| 65 | and so on. These weren't unit tests with mocked SDKs — they |
| 66 | hit the real cloud, run through the full apply path, and |
| 67 | assert on the actual state of the resource. |
| 68 | |
| 69 | That made them perfect as a migration substrate. The AI's |
| 70 | contract for each provider was: |
| 71 | |
| 72 | 1. Fold `create` and `update` into one `reconcile` function. |
| 73 | 2. The existing tests must still pass. |
| 74 | |
| 75 | Behavior changes that survived the tests were fine. |
| 76 | Behavior changes that broke them were rejected and retried. |
| 77 | We weren't reviewing the AI's prose about what it intended |
| 78 | to do — we were running its output against scenarios that |
| 79 | already encoded "the bucket must have these tags after step |
| 80 | 3," "this update must not replace the resource," "adoption |
| 81 | of a tagged bucket must be silent." |
| 82 | |
| 83 | For aspects that weren't yet covered, we added tests |
| 84 | *first*, then asked the AI to migrate. The new tests acted |
| 85 | as an executable spec for the gap. |
| 86 | |
| 87 | ## The engine got the same treatment |
| 88 | |
| 89 | The provider-side change was the visible half. The other |
| 90 | half was inside the engine — [`Plan.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Plan.ts) |
| 91 | and [`Apply.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Apply.ts) — |
| 92 | which decide *which* provider function to call and route |
| 93 | the lifecycle. |
| 94 | |
| 95 | `Plan.ts` kept its `"create" | "update" | "replace"` action |
| 96 | shape (see part 1 for why). `Apply.ts` is what changed: |
| 97 | both `create` and `update` actions now dispatch into the |
| 98 | same provider function, with the action distinction |
| 99 | preserved only for telemetry, terminal output, and `read` |
| 100 | gating. |
| 101 | |
| 102 | The engine has its own integration tests — apply/plan |
| 103 | fixtures that simulate the full lifecycle including |
| 104 | adoption, drift, partial failures, retries. Those tests |
| 105 | became the ground truth for the engine rewrite, the same |
| 106 | way per-resource tests were the ground truth for providers. |
| 107 | The AI modified `Plan.ts` and `Apply.ts` against that test |
| 108 | surface; what passed shipped. |
| 109 | |
| 110 | ## What you get out of this |
| 111 | |
| 112 | The headline number — 50 providers in an afternoon — is a |
| 113 | function of two things, not one. |
| 114 | |
| 115 | The first is that AI is unreasonably good at translating |
| 116 | between two function shapes when both are well-specified |
| 117 | and the translation is mechanical. That's table stakes for |
| 118 | the model now. |
| 119 | |
| 120 | The second is that **the target shape was simpler than what |
| 121 | it replaced.** That's the part you can engineer for. If the |
| 122 | migration had been the other direction — splitting one |
| 123 | function into two, with the AI having to invent the |
| 124 | boundary — it would have been slow and fragile and full of |
| 125 | incorrect assumptions about who owns what postcondition. |
| 126 | Going the other way, toward less structure and more |
| 127 | observation, was a downhill walk. |
| 128 | |
| 129 | The other measurable outcome: providers got shorter. A |
| 130 | typical file lost 30-40% of its lifecycle code. The |
| 131 | DynamoDB `Table` provider, which had the gnarliest split |
| 132 | (GSIs, PITR, TTL, tags, stream config — each with its own |
| 133 | update API), dropped about 200 lines and got *more* correct |
| 134 | in the process. There's a generalizable lesson there: |
| 135 | **framework changes that make code easier to AI-generate |
| 136 | tend to make code easier to read and maintain too**, because |
| 137 | both are downstream of the same thing — fewer hidden |
| 138 | assumptions, fewer postconditions, fewer state machines in |
| 139 | your head. |
| 140 | |
| 141 | ## Where to go next |
| 142 | |
| 143 | - [Part 1 — One reconcile, no create vs. update](/blog/2026-05-04-reconcile) |
| 144 | - [PR #179](https://github.com/alchemy-run/alchemy/issues/179) |
| 145 | — the migration in one diff. |
| 146 | - Per-resource test conventions: |
| 147 | [`test/AWS/DynamoDB/Table.test.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/test/AWS/DynamoDB/Table.test.ts), |
| 148 | [`test/Cloudflare/Workers/Worker.test.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/test/Cloudflare/Workers/Worker.test.ts). |