Collaboration

Read authorship on your own server

Tell which user sent each collaborative update by reading the attribution keys SuperDoc writes into the room.

SuperDoc editors declare who they are inside the room. Any Yjs server can read those declarations from the room's meta map and tell which user sent each update. Nothing from SuperDoc runs on the server.

This gives you "who changed the room, and when". It does not tell you which paragraph changed. For paragraph-level records, run an observer.

The attribution keys

Each SuperDoc client writes two keys into the root map meta. It writes only its own keys. Values are plain JSON objects. SuperDoc adds no other root type to the room.

KeyValue
attribution:client:<yjsClientId>{ v: 1, actorId, name?, email?, sessionId, declaredAt, claim? }
attribution:stamp:<yjsClientId>{ v: 1, at, actorType, cause, changeTag?, fromClock?, toClock? }
  • actorId is a string, or null when the session has no actor id.
  • sessionId is random for each SuperDoc session and stays the same for its lifetime.
  • declaredAt and at are ISO 8601 UTC timestamps from the writing client's clock.
  • actorType is human or code. cause is user, api, undo, redo or maintenance.
  • claim is the client's identity claim, copied as given. Every client in the room can read it.
  • v is the contract version. Ignore keys and fields you do not know.

When SuperDoc writes them

  • The client key is written in the same Yjs transaction as the client's first edit. It is rewritten only when the identity or claim changes. A claim that arrives after the first edit is written in a transaction of its own.
  • The stamp is written in the same transaction as an edit when the edit's cause, actor type or change tag changes, when the last stamp is 10 seconds old or older, and when the edit inserts no content of its own, such as a deletion.
  • Read-only sessions never write either key.
  • Transactions that SuperDoc runs for its own upkeep, such as seeding or repairing a room, produce no change records. When one changes content, the client writes a stamp with cause maintenance. fromClock and toClock give the range of that client's struct clocks the transaction covers, up to and including the stamp itself at toClock.

So every content update from a SuperDoc client inserts at least one struct with that client's id: its content, or its stamp.

Find who sent an update

  1. Apply the update to the room's Y.Doc as usual.
  2. Decode the update and collect the client ids of the structs it inserts.
  3. Look up attribution:client:<clientId> and attribution:stamp:<clientId> in meta for each one.
  4. Skip the update if all its inserted structs belong to one client whose stamp has cause maintenance, and every struct lies from fromClock up to toClock. Anything else is an edit. An update that also carries other content, for example an edit merged with upkeep after a reconnect, is an edit.

A client id with no client key belongs to an older SuperDoc version, to a client whose keys were removed after 30 days, or to a writer that is not SuperDoc. Treat its author as unknown.

This function does those steps with the yjs package:

import { Item, decodeUpdate, type Doc } from 'yjs';

export type ClientDeclaration = {
  actorId: string | null;
  name?: string;
  email?: string;
  claim?: string;
  sessionId: string;
  declaredAt: string;
};

export type EditStamp = {
  at: string;
  actorType: 'human' | 'code';
  cause: 'user' | 'api' | 'undo' | 'redo' | 'maintenance';
  changeTag?: string;
  fromClock?: number;
  toClock?: number;
};

export type UpdateAuthor = {
  clientId: number;
  insertedStructs: number;
  declaration: ClientDeclaration | null;
  stamp: EditStamp | null;
};

export type UpdateAttribution = { maintenance: boolean; authors: UpdateAuthor[] };

type ClockRange = { start: number; end: number };

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null && !Array.isArray(value);
const optionalString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
const optionalClock = (value: unknown): number | undefined =>
  typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
const isCause = (value: unknown): value is EditStamp['cause'] =>
  value === 'user' || value === 'api' || value === 'undo' || value === 'redo' || value === 'maintenance';

function readDeclaration(value: unknown): ClientDeclaration | null {
  if (!isRecord(value) || value.v !== 1) return null;
  const { actorId, sessionId, declaredAt } = value;
  if ((actorId !== null && typeof actorId !== 'string') || typeof sessionId !== 'string') return null;
  if (typeof declaredAt !== 'string') return null;
  return {
    actorId,
    sessionId,
    declaredAt,
    name: optionalString(value.name),
    email: optionalString(value.email),
    claim: optionalString(value.claim),
  };
}

function readStamp(value: unknown): EditStamp | null {
  if (!isRecord(value) || value.v !== 1 || typeof value.at !== 'string') return null;
  const { actorType, cause } = value;
  if ((actorType !== 'human' && actorType !== 'code') || !isCause(cause)) return null;
  return {
    at: value.at,
    actorType,
    cause,
    changeTag: optionalString(value.changeTag),
    fromClock: optionalClock(value.fromClock),
    toClock: optionalClock(value.toClock),
  };
}

function insideMaintenance(stamp: EditStamp | null, ranges: ClockRange[]): boolean {
  if (stamp?.cause !== 'maintenance' || stamp.fromClock === undefined || stamp.toClock === undefined) return false;
  const { fromClock, toClock } = stamp;
  return ranges.every(({ start, end }) => start >= fromClock && end <= toClock + 1);
}

export function attributeUpdate(room: Doc, update: Uint8Array): UpdateAttribution {
  const meta = room.getMap<unknown>('meta');
  const inserted = new Map<number, ClockRange[]>();
  for (const struct of decodeUpdate(update).structs) {
    if (!(struct instanceof Item)) continue;
    const ranges = inserted.get(struct.id.client) ?? [];
    ranges.push({ start: struct.id.clock, end: struct.id.clock + struct.length });
    inserted.set(struct.id.client, ranges);
  }

  const authors = [...inserted]
    .map(([clientId, ranges]) => ({
      clientId,
      insertedStructs: ranges.reduce((total, { start, end }) => total + end - start, 0),
      declaration: readDeclaration(meta.get(`attribution:client:${clientId}`)),
      stamp: readStamp(meta.get(`attribution:stamp:${clientId}`)),
    }))
    .sort((a, b) => b.insertedStructs - a.insertedStructs || a.clientId - b.clientId);

  const [only] = authors;
  const maintenance = authors.length === 1 && insideMaintenance(only.stamp, inserted.get(only.clientId) ?? []);
  return { maintenance, authors };
}

Call it from your server's change hook. With Hocuspocus, onChange runs after the update is applied:

import { Hocuspocus } from '@hocuspocus/server';
import { attributeUpdate } from './attribution-authors';

const server = new Hocuspocus({
  async onChange({ documentName, document, update }) {
    const { maintenance, authors } = attributeUpdate(document, update);
    if (maintenance) return;
    const [primary] = authors;
    console.log(documentName, primary?.declaration?.actorId ?? 'unknown', primary?.stamp?.at);
  },
});

authors lists the clients largest first, by the number of structs each inserted. A yrs server reads the same two keys from its root map meta.

The stamp's at is the time of that client's latest stamp. Stamps are written at most every 10 seconds while the cause, actor type and change tag stay the same, so at can be up to about 10 seconds before the edit. Use your server's clock for the time you received the update.

Remove stale keys

A SuperDoc session that joins an existing room removes the keys of other clients whose newest key is older than 30 days. It does this about 3 seconds after its own first edit, 200 clients at a time, until none are left. Read-only sessions do not remove keys.

Edits that reach the room after their client's keys were removed are recorded without an actor. This can happen when a device edits offline for more than 30 days and then reconnects.

Rooms with older SuperDoc versions

Clients on SuperDoc versions without change records can share a room with newer clients. They open, edit, save and join as before, and the room format does not change. They do not write attribution keys, and they ignore the keys that newer clients write.

A newer session records an older client's edits like any other remote edit. The fields the older client cannot declare are empty:

FieldValue for an older client's edit
actor{ id: null }
provenanceunknown
actorTypenull
causenull, including for undo and redo
at.declarednull. at.observed is set.
session{ id: null, clientId }, with the older client's Yjs client id

changes.actors() does not list older clients, and changes.list({ actorId }) never returns their records. To find them, keep records whose actor.id is null and group them by session.clientId. A client id stays the same for one session of the older client. Older clients still write lastEditActorType. SuperDoc does not use it for actorType.

Older clients write no maintenance stamps, so every content change they make is recorded, including their own repair writes. Some operations in older clients rewrite more of a paragraph than they change. SuperDoc leaves the values they rewrote without changing out of the records.

When one update carries content from several clients, for example after a reconnect, the record's actor is the client that wrote the most content, contributors lists every client that wrote content, and provenance is relayed. An older client appears as { id: null }.

When a newer client replaces the room's file, it keeps the other clients' attribution keys and stamps the replacement as maintenance, so other sessions record no change for it. When an older client replaces the file, it removes every client's attribution keys, and other sessions record no change for the replacement. Each newer client writes its keys again with its next edit. Until then, changes.actors() does not list it.

The 30-day cleanup removes keys only. Older clients have none, so it never affects them.

To attribute every edit, upgrade every client that edits the room.

Relation to lastEditActorType

content.<shard>.meta.lastEditActorType is a separate, older signal. It is written as Observe story edits on your server describes, and it only says whether the last edit to a story came from the UI or from code. The attribution keys add who made the edit.

On this page