@@ -16,6 +16,9 @@ import type {
1616 MetadataWatchEvent ,
1717 MetadataFormat ,
1818 PackagePublishResult ,
19+ MetadataHistoryQueryOptions ,
20+ MetadataHistoryQueryResult ,
21+ MetadataDiffResult ,
1922} from '@objectstack/spec/system' ;
2023import type {
2124 IMetadataService ,
@@ -43,6 +46,7 @@ import type { MetadataSerializer } from './serializers/serializer-interface.js';
4346import type { IDataDriver } from '@objectstack/spec/contracts' ;
4447import type { MetadataLoader } from './loaders/loader-interface.js' ;
4548import { DatabaseLoader } from './loaders/database-loader.js' ;
49+ import { calculateChecksum , generateSimpleDiff , generateDiffSummary } from './utils/metadata-history-utils.js' ;
4650
4751/**
4852 * Watch callback function (legacy)
@@ -1152,7 +1156,7 @@ export class MetadataManager implements IMetadataService {
11521156 protected notifyWatchers ( type : string , event : MetadataWatchEvent ) {
11531157 const callbacks = this . watchCallbacks . get ( type ) ;
11541158 if ( ! callbacks ) return ;
1155-
1159+
11561160 for ( const callback of callbacks ) {
11571161 try {
11581162 void callback ( event ) ;
@@ -1164,5 +1168,252 @@ export class MetadataManager implements IMetadataService {
11641168 }
11651169 }
11661170 }
1171+
1172+ // ==========================================
1173+ // Version History & Rollback
1174+ // ==========================================
1175+
1176+ /**
1177+ * Get the database loader for history operations.
1178+ * Returns undefined if no database loader is configured.
1179+ */
1180+ private getDatabaseLoader ( ) : DatabaseLoader | undefined {
1181+ const dbLoader = this . loaders . get ( 'database' ) ;
1182+ if ( dbLoader && dbLoader instanceof DatabaseLoader ) {
1183+ return dbLoader ;
1184+ }
1185+ return undefined ;
1186+ }
1187+
1188+ /**
1189+ * Get version history for a metadata item.
1190+ * Returns a timeline of all changes made to the item.
1191+ */
1192+ async getHistory (
1193+ type : string ,
1194+ name : string ,
1195+ options ?: MetadataHistoryQueryOptions
1196+ ) : Promise < MetadataHistoryQueryResult > {
1197+ const dbLoader = this . getDatabaseLoader ( ) ;
1198+ if ( ! dbLoader ) {
1199+ throw new Error ( 'History tracking requires a database loader to be configured' ) ;
1200+ }
1201+
1202+ // Get the metadata record to find its ID
1203+ const driver = ( dbLoader as any ) . driver as IDataDriver ;
1204+ const tableName = ( dbLoader as any ) . tableName as string ;
1205+ const historyTableName = ( dbLoader as any ) . historyTableName as string ;
1206+ const tenantId = ( dbLoader as any ) . tenantId as string | undefined ;
1207+
1208+ // Find the metadata record
1209+ const filter : Record < string , unknown > = { type, name } ;
1210+ if ( tenantId ) {
1211+ filter . tenant_id = tenantId ;
1212+ }
1213+
1214+ const metadataRecord = await driver . findOne ( tableName , {
1215+ object : tableName ,
1216+ where : filter ,
1217+ } ) ;
1218+
1219+ if ( ! metadataRecord ) {
1220+ return {
1221+ records : [ ] ,
1222+ total : 0 ,
1223+ hasMore : false ,
1224+ } ;
1225+ }
1226+
1227+ // Build history query
1228+ const historyFilter : Record < string , unknown > = {
1229+ metadata_id : metadataRecord . id ,
1230+ } ;
1231+
1232+ if ( tenantId ) {
1233+ historyFilter . tenant_id = tenantId ;
1234+ }
1235+
1236+ if ( options ?. operationType ) {
1237+ historyFilter . operation_type = options . operationType ;
1238+ }
1239+
1240+ if ( options ?. since ) {
1241+ historyFilter . recorded_at = { $gte : options . since } ;
1242+ }
1243+
1244+ if ( options ?. until ) {
1245+ if ( historyFilter . recorded_at ) {
1246+ ( historyFilter . recorded_at as Record < string , unknown > ) . $lte = options . until ;
1247+ } else {
1248+ historyFilter . recorded_at = { $lte : options . until } ;
1249+ }
1250+ }
1251+
1252+ // Query history records with pagination
1253+ const limit = options ?. limit ?? 50 ;
1254+ const offset = options ?. offset ?? 0 ;
1255+
1256+ const historyRecords = await driver . find ( historyTableName , {
1257+ object : historyTableName ,
1258+ where : historyFilter ,
1259+ orderBy : { recorded_at : 'desc' } ,
1260+ limit : limit + 1 , // Fetch one extra to determine hasMore
1261+ offset,
1262+ } ) ;
1263+
1264+ const hasMore = historyRecords . length > limit ;
1265+ const records = historyRecords . slice ( 0 , limit ) ;
1266+
1267+ // Get total count
1268+ const total = await driver . count ( historyTableName , {
1269+ object : historyTableName ,
1270+ where : historyFilter ,
1271+ } ) ;
1272+
1273+ // Convert rows to MetadataHistoryRecord format
1274+ const includeMetadata = options ?. includeMetadata !== false ;
1275+ const historyResult = records . map ( ( row : Record < string , unknown > ) => ( {
1276+ id : row . id as string ,
1277+ metadataId : row . metadata_id as string ,
1278+ name : row . name as string ,
1279+ type : row . type as string ,
1280+ version : row . version as number ,
1281+ operationType : row . operation_type as 'create' | 'update' | 'publish' | 'revert' | 'delete' ,
1282+ metadata : includeMetadata
1283+ ? ( typeof row . metadata === 'string' ? JSON . parse ( row . metadata as string ) : row . metadata )
1284+ : undefined ,
1285+ checksum : row . checksum as string ,
1286+ previousChecksum : row . previous_checksum as string | undefined ,
1287+ changeNote : row . change_note as string | undefined ,
1288+ tenantId : row . tenant_id as string | undefined ,
1289+ recordedBy : row . recorded_by as string | undefined ,
1290+ recordedAt : row . recorded_at as string ,
1291+ } ) ) ;
1292+
1293+ return {
1294+ records : historyResult ,
1295+ total,
1296+ hasMore,
1297+ } ;
1298+ }
1299+
1300+ /**
1301+ * Rollback a metadata item to a specific version.
1302+ * Restores the metadata definition from the history snapshot.
1303+ */
1304+ async rollback (
1305+ type : string ,
1306+ name : string ,
1307+ version : number ,
1308+ options ?: {
1309+ changeNote ?: string ;
1310+ recordedBy ?: string ;
1311+ }
1312+ ) : Promise < unknown > {
1313+ const dbLoader = this . getDatabaseLoader ( ) ;
1314+ if ( ! dbLoader ) {
1315+ throw new Error ( 'Rollback requires a database loader to be configured' ) ;
1316+ }
1317+
1318+ // Get the target version from history
1319+ const history = await this . getHistory ( type , name , { limit : 1000 } ) ;
1320+ const targetVersion = history . records . find ( r => r . version === version ) ;
1321+
1322+ if ( ! targetVersion ) {
1323+ throw new Error ( `Version ${ version } not found in history for ${ type } /${ name } ` ) ;
1324+ }
1325+
1326+ if ( ! targetVersion . metadata ) {
1327+ throw new Error ( `Version ${ version } metadata snapshot not available` ) ;
1328+ }
1329+
1330+ // Restore the metadata
1331+ const restoredMetadata = targetVersion . metadata ;
1332+
1333+ // Register the restored version
1334+ await this . register ( type , name , restoredMetadata ) ;
1335+
1336+ // Create a history record for the rollback operation
1337+ const driver = ( dbLoader as any ) . driver as IDataDriver ;
1338+ const tableName = ( dbLoader as any ) . tableName as string ;
1339+ const tenantId = ( dbLoader as any ) . tenantId as string | undefined ;
1340+
1341+ const filter : Record < string , unknown > = { type, name } ;
1342+ if ( tenantId ) {
1343+ filter . tenant_id = tenantId ;
1344+ }
1345+
1346+ const metadataRecord = await driver . findOne ( tableName , {
1347+ object : tableName ,
1348+ where : filter ,
1349+ } ) ;
1350+
1351+ if ( metadataRecord ) {
1352+ const currentVersion = ( metadataRecord . version as number ) ?? 1 ;
1353+ await ( dbLoader as any ) . createHistoryRecord (
1354+ metadataRecord . id as string ,
1355+ type ,
1356+ name ,
1357+ currentVersion ,
1358+ restoredMetadata ,
1359+ 'revert' ,
1360+ metadataRecord . checksum as string | undefined ,
1361+ options ?. changeNote ?? `Rolled back to version ${ version } ` ,
1362+ options ?. recordedBy
1363+ ) ;
1364+ }
1365+
1366+ return restoredMetadata ;
1367+ }
1368+
1369+ /**
1370+ * Compare two versions of a metadata item.
1371+ * Returns a diff showing what changed between versions.
1372+ */
1373+ async diff (
1374+ type : string ,
1375+ name : string ,
1376+ version1 : number ,
1377+ version2 : number
1378+ ) : Promise < MetadataDiffResult > {
1379+ const dbLoader = this . getDatabaseLoader ( ) ;
1380+ if ( ! dbLoader ) {
1381+ throw new Error ( 'Diff requires a database loader to be configured' ) ;
1382+ }
1383+
1384+ // Get both versions from history
1385+ const history = await this . getHistory ( type , name , { limit : 1000 } ) ;
1386+ const v1 = history . records . find ( r => r . version === version1 ) ;
1387+ const v2 = history . records . find ( r => r . version === version2 ) ;
1388+
1389+ if ( ! v1 ) {
1390+ throw new Error ( `Version ${ version1 } not found in history for ${ type } /${ name } ` ) ;
1391+ }
1392+
1393+ if ( ! v2 ) {
1394+ throw new Error ( `Version ${ version2 } not found in history for ${ type } /${ name } ` ) ;
1395+ }
1396+
1397+ if ( ! v1 . metadata || ! v2 . metadata ) {
1398+ throw new Error ( 'Version metadata snapshots not available' ) ;
1399+ }
1400+
1401+ // Generate diff
1402+ const patch = generateSimpleDiff ( v1 . metadata , v2 . metadata ) ;
1403+ const identical = patch . length === 0 ;
1404+ const summary = generateDiffSummary ( patch ) ;
1405+
1406+ return {
1407+ type,
1408+ name,
1409+ version1,
1410+ version2,
1411+ checksum1 : v1 . checksum ,
1412+ checksum2 : v2 . checksum ,
1413+ identical,
1414+ patch,
1415+ summary,
1416+ } ;
1417+ }
11671418}
11681419
0 commit comments