Skip to content

feat!: implement per-block plugin data - #192

Open
gohabereg wants to merge 12 commits into
spec/tunes-as-pluginsfrom
feat/plugin-block-data
Open

gohabereg wants to merge 12 commits into
spec/tunes-as-pluginsfrom
feat/plugin-block-data

Conversation

@gohabereg

@gohabereg gohabereg commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Implements the plugin-block-data change from #189. Stacked on #189 — review that one first; this PR's diff is implementation only.

Makes per-block data a working, plugin-owned store. Today BlockNode has a tunes store that nothing above the Model can reach: updateTuneData crashes for an entry the block wasn't created with, BlocksAPI can't read or write it, undo is a silent no-op, and CollaborationManager drops the event as "Unknown event type".

What's in it

  • Model. The tunes store becomes plugins, keyed by plugin name. Writing to an absent entry creates it; setting a key to undefined removes it; empty entries are left out of serialization.
  • Renames. TuneIndex → PluginDataIndex, Index.tune() → Index.pluginData(), TuneModifiedEvent → PluginDataModifiedEvent, BlockTuneName/BlockTuneSerialized → PluginDataName/PluginDataSerialized, and the BlockTune model entity → PluginDataNode.
  • Undo/redo. EditorDocument.modifyData applies Modified changes addressed by a PluginDataIndex, so the generic inversion in UndoRedoManager works for plugin data.
  • Collaboration. PluginDataModifiedEvent becomes a Modify operation, and OperationsTransformer gains a plugin-data branch so such an operation can also be the one transformed against — without it the transformer throws 'Unsupported index type' on the first remote plugin-data change, on every client and on ot-server. ModifyOperationData's payload widens from Record<any, any> so scalar values are representable. Two clients writing the same key converge on the rule data values already use: an incoming operation becomes Neutral when the local client still holds an unsent write to that same block, plugin and key, which is what stops it applying the remote value and ending on one the server does not hold.
  • API. BlocksAPI.getPluginData({ block, plugin }) and updatePluginData({ block, plugin, data, userId? }); insert/insertMany accept a plugins map; move and convert preserve plugin data and split leaves it on the original block. A new augmentable EditorjsPluginDataMap types both accessors, alongside EditorjsPluginApiMap and ToolPluginOptionsMap.
  • v2 conversion. composeDataFromVersion2 carries each v2 block's tunes into plugins, keys verbatim, so a v3 plugin adopting a v2 tune's name finds its data. Object data is copied key by key; a primitive or array is stored under value. The converter dropped tunes silently before.

Fixed along the way, unrelated to plugin data

BlocksManager.insert dropped the block's data entirely. It spread ...data into the block init while BlockNode reads a nested data key, so api.blocks.insert({ type, data }) produced an empty block of the right type — on main today, with nothing to do with tunes or plugin data. It surfaced here because plugins had to be threaded through the same call, and the init object made the mismatch plain. data is now passed under its own key, and BlocksAPI.insert also forwards focus, plugins and userId, which it was silently discarding.

Worth reviewing on its own terms: it is a behaviour change to a public method that any caller passing initial data has been hitting.

Breaking (pre-1.0)

The serialized block key tunes → plugins, and the serialized index k: "tune" → k: "plugin". ot-server is not a passthrough — it keeps its own EditorJSModel and applies every operation — so clients and server deploy together.

Notes for review

  • The SDK's BlockTune tool contract is untouched here. It never read Model tune data; removing it is the next PR in the stack.
  • The naming of the plugins key is under discussion in docs(openspec): propose replacing Block Tunes with plugins #189. If it changes, the rename is mechanical and lands here.
  • Task 6.4 is intentionally unchecked: the archive gate uses an all-checked tasks list as the ready-to-archive signal, and its second half (filling the new capability's ## Purpose, which the mechanical fold writes as a placeholder) can only happen after /archive.

Verified: yarn build, yarn lint and yarn test clean; surviving mutants killed in the plugin-data paths; openspec validate plugin-block-data --type change passes.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Unit Tests

Package Coverage Delta
@editorjs/dom-adapters 86.95% 0% ⚪️
@editorjs/shortcuts-plugin 100% 0% ⚪️
@editorjs/model 98.56% +0.08% 🟢
@editorjs/core 76.39% +3.25% 🟢
@editorjs/editorjs 100% 0% ⚪️
@editorjs/ot-server 20% 0% ⚪️
@editorjs/clipboard-plugin 66.66% 0% ⚪️
@editorjs/model-types 67.85% +8.03% 🟢
@editorjs/collaboration-manager 86.75% +0.94% 🟢

Mutation Tests

Package Mutation score Dashboard URL
@editorjs/collaboration-manager 56.04% 🔴 Dashboard
@editorjs/clipboard-plugin No files to mutate found.
@editorjs/dom-adapters No files to mutate found.
@editorjs/core 71.26% 🟡 Dashboard
@editorjs/shortcuts-plugin No files to mutate found.
@editorjs/model 90.00% 🟢 Dashboard

gohabereg and others added 12 commits October 13, 2026 17:05
Renames the model's tune vocabulary so per-block extra data belongs to
plugins rather than to a dedicated Block Tune entity:

- BlockTune entity -> PluginDataNode, with key deletion when a value is set
  to undefined and an isEmpty getter
- TuneIndex -> PluginDataIndex, serialized as {"k":"plugin",...,"plugin":...}
- TuneModifiedEvent -> PluginDataModifiedEvent, BlockTuneEvents ->
  PluginDataEvents
- BlockTuneName/BlockTuneSerialized -> PluginDataName/PluginDataSerialized
- the serialized block key `tunes` -> `plugins`

Behavior changes beyond the rename:

- BlockNode.updatePluginData creates a missing entry instead of throwing. The
  node is created empty and its listener attached before the first update,
  because PluginDataNode dispatches synchronously.
- entries live in a null-prototype record, and an empty plugin name throws, so
  a document-supplied name like __proto__ is stored as plain data
- empty entries are left out of serialization
- EditorDocument.modifyData applies a Modified change addressed by a
  PluginDataIndex, so undo/redo and remote operations can replay plugin data
- the bubbled event carries the acting user from the context instead of a
  hardcoded 'user'

BREAKING CHANGE: the serialized block key `tunes` is now `plugins`, and the
serialized index kind "tune" is now "plugin".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- re-export the renamed model types (PluginDataName, PluginDataSerialized,
  PluginDataIndex, PluginDataModifiedEvent, createPluginDataName)
- declare EditorjsPluginDataMap, a third augmentable map alongside
  EditorjsPluginApiMap and ToolPluginOptionsMap, and resolve a plugin's data
  shape through PluginDataFor<Id>. An undeclared id falls back to a plain
  record rather than never, since per-block data is often stored by a plugin
  that has no reason to publish its shape.
- add BlocksAPI.getPluginData / updatePluginData and a `plugins` map on insert

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- BlocksAPI.getPluginData / updatePluginData, backed by BlocksManager, with
  the acting user defaulting to the configured one
- insert() accepts a `plugins` map, and convert() carries plugin data over to
  the converted block
- fix insert() dropping the block's data: BlocksManager spread `...data` into
  the block init while BlockNode reads a nested `data` key, so inserting with
  data silently produced an empty block. `plugins` is passed as its own key.
- fix BlocksAPI.insert not forwarding `focus`, `plugins` and `userId`
- add UndoRedoManager integration tests over a real model: undoing a first
  write removes the entry, redo restores it, and undoing one key leaves the
  plugin's other keys untouched
- fix the BlocksAPI integration spec wiring a second, empty model into the API,
  which made every direct model read in it see an empty document

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- map PluginDataModifiedEvent to a Modify operation carrying the new and
  previous values, instead of logging it as an unknown event type
- add a PluginDataIndex branch to OperationsTransformer#applyTransformation,
  which dispatches on the against-operation's index kind and previously threw
  'Unsupported index type'. Without it the first remote plugin data change
  threw in OTClient's pending-operation reduce, in transformStacks for every
  remote event, and in ot-server's conflicting-operation reduce.
- widen ModifyOperationData's payload from Record<any, any> to unknown, so a
  scalar plugin data value is representable, and narrow the two payload length
  reads that relied on the old type
- move the ot-server fixtures off the `tunes` key

Two concurrent writes to the same key stay untransformed: server order decides
and the last applied one wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mutation testing (scoped to the changed files, as CI does) pointed at four
real gaps:

- BlocksManager.insert: nothing asserted that block data is nested under a
  `data` key rather than spread, nor that the plugins map is forwarded and
  omitted when absent
- convertBlock: nothing covered carrying plugin data to the converted block
- EditorDocument.modifyData: the PluginDataIndex branch had no model-level test
- BlockNode: the initial-data path of plugin node creation was unasserted

Fixing the last one surfaced a bug in BlockNode.serialized: it assigned entries
into a plain object, so a plugin named `__proto__` set the object's prototype
instead of becoming a key and vanished from the serialized block. The entry map
is now built with Object.fromEntries. The previous test only passed because its
expected literal was broken the same way — writing the key as a computed
property, to satisfy the naming-convention lint rule, is what exposed it.

Scores for the changed files: model 90.38 (threshold 75), core 86.72,
collaboration-manager 52.08 (threshold 0, unchanged file-level scores). No
surviving mutants remain on lines this branch touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
composeDataFromVersion2 dropped each v2 block's `tunes` silently. It now maps
them onto the v3 `plugins` key:

- names are kept verbatim, so a v3 plugin that adopts a v2 tune's name finds
  its data without a rename table
- object data is copied key by key
- a primitive, an array or null goes under the `value` key, since a plugin entry
  is a key/value map while v2 tune data is any JSON value
- a block with no tunes, or an empty map, produces no `plugins` key
- the entry map is built with Object.fromEntries, so a tune named `__proto__`
  survives instead of setting a prototype

Adds the file's first spec, including the converter's existing data handling,
and an integration test taking a v2 document through the converter into the
model and reading it back with api.blocks.getPluginData.

The spec stubs DOMParser, which the pre-existing inline-fragment extraction
needs and this package's node test environment lacks. That extraction stays
untested, so the file's mutation score is low (25%, threshold 0) — it simply
had no spec at all before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- model.md, events.md, index-serialization.md: PluginDataNode,
  PluginDataIndex (`k: "plugin"` with `plugin`/`key` fields),
  PluginDataModifiedEvent, and Index.pluginData
- plugins.md: a new "Per-block plugin data" section for plugin authors — the
  two API calls, the EditorjsPluginDataMap augmentation, and the behaviors that
  bite (key removal, per-key undo granularity, last-writer-wins on the same
  key, preserved data for unregistered plugins, move/convert/split, v2 carry
  over) — plus the two new api.blocks rows and the third type map
- diagrams: model-tree-structure, events-catalog, architecture-overview
- docs-updater agent: map the renamed model entity to docs/model.md

Mentions of the Block Tune *tool kind* stay: they belong to remove-block-tunes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every task is done except the final one, which is the ready-to-archive signal:
leaving it unchecked keeps the archive gate quiet until a maintainer comments
/archive on the PR, per CONTRIBUTING.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six comments added by this change ran to three. Same content, tightened:
the v2 tune mapping, the BlockNode class doc and its `#plugins` field, the
`updatePluginData` contract and `PluginDataFor`'s fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PluginDataNode.update` dispatched unconditionally, carried over from
`BlockTune`, where it was nearly harmless because tunes were rarely
written. `updatePluginData` is now a public API that plugins call on every
toggle, and each event becomes an operation for collaborators and a step on
the undo stack — so re-applying the state a plugin just read filled the undo
stack with entries that undo nothing.

Storing the value a key already holds, or removing a key that is not there,
now returns before dispatching. The removal case needs the extra clause:
both sides are `undefined` whether the key is absent or present holding
`undefined`, and only the latter is a real change.

Also locks down the wire format for a removal, which has no test today: the
payload is `undefined`, `JSON.stringify` drops the key outright, and the
removal survives only because reading a missing key gives `undefined` back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two clients writing the same plugin data key left one of them on a value
the server does not hold. Remote operations are transformed against the
pending local ones, and the plugin data path returned them untouched, so
the client with an unsent write applied the incoming value over its own --
and since its operation is never echoed back to it, nothing restored what
the server ended up with.

The rule is now the one #transformAgainstDataOperation already applies to
data values: an against-operation with no revision counts as the later of
the two, and the operation being transformed becomes Neutral. It is scoped
to the same block, plugin and key, since nothing else describes the same
value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gohabereg
gohabereg force-pushed the feat/plugin-block-data branch from 544ca4f to dd75828 Compare October 13, 2026 16:09
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.

1 participant