Observe story edits on your server
Read edit attribution from collaborative story updates and schedule saved versions.
Your Hocuspocus server can distinguish edits made through SuperDoc's built-in UI from public API calls by reading lastEditActorType on each story shard. An application toolbar that calls the Document API is code, even when a person clicks it. Count both values when scheduling saved versions for those edits.
Compatibility contract
For SuperDoc 2.x, the following attribution contract is supported:
| Property | Contract |
|---|---|
| Location | document.getMap('content').get(shardId).get('meta') |
| Key | lastEditActorType |
| Values | human or code |
| Meaning | human: supported first-party UI mutation; code: public API or application-owned command mutation |
| Cadence | Per SuperDoc session and story, the key is written in the edit's own Yjs transaction on the first edit, when the actor type changes, for edits that delete or replace content, for Undo/Redo, and at least every 10 seconds. Other edits are followed by one trailing write about 1 second later. |
| Scope | Independent last-write-wins value per story shard, not an edit history. |
Incompatible changes to this contract require a major release, migration guidance and release notes. Additive supported entry points and fixes that restore the documented classification can ship within 2.x. The write cadence is a supported 2.x change that does not alter the key's classification: a SuperDoc session skips the write for some edits and follows with one trailing write carrying the same actor type, and it flushes pending trailing writes when the editor closes. Read-only sessions never write. The room namespace and schema do not change, and no application or server change is required. Writing the key on every keystroke prevented Yjs from merging consecutively typed characters, so rooms grew by about 30 bytes per keystroke permanently; rooms already written keep their history, and growth stops for new edits. Keep counting both values: built-in Undo/Redo and F9 classification fixes do not change that filter. This contract does not retroactively certify every entry point in older releases.
Neither value authenticates a user or proves that a person physically initiated a call. Attribute connections using your server's authentication and authorization policy. Do not use transaction.local as a user identity.
Observe writes, not retained values
Read the actor inside the transaction that writes the key. A later metadata packet can retain an old actor value without representing another edit. A burst of same-actor edits produces a write at its start and a trailing write after it, so a saved-version timer still starts for every burst. Comparing the old and new strings still misses repeated same-actor bursts, so observe writes.
This example registers the content map eagerly, follows each shard's meta map, and reports once per Yjs transaction with all attributed shards:
import type { Doc, Map as YMap, Transaction } from 'yjs';
export type AttributedStoryEdit = { shardId: string; actorType: 'human' | 'code' };
// Install after restoring authoritative room state, before accepting live edits.
export function observeAttributedEdits(
document: Doc,
onEdit: (edits: AttributedStoryEdit[], transaction: Transaction) => void,
): () => void {
const content = document.getMap<YMap<unknown>>('content');
const registered = new Map<string, YMap<unknown>>();
const register = () => {
registered.clear();
for (const [shardId, shard] of content) {
const meta = shard.get('meta') as YMap<unknown> | undefined;
if (meta) registered.set(shardId, meta);
}
};
register();
content.observe(register);
const afterTransaction = (transaction: Transaction) => {
const edits: AttributedStoryEdit[] = [];
const changedTypes: ReadonlyMap<unknown, ReadonlySet<string | null>> = transaction.changed;
for (const [shardId, meta] of registered) {
if (!changedTypes.get(meta)?.has('lastEditActorType')) continue;
const actorType = meta.get('lastEditActorType');
if (actorType === 'human' || actorType === 'code') edits.push({ shardId, actorType });
}
if (edits.length) onEdit(edits, transaction);
};
document.on('afterTransaction', afterTransaction);
const stop = () => {
content.unobserve(register);
document.off('afterTransaction', afterTransaction);
document.off('destroy', stop);
};
document.on('destroy', stop);
return stop;
}
Install the observer in onLoadDocument after restoring the room's authoritative stored Yjs state, before returning the document for live synchronization. Restoring a snapshot can carry old attribution; it is initialization, not a new user edit. Likewise, suppress your own seed/import/replay operations using your server's initialization boundary. Do not infer intent from a full client snapshot arriving in an empty room: persist and restore the room.
In the callback, pass the document name and authenticated connection to your saved-version scheduler. If your policy starts a fifteen-minute timer on the first attributed edit, start it only while no timer is active. One command can affect several shards or transactions; the scheduler should coalesce them into the same timer. A repeated binary update is idempotent and does not produce a fresh actor-key write. A trailing write can arrive up to about 1 second after the last edit, which does not affect fifteen-minute timers.
The callback is a scheduling input, not a storage acknowledgment. Continue persisting all Yjs room changes through your provider's storage flow, including metadata and comments. Do not filter room persistence with this observer.
Which operations count?
| Operation | Attribution behavior |
|---|---|
| Built-in typing, paste/drop, formatting, lists/tables, links/images and supported review actions | human when they mutate a story |
| Built-in keyboard or toolbar Undo/Redo | Actor of the current action: human, regardless of the original edit |
Public doc.*, public ui.commands.execute*, custom commands and application toolbar API calls | code, including public Undo/Redo |
| Native F9 supported field/index updates | human for each changed story |
| Explicit public field rebuild | code for each changed story |
| Reads and no-op mutations | No actor-key write |
| Load, join/reconnect, layout, journal/checkpoint and remote application | No independently authored actor-key write |
| Receiving another client's comment | The originating story tag is received; metadata bookkeeping does not create another story tag |
Collaborative refreshOnOpen preserves cached profile-field results instead of rebuilding them in the background. Layout presentation of PAGE/NUMPAGES is separate from persisting field results. Normal collaborative TOC regeneration is currently unsupported; its rejected update leaves the cached result and actor key unchanged.
SuperDoc's passive lifecycle must not start a timer through this signal. Application-scheduled API calls are still code: if your application rebuilds fields or changes content in the background, account for those calls in your own scheduling policy. The tag classifies the mutation entry point and cannot distinguish a custom button from a background job using the same public API.
This signal covers attributed story edits. Sidecar-only changes, such as editing comment text or resolving/reopening a comment without moving its story anchor, can change the room without writing a story actor key. Use your application's comment workflow if those actions should independently start a saved-version timer. It is not a complete audit log: synchronization can combine updates, and each shard retains only its winning actor value.
The server observer is checked against live Hocuspocus collaboration, including repeated actors, custom API actions, per-story field changes, two-client comments, passive reconnect and snapshot replay.