You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Allow relaxing a field's required flag (true → false) through updateField — today every change is refused, in both directions
#3178
SchemaRegistry.updateField refuses every change to a field's required flag, in both directions:
// packages/core/src/schema/registry.ts (main, and emdash 0.38.0)if(input.required!==undefined&&input.required!==field.required){thrownewSchemaError(`Changing required for field "${fieldSlug}" requires a manual content migration.`,"FIELD_UPDATE_REQUIRES_MIGRATION",);}
For tightening (false → true) that is clearly right: stored rows may not satisfy the new rule. For relaxing (true → false) there is no content to migrate — every stored row already satisfies the looser rule — yet there is no supported path for it at all. Because everything funnels through updateField, the refusal covers:
REST: PUT /_emdash/api/schema/collections/:c/fields/:f with {"required": false} → 400 FIELD_UPDATE_REQUIRES_MIGRATION
MCP: schema_update_field (same handler)
emdash seed --on-conflict update: apply always passes required: field.required || false, so a seed whose flag differs from the database throws — one relaxed field aborts the seed apply.
The docs describe the limit as deliberate (Collections & Fields and Content model: "Changing required or unique … also requires a migration") and point to Evolving a deployed site's schema — but that guide never says what the migration for a required change is. In practice the only way through today is a hand-written UPDATE _emdash_fields SET required = 0 … against the database, which is exactly the kind of out-of-band write the safe-update work (#1681 / #1876 / #2354) set out to make unnecessary.
Two things in the current code suggest direction-awareness would fit:
The neighbouring translatable guard is already directional — only translatable: false on a translatable field is refused; the other direction is a plain metadata update.
The test added with the guard ("rejects field invariant changes that require data or column migration") only exercises the tightening direction ({ required: true }, { unique: true }, { translatable: false }). Relaxing isn't covered either way, so I read the refusal as conservative-by-default rather than a decision about true → false specifically. Happy to be corrected.
Prior art: I searched issues, PRs and Discussions for FIELD_UPDATE_REQUIRES_MIGRATION, "manual content migration" and relaxing required, and found only #2354 itself and its Discussion #1876, neither of which discusses direction.
Motivator
A string field holding an outbound link was created required: true. Later the editorial workflow changed: editors need to save and publish an entry before the link exists. The site has dev, preview and production databases, all carrying required = 1. We could not relax it through the admin API, the CLI, or the seed, so we shipped a one-row SQL script per environment — and then had to reason about cache invalidation and column shape ourselves (next section).
What relaxing by hand actually does (verified on 0.38.0, D1)
This is the part I think is worth the maintainers' attention, because it shows the flag flip is almost but not entirely metadata-only.
Repro on a throwaway collection: create link as string, required: true; flip _emdash_fields.required to 0 directly; restart (the write bypasses the object cache).
Write to the relaxed field
Result
field omitted from data
201, stored ''
"link": ""
201, stored ''
"link": null
500 CONTENT_CREATE_ERROR — NOT NULL constraint failed: ec_<collection>.link
Validation reads required live, so the flag alone is enough to unblock saves — no content rewrite is needed.
But a field created required gets its column as TEXT NOT NULL DEFAULT '' (createContentTable / the add-column path), and the column outlives the flag. The Zod schema for a non-required field is .nullish(), so null passes validation and then dies on the constraint as an opaque 500.
So a relaxed field is not equivalent to a born-optional field: "no value" is '', never NULL, and a client that sends null (a perfectly reasonable thing to send to an optional field) gets a 500.
Proposal
Make the required guard directional, like translatable already is:
if(input.required!==undefined&&input.required!==field.required){if(input.required){// tightening: unchanged — stored rows may violate itthrownewSchemaError(`Making field "${fieldSlug}" required requires a manual content migration.`,"FIELD_UPDATE_REQUIRES_MIGRATION",);}// relaxing: allowed — see "column" below}
No new API surface: UpdateFieldInput.required, the REST body, the MCP tool and the seed already carry the key. The only change is that required: false on a required field stops throwing.
For the leftover NOT NULL column, the options I can see:
Relax the column too. Postgres: ALTER COLUMN … DROP NOT NULL. SQLite/D1: needs the table-rebuild dance, inside the same transaction as the flag update.
Leave the column, coerce on write. When a non-required field's column is NOT NULL, the content repository writes the type's empty default (getEmptyDefault(field.type)) instead of NULL. Cheap, no DDL, but "no value" reads back as '' rather than null for these fields.
Flag-only, documented. Allow the flip, document that the column keeps its creation-time shape, and turn the null write into a 4xx with a useful message instead of a 500.
I'd lean 2 or 3 as a first step — they keep the change metadata-only, which seems to be the spirit of the safe-update tools — but this is your storage layer and I don't have a strong view.
Non-goals
false → true stays refused. That is a real content migration.
Type changes across column affinity stay refused.
Open questions
Is the both-directions refusal intentional for true → false, for a reason I've missed (e.g. something downstream that assumes a required field's column never holds NULL)?
Which of the three column options fits, or is there a fourth?
unique: true → false looks like the same shape (dropping a constraint can't invalidate stored rows) — should it ride along, or stay out of scope? I haven't looked at how the unique index is managed, so I've left it out of the proposal.
Independently of all this: should a null write that hits a NOT NULL content column surface as a validation error rather than 500 CONTENT_CREATE_ERROR? It can already happen today to anyone who has done the manual migration the docs refer to.
Environment
emdash 0.38.0 / @emdash-cms/cloudflare 0.38.0, Cloudflare D1 (SQLite), Astro 7. Guard re-checked against main on 2026-09-17.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Problem
SchemaRegistry.updateFieldrefuses every change to a field'srequiredflag, in both directions:For tightening (
false → true) that is clearly right: stored rows may not satisfy the new rule. For relaxing (true → false) there is no content to migrate — every stored row already satisfies the looser rule — yet there is no supported path for it at all. Because everything funnels throughupdateField, the refusal covers:PUT /_emdash/api/schema/collections/:c/fields/:fwith{"required": false}→400 FIELD_UPDATE_REQUIRES_MIGRATIONschema_update_field(same handler)emdash seed --on-conflict update:applyalways passesrequired: field.required || false, so a seed whose flag differs from the database throws — one relaxed field aborts the seed apply.The docs describe the limit as deliberate (Collections & Fields and Content model: "Changing
requiredorunique… also requires a migration") and point to Evolving a deployed site's schema — but that guide never says what the migration for arequiredchange is. In practice the only way through today is a hand-writtenUPDATE _emdash_fields SET required = 0 …against the database, which is exactly the kind of out-of-band write the safe-update work (#1681 / #1876 / #2354) set out to make unnecessary.Two things in the current code suggest direction-awareness would fit:
translatableguard is already directional — onlytranslatable: falseon a translatable field is refused; the other direction is a plain metadata update.{ required: true },{ unique: true },{ translatable: false }). Relaxing isn't covered either way, so I read the refusal as conservative-by-default rather than a decision abouttrue → falsespecifically. Happy to be corrected.Prior art: I searched issues, PRs and Discussions for
FIELD_UPDATE_REQUIRES_MIGRATION, "manual content migration" and relaxingrequired, and found only #2354 itself and its Discussion #1876, neither of which discusses direction.Motivator
A
stringfield holding an outbound link was createdrequired: true. Later the editorial workflow changed: editors need to save and publish an entry before the link exists. The site has dev, preview and production databases, all carryingrequired = 1. We could not relax it through the admin API, the CLI, or the seed, so we shipped a one-row SQL script per environment — and then had to reason about cache invalidation and column shape ourselves (next section).What relaxing by hand actually does (verified on 0.38.0, D1)
This is the part I think is worth the maintainers' attention, because it shows the flag flip is almost but not entirely metadata-only.
Repro on a throwaway collection: create
linkasstring,required: true; flip_emdash_fields.requiredto0directly; restart (the write bypasses the object cache).data201, stored''"link": ""201, stored''"link": null500 CONTENT_CREATE_ERROR—NOT NULL constraint failed: ec_<collection>.linkrequiredlive, so the flag alone is enough to unblock saves — no content rewrite is needed.TEXT NOT NULL DEFAULT ''(createContentTable/ the add-column path), and the column outlives the flag. The Zod schema for a non-required field is.nullish(), sonullpasses validation and then dies on the constraint as an opaque 500.'', neverNULL, and a client that sendsnull(a perfectly reasonable thing to send to an optional field) gets a 500.Proposal
Make the
requiredguard directional, liketranslatablealready is:No new API surface:
UpdateFieldInput.required, the REST body, the MCP tool and the seed already carry the key. The only change is thatrequired: falseon a required field stops throwing.For the leftover
NOT NULLcolumn, the options I can see:ALTER COLUMN … DROP NOT NULL. SQLite/D1: needs the table-rebuild dance, inside the same transaction as the flag update.NOT NULL, the content repository writes the type's empty default (getEmptyDefault(field.type)) instead ofNULL. Cheap, no DDL, but "no value" reads back as''rather thannullfor these fields.nullwrite into a 4xx with a useful message instead of a 500.I'd lean 2 or 3 as a first step — they keep the change metadata-only, which seems to be the spirit of the safe-update tools — but this is your storage layer and I don't have a strong view.
Non-goals
false → truestays refused. That is a real content migration.Open questions
true → false, for a reason I've missed (e.g. something downstream that assumes a required field's column never holdsNULL)?unique: true → falselooks like the same shape (dropping a constraint can't invalidate stored rows) — should it ride along, or stay out of scope? I haven't looked at how the unique index is managed, so I've left it out of the proposal.nullwrite that hits aNOT NULLcontent column surface as a validation error rather than500 CONTENT_CREATE_ERROR? It can already happen today to anyone who has done the manual migration the docs refer to.Environment
emdash 0.38.0 /
@emdash-cms/cloudflare0.38.0, Cloudflare D1 (SQLite), Astro 7. Guard re-checked againstmainon 2026-09-17.All reactions