Found while investigating #8134. Filing separately because it is independent of that report and, I think, more damaging.
Summary
Pad.copyPadWithoutHistory() builds the destination pad's revision 1 with:
const oldLength = 2;
const newLength = assem.getLengthChange(); // a DELTA
const newText = oldAText.text;
const changeset = pack(oldLength, newLength, assem.toString(), newText);
pack(oldLen, newLen, ...) wants the total length of the new document, not the delta. So the changeset's header disagrees with its own ops.
Impact
1. Every copy gains a newline. opsFromAText() deliberately skips the source document's final newline, so both of the destination pad's rev-0 newlines ("\n\n") survive alongside the inserted text. Measured on a 242-char source pad, the copy is 243 chars. It grows again on each subsequent copy.
2. The copy fails pad.check() permanently — this is the real problem. Revision 1 fails checkRep():
Error: (pad <id> revision 1) Failed assertion: Invalid changeset: claimed length does not match actual length
at checkRep (static/js/Changeset.ts:285:3)
at Pad.check (node/db/Pad.ts:994:9)
Measured header on that same pad: oldLen=2, newLen=241, while applying the ops actually yields 243.
deleteRevisions() — i.e. cleanup.keepRevisions from the admin UI, and compactPad with a keep count — calls pad.check() before it touches anything. And compactPad's full-collapse mode (deleteAllRevisions) routes through copyPadWithoutHistory twice.
So: the feature that exists to reclaim database space leaves pads that can never be cleaned up again. A healthy pad that passes check() fails check() after compactPad.
This also affects the public copyPadWithoutHistory API endpoint and anything built on it.
Reproduction
const pad = await padManager.getPad(padId);
for (let i = 0; i < 6; i++) await pad.appendText(`line ${i}\n`);
await pad.check(); // passes
await api.compactPad(padId); // {ok: true, mode: 'all'}
padManager.unloadPad(padId);
await (await padManager.getPad(padId)).check(); // throws
Why it went unnoticed
- dstPad.appendRevision(changeset, authorId) is not awaited, so failures surface as unhandled rejections rather than failing the copy.
- Two existing tests encode the off-by-one rather than catching it. tests/backend/specs/api/pad.ts strips '<br></body>' when checking a pad's own HTML but '<br><br></body>' when checking a copy of it. tests/backend/specs/compactPad.ts says it "doesn't assert byte-exact equality because Cleanup.deleteAllRevisions goes through copyPadWithoutHistory twice and may adjust trailing whitespace", and never calls check() afterwards.
Fix
PR incoming: pass the total length, delete the surplus newline, trim the char bank to the characters actually inserted, await the appendRevision, and tighten both tests above.
Affected
develop (verified at 671b15c). The pack() call is long-standing, so released versions with compactPad / cleanup are affected.
🤖 Generated with Claude Code
Found while investigating #8134. Filing separately because it is independent of that report and, I think, more damaging.
Summary
Pad.copyPadWithoutHistory() builds the destination pad's revision 1 with:
pack(oldLen, newLen, ...) wants the total length of the new document, not the delta. So the changeset's header disagrees with its own ops.
Impact
1. Every copy gains a newline. opsFromAText() deliberately skips the source document's final newline, so both of the destination pad's rev-0 newlines ("\n\n") survive alongside the inserted text. Measured on a 242-char source pad, the copy is 243 chars. It grows again on each subsequent copy.
2. The copy fails pad.check() permanently — this is the real problem. Revision 1 fails checkRep():
Measured header on that same pad: oldLen=2, newLen=241, while applying the ops actually yields 243.
deleteRevisions() — i.e. cleanup.keepRevisions from the admin UI, and compactPad with a keep count — calls pad.check() before it touches anything. And compactPad's full-collapse mode (deleteAllRevisions) routes through copyPadWithoutHistory twice.
So: the feature that exists to reclaim database space leaves pads that can never be cleaned up again. A healthy pad that passes check() fails check() after compactPad.
This also affects the public copyPadWithoutHistory API endpoint and anything built on it.
Reproduction
Why it went unnoticed
Fix
PR incoming: pass the total length, delete the surplus newline, trim the char bank to the characters actually inserted, await the appendRevision, and tighten both tests above.
Affected
develop (verified at 671b15c). The pack() call is long-standing, so released versions with compactPad / cleanup are affected.
🤖 Generated with Claude Code