@objectstack/metadata 4.0.1 → 4.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.cjs +1046 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2618 -36
- package/dist/index.d.ts +2618 -36
- package/dist/index.js +1039 -18
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +1046 -19
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/node.js +1039 -18
- package/dist/node.js.map +1 -1
- package/package.json +6 -6
package/dist/index.cjs
CHANGED
|
@@ -31,15 +31,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
DatabaseLoader: () => DatabaseLoader,
|
|
34
|
+
HistoryCleanupManager: () => HistoryCleanupManager,
|
|
34
35
|
JSONSerializer: () => JSONSerializer,
|
|
35
36
|
MemoryLoader: () => MemoryLoader,
|
|
36
37
|
MetadataManager: () => MetadataManager,
|
|
37
38
|
MetadataPlugin: () => MetadataPlugin,
|
|
38
39
|
Migration: () => migration_exports,
|
|
39
40
|
RemoteLoader: () => RemoteLoader,
|
|
41
|
+
SysMetadataHistoryObject: () => SysMetadataHistoryObject,
|
|
40
42
|
SysMetadataObject: () => SysMetadataObject,
|
|
41
43
|
TypeScriptSerializer: () => TypeScriptSerializer,
|
|
42
|
-
YAMLSerializer: () => YAMLSerializer
|
|
44
|
+
YAMLSerializer: () => YAMLSerializer,
|
|
45
|
+
calculateChecksum: () => calculateChecksum,
|
|
46
|
+
generateDiffSummary: () => generateDiffSummary,
|
|
47
|
+
generateSimpleDiff: () => generateSimpleDiff,
|
|
48
|
+
registerMetadataHistoryRoutes: () => registerMetadataHistoryRoutes
|
|
43
49
|
});
|
|
44
50
|
module.exports = __toCommonJS(index_exports);
|
|
45
51
|
|
|
@@ -365,6 +371,224 @@ var SysMetadataObject = import_data.ObjectSchema.create({
|
|
|
365
371
|
}
|
|
366
372
|
});
|
|
367
373
|
|
|
374
|
+
// src/objects/sys-metadata-history.object.ts
|
|
375
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
376
|
+
var SysMetadataHistoryObject = import_data2.ObjectSchema.create({
|
|
377
|
+
namespace: "sys",
|
|
378
|
+
name: "metadata_history",
|
|
379
|
+
label: "Metadata History",
|
|
380
|
+
pluralLabel: "Metadata History",
|
|
381
|
+
icon: "history",
|
|
382
|
+
isSystem: true,
|
|
383
|
+
description: "Version history and audit trail for metadata changes",
|
|
384
|
+
fields: {
|
|
385
|
+
/** Primary Key (UUID) */
|
|
386
|
+
id: import_data2.Field.text({
|
|
387
|
+
label: "ID",
|
|
388
|
+
required: true,
|
|
389
|
+
readonly: true
|
|
390
|
+
}),
|
|
391
|
+
/** Foreign key to sys_metadata.id */
|
|
392
|
+
metadata_id: import_data2.Field.text({
|
|
393
|
+
label: "Metadata ID",
|
|
394
|
+
required: true,
|
|
395
|
+
readonly: true,
|
|
396
|
+
maxLength: 255
|
|
397
|
+
}),
|
|
398
|
+
/** Machine name (denormalized for easier querying) */
|
|
399
|
+
name: import_data2.Field.text({
|
|
400
|
+
label: "Name",
|
|
401
|
+
required: true,
|
|
402
|
+
searchable: true,
|
|
403
|
+
readonly: true,
|
|
404
|
+
maxLength: 255
|
|
405
|
+
}),
|
|
406
|
+
/** Metadata type (denormalized for easier querying) */
|
|
407
|
+
type: import_data2.Field.text({
|
|
408
|
+
label: "Metadata Type",
|
|
409
|
+
required: true,
|
|
410
|
+
searchable: true,
|
|
411
|
+
readonly: true,
|
|
412
|
+
maxLength: 100
|
|
413
|
+
}),
|
|
414
|
+
/** Version number at this snapshot */
|
|
415
|
+
version: import_data2.Field.number({
|
|
416
|
+
label: "Version",
|
|
417
|
+
required: true,
|
|
418
|
+
readonly: true
|
|
419
|
+
}),
|
|
420
|
+
/** Type of operation that created this history entry */
|
|
421
|
+
operation_type: import_data2.Field.select(["create", "update", "publish", "revert", "delete"], {
|
|
422
|
+
label: "Operation Type",
|
|
423
|
+
required: true,
|
|
424
|
+
readonly: true
|
|
425
|
+
}),
|
|
426
|
+
/** Historical metadata snapshot (JSON payload) */
|
|
427
|
+
metadata: import_data2.Field.textarea({
|
|
428
|
+
label: "Metadata",
|
|
429
|
+
required: true,
|
|
430
|
+
readonly: true,
|
|
431
|
+
description: "JSON-serialized metadata snapshot at this version"
|
|
432
|
+
}),
|
|
433
|
+
/** SHA-256 checksum of metadata content */
|
|
434
|
+
checksum: import_data2.Field.text({
|
|
435
|
+
label: "Checksum",
|
|
436
|
+
required: true,
|
|
437
|
+
readonly: true,
|
|
438
|
+
maxLength: 64
|
|
439
|
+
}),
|
|
440
|
+
/** Checksum of the previous version */
|
|
441
|
+
previous_checksum: import_data2.Field.text({
|
|
442
|
+
label: "Previous Checksum",
|
|
443
|
+
required: false,
|
|
444
|
+
readonly: true,
|
|
445
|
+
maxLength: 64
|
|
446
|
+
}),
|
|
447
|
+
/** Human-readable description of changes */
|
|
448
|
+
change_note: import_data2.Field.textarea({
|
|
449
|
+
label: "Change Note",
|
|
450
|
+
required: false,
|
|
451
|
+
readonly: true,
|
|
452
|
+
description: "Description of what changed in this version"
|
|
453
|
+
}),
|
|
454
|
+
/** Tenant ID for multi-tenant isolation */
|
|
455
|
+
tenant_id: import_data2.Field.text({
|
|
456
|
+
label: "Tenant ID",
|
|
457
|
+
required: false,
|
|
458
|
+
readonly: true,
|
|
459
|
+
maxLength: 255
|
|
460
|
+
}),
|
|
461
|
+
/** User who made this change */
|
|
462
|
+
recorded_by: import_data2.Field.text({
|
|
463
|
+
label: "Recorded By",
|
|
464
|
+
required: false,
|
|
465
|
+
readonly: true,
|
|
466
|
+
maxLength: 255
|
|
467
|
+
}),
|
|
468
|
+
/** When was this version recorded */
|
|
469
|
+
recorded_at: import_data2.Field.datetime({
|
|
470
|
+
label: "Recorded At",
|
|
471
|
+
required: true,
|
|
472
|
+
readonly: true
|
|
473
|
+
})
|
|
474
|
+
},
|
|
475
|
+
indexes: [
|
|
476
|
+
{ fields: ["metadata_id", "version"], unique: true },
|
|
477
|
+
{ fields: ["metadata_id", "recorded_at"] },
|
|
478
|
+
{ fields: ["type", "name"] },
|
|
479
|
+
{ fields: ["recorded_at"] },
|
|
480
|
+
{ fields: ["operation_type"] },
|
|
481
|
+
{ fields: ["tenant_id"] }
|
|
482
|
+
],
|
|
483
|
+
enable: {
|
|
484
|
+
trackHistory: false,
|
|
485
|
+
// Don't track history of history records
|
|
486
|
+
searchable: false,
|
|
487
|
+
apiEnabled: true,
|
|
488
|
+
apiMethods: ["get", "list"],
|
|
489
|
+
// Read-only via API
|
|
490
|
+
trash: false
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// src/utils/metadata-history-utils.ts
|
|
495
|
+
async function calculateChecksum(metadata) {
|
|
496
|
+
const normalized = normalizeJSON(metadata);
|
|
497
|
+
const jsonString = JSON.stringify(normalized);
|
|
498
|
+
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) {
|
|
499
|
+
const encoder = new TextEncoder();
|
|
500
|
+
const data = encoder.encode(jsonString);
|
|
501
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
502
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
503
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
504
|
+
}
|
|
505
|
+
return simpleHash(jsonString);
|
|
506
|
+
}
|
|
507
|
+
function normalizeJSON(value) {
|
|
508
|
+
if (value === null || value === void 0) {
|
|
509
|
+
return value;
|
|
510
|
+
}
|
|
511
|
+
if (Array.isArray(value)) {
|
|
512
|
+
return value.map(normalizeJSON);
|
|
513
|
+
}
|
|
514
|
+
if (typeof value === "object") {
|
|
515
|
+
const sorted = {};
|
|
516
|
+
const keys = Object.keys(value).sort();
|
|
517
|
+
for (const key of keys) {
|
|
518
|
+
sorted[key] = normalizeJSON(value[key]);
|
|
519
|
+
}
|
|
520
|
+
return sorted;
|
|
521
|
+
}
|
|
522
|
+
return value;
|
|
523
|
+
}
|
|
524
|
+
function simpleHash(str) {
|
|
525
|
+
let hash = 5381;
|
|
526
|
+
for (let i = 0; i < str.length; i++) {
|
|
527
|
+
hash = (hash << 5) + hash + str.charCodeAt(i);
|
|
528
|
+
hash = hash & hash;
|
|
529
|
+
}
|
|
530
|
+
const hexHash = Math.abs(hash).toString(16);
|
|
531
|
+
return hexHash.padStart(64, "0");
|
|
532
|
+
}
|
|
533
|
+
function generateSimpleDiff(oldObj, newObj, path3 = "") {
|
|
534
|
+
const changes = [];
|
|
535
|
+
if (typeof oldObj !== "object" || oldObj === null || typeof newObj !== "object" || newObj === null) {
|
|
536
|
+
if (oldObj !== newObj) {
|
|
537
|
+
changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
|
|
538
|
+
}
|
|
539
|
+
return changes;
|
|
540
|
+
}
|
|
541
|
+
if (Array.isArray(oldObj) || Array.isArray(newObj)) {
|
|
542
|
+
if (!Array.isArray(oldObj) || !Array.isArray(newObj) || oldObj.length !== newObj.length) {
|
|
543
|
+
changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
|
|
544
|
+
} else {
|
|
545
|
+
for (let i = 0; i < oldObj.length; i++) {
|
|
546
|
+
const subPath = `${path3}/${i}`;
|
|
547
|
+
changes.push(...generateSimpleDiff(oldObj[i], newObj[i], subPath));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return changes;
|
|
551
|
+
}
|
|
552
|
+
const oldKeys = new Set(Object.keys(oldObj));
|
|
553
|
+
const newKeys = new Set(Object.keys(newObj));
|
|
554
|
+
for (const key of newKeys) {
|
|
555
|
+
if (!oldKeys.has(key)) {
|
|
556
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
557
|
+
changes.push({ op: "add", path: subPath, value: newObj[key] });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const key of oldKeys) {
|
|
561
|
+
if (!newKeys.has(key)) {
|
|
562
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
563
|
+
changes.push({ op: "remove", path: subPath, oldValue: oldObj[key] });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const key of oldKeys) {
|
|
567
|
+
if (newKeys.has(key)) {
|
|
568
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
569
|
+
changes.push(...generateSimpleDiff(
|
|
570
|
+
oldObj[key],
|
|
571
|
+
newObj[key],
|
|
572
|
+
subPath
|
|
573
|
+
));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return changes;
|
|
577
|
+
}
|
|
578
|
+
function generateDiffSummary(diff) {
|
|
579
|
+
if (diff.length === 0) {
|
|
580
|
+
return "No changes";
|
|
581
|
+
}
|
|
582
|
+
const summary = [];
|
|
583
|
+
const addCount = diff.filter((d) => d.op === "add").length;
|
|
584
|
+
const removeCount = diff.filter((d) => d.op === "remove").length;
|
|
585
|
+
const replaceCount = diff.filter((d) => d.op === "replace").length;
|
|
586
|
+
if (addCount > 0) summary.push(`${addCount} field${addCount > 1 ? "s" : ""} added`);
|
|
587
|
+
if (removeCount > 0) summary.push(`${removeCount} field${removeCount > 1 ? "s" : ""} removed`);
|
|
588
|
+
if (replaceCount > 0) summary.push(`${replaceCount} field${replaceCount > 1 ? "s" : ""} modified`);
|
|
589
|
+
return summary.join(", ");
|
|
590
|
+
}
|
|
591
|
+
|
|
368
592
|
// src/loaders/database-loader.ts
|
|
369
593
|
var DatabaseLoader = class {
|
|
370
594
|
constructor(options) {
|
|
@@ -379,9 +603,12 @@ var DatabaseLoader = class {
|
|
|
379
603
|
}
|
|
380
604
|
};
|
|
381
605
|
this.schemaReady = false;
|
|
606
|
+
this.historySchemaReady = false;
|
|
382
607
|
this.driver = options.driver;
|
|
383
608
|
this.tableName = options.tableName ?? "sys_metadata";
|
|
609
|
+
this.historyTableName = options.historyTableName ?? "sys_metadata_history";
|
|
384
610
|
this.tenantId = options.tenantId;
|
|
611
|
+
this.trackHistory = options.trackHistory !== false;
|
|
385
612
|
}
|
|
386
613
|
/**
|
|
387
614
|
* Ensure the metadata table exists.
|
|
@@ -400,6 +627,22 @@ var DatabaseLoader = class {
|
|
|
400
627
|
this.schemaReady = true;
|
|
401
628
|
}
|
|
402
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Ensure the history table exists.
|
|
632
|
+
* Uses IDataDriver.syncSchema with the SysMetadataHistoryObject definition.
|
|
633
|
+
*/
|
|
634
|
+
async ensureHistorySchema() {
|
|
635
|
+
if (!this.trackHistory || this.historySchemaReady) return;
|
|
636
|
+
try {
|
|
637
|
+
await this.driver.syncSchema(this.historyTableName, {
|
|
638
|
+
...SysMetadataHistoryObject,
|
|
639
|
+
name: this.historyTableName
|
|
640
|
+
});
|
|
641
|
+
this.historySchemaReady = true;
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error("Failed to ensure history schema, will retry on next operation:", error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
403
646
|
/**
|
|
404
647
|
* Build base filter conditions for queries.
|
|
405
648
|
* Always includes tenantId when configured.
|
|
@@ -414,6 +657,64 @@ var DatabaseLoader = class {
|
|
|
414
657
|
}
|
|
415
658
|
return filter;
|
|
416
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Create a history record for a metadata change.
|
|
662
|
+
*
|
|
663
|
+
* @param metadataId - The metadata record ID
|
|
664
|
+
* @param type - Metadata type
|
|
665
|
+
* @param name - Metadata name
|
|
666
|
+
* @param version - Version number
|
|
667
|
+
* @param metadata - The metadata payload
|
|
668
|
+
* @param operationType - Type of operation
|
|
669
|
+
* @param previousChecksum - Checksum of previous version (if any)
|
|
670
|
+
* @param changeNote - Optional change description
|
|
671
|
+
* @param recordedBy - Optional user who made the change
|
|
672
|
+
*/
|
|
673
|
+
async createHistoryRecord(metadataId, type, name, version, metadata, operationType, previousChecksum, changeNote, recordedBy) {
|
|
674
|
+
if (!this.trackHistory) return;
|
|
675
|
+
await this.ensureHistorySchema();
|
|
676
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
677
|
+
const checksum = await calculateChecksum(metadata);
|
|
678
|
+
if (previousChecksum && checksum === previousChecksum && operationType === "update") {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const historyId = generateId();
|
|
682
|
+
const metadataJson = JSON.stringify(metadata);
|
|
683
|
+
const historyRecord = {
|
|
684
|
+
id: historyId,
|
|
685
|
+
metadataId,
|
|
686
|
+
name,
|
|
687
|
+
type,
|
|
688
|
+
version,
|
|
689
|
+
operationType,
|
|
690
|
+
metadata: metadataJson,
|
|
691
|
+
checksum,
|
|
692
|
+
previousChecksum,
|
|
693
|
+
changeNote,
|
|
694
|
+
recordedBy,
|
|
695
|
+
recordedAt: now,
|
|
696
|
+
...this.tenantId ? { tenantId: this.tenantId } : {}
|
|
697
|
+
};
|
|
698
|
+
try {
|
|
699
|
+
await this.driver.create(this.historyTableName, {
|
|
700
|
+
id: historyRecord.id,
|
|
701
|
+
metadata_id: historyRecord.metadataId,
|
|
702
|
+
name: historyRecord.name,
|
|
703
|
+
type: historyRecord.type,
|
|
704
|
+
version: historyRecord.version,
|
|
705
|
+
operation_type: historyRecord.operationType,
|
|
706
|
+
metadata: historyRecord.metadata,
|
|
707
|
+
checksum: historyRecord.checksum,
|
|
708
|
+
previous_checksum: historyRecord.previousChecksum,
|
|
709
|
+
change_note: historyRecord.changeNote,
|
|
710
|
+
recorded_by: historyRecord.recordedBy,
|
|
711
|
+
recorded_at: historyRecord.recordedAt,
|
|
712
|
+
...this.tenantId ? { tenant_id: this.tenantId } : {}
|
|
713
|
+
});
|
|
714
|
+
} catch (error) {
|
|
715
|
+
console.error(`Failed to create history record for ${type}/${name}:`, error);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
417
718
|
/**
|
|
418
719
|
* Convert a database row to a metadata payload.
|
|
419
720
|
* Parses the JSON `metadata` column back into an object.
|
|
@@ -541,24 +842,124 @@ var DatabaseLoader = class {
|
|
|
541
842
|
return [];
|
|
542
843
|
}
|
|
543
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Fetch a single history snapshot by (type, name, version).
|
|
847
|
+
* Returns null when the record does not exist.
|
|
848
|
+
*/
|
|
849
|
+
async getHistoryRecord(type, name, version) {
|
|
850
|
+
if (!this.trackHistory) return null;
|
|
851
|
+
await this.ensureHistorySchema();
|
|
852
|
+
const metadataRow = await this.driver.findOne(this.tableName, {
|
|
853
|
+
object: this.tableName,
|
|
854
|
+
where: this.baseFilter(type, name)
|
|
855
|
+
});
|
|
856
|
+
if (!metadataRow) return null;
|
|
857
|
+
const filter = {
|
|
858
|
+
metadata_id: metadataRow.id,
|
|
859
|
+
version
|
|
860
|
+
};
|
|
861
|
+
if (this.tenantId) {
|
|
862
|
+
filter.tenant_id = this.tenantId;
|
|
863
|
+
}
|
|
864
|
+
const row = await this.driver.findOne(this.historyTableName, {
|
|
865
|
+
object: this.historyTableName,
|
|
866
|
+
where: filter
|
|
867
|
+
});
|
|
868
|
+
if (!row) return null;
|
|
869
|
+
return {
|
|
870
|
+
id: row.id,
|
|
871
|
+
metadataId: row.metadata_id,
|
|
872
|
+
name: row.name,
|
|
873
|
+
type: row.type,
|
|
874
|
+
version: row.version,
|
|
875
|
+
operationType: row.operation_type,
|
|
876
|
+
metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
|
|
877
|
+
checksum: row.checksum,
|
|
878
|
+
previousChecksum: row.previous_checksum,
|
|
879
|
+
changeNote: row.change_note,
|
|
880
|
+
tenantId: row.tenant_id,
|
|
881
|
+
recordedBy: row.recorded_by,
|
|
882
|
+
recordedAt: row.recorded_at
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Perform a rollback: persist `restoredData` as the new current state and record a
|
|
887
|
+
* single 'revert' history entry (instead of the usual 'update' entry that `save()`
|
|
888
|
+
* would produce). This avoids the duplicate-version problem that arises when
|
|
889
|
+
* `register()` → `save()` writes an 'update' entry followed by an additional
|
|
890
|
+
* 'revert' entry for the same version number.
|
|
891
|
+
*/
|
|
892
|
+
async registerRollback(type, name, restoredData, targetVersion, changeNote, recordedBy) {
|
|
893
|
+
await this.ensureSchema();
|
|
894
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
895
|
+
const metadataJson = JSON.stringify(restoredData);
|
|
896
|
+
const newChecksum = await calculateChecksum(restoredData);
|
|
897
|
+
const existing = await this.driver.findOne(this.tableName, {
|
|
898
|
+
object: this.tableName,
|
|
899
|
+
where: this.baseFilter(type, name)
|
|
900
|
+
});
|
|
901
|
+
if (!existing) {
|
|
902
|
+
throw new Error(`Metadata ${type}/${name} not found for rollback`);
|
|
903
|
+
}
|
|
904
|
+
const previousChecksum = existing.checksum;
|
|
905
|
+
const newVersion = (existing.version ?? 0) + 1;
|
|
906
|
+
await this.driver.update(this.tableName, existing.id, {
|
|
907
|
+
metadata: metadataJson,
|
|
908
|
+
version: newVersion,
|
|
909
|
+
checksum: newChecksum,
|
|
910
|
+
updated_at: now,
|
|
911
|
+
state: "active"
|
|
912
|
+
});
|
|
913
|
+
await this.createHistoryRecord(
|
|
914
|
+
existing.id,
|
|
915
|
+
type,
|
|
916
|
+
name,
|
|
917
|
+
newVersion,
|
|
918
|
+
restoredData,
|
|
919
|
+
"revert",
|
|
920
|
+
previousChecksum,
|
|
921
|
+
changeNote ?? `Rolled back to version ${targetVersion}`,
|
|
922
|
+
recordedBy
|
|
923
|
+
);
|
|
924
|
+
}
|
|
544
925
|
async save(type, name, data, _options) {
|
|
545
926
|
const startTime = Date.now();
|
|
546
927
|
await this.ensureSchema();
|
|
547
928
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
548
929
|
const metadataJson = JSON.stringify(data);
|
|
930
|
+
const newChecksum = await calculateChecksum(data);
|
|
549
931
|
try {
|
|
550
932
|
const existing = await this.driver.findOne(this.tableName, {
|
|
551
933
|
object: this.tableName,
|
|
552
934
|
where: this.baseFilter(type, name)
|
|
553
935
|
});
|
|
554
936
|
if (existing) {
|
|
937
|
+
const previousChecksum = existing.checksum;
|
|
938
|
+
if (newChecksum === previousChecksum) {
|
|
939
|
+
return {
|
|
940
|
+
success: true,
|
|
941
|
+
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
942
|
+
size: metadataJson.length,
|
|
943
|
+
saveTime: Date.now() - startTime
|
|
944
|
+
};
|
|
945
|
+
}
|
|
555
946
|
const version = (existing.version ?? 0) + 1;
|
|
556
947
|
await this.driver.update(this.tableName, existing.id, {
|
|
557
948
|
metadata: metadataJson,
|
|
558
949
|
version,
|
|
950
|
+
checksum: newChecksum,
|
|
559
951
|
updated_at: now,
|
|
560
952
|
state: "active"
|
|
561
953
|
});
|
|
954
|
+
await this.createHistoryRecord(
|
|
955
|
+
existing.id,
|
|
956
|
+
type,
|
|
957
|
+
name,
|
|
958
|
+
version,
|
|
959
|
+
data,
|
|
960
|
+
"update",
|
|
961
|
+
previousChecksum
|
|
962
|
+
);
|
|
562
963
|
return {
|
|
563
964
|
success: true,
|
|
564
965
|
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
@@ -574,6 +975,7 @@ var DatabaseLoader = class {
|
|
|
574
975
|
namespace: "default",
|
|
575
976
|
scope: data?.scope ?? "platform",
|
|
576
977
|
metadata: metadataJson,
|
|
978
|
+
checksum: newChecksum,
|
|
577
979
|
strategy: "merge",
|
|
578
980
|
state: "active",
|
|
579
981
|
version: 1,
|
|
@@ -582,6 +984,14 @@ var DatabaseLoader = class {
|
|
|
582
984
|
created_at: now,
|
|
583
985
|
updated_at: now
|
|
584
986
|
});
|
|
987
|
+
await this.createHistoryRecord(
|
|
988
|
+
id,
|
|
989
|
+
type,
|
|
990
|
+
name,
|
|
991
|
+
1,
|
|
992
|
+
data,
|
|
993
|
+
"create"
|
|
994
|
+
);
|
|
585
995
|
return {
|
|
586
996
|
success: true,
|
|
587
997
|
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
@@ -595,6 +1005,20 @@ var DatabaseLoader = class {
|
|
|
595
1005
|
);
|
|
596
1006
|
}
|
|
597
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Delete a metadata item from the database
|
|
1010
|
+
*/
|
|
1011
|
+
async delete(type, name) {
|
|
1012
|
+
await this.ensureSchema();
|
|
1013
|
+
const existing = await this.driver.findOne(this.tableName, {
|
|
1014
|
+
object: this.tableName,
|
|
1015
|
+
where: this.baseFilter(type, name)
|
|
1016
|
+
});
|
|
1017
|
+
if (!existing) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
await this.driver.delete(this.tableName, existing.id);
|
|
1021
|
+
}
|
|
598
1022
|
};
|
|
599
1023
|
function generateId() {
|
|
600
1024
|
if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
|
|
@@ -660,6 +1084,16 @@ var MetadataManager = class {
|
|
|
660
1084
|
this.registerLoader(dbLoader);
|
|
661
1085
|
this.logger.info("DatabaseLoader configured", { datasource: this.config.datasource, tableName });
|
|
662
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Set the realtime service for publishing metadata change events.
|
|
1089
|
+
* Should be called after kernel resolves the realtime service.
|
|
1090
|
+
*
|
|
1091
|
+
* @param service - An IRealtimeService instance for event publishing
|
|
1092
|
+
*/
|
|
1093
|
+
setRealtimeService(service) {
|
|
1094
|
+
this.realtimeService = service;
|
|
1095
|
+
this.logger.info("RealtimeService configured for metadata events");
|
|
1096
|
+
}
|
|
663
1097
|
/**
|
|
664
1098
|
* Register a new metadata loader (data source)
|
|
665
1099
|
*/
|
|
@@ -672,12 +1106,39 @@ var MetadataManager = class {
|
|
|
672
1106
|
// ==========================================
|
|
673
1107
|
/**
|
|
674
1108
|
* Register/save a metadata item by type
|
|
1109
|
+
* Stores in-memory registry and persists to database-backed loaders only.
|
|
1110
|
+
* FilesystemLoader (protocol 'file:') is read-only for static metadata and
|
|
1111
|
+
* should not be written to during runtime registration.
|
|
675
1112
|
*/
|
|
676
1113
|
async register(type, name, data) {
|
|
677
1114
|
if (!this.registry.has(type)) {
|
|
678
1115
|
this.registry.set(type, /* @__PURE__ */ new Map());
|
|
679
1116
|
}
|
|
680
1117
|
this.registry.get(type).set(name, data);
|
|
1118
|
+
for (const loader of this.loaders.values()) {
|
|
1119
|
+
if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
|
|
1120
|
+
await loader.save(type, name, data);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (this.realtimeService) {
|
|
1124
|
+
const event = {
|
|
1125
|
+
type: `metadata.${type}.created`,
|
|
1126
|
+
object: type,
|
|
1127
|
+
payload: {
|
|
1128
|
+
metadataType: type,
|
|
1129
|
+
name,
|
|
1130
|
+
definition: data,
|
|
1131
|
+
packageId: data?.packageId
|
|
1132
|
+
},
|
|
1133
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1134
|
+
};
|
|
1135
|
+
try {
|
|
1136
|
+
await this.realtimeService.publish(event);
|
|
1137
|
+
this.logger.debug(`Published metadata.${type}.created event`, { name });
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
681
1142
|
}
|
|
682
1143
|
/**
|
|
683
1144
|
* Get a metadata item by type and name.
|
|
@@ -718,7 +1179,8 @@ var MetadataManager = class {
|
|
|
718
1179
|
return Array.from(items.values());
|
|
719
1180
|
}
|
|
720
1181
|
/**
|
|
721
|
-
* Unregister/remove a metadata item by type and name
|
|
1182
|
+
* Unregister/remove a metadata item by type and name.
|
|
1183
|
+
* Deletes from database-backed loaders only (same rationale as register()).
|
|
722
1184
|
*/
|
|
723
1185
|
async unregister(type, name) {
|
|
724
1186
|
const typeStore = this.registry.get(type);
|
|
@@ -728,6 +1190,33 @@ var MetadataManager = class {
|
|
|
728
1190
|
this.registry.delete(type);
|
|
729
1191
|
}
|
|
730
1192
|
}
|
|
1193
|
+
for (const loader of this.loaders.values()) {
|
|
1194
|
+
if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
|
|
1195
|
+
if (typeof loader.delete === "function") {
|
|
1196
|
+
try {
|
|
1197
|
+
await loader.delete(type, name);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (this.realtimeService) {
|
|
1204
|
+
const event = {
|
|
1205
|
+
type: `metadata.${type}.deleted`,
|
|
1206
|
+
object: type,
|
|
1207
|
+
payload: {
|
|
1208
|
+
metadataType: type,
|
|
1209
|
+
name
|
|
1210
|
+
},
|
|
1211
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1212
|
+
};
|
|
1213
|
+
try {
|
|
1214
|
+
await this.realtimeService.publish(event);
|
|
1215
|
+
this.logger.debug(`Published metadata.${type}.deleted event`, { name });
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
731
1220
|
}
|
|
732
1221
|
/**
|
|
733
1222
|
* Check if a metadata item exists
|
|
@@ -810,20 +1299,17 @@ var MetadataManager = class {
|
|
|
810
1299
|
* Unregister all metadata items from a specific package
|
|
811
1300
|
*/
|
|
812
1301
|
async unregisterPackage(packageName) {
|
|
1302
|
+
const itemsToDelete = [];
|
|
813
1303
|
for (const [type, typeStore] of this.registry) {
|
|
814
|
-
const toDelete = [];
|
|
815
1304
|
for (const [name, data] of typeStore) {
|
|
816
1305
|
const meta = data;
|
|
817
1306
|
if (meta?.packageId === packageName || meta?.package === packageName) {
|
|
818
|
-
|
|
1307
|
+
itemsToDelete.push({ type, name });
|
|
819
1308
|
}
|
|
820
1309
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (typeStore.size === 0) {
|
|
825
|
-
this.registry.delete(type);
|
|
826
|
-
}
|
|
1310
|
+
}
|
|
1311
|
+
for (const { type, name } of itemsToDelete) {
|
|
1312
|
+
await this.unregister(type, name);
|
|
827
1313
|
}
|
|
828
1314
|
}
|
|
829
1315
|
/**
|
|
@@ -1488,6 +1974,174 @@ var MetadataManager = class {
|
|
|
1488
1974
|
}
|
|
1489
1975
|
}
|
|
1490
1976
|
}
|
|
1977
|
+
// ==========================================
|
|
1978
|
+
// Version History & Rollback
|
|
1979
|
+
// ==========================================
|
|
1980
|
+
/**
|
|
1981
|
+
* Get the database loader for history operations.
|
|
1982
|
+
* Returns undefined if no database loader is configured.
|
|
1983
|
+
*/
|
|
1984
|
+
getDatabaseLoader() {
|
|
1985
|
+
const dbLoader = this.loaders.get("database");
|
|
1986
|
+
if (dbLoader && dbLoader instanceof DatabaseLoader) {
|
|
1987
|
+
return dbLoader;
|
|
1988
|
+
}
|
|
1989
|
+
return void 0;
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Get version history for a metadata item.
|
|
1993
|
+
* Returns a timeline of all changes made to the item.
|
|
1994
|
+
*/
|
|
1995
|
+
async getHistory(type, name, options) {
|
|
1996
|
+
const dbLoader = this.getDatabaseLoader();
|
|
1997
|
+
if (!dbLoader) {
|
|
1998
|
+
throw new Error("History tracking requires a database loader to be configured");
|
|
1999
|
+
}
|
|
2000
|
+
const driver = dbLoader.driver;
|
|
2001
|
+
const tableName = dbLoader.tableName;
|
|
2002
|
+
const historyTableName = dbLoader.historyTableName;
|
|
2003
|
+
const tenantId = dbLoader.tenantId;
|
|
2004
|
+
const filter = { type, name };
|
|
2005
|
+
if (tenantId) {
|
|
2006
|
+
filter.tenant_id = tenantId;
|
|
2007
|
+
}
|
|
2008
|
+
const metadataRecord = await driver.findOne(tableName, {
|
|
2009
|
+
object: tableName,
|
|
2010
|
+
where: filter
|
|
2011
|
+
});
|
|
2012
|
+
if (!metadataRecord) {
|
|
2013
|
+
return {
|
|
2014
|
+
records: [],
|
|
2015
|
+
total: 0,
|
|
2016
|
+
hasMore: false
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
const historyFilter = {
|
|
2020
|
+
metadata_id: metadataRecord.id
|
|
2021
|
+
};
|
|
2022
|
+
if (tenantId) {
|
|
2023
|
+
historyFilter.tenant_id = tenantId;
|
|
2024
|
+
}
|
|
2025
|
+
if (options?.operationType) {
|
|
2026
|
+
historyFilter.operation_type = options.operationType;
|
|
2027
|
+
}
|
|
2028
|
+
if (options?.since) {
|
|
2029
|
+
historyFilter.recorded_at = { $gte: options.since };
|
|
2030
|
+
}
|
|
2031
|
+
if (options?.until) {
|
|
2032
|
+
if (historyFilter.recorded_at) {
|
|
2033
|
+
historyFilter.recorded_at.$lte = options.until;
|
|
2034
|
+
} else {
|
|
2035
|
+
historyFilter.recorded_at = { $lte: options.until };
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const limit = options?.limit ?? 50;
|
|
2039
|
+
const offset = options?.offset ?? 0;
|
|
2040
|
+
const historyRecords = await driver.find(historyTableName, {
|
|
2041
|
+
object: historyTableName,
|
|
2042
|
+
where: historyFilter,
|
|
2043
|
+
orderBy: [{ field: "recorded_at", order: "desc" }],
|
|
2044
|
+
limit: limit + 1,
|
|
2045
|
+
// Fetch one extra to determine hasMore
|
|
2046
|
+
offset
|
|
2047
|
+
});
|
|
2048
|
+
const hasMore = historyRecords.length > limit;
|
|
2049
|
+
const records = historyRecords.slice(0, limit);
|
|
2050
|
+
const total = await driver.count(historyTableName, {
|
|
2051
|
+
object: historyTableName,
|
|
2052
|
+
where: historyFilter
|
|
2053
|
+
});
|
|
2054
|
+
const includeMetadata = options?.includeMetadata !== false;
|
|
2055
|
+
const historyResult = records.map((row) => {
|
|
2056
|
+
const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
|
|
2057
|
+
return {
|
|
2058
|
+
id: row.id,
|
|
2059
|
+
metadataId: row.metadata_id,
|
|
2060
|
+
name: row.name,
|
|
2061
|
+
type: row.type,
|
|
2062
|
+
version: row.version,
|
|
2063
|
+
operationType: row.operation_type,
|
|
2064
|
+
metadata: includeMetadata ? parsedMetadata : null,
|
|
2065
|
+
checksum: row.checksum,
|
|
2066
|
+
previousChecksum: row.previous_checksum,
|
|
2067
|
+
changeNote: row.change_note,
|
|
2068
|
+
tenantId: row.tenant_id,
|
|
2069
|
+
recordedBy: row.recorded_by,
|
|
2070
|
+
recordedAt: row.recorded_at
|
|
2071
|
+
};
|
|
2072
|
+
});
|
|
2073
|
+
return {
|
|
2074
|
+
records: historyResult,
|
|
2075
|
+
total,
|
|
2076
|
+
hasMore
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Rollback a metadata item to a specific version.
|
|
2081
|
+
* Restores the metadata definition from the history snapshot.
|
|
2082
|
+
*/
|
|
2083
|
+
async rollback(type, name, version, options) {
|
|
2084
|
+
const dbLoader = this.getDatabaseLoader();
|
|
2085
|
+
if (!dbLoader) {
|
|
2086
|
+
throw new Error("Rollback requires a database loader to be configured");
|
|
2087
|
+
}
|
|
2088
|
+
const targetVersion = await dbLoader.getHistoryRecord(type, name, version);
|
|
2089
|
+
if (!targetVersion) {
|
|
2090
|
+
throw new Error(`Version ${version} not found in history for ${type}/${name}`);
|
|
2091
|
+
}
|
|
2092
|
+
if (!targetVersion.metadata) {
|
|
2093
|
+
throw new Error(`Version ${version} metadata snapshot not available`);
|
|
2094
|
+
}
|
|
2095
|
+
const restoredMetadata = targetVersion.metadata;
|
|
2096
|
+
await dbLoader.registerRollback(
|
|
2097
|
+
type,
|
|
2098
|
+
name,
|
|
2099
|
+
restoredMetadata,
|
|
2100
|
+
version,
|
|
2101
|
+
options?.changeNote,
|
|
2102
|
+
options?.recordedBy
|
|
2103
|
+
);
|
|
2104
|
+
if (!this.registry.has(type)) {
|
|
2105
|
+
this.registry.set(type, /* @__PURE__ */ new Map());
|
|
2106
|
+
}
|
|
2107
|
+
this.registry.get(type).set(name, restoredMetadata);
|
|
2108
|
+
return restoredMetadata;
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Compare two versions of a metadata item.
|
|
2112
|
+
* Returns a diff showing what changed between versions.
|
|
2113
|
+
*/
|
|
2114
|
+
async diff(type, name, version1, version2) {
|
|
2115
|
+
const dbLoader = this.getDatabaseLoader();
|
|
2116
|
+
if (!dbLoader) {
|
|
2117
|
+
throw new Error("Diff requires a database loader to be configured");
|
|
2118
|
+
}
|
|
2119
|
+
const v1 = await dbLoader.getHistoryRecord(type, name, version1);
|
|
2120
|
+
const v2 = await dbLoader.getHistoryRecord(type, name, version2);
|
|
2121
|
+
if (!v1) {
|
|
2122
|
+
throw new Error(`Version ${version1} not found in history for ${type}/${name}`);
|
|
2123
|
+
}
|
|
2124
|
+
if (!v2) {
|
|
2125
|
+
throw new Error(`Version ${version2} not found in history for ${type}/${name}`);
|
|
2126
|
+
}
|
|
2127
|
+
if (!v1.metadata || !v2.metadata) {
|
|
2128
|
+
throw new Error("Version metadata snapshots not available");
|
|
2129
|
+
}
|
|
2130
|
+
const patch = generateSimpleDiff(v1.metadata, v2.metadata);
|
|
2131
|
+
const identical = patch.length === 0;
|
|
2132
|
+
const summary = generateDiffSummary(patch);
|
|
2133
|
+
return {
|
|
2134
|
+
type,
|
|
2135
|
+
name,
|
|
2136
|
+
version1,
|
|
2137
|
+
version2,
|
|
2138
|
+
checksum1: v1.checksum,
|
|
2139
|
+
checksum2: v2.checksum,
|
|
2140
|
+
identical,
|
|
2141
|
+
patch,
|
|
2142
|
+
summary
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
1491
2145
|
};
|
|
1492
2146
|
|
|
1493
2147
|
// src/node-metadata-manager.ts
|
|
@@ -1908,14 +2562,18 @@ var MetadataPlugin = class {
|
|
|
1908
2562
|
watch: this.options.watch
|
|
1909
2563
|
});
|
|
1910
2564
|
ctx.registerService("metadata", this.manager);
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2565
|
+
console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
|
|
2566
|
+
try {
|
|
2567
|
+
ctx.getService("manifest").register({
|
|
2568
|
+
id: "com.objectstack.metadata",
|
|
2569
|
+
name: "Metadata",
|
|
2570
|
+
version: "1.0.0",
|
|
2571
|
+
type: "plugin",
|
|
2572
|
+
namespace: "sys",
|
|
2573
|
+
objects: [SysMetadataObject]
|
|
2574
|
+
});
|
|
2575
|
+
} catch {
|
|
2576
|
+
}
|
|
1919
2577
|
ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
|
|
1920
2578
|
mode: "file-system",
|
|
1921
2579
|
features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
|
|
@@ -1948,6 +2606,33 @@ var MetadataPlugin = class {
|
|
|
1948
2606
|
totalItems: totalLoaded,
|
|
1949
2607
|
registeredTypes: sortedTypes.length
|
|
1950
2608
|
});
|
|
2609
|
+
try {
|
|
2610
|
+
const services = ctx.getServices();
|
|
2611
|
+
for (const [serviceName, service] of services) {
|
|
2612
|
+
if (serviceName.startsWith("driver.") && service) {
|
|
2613
|
+
ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager for database-backed persistence", {
|
|
2614
|
+
driverService: serviceName
|
|
2615
|
+
});
|
|
2616
|
+
this.manager.setDatabaseDriver(service);
|
|
2617
|
+
break;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
} catch (e) {
|
|
2621
|
+
ctx.logger.debug("[MetadataPlugin] No driver service found \u2014 database metadata persistence not available", {
|
|
2622
|
+
error: e.message
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
try {
|
|
2626
|
+
const realtimeService = ctx.getService("realtime");
|
|
2627
|
+
if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
|
|
2628
|
+
ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
|
|
2629
|
+
this.manager.setRealtimeService(realtimeService);
|
|
2630
|
+
}
|
|
2631
|
+
} catch (e) {
|
|
2632
|
+
ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
|
|
2633
|
+
error: e.message
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
1951
2636
|
};
|
|
1952
2637
|
this.options = {
|
|
1953
2638
|
watch: true,
|
|
@@ -2027,6 +2712,18 @@ var MemoryLoader = class {
|
|
|
2027
2712
|
saveTime: 0
|
|
2028
2713
|
};
|
|
2029
2714
|
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Delete a metadata item from memory storage
|
|
2717
|
+
*/
|
|
2718
|
+
async delete(type, name) {
|
|
2719
|
+
const typeStore = this.storage.get(type);
|
|
2720
|
+
if (typeStore) {
|
|
2721
|
+
typeStore.delete(name);
|
|
2722
|
+
if (typeStore.size === 0) {
|
|
2723
|
+
this.storage.delete(type);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2030
2727
|
};
|
|
2031
2728
|
|
|
2032
2729
|
// src/loaders/remote-loader.ts
|
|
@@ -2126,6 +2823,330 @@ var RemoteLoader = class {
|
|
|
2126
2823
|
}
|
|
2127
2824
|
};
|
|
2128
2825
|
|
|
2826
|
+
// src/routes/history-routes.ts
|
|
2827
|
+
function registerMetadataHistoryRoutes(app, metadataService) {
|
|
2828
|
+
app.get("/api/v1/metadata/:type/:name/history", async (c) => {
|
|
2829
|
+
if (!metadataService.getHistory) {
|
|
2830
|
+
return c.json({ error: "History tracking not enabled" }, 501);
|
|
2831
|
+
}
|
|
2832
|
+
const { type, name } = c.req.param();
|
|
2833
|
+
const query = c.req.query();
|
|
2834
|
+
try {
|
|
2835
|
+
const options = {};
|
|
2836
|
+
if (query.limit !== void 0) {
|
|
2837
|
+
const limit = parseInt(query.limit, 10);
|
|
2838
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
2839
|
+
return c.json({ success: false, error: "limit must be a positive integer" }, 400);
|
|
2840
|
+
}
|
|
2841
|
+
options.limit = limit;
|
|
2842
|
+
}
|
|
2843
|
+
if (query.offset !== void 0) {
|
|
2844
|
+
const offset = parseInt(query.offset, 10);
|
|
2845
|
+
if (!Number.isFinite(offset) || offset < 0) {
|
|
2846
|
+
return c.json({ success: false, error: "offset must be a non-negative integer" }, 400);
|
|
2847
|
+
}
|
|
2848
|
+
options.offset = offset;
|
|
2849
|
+
}
|
|
2850
|
+
if (query.since) options.since = query.since;
|
|
2851
|
+
if (query.until) options.until = query.until;
|
|
2852
|
+
if (query.operationType) options.operationType = query.operationType;
|
|
2853
|
+
if (query.includeMetadata !== void 0) {
|
|
2854
|
+
options.includeMetadata = query.includeMetadata === "true";
|
|
2855
|
+
}
|
|
2856
|
+
const result = await metadataService.getHistory(type, name, options);
|
|
2857
|
+
return c.json({
|
|
2858
|
+
success: true,
|
|
2859
|
+
data: result
|
|
2860
|
+
});
|
|
2861
|
+
} catch (error) {
|
|
2862
|
+
return c.json(
|
|
2863
|
+
{
|
|
2864
|
+
success: false,
|
|
2865
|
+
error: error instanceof Error ? error.message : "Failed to retrieve history"
|
|
2866
|
+
},
|
|
2867
|
+
500
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
});
|
|
2871
|
+
app.post("/api/v1/metadata/:type/:name/rollback", async (c) => {
|
|
2872
|
+
if (!metadataService.rollback) {
|
|
2873
|
+
return c.json({ error: "Rollback not supported" }, 501);
|
|
2874
|
+
}
|
|
2875
|
+
const { type, name } = c.req.param();
|
|
2876
|
+
try {
|
|
2877
|
+
const body = await c.req.json();
|
|
2878
|
+
const { version, changeNote, recordedBy } = body;
|
|
2879
|
+
if (typeof version !== "number") {
|
|
2880
|
+
return c.json(
|
|
2881
|
+
{
|
|
2882
|
+
success: false,
|
|
2883
|
+
error: "Version number is required"
|
|
2884
|
+
},
|
|
2885
|
+
400
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
const restoredMetadata = await metadataService.rollback(type, name, version, {
|
|
2889
|
+
changeNote,
|
|
2890
|
+
recordedBy
|
|
2891
|
+
});
|
|
2892
|
+
return c.json({
|
|
2893
|
+
success: true,
|
|
2894
|
+
data: {
|
|
2895
|
+
type,
|
|
2896
|
+
name,
|
|
2897
|
+
version,
|
|
2898
|
+
metadata: restoredMetadata
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
return c.json(
|
|
2903
|
+
{
|
|
2904
|
+
success: false,
|
|
2905
|
+
error: error instanceof Error ? error.message : "Rollback failed"
|
|
2906
|
+
},
|
|
2907
|
+
500
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
});
|
|
2911
|
+
app.get("/api/v1/metadata/:type/:name/diff", async (c) => {
|
|
2912
|
+
if (!metadataService.diff) {
|
|
2913
|
+
return c.json({ error: "Diff not supported" }, 501);
|
|
2914
|
+
}
|
|
2915
|
+
const { type, name } = c.req.param();
|
|
2916
|
+
const query = c.req.query();
|
|
2917
|
+
try {
|
|
2918
|
+
const version1 = parseInt(query.version1, 10);
|
|
2919
|
+
const version2 = parseInt(query.version2, 10);
|
|
2920
|
+
if (isNaN(version1) || isNaN(version2)) {
|
|
2921
|
+
return c.json(
|
|
2922
|
+
{
|
|
2923
|
+
success: false,
|
|
2924
|
+
error: "Both version1 and version2 query parameters are required"
|
|
2925
|
+
},
|
|
2926
|
+
400
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
const diffResult = await metadataService.diff(type, name, version1, version2);
|
|
2930
|
+
return c.json({
|
|
2931
|
+
success: true,
|
|
2932
|
+
data: diffResult
|
|
2933
|
+
});
|
|
2934
|
+
} catch (error) {
|
|
2935
|
+
return c.json(
|
|
2936
|
+
{
|
|
2937
|
+
success: false,
|
|
2938
|
+
error: error instanceof Error ? error.message : "Diff failed"
|
|
2939
|
+
},
|
|
2940
|
+
500
|
|
2941
|
+
);
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/utils/history-cleanup.ts
|
|
2947
|
+
var HistoryCleanupManager = class {
|
|
2948
|
+
constructor(policy, dbLoader) {
|
|
2949
|
+
this.policy = policy;
|
|
2950
|
+
this.dbLoader = dbLoader;
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Start automatic cleanup if enabled in the policy.
|
|
2954
|
+
*/
|
|
2955
|
+
start() {
|
|
2956
|
+
if (!this.policy.autoCleanup) {
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
|
|
2960
|
+
void this.runCleanup();
|
|
2961
|
+
this.cleanupTimer = setInterval(() => {
|
|
2962
|
+
void this.runCleanup();
|
|
2963
|
+
}, intervalMs);
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Stop automatic cleanup.
|
|
2967
|
+
*/
|
|
2968
|
+
stop() {
|
|
2969
|
+
if (this.cleanupTimer) {
|
|
2970
|
+
clearInterval(this.cleanupTimer);
|
|
2971
|
+
this.cleanupTimer = void 0;
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* Run cleanup based on the retention policy.
|
|
2976
|
+
* Removes history records that exceed the configured limits.
|
|
2977
|
+
*/
|
|
2978
|
+
async runCleanup() {
|
|
2979
|
+
const driver = this.dbLoader.driver;
|
|
2980
|
+
const historyTableName = this.dbLoader.historyTableName;
|
|
2981
|
+
const tenantId = this.dbLoader.tenantId;
|
|
2982
|
+
let deleted = 0;
|
|
2983
|
+
let errors = 0;
|
|
2984
|
+
try {
|
|
2985
|
+
if (this.policy.maxAgeDays) {
|
|
2986
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
2987
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
|
|
2988
|
+
const cutoffISO = cutoffDate.toISOString();
|
|
2989
|
+
const filter = {
|
|
2990
|
+
recorded_at: { $lt: cutoffISO }
|
|
2991
|
+
};
|
|
2992
|
+
if (tenantId) {
|
|
2993
|
+
filter.tenant_id = tenantId;
|
|
2994
|
+
}
|
|
2995
|
+
try {
|
|
2996
|
+
const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
|
|
2997
|
+
deleted += result.deleted;
|
|
2998
|
+
errors += result.errors;
|
|
2999
|
+
} catch {
|
|
3000
|
+
errors++;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
if (this.policy.maxVersions) {
|
|
3004
|
+
try {
|
|
3005
|
+
const metadataIds = await driver.find(historyTableName, {
|
|
3006
|
+
object: historyTableName,
|
|
3007
|
+
where: tenantId ? { tenant_id: tenantId } : {},
|
|
3008
|
+
fields: ["metadata_id"]
|
|
3009
|
+
});
|
|
3010
|
+
const uniqueIds = /* @__PURE__ */ new Set();
|
|
3011
|
+
for (const record of metadataIds) {
|
|
3012
|
+
if (record.metadata_id) {
|
|
3013
|
+
uniqueIds.add(record.metadata_id);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
for (const metadataId of uniqueIds) {
|
|
3017
|
+
const filter = { metadata_id: metadataId };
|
|
3018
|
+
if (tenantId) {
|
|
3019
|
+
filter.tenant_id = tenantId;
|
|
3020
|
+
}
|
|
3021
|
+
try {
|
|
3022
|
+
const historyRecords = await driver.find(historyTableName, {
|
|
3023
|
+
object: historyTableName,
|
|
3024
|
+
where: filter,
|
|
3025
|
+
orderBy: [{ field: "version", order: "desc" }],
|
|
3026
|
+
fields: ["id"]
|
|
3027
|
+
});
|
|
3028
|
+
if (historyRecords.length > this.policy.maxVersions) {
|
|
3029
|
+
const toDelete = historyRecords.slice(this.policy.maxVersions);
|
|
3030
|
+
const ids = toDelete.map((r) => r.id).filter(Boolean);
|
|
3031
|
+
const result = await this.bulkDeleteByIds(driver, historyTableName, ids);
|
|
3032
|
+
deleted += result.deleted;
|
|
3033
|
+
errors += result.errors;
|
|
3034
|
+
}
|
|
3035
|
+
} catch {
|
|
3036
|
+
errors++;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
} catch {
|
|
3040
|
+
errors++;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
} catch (error) {
|
|
3044
|
+
console.error("History cleanup failed:", error);
|
|
3045
|
+
errors++;
|
|
3046
|
+
}
|
|
3047
|
+
return { deleted, errors };
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Delete records matching a filter using the most efficient method available on the driver.
|
|
3051
|
+
*/
|
|
3052
|
+
async bulkDeleteByFilter(driver, table, filter) {
|
|
3053
|
+
const driverAny = driver;
|
|
3054
|
+
if (typeof driverAny.deleteMany === "function") {
|
|
3055
|
+
const count = await driverAny.deleteMany(table, filter);
|
|
3056
|
+
return { deleted: typeof count === "number" ? count : 0, errors: 0 };
|
|
3057
|
+
}
|
|
3058
|
+
const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
|
|
3059
|
+
const ids = records.map((r) => r.id).filter(Boolean);
|
|
3060
|
+
return this.bulkDeleteByIds(driver, table, ids);
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Delete records by IDs using bulkDelete when available, otherwise one-by-one.
|
|
3064
|
+
*/
|
|
3065
|
+
async bulkDeleteByIds(driver, table, ids) {
|
|
3066
|
+
if (ids.length === 0) return { deleted: 0, errors: 0 };
|
|
3067
|
+
const driverAny = driver;
|
|
3068
|
+
if (typeof driverAny.bulkDelete === "function") {
|
|
3069
|
+
const result = await driverAny.bulkDelete(table, ids);
|
|
3070
|
+
return {
|
|
3071
|
+
deleted: typeof result === "number" ? result : ids.length,
|
|
3072
|
+
errors: 0
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
let deleted = 0;
|
|
3076
|
+
let errors = 0;
|
|
3077
|
+
for (const id of ids) {
|
|
3078
|
+
try {
|
|
3079
|
+
await driver.delete(table, id);
|
|
3080
|
+
deleted++;
|
|
3081
|
+
} catch {
|
|
3082
|
+
errors++;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
return { deleted, errors };
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Get cleanup statistics without actually deleting anything.
|
|
3089
|
+
* Useful for previewing what would be cleaned up.
|
|
3090
|
+
*/
|
|
3091
|
+
async getCleanupStats() {
|
|
3092
|
+
const driver = this.dbLoader.driver;
|
|
3093
|
+
const historyTableName = this.dbLoader.historyTableName;
|
|
3094
|
+
const tenantId = this.dbLoader.tenantId;
|
|
3095
|
+
let recordsByAge = 0;
|
|
3096
|
+
let recordsByCount = 0;
|
|
3097
|
+
try {
|
|
3098
|
+
if (this.policy.maxAgeDays) {
|
|
3099
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
3100
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
|
|
3101
|
+
const cutoffISO = cutoffDate.toISOString();
|
|
3102
|
+
const filter = {
|
|
3103
|
+
recorded_at: { $lt: cutoffISO }
|
|
3104
|
+
};
|
|
3105
|
+
if (tenantId) {
|
|
3106
|
+
filter.tenant_id = tenantId;
|
|
3107
|
+
}
|
|
3108
|
+
recordsByAge = await driver.count(historyTableName, {
|
|
3109
|
+
object: historyTableName,
|
|
3110
|
+
where: filter
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
if (this.policy.maxVersions) {
|
|
3114
|
+
const metadataIds = await driver.find(historyTableName, {
|
|
3115
|
+
object: historyTableName,
|
|
3116
|
+
where: tenantId ? { tenant_id: tenantId } : {},
|
|
3117
|
+
fields: ["metadata_id"]
|
|
3118
|
+
});
|
|
3119
|
+
const uniqueIds = /* @__PURE__ */ new Set();
|
|
3120
|
+
for (const record of metadataIds) {
|
|
3121
|
+
if (record.metadata_id) {
|
|
3122
|
+
uniqueIds.add(record.metadata_id);
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
for (const metadataId of uniqueIds) {
|
|
3126
|
+
const filter = { metadata_id: metadataId };
|
|
3127
|
+
if (tenantId) {
|
|
3128
|
+
filter.tenant_id = tenantId;
|
|
3129
|
+
}
|
|
3130
|
+
const count = await driver.count(historyTableName, {
|
|
3131
|
+
object: historyTableName,
|
|
3132
|
+
where: filter
|
|
3133
|
+
});
|
|
3134
|
+
if (count > this.policy.maxVersions) {
|
|
3135
|
+
recordsByCount += count - this.policy.maxVersions;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
} catch (error) {
|
|
3140
|
+
console.error("Failed to get cleanup stats:", error);
|
|
3141
|
+
}
|
|
3142
|
+
return {
|
|
3143
|
+
recordsByAge,
|
|
3144
|
+
recordsByCount,
|
|
3145
|
+
total: recordsByAge + recordsByCount
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
|
|
2129
3150
|
// src/migration/index.ts
|
|
2130
3151
|
var migration_exports = {};
|
|
2131
3152
|
__export(migration_exports, {
|
|
@@ -2184,14 +3205,20 @@ var MigrationExecutor = class {
|
|
|
2184
3205
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2185
3206
|
0 && (module.exports = {
|
|
2186
3207
|
DatabaseLoader,
|
|
3208
|
+
HistoryCleanupManager,
|
|
2187
3209
|
JSONSerializer,
|
|
2188
3210
|
MemoryLoader,
|
|
2189
3211
|
MetadataManager,
|
|
2190
3212
|
MetadataPlugin,
|
|
2191
3213
|
Migration,
|
|
2192
3214
|
RemoteLoader,
|
|
3215
|
+
SysMetadataHistoryObject,
|
|
2193
3216
|
SysMetadataObject,
|
|
2194
3217
|
TypeScriptSerializer,
|
|
2195
|
-
YAMLSerializer
|
|
3218
|
+
YAMLSerializer,
|
|
3219
|
+
calculateChecksum,
|
|
3220
|
+
generateDiffSummary,
|
|
3221
|
+
generateSimpleDiff,
|
|
3222
|
+
registerMetadataHistoryRoutes
|
|
2196
3223
|
});
|
|
2197
3224
|
//# sourceMappingURL=index.cjs.map
|