Document workflows¶
docs/extending.md is how a module adds content to a document, docs/workflows.md is how a module observes and changes a document it did not author.
Everything below is a service or an event another module may depend on. Names, payload members and orderings are part of what the module promises within a major version; where a limit is claimed rather than a guarantee, it is stated as one.
1. What the seam is for¶
Three things, and only three. Reading a stored document at a revision the caller chooses. Proposing a change to one against the revision and the bytes the caller last saw, and being told in one word what became of it. Observing the documents a save or a delete changed, after the change has been committed.
It is not a comments system, not an approval workflow, not collaborative editing, and not an atomic bridge between Drupal and anything outside it. A module that wants any of those builds them on this seam and owns their policies: which proposals are accepted, what an annotation anchors to, what happens to an external record when a Drupal transaction rolls back. The base module supplies the identity, the read, the guarded write and the notification, and nothing above them. Section 12 says what that leaves a comments or collaboration module to own.
The seam does not enumerate documents, and the events report only changes committed after a subscriber is installed, and none of those the HTML converter writes into older revisions (section 10). A mirror seeds itself: it takes the entity types and fields entity_field.manager answers for getFieldMapByFieldType('blocknote_document'), queries each type for its hosts, reads every document on each host with loadAll(), and correlates later events on the uuid and eventId.
2. Reading a document¶
blocknote.document_repository, Drupal\blocknote\Document\DocumentRepository, has four methods and checks no access at all.
load(DocumentIdentity $identity, DocumentRevision $revision = DocumentRevision::Active)reads the document an identity names.loadFrom(FieldableEntityInterface $entity, string $field_name, int $delta = 0)reads from an entity already in hand and loads nothing, which is what a hook implementation uses.loadAll(FieldableEntityInterface $entity)reads every document on one entity, keyed"$field_name.$delta".loadByUuid(string $uuid, DocumentRevision $revision = DocumentRevision::Active, bool $include_pending_revisions = FALSE)finds a document wherever it now sits.
Each of them answers a StoredDocument or NULL. A StoredDocument carries the resolved identity, the exact revision and translation entity the item came from, the stored value, the parsed result, the hash, the formatId and whether that format is one this module renders, the three revision flags isDefaultRevision, isLatestRevision and isLatestTranslationAffectedRevision, and the selection that says how the revision was chosen. hasDocument(), getDocument(), getError() and getSchemaVersion() read the parse result without repeating it.
Drupal\blocknote\Document\DocumentRevision is the word a caller asks with. Each case maps to core as follows.
Defaultis$storage->load($id).LatestisgetLatestRevisionId()thenloadRevision(), falling back toload()on an entity type that keeps no revisions.LatestTranslationAffectedisgetLatestTranslationAffectedRevisionId($id, $langcode)thenloadRevision(), with theEntityRepository::getLatestTranslationAffectedRevision()guard: a missing revision, or one that was the default revision when it was saved and is not the default revision now, falls back toLatest. An entity type that is translatable and revisionable but keeps norevision_translation_affectedfield cannot be asked the question at all, and is answered asLatest. TheselectionstaysLatestTranslationAffectedwhen the guard falls back, because it names the rule the revision was chosen by; the revision read is the one on the identity.ActiveisentityRepository->getActive($type, $id, ['langcode' => $langcode]).Namedis not passed in:load()andloadByUuid()throw\InvalidArgumentExceptionfor it. It is what the repository reports back when it read one exact revision, either because the identity carried a revision id or because the caller handed over a revision object.
A revision id on the identity wins over the word passed in: it is read at exactly that revision and reported as Named. A caller that wants the word to win passes withRevisionId(NULL) first. This is the precedence a caller holding an old identity most often gets wrong, and it is the one that makes a revision revert visible rather than silently followed.
An active workspace changes what Default, Latest and Active resolve to, because it changes what the storage and the entity repository return. This release claims nothing about workspaces beyond that.
The repository takes no current_user and runs no access check. A caller putting a document in front of a request owns view access for it, the same way a caller loading an entity directly does. Writes are checked, and section 4 says where.
loadByUuid() queries the indexed uuid column once per entity type and field that carries a document field. Its default path queries the field data table, which holds the default revision only, so a document living in a pending revision alone is not found: that is what $include_pending_revisions is for, and it is how a moderated draft is reached. On the default path the revision word is applied in the language of the translation that carries the document in the default revision, as load() applies it in the identity's langcode; the source language stands in only when no translation carries it there. The flag replaces the revision word rather than joining it, because every revision is searched and the newest revision carrying the document is the one read, reported as Named. When more than one entity carries the uuid, the first is answered and a warning naming both is logged on the blocknote channel, on that path as much as on the default one: the revisions of one entity would fill the window that catches a second entity, so the search across revisions looks for a second host in a query of its own.
Translations resolve strictly: a translation that is not there is NULL rather than the default language. NULL is also the answer for an entity that is gone, a revision that is gone, a revision id that belongs to another entity, a delta out of range, an empty item, a field the bundle does not have and an identity whose host has never been saved. A PluginNotFoundException propagates for an entity type nothing defines, and an \InvalidArgumentException is thrown for a field that is not a blocknote_document field.
3. The identity and the two serialised forms¶
Drupal\blocknote\Document\DocumentIdentity is the one way a document is named. It is a final readonly class carrying uuid, entityTypeId, bundle, entityId, revisionId, langcode, fieldName, delta and schemaVersion, with equals() over every member, sameDocument() over the uuid alone, key() giving document:<uuid>, isStored() answering whether the host has been saved, and the three withers withRevisionId(), withDelta() and withUuid() answering copies.
toArray() writes the members in one fixed order, uuid, entity_type, bundle, entity_id, revision_id, langcode, field, delta, schema_version, and fromArray() is its inverse. That array is the payload of all three layers at once: the data-blocknote-document attribute the widget writes, the detail.identity of the client events, and the identity members of the server events. A member added to it is added to three public surfaces and to whatever an external store has already persisted.
What is durable is the uuid. It survives a new revision, a revision revert, a reorder within the field, and a translation being created, which gets a uuid of its own. The delta is not durable: a reorder moves it, and a store correlating on a delta correlates on where a document sat once. entity_id and revision_id are NULL on an add form, where the host has not been saved yet, so a client that saw an identity on an add form reconciles it after the first save by asking loadByUuid() for the uuid it was shown.
One rule governs a uuid and a delta that disagree. The uuid at the delta is verified, a mismatch is healed by finding the item that carries the uuid in the same field and translation, the neighbour sitting at the requested delta is never returned, and no item carrying the uuid answers NULL. The updater applies the same rule when it writes, so a reorder between a read and a write is a reorder and the write lands on the document rather than on the position.
ADR 0001 holds the collision rules the field item applies in preSave(): an item with no uuid is given one, one that collides with another document of the same entity is regenerated, and an identity its host did not already store is checked against every revision of every blocknote_document field of every entity type, and regenerated when another entity holds it, which covers duplicating an entity, posting a copy through JSON:API or 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. The widget never takes a stored document's identity from the submission; only a document that has none reads the posted one. Two saves minting the same posted identity at the same moment can both pass the query, because no unique index spans the field tables and their revisions: the field's storage is not a registry of identities, and a site correlating on them owns that registry.
The hash is sha256: followed by hash('sha256', $raw_stored_value), taken by blocknote.document_hasher over the stored bytes and never over a re-encode of the decoded document. A re-encode would compare two encodings of one document as equal, which is exactly how a concurrent write disappears without a trace. The hash lives on StoredDocument, on the widget attribute and on the events, and is never a field property.
4. Proposing a change¶
blocknote.document_updater, Drupal\blocknote\Document\DocumentUpdater, is the write path for anything that is not an entity form.
update(DocumentIdentity $base, string $expected_hash, string $value, UpdateOptions $options) writes $value into the document $base names, provided the store still holds the revision and the bytes the caller read. createTranslation(DocumentIdentity $source, string $langcode, string $value, UpdateOptions $options) adds a translation carrying the document and hands it to prepare, which fills the translation's required fields; the new document carries no uuid of its own, so preSave() mints one and the translation is a document in its own right. A translation that already exists is reported as stale, and so is a host whose entity type is not translatable, a document field that is not translatable, and a source identity whose revision is not the active one. A langcode the site does not have, a locked one or a language-neutral host is the \InvalidArgumentException core's addTranslation() throws: it is a programming error, like an unknown entity type, not a result.
UpdateOptions is a final readonly class: actor is required, because a write through this seam is made on behalf of somebody; newRevision asks a revisionable host for a new revision and is ignored by a host that keeps none; revisionLogMessage is recorded by a host that keeps a log; lockWait is how many seconds to wait for another writer before being answered, 0.0 meaning answer at once; and prepare is a closure handed the translation the document is written in, or the translation being created, inside the lock and the transaction, for a caller that has to touch the host as well as the document. What prepare changes is validated with the document; it must not save the host, and a call to DocumentUpdater from it is refused. A closure cannot be serialised, so an UpdateOptions carrying one cannot be put in a queue.
Both methods throw \LogicException when a transaction is already open on the connection. The updater writes in a transaction of its own, so it is called from outside any transaction: from a controller, a queue worker or a blocknote.document_saved subscriber, and never from prepare, an entity hook or a caller's own transaction.
UpdateResult answers one of six kinds and carries what a caller acting on that kind needs.
| Kind | What the result carries | What the caller does |
|---|---|---|
Saved |
document, the new StoredDocument |
Take it, and correlate on the identity and hash it carries |
Stale |
document, the document now in the store |
Re-read, reapply the change to what is there, and submit again. Never retry the same bytes blind. Only an actor allowed to write the document is told it; access is answered first |
Invalid |
violations, a ConstraintViolationListInterface |
Show them. Nothing was written |
Locked |
retryAfter, the longest the other writer can hold the lock (DocumentUpdater::LOCK_TIMEOUT), not the time it has left |
Try again sooner with a backoff, or pass lockWait. Nothing was read and nothing was written |
Forbidden |
access, an AccessResultInterface |
Stop. The actor may not write this document |
Gone |
Nothing | Drop the correlation. No item carries the uuid any more |
A kind other than Saved leaves nothing in the database: the updater rolls the transaction back where it opened one, and never commits it.
An Invalid result's messages are written for an author and change with the wording and the language, so a workflow that acts on which refusal it got reads each violation's getCode(). A violation of the module's BlockNoteDocument constraint, and one the updater raises for a value that does not parse, carries one of these codes and no other: the value of a Drupal\blocknote\Document\DocumentError case, such as forbidden_style, too_many_blocks or invalid_version, or one of the four the constraint keeps for the format and the block types, BlockNoteDocumentConstraint::NOT_A_BLOCKNOTE_FORMAT, FORMAT_NOT_ALLOWED, UNSUPPORTED_BLOCKS and MISSING_BLOCK_PLUGIN, whose values are not_a_blocknote_format, format_not_allowed, unsupported_blocks and missing_block_plugin. A block or inline content plugin that reports one of the kinds its validation context names gets that kind's own code: UNKNOWN_PROP, INVALID_PROP_TYPE and INVALID_PROP_VALUE are unknown_prop, invalid_prop_type and invalid_prop_value from a block and unknown_inline_prop, invalid_inline_prop_type and invalid_inline_prop_value from inline content, and a block's DANGEROUS_MEDIA_URL is dangerous_media_url. Any other kind is the plugin's own refusal, coded block_refused or inline_refused, and the kind the plugin reported is the violation's getCause(), a string such as heading_forbidden_toggle that tells one of the plugin's refusals from another; the constraint's other violations carry no cause. too_many_problems names no problem of its own: it says the list was cut short, because the content check stops after DocumentValidator::MAX_REPORTED_PROBLEMS problems, ten, so the document has more than the list shows. A violation another constraint raised carries that constraint's code, if it sets one.
The updater answers in a fixed order: the entity, the translation and the item (Gone), then access (Forbidden), then the revision and the hash (Stale), then the value and what prepare changed (Invalid). Inside the lock and under the host row it reads the document past the static cache, as DocumentRevision::Active names it for the identity's langcode: EntityRepository::getActive(), which is the revision an entity form edits and the revision the repository's default read returns, trusting nothing in the identity but the uuid and where to look for it. A revision id read that differs from the one on the base identity is Stale before the hash is looked at; equal revision ids with bytes whose hash does not match the expected one are the same-revision edit and are Stale too. Checking the revision first is why a revision revert makes a byte-identical token stale: the revert writes a new revision whose bytes may be exactly the ones the caller read, and the caller is nonetheless holding a document from before the revert. Gone is answered ahead of access, because there is nothing to ask access of, so an actor without access learns that an identity names nothing; that is recorded as a limit, since Gone is what stops a workflow retrying a deleted document forever.
Access is answered for the actor and not for the request: $translation->access('update', $actor, TRUE) on the translation the document is written in and the document field's edit access, either refusal being Forbidden. createTranslation() asks the same of the source translation and then, as the actor, when content_translation translates the entity type and the actor lacks translate any entity, the translation handler's getTranslationAccess($entity, 'create'), whose refusal is Forbidden as well. The account is switched to before validation and stays switched through the save and the entity hooks, so that core's AllowedValues constraint on the format property and a moderation transition are evaluated against the actor rather than against whoever the request is running as, and the events carry the actor as actorId. Validation is $translation->validate() filtered by the actor's field access, the chain JSON:API runs, so what prepare changed and every entity-level constraint is answered for the actor, and a host that is already invalid is refused as its entity form would refuse it.
The two write paths treat the schema version differently. The updater calls DocumentNormalizer::stampVersion() on the parsed document before it validates. stampVersion() returns a document at the version this release writes unchanged and throws \LogicException for any other, so a later release whose parser accepts an older version has to migrate the blocks before the call and cannot relabel them; in 1.0 the parser has already refused every other version. It then calls DocumentNormalizer::normalize() with the format's policy, which lets a block plugin implementing BlockNoteBlockNormalizableInterface rewrite a stored property to what the format permits, and writes a block style holding a listed style's classes in another order as the format lists them, so the bytes the updater stores, hashes and announces are the healed ones. The widget path does not stamp and does not normalize: the editor refuses to open an envelope whose version differs from editorSettings.schemaVersion, a missing version included, and writes back the envelope it opened with only blocks replaced. Section 11 says how a later schema version reaches stored documents.
5. Ordering¶
A subscriber author can rely on this order for a save made through DocumentUpdater.
- The updater refuses to run inside an open transaction (
\LogicException), acquires the lock namedblocknote_document:<uuid>, opens its own transaction and takes the host's base-table row withSELECT ... FOR UPDATE. - It reads the document uncached at
Active, answers access, checks staleness, switches to the actor, writes the item, runsprepareand validates the translation. - Each field item's
preSave()runs, minting a document uuid for an item that has none and regenerating one that collides. hook_ENTITY_TYPE_presave()andhook_entity_presave()implementations run.BlockNoteDocumentHostLockHookstakes the host row for any save of an existing revisionable host carrying a document field, andBlockNoteDocumentEventHooksruns after the others and reads the default revision that a save making another revision the default one replaces. A document a presave hook adds is stored without a uuid and reported on the entity's next save; a hook that adds one sets its uuid, and a caller of the updater adds it inprepare.- Storage writes the rows, inside the transaction
SqlContentEntityStorage::save()opens for itself. hook_entity_insert()orhook_entity_update()runs, still inside that transaction. This is whereBlockNoteEntityHookswrites thefile_usagerows and whereBlockNoteDocumentEventHooksdiffs the documents the entity holds against the documents it held, and hands each changed one, with a check that finds it in storage, toblocknote.post_transaction_dispatcher, which queues it.- The storage's transaction ends, and the updater commits its own.
- The updater switches the account back, calls
ChangeOriginTracker::forget(), evicts from the static cache a host it did not save, and releases the lock. - The updater drops its last reference to the
Transactionobject. Destroying it is what runs the post-transaction callbacks, and that is what dispatchesblocknote.document_saved.
Steps 3 to 6 are the same for a save made through an entity form, where there is no lock and no account switch; the queue then flushes wherever core's own root transaction object goes out of scope. A delete follows the same shape: hook_entity_predelete() gathers the documents of every revision, hook_entity_delete() runs inside the storage's transaction, one blocknote.document_deleted is queued for every document of every translation of every revision the entity held, a document living only in a pending revision included, and the queue flushes when the root transaction object is destroyed. The union runs both ways: a delete through the default revision reports what only a draft holds, and a delete through the latest revision, which is the revision content moderation's delete form hands over, reports what only the published revision holds. A document a draft removed was reported as ItemRemoved when the draft was saved and is reported again with EntityDeleted, so a mirror applies a delete idempotently.
SqlContentEntityStorage::save() always opens a transaction, so a subscriber hearing about a save made through normal entity storage always takes the deferred path. The immediate path, where PostTransactionDispatcher::dispatch() dispatches where it is called, belongs to a caller dispatching outside entity storage.
The outcome of the root transaction is the outcome of an event queued directly in it, or queued by the save DocumentUpdater makes inside the transaction it opened. An event queued inside a savepoint, raised while events are being dispatched, or flushed by a callback that is told the root rolled back, is dispatched only if what it reports is found in storage at the flush: a saved document at the revision, position and hash it names, a deleted entity that no longer loads, a removed item or translation that the latest revision no longer carries. A check that throws is logged and its event is dropped. A transaction manager that does not extend TransactionManagerBase reports no depth, so every event raised on it is checked.
The delete event's shape is fixed. identity is the document as it was last stored, previousIdentity is NULL because a deleted document did not move, previousRevisionId and previousHash carry the last known revision id and hash from the snapshot taken before the change, and newRevisionId and newHash are NULL because there is nothing left to read.
A save that stays a pending revision is compared with the revision it was loaded from, which is $entity->getOriginal() as ContentEntityStorageBase::doPreSave() sets it; a save that becomes the default revision is compared with the default revision it replaces. Under content moderation a draft edited through the form or DocumentUpdater is compared with the draft it was loaded from, and publishing a draft, or a revert that publishes a former revision, fires when the published bytes change; a revert that only writes a new draft is a draft save. The event does not say whether its revision is published: load($event->identity) reads exactly that revision and isDefaultRevision says, and a mirror of published content reads DocumentRevision::Default.
A subscriber runs after the root transaction has committed, and never for a write a rolled-back savepoint took back. The order among subscribers of one event is Symfony's own priority order.
6. Failure behaviour¶
An invalid document never reaches storage. Both write paths validate before they save, and a violation is an Invalid result or a form error rather than a row.
A hook_entity_presave(), hook_entity_insert() or hook_entity_update() implementation that throws, \Error included, rolls a save made through DocumentUpdater back, the queue is discarded, and nothing is dispatched. A save made through entity storage alone has SqlContentEntityStorage::save() roll back on an \Exception, and from Drupal 11.5 on an \Error too, which is core's own behaviour.
An EntityStorageException or any other throwable from the save rolls the transaction back, releases the lock and propagates. There is no UpdateResult for it and no event: a throwing hook is a defect in some module, not an outcome a caller can act on. The module adds no pre-save event of its own, which would be a second contract with a second failure story.
A subscriber that throws after the commit is logged with Error::logException() on the blocknote channel, and the save stands, because the data is committed and there is nothing left to undo. The limit is that the catch is per event and not per listener: a subscriber that throws costs its event the subscribers that would have run after it, and the next event is still dispatched.
Three defects in core's post-transaction callbacks shape the dispatcher, and no released or development version of core fixes any of them. A callback registered inside the transaction of a running post-transaction callback waits for a later root transaction, because TransactionManagerBase::purge() forgets the root that is ending only after its callbacks have returned (#3447097). A callback registered inside a savepoint that is rolled back still runs, told that the root transaction committed. A callback that runs after another callback which ended a transaction of its own is told how that transaction ended rather than how the root transaction did, because processPostTransactionCallbacks() reads the outcome from the connection again for each callback. No core issue reports either of the last two yet. The storage check at the flush, the check of every event when the dispatcher is told that the root transaction rolled back, and the flush of what a subscriber's own transaction left queued are therefore a compatibility path, kept for every core version the module supports. The order events are dispatched in does not depend on whether core has the fix for #3447097: a callback that runs while the queue is being flushed hands its events to the flush that is running.
Gone comes before access, in the order section 4 gives, for a host, a translation or an item that is not there, so an actor without access learns that an identity names nothing. The order stays: there is nothing to ask access of, entity ids can be enumerated anyway, core's JSON:API answers a missing entity before access in the same way, and Gone is what stops an approval workflow retrying a deleted document forever.
7. What is not atomic¶
The lock is advisory. It is named blocknote_document:<uuid>, held for DocumentUpdater::LOCK_TIMEOUT seconds, and only DocumentUpdater takes it, so what it does is answer a second writer of the same document Locked quickly. What excludes a lost update is the host row: from before its read to its commit the updater holds the host's base-table row, and every save of an existing revisionable host carrying a document takes the same row in presave, so no save commits inside the updater's window and a write that read before another committed comes back Stale. A writer that outlives the lock timeout loses its lock but not the row, so the next writer still waits for it. A form that loaded its entity before the updater committed and saves after it overwrites the updater's write: that is core's form concurrency, which core's changed-time constraint answers for an entity that records a changed time, not this seam's. SQLite ignores FOR UPDATE and serialises writers instead.
The queue flushes when the root Transaction object is destroyed, which in the updater happens after the lock has been released. A subscriber therefore runs on committed state with no lock held, and may call DocumentUpdater for the same document. A write a subscriber makes, through DocumentUpdater or a plain save, is announced in the same flush, after the events already queued. A subscriber that leaves a transaction open when it returns has that transaction's events wait for the next root transaction. For a form save the flush happens wherever core's own root transaction object goes out of scope, which is core's business and not this module's.
An event checked at the flush reads storage after the commit, so a concurrent write to the same document landing between the commit and the flush can make it look as though it never landed, and it is dropped; that write fires its own event.
The outcome core passes the dispatcher can be wrong either way, and only one way is caught. Told that the root transaction rolled back, the dispatcher checks every event against storage, so the events of a root transaction that committed are still dispatched. Told that the root transaction committed when it rolled back, which a post-transaction callback registered before the dispatcher's causes by committing a transaction of its own, the dispatcher cannot tell from inside its callback, and dispatches the events queued directly in the root transaction or by the save DocumentUpdater makes, for writes that were rolled back. That is the defect in processPostTransactionCallbacks() named in section 6, and the limit lasts until core passes every callback the outcome of the root transaction.
No atomic persistence across Drupal and an external service is claimed, and none is possible here. A post-commit subscriber's own writes are outside Drupal's transaction: if they fail, the Drupal change stands. A subscriber that must not lose work queues it rather than doing it inline.
Every event carries an eventId, a v4 uuid minted once per changed document at detection time. The same document changing twice carries two ids, and work retried from a queue carries the id it carried before, so a subscriber writing into another store can recognise what it has already applied and make its own retries safe.
8. The client lifecycle events¶
Three DOM events are dispatched on the textarea the editor stands in front of, never on the editor root and never on document. They bubble and are not cancelable, so one listener bound on document hears every editor on the page, provided it is bound before the editor attaches: at script evaluation rather than inside a Drupal.behaviors attach, or in a library that loads ahead of editor/drupal.editor.
blocknote:ready, after the editor has mounted and rendered, with its editable element on the page.blocknote:change, after the editor has written a changed document back.blocknote:destroy, after the editor has been torn down and the textarea given back.
A change a blocknote:ready listener makes to the document is kept: it is reported to core as soon as core registers its change callback, so the form submits it.
The detail of all three is { identity, hash, editorId, format, dirty }. identity is the array the widget wrote into data-blocknote-document, in the server's own snake case, with the hash among its members. hash is that same hash, echoed and never recomputed in the browser: it describes the bytes the document was loaded from, not whatever the editor currently holds. editorId is the data-blocknote-id value, which keys Drupal.blocknote.instances. format is the text format the editor was attached for. dirty says whether the serialised document differs from the document as this attach first serialised it, so it is false at ready except for a document the editor transformed on load, or stripped and written back while the field is in error; it is per attach, and after a teardown and re-attach it compares with what the previous editor wrote back and says nothing about the bytes the hash describes.
A missing or malformed attribute is one console.warn and nothing else. The events then carry identity: null and hash: null, and the editor works as it did before.
blocknote:change fires from the 400 ms trailing-edge write, after the document has been written back to the textarea, and only when the written value differs from the last value announced, the value the editor opened on counting as announced. It does not fire on the keystroke, and a change that leaves the serialised document alone, a caret move among them, announces nothing.
A detach whose trigger is serialize dispatches nothing: the editor is still there and the textarea is being read, not given back.
9. What a contributed module reads¶
Three computed properties on the field item are non-internal, so JSON:API exposes them and a mapping layer can name them.
plain_textis a computed string: the text of the document, for a token or a meta tag. It is policy-blind and must never be handed to a search index; see README.md, "Search indexing" and ADR 0030.processedis the document rendered through its text format, and carries the cacheability of that render.documentis the decoded envelope, typedany. It is there for a client reading the field as structured data and must not be handed to a search index, which wants a string.
Neither token nor search_api is a dependency of this module. What the module does instead is assert the property shape those modules require, in TokenSanityTest, ComputedPropertiesTest and SearchIndexingTest, so that a site installing them finds the properties where their mapping layers look. entity_usage is a named non-goal of 1.0: file_usage rows are written for the files a document names, uploaded into a block or named by a content link, and a document referencing a media item or any other entity registers nothing.
hook_entity_revision_delete() fires nothing. Deleting an old revision does not delete the document, and telling an annotation store to drop its anchors for a live document would be worse than telling it nothing. A DocumentRevisionDeleted event would go there, in a later release.
10. Change origin¶
Drupal\blocknote\Document\ChangeOrigin says who wrote a document, as far as the module was told. It has four cases: Widget for the editor widget writing through an entity form, Updater for DocumentUpdater, Converter for drush blocknote:convert writing a document converted from formatted text into a revision in place (0039), and Unmarked for everything else.
The converter writes the current revisions first, the default revision and each translation's draft, and announces each document it writes into one as any save does, with newRevisionId naming the revision written, which is not a new one: the save adds no revision, and previousHash is NULL, the field having held no document, except under --overwrite, where it is the hash of the document the converter wrote before and now replaces. It writes the older revisions, when asked to, without announcing anything, because a mirror would read such an event as the document changing back to what an old revision holds; a module that keeps something per revision seeds it from those revisions after the run, as section 1 describes. A document the converter finds already as it would write it is not saved, and nothing is announced for it.
Nothing is inferred. A migration, a Drush script, JSON:API, REST, cron and another module calling save() all arrive as Unmarked, and a subscriber that reads Unmarked as "some other writer" rather than as "not the widget" reads it the way it is meant. The marks are kept by blocknote.change_origin, ChangeOriginTracker, in a \WeakMap keyed by the entity object and lasting one request, and the hook forgets a mark once it has read it.
11. Schema versions¶
Every stored document carries the schema version it was written for, and 1.0 writes and reads version 1 only. Version 1 is the block shape of BlockNote 0.57.0, the release this module builds against, and a later version names the release it corresponds to here. Version 1 named BlockNote 0.54.2 until the module moved to 0.57.0 before its first release; the default blocks' properties, content kinds and styles are the same in both, so no stored document changed shape. The parser refuses any other version, the filter renders nothing for such a document and logs a warning, the updater answers Invalid, and the editor does not open it. DocumentNormalizer::stampVersion() returns a version 1 document as it is and throws \LogicException for any other version, so no write path can store version 1 blocks under a later label.
1.0 ships no .install file, and its six post-update functions in blocknote.post_update.php touch configuration only: five complete stored configuration, and one installs the shipped blocknote_snippet format and its editor on a site that predates them. No update touches a stored document yet. A release that changes the schema adds three things together: a parser that accepts the old version as well as the new one, a migration step in DocumentNormalizer that transforms the blocks and runs wherever stampVersion() runs, and a hook_post_update_NAME() that rewrites the stored documents. It is a post-update function and not hook_update_N(), because it loads and saves content entities. The update walks the documents like this:
- Take the entity types and fields
entity_field.manageranswers forgetFieldMapByFieldType('blocknote_document'), one entity type at a time. - Query that entity type with
allRevisions()andaccessCheck(FALSE), sorted by revision id, keep the last revision id done in$sandboxas the cursor, and load each batch withloadMultipleRevisions(). - For every translation of each revision and every delta of each document field, parse the value, migrate it through the normalizer and write it back.
- Save the revision with
setNewRevision(FALSE), so the migration adds no revisions, and set$sandbox['#finished']from the cursor.
The HTML converter is the other writer that saves revisions in bulk, and it walks them the same way, in place, with setNewRevision(FALSE) and as syncing; section 10 says what it announces. A schema migration would mark its saves with a change origin of its own and announce every revision it rewrites, since it changes what a reader of any revision gets.
What the migration does to this seam is part of the promise to subscribers. Its saves go through the entity API, so every document it changes dispatches blocknote.document_saved, with a change origin of its own, a ChangeOrigin case added with the migration. Its bytes differ, so every changed document gets a new hash, and a caller holding a hash from before the migration is answered Stale and reads the document again. The editor as 1.0 builds it opens only a document at the version the site writes, so a site runs the update before its authors edit.
12. Collaboration and comments¶
Comments, collaborative editing and proposed edits are things a module builds on this seam, and 1.0 includes none of them. BlockNote's comments work through Yjs collaboration and a thread store. The base module requires no collaboration backend, and a site that uses only the field and the editor runs none.
A stored document is a snapshot of blocks. It keeps no Yjs state, no comment threads, no relative anchors and no other extension's metadata, and saving a Drupal revision does not snapshot a Yjs document. Block ids are the anchors the snapshot does keep, and they survive saving and reordering, but a comment anchored to text that was rewritten or deleted is not guaranteed to survive. A module that adds comments or collaboration stores its own state and answers for it.
Such a module can build on:
- the document's uuid (section 3), and the revision ids, hashes and
eventIdthe events carry, to associate its state with a document and a revision; DocumentUpdater, to apply an accepted change against the revision and bytes it was proposed for, and to be answeredStalewhen a person has edited the document since;- the saved and deleted events, to notice a block being replaced or deleted and a revision being restored, which it handles by its own rules, detached annotations included.
The module has no persistence adapter that attaches a collaboration or annotation provider to the editor, and no collaboration provider has been tested with the editor. The editor is built from the stored JSON on every attach, and an editor bound to a live collaborative document must not be reseeded from a snapshot, so such an integration needs an attach path of its own. BlockNote's versioning and visual-diff features, shown in its versioning example, do not provide durable Drupal revision storage, selective acceptance, conflict resolution or an approval workflow, and this module implements none of those policies.
13. What 1.0 does not claim¶
Each of these is a known limit of 1.0.
- Workspaces. An active workspace changes what
Default,LatestandActiveresolve to (section 2), and no code is written for workspaces. entity_usage. A document referencing a media item registers nothing; only the files a document names, uploaded or linked, getfile_usagerows (section 9).- A revision-delete event. Deleting an old revision fires nothing, and there is no
DocumentRevisionDeleted(section 9). - Translation hooks.
hook_entity_translation_insert()andhook_entity_translation_delete()are not implemented. Core invokes them beforehook_entity_update(), and the update hook already reports the documents a translation added or removed, so either would report them twice. - A queue-safe
UpdateOptions. AnUpdateOptionscarrying aprepareclosure cannot be serialized (section 4). A caller queues the values and builds the options in the worker. - Events for a purged field. Deleting a document field and purging its values dispatches no
blocknote.document_deleted, because core purges field values without invoking entity hooks. The file usage the documents recorded is removed as they are purged. - Form concurrency. An entity form that loaded its entity before an updater write and saves after it overwrites that write, which is core's form concurrency, and SQLite does not take the row lock (section 7).
- The identity check. The query
preSave()runs to find a uuid another entity holds checks no access, so a regenerated identity tells a writer that some entity holds the uuid it posted; the writer already had that uuid, and a v4 uuid cannot be guessed. Two saves posting the same new uuid at the same moment can both pass the query, because no unique index spans the field tables and their revisions (section 3). - Events the root transaction cannot vouch for. An event checked against storage at the flush is dropped when a concurrent write to the same document lands between the commit and the flush, and when core reports a rolled-back root transaction as committed, the events queued directly in it are still dispatched (section 7).