Rich text / foldkit-richtext
Migrations
Migrations move semantic data forward when a deployment changes its vocabulary — a preserved node that is now implemented, a prop renamed, a kind replaced:
ts
const Callout = Schema.Struct({ tone: Schema.Literals(['info', 'warning', 'critical']) })
RichText.migrate(document, [
RichText.promoteUnknown('EmbedToCallout', 'Embed', 'Callout', Callout),
RichText.migration('DangerToCritical', 'Callout', block =>
block.type === 'Node' && block.props.tone === 'danger'
? { ...block, props: { ...block.props, tone: 'critical' } }
: undefined,
),
])
// → { document, applied: [{ name, node }], unused: ['…'] }They operate on blocks, never on DOM, and run at a boundary the application chooses — loading, publishing, or an explicit upgrade — never automatically on every read. The list order is the chain: a later migration sees what an earlier one produced. A migration descends into containers, so a preserved block nested in a list is rewritten where it sits.
Three rules are enforced rather than documented and hoped for:
- Identity survives. A migration returns the same
id, so positions, references, and selections keep addressing the same node; changing it throws instead of silently breaking every reference. That holds for a nested block too. - The result is still content. Returned blocks must decode, and the complete document is validated after each changed migration pass. Non-JSON props, unknown shapes, and identities duplicated across blocks are rejected.
- Declining is allowed. Returning
undefinedkeeps the block as it is — which is whatpromoteUnknowndoes when legacy data does not decode against the target's schema, rather than half-converting it.promoteUnknowntakes the target's content mode, so promoting into a container kind yields a valid block (blocks: []) rather than onevalidatewould call a mismatch.