Skip to content

Commit 1358200

Browse files
Copilothotlong
andauthored
Fix all PR reviewer feedback: schema, rollback, diff, cleanup, routes, tests, docs
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/b9843a05-b0ee-4a50-bf70-18097fedf0b8 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 474d921 commit 1358200

8 files changed

Lines changed: 248 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

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

2524
- **CLI: Remote API Commands** - Added 12 new CLI commands for interacting with remote ObjectStack servers:
2625
- **Authentication**: `os auth login`, `os auth logout`, `os auth whoami`

docs/METADATA_HISTORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ The diff result includes:
202202

203203
## Performance Considerations
204204

205-
- History records are written asynchronously and failures don't block main operations
205+
- History records are written synchronously as part of each save operation, ensuring consistency between metadata state and the history timeline
206206
- Checksum deduplication prevents storing identical versions
207207
- Indexes optimize common query patterns
208208
- Automatic cleanup prevents unbounded growth

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

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@ export class DatabaseLoader implements MetadataLoader {
113113
name: this.historyTableName,
114114
});
115115
this.historySchemaReady = true;
116-
} catch {
117-
// If syncSchema fails (e.g. table already exists), mark ready and continue
118-
this.historySchemaReady = true;
116+
} catch (error) {
117+
// Log the error; historySchemaReady remains false so the next operation retries.
118+
// If the error is a benign "already exists" the next attempt will also succeed.
119+
console.error('Failed to ensure history schema, will retry on next operation:', error);
119120
}
120121
}
121122

@@ -378,6 +379,112 @@ export class DatabaseLoader implements MetadataLoader {
378379
}
379380
}
380381

382+
/**
383+
* Fetch a single history snapshot by (type, name, version).
384+
* Returns null when the record does not exist.
385+
*/
386+
async getHistoryRecord(
387+
type: string,
388+
name: string,
389+
version: number
390+
): Promise<MetadataHistoryRecord | null> {
391+
if (!this.trackHistory) return null;
392+
393+
await this.ensureHistorySchema();
394+
395+
// Resolve the parent metadata record ID
396+
const metadataRow = await this.driver.findOne(this.tableName, {
397+
object: this.tableName,
398+
where: this.baseFilter(type, name),
399+
});
400+
if (!metadataRow) return null;
401+
402+
const filter: Record<string, unknown> = {
403+
metadata_id: metadataRow.id,
404+
version,
405+
};
406+
if (this.tenantId) {
407+
filter.tenant_id = this.tenantId;
408+
}
409+
410+
const row = await this.driver.findOne(this.historyTableName, {
411+
object: this.historyTableName,
412+
where: filter,
413+
});
414+
if (!row) return null;
415+
416+
return {
417+
id: row.id as string,
418+
metadataId: row.metadata_id as string,
419+
name: row.name as string,
420+
type: row.type as string,
421+
version: row.version as number,
422+
operationType: row.operation_type as MetadataHistoryRecord['operationType'],
423+
metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata as string) : row.metadata,
424+
checksum: row.checksum as string,
425+
previousChecksum: row.previous_checksum as string | undefined,
426+
changeNote: row.change_note as string | undefined,
427+
tenantId: row.tenant_id as string | undefined,
428+
recordedBy: row.recorded_by as string | undefined,
429+
recordedAt: row.recorded_at as string,
430+
};
431+
}
432+
433+
/**
434+
* Perform a rollback: persist `restoredData` as the new current state and record a
435+
* single 'revert' history entry (instead of the usual 'update' entry that `save()`
436+
* would produce). This avoids the duplicate-version problem that arises when
437+
* `register()` → `save()` writes an 'update' entry followed by an additional
438+
* 'revert' entry for the same version number.
439+
*/
440+
async registerRollback(
441+
type: string,
442+
name: string,
443+
restoredData: unknown,
444+
targetVersion: number,
445+
changeNote?: string,
446+
recordedBy?: string
447+
): Promise<void> {
448+
await this.ensureSchema();
449+
450+
const now = new Date().toISOString();
451+
const metadataJson = JSON.stringify(restoredData);
452+
const newChecksum = await calculateChecksum(restoredData);
453+
454+
const existing = await this.driver.findOne(this.tableName, {
455+
object: this.tableName,
456+
where: this.baseFilter(type, name),
457+
});
458+
459+
if (!existing) {
460+
throw new Error(`Metadata ${type}/${name} not found for rollback`);
461+
}
462+
463+
const previousChecksum = existing.checksum as string | undefined;
464+
const newVersion = ((existing.version as number) ?? 0) + 1;
465+
466+
await this.driver.update(this.tableName, existing.id as string, {
467+
metadata: metadataJson,
468+
version: newVersion,
469+
checksum: newChecksum,
470+
updated_at: now,
471+
state: 'active',
472+
});
473+
474+
// Write exactly one 'revert' history entry (not an 'update' entry)
475+
await this.createHistoryRecord(
476+
existing.id as string,
477+
type,
478+
name,
479+
newVersion,
480+
restoredData,
481+
'revert',
482+
previousChecksum,
483+
changeNote ?? `Rolled back to version ${targetVersion}`,
484+
recordedBy
485+
);
486+
}
487+
381488
async save(
382489
type: string,
383490
name: string,
@@ -399,9 +506,19 @@ export class DatabaseLoader implements MetadataLoader {
399506
});
400507

401508
if (existing) {
509+
// Skip update if the content is identical (prevents phantom version bumps)
510+
const previousChecksum = existing.checksum as string | undefined;
511+
if (newChecksum === previousChecksum) {
512+
return {
513+
success: true,
514+
path: `datasource://${this.tableName}/${type}/${name}`,
515+
size: metadataJson.length,
516+
saveTime: Date.now() - startTime,
517+
};
518+
}
519+
402520
// Update existing record
403521
const version = ((existing.version as number) ?? 0) + 1;
404-
const previousChecksum = existing.checksum as string | undefined;
405522

406523
await this.driver.update(this.tableName, existing.id as string, {
407524
metadata: metadataJson,

packages/metadata/src/metadata-history.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, beforeEach } from 'vitest';
4-
import { MetadataManager } from '../metadata-manager.js';
5-
import { DatabaseLoader } from '../loaders/database-loader.js';
4+
import { MetadataManager } from './metadata-manager';
5+
import { DatabaseLoader } from './loaders/database-loader';
66
import { MemoryDriver } from '@objectstack/driver-memory';
77

88
describe('Metadata History', () => {

packages/metadata/src/metadata-manager.ts

Lines changed: 37 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,23 +1272,28 @@ export class MetadataManager implements IMetadataService {
12721272

12731273
// Convert rows to MetadataHistoryRecord format
12741274
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-
}));
1275+
const historyResult = records.map((row: Record<string, unknown>) => {
1276+
const parsedMetadata =
1277+
typeof row.metadata === 'string'
1278+
? JSON.parse(row.metadata as string)
1279+
: (row.metadata as Record<string, unknown> | null | undefined);
1280+
1281+
return {
1282+
id: row.id as string,
1283+
metadataId: row.metadata_id as string,
1284+
name: row.name as string,
1285+
type: row.type as string,
1286+
version: row.version as number,
1287+
operationType: row.operation_type as 'create' | 'update' | 'publish' | 'revert' | 'delete',
1288+
metadata: includeMetadata ? parsedMetadata : null,
1289+
checksum: row.checksum as string,
1290+
previousChecksum: row.previous_checksum as string | undefined,
1291+
changeNote: row.change_note as string | undefined,
1292+
tenantId: row.tenant_id as string | undefined,
1293+
recordedBy: row.recorded_by as string | undefined,
1294+
recordedAt: row.recorded_at as string,
1295+
};
1296+
});
12921297

12931298
return {
12941299
records: historyResult,
@@ -1315,9 +1320,8 @@ export class MetadataManager implements IMetadataService {
13151320
throw new Error('Rollback requires a database loader to be configured');
13161321
}
13171322

1318-
// Get the target version from history
1319-
const history = await this.getHistory(type, name, { limit: 1000, includeMetadata: true });
1320-
const targetVersion = history.records.find(r => r.version === version);
1323+
// Fetch the target version snapshot directly from the history table
1324+
const targetVersion = await dbLoader.getHistoryRecord(type, name, version);
13211325

13221326
if (!targetVersion) {
13231327
throw new Error(`Version ${version} not found in history for ${type}/${name}`);
@@ -1327,41 +1331,17 @@ export class MetadataManager implements IMetadataService {
13271331
throw new Error(`Version ${version} metadata snapshot not available`);
13281332
}
13291333

1330-
// Restore the metadata
1334+
// Restore the metadata using the dedicated rollback path so that a single
1335+
// 'revert' history entry is written (instead of a conflicting 'update' entry)
13311336
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-
}
1337+
await dbLoader.registerRollback(
1338+
type,
1339+
name,
1340+
restoredMetadata,
1341+
version,
1342+
options?.changeNote,
1343+
options?.recordedBy
1344+
);
13651345

13661346
return restoredMetadata;
13671347
}
@@ -1381,10 +1361,9 @@ export class MetadataManager implements IMetadataService {
13811361
throw new Error('Diff requires a database loader to be configured');
13821362
}
13831363

1384-
// Get both versions from history
1385-
const history = await this.getHistory(type, name, { limit: 1000, includeMetadata: true });
1386-
const v1 = history.records.find(r => r.version === version1);
1387-
const v2 = history.records.find(r => r.version === version2);
1364+
// Fetch the two version snapshots directly from the history table
1365+
const v1 = await dbLoader.getHistoryRecord(type, name, version1);
1366+
const v2 = await dbLoader.getHistoryRecord(type, name, version2);
13881367

13891368
if (!v1) {
13901369
throw new Error(`Version ${version1} not found in history for ${type}/${name}`);

packages/metadata/src/routes/history-routes.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,20 @@ export function registerMetadataHistoryRoutes(
4747
try {
4848
const options: any = {};
4949

50-
if (query.limit) options.limit = parseInt(query.limit, 10);
51-
if (query.offset) options.offset = parseInt(query.offset, 10);
50+
if (query.limit !== undefined) {
51+
const limit = parseInt(query.limit, 10);
52+
if (!Number.isFinite(limit) || limit < 1) {
53+
return c.json({ success: false, error: 'limit must be a positive integer' }, 400);
54+
}
55+
options.limit = limit;
56+
}
57+
if (query.offset !== undefined) {
58+
const offset = parseInt(query.offset, 10);
59+
if (!Number.isFinite(offset) || offset < 0) {
60+
return c.json({ success: false, error: 'offset must be a non-negative integer' }, 400);
61+
}
62+
options.offset = offset;
63+
}
5264
if (query.since) options.since = query.since;
5365
if (query.until) options.until = query.until;
5466
if (query.operationType) options.operationType = query.operationType;

0 commit comments

Comments
 (0)