Skip to content

Add vendor migrations to blocks - #6401

Merged
VPS-Obi merged 14 commits into
mainfrom
claude/pensive-franklin-gws0y7
Sep 21, 2026
Merged

VPS-Obi merged 14 commits into
mainfrom
claude/pensive-franklin-gws0y7

Conversation

@nsams

@nsams nsams commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

An alternative to #6390, solving the same problem with a smaller API.

A block's version is currently a single counter, so only one party can advance it. That works while an application owns all of a block's migrations, but not when the library providing the block ships migrations as well.

Examples where this is a problem:

  • createTipTapRichTextBlock with migrateFromDraftJs (currently we workaround the limitation by letting the vendor use version 1 and application migrations must start at 2)
  • future tiptap migrations (will be needed in Replace the TipTap block's paragraph/heading options with configurable textBlocks #6382) - while keeping the option for project migrations
  • existing library blocks that have migrations: Youtube and ExternalLink currently can't have project migrations

Proposed solution

Add a second migrateVendor, a sibling of the migrate option the application fills. They count from 1 in $$vendorVersion, independently of the block's version, and run before the block's own migrations. migrate stays exactly as it is, and because the two options are separate, a library never merges its migrations into the application's object and neither side can overwrite the other's.

example (in real world this would be split in library and project):

createBlock(VideoBlockData, VideoBlockInput, {
    name: "Video",
    migrate: { //defined in project
        version: 1,
        migrations: typeSafeBlockMigrationPipe([ChangeTitleMigration]),
    },
    migrateVendor: { //defined in library
        version: 1,
        migrations: typeSafeBlockMigrationPipe([ChangeAspectRatioMigration]),
    },
});

naming

I decided to name the new counter "vendor", because it is a migration from a 3rd party vendor. Other option would be "library". A single vendor counter is enough, any other package providing blocks should also use vendor migrations.

Further information

Differences to #6390:

  • There are exactly two chains instead of freely named scopes, and the library's chain is its own option rather than a nested scopes record inside the application's.
  • The application-facing API is untouched: migrate keeps its shape, and $$version its meaning — only $$vendorVersion is added next to it.
  • Vendor migrations run before the block's own migrations, instead of after.
  • The migrations Dextinity ships actually move into the new chain, which is what frees the block's versions for applications. Their legacy counter is split before any migration runs, so a chain can't seed itself from a counter another chain has just advanced.

Note: extending eg. Youtube block is currently not possible because Data and Input classes are not exported.

Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH

A block's version is a single counter, so only one party can advance it.
A block that receives migrations from the library providing it as well as
from the application using it therefore has to share the counter, which
breaks as soon as both sides add a migration.

Migrations shipped with a block are declared with vendorVersion and
vendorMigrations and count independently in $$vendorVersion. They run
before the block's own migrations, which keep using version, migrations
and $$version unchanged.

The DraftJS migration of createTipTapRichTextBlock becomes such a vendor
migration, so it no longer occupies version 1 of the block. Content saved
with the previous numbering is split into the two counters when it is
loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
@github-actions
github-actions Bot requested a review from VPS-Obi September 18, 2026 06:41
@nsams
nsams marked this pull request as draft September 18, 2026 06:48
The migrations of ExternalLinkBlock, YouTubeVideoBlock and DamVideoBlock
belong to Dextinity, not to the application using them, so they count in
$$vendorVersion from now on and leave the block's own version free.

Block instances saved before the move know only $$version, which carries
both chains. legacyVendorVersions states how many of those versions
belong to the vendor chain, so the counter is split up when such an
instance is loaded and no migration runs twice. The DraftJS migration of
createTipTapRichTextBlock uses the same mechanism instead of its own
helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
Vendor migrations lived in the same migrate object the application fills,
so a library adding its migrations had to merge them into the
application's object, where either side can overwrite the other's.

migrateVendor is a sibling of migrate instead, with its own version,
migrations and legacyVersions. Neither side sees the other's option, so
the block's own migrations can't drop the ones shipped with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
An application can extend a block it doesn't own by extending its data
and input classes. The extending block declared no vendor migrations, so
it never ran them - while the version stamp it inherited from the
extended block claimed it had, leaving content marked as migrated that
never was.

The vendor migrations therefore travel with the BlockData class: a block
extending it runs them before its own migrations, and its own migrations
count from 1. The data and input classes of the blocks that ship vendor
migrations are exported, so an application can extend them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
…ng them

A block extending another block needs the classes that block is built
from. Exporting them puts two names into the package's API for every
block that may be extended, although the block itself is the thing
applications already have.

A block therefore carries them as blockDataClass and blockInputClass.
The classes stay unexported, and the blocks shipped with vendor
migrations are extended through their block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
Reaching a block's classes through the block put two more properties on
every block, for a case the blocks shipped with Dextinity don't have to
serve.

A block extending another block's data still inherits its vendor
migrations, which is what keeps both chains correct. Which blocks expose
the classes to extend is left to the library providing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
Comment thread docs/docs/2-core-concepts/2-blocks/5-migrations.mdx Outdated
nsams and others added 2 commits September 18, 2026 11:49
Migrations of a block with migrateFromDraftJs are numbered like any other
block's now, so the block's page has nothing to add to the migrations
page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
@nsams
nsams marked this pull request as ready for review September 18, 2026 09:58
@nsams

nsams commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

@greptileai

Applications write the migrate options inline and never name their types,
so exporting them only grows the package's API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
@greptile-apps

greptile-apps Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no outstanding correctness, security, or repository-rule findings.

Summary

This PR introduces an independent vendor migration chain for library-provided blocks.

  • Adds migrateVendor and the $$vendorVersion counter, with vendor migrations running before application migrations.
  • Splits legacy $$version values for migrations moved into the vendor chain.
  • Preserves inherited vendor migrations and rejects conflicting vendor chains.
  • Moves existing Dextinity migrations to the vendor chain and updates tests, documentation, and the demo.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Load raw block data] --> B{Legacy vendor migrations configured?}
    B -->|Yes and no vendor counter| C[Split legacy version into application and vendor counters]
    B -->|No| D[Use existing counters]
    C --> E[Run pending vendor migrations]
    D --> E
    E --> F[Run pending application migrations]
    F --> G[Create block data]
    G --> H[Save with current version counters]
Loading

Reviews (3) · Last reviewed commit: "cms-api: Take the migrations applyMigrat..."

Comment thread packages/api/cms-api/src/blocks/block.ts Outdated
Comment thread packages/api/cms-api/src/blocks/migrations/types.ts
Comment thread packages/api/cms-api/src/blocks/migrations/applyMigrations.ts Outdated
Comment thread packages/api/cms-api/src/blocks/migrations/vendorMigrations.test.ts
A block extending a block with vendor migrations replaced the inherited
chain when it declared vendor migrations of its own. Both count in
$$vendorVersion, so only one of them can be expressed there, and the
block ran the wrong one while stamping the counter as if it had run the
other.

Supporting both would mean a counter per library. The vendor migrations
of a block come from one library instead, and a second chain is refused
where it is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByXXwcnSeVYzQfLujMknXH
@nsams

nsams commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

@greptileai review

Comment thread packages/api/cms-api/src/blocks/migrations/applyMigrations.ts
Comment thread docs/docs/2-core-concepts/2-blocks/5-migrations.mdx Outdated
Comment thread packages/api/cms-api/src/blocks/block.ts Outdated
VPS-Obi
VPS-Obi previously approved these changes Sep 21, 2026
@VPS-Obi
VPS-Obi merged commit 0076f28 into main Sep 21, 2026
18 checks passed
@VPS-Obi
VPS-Obi deleted the claude/pensive-franklin-gws0y7 branch September 21, 2026 11:24
VPS-Obi pushed a commit that referenced this pull request Sep 23, 2026
…e textBlocks (#6382)

The text block type select could only offer a fixed paragraph entry plus
every enabled heading level, in a fixed order. A design that wants two
entries for the same tag — a display headline above a regular heading 1
— or a different order could not be configured.

`textBlocks` configures the entries explicitly and decouples a text
block from the tag it is stored as:

```tsx
createTipTapRichTextBlock({
    textBlocks: [
        { name: "paragraph", label: "Paragraph", tag: "p" },
        { name: "display", label: "Display", tag: "h1" },
        { name: "heading-1", label: "Heading 1", tag: "h1" },
        { name: "heading-2", label: "Heading 2", tag: "h2" },
    ],
    defaultTextBlock: "paragraph",
});
```

Two entries on the same tag only stay apart if the content records which
one the editor picked, so paragraphs and headings are stored as a single
`textBlock` node that names its entry. The tag is not stored at all — it
follows from the configuration. The name is therefore the only thing
content carries, and a node's type, its level and its text block can no
longer drift apart.

The site renders by that name instead of by heading level:

```tsx
const nodeMapping: Record<string, TipTapNodeHandler> = {
    textBlock: ({ node, children }) => <Typography variant={textBlockToVariant[node.attrs?.textBlock]}>{children}</Typography>,
};
```

Content written before this change — `paragraph` and `heading` nodes
carrying a `level` — is converted when the block is loaded, by a vendor
migration (#6401). A project needs no migration of its own. Its own
migrations run after Dextinity's and therefore see the converted nodes,
so one written against `{ type: "heading", attrs: { level } }` has to
match `{ type: "textBlock", attrs: { textBlock } }` instead.

Because every text block is the same node now, a list item's content
expression can no longer keep a heading out of it. The editor takes the
content out of the list when a heading is chosen there, turns a heading
back into a paragraph when it is wrapped in a list, and the API rejects
a heading text block inside a list item.

First of four stacked pull requests: #6383 moves the styles into the
text blocks, #6384 adds `defaultStyle`, #6385 splits the block's
Storybook stories by topic.

## Example

`createTipTapRichTextBlock.test.ts`/`.test.tsx` cover the configuration,
the stored format and what a list item accepts,
`buildTextBlockNodeMigration.test.ts` the conversion. In Storybook:

- [Heading
Levels](https://tiptap-text-blocks--69df3371c46abe69b5199825.chromatic.com/?path=/story/blocks-tiptaprichtextblock--heading-levels)
- [Heading
Only](https://tiptap-text-blocks--69df3371c46abe69b5199825.chromatic.com/?path=/story/blocks-tiptaprichtextblock--heading-only)
- [Heading Only With Text Block
Styles](https://tiptap-text-blocks--69df3371c46abe69b5199825.chromatic.com/?path=/story/blocks-tiptaprichtextblock--heading-only-with-text-block-styles)
- [List Text
Block](https://tiptap-text-blocks--69df3371c46abe69b5199825.chromatic.com/?path=/story/blocks-tiptaprichtextblock--list-text-block)

Session:
https://claude.ai/code/session_f3c6ad37-c68b-4187-85b6-2abe5aa95c35

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants