Skip to content

feat(document): add $revert() method to undo unsaved changes#16347

Open
gourabsingha1 wants to merge 3 commits into
Automattic:masterfrom
gourabsingha1:feat/gh-8714-revert
Open

feat(document): add $revert() method to undo unsaved changes#16347
gourabsingha1 wants to merge 3 commits into
Automattic:masterfrom
gourabsingha1:feat/gh-8714-revert

Conversation

@gourabsingha1

Copy link
Copy Markdown

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?)

Parameter Type Description
paths (optional) string | string[] If specified, only the specified path(s) (and their subpaths) are reverted. If omitted, the entire document is reverted.

Example

const doc = await UserModel.findOne({ name: 'Alice', age: 30 });

// Edit some paths
doc.name = 'Bob';
doc.age = 45;
doc.$isModified('name'); // true

// Revert specific paths:
doc.$revert('name');
doc.name; // 'Alice' (reverted)
doc.age;  // 45 (not reverted)

// Revert all paths:
doc.name = 'Bob';
doc.$revert();
doc.name; // 'Alice' (reverted)
doc.age;  // 30 (reverted)
doc.$isModified(); // false (modified paths cleared)

Implementation Details

  1. DB Hydration Snapshot: Inside $__init() (the hydration path), we store a deep clone of the initial _doc onto this.$__._originalDoc.
  2. Post-Save Refresh: Inside $__reset() (which runs after a successful save), we refresh the snapshot of _originalDoc using clone(this._doc).
  3. $revert() Execution:
    • If paths is omitted: We restore this._doc from the _originalDoc snapshot and clear all modified paths with this.$clearModifiedPaths().
    • If paths is specified: We restore only those specific paths from _originalDoc onto this._doc and unmark those paths as modified.
  4. TypeScript Declarations: Added type signature for $revert() in types/document.d.ts.

Tests

Added a new test file test/document.revert.test.js containing 10 passing tests verifying:

  • Full document revert
  • Specific path revert (accepting both a single string and array of strings)
  • Restores original value from DB and unmarks modification tracking
  • Refreshing snapshot on save so subsequent reverts undo to the last-saved state
  • Non-interference with other documents
  • Graceful no-op handling for newly constructed (unsaved) documents
  • Reverting nested object paths

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
Comment thread lib/document.js Outdated
// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why setValue() rather than simply this._doc = clone(originalDoc)? setValue() doesn't cast values anyway, so this set is redundant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread lib/document.js Outdated
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gourabsingha1 gourabsingha1 Jun 30, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@gourabsingha1 gourabsingha1 requested a review from vkarpov15 July 4, 2026 12:38

@vkarpov15 vkarpov15 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

Comment thread lib/document.js Outdated
// 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 = {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah right. moved it

@gourabsingha1

Copy link
Copy Markdown
Author

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 ?

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:

  1. Performance (Eager vs. Lazy): toObject() is eager and copies the entire document tree, which is expensive for larger documents. The lazy savedState approach only captures the specific paths that are modified, which makes the upfront cost practically zero (especially now that it's moved out of the constructor).
  2. Avoiding Side-Effects: overwrite() replaces all paths, which forces Mongoose to re-run setters, validation, and default generators on untouched fields. A targeted $revert() only restores the modified paths, avoiding any unintended side-effects on unmodified fields.
  3. DX: $revert() is a standard API pattern in state tracking and much cleaner than having developers write three lines of boilerplate for a common query undo task.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature to undo/revert changes on a document (which haven't been persisted yet)

2 participants