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