Implement a linear document versioning system with active version tracking, manual milestones, configurable limits, and optional AI integration. Use when the user asks to "add version history", "implement document versioning", "track document changes", "add undo/version support", "preserve document history", "add save points to documents", or wants users to be able to snapshot, browse, and restore previous document states.
From recipesnpx claudepluginhub ichabodcole/project-docs-scaffold-template --plugin recipesThis skill uses the workspace's default tool permissions.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Calculates TAM/SAM/SOM using top-down, bottom-up, and value theory methodologies for market sizing, revenue estimation, and startup validation.
Implement a linear document versioning system that lets users save milestones, browse history, and restore previous states. This is the "save game" pattern for documents - not real-time collaborative editing or git-style branching, but a simple, reliable system for preserving document states over time.
The recipe is technology-agnostic at the architecture level. The concepts, data model, and service API work with any database (SQL, NoSQL, local SQLite, cloud Postgres) and any frontend framework (React, Vue, SwiftUI, etc.).
Every document has exactly one active version that represents the current working state. The active version is mutable (updated by auto-save). All other versions are frozen snapshots.
Document: "Meeting Notes"
├── Version 4 (active) - "Final Edit" ← Mutable, shown in editor
├── Version 3 - "AI Enhanced" ← Frozen snapshot
├── Version 2 - "AI Organized" ← Frozen snapshot
└── Version 1 - "Original" ← Frozen snapshot (auto-created)
Key properties:
Problem it solves: "I ran an AI operation on my notes and want the original back" or "I want to save the current state before making big changes."
What it avoids:
Trade-offs:
Two tables with a bidirectional relationship:
┌────────────────────────┐ ┌──────────────────────────┐
│ documents │────┐ │ document_versions │
│ │ │ │ │
│ id (PK) │ │ │ id (PK) │
│ title │ └───→│ documentId (FK) │
│ content (mirror) │ │ content │
│ activeVersionId ───────┼────────→│ versionNumber │
│ updatedAt │ │ label │
│ ... │ │ createdAt │
└────────────────────────┘ │ createdBy │
└──────────────────────────┘
document_versions Schema| Column | Type | Constraints | Purpose |
|---|---|---|---|
id | text/uuid | PK | Unique version identifier |
documentId | text/uuid | NOT NULL, FK → documents.id | Parent document |
content | text | NOT NULL | Full document content snapshot |
label | text | nullable | Human-readable name ("Original", "Before AI edit") |
versionNumber | integer | NOT NULL | Sequential per document (1, 2, 3...) |
createdAt | timestamp | NOT NULL | When version was created |
createdBy | text | NOT NULL | Origin: 'user', 'ai:organize', 'ai:agent:xyz' |
Required indexes:
(documentId) - Fast version listing(documentId, versionNumber) UNIQUE - Enforces sequential numbering, prevents
duplicates(createdAt) - Chronological queriesOn the documents table, add:
activeVersionId (text, nullable) - Points to the current active versioncontent (text) - Mirrors the active version's content (see "Content
Mirroring" below)The documents.content field duplicates the active version's content. This
redundancy exists for:
document.content
keeps workingInvariant: documents.content MUST always equal the active version's
content. Every operation that changes content or switches versions must maintain
this.
Future optimization: Could remove documents.content and always join, but
the duplication is cheap and simplifies reads.
Depending on your needs, you may also want:
| Column | Type | Purpose |
|---|---|---|
sessionId | text, nullable | Groups edits within an agent/automation session (see "Session Deduplication") |
ownerId | text, nullable | Denormalized owner for row-level security / sync rules |
metadata | json, nullable | Extensible metadata (word count, AI model used, etc.) |
The version service is the core of the system. It should be a standalone module with no UI dependencies - just database operations and business rules.
VersionService
├── createVersion(params) → DocumentVersion
├── getVersions(documentId) → DocumentVersion[]
├── getActiveVersion(documentId) → DocumentVersion | null
├── switchVersion(documentId, versionId) → void
├── updateVersion(versionId, content) → void
├── deleteVersion(documentId, versionId) → void
├── renameVersion(versionId, label) → void
└── duplicateVersion(versionId) → DocumentVersion
createVersion(params)The most complex operation. Handles limit enforcement and version numbering.
Parameters:
{
documentId: string // Parent document
content: string // Content to snapshot
label?: string // Optional label (default: auto-generated)
createdBy: string // 'user' | 'ai:*'
setActive?: boolean // Make this the active version (default: true)
}
Logic:
VersionLimitError (do NOT silently skip)versionNumber = currentMaxVersionNumber + 1setActive: true:
documents.activeVersionId to new version IDdocuments.content to new version contentImportant: Steps 7-8 should run in a transaction.
updateVersion(versionId, content)Called during auto-save. Updates an existing version's content in-place.
Logic:
document_versions.content where id = versionIddocuments.content (maintain
mirror)Important: This does NOT create a new version. Auto-save modifies the active version in-place. This is a deliberate design choice to avoid version bloat from continuous typing.
switchVersion(documentId, versionId)Changes which version is displayed in the editor.
Logic:
documents.activeVersionId = versionIddocuments.content = version.content (maintain mirror)documents.updatedAtUI consideration: Switching versions should clear the editor's undo/redo stack, since the undo history belongs to the previous version's editing session.
deleteVersion(documentId, versionId)Removes a version permanently.
Logic:
activeVersionIdversionId === activeVersionId, throw ActiveVersionOperationError (user
must switch to a different version first)Important: Version numbers are NOT renumbered after deletion. If you delete Version 2 from [1, 2, 3], you get [1, 3]. This is intentional - version numbers are stable identifiers, not array indices.
duplicateVersion(versionId)Creates a copy of an existing version as a new version.
Logic:
"{original label} (copy)" or "Version copy"When a new document is created, Version 1 must be created atomically:
Transaction:
1. Insert document (activeVersionId = null initially)
2. Insert Version 1 (label = "Original", versionNumber = 1, createdBy = "user")
3. Update document.activeVersionId = Version 1 ID
This ensures no document exists without at least one version.
Define two domain-specific errors:
VersionLimitError - Thrown when version count reaches the configured
limit.
Properties:
- documentId: string
- currentCount: number
- maxCount: number
The UI should catch this and show a warning like "Maximum versions reached (20/20). Delete old versions to save new ones."
ActiveVersionOperationError - Thrown when attempting to delete the active
version. The UI should explain that the user must switch to a different version
before deleting.
Auto-save and versioning interact but are separate concerns:
| Operation | Creates New Version? | Modifies Active Version? |
|---|---|---|
| Auto-save (typing) | No | Yes (in-place update) |
| Manual "Save Version" | Yes | Yes (new version becomes active) |
| AI operation (auto-version ON) | Yes | Yes (new version becomes active) |
| AI operation (auto-version OFF) | No | Yes (in-place update) |
Pattern: Auto-save calls updateVersion(activeVersionId, content). "Save
Version" calls createVersion(...). These are fundamentally different
operations.
Debouncing: Auto-save should be debounced (e.g., 300ms) to batch rapid keystrokes into single writes.
When AI operations modify document content, versioning can optionally preserve the "before" state.
User triggers AI operation (e.g., "Organize")
↓
Check auto-versioning setting
↓
If enabled AND content changed:
├─ Create new version with AI output
│ (label: "AI Organized", createdBy: "ai:organize")
├─ New version becomes active
└─ Previous version preserved as frozen snapshot
↓
If disabled:
└─ Update active version in-place (no snapshot preserved)
If your app supports AI agents or automations that make many small edits, you need session deduplication to prevent consuming all version slots.
Problem: An AI agent makes 50 small edits. Without deduplication, each edit creates a new version, hitting the limit immediately.
Solution: Assign a sessionId to a group of related edits. If a version
with that sessionId already exists for the document, update it in-place
instead of creating a new version.
Add a UNIQUE constraint: (documentId, sessionId) to enforce one version per
session per document.
Logic in createVersion:
If sessionId is provided:
Look for existing version with this (documentId, sessionId)
If found:
Update existing version's content in-place
Return existing version (no new version created)
If not found:
Create new version normally (with sessionId stored)
This collapses an entire AI session into a single version slot.
The createdBy field uses a namespace pattern:
"user" - Human-initiated save"ai:organize" - AI organize workflow"ai:enhance" - AI enhance workflow"ai:agent:<agentId>" - Specific AI agent"ai:pipeline:<executionId>" - Automation pipelineThis lets the UI distinguish human vs AI versions and show appropriate labels.
createVersion() checks count BEFORE insertingShow version count and limit status in the UI:
If your app has user-configurable settings, expose these:
| Setting | Type | Default | Purpose |
|---|---|---|---|
versioning.enabled | boolean | true | Master toggle for versioning UI |
versioning.maxVersionsPerDocument | number | 20 | Version limit (5-100) |
versioning.defaultLabelStrategy | enum | "numbered" | How auto-generated labels work |
versioning.autoVersionOnAI | boolean | false | Create version before AI operations |
Label strategies:
"numbered" - "Version 1", "Version 2", ..."timestamped" - "Jan 15, 3:30 PM""custom" - Always prompt user for label nameThe versioning UI consists of these components (adapt to your framework):
Shows in the document header/toolbar. Displays the current version and lets users browse history.
Features:
Simple button in the toolbar. Opens a dialog to optionally name the version.
States:
Modal with a text input for the version label.
Features:
Full management view for all versions. Can be a sidebar, modal, or dedicated page.
Per-version actions:
Footer: Version count, limit indicator, limit warnings
Helper functions for formatting version data in the UI:
formatVersionLabel(version) - Returns label or fallback "Version {n}"formatTimestamp(date) - Locale-aware date/time formattingformatCreatorLabel(createdBy) - "User" vs "AI generated" vs specific agent
namepreviewContent(content, maxLength) - Truncated content with ellipsisisVersionLimitReached(count, max) - Boolean checkisApproachingLimit(count, max) - Warning threshold check (within 20% or 3)When implementing this recipe, follow these phases in order:
document_versions table to your schemaactiveVersionId to your documents table (if not already present)VersionService with all operationsVersionLimitError and ActiveVersionOperationErrorValidate: Create a document, verify Version 1 exists, verify
activeVersionId is set.
versionService.updateVersion() instead of directly writing contentdocuments.content) stays in syncValidate: Edit a document, verify only one version exists (Version 1),
verify content updates in both documents.content and the version record.
versionService.createVersion()VersionLimitError in UIValidate: Create a document, make edits, save a version, verify two versions exist, verify new version is active.
versionService.getVersions() and switchVersion()Validate: Switch between versions, verify editor content changes, verify switching back restores previous content.
Validate: Rename a version, delete a non-active version, try to delete active version (should be blocked), duplicate a version.
SQLite (local-first apps):
TEXT for IDs, INTEGER for timestamps (unix ms or ISO string)PostgreSQL:
TEXT or UUID for IDs, TIMESTAMPTZ for timestampsON DELETE CASCADE for the documentId FKownerId columnNoSQL (Firestore, MongoDB, etc.):
React: Version state in a context or Zustand store. Components: dropdown, dialog, management panel. Custom hooks for version operations.
Vue: Pinia store for version state. Composables for version operations, dialogs, and auto-versioning. Components for dropdown, dialog, panel.
SwiftUI / Native: ViewModel with published version state. Native sheet/modal for dialogs. List view for version management.
If your app runs on multiple platforms (desktop + mobile):
ownerId
column for row-level filtering in sync rules.Version numbers are stable. Never renumber after deletion. Treat them as identifiers, not array positions. Gaps are fine (1, 3, 5 is valid).
Auto-save does NOT create versions. This is the most common misunderstanding. Auto-save updates the active version in-place. Only explicit "Save Version" or programmatic triggers create new versions.
Active version cannot be deleted. Enforce this at the service layer AND the UI layer. The service should throw; the UI should disable the button.
Document creation must be atomic. Always create Version 1 in the same transaction as the document. A document without a version is an invalid state.
Content mirror must stay in sync. Every code path that changes content or
switches versions must update both document_versions.content AND
documents.content. Missing one causes data inconsistency.
No auto-pruning. Resist the temptation to auto-delete old versions. Users don't expect their saved milestones to disappear. If storage is a concern, lower the default limit rather than pruning silently.
Full content, not diffs. Each version stores the complete document. This is intentional - diffs add complexity (computing, applying, handling corruption) for minimal storage savings on text documents. If your documents are very large (100KB+), consider diff storage as a future optimization.
Clear undo on version switch. The editor's in-memory undo stack belongs to the editing session of the previously active version. Switching versions must clear it to avoid confusing undo behavior.
Lazy-load versions. Don't load the full version list when opening a document. Load it when the user opens the version dropdown. Most users work with the active version and never look at history.