Skip to content

0001. One document per field item, stored in a versioned envelope

Status: Accepted

Date: 2026-09-13

Context

The module stores documents produced by the BlockNote editor. BlockNote's editing contract is a single array of blocks: the editor is constructed from a Block[] array, editor.document reads one back, and nesting is expressed by a children array inside each block rather than by references between blocks. There is no upstream concept of a block that exists independently of the document it sits in.

Several properties of that data shaped the storage decision.

The document JSON carries no version marker of its own. Upstream runs schema migrations only for documents held in a Yjs binary format; a plain JSON document has nothing in it that says which BlockNote release produced it. BlockNote is pre-1.0 and has changed the JSON shape repeatedly in recent minors, including the addition of the divider block, the rework of checklist and toggle blocks, a change in how toggles export, the introduction of a plain content kind, and the move of color attributes between nodes. A stored document that cannot say what it is cannot be migrated safely.

Block ids are UUID v4 strings generated by the editor. They are preserved when a document is loaded as initialContent, but paste regenerates them, and drops regenerate them when content crosses editors. They identify a block inside one editing session reliably and identify it across a paste not at all.

A blank editor does not produce an empty document. It produces one empty paragraph, and the editor refuses an empty initialContent array. Emptiness therefore has to be defined by the module rather than read off the data.

Drupal core has no portable json column type. The core issue that would add one has not landed, and the only database driver that offers a native JSON type in core today is SQLite. Storage of the document must use a text column with size: big, with utf8mb4 verified end to end.

Rendering goes through the filter system, and a filter plugin receives only a string. Anything the renderer needs in order to interpret a document, for every revision that already exists, has to be inside that string.

Drupal Canvas took the opposite route for its own JSON: its ADR 0006 replaced two JSON blobs per revision with one field row per component instance, citing SQL querying and auditing of component usage, straightforward JSON:API read and write without custom serialization, cheap per-instance columns, brittle and risky update paths for JSON blobs, and, as a stated bonus, fewer collaborative-editing conflicts because two users can touch different component instances at once. That reversal is the strongest available argument against storing a document as one value, and it deserved an answer rather than silence.

Decision

One field item holds one document.

The field type is blocknote_document, extending FieldItemBase. It does not extend TextItemBase: the Text Editor module's file-usage scanning inspects only field types whose class extends TextItemBase, and those scanners look for HTML. A JSON document must not be fed to them.

The item has these properties.

value holds an envelope, {"version": 1, "blocks": [...]}, in a text column with size: big. The blocks member is exactly what BlockNote produces, unmodified. The envelope exists because the renderer is a filter plugin and a filter plugin receives only text, so the document schema version has to travel inside the value to reach the renderer for every revision that was ever saved. version is the module's own document schema version, not BlockNote's; it is 1 at launch, and the module documents which BlockNote release each version corresponds to. DocumentUpdater calls DocumentNormalizer::stampVersion() before it validates, and the method returns a document whose version equals DocumentParser::SCHEMA_VERSION as it is and throws \LogicException for any other version, because relabeling a document would store blocks written for one schema under another schema's number. Nothing stamps on the widget path. The client opens an envelope only when its version equals the schema version the editor plugin sends, and shows the unreadable-document notice for any other, a missing version included. It writes back the envelope it received with only blocks replaced and adds no version, so a client that does not understand a newer envelope member cannot drop it. An envelope whose version is missing or is not an integer never reaches the stamp: DocumentParser::parse() refuses it as DocumentError::InvalidVersion with an empty context, so no version is reported at all, and only a version that is an integer and not the current one is refused as DocumentError::UnsupportedVersion, which carries @version.

format holds a filter format id, which is the editor profile and the permission set (see 0002). value and format migrate and translate together and are never separated. A field setting allowed_formats, modelled on core text fields, restricts which formats the field offers; empty means all of them.

uuid holds a durable document identity. It is assigned in preSave() when the item has none, and regenerated there when the identity is already taken. Two rules decide who keeps a colliding identity. Within one entity, the document that already carried it when the entity was last stored keeps it; failing that, the source language's document does, then the first of the others. That covers translation creation, which copies the source language's field values, and it does not depend on the order core happens to return translations in, which is language weight order and changes when an administrator reorders languages. Across entities, an identity its host did not already store is checked against every revision of every document field of every entity type and regenerated when another entity holds it, which covers duplicating an entity, JSON:API POST and PATCH, and a uuid living only in a pending revision; the query is paid only when an identity is new to its host, and it leaves out the host's own revisions, so a restored revision keeps its identity. External stores that need to correlate with a document use this value, not the field delta, because a delta changes when items are reordered.

Three computed properties are exposed: processed, the document rendered through its format with cacheability preserved, modelled on core's TextProcessed but going through the processed_text render element; plain_text, the text of every block, for search, tokens and meta tags, walked from the document rather than stripped out of processed; and document, the decoded envelope, so a JSON:API client reads structured data instead of an escaped string. All three are non-internal, as core's processed is, so JSON:API exposes them, and all three are read-only: a client writes value.

Emptiness is defined on the server. A document is empty when normalization leaves no content-bearing block. An empty paragraph is not content. A divider, a file or media block, or any non-empty text is. The widget maps a blank editor to an empty value, so required-field validation means what a site builder expects it to mean.

Validation and normalization are services, and entity storage does not call them by itself. The document constraint covers form and JSON:API saves, and every trusted caller inside the module invokes the services explicitly. Format access is enforced on every save today by core rather than by this module: core attaches an AllowedValues constraint to every filter_format-typed property, which refuses on every validation any format the acting account may not use, and the widget suppresses that violation on the form for a format the user cannot change, the way core's TextareaWidget does. The module does not narrow that check to changed values, although an earlier draft of this record said it would: core evaluates AllowedValues against the formats the acting account may use on every validation, so every case a change-aware check would catch has already been caught, and narrowing it would mean overriding the constraint on the property and replacing a core security behaviour, one that core's own text fields rely on, with a module-owned one. What core does not check is the field's own allowed_formats setting: FilterFormat::getFormatsAsOptions() reads the format repository and nothing else, so outside the widget a programmatic or JSON:API write can store a BlockNote format the field was configured not to offer. The document constraint adds exactly that check, refusing a format absent from a non-empty allowed_formats with a violation on the format property. Limits on byte size, nesting depth, block count and table size are enforced from the first release, as a container parameter a site raises in its own services.yml rather than as exported configuration.

Alternatives considered

One database row per block, in the style of Drupal Canvas. Rejected. Canvas's reasons apply weakly to this data. A Canvas component tree is a composition of independently addressable component instances whose inputs change shape when a component implementation changes, which is what makes per-instance rows and per-instance update paths worth their cost. A BlockNote document is a linear prose document whose editor contract is a single array; every save replaces the whole array anyway, because that is what the editor hands over. Decomposing it would mean inventing stable per-block identity that the editor does not provide, since paste regenerates block ids, and reassembling the array on every load and every save. The argument about querying content is real but is answered more cheaply by the computed plain_text property, which Search API can index, and by the reference seam described in 0005. The collaboration argument does not apply, because collaborative editing in BlockNote runs over a separate Yjs document and never through this field.

A native JSON column. Not available. Core has no portable JSON column type, so this would restrict the module to one database driver.

No envelope, with the version stored in a second column. Rejected. The renderer is a filter plugin and receives only the text of one property. A version in a sibling column would be unreachable at render time, which is exactly when a document of an older schema has to be interpreted.

No version at all, inferring the schema from the shape of the data. Rejected. BlockNote's JSON has changed in ways that are not always distinguishable by shape, and the module would be guessing on content that already exists in production.

Reusing a core text_long field, as the ProseMirror module does. Rejected. It inherits the Text Editor module's HTML scanners, offers no place for the envelope rules or the document uuid, and lets any text format be selected on the field.

Consequences

The whole document is read and written as one value. A change to one word rewrites the entire value column for that revision, and a long document costs its whole size on every save. That is accepted. The ceiling is blocknote.limits.max_bytes, 1 MiB unless a site changes it in its own services.yml, and the parser refuses anything larger. The typical size is well below it: the realistic long document in the fixture corpus, tests/fixtures/documents/long-document.json, holds 258 blocks nested five levels deep and measured 161,100 bytes compact on 2026-09-14.

Queries cannot reach inside a document in SQL. Anything that needs to ask "which documents contain block type X" has to go through the indexed computed properties, through the reference seam, or through a full scan. No per-block index is provided.

Update paths are code, not migrations of rows. When the envelope version increases, an update hook has to walk stored documents and transform them, and the fixture corpus is the test for that transformation. This is the cost Canvas's ADR 0006 warned about, accepted knowingly because the alternative costs more here.

The envelope is a module invention and is not what BlockNote produces. Anything reading the column directly, including external consumers and hand-written migrations, has to unwrap it. This is documented, and the JSON:API representation of value is a JSON string containing the envelope.

Because uuid is regenerated whenever it collides, a translation and its source never share a document identity, and a duplicated entity never inherits one. Code that assumed the identity follows the delta will be wrong; the delta is not stable. Storage refuses no identity itself; preSave() regenerates one another entity holds, so a site sees one host per identity except for a race between two saves minting the same posted identity at once, which no unique index can close because the identities sit in one table per field and per revision. The lookup that finds another holder is not access checked, so a caller whose posted identity comes back regenerated learns that some entity, perhaps one it may not view, holds that uuid; a version 4 uuid cannot be guessed, so this discloses only a value the caller already had.

Emptiness being a server-side judgement means a document that looks empty in the editor and a document that is empty in storage are the same thing only because the widget and the normalizer agree. That agreement is covered by tests, and is a place where a future change to either side can drift.

References

  • BlockNote 0.54.2, block and document types: @blocknote/core src/schema/blocks/types.ts, src/editor/BlockNoteEditor.ts.
  • Block id generation and preservation: @blocknote/core src/extensions/tiptap-extensions/UniqueID/UniqueID.ts.
  • Schema migration limited to Yjs documents: @blocknote/core src/yjs/extensions/schemaMigration/.
  • Drupal core TextItemBase and TextProcessed: core/modules/text/src/Plugin/Field/FieldType/TextItemBase.php, core/modules/text/src/TextProcessed.php.
  • Core issue "Add json as core data type": https://www.drupal.org/project/drupal/issues/3343634
  • Drupal Canvas ADR 0006, one field row per component instance: docs/adr/0006-One-field-row-per-component-instance.md in the Canvas repository.
  • 0002, 0005.