Skip to main content
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
schema.yml
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
schema.yml
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"
}
collection
string
required
Source collection name
doc_id
string
required
Document ID being versioned
version
integer
required
Version number (1, 2, 3, …)
mode
string
required
"full" or "diff"
data
object
Full document snapshot (only in full mode)
changes
array
List of field changes (only in diff mode)
updated_by
string
User ID who made the change
updated_at
date
When the version was created
change_reason
string
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:
  1. Retrieves all versions from 1 to the target version
  2. Starts with version 1 (always full)
  3. 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:
schema.yml
collections:
  documents:
    versioning:
      enabled: true
      mode: "diff"
      max_versions: 100       # Keep at most 100 versions
      retention_days: 365     # Delete versions older than 1 year
max_versions
integer
Maximum number of versions to keep per document. Oldest versions are deleted first.
retention_days
integer
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

Use full mode for documents that change frequently with many fields modified each time. Use diff mode for documents with infrequent, targeted changes.
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.
Combine versioning with audit logging for complete change tracking.

Build docs developers (and LLMs) love