@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/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
- toDelete.push(name);
1307
+ itemsToDelete.push({ type, name });
819
1308
  }
820
1309
  }
821
- for (const name of toDelete) {
822
- typeStore.delete(name);
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
- ctx.registerService("app.com.objectstack.metadata", {
1912
- id: "com.objectstack.metadata",
1913
- name: "Metadata",
1914
- version: "1.0.0",
1915
- type: "plugin",
1916
- namespace: "sys",
1917
- objects: [SysMetadataObject]
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