Skip to content

Commit 86e97bc

Browse files
Claudehotlong
andauthored
Implement metadata history schemas and database tracking
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/f60fe20f-aa2a-44ef-9eea-fb72d6cc454f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 9bc0f2f commit 86e97bc

5 files changed

Lines changed: 674 additions & 1 deletion

File tree

packages/metadata/src/loaders/database-loader.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ import type {
1717
MetadataSaveOptions,
1818
MetadataSaveResult,
1919
MetadataRecord,
20+
MetadataHistoryRecord,
2021
} from '@objectstack/spec/system';
2122
import { SysMetadataObject } from '../objects/sys-metadata.object.js';
23+
import { SysMetadataHistoryObject } from '../objects/sys-metadata-history.object.js';
2224
import type { IDataDriver } from '@objectstack/spec/contracts';
2325
import type { MetadataLoader } from './loader-interface.js';
26+
import { calculateChecksum } from '../utils/metadata-history-utils.js';
2427

2528
/**
2629
* Configuration for the DatabaseLoader.
@@ -32,8 +35,14 @@ export interface DatabaseLoaderOptions {
3235
/** The table name to store metadata records (default: 'sys_metadata') */
3336
tableName?: string;
3437

38+
/** The table name to store history records (default: 'sys_metadata_history') */
39+
historyTableName?: string;
40+
3541
/** Tenant ID for multi-tenant isolation */
3642
tenantId?: string;
43+
44+
/** Enable history tracking (default: true) */
45+
trackHistory?: boolean;
3746
}
3847

3948
/**
@@ -57,13 +66,18 @@ export class DatabaseLoader implements MetadataLoader {
5766

5867
private driver: IDataDriver;
5968
private tableName: string;
69+
private historyTableName: string;
6070
private tenantId?: string;
71+
private trackHistory: boolean;
6172
private schemaReady = false;
73+
private historySchemaReady = false;
6274

6375
constructor(options: DatabaseLoaderOptions) {
6476
this.driver = options.driver;
6577
this.tableName = options.tableName ?? 'sys_metadata';
78+
this.historyTableName = options.historyTableName ?? 'sys_metadata_history';
6679
this.tenantId = options.tenantId;
80+
this.trackHistory = options.trackHistory !== false; // Default to true
6781
}
6882

6983
/**
@@ -86,6 +100,25 @@ export class DatabaseLoader implements MetadataLoader {
86100
}
87101
}
88102

103+
/**
104+
* Ensure the history table exists.
105+
* Uses IDataDriver.syncSchema with the SysMetadataHistoryObject definition.
106+
*/
107+
private async ensureHistorySchema(): Promise<void> {
108+
if (!this.trackHistory || this.historySchemaReady) return;
109+
110+
try {
111+
await this.driver.syncSchema(this.historyTableName, {
112+
...SysMetadataHistoryObject,
113+
name: this.historyTableName,
114+
});
115+
this.historySchemaReady = true;
116+
} catch {
117+
// If syncSchema fails (e.g. table already exists), mark ready and continue
118+
this.historySchemaReady = true;
119+
}
120+
}
121+
89122
/**
90123
* Build base filter conditions for queries.
91124
* Always includes tenantId when configured.
@@ -101,6 +134,83 @@ export class DatabaseLoader implements MetadataLoader {
101134
return filter;
102135
}
103136

137+
/**
138+
* Create a history record for a metadata change.
139+
*
140+
* @param metadataId - The metadata record ID
141+
* @param type - Metadata type
142+
* @param name - Metadata name
143+
* @param version - Version number
144+
* @param metadata - The metadata payload
145+
* @param operationType - Type of operation
146+
* @param previousChecksum - Checksum of previous version (if any)
147+
* @param changeNote - Optional change description
148+
* @param recordedBy - Optional user who made the change
149+
*/
150+
private async createHistoryRecord(
151+
metadataId: string,
152+
type: string,
153+
name: string,
154+
version: number,
155+
metadata: unknown,
156+
operationType: 'create' | 'update' | 'publish' | 'revert' | 'delete',
157+
previousChecksum?: string,
158+
changeNote?: string,
159+
recordedBy?: string
160+
): Promise<void> {
161+
if (!this.trackHistory) return;
162+
163+
await this.ensureHistorySchema();
164+
165+
const now = new Date().toISOString();
166+
const checksum = await calculateChecksum(metadata);
167+
168+
// Skip if checksum matches previous version (no actual change)
169+
if (previousChecksum && checksum === previousChecksum && operationType === 'update') {
170+
return;
171+
}
172+
173+
const historyId = generateId();
174+
const metadataJson = JSON.stringify(metadata);
175+
176+
const historyRecord: Partial<MetadataHistoryRecord> = {
177+
id: historyId,
178+
metadataId,
179+
name,
180+
type,
181+
version,
182+
operationType,
183+
metadata: metadataJson as any,
184+
checksum,
185+
previousChecksum,
186+
changeNote,
187+
recordedBy,
188+
recordedAt: now,
189+
...(this.tenantId ? { tenantId: this.tenantId } : {}),
190+
};
191+
192+
try {
193+
await this.driver.create(this.historyTableName, {
194+
id: historyRecord.id,
195+
metadata_id: historyRecord.metadataId,
196+
name: historyRecord.name,
197+
type: historyRecord.type,
198+
version: historyRecord.version,
199+
operation_type: historyRecord.operationType,
200+
metadata: historyRecord.metadata,
201+
checksum: historyRecord.checksum,
202+
previous_checksum: historyRecord.previousChecksum,
203+
change_note: historyRecord.changeNote,
204+
recorded_by: historyRecord.recordedBy,
205+
recorded_at: historyRecord.recordedAt,
206+
...(this.tenantId ? { tenant_id: this.tenantId } : {}),
207+
});
208+
} catch (error) {
209+
// Log error but don't fail the main operation
210+
console.error(`Failed to create history record for ${type}/${name}:`, error);
211+
}
212+
}
213+
104214
/**
105215
* Convert a database row to a metadata payload.
106216
* Parses the JSON `metadata` column back into an object.
@@ -280,6 +390,7 @@ export class DatabaseLoader implements MetadataLoader {
280390

281391
const now = new Date().toISOString();
282392
const metadataJson = JSON.stringify(data);
393+
const newChecksum = await calculateChecksum(data);
283394

284395
try {
285396
const existing = await this.driver.findOne(this.tableName, {
@@ -290,13 +401,27 @@ export class DatabaseLoader implements MetadataLoader {
290401
if (existing) {
291402
// Update existing record
292403
const version = ((existing.version as number) ?? 0) + 1;
404+
const previousChecksum = existing.checksum as string | undefined;
405+
293406
await this.driver.update(this.tableName, existing.id as string, {
294407
metadata: metadataJson,
295408
version,
409+
checksum: newChecksum,
296410
updated_at: now,
297411
state: 'active',
298412
});
299413

414+
// Create history record for update
415+
await this.createHistoryRecord(
416+
existing.id as string,
417+
type,
418+
name,
419+
version,
420+
data,
421+
'update',
422+
previousChecksum
423+
);
424+
300425
return {
301426
success: true,
302427
path: `datasource://${this.tableName}/${type}/${name}`,
@@ -313,6 +438,7 @@ export class DatabaseLoader implements MetadataLoader {
313438
namespace: 'default',
314439
scope: (data as any)?.scope ?? 'platform',
315440
metadata: metadataJson,
441+
checksum: newChecksum,
316442
strategy: 'merge',
317443
state: 'active',
318444
version: 1,
@@ -322,6 +448,16 @@ export class DatabaseLoader implements MetadataLoader {
322448
updated_at: now,
323449
});
324450

451+
// Create history record for creation
452+
await this.createHistoryRecord(
453+
id,
454+
type,
455+
name,
456+
1,
457+
data,
458+
'create'
459+
);
460+
325461
return {
326462
success: true,
327463
path: `datasource://${this.tableName}/${type}/${name}`,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_metadata_history — Metadata Version History Object
7+
*
8+
* Stores historical snapshots of metadata changes for version tracking,
9+
* audit trail, and rollback capabilities.
10+
*
11+
* This is a system object (isSystem: true) — protected from deletion and
12+
* automatically provisioned when metadata history is enabled.
13+
*
14+
* Each record represents a single version snapshot of a metadata item,
15+
* created whenever the metadata is modified, published, or reverted.
16+
*
17+
* @see MetadataHistoryRecordSchema in metadata-persistence.zod.ts
18+
*/
19+
export const SysMetadataHistoryObject = ObjectSchema.create({
20+
namespace: 'sys',
21+
name: 'metadata_history',
22+
label: 'Metadata History',
23+
pluralLabel: 'Metadata History',
24+
icon: 'history',
25+
isSystem: true,
26+
description: 'Version history and audit trail for metadata changes',
27+
28+
fields: {
29+
/** Primary Key (UUID) */
30+
id: Field.text({
31+
label: 'ID',
32+
required: true,
33+
readonly: true,
34+
}),
35+
36+
/** Foreign key to sys_metadata.id */
37+
metadata_id: Field.text({
38+
label: 'Metadata ID',
39+
required: true,
40+
readonly: true,
41+
maxLength: 255,
42+
}),
43+
44+
/** Machine name (denormalized for easier querying) */
45+
name: Field.text({
46+
label: 'Name',
47+
required: true,
48+
searchable: true,
49+
readonly: true,
50+
maxLength: 255,
51+
}),
52+
53+
/** Metadata type (denormalized for easier querying) */
54+
type: Field.text({
55+
label: 'Metadata Type',
56+
required: true,
57+
searchable: true,
58+
readonly: true,
59+
maxLength: 100,
60+
}),
61+
62+
/** Version number at this snapshot */
63+
version: Field.number({
64+
label: 'Version',
65+
required: true,
66+
readonly: true,
67+
}),
68+
69+
/** Type of operation that created this history entry */
70+
operation_type: Field.select(['create', 'update', 'publish', 'revert', 'delete'], {
71+
label: 'Operation Type',
72+
required: true,
73+
readonly: true,
74+
}),
75+
76+
/** Historical metadata snapshot (JSON payload) */
77+
metadata: Field.textarea({
78+
label: 'Metadata',
79+
required: true,
80+
readonly: true,
81+
description: 'JSON-serialized metadata snapshot at this version',
82+
}),
83+
84+
/** SHA-256 checksum of metadata content */
85+
checksum: Field.text({
86+
label: 'Checksum',
87+
required: true,
88+
readonly: true,
89+
maxLength: 64,
90+
}),
91+
92+
/** Checksum of the previous version */
93+
previous_checksum: Field.text({
94+
label: 'Previous Checksum',
95+
required: false,
96+
readonly: true,
97+
maxLength: 64,
98+
}),
99+
100+
/** Human-readable description of changes */
101+
change_note: Field.textarea({
102+
label: 'Change Note',
103+
required: false,
104+
readonly: true,
105+
description: 'Description of what changed in this version',
106+
}),
107+
108+
/** Tenant ID for multi-tenant isolation */
109+
tenant_id: Field.text({
110+
label: 'Tenant ID',
111+
required: false,
112+
readonly: true,
113+
maxLength: 255,
114+
}),
115+
116+
/** User who made this change */
117+
recorded_by: Field.text({
118+
label: 'Recorded By',
119+
required: false,
120+
readonly: true,
121+
maxLength: 255,
122+
}),
123+
124+
/** When was this version recorded */
125+
recorded_at: Field.datetime({
126+
label: 'Recorded At',
127+
required: true,
128+
readonly: true,
129+
}),
130+
},
131+
132+
indexes: [
133+
{ fields: ['metadata_id', 'version'], unique: true },
134+
{ fields: ['metadata_id', 'recorded_at'] },
135+
{ fields: ['type', 'name'] },
136+
{ fields: ['recorded_at'] },
137+
{ fields: ['operation_type'] },
138+
{ fields: ['tenant_id'] },
139+
],
140+
141+
enable: {
142+
trackHistory: false, // Don't track history of history records
143+
searchable: false,
144+
apiEnabled: true,
145+
apiMethods: ['get', 'list'], // Read-only via API
146+
trash: false,
147+
},
148+
});

0 commit comments

Comments
 (0)