fix: copyPadWithoutHistory wrote an invalid changeset - #8140
Conversation
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
PR Summary by QodoFix copyPadWithoutHistory changeset length, trailing newline drift, and async writes
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo
1. No cleanup on failure
|
| // Must be awaited: an un-awaited rejection here (an invalid changeset, | ||
| // a failed write) surfaces as an unhandled rejection instead of failing | ||
| // the copy, which is how the length bug above went unnoticed. | ||
| await dstPad.appendRevision(changeset, authorId); |
There was a problem hiding this comment.
1. No cleanup on failure 🐞 Bug ☼ Reliability
If dstPad.appendRevision() rejects, copyPadWithoutHistory() throws after creating the destination pad and writing author/group metadata, leaving a partially-created destination pad behind. A retry with force=false will then fail early with “destinationID already exists”.
Agent Prompt
### Issue description
`copyPadWithoutHistory()` now correctly `await`s `dstPad.appendRevision(...)`, which means failures (db write, hook error, invariant error) will reject the copy. However, the function has already created/persisted the destination pad and updated related metadata (authors + group pad list). If `appendRevision()` fails, there is no rollback, so an incomplete destination pad can remain and a subsequent retry with `force=false` will fail with `destinationID already exists`.
### Issue Context
The destination pad is created via `padManager.getPad(...)` (which initializes and persists rev0). Then author/group side effects are applied before the awaited `appendRevision()`.
### Fix Focus Areas
- src/node/db/Pad.ts[746-843]
- src/node/db/Pad.ts[717-737]
### Suggested fix
- Wrap the destination-pad creation + revision append in a `try { ... } catch (err) { ... }`.
- In the `catch` block, best-effort remove the destination pad that was just created (for example `await dstPad.remove()`), then rethrow the original error.
- Consider moving `copyAuthorInfoToDestinationPad()` and the `group:${destGroupID}.pads` update to *after* the revision append succeeds, to reduce rollback work.
- Add a regression test that forces `appendRevision()` to throw (stub/hook) and asserts the destination pad does not exist afterward (or is retryable without `force=true`).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
pack() takes the TOTAL length of the new document, but the call passed
`assem.getLengthChange()` -- a delta. The resulting revision 1 header
disagreed with its own ops, with two consequences:
1. opsFromAText() skips the source document's final newline, so both
of the destination pad's rev-0 newlines survived and the copy came
out one newline longer than the source. It grew again on every
subsequent copy.
2. The copy's revision 1 failed checkRep(), so the copied pad failed
pad.check() from then on.
(2) is the damaging one. deleteRevisions() -- `cleanup.keepRevisions`,
and compactPad with a keep count -- calls pad.check() before it touches
anything. compactPad's full-collapse mode goes through this function
twice, so the feature meant to reclaim database space produced pads
that could never be cleaned up again.
Pass the total length, drop the surplus newline, and trim the char bank
to the characters actually inserted.
Also await dstPad.appendRevision(): an un-awaited rejection surfaced as
an unhandled rejection rather than failing the copy, which is how the
malformed changeset went unnoticed. Same for the `saveToDatabase()`
that the comment above it calls "flush the source pad".
The existing API test quantified the off-by-one without naming it: the
direct getHTML test strips one trailing '<br>' while the copy test
stripped '<br><br>'. It now strips one, like the source. The compactPad
test's tolerance for "adjusted trailing whitespace" is likewise replaced
with a byte-exact comparison plus a check() assertion.
Found while investigating #8134.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f735f53 to
d5a788e
Compare
Fixes #8139. Found while investigating #8134.
Problem
copyPadWithoutHistory()packed a length delta intopack()'snewLenparameter, which wants a total:The resulting revision 1 header disagrees with its own ops. Two consequences:
opsFromAText()skips the source document's final newline, so both of the destination pad's rev-0 newlines survive alongside the inserted text. A 242-char source produced a 243-char copy, growing again on every subsequent copy.pad.check()forever. Revision 1 failscheckRep()withclaimed length does not match actual length.(2) is the damaging one:
deleteRevisions()—cleanup.keepRevisionsfrom the admin UI, andcompactPadwith a keep count — callspad.check()before touching anything, andcompactPad's full-collapse mode routes through this function twice. The feature that exists to reclaim database space left pads that could never be cleaned up again.Fix
oldLength + assem.getLengthChange()as the total new length.-op to drop the surplus newline so the copy matches the source exactly.oldAText.text.slice(0, -1)), sinceopsFromAText()omits the final newline.await dstPad.appendRevision(...). Un-awaited, a rejection became an unhandled rejection instead of failing the copy — which is how the malformed changeset survived this long. Same for thesaveToDatabase()whose own comment calls it "flush the source pad".Tests
New
src/tests/backend/specs/copyPadWithoutHistoryIntegrity.ts: text equality with the source,checkRep()on revision 1,pad.check()on the copy, stability across three chained copies, author attribution, and bothcompactPadfull-collapse properties (text preserved, and the pad remains cleanable afterwards). 6 of its 7 cases fail without thePad.tschange.Two existing tests encoded the bug rather than catching it, and are corrected here:
tests/backend/specs/api/pad.tsstripped'<br></body>'when checking a pad's own HTML but'<br><br></body>'when checking a copy of it — the off-by-one, quantified. Now strips one, like the source.tests/backend/specs/compactPad.tsdeclined to "assert byte-exact equality because Cleanup.deleteAllRevisions goes through copyPadWithoutHistory twice and may adjust trailing whitespace", and never calledcheck()afterwards. Now byte-exact, plus acheck()assertion.Full backend suite: 1628 passing, 0 failing.
Note on existing data
This stops new pads being corrupted; it does not repair pads already copied or compacted by the old code. Those still fail
pad.check()and so still refuse keep-count cleanup. Worth a follow-up if we want a repair path — happy to take that on separately.🤖 Generated with Claude Code