Skip to content
Merged
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Metadata Versioning & History** — Comprehensive version history tracking and rollback capabilities
for metadata items. Key features include:
- `MetadataHistoryRecordSchema` defining structure for historical snapshots
- `sys_metadata_history` system table for version storage
- Automatic history tracking in `DatabaseLoader` with SHA-256 checksum deduplication
- `getHistory()`, `rollback()`, and `diff()` methods in `IMetadataService`
- REST API endpoints: `GET /history`, `POST /rollback`, `GET /diff`
- `HistoryCleanupManager` with configurable retention policies (age-based and count-based)
- Comprehensive test suite covering all history operations

This aligns ObjectStack with enterprise platforms like Salesforce Setup Audit Trail and
ServiceNow Update Sets. See `docs/METADATA_HISTORY.md` for detailed usage.
([Phase 4a: Metadata Versioning & History](https://github.com/objectstack-ai/framework/issues/XXXX))
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The changelog entry links to issues/XXXX, which looks like a placeholder and will be a broken link if merged. Replace with the real issue/PR number or remove the link until one exists.

Suggested change
([Phase 4a: Metadata Versioning & History](https://github.com/objectstack-ai/framework/issues/XXXX))
(Phase 4a: Metadata Versioning & History)

Copilot uses AI. Check for mistakes.

### Changed
- **i18n: `I18nLabelSchema` now accepts `string` only** — `label`, `description`, `title`,
and other display-text fields across all UI schemas (`AppSchema`, `NavigationArea`,
Expand Down
226 changes: 226 additions & 0 deletions docs/METADATA_HISTORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# Metadata Versioning & History

## Overview

The ObjectStack metadata system now supports comprehensive version history tracking and rollback capabilities. This feature allows administrators to:

- View complete change history for any metadata item
- Compare versions to see what changed
- Rollback to previous versions when needed
- Track who made changes and when
- Automatically clean up old history records

## Architecture

### Core Components

1. **MetadataHistoryRecordSchema** (`packages/spec/src/system/metadata-persistence.zod.ts`)
- Defines the structure for history records
- Includes version, checksum, operation type, change notes, and audit fields

2. **sys_metadata_history Object** (`packages/metadata/src/objects/sys-metadata-history.object.ts`)
- System table for storing historical snapshots
- Automatically created when history tracking is enabled

3. **DatabaseLoader History Tracking** (`packages/metadata/src/loaders/database-loader.ts`)
- Automatically writes history records on create/update
- Calculates SHA-256 checksums for change detection
- Skips duplicate history records when content is unchanged

4. **MetadataManager History Methods** (`packages/metadata/src/metadata-manager.ts`)
- `getHistory()` - Query version timeline
- `rollback()` - Restore previous version
- `diff()` - Compare two versions

5. **REST API Endpoints** (`packages/metadata/src/routes/history-routes.ts`)
- `GET /api/v1/metadata/:type/:name/history` - View history
- `POST /api/v1/metadata/:type/:name/rollback` - Rollback to version
- `GET /api/v1/metadata/:type/:name/diff` - Compare versions

6. **Cleanup Manager** (`packages/metadata/src/utils/history-cleanup.ts`)
- Age-based retention (maxAgeDays)
- Count-based retention (maxVersions)
- Automatic scheduled cleanup

## Usage

### Enable History Tracking

History tracking is enabled by default when using a DatabaseLoader:

```typescript
import { DatabaseLoader } from '@objectstack/metadata';

const dbLoader = new DatabaseLoader({
driver: myDriver,
trackHistory: true, // Default: true
});
```

### Query Version History

```typescript
const history = await metadataService.getHistory('object', 'account', {
limit: 50,
offset: 0,
operationType: 'update',
since: '2025-01-01T00:00:00Z',
});

console.log(`Total versions: ${history.total}`);
history.records.forEach(record => {
console.log(`Version ${record.version} - ${record.operationType} by ${record.recordedBy} at ${record.recordedAt}`);
});
```

### Rollback to Previous Version

```typescript
const restored = await metadataService.rollback('object', 'account', 5, {
changeNote: 'Reverting problematic changes',
recordedBy: 'admin@example.com',
});
```

### Compare Versions

```typescript
const diff = await metadataService.diff('object', 'account', 5, 6);

console.log(`Identical: ${diff.identical}`);
console.log(`Summary: ${diff.summary}`);
console.log(`Changes: ${diff.patch.length} operations`);

diff.patch.forEach(op => {
console.log(`${op.op} ${op.path}: ${JSON.stringify(op.value)}`);
});
```

### Configure Retention Policy

```typescript
import { HistoryCleanupManager } from '@objectstack/metadata';

const cleanupManager = new HistoryCleanupManager(
{
maxVersions: 100, // Keep last 100 versions per item
maxAgeDays: 180, // Keep history for 6 months
autoCleanup: true, // Enable automatic cleanup
cleanupIntervalHours: 24, // Run daily
},
dbLoader
);

// Start automatic cleanup
cleanupManager.start();

// Manual cleanup
const result = await cleanupManager.runCleanup();
console.log(`Deleted ${result.deleted} records, ${result.errors} errors`);

// Preview cleanup
const stats = await cleanupManager.getCleanupStats();
console.log(`Would delete ${stats.total} records`);

// Stop cleanup
cleanupManager.stop();
```

### REST API Examples

```bash
# Get history
curl "http://localhost:3000/api/v1/metadata/object/account/history?limit=10"

# Rollback to version 5
curl -X POST "http://localhost:3000/api/v1/metadata/object/account/rollback" \
-H "Content-Type: application/json" \
-d '{"version": 5, "changeNote": "Reverting changes"}'

# Compare versions
curl "http://localhost:3000/api/v1/metadata/object/account/diff?version1=5&version2=6"
```

### Register History Routes

In your Hono app:

```typescript
import { registerMetadataHistoryRoutes } from '@objectstack/metadata';

registerMetadataHistoryRoutes(app, metadataService);
```

## Implementation Details

### Checksum Calculation

- Uses SHA-256 hashing of normalized JSON
- Keys are sorted recursively for deterministic output
- Fallback to simple hash for environments without Web Crypto API

### History Record Structure

```typescript
{
id: string; // UUID
metadataId: string; // FK to sys_metadata.id
name: string; // Denormalized for queries
type: string; // Denormalized for queries
version: number; // Version number
operationType: 'create' | 'update' | 'publish' | 'revert' | 'delete';
metadata: Record<string, unknown>; // Full snapshot
checksum: string; // SHA-256 hash
previousChecksum?: string; // For diff optimization
changeNote?: string; // Human-readable description
tenantId?: string; // Multi-tenant isolation
recordedBy?: string; // User identifier
recordedAt: string; // ISO datetime
}
```

### Database Schema

The `sys_metadata_history` table includes indexes for:
- `(metadata_id, version)` - Unique constraint
- `(metadata_id, recorded_at)` - Timeline queries
- `(type, name)` - Cross-type queries
- `(recorded_at)` - Age-based cleanup
- `(operation_type)` - Operation filtering
- `(tenant_id)` - Multi-tenant isolation

### Diff Algorithm

Uses a simple recursive diff algorithm that generates operations:
- `add` - New field added
- `remove` - Field removed
- `replace` - Field value changed

The diff result includes:
- Array of change operations
- Human-readable summary (e.g., "2 fields added, 1 field modified")

## Performance Considerations

- History records are written asynchronously and failures don't block main operations
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

This section states “History records are written asynchronously”, but the current implementation awaits history writes in DatabaseLoader.save(). Either adjust the wording to match actual behavior, or update the implementation to perform background persistence so the documentation stays accurate.

Suggested change
- History records are written asynchronously and failures don't block main operations
- History records are written as part of the main save operation (synchronous with metadata changes)

Copilot uses AI. Check for mistakes.
- Checksum deduplication prevents storing identical versions
- Indexes optimize common query patterns
- Automatic cleanup prevents unbounded growth
- History queries default to 50 records with pagination

## Comparison with Other Platforms

| Platform | Version Control | History Duration |
|----------|----------------|------------------|
| Salesforce | Setup Audit Trail | 6 months |
| ServiceNow | Update Sets + Versions | Unlimited |
| ObjectStack | sys_metadata_history | Configurable |

## Future Enhancements

- [ ] Export/import history bundles
- [ ] Visual diff UI in Studio
- [ ] Change request workflow integration
- [ ] History annotations and tagging
- [ ] Merge conflict resolution
- [ ] Branch/fork metadata workflow
13 changes: 13 additions & 0 deletions packages/metadata/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export { DatabaseLoader, type DatabaseLoaderOptions } from './loaders/database-l

// Objects
export { SysMetadataObject } from './objects/sys-metadata.object.js';
export { SysMetadataHistoryObject } from './objects/sys-metadata-history.object.js';

// Routes
export { registerMetadataHistoryRoutes } from './routes/history-routes.js';

// Utils
export { calculateChecksum, generateSimpleDiff, generateDiffSummary } from './utils/metadata-history-utils.js';
export { HistoryCleanupManager } from './utils/history-cleanup.js';

// Serializers
export { type MetadataSerializer, type SerializeOptions } from './serializers/serializer-interface.js';
Expand All @@ -43,6 +51,11 @@ export type {
MetadataCollectionInfo,
MetadataLoaderContract,
MetadataManagerConfig,
MetadataHistoryRecord,
MetadataHistoryQueryOptions,
MetadataHistoryQueryResult,
MetadataDiffResult,
MetadataHistoryRetentionPolicy,
} from '@objectstack/spec/system';

// Re-export IMetadataService contract
Expand Down
Loading
Loading