@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.js CHANGED
@@ -326,6 +326,224 @@ var SysMetadataObject = ObjectSchema.create({
326
326
  }
327
327
  });
328
328
 
329
+ // src/objects/sys-metadata-history.object.ts
330
+ import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
331
+ var SysMetadataHistoryObject = ObjectSchema2.create({
332
+ namespace: "sys",
333
+ name: "metadata_history",
334
+ label: "Metadata History",
335
+ pluralLabel: "Metadata History",
336
+ icon: "history",
337
+ isSystem: true,
338
+ description: "Version history and audit trail for metadata changes",
339
+ fields: {
340
+ /** Primary Key (UUID) */
341
+ id: Field2.text({
342
+ label: "ID",
343
+ required: true,
344
+ readonly: true
345
+ }),
346
+ /** Foreign key to sys_metadata.id */
347
+ metadata_id: Field2.text({
348
+ label: "Metadata ID",
349
+ required: true,
350
+ readonly: true,
351
+ maxLength: 255
352
+ }),
353
+ /** Machine name (denormalized for easier querying) */
354
+ name: Field2.text({
355
+ label: "Name",
356
+ required: true,
357
+ searchable: true,
358
+ readonly: true,
359
+ maxLength: 255
360
+ }),
361
+ /** Metadata type (denormalized for easier querying) */
362
+ type: Field2.text({
363
+ label: "Metadata Type",
364
+ required: true,
365
+ searchable: true,
366
+ readonly: true,
367
+ maxLength: 100
368
+ }),
369
+ /** Version number at this snapshot */
370
+ version: Field2.number({
371
+ label: "Version",
372
+ required: true,
373
+ readonly: true
374
+ }),
375
+ /** Type of operation that created this history entry */
376
+ operation_type: Field2.select(["create", "update", "publish", "revert", "delete"], {
377
+ label: "Operation Type",
378
+ required: true,
379
+ readonly: true
380
+ }),
381
+ /** Historical metadata snapshot (JSON payload) */
382
+ metadata: Field2.textarea({
383
+ label: "Metadata",
384
+ required: true,
385
+ readonly: true,
386
+ description: "JSON-serialized metadata snapshot at this version"
387
+ }),
388
+ /** SHA-256 checksum of metadata content */
389
+ checksum: Field2.text({
390
+ label: "Checksum",
391
+ required: true,
392
+ readonly: true,
393
+ maxLength: 64
394
+ }),
395
+ /** Checksum of the previous version */
396
+ previous_checksum: Field2.text({
397
+ label: "Previous Checksum",
398
+ required: false,
399
+ readonly: true,
400
+ maxLength: 64
401
+ }),
402
+ /** Human-readable description of changes */
403
+ change_note: Field2.textarea({
404
+ label: "Change Note",
405
+ required: false,
406
+ readonly: true,
407
+ description: "Description of what changed in this version"
408
+ }),
409
+ /** Tenant ID for multi-tenant isolation */
410
+ tenant_id: Field2.text({
411
+ label: "Tenant ID",
412
+ required: false,
413
+ readonly: true,
414
+ maxLength: 255
415
+ }),
416
+ /** User who made this change */
417
+ recorded_by: Field2.text({
418
+ label: "Recorded By",
419
+ required: false,
420
+ readonly: true,
421
+ maxLength: 255
422
+ }),
423
+ /** When was this version recorded */
424
+ recorded_at: Field2.datetime({
425
+ label: "Recorded At",
426
+ required: true,
427
+ readonly: true
428
+ })
429
+ },
430
+ indexes: [
431
+ { fields: ["metadata_id", "version"], unique: true },
432
+ { fields: ["metadata_id", "recorded_at"] },
433
+ { fields: ["type", "name"] },
434
+ { fields: ["recorded_at"] },
435
+ { fields: ["operation_type"] },
436
+ { fields: ["tenant_id"] }
437
+ ],
438
+ enable: {
439
+ trackHistory: false,
440
+ // Don't track history of history records
441
+ searchable: false,
442
+ apiEnabled: true,
443
+ apiMethods: ["get", "list"],
444
+ // Read-only via API
445
+ trash: false
446
+ }
447
+ });
448
+
449
+ // src/utils/metadata-history-utils.ts
450
+ async function calculateChecksum(metadata) {
451
+ const normalized = normalizeJSON(metadata);
452
+ const jsonString = JSON.stringify(normalized);
453
+ if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) {
454
+ const encoder = new TextEncoder();
455
+ const data = encoder.encode(jsonString);
456
+ const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", data);
457
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
458
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
459
+ }
460
+ return simpleHash(jsonString);
461
+ }
462
+ function normalizeJSON(value) {
463
+ if (value === null || value === void 0) {
464
+ return value;
465
+ }
466
+ if (Array.isArray(value)) {
467
+ return value.map(normalizeJSON);
468
+ }
469
+ if (typeof value === "object") {
470
+ const sorted = {};
471
+ const keys = Object.keys(value).sort();
472
+ for (const key of keys) {
473
+ sorted[key] = normalizeJSON(value[key]);
474
+ }
475
+ return sorted;
476
+ }
477
+ return value;
478
+ }
479
+ function simpleHash(str) {
480
+ let hash = 5381;
481
+ for (let i = 0; i < str.length; i++) {
482
+ hash = (hash << 5) + hash + str.charCodeAt(i);
483
+ hash = hash & hash;
484
+ }
485
+ const hexHash = Math.abs(hash).toString(16);
486
+ return hexHash.padStart(64, "0");
487
+ }
488
+ function generateSimpleDiff(oldObj, newObj, path3 = "") {
489
+ const changes = [];
490
+ if (typeof oldObj !== "object" || oldObj === null || typeof newObj !== "object" || newObj === null) {
491
+ if (oldObj !== newObj) {
492
+ changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
493
+ }
494
+ return changes;
495
+ }
496
+ if (Array.isArray(oldObj) || Array.isArray(newObj)) {
497
+ if (!Array.isArray(oldObj) || !Array.isArray(newObj) || oldObj.length !== newObj.length) {
498
+ changes.push({ op: "replace", path: path3 || "/", value: newObj, oldValue: oldObj });
499
+ } else {
500
+ for (let i = 0; i < oldObj.length; i++) {
501
+ const subPath = `${path3}/${i}`;
502
+ changes.push(...generateSimpleDiff(oldObj[i], newObj[i], subPath));
503
+ }
504
+ }
505
+ return changes;
506
+ }
507
+ const oldKeys = new Set(Object.keys(oldObj));
508
+ const newKeys = new Set(Object.keys(newObj));
509
+ for (const key of newKeys) {
510
+ if (!oldKeys.has(key)) {
511
+ const subPath = path3 ? `${path3}/${key}` : `/${key}`;
512
+ changes.push({ op: "add", path: subPath, value: newObj[key] });
513
+ }
514
+ }
515
+ for (const key of oldKeys) {
516
+ if (!newKeys.has(key)) {
517
+ const subPath = path3 ? `${path3}/${key}` : `/${key}`;
518
+ changes.push({ op: "remove", path: subPath, oldValue: oldObj[key] });
519
+ }
520
+ }
521
+ for (const key of oldKeys) {
522
+ if (newKeys.has(key)) {
523
+ const subPath = path3 ? `${path3}/${key}` : `/${key}`;
524
+ changes.push(...generateSimpleDiff(
525
+ oldObj[key],
526
+ newObj[key],
527
+ subPath
528
+ ));
529
+ }
530
+ }
531
+ return changes;
532
+ }
533
+ function generateDiffSummary(diff) {
534
+ if (diff.length === 0) {
535
+ return "No changes";
536
+ }
537
+ const summary = [];
538
+ const addCount = diff.filter((d) => d.op === "add").length;
539
+ const removeCount = diff.filter((d) => d.op === "remove").length;
540
+ const replaceCount = diff.filter((d) => d.op === "replace").length;
541
+ if (addCount > 0) summary.push(`${addCount} field${addCount > 1 ? "s" : ""} added`);
542
+ if (removeCount > 0) summary.push(`${removeCount} field${removeCount > 1 ? "s" : ""} removed`);
543
+ if (replaceCount > 0) summary.push(`${replaceCount} field${replaceCount > 1 ? "s" : ""} modified`);
544
+ return summary.join(", ");
545
+ }
546
+
329
547
  // src/loaders/database-loader.ts
330
548
  var DatabaseLoader = class {
331
549
  constructor(options) {
@@ -340,9 +558,12 @@ var DatabaseLoader = class {
340
558
  }
341
559
  };
342
560
  this.schemaReady = false;
561
+ this.historySchemaReady = false;
343
562
  this.driver = options.driver;
344
563
  this.tableName = options.tableName ?? "sys_metadata";
564
+ this.historyTableName = options.historyTableName ?? "sys_metadata_history";
345
565
  this.tenantId = options.tenantId;
566
+ this.trackHistory = options.trackHistory !== false;
346
567
  }
347
568
  /**
348
569
  * Ensure the metadata table exists.
@@ -361,6 +582,22 @@ var DatabaseLoader = class {
361
582
  this.schemaReady = true;
362
583
  }
363
584
  }
585
+ /**
586
+ * Ensure the history table exists.
587
+ * Uses IDataDriver.syncSchema with the SysMetadataHistoryObject definition.
588
+ */
589
+ async ensureHistorySchema() {
590
+ if (!this.trackHistory || this.historySchemaReady) return;
591
+ try {
592
+ await this.driver.syncSchema(this.historyTableName, {
593
+ ...SysMetadataHistoryObject,
594
+ name: this.historyTableName
595
+ });
596
+ this.historySchemaReady = true;
597
+ } catch (error) {
598
+ console.error("Failed to ensure history schema, will retry on next operation:", error);
599
+ }
600
+ }
364
601
  /**
365
602
  * Build base filter conditions for queries.
366
603
  * Always includes tenantId when configured.
@@ -375,6 +612,64 @@ var DatabaseLoader = class {
375
612
  }
376
613
  return filter;
377
614
  }
615
+ /**
616
+ * Create a history record for a metadata change.
617
+ *
618
+ * @param metadataId - The metadata record ID
619
+ * @param type - Metadata type
620
+ * @param name - Metadata name
621
+ * @param version - Version number
622
+ * @param metadata - The metadata payload
623
+ * @param operationType - Type of operation
624
+ * @param previousChecksum - Checksum of previous version (if any)
625
+ * @param changeNote - Optional change description
626
+ * @param recordedBy - Optional user who made the change
627
+ */
628
+ async createHistoryRecord(metadataId, type, name, version, metadata, operationType, previousChecksum, changeNote, recordedBy) {
629
+ if (!this.trackHistory) return;
630
+ await this.ensureHistorySchema();
631
+ const now = (/* @__PURE__ */ new Date()).toISOString();
632
+ const checksum = await calculateChecksum(metadata);
633
+ if (previousChecksum && checksum === previousChecksum && operationType === "update") {
634
+ return;
635
+ }
636
+ const historyId = generateId();
637
+ const metadataJson = JSON.stringify(metadata);
638
+ const historyRecord = {
639
+ id: historyId,
640
+ metadataId,
641
+ name,
642
+ type,
643
+ version,
644
+ operationType,
645
+ metadata: metadataJson,
646
+ checksum,
647
+ previousChecksum,
648
+ changeNote,
649
+ recordedBy,
650
+ recordedAt: now,
651
+ ...this.tenantId ? { tenantId: this.tenantId } : {}
652
+ };
653
+ try {
654
+ await this.driver.create(this.historyTableName, {
655
+ id: historyRecord.id,
656
+ metadata_id: historyRecord.metadataId,
657
+ name: historyRecord.name,
658
+ type: historyRecord.type,
659
+ version: historyRecord.version,
660
+ operation_type: historyRecord.operationType,
661
+ metadata: historyRecord.metadata,
662
+ checksum: historyRecord.checksum,
663
+ previous_checksum: historyRecord.previousChecksum,
664
+ change_note: historyRecord.changeNote,
665
+ recorded_by: historyRecord.recordedBy,
666
+ recorded_at: historyRecord.recordedAt,
667
+ ...this.tenantId ? { tenant_id: this.tenantId } : {}
668
+ });
669
+ } catch (error) {
670
+ console.error(`Failed to create history record for ${type}/${name}:`, error);
671
+ }
672
+ }
378
673
  /**
379
674
  * Convert a database row to a metadata payload.
380
675
  * Parses the JSON `metadata` column back into an object.
@@ -502,24 +797,124 @@ var DatabaseLoader = class {
502
797
  return [];
503
798
  }
504
799
  }
800
+ /**
801
+ * Fetch a single history snapshot by (type, name, version).
802
+ * Returns null when the record does not exist.
803
+ */
804
+ async getHistoryRecord(type, name, version) {
805
+ if (!this.trackHistory) return null;
806
+ await this.ensureHistorySchema();
807
+ const metadataRow = await this.driver.findOne(this.tableName, {
808
+ object: this.tableName,
809
+ where: this.baseFilter(type, name)
810
+ });
811
+ if (!metadataRow) return null;
812
+ const filter = {
813
+ metadata_id: metadataRow.id,
814
+ version
815
+ };
816
+ if (this.tenantId) {
817
+ filter.tenant_id = this.tenantId;
818
+ }
819
+ const row = await this.driver.findOne(this.historyTableName, {
820
+ object: this.historyTableName,
821
+ where: filter
822
+ });
823
+ if (!row) return null;
824
+ return {
825
+ id: row.id,
826
+ metadataId: row.metadata_id,
827
+ name: row.name,
828
+ type: row.type,
829
+ version: row.version,
830
+ operationType: row.operation_type,
831
+ metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
832
+ checksum: row.checksum,
833
+ previousChecksum: row.previous_checksum,
834
+ changeNote: row.change_note,
835
+ tenantId: row.tenant_id,
836
+ recordedBy: row.recorded_by,
837
+ recordedAt: row.recorded_at
838
+ };
839
+ }
840
+ /**
841
+ * Perform a rollback: persist `restoredData` as the new current state and record a
842
+ * single 'revert' history entry (instead of the usual 'update' entry that `save()`
843
+ * would produce). This avoids the duplicate-version problem that arises when
844
+ * `register()` → `save()` writes an 'update' entry followed by an additional
845
+ * 'revert' entry for the same version number.
846
+ */
847
+ async registerRollback(type, name, restoredData, targetVersion, changeNote, recordedBy) {
848
+ await this.ensureSchema();
849
+ const now = (/* @__PURE__ */ new Date()).toISOString();
850
+ const metadataJson = JSON.stringify(restoredData);
851
+ const newChecksum = await calculateChecksum(restoredData);
852
+ const existing = await this.driver.findOne(this.tableName, {
853
+ object: this.tableName,
854
+ where: this.baseFilter(type, name)
855
+ });
856
+ if (!existing) {
857
+ throw new Error(`Metadata ${type}/${name} not found for rollback`);
858
+ }
859
+ const previousChecksum = existing.checksum;
860
+ const newVersion = (existing.version ?? 0) + 1;
861
+ await this.driver.update(this.tableName, existing.id, {
862
+ metadata: metadataJson,
863
+ version: newVersion,
864
+ checksum: newChecksum,
865
+ updated_at: now,
866
+ state: "active"
867
+ });
868
+ await this.createHistoryRecord(
869
+ existing.id,
870
+ type,
871
+ name,
872
+ newVersion,
873
+ restoredData,
874
+ "revert",
875
+ previousChecksum,
876
+ changeNote ?? `Rolled back to version ${targetVersion}`,
877
+ recordedBy
878
+ );
879
+ }
505
880
  async save(type, name, data, _options) {
506
881
  const startTime = Date.now();
507
882
  await this.ensureSchema();
508
883
  const now = (/* @__PURE__ */ new Date()).toISOString();
509
884
  const metadataJson = JSON.stringify(data);
885
+ const newChecksum = await calculateChecksum(data);
510
886
  try {
511
887
  const existing = await this.driver.findOne(this.tableName, {
512
888
  object: this.tableName,
513
889
  where: this.baseFilter(type, name)
514
890
  });
515
891
  if (existing) {
892
+ const previousChecksum = existing.checksum;
893
+ if (newChecksum === previousChecksum) {
894
+ return {
895
+ success: true,
896
+ path: `datasource://${this.tableName}/${type}/${name}`,
897
+ size: metadataJson.length,
898
+ saveTime: Date.now() - startTime
899
+ };
900
+ }
516
901
  const version = (existing.version ?? 0) + 1;
517
902
  await this.driver.update(this.tableName, existing.id, {
518
903
  metadata: metadataJson,
519
904
  version,
905
+ checksum: newChecksum,
520
906
  updated_at: now,
521
907
  state: "active"
522
908
  });
909
+ await this.createHistoryRecord(
910
+ existing.id,
911
+ type,
912
+ name,
913
+ version,
914
+ data,
915
+ "update",
916
+ previousChecksum
917
+ );
523
918
  return {
524
919
  success: true,
525
920
  path: `datasource://${this.tableName}/${type}/${name}`,
@@ -535,6 +930,7 @@ var DatabaseLoader = class {
535
930
  namespace: "default",
536
931
  scope: data?.scope ?? "platform",
537
932
  metadata: metadataJson,
933
+ checksum: newChecksum,
538
934
  strategy: "merge",
539
935
  state: "active",
540
936
  version: 1,
@@ -543,6 +939,14 @@ var DatabaseLoader = class {
543
939
  created_at: now,
544
940
  updated_at: now
545
941
  });
942
+ await this.createHistoryRecord(
943
+ id,
944
+ type,
945
+ name,
946
+ 1,
947
+ data,
948
+ "create"
949
+ );
546
950
  return {
547
951
  success: true,
548
952
  path: `datasource://${this.tableName}/${type}/${name}`,
@@ -556,6 +960,20 @@ var DatabaseLoader = class {
556
960
  );
557
961
  }
558
962
  }
963
+ /**
964
+ * Delete a metadata item from the database
965
+ */
966
+ async delete(type, name) {
967
+ await this.ensureSchema();
968
+ const existing = await this.driver.findOne(this.tableName, {
969
+ object: this.tableName,
970
+ where: this.baseFilter(type, name)
971
+ });
972
+ if (!existing) {
973
+ return;
974
+ }
975
+ await this.driver.delete(this.tableName, existing.id);
976
+ }
559
977
  };
560
978
  function generateId() {
561
979
  if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function") {
@@ -633,12 +1051,18 @@ var MetadataManager = class {
633
1051
  // ==========================================
634
1052
  /**
635
1053
  * Register/save a metadata item by type
1054
+ * Stores in-memory registry and persists to writable loaders (if configured)
636
1055
  */
637
1056
  async register(type, name, data) {
638
1057
  if (!this.registry.has(type)) {
639
1058
  this.registry.set(type, /* @__PURE__ */ new Map());
640
1059
  }
641
1060
  this.registry.get(type).set(name, data);
1061
+ for (const loader of this.loaders.values()) {
1062
+ if (loader.save) {
1063
+ await loader.save(type, name, data);
1064
+ }
1065
+ }
642
1066
  }
643
1067
  /**
644
1068
  * Get a metadata item by type and name.
@@ -689,6 +1113,15 @@ var MetadataManager = class {
689
1113
  this.registry.delete(type);
690
1114
  }
691
1115
  }
1116
+ for (const loader of this.loaders.values()) {
1117
+ if (typeof loader.delete === "function") {
1118
+ try {
1119
+ await loader.delete(type, name);
1120
+ } catch (error) {
1121
+ this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
1122
+ }
1123
+ }
1124
+ }
692
1125
  }
693
1126
  /**
694
1127
  * Check if a metadata item exists
@@ -771,20 +1204,17 @@ var MetadataManager = class {
771
1204
  * Unregister all metadata items from a specific package
772
1205
  */
773
1206
  async unregisterPackage(packageName) {
1207
+ const itemsToDelete = [];
774
1208
  for (const [type, typeStore] of this.registry) {
775
- const toDelete = [];
776
1209
  for (const [name, data] of typeStore) {
777
1210
  const meta = data;
778
1211
  if (meta?.packageId === packageName || meta?.package === packageName) {
779
- toDelete.push(name);
1212
+ itemsToDelete.push({ type, name });
780
1213
  }
781
1214
  }
782
- for (const name of toDelete) {
783
- typeStore.delete(name);
784
- }
785
- if (typeStore.size === 0) {
786
- this.registry.delete(type);
787
- }
1215
+ }
1216
+ for (const { type, name } of itemsToDelete) {
1217
+ await this.unregister(type, name);
788
1218
  }
789
1219
  }
790
1220
  /**
@@ -1449,6 +1879,174 @@ var MetadataManager = class {
1449
1879
  }
1450
1880
  }
1451
1881
  }
1882
+ // ==========================================
1883
+ // Version History & Rollback
1884
+ // ==========================================
1885
+ /**
1886
+ * Get the database loader for history operations.
1887
+ * Returns undefined if no database loader is configured.
1888
+ */
1889
+ getDatabaseLoader() {
1890
+ const dbLoader = this.loaders.get("database");
1891
+ if (dbLoader && dbLoader instanceof DatabaseLoader) {
1892
+ return dbLoader;
1893
+ }
1894
+ return void 0;
1895
+ }
1896
+ /**
1897
+ * Get version history for a metadata item.
1898
+ * Returns a timeline of all changes made to the item.
1899
+ */
1900
+ async getHistory(type, name, options) {
1901
+ const dbLoader = this.getDatabaseLoader();
1902
+ if (!dbLoader) {
1903
+ throw new Error("History tracking requires a database loader to be configured");
1904
+ }
1905
+ const driver = dbLoader.driver;
1906
+ const tableName = dbLoader.tableName;
1907
+ const historyTableName = dbLoader.historyTableName;
1908
+ const tenantId = dbLoader.tenantId;
1909
+ const filter = { type, name };
1910
+ if (tenantId) {
1911
+ filter.tenant_id = tenantId;
1912
+ }
1913
+ const metadataRecord = await driver.findOne(tableName, {
1914
+ object: tableName,
1915
+ where: filter
1916
+ });
1917
+ if (!metadataRecord) {
1918
+ return {
1919
+ records: [],
1920
+ total: 0,
1921
+ hasMore: false
1922
+ };
1923
+ }
1924
+ const historyFilter = {
1925
+ metadata_id: metadataRecord.id
1926
+ };
1927
+ if (tenantId) {
1928
+ historyFilter.tenant_id = tenantId;
1929
+ }
1930
+ if (options?.operationType) {
1931
+ historyFilter.operation_type = options.operationType;
1932
+ }
1933
+ if (options?.since) {
1934
+ historyFilter.recorded_at = { $gte: options.since };
1935
+ }
1936
+ if (options?.until) {
1937
+ if (historyFilter.recorded_at) {
1938
+ historyFilter.recorded_at.$lte = options.until;
1939
+ } else {
1940
+ historyFilter.recorded_at = { $lte: options.until };
1941
+ }
1942
+ }
1943
+ const limit = options?.limit ?? 50;
1944
+ const offset = options?.offset ?? 0;
1945
+ const historyRecords = await driver.find(historyTableName, {
1946
+ object: historyTableName,
1947
+ where: historyFilter,
1948
+ orderBy: [{ field: "recorded_at", order: "desc" }],
1949
+ limit: limit + 1,
1950
+ // Fetch one extra to determine hasMore
1951
+ offset
1952
+ });
1953
+ const hasMore = historyRecords.length > limit;
1954
+ const records = historyRecords.slice(0, limit);
1955
+ const total = await driver.count(historyTableName, {
1956
+ object: historyTableName,
1957
+ where: historyFilter
1958
+ });
1959
+ const includeMetadata = options?.includeMetadata !== false;
1960
+ const historyResult = records.map((row) => {
1961
+ const parsedMetadata = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
1962
+ return {
1963
+ id: row.id,
1964
+ metadataId: row.metadata_id,
1965
+ name: row.name,
1966
+ type: row.type,
1967
+ version: row.version,
1968
+ operationType: row.operation_type,
1969
+ metadata: includeMetadata ? parsedMetadata : null,
1970
+ checksum: row.checksum,
1971
+ previousChecksum: row.previous_checksum,
1972
+ changeNote: row.change_note,
1973
+ tenantId: row.tenant_id,
1974
+ recordedBy: row.recorded_by,
1975
+ recordedAt: row.recorded_at
1976
+ };
1977
+ });
1978
+ return {
1979
+ records: historyResult,
1980
+ total,
1981
+ hasMore
1982
+ };
1983
+ }
1984
+ /**
1985
+ * Rollback a metadata item to a specific version.
1986
+ * Restores the metadata definition from the history snapshot.
1987
+ */
1988
+ async rollback(type, name, version, options) {
1989
+ const dbLoader = this.getDatabaseLoader();
1990
+ if (!dbLoader) {
1991
+ throw new Error("Rollback requires a database loader to be configured");
1992
+ }
1993
+ const targetVersion = await dbLoader.getHistoryRecord(type, name, version);
1994
+ if (!targetVersion) {
1995
+ throw new Error(`Version ${version} not found in history for ${type}/${name}`);
1996
+ }
1997
+ if (!targetVersion.metadata) {
1998
+ throw new Error(`Version ${version} metadata snapshot not available`);
1999
+ }
2000
+ const restoredMetadata = targetVersion.metadata;
2001
+ await dbLoader.registerRollback(
2002
+ type,
2003
+ name,
2004
+ restoredMetadata,
2005
+ version,
2006
+ options?.changeNote,
2007
+ options?.recordedBy
2008
+ );
2009
+ if (!this.registry.has(type)) {
2010
+ this.registry.set(type, /* @__PURE__ */ new Map());
2011
+ }
2012
+ this.registry.get(type).set(name, restoredMetadata);
2013
+ return restoredMetadata;
2014
+ }
2015
+ /**
2016
+ * Compare two versions of a metadata item.
2017
+ * Returns a diff showing what changed between versions.
2018
+ */
2019
+ async diff(type, name, version1, version2) {
2020
+ const dbLoader = this.getDatabaseLoader();
2021
+ if (!dbLoader) {
2022
+ throw new Error("Diff requires a database loader to be configured");
2023
+ }
2024
+ const v1 = await dbLoader.getHistoryRecord(type, name, version1);
2025
+ const v2 = await dbLoader.getHistoryRecord(type, name, version2);
2026
+ if (!v1) {
2027
+ throw new Error(`Version ${version1} not found in history for ${type}/${name}`);
2028
+ }
2029
+ if (!v2) {
2030
+ throw new Error(`Version ${version2} not found in history for ${type}/${name}`);
2031
+ }
2032
+ if (!v1.metadata || !v2.metadata) {
2033
+ throw new Error("Version metadata snapshots not available");
2034
+ }
2035
+ const patch = generateSimpleDiff(v1.metadata, v2.metadata);
2036
+ const identical = patch.length === 0;
2037
+ const summary = generateDiffSummary(patch);
2038
+ return {
2039
+ type,
2040
+ name,
2041
+ version1,
2042
+ version2,
2043
+ checksum1: v1.checksum,
2044
+ checksum2: v2.checksum,
2045
+ identical,
2046
+ patch,
2047
+ summary
2048
+ };
2049
+ }
1452
2050
  };
1453
2051
 
1454
2052
  // src/node-metadata-manager.ts
@@ -1869,14 +2467,17 @@ var MetadataPlugin = class {
1869
2467
  watch: this.options.watch
1870
2468
  });
1871
2469
  ctx.registerService("metadata", this.manager);
1872
- ctx.registerService("app.com.objectstack.metadata", {
1873
- id: "com.objectstack.metadata",
1874
- name: "Metadata",
1875
- version: "1.0.0",
1876
- type: "plugin",
1877
- namespace: "sys",
1878
- objects: [SysMetadataObject]
1879
- });
2470
+ try {
2471
+ ctx.getService("manifest").register({
2472
+ id: "com.objectstack.metadata",
2473
+ name: "Metadata",
2474
+ version: "1.0.0",
2475
+ type: "plugin",
2476
+ namespace: "sys",
2477
+ objects: [SysMetadataObject]
2478
+ });
2479
+ } catch {
2480
+ }
1880
2481
  ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
1881
2482
  mode: "file-system",
1882
2483
  features: ["watch", "persistence", "multi-format", "query", "overlay", "type-registry"]
@@ -1988,6 +2589,18 @@ var MemoryLoader = class {
1988
2589
  saveTime: 0
1989
2590
  };
1990
2591
  }
2592
+ /**
2593
+ * Delete a metadata item from memory storage
2594
+ */
2595
+ async delete(type, name) {
2596
+ const typeStore = this.storage.get(type);
2597
+ if (typeStore) {
2598
+ typeStore.delete(name);
2599
+ if (typeStore.size === 0) {
2600
+ this.storage.delete(type);
2601
+ }
2602
+ }
2603
+ }
1991
2604
  };
1992
2605
 
1993
2606
  // src/loaders/remote-loader.ts
@@ -2087,6 +2700,330 @@ var RemoteLoader = class {
2087
2700
  }
2088
2701
  };
2089
2702
 
2703
+ // src/routes/history-routes.ts
2704
+ function registerMetadataHistoryRoutes(app, metadataService) {
2705
+ app.get("/api/v1/metadata/:type/:name/history", async (c) => {
2706
+ if (!metadataService.getHistory) {
2707
+ return c.json({ error: "History tracking not enabled" }, 501);
2708
+ }
2709
+ const { type, name } = c.req.param();
2710
+ const query = c.req.query();
2711
+ try {
2712
+ const options = {};
2713
+ if (query.limit !== void 0) {
2714
+ const limit = parseInt(query.limit, 10);
2715
+ if (!Number.isFinite(limit) || limit < 1) {
2716
+ return c.json({ success: false, error: "limit must be a positive integer" }, 400);
2717
+ }
2718
+ options.limit = limit;
2719
+ }
2720
+ if (query.offset !== void 0) {
2721
+ const offset = parseInt(query.offset, 10);
2722
+ if (!Number.isFinite(offset) || offset < 0) {
2723
+ return c.json({ success: false, error: "offset must be a non-negative integer" }, 400);
2724
+ }
2725
+ options.offset = offset;
2726
+ }
2727
+ if (query.since) options.since = query.since;
2728
+ if (query.until) options.until = query.until;
2729
+ if (query.operationType) options.operationType = query.operationType;
2730
+ if (query.includeMetadata !== void 0) {
2731
+ options.includeMetadata = query.includeMetadata === "true";
2732
+ }
2733
+ const result = await metadataService.getHistory(type, name, options);
2734
+ return c.json({
2735
+ success: true,
2736
+ data: result
2737
+ });
2738
+ } catch (error) {
2739
+ return c.json(
2740
+ {
2741
+ success: false,
2742
+ error: error instanceof Error ? error.message : "Failed to retrieve history"
2743
+ },
2744
+ 500
2745
+ );
2746
+ }
2747
+ });
2748
+ app.post("/api/v1/metadata/:type/:name/rollback", async (c) => {
2749
+ if (!metadataService.rollback) {
2750
+ return c.json({ error: "Rollback not supported" }, 501);
2751
+ }
2752
+ const { type, name } = c.req.param();
2753
+ try {
2754
+ const body = await c.req.json();
2755
+ const { version, changeNote, recordedBy } = body;
2756
+ if (typeof version !== "number") {
2757
+ return c.json(
2758
+ {
2759
+ success: false,
2760
+ error: "Version number is required"
2761
+ },
2762
+ 400
2763
+ );
2764
+ }
2765
+ const restoredMetadata = await metadataService.rollback(type, name, version, {
2766
+ changeNote,
2767
+ recordedBy
2768
+ });
2769
+ return c.json({
2770
+ success: true,
2771
+ data: {
2772
+ type,
2773
+ name,
2774
+ version,
2775
+ metadata: restoredMetadata
2776
+ }
2777
+ });
2778
+ } catch (error) {
2779
+ return c.json(
2780
+ {
2781
+ success: false,
2782
+ error: error instanceof Error ? error.message : "Rollback failed"
2783
+ },
2784
+ 500
2785
+ );
2786
+ }
2787
+ });
2788
+ app.get("/api/v1/metadata/:type/:name/diff", async (c) => {
2789
+ if (!metadataService.diff) {
2790
+ return c.json({ error: "Diff not supported" }, 501);
2791
+ }
2792
+ const { type, name } = c.req.param();
2793
+ const query = c.req.query();
2794
+ try {
2795
+ const version1 = parseInt(query.version1, 10);
2796
+ const version2 = parseInt(query.version2, 10);
2797
+ if (isNaN(version1) || isNaN(version2)) {
2798
+ return c.json(
2799
+ {
2800
+ success: false,
2801
+ error: "Both version1 and version2 query parameters are required"
2802
+ },
2803
+ 400
2804
+ );
2805
+ }
2806
+ const diffResult = await metadataService.diff(type, name, version1, version2);
2807
+ return c.json({
2808
+ success: true,
2809
+ data: diffResult
2810
+ });
2811
+ } catch (error) {
2812
+ return c.json(
2813
+ {
2814
+ success: false,
2815
+ error: error instanceof Error ? error.message : "Diff failed"
2816
+ },
2817
+ 500
2818
+ );
2819
+ }
2820
+ });
2821
+ }
2822
+
2823
+ // src/utils/history-cleanup.ts
2824
+ var HistoryCleanupManager = class {
2825
+ constructor(policy, dbLoader) {
2826
+ this.policy = policy;
2827
+ this.dbLoader = dbLoader;
2828
+ }
2829
+ /**
2830
+ * Start automatic cleanup if enabled in the policy.
2831
+ */
2832
+ start() {
2833
+ if (!this.policy.autoCleanup) {
2834
+ return;
2835
+ }
2836
+ const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
2837
+ void this.runCleanup();
2838
+ this.cleanupTimer = setInterval(() => {
2839
+ void this.runCleanup();
2840
+ }, intervalMs);
2841
+ }
2842
+ /**
2843
+ * Stop automatic cleanup.
2844
+ */
2845
+ stop() {
2846
+ if (this.cleanupTimer) {
2847
+ clearInterval(this.cleanupTimer);
2848
+ this.cleanupTimer = void 0;
2849
+ }
2850
+ }
2851
+ /**
2852
+ * Run cleanup based on the retention policy.
2853
+ * Removes history records that exceed the configured limits.
2854
+ */
2855
+ async runCleanup() {
2856
+ const driver = this.dbLoader.driver;
2857
+ const historyTableName = this.dbLoader.historyTableName;
2858
+ const tenantId = this.dbLoader.tenantId;
2859
+ let deleted = 0;
2860
+ let errors = 0;
2861
+ try {
2862
+ if (this.policy.maxAgeDays) {
2863
+ const cutoffDate = /* @__PURE__ */ new Date();
2864
+ cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
2865
+ const cutoffISO = cutoffDate.toISOString();
2866
+ const filter = {
2867
+ recorded_at: { $lt: cutoffISO }
2868
+ };
2869
+ if (tenantId) {
2870
+ filter.tenant_id = tenantId;
2871
+ }
2872
+ try {
2873
+ const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
2874
+ deleted += result.deleted;
2875
+ errors += result.errors;
2876
+ } catch {
2877
+ errors++;
2878
+ }
2879
+ }
2880
+ if (this.policy.maxVersions) {
2881
+ try {
2882
+ const metadataIds = await driver.find(historyTableName, {
2883
+ object: historyTableName,
2884
+ where: tenantId ? { tenant_id: tenantId } : {},
2885
+ fields: ["metadata_id"]
2886
+ });
2887
+ const uniqueIds = /* @__PURE__ */ new Set();
2888
+ for (const record of metadataIds) {
2889
+ if (record.metadata_id) {
2890
+ uniqueIds.add(record.metadata_id);
2891
+ }
2892
+ }
2893
+ for (const metadataId of uniqueIds) {
2894
+ const filter = { metadata_id: metadataId };
2895
+ if (tenantId) {
2896
+ filter.tenant_id = tenantId;
2897
+ }
2898
+ try {
2899
+ const historyRecords = await driver.find(historyTableName, {
2900
+ object: historyTableName,
2901
+ where: filter,
2902
+ orderBy: [{ field: "version", order: "desc" }],
2903
+ fields: ["id"]
2904
+ });
2905
+ if (historyRecords.length > this.policy.maxVersions) {
2906
+ const toDelete = historyRecords.slice(this.policy.maxVersions);
2907
+ const ids = toDelete.map((r) => r.id).filter(Boolean);
2908
+ const result = await this.bulkDeleteByIds(driver, historyTableName, ids);
2909
+ deleted += result.deleted;
2910
+ errors += result.errors;
2911
+ }
2912
+ } catch {
2913
+ errors++;
2914
+ }
2915
+ }
2916
+ } catch {
2917
+ errors++;
2918
+ }
2919
+ }
2920
+ } catch (error) {
2921
+ console.error("History cleanup failed:", error);
2922
+ errors++;
2923
+ }
2924
+ return { deleted, errors };
2925
+ }
2926
+ /**
2927
+ * Delete records matching a filter using the most efficient method available on the driver.
2928
+ */
2929
+ async bulkDeleteByFilter(driver, table, filter) {
2930
+ const driverAny = driver;
2931
+ if (typeof driverAny.deleteMany === "function") {
2932
+ const count = await driverAny.deleteMany(table, filter);
2933
+ return { deleted: typeof count === "number" ? count : 0, errors: 0 };
2934
+ }
2935
+ const records = await driver.find(table, { object: table, where: filter, fields: ["id"] });
2936
+ const ids = records.map((r) => r.id).filter(Boolean);
2937
+ return this.bulkDeleteByIds(driver, table, ids);
2938
+ }
2939
+ /**
2940
+ * Delete records by IDs using bulkDelete when available, otherwise one-by-one.
2941
+ */
2942
+ async bulkDeleteByIds(driver, table, ids) {
2943
+ if (ids.length === 0) return { deleted: 0, errors: 0 };
2944
+ const driverAny = driver;
2945
+ if (typeof driverAny.bulkDelete === "function") {
2946
+ const result = await driverAny.bulkDelete(table, ids);
2947
+ return {
2948
+ deleted: typeof result === "number" ? result : ids.length,
2949
+ errors: 0
2950
+ };
2951
+ }
2952
+ let deleted = 0;
2953
+ let errors = 0;
2954
+ for (const id of ids) {
2955
+ try {
2956
+ await driver.delete(table, id);
2957
+ deleted++;
2958
+ } catch {
2959
+ errors++;
2960
+ }
2961
+ }
2962
+ return { deleted, errors };
2963
+ }
2964
+ /**
2965
+ * Get cleanup statistics without actually deleting anything.
2966
+ * Useful for previewing what would be cleaned up.
2967
+ */
2968
+ async getCleanupStats() {
2969
+ const driver = this.dbLoader.driver;
2970
+ const historyTableName = this.dbLoader.historyTableName;
2971
+ const tenantId = this.dbLoader.tenantId;
2972
+ let recordsByAge = 0;
2973
+ let recordsByCount = 0;
2974
+ try {
2975
+ if (this.policy.maxAgeDays) {
2976
+ const cutoffDate = /* @__PURE__ */ new Date();
2977
+ cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);
2978
+ const cutoffISO = cutoffDate.toISOString();
2979
+ const filter = {
2980
+ recorded_at: { $lt: cutoffISO }
2981
+ };
2982
+ if (tenantId) {
2983
+ filter.tenant_id = tenantId;
2984
+ }
2985
+ recordsByAge = await driver.count(historyTableName, {
2986
+ object: historyTableName,
2987
+ where: filter
2988
+ });
2989
+ }
2990
+ if (this.policy.maxVersions) {
2991
+ const metadataIds = await driver.find(historyTableName, {
2992
+ object: historyTableName,
2993
+ where: tenantId ? { tenant_id: tenantId } : {},
2994
+ fields: ["metadata_id"]
2995
+ });
2996
+ const uniqueIds = /* @__PURE__ */ new Set();
2997
+ for (const record of metadataIds) {
2998
+ if (record.metadata_id) {
2999
+ uniqueIds.add(record.metadata_id);
3000
+ }
3001
+ }
3002
+ for (const metadataId of uniqueIds) {
3003
+ const filter = { metadata_id: metadataId };
3004
+ if (tenantId) {
3005
+ filter.tenant_id = tenantId;
3006
+ }
3007
+ const count = await driver.count(historyTableName, {
3008
+ object: historyTableName,
3009
+ where: filter
3010
+ });
3011
+ if (count > this.policy.maxVersions) {
3012
+ recordsByCount += count - this.policy.maxVersions;
3013
+ }
3014
+ }
3015
+ }
3016
+ } catch (error) {
3017
+ console.error("Failed to get cleanup stats:", error);
3018
+ }
3019
+ return {
3020
+ recordsByAge,
3021
+ recordsByCount,
3022
+ total: recordsByAge + recordsByCount
3023
+ };
3024
+ }
3025
+ };
3026
+
2090
3027
  // src/migration/index.ts
2091
3028
  var migration_exports = {};
2092
3029
  __export(migration_exports, {
@@ -2144,14 +3081,20 @@ var MigrationExecutor = class {
2144
3081
  };
2145
3082
  export {
2146
3083
  DatabaseLoader,
3084
+ HistoryCleanupManager,
2147
3085
  JSONSerializer,
2148
3086
  MemoryLoader,
2149
3087
  MetadataManager,
2150
3088
  MetadataPlugin,
2151
3089
  migration_exports as Migration,
2152
3090
  RemoteLoader,
3091
+ SysMetadataHistoryObject,
2153
3092
  SysMetadataObject,
2154
3093
  TypeScriptSerializer,
2155
- YAMLSerializer
3094
+ YAMLSerializer,
3095
+ calculateChecksum,
3096
+ generateDiffSummary,
3097
+ generateSimpleDiff,
3098
+ registerMetadataHistoryRoutes
2156
3099
  };
2157
3100
  //# sourceMappingURL=index.js.map