Permission Mongo provides built-in document versioning to track changes over time and restore previous versions.
Overview
The versioning system supports:
Two storage modes - Full snapshots or space-efficient diffs
Version history - Track all changes to documents
Change attribution - Record who made each change and why
Point-in-time restoration - Restore documents to any previous version
Diff comparison - Compare any two versions
Retention policies - Automatic cleanup of old versions
Versioning modes
Choose between two versioning strategies:
Full mode
Stores complete document snapshots for each version.
Advantages:
Simple and fast - no reconstruction needed
Direct access to any version
No dependency on previous versions
Disadvantages:
Larger storage footprint
Redundant data across versions
collections :
documents :
versioning :
enabled : true
mode : "full"
max_versions : 100
retention_days : 365
Diff mode
Stores only the changes between versions.
Advantages:
Space-efficient storage
Clear change visibility
Minimal redundancy
Disadvantages:
Requires reconstruction from base version
Slower access to older versions
Version 1 must always be full
collections :
users :
versioning :
enabled : true
mode : "diff"
max_versions : 50
retention_days : 90
const (
// ModeFull stores complete document snapshots
ModeFull = "full"
// ModeDiff stores only the changes between versions
ModeDiff = "diff"
// OpSet represents a field set/change operation
OpSet = "set"
// OpUnset represents a field removal operation
OpUnset = "unset"
)
Version storage
Versions are stored in the _pm_versions collection with this structure:
{
"_id" : ObjectId( "..." ) ,
"collection" : "documents" ,
"doc_id" : "507f1f77bcf86cd799439011" ,
"version" : 3 ,
"mode" : "diff" ,
"changes" : [
{
"field" : "title" ,
"op" : "set" ,
"from" : "Old Title" ,
"to" : "New Title"
},
{
"field" : "status" ,
"op" : "set" ,
"from" : "draft" ,
"to" : "published"
}
],
"tenant_id" : "507f1f77bcf86cd799439012" ,
"updated_by" : "user123" ,
"updated_at" : ISODate( "2024-01-15T10:30:00Z" ) ,
"change_reason" : "Published for review"
}
Document ID being versioned
Version number (1, 2, 3, …)
Full document snapshot (only in full mode)
List of field changes (only in diff mode)
User ID who made the change
When the version was created
Optional reason for the change
Saving versions
Versions are automatically saved before updates when versioning is enabled:
// SaveVersion saves a version snapshot before an update
func ( m * Manager ) SaveVersion ( ctx context . Context , opts * SaveVersionOpts ) error {
// Get the next version number
nextVersion , err := m . getNextVersion ( ctx , opts . Collection , opts . DocID )
if err != nil {
return fmt . Errorf ( "failed to get next version: %w " , err )
}
// Build the version document
versionDoc := map [ string ] interface {}{
"_id" : primitive . NewObjectID (),
"collection" : opts . Collection ,
"doc_id" : opts . DocID ,
"version" : nextVersion ,
"mode" : opts . Mode ,
"updated_by" : opts . UpdatedBy ,
"updated_at" : time . Now (). UTC (),
}
if opts . ChangeReason != "" {
versionDoc [ "change_reason" ] = opts . ChangeReason
}
// Handle based on mode
switch opts . Mode {
case ModeFull :
versionDoc [ "data" ] = opts . Document
case ModeDiff :
// First version must always be full
if nextVersion == 1 {
versionDoc [ "mode" ] = ModeFull
versionDoc [ "data" ] = opts . Document
} else {
changes := CalculateDiff ( opts . Document , opts . NewDocument )
versionDoc [ "changes" ] = changes
}
}
coll := m . store . Collection ( VersionsCollection )
_ , err = coll . InsertOne ( ctx , versionDoc )
return err
}
Listing versions
Retrieve version history for a document:
// GetVersions lists all versions for a document
func ( m * Manager ) GetVersions ( ctx context . Context , collection , docID string ) ([] * VersionInfo , error ) {
filter := bson . M {
"collection" : collection ,
"doc_id" : docID ,
}
results , err := m . store . Find ( ctx , VersionsCollection , filter , & store . FindOptions {
Sort : bson . D {{ Key : "version" , Value : 1 }},
})
if err != nil {
return nil , fmt . Errorf ( "failed to get versions: %w " , err )
}
versions := make ([] * VersionInfo , 0 , len ( results ))
for _ , doc := range results {
info := & VersionInfo {
Version : getVersionNumber ( doc ),
UpdatedBy : doc [ "updated_by" ].( string ),
UpdatedAt : doc [ "updated_at" ].( time . Time ),
ChangeReason : doc [ "change_reason" ].( string ),
}
versions = append ( versions , info )
}
return versions , nil
}
Returns version metadata:
[
{
"version" : 1 ,
"updated_by" : "user123" ,
"updated_at" : "2024-01-10T08:00:00Z" ,
"change_reason" : "Initial creation"
},
{
"version" : 2 ,
"updated_by" : "user123" ,
"updated_at" : "2024-01-12T14:30:00Z" ,
"change_reason" : "Updated title"
},
{
"version" : 3 ,
"updated_by" : "user456" ,
"updated_at" : "2024-01-15T10:30:00Z" ,
"change_reason" : "Published for review"
}
]
Retrieving version data
Get the complete document at a specific version:
// GetVersionData reconstructs document at a specific version
func ( m * Manager ) GetVersionData ( ctx context . Context , collection , docID string , version int ) ( map [ string ] interface {}, error ) {
// Get the target version first to check its mode
targetVersion , err := m . GetVersion ( ctx , collection , docID , version )
if err != nil {
return nil , err
}
// If it's full mode, return the data directly
if targetVersion . Mode == ModeFull {
return targetVersion . Data , nil
}
// For diff mode, we need to reconstruct from version 1
filter := bson . M {
"collection" : collection ,
"doc_id" : docID ,
"version" : bson . M { "$lte" : version },
}
results , err := m . store . Find ( ctx , VersionsCollection , filter , & store . FindOptions {
Sort : bson . D {{ Key : "version" , Value : 1 }},
})
if err != nil {
return nil , fmt . Errorf ( "failed to get versions: %w " , err )
}
// Start with base document (deep copy)
data := deepCopyMap ( versions [ 0 ]. Data )
// Apply diffs in order
for i := 1 ; i < len ( versions ); i ++ {
v := versions [ i ]
if v . Mode == ModeFull {
data = deepCopyMap ( v . Data )
} else {
applyChanges ( data , v . Changes )
}
}
return data , nil
}
For diff mode, the system:
Retrieves all versions from 1 to the target version
Starts with version 1 (always full)
Applies each diff sequentially to reconstruct the document
Comparing versions
Compare two versions to see what changed:
// DiffVersions compares two versions and returns the changes
func ( m * Manager ) DiffVersions ( ctx context . Context , collection , docID string , v1 , v2 int ) ([] FieldChange , error ) {
// Get data for both versions
data1 , err := m . GetVersionData ( ctx , collection , docID , v1 )
if err != nil {
return nil , fmt . Errorf ( "failed to get version %d : %w " , v1 , err )
}
data2 , err := m . GetVersionData ( ctx , collection , docID , v2 )
if err != nil {
return nil , fmt . Errorf ( "failed to get version %d : %w " , v2 , err )
}
return CalculateDiff ( data1 , data2 ), nil
}
Returns field-level changes:
[
{
"field" : "title" ,
"op" : "set" ,
"from" : "Old Title" ,
"to" : "New Title"
},
{
"field" : "metadata.priority" ,
"op" : "set" ,
"from" : 3 ,
"to" : 5
},
{
"field" : "tags" ,
"op" : "unset" ,
"from" : [ "tag1" , "tag2" ],
"to" : null
}
]
Diff calculation
The diff algorithm uses dot notation for nested fields:
// CalculateDiff calculates the changes between two documents
func CalculateDiff ( old , new map [ string ] interface {}) [] FieldChange {
changes := make ([] FieldChange , 0 )
calculateDiffRecursive ( "" , old , new , & changes )
// Sort changes by field name for consistent ordering
sort . Slice ( changes , func ( i , j int ) bool {
return changes [ i ]. Field < changes [ j ]. Field
})
return changes
}
// calculateDiffRecursive recursively calculates diffs
func calculateDiffRecursive ( prefix string , old , new map [ string ] interface {}, changes * [] FieldChange ) {
// Track all keys from both maps
allKeys := make ( map [ string ] bool )
for k := range old {
allKeys [ k ] = true
}
for k := range new {
allKeys [ k ] = true
}
for key := range allKeys {
fieldPath := key
if prefix != "" {
fieldPath = prefix + "." + key
}
oldVal , oldExists := old [ key ]
newVal , newExists := new [ key ]
if ! oldExists {
// Field was added
* changes = append ( * changes , FieldChange {
Field : fieldPath ,
Op : OpSet ,
From : nil ,
To : newVal ,
})
} else if ! newExists {
// Field was removed
* changes = append ( * changes , FieldChange {
Field : fieldPath ,
Op : OpUnset ,
From : oldVal ,
To : nil ,
})
} else if ! deepEqual ( oldVal , newVal ) {
// Check if both are maps for recursive diff
oldMap , oldIsMap := oldVal .( map [ string ] interface {})
newMap , newIsMap := newVal .( map [ string ] interface {})
if oldIsMap && newIsMap {
calculateDiffRecursive ( fieldPath , oldMap , newMap , changes )
} else {
// Field was changed
* changes = append ( * changes , FieldChange {
Field : fieldPath ,
Op : OpSet ,
From : oldVal ,
To : newVal ,
})
}
}
}
}
Restoring versions
Restore a document to a previous version:
// GetDataForRestore gets document data for restoring to a version
func ( m * Manager ) GetDataForRestore ( ctx context . Context , collection , docID string , version int ) ( map [ string ] interface {}, error ) {
return m . GetVersionData ( ctx , collection , docID , version )
}
Use with the restore API endpoint or manually:
curl -X POST https://api.example.com/v1/collections/documents/507f1f77bcf86cd799439011/restore \
-H "Authorization: Bearer $TOKEN " \
-H "Content-Type: application/json" \
-d '{
"version": 2,
"reason": "Reverting accidental changes"
}'
Retention policies
Automatically clean up old versions:
collections :
documents :
versioning :
enabled : true
mode : "diff"
max_versions : 100 # Keep at most 100 versions
retention_days : 365 # Delete versions older than 1 year
Maximum number of versions to keep per document. Oldest versions are deleted first.
Delete versions older than this many days.
Version 1 (the base version) is never deleted, even if it exceeds retention limits.
Run cleanup manually:
// CleanupVersions removes old versions based on retention policy
func ( m * Manager ) CleanupVersions ( ctx context . Context , collection string , cfg * config . CollectionVersioningConfig ) error {
// Get all unique doc_ids in this collection
pipeline := [] bson . M {
{ "$match" : bson . M { "collection" : collection }},
{ "$group" : bson . M { "_id" : "$doc_id" }},
}
docIDs , err := m . store . Aggregate ( ctx , VersionsCollection , pipeline )
if err != nil {
return fmt . Errorf ( "failed to get document IDs: %w " , err )
}
for _ , doc := range docIDs {
docID := doc [ "_id" ].( string )
if err := m . cleanupDocumentVersions ( ctx , collection , docID , cfg ); err != nil {
return err
}
}
return nil
}
Indexes
The version manager creates these indexes for performance:
// Unique index for version lookup
{
"collection" : 1 ,
"doc_id" : 1 ,
"version" : 1
}
// Index for document queries
{
"collection" : 1 ,
"doc_id" : 1
}
// Sparse index for tenant filtering
{
"tenant_id" : 1
}
// Index for retention cleanup
{
"updated_at" : 1
}
Best practices
Choose the right mode for your use case
Use full mode for documents that change frequently with many fields modified each time. Use diff mode for documents with infrequent, targeted changes.
Set appropriate retention policies
Balance storage costs with audit requirements. Consider longer retention for compliance-critical collections.
Always populate change_reason to create an audit trail explaining why changes were made.
Track the number of versions per document and adjust retention policies if storage grows too large.
Use versioning with audit logs
Combine versioning with audit logging for complete change tracking.