feat(collab): persist a deliberate page clear via an intentional-clear signal (#251)
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to overwrite non-empty persisted content with an empty document, because a momentarily-empty live Y.Doc is indistinguishable from a real clear at the store layer. That correctly blocks glitches/bad-merges, but also blocks a user who genuinely wants to empty a page. This re-introduces a WORKING, narrow, non-spoofable exception (the dead context.intentionalClear hatch #248 removed never had a real channel). Definition of an intentional clear (client, IntentionalClear editor extension): a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph shape. This is exactly the select-all + Delete/Backspace keystroke path. Transport (option b — hocuspocus stateless message): on that transition the client sends a `{type:'intentional-clear'}` stateless message. The server (PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s maxDebounce), single-use "pending clear" flag keyed by the connection's document. The next debounced onStoreDocument consumes it on the empty-guard branch to let that one empty write through. Why this is the right channel and non-spoofable: - Yjs transaction origin/metadata does not survive to the server store; awareness is per-connection and racy. A stateless message ties the signal to a specific clear, survives the debounce, and rides the authenticated connection. - The document is taken from the connection, never the payload, so a client cannot target another page. - The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged signal can do is clear a page the connection may already edit; it can never force or alter a non-empty write. Read-only connections cannot arm it. Every non-empty store drops a pending flag, so "cleared then retyped" leaves nothing usable; the flag is single-use and TTL-bounded. NOTE: #248 is not yet on develop, so the empty-guard block is included here as the foundation this exception extends. If #248 lands first this rebases cleanly (the guard logic is identical; the #251-unique additions are the exception, onStateless, the pending-flag state, and the client extension). Tests: - Server (real transport path, not a hand-poke): onStateless sets the flag with the exact client payload, then the debounced onStoreDocument persists the empty doc; plus single-use consumption, read-only rejection, non-empty-store drops the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked, empty-over-empty allowed). - Client: a real Editor + the actual selectAll+deleteSelection command emits the signal; typing / non-emptying edits / already-empty docs do not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,31 +205,158 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// #206 persist-6 — RED (it.failing): a momentarily-empty live Y.Doc must not
|
||||
// overwrite non-empty persisted content. `onStoreDocument` empty-guards the
|
||||
// LOAD path but not the STORE path, so today an empty doc (a client/agent
|
||||
// glitch, a bad merge, an emptying transclusion) is written straight over the
|
||||
// page and the content is wiped silently. A store-side empty-guard is a real
|
||||
// behaviour change (a deliberate "select-all + delete" is also empty), so it
|
||||
// is left UNFIXED pending a product decision; this documents the data-loss
|
||||
// path and flips to a normal passing test the moment the guard lands.
|
||||
it.failing(
|
||||
'does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)',
|
||||
async () => {
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
// #206 persist-6 / #248 — a momentarily-empty live Y.Doc must not overwrite
|
||||
// non-empty persisted content. The store-side empty-guard blocks an empty doc
|
||||
// (a client/agent glitch, a bad merge, an emptying transclusion) from wiping
|
||||
// the page silently when NO intentional-clear signal is present.
|
||||
it('does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)', async () => {
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// Desired contract: the empty incoming doc is rejected and the rich page
|
||||
// survives. Today updatePage is called with the empty content (data loss).
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
// The empty incoming doc is rejected and the rich page survives.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #248 — an empty-over-empty store is allowed (nothing to lose); the guard
|
||||
// only protects non-empty persisted content.
|
||||
it('allows an empty store over already-empty content (#248)', async () => {
|
||||
const liveEmptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(liveEmptyDoc);
|
||||
// Stored content is empty per isEmptyParagraphDoc (paragraph with content:[])
|
||||
// but NOT deep-equal to the normalized live doc, so the unchanged
|
||||
// short-circuit is skipped and the empty-guard is genuinely reached.
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// #251 — REAL-PATH regression test. The intentional-clear signal is set via
|
||||
// the actual transport seam (ext.onStateless with the exact stateless payload
|
||||
// the client's IntentionalClear extension sends), NOT a hand-injected
|
||||
// context.intentionalClear poke. We then run the debounced store with an empty
|
||||
// live doc over non-empty persisted content and assert the empty write goes
|
||||
// through — i.e. the clear persists.
|
||||
it('persists an intentional clear signalled via the real stateless transport (#251)', async () => {
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
|
||||
// The client signalled a deliberate clear over the live connection.
|
||||
await ext.onStateless({
|
||||
connection: { readOnly: false } as any,
|
||||
documentName,
|
||||
document: document as any,
|
||||
payload: JSON.stringify({ type: 'intentional-clear' }),
|
||||
} as any);
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// The empty doc was written (the clear persisted). The persisted content is
|
||||
// the Y.Doc round-trip of the empty doc (attrs normalized), so compare
|
||||
// against fromYdoc rather than the raw literal.
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const expectedEmpty = TiptapTransformer.fromYdoc(document, 'default');
|
||||
expect(pageRepo.updatePage.mock.calls[0][0].content).toEqual(expectedEmpty);
|
||||
});
|
||||
|
||||
// #251 — the signal is single-use: it is consumed by the first empty store,
|
||||
// so a SECOND accidental empty (no fresh signal) is still blocked.
|
||||
it('consumes the intentional-clear signal once; a later empty is blocked (#251)', async () => {
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
|
||||
await ext.onStateless({
|
||||
connection: { readOnly: false } as any,
|
||||
documentName,
|
||||
document: ydocFor(emptyDoc) as any,
|
||||
payload: JSON.stringify({ type: 'intentional-clear' }),
|
||||
} as any);
|
||||
|
||||
// First empty store consumes the signal and writes.
|
||||
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-arm findById to non-empty (as if content came back) and fire another
|
||||
// empty store WITHOUT a new signal — the guard must block it.
|
||||
pageRepo.updatePage.mockClear();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #251 — a read-only connection cannot arm the clear, so its empty store is
|
||||
// still blocked (defends the guard against a read-only spoof).
|
||||
it('ignores an intentional-clear signal from a read-only connection (#251)', async () => {
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
|
||||
await ext.onStateless({
|
||||
connection: { readOnly: true } as any,
|
||||
documentName,
|
||||
document: document as any,
|
||||
payload: JSON.stringify({ type: 'intentional-clear' }),
|
||||
} as any);
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #251 — a non-empty store between the signal and the empty store drops the
|
||||
// pending flag ("cleared then retyped" can't leave a usable signal behind).
|
||||
it('drops a pending clear when a non-empty store intervenes (#251)', async () => {
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
await ext.onStateless({
|
||||
connection: { readOnly: false } as any,
|
||||
documentName,
|
||||
document: ydocFor(emptyDoc) as any,
|
||||
payload: JSON.stringify({ type: 'intentional-clear' }),
|
||||
} as any);
|
||||
|
||||
// A non-empty store lands first → consumes/drops the stale flag.
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN TEXT'));
|
||||
await ext.onStoreDocument(
|
||||
buildData(ydocFor(doc('NEW HUMAN TEXT')), 'user') as any,
|
||||
);
|
||||
pageRepo.updatePage.mockClear();
|
||||
|
||||
// Now an empty store with no fresh signal must be blocked.
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
||||
// success: no "page.updated" badge broadcast and no history snapshot for
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onLoadDocumentPayload,
|
||||
onStatelessPayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
import * as Y from 'yjs';
|
||||
@@ -41,6 +42,26 @@ import {
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
|
||||
/**
|
||||
* #251 — wire format of the client→server stateless message that signals a
|
||||
* deliberate page clear. The client (IntentionalClear editor extension) sends
|
||||
* `{ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }`; the document is taken from the
|
||||
* connection, not the payload, so the signal cannot be aimed at another page.
|
||||
*/
|
||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||
|
||||
/**
|
||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||
* DEBOUNCED onStoreDocument, so the TTL must comfortably exceed the collab
|
||||
* store debounce window (hocuspocus is configured with maxDebounce = 45s in
|
||||
* collaboration.gateway.ts). 60s leaves a margin while keeping the window for a
|
||||
* stale flag small; on top of the TTL, any non-empty store immediately drops a
|
||||
* pending flag (see onStoreDocument), so a "cleared then retyped" sequence can
|
||||
* never leave a usable flag behind.
|
||||
*/
|
||||
export const INTENTIONAL_CLEAR_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolve the provenance source for a coalesced snapshot.
|
||||
*
|
||||
@@ -96,6 +117,13 @@ export class PersistenceExtension implements Extension {
|
||||
// coalescing window" per document and OR it across all edits in the window,
|
||||
// so the snapshot is marked 'agent' regardless of who wrote last.
|
||||
private agentTouched: Map<string, boolean> = new Map();
|
||||
// #251 — per-document "intentional clear pending" flags. Keyed by
|
||||
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
||||
// client reports a deliberate clear; consumed once by the next
|
||||
// onStoreDocument empty-guard branch. This is the per-EDIT channel the
|
||||
// per-connection context cannot provide (a clear is an edit event, but the
|
||||
// store is debounced and connection context is fixed at authentication).
|
||||
private intentionalClear: Map<string, number> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@@ -210,6 +238,43 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
// #206 persist-6 / #248 — store-side empty-guard. A momentarily-empty
|
||||
// live Y.Doc (a client/agent glitch, a bad merge, a transclusion that
|
||||
// emptied) must NOT overwrite non-empty persisted content. The LOAD
|
||||
// path already guards emptiness (onLoadDocument only hydrates from db
|
||||
// when the live doc isEmpty); the STORE path did not, so an empty
|
||||
// serialization was written straight over the page, wiping it
|
||||
// silently.
|
||||
//
|
||||
// #251 — the ONE legitimate empty-over-non-empty write is a user who
|
||||
// deliberately clears the page. That intent arrives out-of-band as a
|
||||
// stateless message (consumed below), NOT from the doc content, which
|
||||
// is why it cannot be spoofed for non-clear writes: the flag is only
|
||||
// ever read on this empty-incoming branch, so the worst a forged
|
||||
// signal can do is clear a page the connection may already edit. Every
|
||||
// non-empty write ignores the flag entirely and additionally drops any
|
||||
// pending flag (a "cleared then retyped" sequence can't leave a usable
|
||||
// one behind).
|
||||
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
|
||||
if (!incomingEmpty) {
|
||||
this.intentionalClear.delete(documentName);
|
||||
} else if (page.content && !isEmptyParagraphDoc(page.content as any)) {
|
||||
if (this.consumeIntentionalClear(documentName)) {
|
||||
this.logger.debug(
|
||||
`Intentional clear for ${pageId}: persisting empty doc over ` +
|
||||
`non-empty content (user-signalled)`,
|
||||
);
|
||||
// fall through — the empty write is allowed exactly once.
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Skipping store for ${pageId}: empty live doc would overwrite ` +
|
||||
`non-empty persisted content`,
|
||||
);
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
@@ -345,6 +410,37 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #251 — receive the client's deliberate-clear signal. Records a short-lived,
|
||||
* single-use pending flag for the originating document so the next
|
||||
* onStoreDocument may let one empty-over-non-empty write through the guard.
|
||||
*
|
||||
* Hardening: read-only connections cannot arm the flag, and the document is
|
||||
* taken from the connection (`data.documentName`), never the payload, so a
|
||||
* client cannot target a page it isn't editing. The flag only ever RELAXES
|
||||
* the guard for an empty write (a clear); it can never force or alter a
|
||||
* non-empty write, so it is not a guard bypass for normal content.
|
||||
*/
|
||||
async onStateless(data: onStatelessPayload) {
|
||||
const { connection, documentName, payload } = data;
|
||||
|
||||
if (connection?.readOnly) return;
|
||||
|
||||
let message: { type?: string } | undefined;
|
||||
try {
|
||||
message = JSON.parse(payload);
|
||||
} catch {
|
||||
return; // unrelated / malformed stateless message
|
||||
}
|
||||
|
||||
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
||||
|
||||
this.intentionalClear.set(
|
||||
documentName,
|
||||
Date.now() + INTENTIONAL_CLEAR_TTL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async onChange(data: onChangePayload) {
|
||||
const documentName = data.documentName;
|
||||
const userId = data.context?.user?.id;
|
||||
@@ -368,6 +464,7 @@ export class PersistenceExtension implements Extension {
|
||||
const documentName = data.documentName;
|
||||
this.contributors.delete(documentName);
|
||||
this.agentTouched.delete(documentName);
|
||||
this.intentionalClear.delete(documentName);
|
||||
}
|
||||
|
||||
private consumeContributors(documentName: string): string[] {
|
||||
@@ -385,6 +482,18 @@ export class PersistenceExtension implements Extension {
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* #251 — read and clear the intentional-clear flag for this document. Returns
|
||||
* true only if a flag was pending AND still within its TTL. Always deletes the
|
||||
* entry so the signal is strictly single-use (one clear → one allowed empty
|
||||
* write); an expired flag is treated as absent (guard still blocks).
|
||||
*/
|
||||
private consumeIntentionalClear(documentName: string): boolean {
|
||||
const expiry = this.intentionalClear.get(documentName);
|
||||
this.intentionalClear.delete(documentName);
|
||||
return expiry !== undefined && Date.now() < expiry;
|
||||
}
|
||||
|
||||
private async enqueuePageHistory(
|
||||
page: Page,
|
||||
lastUpdatedSource: string,
|
||||
|
||||
Reference in New Issue
Block a user