Source

contract/models/history-dec.ts

import { Model } from "@decaf-ts/decorator-validation";
import { Repository } from "@decaf-ts/core";
import { apply, Constructor, Metadata, metadata } from "@decaf-ts/decoration";
import {
  afterDelete,
  DBKeys,
  InternalError,
  onUpdate,
  PrimaryKeyType,
} from "@decaf-ts/db-decorators";
import { populateRelations } from "./decorators";
import { type FabricContractContext } from "../../contracts/index";
import { History } from "./History";

/**
 * Correctly extracts the version number from a model by inspecting
 * the property decorated with `@version()`.
 *
 * `Model.versionProp()` has a bug where it returns `Object.keys(meta)[0]`
 * (the first metadata key overall) instead of the actual version property name.
 * This helper uses `Metadata.get()` correctly to find the `DBKeys.VERSION`
 * entry and then reads the first property name from it.
 *
 * @returns the version number (>= 1), or undefined if none found.
 */
function getVersionOf(model: Model): number | undefined {
  const meta = Metadata.get(model.constructor as Constructor<Model>);
  if (!meta) return undefined;
  const versionMeta = (meta as Record<string, any>)[DBKeys.VERSION];
  if (!versionMeta || typeof versionMeta !== "object") return undefined;
  const versionProp = Object.keys(versionMeta)[0];
  if (!versionProp) return undefined;
  const value = (model as any)[versionProp];
  return typeof value === "number" && value >= 1 ? value : undefined;
}

export async function updateHistoryHandler<
  M extends Model,
  R extends Repository<M, any>,
>(
  this: R,
  context: FabricContractContext,
  data: any,
  key: keyof M,
  model: M,
  oldModel: M
): Promise<void> {
  if (!context.identity || !context.identity.getID)
    throw new InternalError(`Lost context apparently for history`);

  const table = Model.tableName(oldModel);
  const pk: PrimaryKeyType = Model.pk(oldModel, true) as any;
  const version = getVersionOf(oldModel);
  if (version === undefined) return; // model has no @version field — skip history

  // Populate relations on a non-mutating copy of oldModel, then convert to a
  // plain object so no model-anchor (__model) keys appear in the stored JSON.
  const populated = await populateRelations(oldModel, context, this._overrides);
  const record: Record<string, any> = JSON.parse(JSON.stringify(populated));

  const repo = Repository.forModel(History).override(this._overrides);
  const toCreate = new History({
    table,
    key: pk,
    version,
    record,
  });

  await repo.create(toCreate, context);
  context.logger.info(
    `History for ${table}'s ${pk.toString()} version ${version} stored`
  );
}

export async function deleteHistoryHandler<
  M extends Model,
  R extends Repository<M, any>,
>(
  this: R,
  context: FabricContractContext,
  data: any,
  key: keyof M,
  model: M
): Promise<void> {
  if (!context.identity || !context.identity.getID)
    throw new InternalError(`Lost context apparently for history`);

  const table = Model.tableName(model);
  const pk: PrimaryKeyType = Model.pk(model, true) as any;
  const version = getVersionOf(model);
  if (version === undefined) return; // model has no @version field — skip history

  // Populate relations on a non-mutating copy of model, then convert to a
  // plain object so no model-anchor (__model) keys appear in the stored JSON.
  const populated = await populateRelations(model, context, this._overrides);
  const record: Record<string, any> = JSON.parse(JSON.stringify(populated));

  const repo = Repository.forModel(History).override(this._overrides);
  const toCreate = new History({
    table,
    key: pk,
    version,
    record,
  });

  await repo.create(toCreate, context);
  context.logger.info(
    `History for ${table}'s ${pk.toString()} version ${version} stored`
  );
}

export function historyDec() {
  return apply(
    onUpdate(updateHistoryHandler as any, {}, { priority: 99 }),
    afterDelete(deleteHistoryHandler as any, {}, { priority: 99 }),
    metadata("history", true)
  );
}