@@ -17,10 +17,13 @@ import type {
1717 MetadataSaveOptions ,
1818 MetadataSaveResult ,
1919 MetadataRecord ,
20+ MetadataHistoryRecord ,
2021} from '@objectstack/spec/system' ;
2122import { SysMetadataObject } from '../objects/sys-metadata.object.js' ;
23+ import { SysMetadataHistoryObject } from '../objects/sys-metadata-history.object.js' ;
2224import type { IDataDriver } from '@objectstack/spec/contracts' ;
2325import 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 } ` ,
0 commit comments