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 |
| Coupling | The key is written in the same Yjs transaction as the story edit it describes. |
| 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. 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. Successive edits can also write the same value, so comparing the old and new strings misses edits.
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.
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.