Parent
#328. Related: #164, #213
What to build
Production runs two collaboration replicas, and both can host one room. Each replica saves the room on its own timer. Under steady typing, each replica saves at least once a minute, so one busy document can write two version rows a minute. When the second save adds nothing, History shows a version with no change. This RFC asks a person to measure these no-change rows first. The maintainer then rules whether a no-change save writes a row.
Acceptance criteria
Blocked by
None — can start now.
Agent brief
Type: HITL — a person with read access to production Postgres runs the query below and posts the result as a comment on this issue. The maintainer then picks an option in a comment on this issue. An agent records the ruling in apps/hocuspocus.server/CLAUDE.md §Persistence.
Category: ruling
Current behavior:
- The
store() hook enqueues each save with a job id from buildStoreJobId (apps/hocuspocus.server/src/config/hocuspocus.config.ts:252-255).
buildStoreJobId (apps/hocuspocus.server/src/lib/queue.ts:157-161) joins the document id, a 10-second time window, the byte length and a hash of the state. BullMQ refuses a second job with an id it still holds. So equal bytes in one window make one job.
- The Database extension runs
debounce: 10_000 and maxDebounce: 60_000 (hocuspocus.config.ts:400-401). Each replica runs its own timer. Production runs two collaboration replicas (docker-compose.prod.yml:369) with a sticky cookie per browser (docker-compose.prod.yml:338-342).
- The worker always inserts a row. It merges the locked head with the incoming state, strips metadata, and writes
version + 1 (createDocumentWorker, queue.ts:419-470). It never compares the result with the head.
- Every row fires the
doc:{id}:saved publish and the content-change fan-out (queue.ts:530-560).
contributors are per replica and best effort (comment at the fan-out in queue.ts:554-555). Replica B's row can name people that replica A's row does not.
pruneAutosaveVersions (apps/hocuspocus.server/src/lib/retention.ts:62) thins unnamed rows older than DOC_AUTOSAVE_RETENTION_DAYS to one per day. The default is 30 (apps/hocuspocus.server/ENV.md:169). Until then every row shows in History.
- Two causes are possible, both unverified: the two timers fall in different 10-second windows, or two replicas encode one converged document to different bytes.
Desired behavior: A save that adds nothing to the head writes no new row, or the ruling says why these rows stay.
Options.
- A. Skip on equal bytes. In the worker, compare the stripped merge with the head bytes. When they are equal and the job is an unnamed
websocket save, insert nothing. Still publish saved with the head version. Rule on the skipped job's contributors: merge them into the head row, or drop them.
- B. Skip when the incoming state adds nothing. Compare state vectors and delete sets instead of bytes. This also catches equal content that encodes to different bytes. It costs one more decode per save.
- C. Accept these rows. Rely on the 30-day thinning. Optionally hide no-change rows in the History list.
Named saves, checkpoints, restores, api writes and mcp writes never skip under A or B.
Evidence the maintainer needs. Run this read-only query for one busy document. Run it from a local client, not inside a production container.
SELECT version, "createdAt", octet_length(data) AS bytes,
md5(data) = lag(md5(data)) OVER (ORDER BY version) AS same_as_previous,
trigger, cardinality(contributors) AS contributor_count
FROM "Documents"
WHERE "documentId" = '<busy document id>'
ORDER BY version DESC
LIMIT 60;
The query shows byte equality only. For neighbour rows that differ in bytes, call GET /api/documents/{documentId}/versions/{version}/diff with a service-role token. A diff with no changed blocks means no change in content. If same_as_previous is often true, option A is enough. If rows differ in bytes but the diff is empty, only B catches them.
Where to start: buildStoreJobId and createDocumentWorker in apps/hocuspocus.server/src/lib/queue.ts; the store() hook in apps/hocuspocus.server/src/config/hocuspocus.config.ts; stripSnapshotMetadata in apps/hocuspocus.server/src/lib/snapshotMetadata.ts; VersionTrigger in apps/hocuspocus.server/src/types/queue.types.ts. Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply: Root CLAUDE.md §Settled "Collab storage design": each kept row stays a full snapshot, and this RFC is not content addressing. apps/hocuspocus.server/CLAUDE.md §Persistence ("Merge raw, strip the result") and §Retention and schema. AGENTS.md §Test Policy. AGENTS.md §Simplified English Mandate for the recorded ruling.
Verify: After the doc edit, run bun run check from the repo root and see it pass.
Out of scope
Parent
#328. Related: #164, #213
What to build
Production runs two collaboration replicas, and both can host one room. Each replica saves the room on its own timer. Under steady typing, each replica saves at least once a minute, so one busy document can write two version rows a minute. When the second save adds nothing, History shows a version with no change. This RFC asks a person to measure these no-change rows first. The maintainer then rules whether a no-change save writes a row.
Acceptance criteria
contributors.apps/hocuspocus.server/CLAUDE.md§Persistence records the ruling.Blocked by
None — can start now.
Agent brief
Type: HITL — a person with read access to production Postgres runs the query below and posts the result as a comment on this issue. The maintainer then picks an option in a comment on this issue. An agent records the ruling in
apps/hocuspocus.server/CLAUDE.md§Persistence.Category: ruling
Current behavior:
store()hook enqueues each save with a job id frombuildStoreJobId(apps/hocuspocus.server/src/config/hocuspocus.config.ts:252-255).buildStoreJobId(apps/hocuspocus.server/src/lib/queue.ts:157-161) joins the document id, a 10-second time window, the byte length and a hash of the state. BullMQ refuses a second job with an id it still holds. So equal bytes in one window make one job.debounce: 10_000andmaxDebounce: 60_000(hocuspocus.config.ts:400-401). Each replica runs its own timer. Production runs two collaboration replicas (docker-compose.prod.yml:369) with a sticky cookie per browser (docker-compose.prod.yml:338-342).version + 1(createDocumentWorker,queue.ts:419-470). It never compares the result with the head.doc:{id}:savedpublish and the content-change fan-out (queue.ts:530-560).contributorsare per replica and best effort (comment at the fan-out inqueue.ts:554-555). Replica B's row can name people that replica A's row does not.pruneAutosaveVersions(apps/hocuspocus.server/src/lib/retention.ts:62) thins unnamed rows older thanDOC_AUTOSAVE_RETENTION_DAYSto one per day. The default is 30 (apps/hocuspocus.server/ENV.md:169). Until then every row shows in History.Desired behavior: A save that adds nothing to the head writes no new row, or the ruling says why these rows stay.
Options.
websocketsave, insert nothing. Still publishsavedwith the head version. Rule on the skipped job'scontributors: merge them into the head row, or drop them.Named saves, checkpoints, restores,
apiwrites andmcpwrites never skip under A or B.Evidence the maintainer needs. Run this read-only query for one busy document. Run it from a local client, not inside a production container.
The query shows byte equality only. For neighbour rows that differ in bytes, call
GET /api/documents/{documentId}/versions/{version}/diffwith a service-role token. A diff with no changed blocks means no change in content. Ifsame_as_previousis often true, option A is enough. If rows differ in bytes but the diff is empty, only B catches them.Where to start:
buildStoreJobIdandcreateDocumentWorkerinapps/hocuspocus.server/src/lib/queue.ts; thestore()hook inapps/hocuspocus.server/src/config/hocuspocus.config.ts;stripSnapshotMetadatainapps/hocuspocus.server/src/lib/snapshotMetadata.ts;VersionTriggerinapps/hocuspocus.server/src/types/queue.types.ts. Line numbers are hints as of 2026-09-28; the agent searches by symbol.Rules that apply: Root
CLAUDE.md§Settled "Collab storage design": each kept row stays a full snapshot, and this RFC is not content addressing.apps/hocuspocus.server/CLAUDE.md§Persistence ("Merge raw, strip the result") and §Retention and schema. AGENTS.md §Test Policy. AGENTS.md §Simplified English Mandate for the recorded ruling.Verify: After the doc edit, run
bun run checkfrom the repo root and see it pass.Out of scope
PATCHreturns forversion. RFC: what should a content PATCH return so a caller can confirm its write? #164 owns that.