@objectstack/metadata 4.0.0 → 4.0.2

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") {
@@ -672,12 +1096,18 @@ var MetadataManager = class {
672
1096
  // ==========================================
673
1097
  /**
674
1098
  * Register/save a metadata item by type
1099
+ * Stores in-memory registry and persists to writable loaders (if configured)
675
1100
  */
676
1101
  async register(type, name, data) {
677
1102
  if (!this.registry.has(type)) {
678
1103
  this.registry.set(type, /* @__PURE__ */ new Map());
679
1104
  }
680
1105
  this.registry.get(type).set(name, data);
1106
+ for (const loader of this.loaders.values()) {
1107
+ if (loader.save) {
1108
+ await loader.save(type, name, data);
1109
+ }
1110
+ }
681
1111
  }
682
1112
  /**
683
1113
  * Get a metadata item by type and name.
@@ -728,6 +1158,15 @@ var MetadataManager = class {
728
1158
  this.registry.delete(type);
729
1159
  }
730
1160
  }
1161
+ for (const loader of this.loaders.values()) {
1162
+ if (typeof loader.delete === "function") {
1163
+ try {
1164
+ await loader.delete(type, name);
1165
+ } catch (error) {
1166
+ this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
1167
+ }
1168
+ }
1169
+ }
731
1170
  }
732
1171
  /**
733
1172
  * Check if a metadata item exists
@@ -810,20 +1249,17 @@ var MetadataManager = class {
810
1249
  * Unregister all metadata items from a specific package
811
1250
  */
812
1251
  async unregisterPackage(packageName) {
1252
+ const itemsToDelete = [];
813
1253
  for (const [type, typeStore] of this.registry) {
814
- const toDelete = [];
815
1254
  for (const [name, data] of typeStore) {
816
1255
  const meta = data;
817
1256
  if (meta?.packageId === packageName || meta?.package === packageName) {
818
- toDelete.push(name);
1257
+ itemsToDelete.push({ type, name });
819
1258
  }
820
1259
  }
821
- for (const name of toDelete) {
822
- typeStore.delete(name);
823
- }
824
- if (typeStore.size === 0) {
825
- this.registry.delete(type);
826
- }
1260
+ }
1261
+ for (const { type, name } of itemsToDelete) {
1262
+ await this.unregister(type, name);
827
1263
  }
828
1264
  }
829
1265
  /**
@@ -1488,6 +1924,174 @@ var MetadataManager = class {
1488
1924
  }
1489
1925
  }
1490
1926
  }
1927
+ // ==========================================
1928
+ // Version History & Rollback
1929
+ // ==========================================
1930
+ /**
1931
+ * Get the database loader for history operations.
1932
+ * Returns undefined if no database loader is configured.
1933
+ */
1934
+ getDatabaseLoader() {
1935
+ const dbLoader = this.loaders.get("database");
1936
+ if (dbLoader && dbLoader instanceof DatabaseLoader) {
1937
+ return dbLoader;
1938
+ }
1939
+ return void 0;
1940
+ }
1941
+ /**
1942
+ * Get version history for a metadata item.
1943
+ * Returns a timeline of all changes made to the item.
1944
+ */
1945
+ async getHistory(type, name, options) {
1946
+ const dbLoader = this.getDatabaseLoader();
1947
+ if (!dbLoader) {
1948
+ throw new Error("History tracking requires a database loader to be configured");
1949
+ }
1950
+ const driver = dbLoader.driver;
1951
+ const tableName = dbLoader.tableName;
1952
+ const historyTableName = dbLoader.historyTableName;
1953
+ const tenantId = dbLoader.tenantId;
1954
+ const filter = { type, name };
1955
+ if (tenantId) {
1956
+ filter.tenant_id = tenantId;
1957
+ }
1958
+ const metadataRecord = await driver.findOne(tableName, {
1959
+ object: tableName,
1960
+ where: filter
1961
+ });
1962
+ if (!metadataRecord) {
1963
+ return {
1964
+ records: [],
1965
+ total: 0,
1966
+ hasMore: false
1967
+ };
1968
+ }
1969
+ const historyFilter = {
1970
+ metadata_id: metadataRecord.id
1971
+ };
1972
+ if (tenantId) {
1973
+ historyFilter.tenant_id = tenantId;
1974
+ }
1975
+ if (options?.operationType) {
1976
+ historyFilter.operation_type = options.operationType;
1977
+ }
1978
+ if (options?.since) {
1979
+ historyFilter.recorded_at = { $gte: options.since };
1980
+ }
1981
+ if (options?.until) {
1982
+ if (historyFilter.recorded_at) {
1983
+ historyFilter.recorded_at.$lte = options.until;
1984
+ } else {
1985
+ historyFilter.recorded_at = { $lte: options.until };
1986
+ }
1987
+ }
1988
+ const limit = options?.limit ?? 50;
1989
+ const offset = options?.offset ?? 0;
1990
+ const historyRecords = await driver.find(historyTableName, {
1991
+ object: historyTableName,
1992
+ where: historyFilter,
1993
+ orderBy: [{ field: "recorded_at", order: "desc" }],
1994
+ limit: limit + 1,
1995
+ // Fetch one extra to determine hasMore
1996
+ offset
1997
+ });
1998
+ const hasMore = historyRecords.length > limit;
1999
+ const records = historyRecords.slice(0, limit);
2000
+ const total = await driver.count(historyTableName, {
2001
+ object: historyTableName,
2002
+ where: historyFilter
2003
+ });
2004
+ const includeMetadata = options?.includeMetadata !== false;
2005
+ const historyResult = records.map((row) => {
2006
+ const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
2007
+ return {
2008
+ id: row.id,
2009
+ metadataId: row.metadata_id,
2010
+ name: row.name,
2011
+ type: row.type,
2012
+ version: row.version,
2013
+ operationType: row.operation_type,
2014
+ metadata: includeMetadata ? parsedMetadata : null,
2015
+ checksum: row.checksum,
2016
+ previousChecksum: row.previous_checksum,
2017
+ changeNote: row.change_note,
2018
+ tenantId: row.tenant_id,
2019
+ recordedBy: row.recorded_by,
2020
+ recordedAt: row.recorded_at
2021
+ };
2022
+ });
2023
+ return {
2024
+ records: historyResult,
2025
+ total,
2026
+ hasMore
2027
+ };
2028
+ }
2029
+ /**
2030
+ * Rollback a metadata item to a specific version.
2031
+ * Restores the metadata definition from the history snapshot.
2032
+ */
2033
+ async rollback(type, name, version, options) {
2034
+ const dbLoader = this.getDatabaseLoader();
2035
+ if (!dbLoader) {
2036
+ throw new Error("Rollback requires a database loader to be configured");
2037
+ }
2038
+ const targetVersion = await dbLoader.getHistoryRecord(type, name, version);
2039
+ if (!targetVersion) {
2040
+ throw new Error(`Version ${version} not found in history for ${type}/${name}`);
2041
+ }
2042
+ if (!targetVersion.metadata) {
2043
+ throw new Error(`Version ${version} metadata snapshot not available`);
2044
+ }
2045
+ const restoredMetadata = targetVersion.metadata;
2046
+ await dbLoader.registerRollback(
2047
+ type,
2048
+ name,
2049
+ restoredMetadata,
2050
+ version,
2051
+ options?.changeNote,
2052
+ options?.recordedBy
2053
+ );
2054
+ if (!this.registry.has(type)) {
2055
+ this.registry.set(type, /* @__PURE__ */ new Map());
2056
+ }
2057
+ this.registry.get(type).set(name, restoredMetadata);
2058
+ return restoredMetadata;
2059
+ }
2060
+ /**
2061
+ * Compare two versions of a metadata item.
2062
+ * Returns a diff showing what changed between versions.
2063
+ */
2064
+ async diff(type, name, version1, version2) {
2065
+ const dbLoader = this.getDatabaseLoader();
2066
+ if (!dbLoader) {
2067
+ throw new Error("Diff requires a database loader to be configured");
2068
+ }
2069
+ const v1 = await dbLoader.getHistoryRecord(type, name, version1);
2070
+ const v2 = await dbLoader.getHistoryRecord(type, name, version2);
2071
+ if (!v1) {
2072
+ throw new Error(`Version ${version1} not found in history for ${type}/${name}`);
2073
+ }
2074
+ if (!v2) {
2075
+ throw new Error(`Version ${version2} not found in history for ${type}/${name}`);
2076
+ }
2077
+ if (!v1.metadata || !v2.metadata) {
2078
+ throw new Error("Version metadata snapshots not available");
2079
+ }
2080
+ const patch = generateSimpleDiff(v1.metadata, v2.metadata);
2081
+ const identical = patch.length === 0;
2082
+ const summary = generateDiffSummary(patch);
2083
+ return {
2084
+ type,
2085
+ name,
2086
+ version1,
2087
+ version2,
2088
+ checksum1: v1.checksum,
2089
+ checksum2: v2.checksum,
2090
+ identical,
2091
+ patch,
2092
+ summary
2093
+ };
2094
+ }
1491
2095
  };
1492
2096
 
1493
2097
  // src/node-metadata-manager.ts
@@ -1908,14 +2512,17 @@ var MetadataPlugin = class {
1908
2512
  watch: this.options.watch
1909
2513
  });
1910
2514
  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
- });
2515
+ try {
2516
+ ctx.getService("manifest").register({
2517
+ id: "com.objectstack.metadata",
2518
+ name: "Metadata",
2519
+ version: "1.0.0",
2520
+ type: "plugin",
2521
+ namespace: "sys",
2522
+ objects: [SysMetadataObject]
2523
+ });
2524
+ } catch {
2525
+ }
1919
2526
  ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
1920
2527
  mode: "file-system",
1921
2528
  features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
@@ -2027,6 +2634,18 @@ var MemoryLoader = class {
2027
2634
  saveTime: 0
2028
2635
  };
2029
2636
  }
2637
+ /**
2638
+ * Delete a metadata item from memory storage
2639
+ */
2640
+ async delete(type, name) {
2641
+ const typeStore = this.storage.get(type);
2642
+ if (typeStore) {
2643
+ typeStore.delete(name);
2644
+ if (typeStore.size === 0) {
2645
+ this.storage.delete(type);
2646
+ }
2647
+ }
2648
+ }
2030
2649
  };
2031
2650
 
2032
2651
  // src/loaders/remote-loader.ts
@@ -2126,6 +2745,330 @@ var RemoteLoader = class {
2126
2745
  }
2127
2746
  };
2128
2747
 
2748
+ // src/routes/history-routes.ts
2749
+ function registerMetadataHistoryRoutes(app, metadataService) {
2750
+ app.get("/api/v1/metadata/:type/:name/history", async (c) => {
2751
+ if (!metadataService.getHistory) {
2752
+ return c.json({ error: "History tracking not enabled" }, 501);
2753
+ }
2754
+ const { type, name } = c.req.param();
2755
+ const query = c.req.query();
2756
+ try {
2757
+ const options = {};
2758
+ if (query.limit !== void 0) {
2759
+ const limit = parseInt(query.limit, 10);
2760
+ if (!Number.isFinite(limit) || limit < 1) {
2761
+ return c.json({ success: false, error: "limit must be a positive integer" }, 400);
2762
+ }
2763
+ options.limit = limit;
2764
+ }
2765
+ if (query.offset !== void 0) {
2766
+ const offset = parseInt(query.offset, 10);
2767
+ if (!Number.isFinite(offset) || offset < 0) {
2768
+ return c.json({ success: false, error: "offset must be a non-negative integer" }, 400);
2769
+ }
2770
+ options.offset = offset;
2771
+ }
2772
+ if (query.since) options.since = query.since;
2773
+ if (query.until) options.until = query.until;
2774
+ if (query.operationType) options.operationType = query.operationType;
2775
+ if (query.includeMetadata !== void 0) {
2776
+ options.includeMetadata = query.includeMetadata === "true";
2777
+ }
2778
+ const result = await metadataService.getHistory(type, name, options);
2779
+ return c.json({
2780
+ success: true,
2781
+ data: result
2782
+ });
2783
+ } catch (error) {
2784
+ return c.json(
2785
+ {
2786
+ success: false,
2787
+ error: error instanceof Error ? error.message : "Failed to retrieve history"
2788
+ },
2789
+ 500
2790
+ );
2791
+ }
2792
+ });
2793
+ app.post("/api/v1/metadata/:type/:name/rollback", async (c) => {
2794
+ if (!metadataService.rollback) {
2795
+ return c.json({ error: "Rollback not supported" }, 501);
2796
+ }
2797
+ const { type, name } = c.req.param();
2798
+ try {
2799
+ const body = await c.req.json();
2800
+ const { version, changeNote, recordedBy } = body;
2801
+ if (typeof version !== "number") {
2802
+ return c.json(
2803
+ {
2804
+ success: false,
2805
+ error: "Version number is required"
2806
+ },
2807
+ 400
2808
+ );
2809
+ }
2810
+ const restoredMetadata = await metadataService.rollback(type, name, version, {
2811
+ changeNote,
2812
+ recordedBy
2813
+ });
2814
+ return c.json({
2815
+ success: true,
2816
+ data: {
2817
+ type,
2818
+ name,
2819
+ version,
2820
+ metadata: restoredMetadata
2821
+ }
2822
+ });
2823
+ } catch (error) {
2824
+ return c.json(
2825
+ {
2826
+ success: false,
2827
+ error: error instanceof Error ? error.message : "Rollback failed"
2828
+ },
2829
+ 500
2830
+ );
2831
+ }
2832
+ });
2833
+ app.get("/api/v1/metadata/:type/:name/diff", async (c) => {
2834
+ if (!metadataService.diff) {
2835
+ return c.json({ error: "Diff not supported" }, 501);
2836
+ }
2837
+ const { type, name } = c.req.param();
2838
+ const query = c.req.query();
2839
+ try {
2840
+ const version1 = parseInt(query.version1, 10);
2841
+ const version2 = parseInt(query.version2, 10);
2842
+ if (isNaN(version1) || isNaN(version2)) {
2843
+ return c.json(
2844
+ {
2845
+ success: false,
2846
+ error: "Both version1 and version2 query parameters are required"
2847
+ },
2848
+ 400
2849
+ );
2850
+ }
2851
+ const diffResult = await metadataService.diff(type, name, version1, version2);
2852
+ return c.json({
2853
+ success: true,
2854
+ data: diffResult
2855
+ });
2856
+ } catch (error) {
2857
+ return c.json(
2858
+ {
2859
+ success: false,
2860
+ error: error instanceof Error ? error.message : "Diff failed"
2861
+ },
2862
+ 500
2863
+ );
2864
+ }
2865
+ });
2866
+ }
2867
+
2868
+ // src/utils/history-cleanup.ts
2869
+ var HistoryCleanupManager = class {
2870
+ constructor(policy, dbLoader) {
2871
+ this.policy = policy;
2872
+ this.dbLoader = dbLoader;
2873
+ }
2874
+ /**
2875
+ * Start automatic cleanup if enabled in the policy.
2876
+ */
2877
+ start() {
2878
+ if (!this.policy.autoCleanup) {
2879
+ return;
2880
+ }
2881
+ const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
2882
+ void this.runCleanup();
2883
+ this.cleanupTimer = setInterval(() => {
2884
+ void this.runCleanup();
2885
+ }, intervalMs);
2886
+ }
2887
+ /**
2888
+ * Stop automatic cleanup.
2889
+ */
2890
+ stop() {
2891
+ if (this.cleanupTimer) {
2892
+ clearInterval(this.cleanupTimer);
2893
+ this.cleanupTimer = void 0;
2894
+ }
2895
+ }
2896
+ /**
2897
+ * Run cleanup based on the retention policy.
2898
+ * Removes history records that exceed the configured limits.
2899
+ */
2900
+ async runCleanup() {
2901
+ const driver = this.dbLoader.driver;
2902
+ const historyTableName = this.dbLoader.historyTableName;
2903
+ const tenantId = this.dbLoader.tenantId;
2904
+ let deleted = 0;
2905
+ let errors = 0;
2906
+ try {
2907
+ if (this.policy.maxAgeDays) {
2908
+ const cutoffDate = /* @__PURE__ */ new Date();
2909
+ cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
2910
+ const cutoffISO = cutoffDate.toISOString();
2911
+ const filter = {
2912
+ recorded_at: { $lt: cutoffISO }
2913
+ };
2914
+ if (tenantId) {
2915
+ filter.tenant_id = tenantId;
2916
+ }
2917
+ try {
2918
+ const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
2919
+ deleted += result.deleted;
2920
+ errors += result.errors;
2921
+ } catch {
2922
+ errors++;
2923
+ }
2924
+ }
2925
+ if (this.policy.maxVersions) {
2926
+ try {
2927
+ const metadataIds = await driver.find(historyTableName, {
2928
+ object: historyTableName,
2929
+ where: tenantId ? { tenant_id: tenantId } : {},
2930
+ fields: ["metadata_id"]
2931
+ });
2932
+ const uniqueIds = /* @__PURE__ */ new Set();
2933
+ for (const record of metadataIds) {
2934
+ if (record.metadata_id) {
2935
+ uniqueIds.add(record.metadata_id);
2936
+ }
2937
+ }
2938
+ for (const metadataId of uniqueIds) {
2939
+ const filter = { metadata_id: metadataId };
2940
+ if (tenantId) {
2941
+ filter.tenant_id = tenantId;
2942
+ }
2943
+ try {
2944
+ const historyRecords = await driver.find(historyTableName, {
2945
+ object: historyTableName,
2946
+ where: filter,
2947
+ orderBy: [{ field: "version", order: "desc" }],
2948
+ fields: ["id"]
2949
+ });
2950
+ if (historyRecords.length > this.policy.maxVersions) {
2951
+ const toDelete = historyRecords.slice(this.policy.maxVersions);
2952
+ const ids = toDelete.map((r) => r.id).filter(Boolean);
2953
+ const result = await this.bulkDeleteByIds(driver, historyTableName, ids);
2954
+ deleted += result.deleted;
2955
+ errors += result.errors;
2956
+ }
2957
+ } catch {
2958
+ errors++;
2959
+ }
2960
+ }
2961
+ } catch {
2962
+ errors++;
2963
+ }
2964
+ }
2965
+ } catch (error) {
2966
+ console.error("History cleanup failed:", error);
2967
+ errors++;
2968
+ }
2969
+ return { deleted, errors };
2970
+ }
2971
+ /**
2972
+ * Delete records matching a filter using the most efficient method available on the driver.
2973
+ */
2974
+ async bulkDeleteByFilter(driver, table, filter) {
2975
+ const driverAny = driver;
2976
+ if (typeof driverAny.deleteMany === "function") {
2977
+ const count = await driverAny.deleteMany(table, filter);
2978
+ return { deleted: typeof count === "number" ? count : 0, errors: 0 };
2979
+ }
2980
+ const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
2981
+ const ids = records.map((r) => r.id).filter(Boolean);
2982
+ return this.bulkDeleteByIds(driver, table, ids);
2983
+ }
2984
+ /**
2985
+ * Delete records by IDs using bulkDelete when available, otherwise one-by-one.
2986
+ */
2987
+ async bulkDeleteByIds(driver, table, ids) {
2988
+ if (ids.length === 0) return { deleted: 0, errors: 0 };
2989
+ const driverAny = driver;
2990
+ if (typeof driverAny.bulkDelete === "function") {
2991
+ const result = await driverAny.bulkDelete(table, ids);
2992
+ return {
2993
+ deleted: typeof result === "number" ? result : ids.length,
2994
+ errors: 0
2995
+ };
2996
+ }
2997
+ let deleted = 0;
2998
+ let errors = 0;
2999
+ for (const id of ids) {
3000
+ try {
3001
+ await driver.delete(table, id);
3002
+ deleted++;
3003
+ } catch {
3004
+ errors++;
3005
+ }
3006
+ }
3007
+ return { deleted, errors };
3008
+ }
3009
+ /**
3010
+ * Get cleanup statistics without actually deleting anything.
3011
+ * Useful for previewing what would be cleaned up.
3012
+ */
3013
+ async getCleanupStats() {
3014
+ const driver = this.dbLoader.driver;
3015
+ const historyTableName = this.dbLoader.historyTableName;
3016
+ const tenantId = this.dbLoader.tenantId;
3017
+ let recordsByAge = 0;
3018
+ let recordsByCount = 0;
3019
+ try {
3020
+ if (this.policy.maxAgeDays) {
3021
+ const cutoffDate = /* @__PURE__ */ new Date();
3022
+ cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
3023
+ const cutoffISO = cutoffDate.toISOString();
3024
+ const filter = {
3025
+ recorded_at: { $lt: cutoffISO }
3026
+ };
3027
+ if (tenantId) {
3028
+ filter.tenant_id = tenantId;
3029
+ }
3030
+ recordsByAge = await driver.count(historyTableName, {
3031
+ object: historyTableName,
3032
+ where: filter
3033
+ });
3034
+ }
3035
+ if (this.policy.maxVersions) {
3036
+ const metadataIds = await driver.find(historyTableName, {
3037
+ object: historyTableName,
3038
+ where: tenantId ? { tenant_id: tenantId } : {},
3039
+ fields: ["metadata_id"]
3040
+ });
3041
+ const uniqueIds = /* @__PURE__ */ new Set();
3042
+ for (const record of metadataIds) {
3043
+ if (record.metadata_id) {
3044
+ uniqueIds.add(record.metadata_id);
3045
+ }
3046
+ }
3047
+ for (const metadataId of uniqueIds) {
3048
+ const filter = { metadata_id: metadataId };
3049
+ if (tenantId) {
3050
+ filter.tenant_id = tenantId;
3051
+ }
3052
+ const count = await driver.count(historyTableName, {
3053
+ object: historyTableName,
3054
+ where: filter
3055
+ });
3056
+ if (count > this.policy.maxVersions) {
3057
+ recordsByCount += count - this.policy.maxVersions;
3058
+ }
3059
+ }
3060
+ }
3061
+ } catch (error) {
3062
+ console.error("Failed to get cleanup stats:", error);
3063
+ }
3064
+ return {
3065
+ recordsByAge,
3066
+ recordsByCount,
3067
+ total: recordsByAge + recordsByCount
3068
+ };
3069
+ }
3070
+ };
3071
+
2129
3072
  // src/migration/index.ts
2130
3073
  var migration_exports = {};
2131
3074
  __export(migration_exports, {
@@ -2184,14 +3127,20 @@ var MigrationExecutor = class {
2184
3127
  // Annotate the CommonJS export names for ESM import in node:
2185
3128
  0 && (module.exports = {
2186
3129
  DatabaseLoader,
3130
+ HistoryCleanupManager,
2187
3131
  JSONSerializer,
2188
3132
  MemoryLoader,
2189
3133
  MetadataManager,
2190
3134
  MetadataPlugin,
2191
3135
  Migration,
2192
3136
  RemoteLoader,
3137
+ SysMetadataHistoryObject,
2193
3138
  SysMetadataObject,
2194
3139
  TypeScriptSerializer,
2195
- YAMLSerializer
3140
+ YAMLSerializer,
3141
+ calculateChecksum,
3142
+ generateDiffSummary,
3143
+ generateSimpleDiff,
3144
+ registerMetadataHistoryRoutes
2196
3145
  });
2197
3146
  //# sourceMappingURL=index.cjs.map