@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.js
CHANGED
|
@@ -326,6 +326,224 @@ var SysMetadataObject = ObjectSchema.create({
|
|
|
326
326
|
}
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
// src/objects/sys-metadata-history.object.ts
|
|
330
|
+
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
331
|
+
var SysMetadataHistoryObject = ObjectSchema2.create({
|
|
332
|
+
namespace: "sys",
|
|
333
|
+
name: "metadata_history",
|
|
334
|
+
label: "Metadata History",
|
|
335
|
+
pluralLabel: "Metadata History",
|
|
336
|
+
icon: "history",
|
|
337
|
+
isSystem: true,
|
|
338
|
+
description: "Version history and audit trail for metadata changes",
|
|
339
|
+
fields: {
|
|
340
|
+
/** Primary Key (UUID) */
|
|
341
|
+
id: Field2.text({
|
|
342
|
+
label: "ID",
|
|
343
|
+
required: true,
|
|
344
|
+
readonly: true
|
|
345
|
+
}),
|
|
346
|
+
/** Foreign key to sys_metadata.id */
|
|
347
|
+
metadata_id: Field2.text({
|
|
348
|
+
label: "Metadata ID",
|
|
349
|
+
required: true,
|
|
350
|
+
readonly: true,
|
|
351
|
+
maxLength: 255
|
|
352
|
+
}),
|
|
353
|
+
/** Machine name (denormalized for easier querying) */
|
|
354
|
+
name: Field2.text({
|
|
355
|
+
label: "Name",
|
|
356
|
+
required: true,
|
|
357
|
+
searchable: true,
|
|
358
|
+
readonly: true,
|
|
359
|
+
maxLength: 255
|
|
360
|
+
}),
|
|
361
|
+
/** Metadata type (denormalized for easier querying) */
|
|
362
|
+
type: Field2.text({
|
|
363
|
+
label: "Metadata Type",
|
|
364
|
+
required: true,
|
|
365
|
+
searchable: true,
|
|
366
|
+
readonly: true,
|
|
367
|
+
maxLength: 100
|
|
368
|
+
}),
|
|
369
|
+
/** Version number at this snapshot */
|
|
370
|
+
version: Field2.number({
|
|
371
|
+
label: "Version",
|
|
372
|
+
required: true,
|
|
373
|
+
readonly: true
|
|
374
|
+
}),
|
|
375
|
+
/** Type of operation that created this history entry */
|
|
376
|
+
operation_type: Field2.select(["create", "update", "publish", "revert", "delete"], {
|
|
377
|
+
label: "Operation Type",
|
|
378
|
+
required: true,
|
|
379
|
+
readonly: true
|
|
380
|
+
}),
|
|
381
|
+
/** Historical metadata snapshot (JSON payload) */
|
|
382
|
+
metadata: Field2.textarea({
|
|
383
|
+
label: "Metadata",
|
|
384
|
+
required: true,
|
|
385
|
+
readonly: true,
|
|
386
|
+
description: "JSON-serialized metadata snapshot at this version"
|
|
387
|
+
}),
|
|
388
|
+
/** SHA-256 checksum of metadata content */
|
|
389
|
+
checksum: Field2.text({
|
|
390
|
+
label: "Checksum",
|
|
391
|
+
required: true,
|
|
392
|
+
readonly: true,
|
|
393
|
+
maxLength: 64
|
|
394
|
+
}),
|
|
395
|
+
/** Checksum of the previous version */
|
|
396
|
+
previous_checksum: Field2.text({
|
|
397
|
+
label: "Previous Checksum",
|
|
398
|
+
required: false,
|
|
399
|
+
readonly: true,
|
|
400
|
+
maxLength: 64
|
|
401
|
+
}),
|
|
402
|
+
/** Human-readable description of changes */
|
|
403
|
+
change_note: Field2.textarea({
|
|
404
|
+
label: "Change Note",
|
|
405
|
+
required: false,
|
|
406
|
+
readonly: true,
|
|
407
|
+
description: "Description of what changed in this version"
|
|
408
|
+
}),
|
|
409
|
+
/** Tenant ID for multi-tenant isolation */
|
|
410
|
+
tenant_id: Field2.text({
|
|
411
|
+
label: "Tenant ID",
|
|
412
|
+
required: false,
|
|
413
|
+
readonly: true,
|
|
414
|
+
maxLength: 255
|
|
415
|
+
}),
|
|
416
|
+
/** User who made this change */
|
|
417
|
+
recorded_by: Field2.text({
|
|
418
|
+
label: "Recorded By",
|
|
419
|
+
required: false,
|
|
420
|
+
readonly: true,
|
|
421
|
+
maxLength: 255
|
|
422
|
+
}),
|
|
423
|
+
/** When was this version recorded */
|
|
424
|
+
recorded_at: Field2.datetime({
|
|
425
|
+
label: "Recorded At",
|
|
426
|
+
required: true,
|
|
427
|
+
readonly: true
|
|
428
|
+
})
|
|
429
|
+
},
|
|
430
|
+
indexes: [
|
|
431
|
+
{ fields: ["metadata_id", "version"], unique: true },
|
|
432
|
+
{ fields: ["metadata_id", "recorded_at"] },
|
|
433
|
+
{ fields: ["type", "name"] },
|
|
434
|
+
{ fields: ["recorded_at"] },
|
|
435
|
+
{ fields: ["operation_type"] },
|
|
436
|
+
{ fields: ["tenant_id"] }
|
|
437
|
+
],
|
|
438
|
+
enable: {
|
|
439
|
+
trackHistory: false,
|
|
440
|
+
// Don't track history of history records
|
|
441
|
+
searchable: false,
|
|
442
|
+
apiEnabled: true,
|
|
443
|
+
apiMethods: ["get", "list"],
|
|
444
|
+
// Read-only via API
|
|
445
|
+
trash: false
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// src/utils/metadata-history-utils.ts
|
|
450
|
+
async function calculateChecksum(metadata) {
|
|
451
|
+
const normalized = normalizeJSON(metadata);
|
|
452
|
+
const jsonString = JSON.stringify(normalized);
|
|
453
|
+
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) {
|
|
454
|
+
const encoder = new TextEncoder();
|
|
455
|
+
const data = encoder.encode(jsonString);
|
|
456
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
457
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
458
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
459
|
+
}
|
|
460
|
+
return simpleHash(jsonString);
|
|
461
|
+
}
|
|
462
|
+
function normalizeJSON(value) {
|
|
463
|
+
if (value === null || value === void 0) {
|
|
464
|
+
return value;
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(value)) {
|
|
467
|
+
return value.map(normalizeJSON);
|
|
468
|
+
}
|
|
469
|
+
if (typeof value === "object") {
|
|
470
|
+
const sorted = {};
|
|
471
|
+
const keys = Object.keys(value).sort();
|
|
472
|
+
for (const key of keys) {
|
|
473
|
+
sorted[key] = normalizeJSON(value[key]);
|
|
474
|
+
}
|
|
475
|
+
return sorted;
|
|
476
|
+
}
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
function simpleHash(str) {
|
|
480
|
+
let hash = 5381;
|
|
481
|
+
for (let i = 0; i < str.length; i++) {
|
|
482
|
+
hash = (hash << 5) + hash + str.charCodeAt(i);
|
|
483
|
+
hash = hash & hash;
|
|
484
|
+
}
|
|
485
|
+
const hexHash = Math.abs(hash).toString(16);
|
|
486
|
+
return hexHash.padStart(64, "0");
|
|
487
|
+
}
|
|
488
|
+
function generateSimpleDiff(oldObj, newObj, path3 = "") {
|
|
489
|
+
const changes = [];
|
|
490
|
+
if (typeof oldObj !== "object" || oldObj === null || typeof newObj !== "object" || newObj === null) {
|
|
491
|
+
if (oldObj !== newObj) {
|
|
492
|
+
changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
|
|
493
|
+
}
|
|
494
|
+
return changes;
|
|
495
|
+
}
|
|
496
|
+
if (Array.isArray(oldObj) || Array.isArray(newObj)) {
|
|
497
|
+
if (!Array.isArray(oldObj) || !Array.isArray(newObj) || oldObj.length !== newObj.length) {
|
|
498
|
+
changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
|
|
499
|
+
} else {
|
|
500
|
+
for (let i = 0; i < oldObj.length; i++) {
|
|
501
|
+
const subPath = `${path3}/${i}`;
|
|
502
|
+
changes.push(...generateSimpleDiff(oldObj[i], newObj[i], subPath));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return changes;
|
|
506
|
+
}
|
|
507
|
+
const oldKeys = new Set(Object.keys(oldObj));
|
|
508
|
+
const newKeys = new Set(Object.keys(newObj));
|
|
509
|
+
for (const key of newKeys) {
|
|
510
|
+
if (!oldKeys.has(key)) {
|
|
511
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
512
|
+
changes.push({ op: "add", path: subPath, value: newObj[key] });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const key of oldKeys) {
|
|
516
|
+
if (!newKeys.has(key)) {
|
|
517
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
518
|
+
changes.push({ op: "remove", path: subPath, oldValue: oldObj[key] });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (const key of oldKeys) {
|
|
522
|
+
if (newKeys.has(key)) {
|
|
523
|
+
const subPath = path3 ? `${path3}/${key}` : `/${key}`;
|
|
524
|
+
changes.push(...generateSimpleDiff(
|
|
525
|
+
oldObj[key],
|
|
526
|
+
newObj[key],
|
|
527
|
+
subPath
|
|
528
|
+
));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return changes;
|
|
532
|
+
}
|
|
533
|
+
function generateDiffSummary(diff) {
|
|
534
|
+
if (diff.length === 0) {
|
|
535
|
+
return "No changes";
|
|
536
|
+
}
|
|
537
|
+
const summary = [];
|
|
538
|
+
const addCount = diff.filter((d) => d.op === "add").length;
|
|
539
|
+
const removeCount = diff.filter((d) => d.op === "remove").length;
|
|
540
|
+
const replaceCount = diff.filter((d) => d.op === "replace").length;
|
|
541
|
+
if (addCount > 0) summary.push(`${addCount} field${addCount > 1 ? "s" : ""} added`);
|
|
542
|
+
if (removeCount > 0) summary.push(`${removeCount} field${removeCount > 1 ? "s" : ""} removed`);
|
|
543
|
+
if (replaceCount > 0) summary.push(`${replaceCount} field${replaceCount > 1 ? "s" : ""} modified`);
|
|
544
|
+
return summary.join(", ");
|
|
545
|
+
}
|
|
546
|
+
|
|
329
547
|
// src/loaders/database-loader.ts
|
|
330
548
|
var DatabaseLoader = class {
|
|
331
549
|
constructor(options) {
|
|
@@ -340,9 +558,12 @@ var DatabaseLoader = class {
|
|
|
340
558
|
}
|
|
341
559
|
};
|
|
342
560
|
this.schemaReady = false;
|
|
561
|
+
this.historySchemaReady = false;
|
|
343
562
|
this.driver = options.driver;
|
|
344
563
|
this.tableName = options.tableName ?? "sys_metadata";
|
|
564
|
+
this.historyTableName = options.historyTableName ?? "sys_metadata_history";
|
|
345
565
|
this.tenantId = options.tenantId;
|
|
566
|
+
this.trackHistory = options.trackHistory !== false;
|
|
346
567
|
}
|
|
347
568
|
/**
|
|
348
569
|
* Ensure the metadata table exists.
|
|
@@ -361,6 +582,22 @@ var DatabaseLoader = class {
|
|
|
361
582
|
this.schemaReady = true;
|
|
362
583
|
}
|
|
363
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Ensure the history table exists.
|
|
587
|
+
* Uses IDataDriver.syncSchema with the SysMetadataHistoryObject definition.
|
|
588
|
+
*/
|
|
589
|
+
async ensureHistorySchema() {
|
|
590
|
+
if (!this.trackHistory || this.historySchemaReady) return;
|
|
591
|
+
try {
|
|
592
|
+
await this.driver.syncSchema(this.historyTableName, {
|
|
593
|
+
...SysMetadataHistoryObject,
|
|
594
|
+
name: this.historyTableName
|
|
595
|
+
});
|
|
596
|
+
this.historySchemaReady = true;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.error("Failed to ensure history schema, will retry on next operation:", error);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
364
601
|
/**
|
|
365
602
|
* Build base filter conditions for queries.
|
|
366
603
|
* Always includes tenantId when configured.
|
|
@@ -375,6 +612,64 @@ var DatabaseLoader = class {
|
|
|
375
612
|
}
|
|
376
613
|
return filter;
|
|
377
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* Create a history record for a metadata change.
|
|
617
|
+
*
|
|
618
|
+
* @param metadataId - The metadata record ID
|
|
619
|
+
* @param type - Metadata type
|
|
620
|
+
* @param name - Metadata name
|
|
621
|
+
* @param version - Version number
|
|
622
|
+
* @param metadata - The metadata payload
|
|
623
|
+
* @param operationType - Type of operation
|
|
624
|
+
* @param previousChecksum - Checksum of previous version (if any)
|
|
625
|
+
* @param changeNote - Optional change description
|
|
626
|
+
* @param recordedBy - Optional user who made the change
|
|
627
|
+
*/
|
|
628
|
+
async createHistoryRecord(metadataId, type, name, version, metadata, operationType, previousChecksum, changeNote, recordedBy) {
|
|
629
|
+
if (!this.trackHistory) return;
|
|
630
|
+
await this.ensureHistorySchema();
|
|
631
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
632
|
+
const checksum = await calculateChecksum(metadata);
|
|
633
|
+
if (previousChecksum && checksum === previousChecksum && operationType === "update") {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const historyId = generateId();
|
|
637
|
+
const metadataJson = JSON.stringify(metadata);
|
|
638
|
+
const historyRecord = {
|
|
639
|
+
id: historyId,
|
|
640
|
+
metadataId,
|
|
641
|
+
name,
|
|
642
|
+
type,
|
|
643
|
+
version,
|
|
644
|
+
operationType,
|
|
645
|
+
metadata: metadataJson,
|
|
646
|
+
checksum,
|
|
647
|
+
previousChecksum,
|
|
648
|
+
changeNote,
|
|
649
|
+
recordedBy,
|
|
650
|
+
recordedAt: now,
|
|
651
|
+
...this.tenantId ? { tenantId: this.tenantId } : {}
|
|
652
|
+
};
|
|
653
|
+
try {
|
|
654
|
+
await this.driver.create(this.historyTableName, {
|
|
655
|
+
id: historyRecord.id,
|
|
656
|
+
metadata_id: historyRecord.metadataId,
|
|
657
|
+
name: historyRecord.name,
|
|
658
|
+
type: historyRecord.type,
|
|
659
|
+
version: historyRecord.version,
|
|
660
|
+
operation_type: historyRecord.operationType,
|
|
661
|
+
metadata: historyRecord.metadata,
|
|
662
|
+
checksum: historyRecord.checksum,
|
|
663
|
+
previous_checksum: historyRecord.previousChecksum,
|
|
664
|
+
change_note: historyRecord.changeNote,
|
|
665
|
+
recorded_by: historyRecord.recordedBy,
|
|
666
|
+
recorded_at: historyRecord.recordedAt,
|
|
667
|
+
...this.tenantId ? { tenant_id: this.tenantId } : {}
|
|
668
|
+
});
|
|
669
|
+
} catch (error) {
|
|
670
|
+
console.error(`Failed to create history record for ${type}/${name}:`, error);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
378
673
|
/**
|
|
379
674
|
* Convert a database row to a metadata payload.
|
|
380
675
|
* Parses the JSON `metadata` column back into an object.
|
|
@@ -502,24 +797,124 @@ var DatabaseLoader = class {
|
|
|
502
797
|
return [];
|
|
503
798
|
}
|
|
504
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* Fetch a single history snapshot by (type, name, version).
|
|
802
|
+
* Returns null when the record does not exist.
|
|
803
|
+
*/
|
|
804
|
+
async getHistoryRecord(type, name, version) {
|
|
805
|
+
if (!this.trackHistory) return null;
|
|
806
|
+
await this.ensureHistorySchema();
|
|
807
|
+
const metadataRow = await this.driver.findOne(this.tableName, {
|
|
808
|
+
object: this.tableName,
|
|
809
|
+
where: this.baseFilter(type, name)
|
|
810
|
+
});
|
|
811
|
+
if (!metadataRow) return null;
|
|
812
|
+
const filter = {
|
|
813
|
+
metadata_id: metadataRow.id,
|
|
814
|
+
version
|
|
815
|
+
};
|
|
816
|
+
if (this.tenantId) {
|
|
817
|
+
filter.tenant_id = this.tenantId;
|
|
818
|
+
}
|
|
819
|
+
const row = await this.driver.findOne(this.historyTableName, {
|
|
820
|
+
object: this.historyTableName,
|
|
821
|
+
where: filter
|
|
822
|
+
});
|
|
823
|
+
if (!row) return null;
|
|
824
|
+
return {
|
|
825
|
+
id: row.id,
|
|
826
|
+
metadataId: row.metadata_id,
|
|
827
|
+
name: row.name,
|
|
828
|
+
type: row.type,
|
|
829
|
+
version: row.version,
|
|
830
|
+
operationType: row.operation_type,
|
|
831
|
+
metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
|
|
832
|
+
checksum: row.checksum,
|
|
833
|
+
previousChecksum: row.previous_checksum,
|
|
834
|
+
changeNote: row.change_note,
|
|
835
|
+
tenantId: row.tenant_id,
|
|
836
|
+
recordedBy: row.recorded_by,
|
|
837
|
+
recordedAt: row.recorded_at
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Perform a rollback: persist `restoredData` as the new current state and record a
|
|
842
|
+
* single 'revert' history entry (instead of the usual 'update' entry that `save()`
|
|
843
|
+
* would produce). This avoids the duplicate-version problem that arises when
|
|
844
|
+
* `register()` → `save()` writes an 'update' entry followed by an additional
|
|
845
|
+
* 'revert' entry for the same version number.
|
|
846
|
+
*/
|
|
847
|
+
async registerRollback(type, name, restoredData, targetVersion, changeNote, recordedBy) {
|
|
848
|
+
await this.ensureSchema();
|
|
849
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
850
|
+
const metadataJson = JSON.stringify(restoredData);
|
|
851
|
+
const newChecksum = await calculateChecksum(restoredData);
|
|
852
|
+
const existing = await this.driver.findOne(this.tableName, {
|
|
853
|
+
object: this.tableName,
|
|
854
|
+
where: this.baseFilter(type, name)
|
|
855
|
+
});
|
|
856
|
+
if (!existing) {
|
|
857
|
+
throw new Error(`Metadata ${type}/${name} not found for rollback`);
|
|
858
|
+
}
|
|
859
|
+
const previousChecksum = existing.checksum;
|
|
860
|
+
const newVersion = (existing.version ?? 0) + 1;
|
|
861
|
+
await this.driver.update(this.tableName, existing.id, {
|
|
862
|
+
metadata: metadataJson,
|
|
863
|
+
version: newVersion,
|
|
864
|
+
checksum: newChecksum,
|
|
865
|
+
updated_at: now,
|
|
866
|
+
state: "active"
|
|
867
|
+
});
|
|
868
|
+
await this.createHistoryRecord(
|
|
869
|
+
existing.id,
|
|
870
|
+
type,
|
|
871
|
+
name,
|
|
872
|
+
newVersion,
|
|
873
|
+
restoredData,
|
|
874
|
+
"revert",
|
|
875
|
+
previousChecksum,
|
|
876
|
+
changeNote ?? `Rolled back to version ${targetVersion}`,
|
|
877
|
+
recordedBy
|
|
878
|
+
);
|
|
879
|
+
}
|
|
505
880
|
async save(type, name, data, _options) {
|
|
506
881
|
const startTime = Date.now();
|
|
507
882
|
await this.ensureSchema();
|
|
508
883
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
509
884
|
const metadataJson = JSON.stringify(data);
|
|
885
|
+
const newChecksum = await calculateChecksum(data);
|
|
510
886
|
try {
|
|
511
887
|
const existing = await this.driver.findOne(this.tableName, {
|
|
512
888
|
object: this.tableName,
|
|
513
889
|
where: this.baseFilter(type, name)
|
|
514
890
|
});
|
|
515
891
|
if (existing) {
|
|
892
|
+
const previousChecksum = existing.checksum;
|
|
893
|
+
if (newChecksum === previousChecksum) {
|
|
894
|
+
return {
|
|
895
|
+
success: true,
|
|
896
|
+
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
897
|
+
size: metadataJson.length,
|
|
898
|
+
saveTime: Date.now() - startTime
|
|
899
|
+
};
|
|
900
|
+
}
|
|
516
901
|
const version = (existing.version ?? 0) + 1;
|
|
517
902
|
await this.driver.update(this.tableName, existing.id, {
|
|
518
903
|
metadata: metadataJson,
|
|
519
904
|
version,
|
|
905
|
+
checksum: newChecksum,
|
|
520
906
|
updated_at: now,
|
|
521
907
|
state: "active"
|
|
522
908
|
});
|
|
909
|
+
await this.createHistoryRecord(
|
|
910
|
+
existing.id,
|
|
911
|
+
type,
|
|
912
|
+
name,
|
|
913
|
+
version,
|
|
914
|
+
data,
|
|
915
|
+
"update",
|
|
916
|
+
previousChecksum
|
|
917
|
+
);
|
|
523
918
|
return {
|
|
524
919
|
success: true,
|
|
525
920
|
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
@@ -535,6 +930,7 @@ var DatabaseLoader = class {
|
|
|
535
930
|
namespace: "default",
|
|
536
931
|
scope: data?.scope ?? "platform",
|
|
537
932
|
metadata: metadataJson,
|
|
933
|
+
checksum: newChecksum,
|
|
538
934
|
strategy: "merge",
|
|
539
935
|
state: "active",
|
|
540
936
|
version: 1,
|
|
@@ -543,6 +939,14 @@ var DatabaseLoader = class {
|
|
|
543
939
|
created_at: now,
|
|
544
940
|
updated_at: now
|
|
545
941
|
});
|
|
942
|
+
await this.createHistoryRecord(
|
|
943
|
+
id,
|
|
944
|
+
type,
|
|
945
|
+
name,
|
|
946
|
+
1,
|
|
947
|
+
data,
|
|
948
|
+
"create"
|
|
949
|
+
);
|
|
546
950
|
return {
|
|
547
951
|
success: true,
|
|
548
952
|
path: `datasource://${this.tableName}/${type}/${name}`,
|
|
@@ -556,6 +960,20 @@ var DatabaseLoader = class {
|
|
|
556
960
|
);
|
|
557
961
|
}
|
|
558
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* Delete a metadata item from the database
|
|
965
|
+
*/
|
|
966
|
+
async delete(type, name) {
|
|
967
|
+
await this.ensureSchema();
|
|
968
|
+
const existing = await this.driver.findOne(this.tableName, {
|
|
969
|
+
object: this.tableName,
|
|
970
|
+
where: this.baseFilter(type, name)
|
|
971
|
+
});
|
|
972
|
+
if (!existing) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
await this.driver.delete(this.tableName, existing.id);
|
|
976
|
+
}
|
|
559
977
|
};
|
|
560
978
|
function generateId() {
|
|
561
979
|
if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
|
|
@@ -621,6 +1039,16 @@ var MetadataManager = class {
|
|
|
621
1039
|
this.registerLoader(dbLoader);
|
|
622
1040
|
this.logger.info("DatabaseLoader configured", { datasource: this.config.datasource, tableName });
|
|
623
1041
|
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Set the realtime service for publishing metadata change events.
|
|
1044
|
+
* Should be called after kernel resolves the realtime service.
|
|
1045
|
+
*
|
|
1046
|
+
* @param service - An IRealtimeService instance for event publishing
|
|
1047
|
+
*/
|
|
1048
|
+
setRealtimeService(service) {
|
|
1049
|
+
this.realtimeService = service;
|
|
1050
|
+
this.logger.info("RealtimeService configured for metadata events");
|
|
1051
|
+
}
|
|
624
1052
|
/**
|
|
625
1053
|
* Register a new metadata loader (data source)
|
|
626
1054
|
*/
|
|
@@ -633,12 +1061,39 @@ var MetadataManager = class {
|
|
|
633
1061
|
// ==========================================
|
|
634
1062
|
/**
|
|
635
1063
|
* Register/save a metadata item by type
|
|
1064
|
+
* Stores in-memory registry and persists to database-backed loaders only.
|
|
1065
|
+
* FilesystemLoader (protocol 'file:') is read-only for static metadata and
|
|
1066
|
+
* should not be written to during runtime registration.
|
|
636
1067
|
*/
|
|
637
1068
|
async register(type, name, data) {
|
|
638
1069
|
if (!this.registry.has(type)) {
|
|
639
1070
|
this.registry.set(type, /* @__PURE__ */ new Map());
|
|
640
1071
|
}
|
|
641
1072
|
this.registry.get(type).set(name, data);
|
|
1073
|
+
for (const loader of this.loaders.values()) {
|
|
1074
|
+
if (loader.save && loader.contract.protocol === "datasource:" && loader.contract.capabilities.write) {
|
|
1075
|
+
await loader.save(type, name, data);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (this.realtimeService) {
|
|
1079
|
+
const event = {
|
|
1080
|
+
type: `metadata.${type}.created`,
|
|
1081
|
+
object: type,
|
|
1082
|
+
payload: {
|
|
1083
|
+
metadataType: type,
|
|
1084
|
+
name,
|
|
1085
|
+
definition: data,
|
|
1086
|
+
packageId: data?.packageId
|
|
1087
|
+
},
|
|
1088
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1089
|
+
};
|
|
1090
|
+
try {
|
|
1091
|
+
await this.realtimeService.publish(event);
|
|
1092
|
+
this.logger.debug(`Published metadata.${type}.created event`, { name });
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
642
1097
|
}
|
|
643
1098
|
/**
|
|
644
1099
|
* Get a metadata item by type and name.
|
|
@@ -679,7 +1134,8 @@ var MetadataManager = class {
|
|
|
679
1134
|
return Array.from(items.values());
|
|
680
1135
|
}
|
|
681
1136
|
/**
|
|
682
|
-
* Unregister/remove a metadata item by type and name
|
|
1137
|
+
* Unregister/remove a metadata item by type and name.
|
|
1138
|
+
* Deletes from database-backed loaders only (same rationale as register()).
|
|
683
1139
|
*/
|
|
684
1140
|
async unregister(type, name) {
|
|
685
1141
|
const typeStore = this.registry.get(type);
|
|
@@ -689,6 +1145,33 @@ var MetadataManager = class {
|
|
|
689
1145
|
this.registry.delete(type);
|
|
690
1146
|
}
|
|
691
1147
|
}
|
|
1148
|
+
for (const loader of this.loaders.values()) {
|
|
1149
|
+
if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
|
|
1150
|
+
if (typeof loader.delete === "function") {
|
|
1151
|
+
try {
|
|
1152
|
+
await loader.delete(type, name);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (this.realtimeService) {
|
|
1159
|
+
const event = {
|
|
1160
|
+
type: `metadata.${type}.deleted`,
|
|
1161
|
+
object: type,
|
|
1162
|
+
payload: {
|
|
1163
|
+
metadataType: type,
|
|
1164
|
+
name
|
|
1165
|
+
},
|
|
1166
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1167
|
+
};
|
|
1168
|
+
try {
|
|
1169
|
+
await this.realtimeService.publish(event);
|
|
1170
|
+
this.logger.debug(`Published metadata.${type}.deleted event`, { name });
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
this.logger.warn(`Failed to publish metadata event`, { type, name, error });
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
692
1175
|
}
|
|
693
1176
|
/**
|
|
694
1177
|
* Check if a metadata item exists
|
|
@@ -771,20 +1254,17 @@ var MetadataManager = class {
|
|
|
771
1254
|
* Unregister all metadata items from a specific package
|
|
772
1255
|
*/
|
|
773
1256
|
async unregisterPackage(packageName) {
|
|
1257
|
+
const itemsToDelete = [];
|
|
774
1258
|
for (const [type, typeStore] of this.registry) {
|
|
775
|
-
const toDelete = [];
|
|
776
1259
|
for (const [name, data] of typeStore) {
|
|
777
1260
|
const meta = data;
|
|
778
1261
|
if (meta?.packageId === packageName || meta?.package === packageName) {
|
|
779
|
-
|
|
1262
|
+
itemsToDelete.push({ type, name });
|
|
780
1263
|
}
|
|
781
1264
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (typeStore.size === 0) {
|
|
786
|
-
this.registry.delete(type);
|
|
787
|
-
}
|
|
1265
|
+
}
|
|
1266
|
+
for (const { type, name } of itemsToDelete) {
|
|
1267
|
+
await this.unregister(type, name);
|
|
788
1268
|
}
|
|
789
1269
|
}
|
|
790
1270
|
/**
|
|
@@ -1449,6 +1929,174 @@ var MetadataManager = class {
|
|
|
1449
1929
|
}
|
|
1450
1930
|
}
|
|
1451
1931
|
}
|
|
1932
|
+
// ==========================================
|
|
1933
|
+
// Version History & Rollback
|
|
1934
|
+
// ==========================================
|
|
1935
|
+
/**
|
|
1936
|
+
* Get the database loader for history operations.
|
|
1937
|
+
* Returns undefined if no database loader is configured.
|
|
1938
|
+
*/
|
|
1939
|
+
getDatabaseLoader() {
|
|
1940
|
+
const dbLoader = this.loaders.get("database");
|
|
1941
|
+
if (dbLoader && dbLoader instanceof DatabaseLoader) {
|
|
1942
|
+
return dbLoader;
|
|
1943
|
+
}
|
|
1944
|
+
return void 0;
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Get version history for a metadata item.
|
|
1948
|
+
* Returns a timeline of all changes made to the item.
|
|
1949
|
+
*/
|
|
1950
|
+
async getHistory(type, name, options) {
|
|
1951
|
+
const dbLoader = this.getDatabaseLoader();
|
|
1952
|
+
if (!dbLoader) {
|
|
1953
|
+
throw new Error("History tracking requires a database loader to be configured");
|
|
1954
|
+
}
|
|
1955
|
+
const driver = dbLoader.driver;
|
|
1956
|
+
const tableName = dbLoader.tableName;
|
|
1957
|
+
const historyTableName = dbLoader.historyTableName;
|
|
1958
|
+
const tenantId = dbLoader.tenantId;
|
|
1959
|
+
const filter = { type, name };
|
|
1960
|
+
if (tenantId) {
|
|
1961
|
+
filter.tenant_id = tenantId;
|
|
1962
|
+
}
|
|
1963
|
+
const metadataRecord = await driver.findOne(tableName, {
|
|
1964
|
+
object: tableName,
|
|
1965
|
+
where: filter
|
|
1966
|
+
});
|
|
1967
|
+
if (!metadataRecord) {
|
|
1968
|
+
return {
|
|
1969
|
+
records: [],
|
|
1970
|
+
total: 0,
|
|
1971
|
+
hasMore: false
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
const historyFilter = {
|
|
1975
|
+
metadata_id: metadataRecord.id
|
|
1976
|
+
};
|
|
1977
|
+
if (tenantId) {
|
|
1978
|
+
historyFilter.tenant_id = tenantId;
|
|
1979
|
+
}
|
|
1980
|
+
if (options?.operationType) {
|
|
1981
|
+
historyFilter.operation_type = options.operationType;
|
|
1982
|
+
}
|
|
1983
|
+
if (options?.since) {
|
|
1984
|
+
historyFilter.recorded_at = { $gte: options.since };
|
|
1985
|
+
}
|
|
1986
|
+
if (options?.until) {
|
|
1987
|
+
if (historyFilter.recorded_at) {
|
|
1988
|
+
historyFilter.recorded_at.$lte = options.until;
|
|
1989
|
+
} else {
|
|
1990
|
+
historyFilter.recorded_at = { $lte: options.until };
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const limit = options?.limit ?? 50;
|
|
1994
|
+
const offset = options?.offset ?? 0;
|
|
1995
|
+
const historyRecords = await driver.find(historyTableName, {
|
|
1996
|
+
object: historyTableName,
|
|
1997
|
+
where: historyFilter,
|
|
1998
|
+
orderBy: [{ field: "recorded_at", order: "desc" }],
|
|
1999
|
+
limit: limit + 1,
|
|
2000
|
+
// Fetch one extra to determine hasMore
|
|
2001
|
+
offset
|
|
2002
|
+
});
|
|
2003
|
+
const hasMore = historyRecords.length > limit;
|
|
2004
|
+
const records = historyRecords.slice(0, limit);
|
|
2005
|
+
const total = await driver.count(historyTableName, {
|
|
2006
|
+
object: historyTableName,
|
|
2007
|
+
where: historyFilter
|
|
2008
|
+
});
|
|
2009
|
+
const includeMetadata = options?.includeMetadata !== false;
|
|
2010
|
+
const historyResult = records.map((row) => {
|
|
2011
|
+
const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
|
|
2012
|
+
return {
|
|
2013
|
+
id: row.id,
|
|
2014
|
+
metadataId: row.metadata_id,
|
|
2015
|
+
name: row.name,
|
|
2016
|
+
type: row.type,
|
|
2017
|
+
version: row.version,
|
|
2018
|
+
operationType: row.operation_type,
|
|
2019
|
+
metadata: includeMetadata ? parsedMetadata : null,
|
|
2020
|
+
checksum: row.checksum,
|
|
2021
|
+
previousChecksum: row.previous_checksum,
|
|
2022
|
+
changeNote: row.change_note,
|
|
2023
|
+
tenantId: row.tenant_id,
|
|
2024
|
+
recordedBy: row.recorded_by,
|
|
2025
|
+
recordedAt: row.recorded_at
|
|
2026
|
+
};
|
|
2027
|
+
});
|
|
2028
|
+
return {
|
|
2029
|
+
records: historyResult,
|
|
2030
|
+
total,
|
|
2031
|
+
hasMore
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Rollback a metadata item to a specific version.
|
|
2036
|
+
* Restores the metadata definition from the history snapshot.
|
|
2037
|
+
*/
|
|
2038
|
+
async rollback(type, name, version, options) {
|
|
2039
|
+
const dbLoader = this.getDatabaseLoader();
|
|
2040
|
+
if (!dbLoader) {
|
|
2041
|
+
throw new Error("Rollback requires a database loader to be configured");
|
|
2042
|
+
}
|
|
2043
|
+
const targetVersion = await dbLoader.getHistoryRecord(type, name, version);
|
|
2044
|
+
if (!targetVersion) {
|
|
2045
|
+
throw new Error(`Version ${version} not found in history for ${type}/${name}`);
|
|
2046
|
+
}
|
|
2047
|
+
if (!targetVersion.metadata) {
|
|
2048
|
+
throw new Error(`Version ${version} metadata snapshot not available`);
|
|
2049
|
+
}
|
|
2050
|
+
const restoredMetadata = targetVersion.metadata;
|
|
2051
|
+
await dbLoader.registerRollback(
|
|
2052
|
+
type,
|
|
2053
|
+
name,
|
|
2054
|
+
restoredMetadata,
|
|
2055
|
+
version,
|
|
2056
|
+
options?.changeNote,
|
|
2057
|
+
options?.recordedBy
|
|
2058
|
+
);
|
|
2059
|
+
if (!this.registry.has(type)) {
|
|
2060
|
+
this.registry.set(type, /* @__PURE__ */ new Map());
|
|
2061
|
+
}
|
|
2062
|
+
this.registry.get(type).set(name, restoredMetadata);
|
|
2063
|
+
return restoredMetadata;
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Compare two versions of a metadata item.
|
|
2067
|
+
* Returns a diff showing what changed between versions.
|
|
2068
|
+
*/
|
|
2069
|
+
async diff(type, name, version1, version2) {
|
|
2070
|
+
const dbLoader = this.getDatabaseLoader();
|
|
2071
|
+
if (!dbLoader) {
|
|
2072
|
+
throw new Error("Diff requires a database loader to be configured");
|
|
2073
|
+
}
|
|
2074
|
+
const v1 = await dbLoader.getHistoryRecord(type, name, version1);
|
|
2075
|
+
const v2 = await dbLoader.getHistoryRecord(type, name, version2);
|
|
2076
|
+
if (!v1) {
|
|
2077
|
+
throw new Error(`Version ${version1} not found in history for ${type}/${name}`);
|
|
2078
|
+
}
|
|
2079
|
+
if (!v2) {
|
|
2080
|
+
throw new Error(`Version ${version2} not found in history for ${type}/${name}`);
|
|
2081
|
+
}
|
|
2082
|
+
if (!v1.metadata || !v2.metadata) {
|
|
2083
|
+
throw new Error("Version metadata snapshots not available");
|
|
2084
|
+
}
|
|
2085
|
+
const patch = generateSimpleDiff(v1.metadata, v2.metadata);
|
|
2086
|
+
const identical = patch.length === 0;
|
|
2087
|
+
const summary = generateDiffSummary(patch);
|
|
2088
|
+
return {
|
|
2089
|
+
type,
|
|
2090
|
+
name,
|
|
2091
|
+
version1,
|
|
2092
|
+
version2,
|
|
2093
|
+
checksum1: v1.checksum,
|
|
2094
|
+
checksum2: v2.checksum,
|
|
2095
|
+
identical,
|
|
2096
|
+
patch,
|
|
2097
|
+
summary
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
1452
2100
|
};
|
|
1453
2101
|
|
|
1454
2102
|
// src/node-metadata-manager.ts
|
|
@@ -1869,14 +2517,18 @@ var MetadataPlugin = class {
|
|
|
1869
2517
|
watch: this.options.watch
|
|
1870
2518
|
});
|
|
1871
2519
|
ctx.registerService("metadata", this.manager);
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2520
|
+
console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
|
|
2521
|
+
try {
|
|
2522
|
+
ctx.getService("manifest").register({
|
|
2523
|
+
id: "com.objectstack.metadata",
|
|
2524
|
+
name: "Metadata",
|
|
2525
|
+
version: "1.0.0",
|
|
2526
|
+
type: "plugin",
|
|
2527
|
+
namespace: "sys",
|
|
2528
|
+
objects: [SysMetadataObject]
|
|
2529
|
+
});
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
1880
2532
|
ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
|
|
1881
2533
|
mode: "file-system",
|
|
1882
2534
|
features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
|
|
@@ -1909,6 +2561,33 @@ var MetadataPlugin = class {
|
|
|
1909
2561
|
totalItems: totalLoaded,
|
|
1910
2562
|
registeredTypes: sortedTypes.length
|
|
1911
2563
|
});
|
|
2564
|
+
try {
|
|
2565
|
+
const services = ctx.getServices();
|
|
2566
|
+
for (const [serviceName, service] of services) {
|
|
2567
|
+
if (serviceName.startsWith("driver.") && service) {
|
|
2568
|
+
ctx.logger.info("[MetadataPlugin] Bridging driver to MetadataManager for database-backed persistence", {
|
|
2569
|
+
driverService: serviceName
|
|
2570
|
+
});
|
|
2571
|
+
this.manager.setDatabaseDriver(service);
|
|
2572
|
+
break;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
} catch (e) {
|
|
2576
|
+
ctx.logger.debug("[MetadataPlugin] No driver service found \u2014 database metadata persistence not available", {
|
|
2577
|
+
error: e.message
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
try {
|
|
2581
|
+
const realtimeService = ctx.getService("realtime");
|
|
2582
|
+
if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
|
|
2583
|
+
ctx.logger.info("[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing");
|
|
2584
|
+
this.manager.setRealtimeService(realtimeService);
|
|
2585
|
+
}
|
|
2586
|
+
} catch (e) {
|
|
2587
|
+
ctx.logger.debug("[MetadataPlugin] No realtime service found \u2014 metadata events will not be published", {
|
|
2588
|
+
error: e.message
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
1912
2591
|
};
|
|
1913
2592
|
this.options = {
|
|
1914
2593
|
watch: true,
|
|
@@ -1988,6 +2667,18 @@ var MemoryLoader = class {
|
|
|
1988
2667
|
saveTime: 0
|
|
1989
2668
|
};
|
|
1990
2669
|
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Delete a metadata item from memory storage
|
|
2672
|
+
*/
|
|
2673
|
+
async delete(type, name) {
|
|
2674
|
+
const typeStore = this.storage.get(type);
|
|
2675
|
+
if (typeStore) {
|
|
2676
|
+
typeStore.delete(name);
|
|
2677
|
+
if (typeStore.size === 0) {
|
|
2678
|
+
this.storage.delete(type);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
1991
2682
|
};
|
|
1992
2683
|
|
|
1993
2684
|
// src/loaders/remote-loader.ts
|
|
@@ -2087,6 +2778,330 @@ var RemoteLoader = class {
|
|
|
2087
2778
|
}
|
|
2088
2779
|
};
|
|
2089
2780
|
|
|
2781
|
+
// src/routes/history-routes.ts
|
|
2782
|
+
function registerMetadataHistoryRoutes(app, metadataService) {
|
|
2783
|
+
app.get("/api/v1/metadata/:type/:name/history", async (c) => {
|
|
2784
|
+
if (!metadataService.getHistory) {
|
|
2785
|
+
return c.json({ error: "History tracking not enabled" }, 501);
|
|
2786
|
+
}
|
|
2787
|
+
const { type, name } = c.req.param();
|
|
2788
|
+
const query = c.req.query();
|
|
2789
|
+
try {
|
|
2790
|
+
const options = {};
|
|
2791
|
+
if (query.limit !== void 0) {
|
|
2792
|
+
const limit = parseInt(query.limit, 10);
|
|
2793
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
2794
|
+
return c.json({ success: false, error: "limit must be a positive integer" }, 400);
|
|
2795
|
+
}
|
|
2796
|
+
options.limit = limit;
|
|
2797
|
+
}
|
|
2798
|
+
if (query.offset !== void 0) {
|
|
2799
|
+
const offset = parseInt(query.offset, 10);
|
|
2800
|
+
if (!Number.isFinite(offset) || offset < 0) {
|
|
2801
|
+
return c.json({ success: false, error: "offset must be a non-negative integer" }, 400);
|
|
2802
|
+
}
|
|
2803
|
+
options.offset = offset;
|
|
2804
|
+
}
|
|
2805
|
+
if (query.since) options.since = query.since;
|
|
2806
|
+
if (query.until) options.until = query.until;
|
|
2807
|
+
if (query.operationType) options.operationType = query.operationType;
|
|
2808
|
+
if (query.includeMetadata !== void 0) {
|
|
2809
|
+
options.includeMetadata = query.includeMetadata === "true";
|
|
2810
|
+
}
|
|
2811
|
+
const result = await metadataService.getHistory(type, name, options);
|
|
2812
|
+
return c.json({
|
|
2813
|
+
success: true,
|
|
2814
|
+
data: result
|
|
2815
|
+
});
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
return c.json(
|
|
2818
|
+
{
|
|
2819
|
+
success: false,
|
|
2820
|
+
error: error instanceof Error ? error.message : "Failed to retrieve history"
|
|
2821
|
+
},
|
|
2822
|
+
500
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
app.post("/api/v1/metadata/:type/:name/rollback", async (c) => {
|
|
2827
|
+
if (!metadataService.rollback) {
|
|
2828
|
+
return c.json({ error: "Rollback not supported" }, 501);
|
|
2829
|
+
}
|
|
2830
|
+
const { type, name } = c.req.param();
|
|
2831
|
+
try {
|
|
2832
|
+
const body = await c.req.json();
|
|
2833
|
+
const { version, changeNote, recordedBy } = body;
|
|
2834
|
+
if (typeof version !== "number") {
|
|
2835
|
+
return c.json(
|
|
2836
|
+
{
|
|
2837
|
+
success: false,
|
|
2838
|
+
error: "Version number is required"
|
|
2839
|
+
},
|
|
2840
|
+
400
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
const restoredMetadata = await metadataService.rollback(type, name, version, {
|
|
2844
|
+
changeNote,
|
|
2845
|
+
recordedBy
|
|
2846
|
+
});
|
|
2847
|
+
return c.json({
|
|
2848
|
+
success: true,
|
|
2849
|
+
data: {
|
|
2850
|
+
type,
|
|
2851
|
+
name,
|
|
2852
|
+
version,
|
|
2853
|
+
metadata: restoredMetadata
|
|
2854
|
+
}
|
|
2855
|
+
});
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
return c.json(
|
|
2858
|
+
{
|
|
2859
|
+
success: false,
|
|
2860
|
+
error: error instanceof Error ? error.message : "Rollback failed"
|
|
2861
|
+
},
|
|
2862
|
+
500
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
app.get("/api/v1/metadata/:type/:name/diff", async (c) => {
|
|
2867
|
+
if (!metadataService.diff) {
|
|
2868
|
+
return c.json({ error: "Diff not supported" }, 501);
|
|
2869
|
+
}
|
|
2870
|
+
const { type, name } = c.req.param();
|
|
2871
|
+
const query = c.req.query();
|
|
2872
|
+
try {
|
|
2873
|
+
const version1 = parseInt(query.version1, 10);
|
|
2874
|
+
const version2 = parseInt(query.version2, 10);
|
|
2875
|
+
if (isNaN(version1) || isNaN(version2)) {
|
|
2876
|
+
return c.json(
|
|
2877
|
+
{
|
|
2878
|
+
success: false,
|
|
2879
|
+
error: "Both version1 and version2 query parameters are required"
|
|
2880
|
+
},
|
|
2881
|
+
400
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
const diffResult = await metadataService.diff(type, name, version1, version2);
|
|
2885
|
+
return c.json({
|
|
2886
|
+
success: true,
|
|
2887
|
+
data: diffResult
|
|
2888
|
+
});
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
return c.json(
|
|
2891
|
+
{
|
|
2892
|
+
success: false,
|
|
2893
|
+
error: error instanceof Error ? error.message : "Diff failed"
|
|
2894
|
+
},
|
|
2895
|
+
500
|
|
2896
|
+
);
|
|
2897
|
+
}
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// src/utils/history-cleanup.ts
|
|
2902
|
+
var HistoryCleanupManager = class {
|
|
2903
|
+
constructor(policy, dbLoader) {
|
|
2904
|
+
this.policy = policy;
|
|
2905
|
+
this.dbLoader = dbLoader;
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Start automatic cleanup if enabled in the policy.
|
|
2909
|
+
*/
|
|
2910
|
+
start() {
|
|
2911
|
+
if (!this.policy.autoCleanup) {
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
|
|
2915
|
+
void this.runCleanup();
|
|
2916
|
+
this.cleanupTimer = setInterval(() => {
|
|
2917
|
+
void this.runCleanup();
|
|
2918
|
+
}, intervalMs);
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* Stop automatic cleanup.
|
|
2922
|
+
*/
|
|
2923
|
+
stop() {
|
|
2924
|
+
if (this.cleanupTimer) {
|
|
2925
|
+
clearInterval(this.cleanupTimer);
|
|
2926
|
+
this.cleanupTimer = void 0;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Run cleanup based on the retention policy.
|
|
2931
|
+
* Removes history records that exceed the configured limits.
|
|
2932
|
+
*/
|
|
2933
|
+
async runCleanup() {
|
|
2934
|
+
const driver = this.dbLoader.driver;
|
|
2935
|
+
const historyTableName = this.dbLoader.historyTableName;
|
|
2936
|
+
const tenantId = this.dbLoader.tenantId;
|
|
2937
|
+
let deleted = 0;
|
|
2938
|
+
let errors = 0;
|
|
2939
|
+
try {
|
|
2940
|
+
if (this.policy.maxAgeDays) {
|
|
2941
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
2942
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
|
|
2943
|
+
const cutoffISO = cutoffDate.toISOString();
|
|
2944
|
+
const filter = {
|
|
2945
|
+
recorded_at: { $lt: cutoffISO }
|
|
2946
|
+
};
|
|
2947
|
+
if (tenantId) {
|
|
2948
|
+
filter.tenant_id = tenantId;
|
|
2949
|
+
}
|
|
2950
|
+
try {
|
|
2951
|
+
const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
|
|
2952
|
+
deleted += result.deleted;
|
|
2953
|
+
errors += result.errors;
|
|
2954
|
+
} catch {
|
|
2955
|
+
errors++;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
if (this.policy.maxVersions) {
|
|
2959
|
+
try {
|
|
2960
|
+
const metadataIds = await driver.find(historyTableName, {
|
|
2961
|
+
object: historyTableName,
|
|
2962
|
+
where: tenantId ? { tenant_id: tenantId } : {},
|
|
2963
|
+
fields: ["metadata_id"]
|
|
2964
|
+
});
|
|
2965
|
+
const uniqueIds = /* @__PURE__ */ new Set();
|
|
2966
|
+
for (const record of metadataIds) {
|
|
2967
|
+
if (record.metadata_id) {
|
|
2968
|
+
uniqueIds.add(record.metadata_id);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
for (const metadataId of uniqueIds) {
|
|
2972
|
+
const filter = { metadata_id: metadataId };
|
|
2973
|
+
if (tenantId) {
|
|
2974
|
+
filter.tenant_id = tenantId;
|
|
2975
|
+
}
|
|
2976
|
+
try {
|
|
2977
|
+
const historyRecords = await driver.find(historyTableName, {
|
|
2978
|
+
object: historyTableName,
|
|
2979
|
+
where: filter,
|
|
2980
|
+
orderBy: [{ field: "version", order: "desc" }],
|
|
2981
|
+
fields: ["id"]
|
|
2982
|
+
});
|
|
2983
|
+
if (historyRecords.length > this.policy.maxVersions) {
|
|
2984
|
+
const toDelete = historyRecords.slice(this.policy.maxVersions);
|
|
2985
|
+
const ids = toDelete.map((r) => r.id).filter(Boolean);
|
|
2986
|
+
const result = await this.bulkDeleteByIds(driver, historyTableName, ids);
|
|
2987
|
+
deleted += result.deleted;
|
|
2988
|
+
errors += result.errors;
|
|
2989
|
+
}
|
|
2990
|
+
} catch {
|
|
2991
|
+
errors++;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
} catch {
|
|
2995
|
+
errors++;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
} catch (error) {
|
|
2999
|
+
console.error("History cleanup failed:", error);
|
|
3000
|
+
errors++;
|
|
3001
|
+
}
|
|
3002
|
+
return { deleted, errors };
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Delete records matching a filter using the most efficient method available on the driver.
|
|
3006
|
+
*/
|
|
3007
|
+
async bulkDeleteByFilter(driver, table, filter) {
|
|
3008
|
+
const driverAny = driver;
|
|
3009
|
+
if (typeof driverAny.deleteMany === "function") {
|
|
3010
|
+
const count = await driverAny.deleteMany(table, filter);
|
|
3011
|
+
return { deleted: typeof count === "number" ? count : 0, errors: 0 };
|
|
3012
|
+
}
|
|
3013
|
+
const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
|
|
3014
|
+
const ids = records.map((r) => r.id).filter(Boolean);
|
|
3015
|
+
return this.bulkDeleteByIds(driver, table, ids);
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Delete records by IDs using bulkDelete when available, otherwise one-by-one.
|
|
3019
|
+
*/
|
|
3020
|
+
async bulkDeleteByIds(driver, table, ids) {
|
|
3021
|
+
if (ids.length === 0) return { deleted: 0, errors: 0 };
|
|
3022
|
+
const driverAny = driver;
|
|
3023
|
+
if (typeof driverAny.bulkDelete === "function") {
|
|
3024
|
+
const result = await driverAny.bulkDelete(table, ids);
|
|
3025
|
+
return {
|
|
3026
|
+
deleted: typeof result === "number" ? result : ids.length,
|
|
3027
|
+
errors: 0
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
let deleted = 0;
|
|
3031
|
+
let errors = 0;
|
|
3032
|
+
for (const id of ids) {
|
|
3033
|
+
try {
|
|
3034
|
+
await driver.delete(table, id);
|
|
3035
|
+
deleted++;
|
|
3036
|
+
} catch {
|
|
3037
|
+
errors++;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
return { deleted, errors };
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Get cleanup statistics without actually deleting anything.
|
|
3044
|
+
* Useful for previewing what would be cleaned up.
|
|
3045
|
+
*/
|
|
3046
|
+
async getCleanupStats() {
|
|
3047
|
+
const driver = this.dbLoader.driver;
|
|
3048
|
+
const historyTableName = this.dbLoader.historyTableName;
|
|
3049
|
+
const tenantId = this.dbLoader.tenantId;
|
|
3050
|
+
let recordsByAge = 0;
|
|
3051
|
+
let recordsByCount = 0;
|
|
3052
|
+
try {
|
|
3053
|
+
if (this.policy.maxAgeDays) {
|
|
3054
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
3055
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
|
|
3056
|
+
const cutoffISO = cutoffDate.toISOString();
|
|
3057
|
+
const filter = {
|
|
3058
|
+
recorded_at: { $lt: cutoffISO }
|
|
3059
|
+
};
|
|
3060
|
+
if (tenantId) {
|
|
3061
|
+
filter.tenant_id = tenantId;
|
|
3062
|
+
}
|
|
3063
|
+
recordsByAge = await driver.count(historyTableName, {
|
|
3064
|
+
object: historyTableName,
|
|
3065
|
+
where: filter
|
|
3066
|
+
});
|
|
3067
|
+
}
|
|
3068
|
+
if (this.policy.maxVersions) {
|
|
3069
|
+
const metadataIds = await driver.find(historyTableName, {
|
|
3070
|
+
object: historyTableName,
|
|
3071
|
+
where: tenantId ? { tenant_id: tenantId } : {},
|
|
3072
|
+
fields: ["metadata_id"]
|
|
3073
|
+
});
|
|
3074
|
+
const uniqueIds = /* @__PURE__ */ new Set();
|
|
3075
|
+
for (const record of metadataIds) {
|
|
3076
|
+
if (record.metadata_id) {
|
|
3077
|
+
uniqueIds.add(record.metadata_id);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
for (const metadataId of uniqueIds) {
|
|
3081
|
+
const filter = { metadata_id: metadataId };
|
|
3082
|
+
if (tenantId) {
|
|
3083
|
+
filter.tenant_id = tenantId;
|
|
3084
|
+
}
|
|
3085
|
+
const count = await driver.count(historyTableName, {
|
|
3086
|
+
object: historyTableName,
|
|
3087
|
+
where: filter
|
|
3088
|
+
});
|
|
3089
|
+
if (count > this.policy.maxVersions) {
|
|
3090
|
+
recordsByCount += count - this.policy.maxVersions;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
} catch (error) {
|
|
3095
|
+
console.error("Failed to get cleanup stats:", error);
|
|
3096
|
+
}
|
|
3097
|
+
return {
|
|
3098
|
+
recordsByAge,
|
|
3099
|
+
recordsByCount,
|
|
3100
|
+
total: recordsByAge + recordsByCount
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
};
|
|
3104
|
+
|
|
2090
3105
|
// src/migration/index.ts
|
|
2091
3106
|
var migration_exports = {};
|
|
2092
3107
|
__export(migration_exports, {
|
|
@@ -2144,14 +3159,20 @@ var MigrationExecutor = class {
|
|
|
2144
3159
|
};
|
|
2145
3160
|
export {
|
|
2146
3161
|
DatabaseLoader,
|
|
3162
|
+
HistoryCleanupManager,
|
|
2147
3163
|
JSONSerializer,
|
|
2148
3164
|
MemoryLoader,
|
|
2149
3165
|
MetadataManager,
|
|
2150
3166
|
MetadataPlugin,
|
|
2151
3167
|
migration_exports as Migration,
|
|
2152
3168
|
RemoteLoader,
|
|
3169
|
+
SysMetadataHistoryObject,
|
|
2153
3170
|
SysMetadataObject,
|
|
2154
3171
|
TypeScriptSerializer,
|
|
2155
|
-
YAMLSerializer
|
|
3172
|
+
YAMLSerializer,
|
|
3173
|
+
calculateChecksum,
|
|
3174
|
+
generateDiffSummary,
|
|
3175
|
+
generateSimpleDiff,
|
|
3176
|
+
registerMetadataHistoryRoutes
|
|
2156
3177
|
};
|
|
2157
3178
|
//# sourceMappingURL=index.js.map
|