Versioning & Audit Logs
Radish CLI generates comprehensive versioning and audit trail capabilities for tracking entity changes over time. This supports compliance requirements, debugging, rollback scenarios, and approval workflows.
Overview
Radish provides three levels of change tracking:
- Full Versioning (
versioning: full) - Complete entity snapshots with revert capability - Simple Versioning (
versioning: simple) - Lightweight audit logs with field diffs - Field-Level Tracking (
trackChanges: true) - Enhanced metadata for critical fields with approval workflows
These can be used independently or combined for maximum flexibility.
Quick Comparison
| Feature | Simple Versioning | Full Versioning |
|---|---|---|
| Storage | Field diffs only (~100-500 bytes) | Complete snapshots (~1-10KB) |
| Rollback | ❌ No | ✅ Yes - POST /revert/{version} |
| Comparison | ❌ No | ✅ Yes - GET /versions/compare |
| Audit Trail | ✅ Yes - /audit endpoint | ✅ Yes - /versions endpoint |
| Use Case | Activity tracking, debugging | Compliance, rollback workflows |
| Collections | {entity}AuditLog | {entity}Version |
Full Versioning
Full versioning stores complete snapshots of entities at each change, enabling you to:
- View exact state at any point in history
- Revert to previous versions
- Compare versions side-by-side
When to Use
- ✅ Legal/compliance requirements
- ✅ Need to revert changes (e-commerce pricing, contracts)
- ✅ Document versioning
- ✅ Financial records (regulatory requirements)
- ❌ High-volume updates (consider simple versioning instead)
Configuration
entities:
Product:
versioning: full
plural: products
fields:
name: { type: string, required: true }
price: { type: float, required: true }
description: { type: string }
Generated Artifacts
- Contracts:
productVersionSchema,ProductVersiontype in .radish/lib/datalayer/contracts/product-version.ts - Repository:
ProductVersionRepoin.radish/lib/datalayer/server/repos/product-version.repo.ts - Model:
ProductVersionMongoose model in.radish/lib/datalayer/server/models/product-version.model.ts - Routes: Version history and revert endpoints
Storage Cost
Each change stores a complete entity snapshot (~1-10KB per version typically).
Simple Versioning
Simple versioning stores lightweight audit logs with field-level diffs, enabling you to:
- Track what changed, when, and by whom
- View change history
- Debug issues
When to Use
- ✅ User activity tracking
- ✅ Debugging and troubleshooting
- ✅ Analytics and reporting
- ✅ Storage-constrained environments
- ✅ High-volume entities
- ❌ Need to revert (use full versioning instead)
Configuration
entities:
Order:
versioning: simple
plural: orders
fields:
status: { type: enum, values: [pending, shipped, delivered] }
total: { type: float }
Generated Artifacts
- Contracts:
orderAuditLogSchema,OrderAuditLogtype in.radish/lib/datalayer/contracts/order-auditlog.ts - Repository:
OrderAuditLogRepoin.radish/lib/datalayer/server/repos/order-auditlog.repo.ts - Model:
OrderAuditLogMongoose model in.radish/lib/datalayer/server/models/order-auditlog.model.ts - Routes: Audit log access endpoints
Storage Cost
Each change stores only field diffs (~100-500 bytes per change typically).
Field-Level Change Tracking
Field-level tracking adds enhanced metadata and approval workflows for critical fields.
When to Use
- ✅ Price changes requiring approval
- ✅ Sensitive data modifications
- ✅ Compliance-critical fields
- ✅ Fields needing change justification
- ❌ All fields (use entity-level versioning instead)
Configuration
entities:
Invoice:
versioning: full # Entity-level versioning (optional)
plural: invoices
fields:
customerId: { type: objectId, ref: Customer }
amount:
type: float
required: true
trackChanges: true # Enhanced field tracking
status: { type: enum, values: [draft, sent, paid] }
Generated Artifacts
- Contracts:
invoiceFieldChangeSchema,InvoiceFieldChangetype in.radish/lib/datalayer/contracts/invoice-fieldchange.ts - Repository:
InvoiceFieldChangeRepowith approval workflow methods - Model:
InvoiceFieldChangeMongoose model - Routes: Field change history and approval endpoints
Features
- Tracks old value, new value, and metadata
- Approval workflow (pending → approved/rejected)
- Change reason documentation
- Custom metadata support
Generated Contracts
Full Versioning Schema
import { productVersionSchema } from '@generated/datalayer/contracts/product-version';
import type { ProductVersion } from '@generated/datalayer/contracts/product-version';
// Schema definition
export const productVersionSchema = z.object({
id: z.string(),
entityId: z.string(),
version: z.number().int(),
snapshot: z.any(), // Complete entity state
changedBy: z.string(),
changeReason: z.string().optional(),
changeType: z.enum(['create', 'update', 'delete', 'archive', 'restore']),
changedFields: z.array(z.string()).optional(),
createdAt: z.string(),
updatedAt: z.string(),
}).strict();
export type ProductVersion = z.infer<typeof productVersionSchema>;
Simple Versioning Schema
import { orderAuditLogSchema } from '@generated/datalayer/contracts/order-auditlog';
import type { OrderAuditLog } from '@generated/datalayer/contracts/order-auditlog';
// Schema definition
export const orderAuditLogSchema = z.object({
id: z.string(),
entityId: z.string(),
changedBy: z.string(),
changeType: z.enum(['create', 'update', 'delete', 'archive', 'restore']),
changedFields: z.array(z.string()).optional(),
changes: z.record(z.object({ // Field diffs
old: z.any(),
new: z.any(),
})).optional(),
changeReason: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string(),
}).strict();
export type OrderAuditLog = z.infer<typeof orderAuditLogSchema>;
Field Change Schema
import { invoiceFieldChangeSchema } from '@generated/datalayer/contracts/invoice-fieldchange';
import type { InvoiceFieldChange } from '@generated/datalayer/contracts/invoice-fieldchange';
// Schema definition
export const invoiceFieldChangeSchema = z.object({
id: z.string(),
entityId: z.string(),
fieldName: z.string(),
oldValue: z.any(),
newValue: z.any(),
changedBy: z.string(),
changeReason: z.string().optional(),
approvedBy: z.string().optional(),
approvalStatus: z.enum(['pending', 'approved', 'rejected']).optional(),
metadata: z.record(z.any()).optional(),
createdAt: z.string(),
updatedAt: z.string(),
}).strict();
export type InvoiceFieldChange = z.infer<typeof invoiceFieldChangeSchema>;
API Endpoints
Full Versioning Endpoints
Get Version History
GET /api/v1/products/{id}/versions?limit=20&cursor=...
Authorization: Bearer <token>
Response:
{
"items": [
{
"id": "...",
"entityId": "product-123",
"version": 5,
"snapshot": {
"id": "product-123",
"name": "MacBook Pro",
"price": 2499,
"description": "Latest model"
},
"changedBy": "user-456",
"changeType": "update",
"changedFields": ["price"],
"createdAt": "2026-03-10T14:30:00Z",
"updatedAt": "2026-03-10T14:30:00Z"
}
],
"total": 5,
"hasMore": false
}
Get Specific Version
GET /api/v1/products/{id}/versions/{version}
Authorization: Bearer <token>
Response:
{
"id": "...",
"entityId": "product-123",
"version": 3,
"snapshot": {
"id": "product-123",
"name": "MacBook Pro",
"price": 2299
},
"changedBy": "user-789",
"createdAt": "2026-03-09T10:15:00Z"
}
Compare Versions
GET /api/v1/products/{id}/versions/compare?a=3&b=5
Authorization: Bearer <token>
Response:
{
"entityId": "product-123",
"versionA": {
"version": 3,
"createdAt": "2026-03-09T10:15:00Z",
"changedBy": "user-789"
},
"versionB": {
"version": 5,
"createdAt": "2026-03-10T14:30:00Z",
"changedBy": "user-456"
},
"changes": [
{
"field": "price",
"oldValue": 2299,
"newValue": 2499,
"changed": true
},
{
"field": "status",
"oldValue": "draft",
"newValue": "active",
"changed": true
}
],
"totalChanges": 2
}
Revert to Version
POST /api/v1/products/{id}/revert/{version}
Authorization: Bearer <token>
Content-Type: application/json
{
"changeReason": "Reverting accidental price change"
}
Response:
{
"success": true,
"revertedTo": 3,
"newVersion": 6,
"entity": {
"id": "product-123",
"name": "MacBook Pro",
"price": 2299
}
}
Simple Versioning Endpoints
Get Audit Log
GET /api/v1/orders/{id}/audit?limit=20&cursor=...
Authorization: Bearer <token>
Response:
{
"items": [
{
"id": "...",
"entityId": "order-123",
"changedBy": "user-456",
"changeType": "update",
"changedFields": ["status", "shippedAt"],
"changes": {
"status": {
"old": "pending",
"new": "shipped"
},
"shippedAt": {
"old": null,
"new": "2026-03-10T14:30:00Z"
}
},
"changeReason": "Order shipped via FedEx",
"createdAt": "2026-03-10T14:30:00Z"
}
],
"total": 12,
"hasMore": false
}
Field Change Endpoints
Get Field Changes
GET /api/v1/invoices/{id}/field-changes?fieldName=amount
Authorization: Bearer <token>
Response:
{
"items": [
{
"id": "...",
"entityId": "invoice-123",
"fieldName": "amount",
"oldValue": 1000,
"newValue": 1200,
"changedBy": "user-456",
"changeReason": "Added consulting hours",
"approvalStatus": "pending",
"createdAt": "2026-03-10T14:30:00Z"
}
]
}
Get Pending Approvals
GET /api/v1/invoices/{id}/field-changes?approvalStatus=pending
Authorization: Bearer <token>
Approve/Reject Change
PUT /api/v1/invoices/{id}/field-changes/{changeId}
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"approvalStatus": "approved",
"approvedBy": "admin-123"
}
Use Cases
E-Commerce Price Management
Product:
versioning: full # Need to revert price changes
fields:
name: { type: string, required: true }
price:
type: float
required: true
trackChanges: true # Price changes need approval
cost: { type: float }
Workflow:
- User updates price → Full snapshot saved automatically
- Field change record created with old/new price
- Admin approves/rejects price change
- Can revert to previous version if needed
Compliance & Audit
Contract:
versioning: full # Legal requirement for complete history
fields:
terms: { type: string }
effectiveDate: { type: isoDate }
signedBy: { type: objectId, ref: User }
Benefits:
- Complete audit trail for regulators
- Prove when changes were made and by whom
- Revert unauthorized modifications
- Compare contract versions side-by-side
User Activity Tracking
UserProfile:
versioning: simple # Just need to know what changed
fields:
displayName: { type: string }
avatar: { type: url }
bio: { type: string }
Benefits:
- Low storage overhead
- Track user modifications
- Debug profile issues
- Analytics on profile updates
Mixed Strategy
Order:
versioning: simple # Track all changes
fields:
items: { type: array }
total:
type: float
trackChanges: true # Total changes need approval
status: { type: enum, values: [pending, confirmed, shipped] }
Combines:
- Simple audit log for all changes
- Enhanced tracking for critical total field
- Approval workflow for financial data
Service Integration
Services automatically create version/audit records when entities change.
Full Versioning Example
import { ProductService } from '@generated/datalayer/services';
import { ProductVersionRepo } from '@generated/datalayer/server/repos/ (internal - use services instead)product-version.repo';
import type { AuthContext } from '@generated/server/auth';
const ctx: AuthContext = {
userId: 'user-123',
roles: ['USER'],
permissions: ['product:edit:own']
};
// Create product (version 1 created automatically)
const product = await ProductService.create(ctx, {
name: 'MacBook Pro',
price: 2499,
description: 'Latest model'
});
// Update product (version 2 created automatically)
await ProductService.update(ctx, product.id, {
price: 2299
});
// Get version history
const versions = await ProductVersionRepo.listVersions(product.id);
console.log(versions.length); // 2
// Get specific version
const v1 = await ProductVersionRepo.getVersion(product.id, 1);
console.log(v1.snapshot.price); // 2499
// Revert to version 1 (creates version 3 with v1 data)
await ProductService.revert(ctx, product.id, 1);
Simple Versioning Example
import { OrderService } from '@generated/datalayer/services';
import { OrderAuditLogRepo } from '@generated/datalayer/server/repos/ (internal - use services instead)order-auditlog.repo';
// Update order (audit log created automatically)
await OrderService.update(ctx, orderId, {
status: 'shipped',
shippedAt: new Date().toISOString()
});
// Get audit log
const auditLog = await OrderAuditLogRepo.getAuditLog(orderId);
console.log(auditLog.items[0].changes);
// { status: { old: 'pending', new: 'shipped' }, ... }
Best Practices
1. Choose the Right Strategy
Full Versioning:
- ✅ Legal/compliance requirements
- ✅ Need to revert changes
- ✅ Moderate change frequency
- ❌ High-volume updates (use simple instead)
Simple Versioning:
- ✅ High-volume entities
- ✅ Storage-constrained environments
- ✅ Debugging and analytics
- ❌ Need to revert (use full instead)
Field-Level Tracking:
- ✅ Critical fields only
- ✅ Approval workflows needed
- ✅ Sensitive data
- ❌ All fields (use entity-level instead)
2. Combine Strategies
Invoice:
versioning: full # Can revert entire invoice
fields:
amount:
type: float
trackChanges: true # Amount changes need approval
items: { type: array }
This provides:
- Full entity history for compliance
- Enhanced tracking for sensitive amount field
- Approval workflow for financial data
3. Storage Considerations
Full Versioning:
- Storage grows with each change
- Monitor collection sizes
- Consider retention policies (e.g., keep 90 days)
- Archive old versions to cold storage
Simple Versioning:
- Much lower storage footprint (~10-20x smaller)
- Suitable for high-volume entities
- Still provides valuable audit trail
4. Performance Tips
- Index
entityIdin version/audit collections (done automatically) - Use cursor pagination for version history queries
- Consider caching latest version
- Archive old versions periodically
5. Security & Permissions
Versioning respects entity permissions:
- Users must have
{entity}:viewpermission to see history - Only authorized users can revert changes
- Field change approvals require appropriate permissions
- Change records include
changedByuser ID
6. Testing
import { ProductService } from '@generated/datalayer/services';
import { ProductVersionRepo } from '@generated/datalayer/server/repos/ (internal - use services instead)product-version.repo';
// Test versioning is working
const product = await ProductService.create(ctx, {
name: 'Widget',
price: 99
});
await ProductService.update(ctx, product.id, { price: 149 });
const versions = await ProductVersionRepo.listVersions(product.id);
expect(versions.items).toHaveLength(2);
expect(versions.items[0].snapshot.price).toBe(149);
expect(versions.items[1].snapshot.price).toBe(99);
Migration Guide
Adding Versioning to Existing Entities
1. Add versioning to blueprint:
Product:
versioning: full # or simple
# ... existing fields
2. Regenerate code:
radish-cli create datalayer . --schema blueprints/app.types.yml
3. Existing data:
- Old records have no version history (expected)
- Versioning starts tracking from next change
- No migration required
Changing Versioning Strategy
You can change from simple to full or vice versa:
# Before
Product:
versioning: simple
# After
Product:
versioning: full
Note: Existing audit logs/versions remain unchanged. New changes use new strategy.
Troubleshooting
Version History Not Appearing
Check:
- Versioning enabled in schema:
versioning: fullorversioning: simple - Code regenerated after schema change
- Service methods (
create,update) being called (not direct repo access) - No errors in service layer
Field Changes Not Tracked
Check:
- Field has
trackChanges: truein schema - Updates going through service layer
- Field actually changing (old !== new)
Revert Not Working
Check:
- Using
versioning: full(revert requires full snapshots) - User has appropriate permissions
- Version exists and is accessible
- No validation errors in snapshot data
Complete Example
# blueprints/app.types.yml
entities:
Product:
plural: products
versioning: full # Complete history with revert
fields:
name: { type: string, required: true }
price:
type: float
required: true
trackChanges: true # Price needs approval
description: { type: string }
category: { type: enum, values: [electronics, clothing, books] }
UserActivity:
plural: user-activities
versioning: simple # Lightweight audit only
fields:
action: { type: string }
details: { type: object }
ipAddress: { type: string }
Generated files:
.radish/lib/datalayer/
├── contracts/
│ ├── product-version.ts
│ ├── product-fieldchange.ts
│ └── useractivity-auditlog.ts
├── server/
│ ├── models/
│ │ ├── product-version.model.ts
│ │ ├── product-fieldchange.model.ts
│ │ └── useractivity-auditlog.model.ts
│ └── repos/
│ ├── product-version.repo.ts
│ ├── product-fieldchange.repo.ts
│ └── useractivity-auditlog.repo.ts
Usage:
import { ProductService } from '@generated/datalayer/services';
import type { AuthContext } from '@generated/server/auth';
const ctx: AuthContext = { userId: 'user-123', roles: ['USER'], permissions: [] };
// Product with full versioning + field tracking
const product = await ProductService.create(ctx, {
name: 'MacBook Pro',
price: 2499,
description: 'Latest model',
category: 'electronics'
});
// Update creates version + field change
await ProductService.update(ctx, product.id, { price: 2299 });
// View history via API
const versions = await fetch(`/api/v1/products/${product.id}/versions`);
const priceChanges = await fetch(`/api/v1/products/${product.id}/field-changes?fieldName=price`);
// Revert via API
await fetch(`/api/v1/products/${product.id}/revert/1`, { method: 'POST' });
// User activity with simple versioning
import { UserActivityService } from '@generated/datalayer/services';
await UserActivityService.create(ctx, {
action: 'login',
details: { method: 'oauth' },
ipAddress: '192.168.1.1'
});
// View audit log via API
const auditLog = await fetch(`/api/v1/user-activities/${activityId}/audit`);
Summary
- Full Versioning (
versioning: full) - Complete snapshots + revert capability - Simple Versioning (
versioning: simple) - Lightweight audit logs - Field Tracking (
trackChanges: true) - Enhanced metadata + approval workflow - Combine strategies for maximum flexibility
- Choose based on requirements, storage, and compliance needs
- All versioning respects authentication and authorization
Next Steps
- Entity Definitions - Configure versioning in blueprints
- Contracts - Version schema details
- Services - Using versioned services
- Access Control - Permissions for version endpoints