Source

contracts/crud/crud-contract.ts

import { FabricContractAdapter } from "../ContractAdapter";
import { Context, Contract, Context as Ctx } from "fabric-contract-api";
import { Model, Serializer } from "@decaf-ts/decorator-validation";
import { ContextualizedArgs, LoggerOf, Repository } from "@decaf-ts/core";
import { FabricContractRepository } from "../FabricContractRepository";
import { DeterministicSerializer } from "../../shared/DeterministicSerializer";
import { MangoQuery } from "@decaf-ts/for-couchdb";
import { Checkable, healthcheck } from "../../shared/interfaces/Checkable";
import { Constructor } from "@decaf-ts/decoration";
import { FabricContractContext } from "../ContractContext";
import {
  BulkCrudOperationKeys,
  InternalError,
  OperationKeys,
} from "@decaf-ts/db-decorators";

/**
 * @description Base contract class for CRUD operations in Fabric chaincode
 * @summary Provides standard create, read, update, and delete operations for models in Fabric chaincode
 * @template M - Type extending Model
 * @class FabricCrudContract
 * @extends {Contract}
 * @example
 * ```typescript
 * // Define a model
 * @table('assets')
 * class Asset extends Model {
 *   @id()
 *   id: string;
 *
 *   @property()
 *   data: string;
 * }
 *
 * // Create a contract that extends FabricCrudContract
 * export class AssetContract extends FabricCrudContract<Asset> {
 *   constructor() {
 *     super('AssetContract', Asset);
 *   }
 *
 *   // Add custom methods as needed
 *   async getAssetHistory(ctx: Context, id: string): Promise<any[]> {
 *     // Custom implementation
 *   }
 * }
 * ```
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Contract
 *   participant Repository
 *   participant Adapter
 *   participant StateDB
 *
 *   Client->>Contract: create(ctx, model)
 *   Contract->>Repository: repository(ctx)
 *   Contract->>Repository: create(model, ctx)
 *   Repository->>Adapter: create(tableName, id, record, transient, ctx)
 *   Adapter->>StateDB: putState(id, serializedData)
 *   StateDB-->>Adapter: Success
 *   Adapter-->>Repository: record
 *   Repository-->>Contract: model
 *   Contract-->>Client: model
 */
export abstract class FabricCrudContract<M extends Model>
  extends Contract
  implements Checkable
{
  /**
   * @description Shared adapter instance for all contract instances
   */
  protected static adapter: FabricContractAdapter = new FabricContractAdapter();

  protected readonly repo: FabricContractRepository<M>;

  protected static readonly serializer = new DeterministicSerializer();

  protected initialized: boolean = false;

  /**
   * @description Creates a new FabricCrudContract instance
   * @summary Initializes a contract with a name and model class
   * @param {string} name - The name of the contract
   * @param {Constructor<M>} clazz - The model constructor
   */
  protected constructor(
    name: string,
    protected readonly clazz: Constructor<M>
  ) {
    super(name);
    this.repo = Repository.forModel(clazz);
  }

  /**
   * @description Creates a single model in the state database
   * @summary Delegates to the repository's create method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {M} model - The model to create
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M>} Promise resolving to the created model
   */
  async create(
    ctx: Ctx,
    model: string | M,
    ...args: any[]
  ): Promise<string | M> {
    const { log, ctxArgs } = await this.logCtx([...args, ctx], this.create);
    log.info(`CONTRACT CREATE, ${ctxArgs}`);

    if (typeof model === "string") model = this.deserialize<M>(model) as M;

    log.info(`Creating model: ${JSON.stringify(model)}`);

    const transient = this.getTransientData(ctx);

    log.info(`Merging transient data...`);
    model = Model.merge(model, transient, this.clazz) as M;

    return this.repo.create(model, ...ctxArgs);
  }

  /**
   * @description Reads a single model from the state database
   * @summary Delegates to the repository's read method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {string | number} key - The key of the model to read
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M>} Promise resolving to the retrieved model
   */
  async read(
    ctx: Ctx,
    key: string | number,
    ...args: any[]
  ): Promise<M | string> {
    const { log, ctxArgs } = await this.logCtx([...args, ctx], this.read);

    log.info(`reading entry with pk ${key} `);

    return this.repo.read(key, ...ctxArgs);
  }

  protected getTransientData(ctx: Ctx): any {
    const transientMap = ctx.stub.getTransient();
    let transient: any = {};

    if (transientMap.has((this.repo as any).tableName)) {
      transient = JSON.parse(
        (transientMap.get((this.repo as any).tableName) as Buffer)?.toString(
          "utf8"
        ) as string
      );
    }

    return transient;
  }

  /**
   * @description Updates a single model in the state database
   * @summary Delegates to the repository's update method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {M} model - The model to update
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M>} Promise resolving to the updated model
   */
  async update(
    ctx: Ctx,
    model: string | M,
    ...args: any[]
  ): Promise<string | M> {
    const { log, ctxArgs } = await this.logCtx([...args, ctx], this.update);

    if (typeof model === "string") model = this.deserialize<M>(model) as M;

    log.info(`Updating model: ${JSON.stringify(model)}`);

    const transient = this.getTransientData(ctx);

    log.info(`Merging transient data...`);
    model = Model.merge(model, transient, this.clazz) as M;
    return this.repo.update(model, ...ctxArgs);
  }

  /**
   * @description Deletes a single model from the state database
   * @summary Delegates to the repository's delete method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {string | number} key - The key of the model to delete
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M>} Promise resolving to the deleted model
   */
  async delete(
    ctx: Ctx,
    key: string | number,
    ...args: any[]
  ): Promise<M | string> {
    const { log, ctxArgs } = await this.logCtx([...args, ctx], this.delete);
    log.info(`deleting entry with pk ${key} `);
    return this.repo.delete(String(key), ...ctxArgs);
  }

  /**
   * @description Deletes multiple models from the state database
   * @summary Delegates to the repository's deleteAll method
   * @param {string[] | number[]} keys - The keys of the models to delete
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M[]>} Promise resolving to the deleted models
   */
  async deleteAll(
    ctx: Ctx,
    keys: string | string[] | number[],
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs } = await this.logCtx([...args, ctx], this.readAll);
    if (typeof keys === "string") keys = JSON.parse(keys) as string[];
    return this.repo.deleteAll(keys, ...ctxArgs);
  }

  /**
   * @description Reads multiple models from the state database
   * @summary Delegates to the repository's readAll method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {string[] | number[]} keys - The keys of the models to read
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M[]>} Promise resolving to the retrieved models
   */
  async readAll(
    ctx: Ctx,
    keys: string | string[] | number[],
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs } = await this.logCtx([...args, ctx], this.readAll);
    if (typeof keys === "string") keys = JSON.parse(keys) as string[];
    return this.repo.readAll(keys, ...ctxArgs);
  }

  /**
   * @description Updates multiple models in the state database
   * @summary Delegates to the repository's updateAll method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {M[]} models - The models to update
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M[]>} Promise resolving to the updated models
   */
  async updateAll(
    ctx: Ctx,
    models: string | M[],
    ...args: any[]
  ): Promise<string | M[]> {
    const { log, ctxArgs } = await this.logCtx([...args, ctx], this.updateAll);
    if (typeof models === "string")
      models = (JSON.parse(models) as [])
        .map((m) => this.deserialize(m))
        .map((m) => new this.clazz(m)) as any;

    log.info(`updating ${models.length} entries to the table`);
    return this.repo.updateAll(models as unknown as M[], ...ctxArgs);
  }

  /**
   * @description Executes a raw query against the state database
   * @summary Delegates to the repository's raw method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {any} rawInput - The query to execute
   * @param {boolean} docsOnly - Whether to return only documents
   * @param {...any[]} args - Additional arguments
   * @return {Promise<any>} Promise resolving to the query results
   */
  async raw(
    ctx: Ctx | FabricContractContext,
    rawInput: MangoQuery | string,
    docsOnly: boolean,
    ...args: any[]
  ): Promise<any | string> {
    const { ctxArgs } = await this.logCtx([...args, ctx], this.raw);
    if (typeof rawInput === "string")
      rawInput = JSON.parse(rawInput) as MangoQuery;
    return FabricCrudContract.adapter.raw(rawInput, docsOnly, ...ctxArgs);
  }

  protected serialize(model: M): string {
    return FabricCrudContract.serializer.serialize(model);
  }

  protected deserialize<M extends Model>(str: string): M {
    return (
      FabricCrudContract.serializer as unknown as Serializer<M>
    ).deserialize(str);
  }

  protected async init(ctx: Ctx): Promise<void> {
    const { log } = await this.logCtx([ctx], this.init);
    log.info(`Running contract initialization...`);
    this.initialized = true;
    log.info(`Contract initialization completed.`);
  }

  async healthcheck(ctx: Ctx): Promise<string | healthcheck> {
    const { log } = await this.logCtx([ctx], this.healthcheck);
    log.info(`Running Healthcheck: ${this.initialized}...`);
    return { healthcheck: this.initialized };
  }

  /**
   * @description Creates multiple models in the state database
   * @summary Delegates to the repository's createAll method
   * @param {Ctx} ctx - The Fabric chaincode context
   * @param {M[]} models - The models to create
   * @param {...any[]} args - Additional arguments
   * @return {Promise<M[]>} Promise resolving to the created models
   */
  async createAll(
    ctx: Ctx,
    models: string | M[],
    ...args: any[]
  ): Promise<string | M[]> {
    const { log } = await this.logCtx([...args, ctx], this.createAll);

    if (typeof models === "string")
      models = (JSON.parse(models) as [])
        .map((m) => this.deserialize(m))
        .map((m) => new this.clazz(m)) as any;

    log.info(`adding ${models.length} entries to the table`);
    return this.repo.createAll(models as unknown as M[], ctx, ...args);
  }

  async logCtx<ARGS extends any[]>(
    args: ARGS,
    method: ((...args: any[]) => any) | string
  ): Promise<ContextualizedArgs<FabricContractContext, ARGS>> {
    return FabricCrudContract.logCtx.bind(this)(args, method as any);
  }

  protected static async logCtx<ARGS extends any[]>(
    this: any,
    args: ARGS,
    method: string
  ): Promise<ContextualizedArgs<FabricContractContext, ARGS>>;
  protected static async logCtx<ARGS extends any[]>(
    this: any,
    args: ARGS,
    method: (...args: any[]) => any
  ): Promise<ContextualizedArgs<FabricContractContext, ARGS>>;
  protected static async logCtx<ARGS extends any[]>(
    this: any,
    args: ARGS,
    method: ((...args: any[]) => any) | string
  ): Promise<ContextualizedArgs<FabricContractContext, ARGS>> {
    if (args.length < 1) throw new InternalError("No context provided");
    const ctx = args.pop() as FabricContractContext | Context;
    if (ctx instanceof FabricContractContext)
      return {
        ctx,
        log: ctx.logger.clear().for(this).for(method),
        ctxArgs: [...args, ctx],
      };
    if (!(ctx instanceof Context))
      throw new InternalError("No valid context provided");

    function getOp() {
      if (typeof method === "string") return method;
      switch (method.name) {
        case OperationKeys.CREATE:
        case OperationKeys.READ:
        case OperationKeys.UPDATE:
        case OperationKeys.DELETE:
        case BulkCrudOperationKeys.CREATE_ALL:
        case BulkCrudOperationKeys.READ_ALL:
        case BulkCrudOperationKeys.UPDATE_ALL:
        case BulkCrudOperationKeys.DELETE_ALL:
          return method.name;
        default:
          return method.name;
      }
    }

    const overrides = {
      correlationId: ctx.stub.getTxID(),
    };

    const context = await FabricCrudContract.adapter.context(
      getOp(),
      overrides as any,
      this.clazz,
      ctx
    );
    const log = (
      this
        ? context.logger.for(this).for(method)
        : context.logger.clear().for(this).for(method)
    ) as LoggerOf<FabricContractContext>;
    return {
      ctx: context,
      log: log,
      ctxArgs: [...args, context],
    };
  }
}