Accepted
ADR 0024 — Field-type migration workflow
Changing a field's type on a collection that already holds documents is a resumable workflow with a dry-run preview
Decision
An administrator can change an existing field's type from the admin, even when the collection already holds documents written under the old type. The MigrateField workflow, bound as MIGRATE, converts every existing value, then commits the field definition's new type. It is editor-facing work, not a deploy-time operation.
The conversion is bounded by a registry of supported type pairs. Each pair is a pure function that classifies a value as lossless, fallback, or fail, then converts it. A value that round-trips faithfully is lossless. A value that is empty, null, or missing is fallback — it takes the editor-supplied fallback. A value that is present but cannot be coerced to the target type is a fail.
Supported conversions
The v1 registry covers scalar-to-scalar pairs. Anything involving media, reference, repeater, group, or block is rejected loudly with UNSUPPORTED_TYPE_CHANGE before the workflow ever starts, because those types carry shape semantics a scalar source cannot reconstruct.
- text to richtext — text wraps into a single Portable Text paragraph; an empty value becomes an empty array. richtext to text flattens the spans back to plain text.
- text to number — a numeric-only string parses; a missing value takes the fallback; any other string fails.
- text to boolean — the literals "true" and "false" convert; a missing value takes the fallback; anything else fails.
- text to date — a YYYY-MM-DD string converts; a missing value takes the fallback; a malformed shape fails.
- number to boolean — 0 and 1 convert; a missing value takes the fallback; any other number fails, on purpose, so 5 does not silently become true.
- Each scalar pair is symmetric: number to text, boolean to text, and date to text all stringify the source losslessly.
Dry-run preview
Before running, the editor previews the change against a sample of the ten most recently updated documents. The preview is synchronous, runs the same conversion rules the workflow does, mutates nothing, and reports how many documents in the sample are lossless, fallback, or fail. It cannot extrapolate to the whole collection, so the totals are labelled in-sample.
How the workflow runs
The workflow compiles the conversion rule, enumerates the collection's documents in chunked batches of fifty, converts each batch, then commits the field definition's new type, then finalises the run. Every per-document write goes through the document repository's versioned-write path, so each conversion lands in the version history with a change summary the version-history UI recognises — an editor can revert any single document the migration touched.
The field definition's type flips only after every batch succeeds. That ordering is deliberate: a mid-run failure leaves the documents partially converted and the type unchanged, and because the conversion rules guarantee the converted value still validates against the old type, in-flight reads stay coherent. The schema cache is purged once, when the type finally flips.
Resume and coordination
A migration_runs row coordinates the migration. It is the single source of truth for three jobs: resume, status, and concurrency. Documents are converted in ULID order and the row records the highest converted id, so a retried batch reads that marker and skips what is already done — a Worker restart mid-migration loses no work and double-converts nothing. That last guarantee rests on the conversion and the cursor advance committing in a single D1 batch, so an at-least-once replay can never see a converted document behind a stale cursor (ADR 0057). A unique partial index on the running rows rejects a second concurrent migration of the same field with a 409.
The migration_runs row is workflow-coordination scratchpad, not a versioned entity, so it is written with direct updates rather than the versioned-write path. The admin polls it for live status — converted count, total, and any error step — and links out to the workflow's logs in the Cloudflare dashboard.
Consequences
- Field-type changes on populated collections are an admin capability, with a preview so editors see the classification before committing.
- Per-batch chunking at fifty keeps every step well under D1's parameter ceiling; per-batch versioned writes put every conversion in the version history for per-document rollback.
- Cache staleness during a long migration is accepted: converted documents serve from the edge against the old schema until their TTL expires, rather than triggering a thundering herd of per-batch purges. The conversion rules guarantee the served value is still valid.
- One field per workflow. An editor changing three fields runs three migrations in sequence, which keeps the failure posture and the version-history surface clean.