feat(document): add $revert() method to undo unsaved changes#16347
feat(document): add $revert() method to undo unsaved changes#16347gourabsingha1 wants to merge 3 commits into
$revert() method to undo unsaved changes#16347Conversation
Adds a new `()` method on Document that discards any unsaved modifications and restores the document instance to the state it was in when last loaded from the database or last successfully saved. Also supports passing specific paths to partially revert a document. Closes Automattic#8714
| // Use init() to recast/rehydrate values properly from the snapshot | ||
| const keys = Object.keys(originalDoc); | ||
| for (const key of keys) { | ||
| utils.setValue(key, clone(originalDoc[key]), this._doc); |
There was a problem hiding this comment.
Why setValue() rather than simply this._doc = clone(originalDoc)? setValue() doesn't cast values anyway, so this set is redundant.
There was a problem hiding this comment.
you're right. it was unnecessary. I've replaced the entire $revert() body to use this.$set(key, clone(savedState[key])) instead, which additionally runs Mongoose's schema casting/rehydration correctly
| applyDefaults(this, this.$__.selected, this.$__.exclude, hasIncludedChildren, false, this.$__.skipDefaults); | ||
|
|
||
| // Take a snapshot of the document as loaded from the DB so $revert() can restore it | ||
| this.$__._originalDoc = clone(this._doc); |
There was a problem hiding this comment.
I'd strongly prefer this to be a method where you can take a snapshot of the current document and later restore the snapshot. clone() can be expensive on large documents, and init() is one of the most performance sensitive parts of Mongoose.
There was a problem hiding this comment.
gott it! I've removed clone(this._doc) entirely from both $__init() and $__reset(). now it piggybacks on Mongoose's existing $__savedState lazy-capture mechanism. $__init() now just does a single O(1) this.$__.savedState = {} assignment.
the first time a user modifies any path, $__saveInitialState() clones only that top-level path, not the whole document. $revert() restores those lazily-captured values.
…g _doc Address reviewer feedback on PR Automattic#16347: 1. (Comment 1) No more key-by-key setValue() loop — () now calls this.$set(key, clone(savedState[key])) which properly casts/rehydrates values through the schema, and also correctly unmarked modified paths. 2. (Comment 2) Removed clone(this._doc) from both $__init() and $__reset(). init() is now performance-unchanged. Instead, $__init() now does a single O(1) assignment: this.$__.savedState = {} (no clone). The existing $__saveInitialState() mechanism already lazily captures the pre-modification top-level value of any path the user changes for the first time. $revert() simply restores those captured values. Documents that are never modified pay zero cost. 3. Subdocument reverts now delegate to the owner document using the full path prefix, so $revert() on a subdoc correctly reverts the owner's change-tracking state. 4. Updated two tests to match corrected semantics: - Direct _doc mutation is not tracked (document behavior is unchanged); updated test to use Mongoose's API (doc.set()) instead. - New (unsaved) documents have savedState=null so $revert() is a no-op, which is the correct and expected behavior.
vkarpov15
left a comment
There was a problem hiding this comment.
I have a few comments here and I did some digging into savedState - on my original review I didn't even remember we had savedState as a property.
TBH, I don't want to turn on savedState for all documents, I worry that will be too much performance overhead for not enough benefit. If you want to store the original state of the document and later revert it, you could do the following:
const docSnapshot = doc.toObject({ getters: false });
// Later
doc.overwrite(docSnapshot);
doc.$clearModifiedPaths();
// `doc` now has the exact same properties as docSnapshot with nothing modified
WDYT about this @gourabsingha1 ?
| // Initialize savedState so $__saveInitialState() can lazily capture the | ||
| // pre-modification value of each top-level path the user later changes. | ||
| // This is O(1) — no clone — and enables $revert() at zero upfront cost. | ||
| this.$__.savedState = {}; |
There was a problem hiding this comment.
Cost isn't zero here, creating a new property on $__ and creating a new object is fast but still comes with a cost. The other big tradeoff is that this triggers savedState storage even on newly created documents, which seems incorrect and unnecessary for this PR's stated goal of undoing unsaved changes - $revert() on a new MyModel() would leave current changes. Might be better to move this to init?
Hey Valeri! Thanks for the suggestion. While using toObject({ getters: false }) + doc.overwrite() works as a workaround, a native $revert() has a few major advantages:
|
What does this PR do?
Closes #8714
Adds a new public
$revert(paths?)method to documents. This method allows discarding any unsaved modifications and restoring the document to its original state (either when it was last loaded from the database or after a successful save).Why?
Currently, there is no straightforward way to discard unsaved modifications to a document. Users who wanted to undo edits had to manually query the DB again, clone the object, or manually track and reset every property. This is especially useful in failure/retry/transaction rollback logic.
API Signature:
doc.$revert(paths?)paths(optional)string | string[]Example
Implementation Details
$__init()(the hydration path), we store a deep clone of the initial_docontothis.$__._originalDoc.$__reset()(which runs after a successful save), we refresh the snapshot of_originalDocusingclone(this._doc).$revert()Execution:pathsis omitted: We restorethis._docfrom the_originalDocsnapshot and clear all modified paths withthis.$clearModifiedPaths().pathsis specified: We restore only those specific paths from_originalDocontothis._docand unmark those paths as modified.$revert()intypes/document.d.ts.Tests
Added a new test file
test/document.revert.test.jscontaining 10 passing tests verifying: