Source

contracts/crud/crud-contract.ts

import "../../shared/overrides";
import {
  FabricContextualizedArgs,
  FabricContractAdapter,
} from "../ContractAdapter";
import {
  Context as Ctx,
  Contract,
  Object as FabricObject,
} from "fabric-contract-api";
import { Model, Serializer } from "@decaf-ts/decorator-validation";
import {
  Condition,
  DirectionLimitOffset,
  MaybeContextualArg,
  MethodOrOperation,
  OrderDirection,
  PersistenceKeys,
  PreparedStatementKeys,
  Repository,
  SerializedPage,
} 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,
  OperationKeys,
  PrimaryKeyType,
} from "@decaf-ts/db-decorators";
import { MissingContextError } from "../../shared/index";

FabricObject()(Date);
/**
 * @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);

    // prefixMethod(this);
  }

  async listBy(
    ctx: Ctx | FabricContractContext,
    key: string | keyof M,
    order: string,
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs, log } = (
      await this.logCtx([...args, ctx], PreparedStatementKeys.LIST_BY, true)
    ).for(this.listBy);
    log.info(
      `Running listBy key ${key as string}, order ${order} and args ${ctxArgs}`
    );
    return this.repo.listBy(
      key as keyof M,
      order as OrderDirection,
      ...ctxArgs
    );
  }

  async paginateBy(
    ctx: Ctx | FabricContractContext,
    key: string | keyof M,
    order: string,
    ref: Omit<DirectionLimitOffset, "direction"> | string = {
      offset: 1,
      limit: 10,
    },
    ...args: any[]
  ): Promise<SerializedPage<M> | string> {
    const { ctxArgs, log } = (
      await this.logCtx([...args, ctx], PreparedStatementKeys.PAGE_BY, true)
    ).for(this.paginateBy);
    log.info(
      `Running paginateBy key ${key as string}, order ${order} with size ${(ref as any).limit} and args ${ctxArgs}`
    );
    return this.repo.paginateBy(
      key as keyof M,
      order as any,
      ref as any,
      ...ctxArgs
    );
  }

  async findOneBy(
    ctx: Ctx | FabricContractContext,
    key: string | keyof M,
    value: any,
    ...args: any[]
  ): Promise<M | string> {
    const { ctxArgs, log } = (
      await this.logCtx([...args, ctx], PreparedStatementKeys.FIND_ONE_BY, true)
    ).for(this.findOneBy);
    log.info(
      `Running findOneBy key ${key as string}, value: ${value} with args ${ctxArgs}`
    );
    return this.repo.findOneBy(key as keyof M, value, ...ctxArgs);
  }

  async statement(
    ctx: Ctx | FabricContractContext,
    method: string,
    ...args: any[]
  ): Promise<any> {
    const { ctxArgs, log } = (
      await this.logCtx([...args, ctx], PersistenceKeys.STATEMENT, true)
    ).for(this.statement);
    log.info(`Running statement ${method} with args ${ctxArgs}`);
    return this.repo.statement(method, ...ctxArgs);
  }

  /**
   * @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 | FabricContractContext,
    model: string | M,
    ...args: any[]
  ): Promise<string | M> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], OperationKeys.CREATE, true)
    ).for(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 | FabricContractContext,
    key: PrimaryKeyType | string,
    ...args: any[]
  ): Promise<M | string> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], OperationKeys.READ, true)
    ).for(this.create);
    log.info(`reading entry with pk ${key} `);

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

  protected getTransientData(ctx: Ctx | FabricContractContext): 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 | FabricContractContext,
    model: string | M,
    ...args: any[]
  ): Promise<string | M> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], OperationKeys.UPDATE, true)
    ).for(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 | FabricContractContext,
    key: PrimaryKeyType | string,
    ...args: any[]
  ): Promise<M | string> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], OperationKeys.DELETE, true)
    ).for(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 | FabricContractContext,
    keys: PrimaryKeyType[] | string,
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs } = (
      await this.logCtx([...args, ctx], BulkCrudOperationKeys.DELETE_ALL, true)
    ).for(this.deleteAll);
    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 | FabricContractContext,
    keys: PrimaryKeyType[] | string,
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs } = (
      await this.logCtx([...args, ctx], BulkCrudOperationKeys.READ_ALL, true)
    ).for(this.create);
    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 | FabricContractContext,
    models: string | M[],
    ...args: any[]
  ): Promise<string | M[]> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], BulkCrudOperationKeys.UPDATE_ALL, true)
    ).for(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 query with the specified conditions and options.
   * @summary Provides a simplified way to query the database with common query parameters.
   * @param {Condition<M>} condition - The condition to filter records.
   * @param orderBy - The field to order results by.
   * @param {OrderDirection} [order=OrderDirection.ASC] - The sort direction.
   * @param {number} [limit] - Optional maximum number of results to return.
   * @param {number} [skip] - Optional number of results to skip.
   * @return {Promise<M[]>} The query results as model instances.
   */
  async query(
    context: Ctx | FabricContractContext,
    condition: Condition<M> | string,
    orderBy: string | keyof M,
    order: OrderDirection | string = OrderDirection.ASC,
    limit?: number,
    skip?: number,
    ...args: any[]
  ): Promise<M[] | string> {
    const { ctxArgs } = (
      await this.logCtx([...args, context], PersistenceKeys.QUERY, true)
    ).for(this.create);
    return this.repo.query(
      condition as Condition<M>,
      orderBy as keyof M,
      order as OrderDirection,
      limit,
      skip,
      ...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,
    docsOnly: boolean,
    ...args: any[]
  ): Promise<any> {
    const { ctxArgs } = (await this.logCtx([...args, ctx], "raw", true)).for(
      this.raw
    );
    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 | FabricContractContext): Promise<void> {
    const { log, ctxArgs } = (
      await this.logCtx([ctx], PersistenceKeys.INITIALIZATION, true)
    ).for(this.init);
    log.info(`Running contract ${this.getName()} initialization...`);
    this.initialized = true;
    log.info(`Contract initialization completed.`);
  }

  async healthcheck(
    ctx: Ctx | FabricContractContext
  ): Promise<string | healthcheck> {
    const { log } = (await this.logCtx([ctx], "healthcheck", true)).for(
      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 | FabricContractContext,
    models: string | M[],
    ...args: any[]
  ): Promise<string | M[]> {
    const { log, ctxArgs } = (
      await this.logCtx([...args, ctx], BulkCrudOperationKeys.CREATE_ALL, true)
    ).for(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[], ...ctxArgs);
  }

  protected logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD
  ): FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>;
  protected logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD,
    allowCreate: false
  ): FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>;
  protected logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD,
    allowCreate: true
  ): Promise<
    FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
  >;
  protected logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD,
    allowCreate: boolean = false
  ):
    | Promise<
        FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
      >
    | FabricContextualizedArgs<ARGS, METHOD extends string ? true : false> {
    const ctx = args.pop();
    if (!ctx || !ctx.stub) {
      throw new MissingContextError(`No valid context provided...`);
    }
    const contextualized = FabricCrudContract.adapter["logCtx"](
      [this.clazz as any, ...args] as any,
      operation,
      allowCreate as any,
      ctx
    ) as
      | FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
      | Promise<
          FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
        >;
    function squashArgs(ctx: FabricContextualizedArgs) {
      ctx.ctxArgs.shift(); // removes added model to args
      return ctx as any;
    }

    if (!(contextualized instanceof Promise)) return squashArgs(contextualized);
    return contextualized.then(squashArgs);
  }
  //
  // protected static async logCtx<ARGS extends any[]>(
  //   this: any,
  //   args: ARGS,
  //   method: string
  // ): Promise<
  //   ContextualizedArgs<FabricContractContext, ARGS> & {
  //     stub: ChaincodeStub;
  //     identity: ClientIdentity;
  //   }
  // >;
  // protected static async logCtx<ARGS extends any[]>(
  //   this: any,
  //   args: ARGS,
  //   method: (...args: any[]) => any
  // ): Promise<
  //   ContextualizedArgs<FabricContractContext, ARGS> & {
  //     stub: ChaincodeStub;
  //     identity: ClientIdentity;
  //   }
  // >;
  // protected static async logCtx<ARGS extends any[]>(
  //   this: any,
  //   args: ARGS,
  //   method: ((...args: any[]) => any) | string
  // ): Promise<
  //   ContextualizedArgs<FabricContractContext, ARGS> & {
  //     stub: ChaincodeStub;
  //     identity: ClientIdentity;
  //   }
  // > {
  //   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 ||
  //         new ContractLogger((this as any)?.name || "Contract", undefined)
  //       )
  //         .clear()
  //         .for(this)
  //         .for(method),
  //       ctxArgs: [...args, ctx],
  //       stub: ctx.stub,
  //       identity: ctx.identity,
  //     };
  //
  //   if (!(ctx instanceof Ctx))
  //     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 baseLogger =
  //     context.logger ||
  //     new ContractLogger((this as any)?.name || "Contract", undefined, ctx);
  //   const log = (
  //     this
  //       ? baseLogger.for(this).for(method)
  //       : baseLogger.clear().for(this).for(method)
  //   ) as LoggerOf<FabricContractContext>;
  //   return {
  //     ctx: context,
  //     log: log,
  //     stub: context.stub,
  //     identity: context.identity,
  //     ctxArgs: [...args, context],
  //   };
  // }
}