Skip to content

Commit 5298d35

Browse files
Claudehotlong
andauthored
Implement history methods in MetadataManager
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 86e97bc commit 5298d35

1 file changed

Lines changed: 252 additions & 1 deletion

File tree

packages/metadata/src/metadata-manager.ts

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import type {
1616
MetadataWatchEvent,
1717
MetadataFormat,
1818
PackagePublishResult,
19+
MetadataHistoryQueryOptions,
20+
MetadataHistoryQueryResult,
21+
MetadataDiffResult,
1922
} from '@objectstack/spec/system';
2023
import type {
2124
IMetadataService,
@@ -43,6 +46,7 @@ import type { MetadataSerializer } from './serializers/serializer-interface.js';
4346
import type { IDataDriver } from '@objectstack/spec/contracts';
4447
import type { MetadataLoader } from './loaders/loader-interface.js';
4548
import { 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

Comments
 (0)