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