Source

contracts/ContractAdapter.ts

import {
  CouchDBAdapter,
  CouchDBKeys,
  MangoQuery,
  ViewResponse,
} from "@decaf-ts/for-couchdb";
import { Model, ValidationKeys } from "@decaf-ts/decorator-validation";
import { FabricContractFlags } from "./types";
import { FabricContractContext } from "./ContractContext";
import {
  BadRequestError,
  BaseError,
  BulkCrudOperationKeys,
  ConflictError,
  InternalError,
  NotFoundError,
  onCreate,
  onCreateUpdate,
  OperationKeys,
  PrimaryKeyType,
  SerializationError,
} from "@decaf-ts/db-decorators";
import {
  Context as Ctx,
  Object as FabricObject,
  Property,
  Property as FabricProperty,
} from "fabric-contract-api";
import { Logger, Logging } from "@decaf-ts/logging";
import {
  PersistenceKeys,
  RelationsMetadata,
  Sequence,
  SequenceOptions,
  UnsupportedError,
  Adapter,
  PreparedModel,
  Repository,
  QueryError,
  PagingError,
  MigrationError,
  ObserverError,
  AuthorizationError,
  ForbiddenError,
  ConnectionError,
  ContextualizedArgs,
  Context,
  RawResult,
  Paginator,
  ContextualArgs,
  MaybeContextualArg,
  MethodOrOperation,
  AllOperationKeys,
  FlagsOf,
  ContextOf,
  TransactionOperationKeys,
  EventIds,
  Dispatch,
} from "@decaf-ts/core";
import { FabricContractRepository } from "./FabricContractRepository";
import {
  ChaincodeStub,
  ClientIdentity,
  Iterators,
  StateQueryResponse,
} from "fabric-shim-api";
import { FabricStatement } from "./FabricContractStatement";
import { FabricContractSequence } from "./FabricContractSequence";
import { FabricFlavour } from "../shared/constants";
import { SimpleDeterministicSerializer } from "../shared/SimpleDeterministicSerializer";
import {
  apply,
  Constructor,
  Decoration,
  Metadata,
  propMetadata,
} from "@decaf-ts/decoration";
import { FabricContractPaginator } from "./FabricContractPaginator";
import { MissingContextError } from "../shared/errors";
import { SegregatedModel } from "../shared/index";
import { FabricContractDispatch } from "./FabricContractDispatch";

export type FabricContextualizedArgs<
  ARGS extends any[] = any[],
  EXTEND extends boolean = false,
> = ContextualizedArgs<FabricContractContext, ARGS, EXTEND> & {
  stub: ChaincodeStub;
  identity: ClientIdentity;
};

/**
 * @description Sets the creator or updater field in a model based on the user in the context
 * @summary Callback function used in decorators to automatically set the created_by or updated_by fields
 * with the username from the context when a document is created or updated
 * @template M - Type extending Model
 * @template R - Type extending NanoRepository<M>
 * @template V - Type extending RelationsMetadata
 * @param {R} this - The repository instance
 * @param {FabricContractContext} context - The operation context containing user information
 * @param {V} data - The relation metadata
 * @param {string} key - The property key to set with the username
 * @param {M} model - The model instance being created or updated
 * @return {Promise<void>} A promise that resolves when the operation is complete
 * @function createdByOnFabricCreateUpdate
 * @memberOf module:fabric.contracts
 * @mermaid
 * sequenceDiagram
 *   participant F as createdByOnNanoCreateUpdate
 *   participant C as Context
 *   participant M as Model
 *   F->>C: get("user")
 *   C-->>F: user object
 *   F->>M: set key to user.name
 *   Note over F: If no user in context
 *   F-->>F: throw UnsupportedError
 */
export async function createdByOnFabricCreateUpdate<
  M extends Model,
  R extends FabricContractRepository<M>,
  V extends RelationsMetadata,
>(
  this: R,
  context: ContextOf<R>,
  data: V,
  key: keyof M,
  model: M
): Promise<void> {
  try {
    const user = context.get("identity") as ClientIdentity;
    model[key] = user.getID() as M[typeof key];
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
  } catch (e: unknown) {
    throw new UnsupportedError(
      "No User found in context. Please provide a user in the context"
    );
  }
}

/**
 * @description Adapter for Hyperledger Fabric chaincode state database operations
 * @summary Provides a CouchDB-like interface for interacting with the Fabric state database from within a chaincode contract
 * @template void - No configuration needed for contract adapter
 * @template FabricContractFlags - Flags specific to Fabric contract operations
 * @template FabricContractContext - Context type for Fabric contract operations
 * @class FabricContractAdapter
 * @example
 * ```typescript
 * // In a Fabric chaincode contract class
 * import { FabricContractAdapter } from '@decaf-ts/for-fabric';
 *
 * export class MyContract extends Contract {
 *   private adapter = new FabricContractAdapter();
 *
 *   @Transaction()
 *   async createAsset(ctx: Context, id: string, data: string): Promise<void> {
 *     const model = { id, data, timestamp: Date.now() };
 *     await this.adapter.create('assets', id, model, {}, { stub: ctx.stub });
 *   }
 * }
 * ```
 * @mermaid
 * sequenceDiagram
 *   participant Contract
 *   participant FabricContractAdapter
 *   participant Stub
 *   participant StateDB
 *
 *   Contract->>FabricContractAdapter: create(tableName, id, model, transient, ctx)
 *   FabricContractAdapter->>FabricContractAdapter: Serialize model to JSON
 *   FabricContractAdapter->>Stub: putState(id, serializedData)
 *   Stub->>StateDB: Write data
 *   StateDB-->>Stub: Success
 *   Stub-->>FabricContractAdapter: Success
 *   FabricContractAdapter-->>Contract: model
 */
export class FabricContractAdapter extends CouchDBAdapter<
  any,
  void,
  FabricContractContext
> {
  protected override getClient(): void {
    throw new UnsupportedError("Client is not supported in Fabric contracts");
  }
  /**
   * @description Text decoder for converting binary data to strings
   */
  private static textDecoder = new TextDecoder("utf8");

  protected static readonly serializer = new SimpleDeterministicSerializer();

  /**
   * @description Context constructor for this adapter
   * @summary Overrides the base Context constructor with FabricContractContext
   */
  protected override get Context(): Constructor<FabricContractContext> {
    return FabricContractContext;
  }
  /**
   * @description Gets the repository constructor for this adapter
   * @summary Returns the FabricContractRepository constructor for creating repositories
   * @template M - Type extending Model
   * @return {Constructor<Repository<M, MangoQuery, FabricContractAdapter, FabricContractFlags, FabricContractContext>>} The repository constructor
   */
  override repository<
    R extends Repository<
      any,
      Adapter<any, void, MangoQuery, Context<FabricContractFlags>>
    >,
  >(): Constructor<R> {
    return FabricContractRepository as unknown as Constructor<R>;
  }

  override Paginator<M extends Model>(
    query: MangoQuery,
    size: number,
    clazz: Constructor<M>
  ): Paginator<M, any, MangoQuery> {
    return new FabricContractPaginator(this, query, size, clazz);
  }

  override async Sequence(options: SequenceOptions): Promise<Sequence> {
    return new FabricContractSequence(options, this as any);
  }

  protected override Dispatch(): Dispatch<FabricContractAdapter> {
    return new FabricContractDispatch();
  }

  /**
   * @description Creates a new FabricContractAdapter instance
   * @summary Initializes an adapter for interacting with the Fabric state database
   * @param {void} scope - Not used in this adapter
   * @param {string} [alias] - Optional alias for the adapter instance
   */
  constructor(scope: void, alias?: string) {
    super(scope, FabricFlavour, alias);
  }

  override for(config: Partial<any>, ...args: any): typeof this {
    return super.for(config, ...args);
  }

  protected getModelDefaults<M extends Model>(clazz: Constructor<M>) {
    const m = new clazz();
    return (Metadata.properties(clazz) || []).reduce(
      (acc, p) => {
        if (typeof m[p as keyof M] !== "undefined")
          acc[p as keyof M] = m[p as keyof M];
        return acc;
      },
      {} as Record<keyof M, any>
    );
  }

  /**
   * @description Creates a record in the state database
   * @summary Serializes a model and stores it in the Fabric state database
   * @param {string} tableName - The name of the table/collection
   * @param {string | number} id - The record identifier
   * @param {Record<string, any>} model - The record data
   * @param {Record<string, any>} transient - Transient data (not used in this implementation)
   * @param {...any[]} args - Additional arguments, including the chaincode stub and logger
   * @return {Promise<Record<string, any>>} Promise resolving to the created record
   */
  override async create<M extends Model>(
    clazz: Constructor<M>,
    id: PrimaryKeyType,
    model: Record<string, any>,
    ...args: ContextualArgs<Context<FabricContractFlags>>
  ): Promise<Record<string, any>> {
    const { ctx, log } = this.logCtx(args, this.create);
    this.enforceMirrorAuthorization(clazz, ctx);
    log.info(`in ADAPTER create with args ${args}`);
    const tableName = Model.tableName(clazz);
    const composedKey = ctx.stub.createCompositeKey(tableName, [String(id)]);
    const mirrorCollection = ctx.getOrUndefined("mirrorCollection") as
      | string
      | undefined;
    const fullySegregated = ctx.isFullySegregated && !mirrorCollection;

    if (!mirrorCollection) {
      let existing: any;
      try {
        existing = await this.readState(composedKey, ctx);
      } catch (e: unknown) {
        // eslint-disable-next-line no-ex-assign
        e = this.parseError(e as Error);
        if (!(e instanceof NotFoundError)) throw e;
      }
      if (existing)
        throw new ConflictError(
          `record with id ${id} in table ${tableName} already exists`
        );
    }

    try {
      log.info(`adding entry to ${tableName} table with pk ${id}`);

      if (mirrorCollection) {
        model = await this.forPrivate(mirrorCollection).putState(
          composedKey,
          model,
          ctx
        );
      } else {
        const defaults = this.getModelDefaults(clazz);
        // handle public data if not fully segregated
        if (!fullySegregated) {
          if (
            Object.keys(model).filter((k) => {
              if (k === CouchDBKeys.TABLE) return false;
              return !(
                defaults &&
                k in defaults &&
                defaults[k as keyof M] === model[k]
              );
            }).length
          )
            model = await this.putState(composedKey, model, ctx);
        }

        // handle segregated writes
        const data = ctx.getFromChildren("segregatedData");
        if (data) {
          for (const collection in data) {
            Object.assign(
              model,
              await this.forPrivate(collection).putState(
                composedKey,
                data[collection][id as any],
                ctx
              )
            );
          }
        }
      }
    } catch (e: unknown) {
      throw this.parseError(e as Error);
    }

    return model;
  }

  override async createAll<M extends Model>(
    tableName: Constructor<M>,
    id: PrimaryKeyType[],
    model: Record<string, any>[],
    ...args: ContextualArgs<FabricContractContext>
  ): Promise<Record<string, any>[]> {
    if (id.length !== model.length)
      throw new InternalError("Ids and models must have the same length");
    const { log, ctxArgs } = this.logCtx(args, this.createAll);
    const tableLabel = Model.tableName(tableName);
    log.debug(`Creating ${id.length} entries ${tableLabel} table`);
    return Promise.all(
      id.map((i, count) => this.create(tableName, i, model[count], ...ctxArgs))
    );
  }

  /**
   * @description Reads a record from the state database
   * @summary Retrieves and deserializes a record from the Fabric state database
   * @param {string} tableName - The name of the table/collection
   * @param {string | number} id - The record identifier
   * @param {...any[]} args - Additional arguments, including the chaincode stub and logger
   * @return {Promise<Record<string, any>>} Promise resolving to the retrieved record
   */
  override async read<M extends Model>(
    clazz: Constructor<M>,
    id: PrimaryKeyType,
    ...args: ContextualArgs<Context<FabricContractFlags>>
  ): Promise<Record<string, any>> {
    const { ctx, log } = this.logCtx(args, this.read);
    log.info(`in ADAPTER read with args ${args}`);
    const tableName = Model.tableName(clazz);

    const composedKey = ctx.stub.createCompositeKey(tableName, [String(id)]);
    const mirrorCollection = ctx.getOrUndefined("mirrorCollection") as
      | string
      | undefined;
    const isMirror = ctx.getOrUndefined("mirror") as boolean | undefined;
    if (isMirror && mirrorCollection) {
      try {
        return await this.forPrivate(mirrorCollection).readState(
          composedKey,
          ctx
        );
      } catch (e: unknown) {
        throw this.parseError(e as Error);
      } finally {
        ctx.put("mirror" as any, undefined);
        ctx.put("mirrorCollection" as any, undefined);
      }
    }

    let model: Record<string, any>;

    try {
      model = ctx.isFullySegregated
        ? {}
        : await this.readState(composedKey, ctx);
    } catch (e: unknown) {
      throw this.parseError(e as Error);
    }

    const readCollections = new Set<string>([
      ...(ctx.getReadCollections() || []),
      ...(ctx.consumeReadCollections() || []),
    ]);
    for (const col of readCollections)
      Object.assign(
        model,
        await this.forPrivate(col).readState(composedKey, ctx)
      );
    return model;
  }

  /**
   * @description Updates a record in the state database
   * @summary Serializes a model and updates it in the Fabric state database
   * @param {string} tableName - The name of the table/collection
   * @param {string | number} id - The record identifier
   * @param {Record<string, any>} model - The updated record data
   * @param {Record<string, any>} transient - Transient data (not used in this implementation)
   * @param {...any[]} args - Additional arguments, including the chaincode stub and logger
   * @return {Promise<Record<string, any>>} Promise resolving to the updated record
   */
  override async update<M extends Model>(
    clazz: Constructor<M>,
    id: PrimaryKeyType,
    model: Record<string, any>,
    ...args: ContextualArgs<Context<FabricContractFlags>>
  ): Promise<Record<string, any>> {
    const { ctx, log } = this.logCtx(args, this.update);

    this.enforceMirrorAuthorization(clazz, ctx);
    log.info(`in ADAPTER update with args ${args}`);
    const tableName = Model.tableName(clazz);
    const composedKey = ctx.stub.createCompositeKey(tableName, [String(id)]);
    const mirrorCollection = ctx.getOrUndefined("mirrorCollection") as
      | string
      | undefined;

    try {
      log.info(`updating entry in ${tableName} table with pk ${id}`);

      if (mirrorCollection) {
        model = await this.forPrivate(mirrorCollection).putState(
          composedKey,
          model,
          ctx
        );
      } else {
        const defaults = this.getModelDefaults(clazz);
        // handle public data
        if (
          Object.keys(model).filter((k) => {
            if (k === CouchDBKeys.TABLE) return false;
            return !(
              defaults &&
              k in defaults &&
              defaults[k as keyof M] === model[k]
            );
          }).length
        )
          model = await this.putState(composedKey, model, ctx);

        // handle segregated writes
        const data = ctx.getFromChildren("segregatedData");
        if (data) {
          for (const collection in data) {
            Object.assign(
              model,
              await this.forPrivate(collection).putState(
                composedKey,
                data[collection][id as any],
                ctx
              )
            );
          }
        }
      }
    } catch (e: unknown) {
      throw this.parseError(e as Error);
    }

    return model;
  }

  /**
   * @description Deletes a record from the state database
   * @summary Retrieves a record and then removes it from the Fabric state database
   * @param {string} tableName - The name of the table/collection
   * @param {string | number} id - The record identifier to delete
   * @param {...any[]} args - Additional arguments, including the chaincode stub and logger
   * @return {Promise<Record<string, any>>} Promise resolving to the deleted record
   */
  async delete<M extends Model>(
    clazz: Constructor<M>,
    id: PrimaryKeyType,
    ...args: ContextualArgs<Context<FabricContractFlags>>
  ): Promise<Record<string, any>> {
    const { ctx } = this.logCtx(args, this.delete);

    this.enforceMirrorAuthorization(clazz, ctx);
    const tableName = Model.tableName(clazz);

    const composedKey = ctx.stub.createCompositeKey(tableName, [String(id)]);
    const mirrorCollection = ctx.getOrUndefined("mirrorCollection") as
      | string
      | undefined;
    let model: Record<string, any>;

    if (mirrorCollection) {
      try {
        model = await this.forPrivate(mirrorCollection).readState(
          composedKey,
          ctx
        );
        await this.forPrivate(mirrorCollection).deleteState(composedKey, ctx);
      } catch (e: unknown) {
        throw this.parseError(e as Error);
      }
    } else {
      try {
        model = ctx.isFullySegregated
          ? {}
          : await this.readState(composedKey, ctx);
        if (!ctx.isFullySegregated) await this.deleteState(composedKey, ctx);
      } catch (e: unknown) {
        throw this.parseError(e as Error);
      }

      const collections = ctx.getReadCollections();
      if (collections && collections.length) {
        for (const col of collections) {
          Object.assign(
            model,
            await this.forPrivate(col).readState(composedKey, ctx)
          );
          await this.forPrivate(col).deleteState(composedKey, ctx);
        }
      }
    }
    return model;
  }

  protected async deleteState(id: string, context: FabricContractContext) {
    await context.stub.deleteState(id);
  }

  forPrivate(collection: string): FabricContractAdapter {
    const toOverride = [
      this.putState,
      this.readState,
      this.deleteState,
      this.queryResult,
      this.queryResultPaginated,
    ].map((fn) => fn.name);
    return new Proxy(this, {
      get(target, prop, receiver) {
        if (!toOverride.includes(prop as string))
          return Reflect.get(target, prop, receiver);
        return new Proxy((target as any)[prop], {
          async apply(fn, thisArg, argsList) {
            switch (prop) {
              case "putState": {
                // putState signature: (id: string, model: Record<string, any>, ctx: FabricContractContext)
                const [id, model, ctx] = argsList;
                const data = Buffer.from(
                  FabricContractAdapter.serializer.serialize(
                    model as Model,
                    false
                  )
                );
                await ctx.stub.putPrivateData(collection, id.toString(), data);
                return model;
              }
              case "deleteState": {
                // deleteState signature: (id: string, context: FabricContractContext)
                const [id, ctx] = argsList;
                await ctx.stub.deletePrivateData(collection, id.toString());
                return;
              }
              case "readState": {
                // readState signature: (id: string, ctx: FabricContractContext)
                const [id, ctx] = argsList;
                const data = await ctx.stub.getPrivateData(collection, id);
                if (!data || !data.toString().length)
                  throw new NotFoundError(`Record with id ${id} not found`);
                try {
                  return FabricContractAdapter.serializer.deserialize(
                    data.toString("utf8")
                  );
                } catch {
                  return data.toString("utf8");
                }
              }
              case "queryResult": {
                const [stub, rawInput] = argsList;
                const res = await stub.getPrivateDataQueryResult(
                  collection,
                  JSON.stringify(rawInput)
                );
                return res.iterator || res;
              }
              case "queryResultPaginated": {
                const [stub, rawInput, limit, , bookmark] = argsList;

                // Fabric has no native pagination API for private data.
                // Emulate it: query all matching records (with selector
                // and sort preserved), locate the bookmark position in
                // the sorted results, then slice to the page size.
                const query = { ...rawInput };
                delete query.limit;
                delete query.skip;
                delete query.bookmark;

                let iterator = await (
                  stub as ChaincodeStub
                ).getPrivateDataQueryResult(collection, JSON.stringify(query));
                iterator = (iterator as any).iterator || iterator;

                // Collect all matching records from the iterator
                const allResults: Array<{ key: string; value: Buffer }> = [];
                while (true) {
                  const res = await iterator.next();
                  if (res.value && res.value.value) {
                    allResults.push({
                      key: res.value.key,
                      value: Buffer.isBuffer(res.value.value)
                        ? res.value.value
                        : Buffer.from(
                            (res.value.value as any).toString("utf8")
                          ),
                    });
                  }
                  if (res.done) {
                    await iterator.close();
                    break;
                  }
                }

                // Find the bookmark position and slice the page
                let startIndex = 0;
                if (bookmark) {
                  const found = allResults.findIndex((r) => r.key === bookmark);
                  startIndex = found >= 0 ? found + 1 : 0;
                }
                const paged = allResults.slice(startIndex, startIndex + limit);
                const lastKey =
                  paged.length > 0
                    ? paged[paged.length - 1].key
                    : bookmark || "";

                // Wrap the page in an async iterator for resultIterator()
                let idx = 0;
                const arrayIterator = {
                  async next() {
                    if (idx < paged.length) {
                      return { value: paged[idx++], done: false };
                    }
                    return { value: undefined as any, done: true };
                  },
                  async close() {},
                };

                return {
                  iterator:
                    arrayIterator as unknown as Iterators.StateQueryIterator,
                  metadata: {
                    fetchedRecordsCount: paged.length,
                    bookmark: paged.length >= limit ? lastKey : "",
                  },
                };
              }
              default:
                throw new InternalError(
                  `Unsupported method override ${String(prop)}`
                );
            }
          },
        });
      },
    });
  }

  protected async putState(
    id: string,
    model: Record<string, any>,
    ctx: FabricContractContext
  ) {
    let data: Buffer;

    try {
      data = Buffer.from(
        FabricContractAdapter.serializer.serialize(model as Model, false)
      );
    } catch (e: unknown) {
      throw new SerializationError(
        `Failed to serialize record with id ${id}: ${e}`
      );
    }
    await ctx.stub.putState(id.toString(), data);

    return model;
  }

  protected async readState(id: string, ctx: FabricContractContext) {
    let result: any;

    const { log } = this.logCtx([ctx], this.readState);
    const res = (await ctx.stub.getState(id.toString())).toString();
    if (!res) throw new NotFoundError(`Record with id ${id} not found`);
    log.silly(`state retrieved under id ${id}`);
    try {
      result = FabricContractAdapter.serializer.deserialize(res.toString());
    } catch (e: unknown) {
      throw new SerializationError(`Failed to parse record: ${e}`);
    }

    return result;
  }

  protected async queryResult(
    stub: ChaincodeStub,
    rawInput: any,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ...args: ContextualArgs<FabricContractContext>
  ): Promise<Iterators.StateQueryIterator> {
    return stub.getQueryResult(JSON.stringify(rawInput));
  }

  protected async queryResultPaginated(
    stub: ChaincodeStub,
    rawInput: any,
    limit: number = 250,
    page?: number,
    bookmark?: string | number,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ...args: any[]
  ): Promise<StateQueryResponse<Iterators.StateQueryIterator>> {
    return stub.getQueryResultWithPagination(
      JSON.stringify(rawInput),
      limit,
      bookmark?.toString()
    );
  }

  /**
   * @description Decodes binary data to string
   * @summary Converts a Uint8Array to a string using UTF-8 encoding
   * @param {Uint8Array} buffer - The binary data to decode
   * @return {string} The decoded string
   */
  protected decode(buffer: Uint8Array) {
    return FabricContractAdapter.textDecoder.decode(buffer);
  }

  /**
   * @description Creates operation flags for Fabric contract operations
   * @summary Merges default flags with Fabric-specific context information
   * @template M - Type extending Model
   * @param {OperationKeys} operation - The operation being performed
   * @param {Constructor<M>} model - The model constructor
   * @param {Partial<FabricContractFlags>} flags - Partial flags to merge with defaults
   * @param {Ctx} ctx - The Fabric chaincode context
   * @return {FabricContractFlags} The merged flags
   */
  protected override async flags<M extends Model>(
    operation: AllOperationKeys,
    model: Constructor<M> | undefined,
    flags: Partial<FabricContractFlags> | FabricContractContext | Ctx | any
  ): Promise<FabricContractFlags> {
    let baseFlags = {
      segregated: false,
      rebuildWithTransient: false,
      fullySegregated: false,
    };

    baseFlags = Object.assign(baseFlags, flags);
    const stubFromFlags =
      (flags as FabricContractContext).stub || (flags as Ctx).stub;
    const identityFromFlags =
      (flags as FabricContractContext).identity ||
      (flags as Ctx).clientIdentity;
    if (stubFromFlags && identityFromFlags) {
      const txId = stubFromFlags.getTxID();
      Object.assign(baseFlags, {
        stub: stubFromFlags,
        identity: identityFromFlags,
        cert: identityFromFlags.getIDBytes().toString(),
        roles: identityFromFlags.getAttributeValue("roles"),
        logger: Logging.for(
          operation,
          {
            logLevel: false,
            timestamp: false,
            correlationId: txId,
          },
          flags
        ),
        correlationId: txId,
      });
    } else {
      baseFlags = Object.assign(baseFlags, flags || {});
    }

    return (await super.flags(
      operation,
      model,
      baseFlags as any
    )) as FabricContractFlags;
  }

  /**
   * @description Creates an index for a model
   * @summary This method is not implemented for Fabric contracts and returns a resolved promise
   * @template M - Type extending Model
   * @param {Constructor<M>} models - The model constructor
   * @return {Promise<void>} Promise that resolves immediately
   */
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected index<M>(models: Constructor<M>): Promise<void> {
    return Promise.resolve(undefined);
  }

  /**
   * @description Processes results from a state query iterator
   * @summary Iterates through query results and converts them to a structured format
   * @param {Logger} log - Logger instance for debugging
   * @param {Iterators.StateQueryIterator} iterator - The state query iterator
   * @param {boolean} [isHistory=false] - Whether this is a history query
   * @return {Promise<any[]>} Promise resolving to an array of processed results
   * @mermaid
   * sequenceDiagram
   *   participant Caller
   *   participant ResultIterator
   *   participant Iterator
   *
   *   Caller->>ResultIterator: resultIterator(log, iterator, isHistory)
   *   loop Until done
   *     ResultIterator->>Iterator: next()
   *     Iterator-->>ResultIterator: { value, done }
   *     alt Has value
   *       ResultIterator->>ResultIterator: Process value based on isHistory
   *       ResultIterator->>ResultIterator: Add to results array
   *     end
   *   end
   *   ResultIterator->>Iterator: close()
   *   ResultIterator-->>Caller: allResults
   */
  protected async resultIterator(
    log: Logger,
    iterator: Iterators.StateQueryIterator,
    isHistory = false
  ) {
    const allResults = [];
    let res: { value: any; done: boolean } = await iterator.next();
    while (!res.done) {
      if (res.value && res.value.value.toString()) {
        let jsonRes: any = {};
        log.debug(res.value.value.toString("utf8"));
        if (isHistory /* && isHistory === true*/) {
          jsonRes.TxId = res.value.txId;
          jsonRes.Timestamp = res.value.timestamp;
          try {
            jsonRes.Value = JSON.parse(res.value.value.toString("utf8"));
          } catch (err: any) {
            log.error(err);
            jsonRes.Value = res.value.value.toString("utf8");
          }
        } else {
          try {
            jsonRes = JSON.parse(res.value.value.toString("utf8"));
          } catch (err: any) {
            log.error(err);
            jsonRes = res.value.value.toString("utf8");
          }
        }
        allResults.push(jsonRes);
      }
      res = await iterator.next();
    }
    log.debug(`Closing iterator after ${allResults.length} results`);
    await iterator.close();
    return allResults;
  }

  /**
   * @description Executes a raw query against the state database
   * @summary Performs a rich query using CouchDB syntax against the Fabric state database
   * @template R - The return type
   * @param {MangoQuery} rawInput - The Mango Query to execute
   * @param {boolean} docsOnly - Whether to return only documents (not used in this implementation)
   * @param {...any[]} args - Additional arguments, including the chaincode stub and logger
   * @return {Promise<R>} Promise resolving to the query results
   * @mermaid
   * sequenceDiagram
   *   participant Caller
   *   participant FabricContractAdapter
   *   participant Stub
   *   participant StateDB
   *
   *   Caller->>FabricContractAdapter: raw(rawInput, docsOnly, ctx)
   *   FabricContractAdapter->>FabricContractAdapter: Extract limit and skip
   *   alt With pagination
   *     FabricContractAdapter->>Stub: getQueryResultWithPagination(query, limit, skip)
   *   else Without pagination
   *     FabricContractAdapter->>Stub: getQueryResult(query)
   *   end
   *   Stub->>StateDB: Execute query
   *   StateDB-->>Stub: Iterator
   *   Stub-->>FabricContractAdapter: Iterator
   *   FabricContractAdapter->>FabricContractAdapter: resultIterator(log, iterator)
   *   FabricContractAdapter-->>Caller: results
   */
  async raw<R, D extends boolean>(
    rawInput: MangoQuery,
    docsOnly: D = true as D,
    ...args: ContextualArgs<FabricContractContext>
  ): Promise<RawResult<R, D>> {
    const { log, ctx, ctxArgs } = this.logCtx(args, this.raw);

    const enableSegregates = !args.length || args[0] !== true;
    const fullySegregated = enableSegregates && ctx.isFullySegregated;

    const { skip, limit } = rawInput;
    const bookmark = rawInput["bookmark"];
    let resp = { docs: [], bookmark: undefined as string | undefined };

    // Query public state only when the model is NOT fully segregated
    if (!fullySegregated) {
      let iterator: Iterators.StateQueryIterator;
      if (limit || skip) {
        delete rawInput["limit"];
        delete rawInput["skip"];
        log.debug(
          `Retrieving paginated iterator: limit: ${limit}/ skip: ${skip}`
        );
        const response: StateQueryResponse<Iterators.StateQueryIterator> =
          (await this.queryResultPaginated(
            ctx.stub,
            rawInput,
            limit || Number.MAX_VALUE,
            (skip as any)?.toString(),
            bookmark,
            ...[ctx as FabricContractContext]
          )) as StateQueryResponse<Iterators.StateQueryIterator>;
        resp.bookmark = response.metadata.bookmark;
        iterator = response.iterator;
      } else {
        log.debug("Retrieving iterator");
        iterator = (await this.queryResult(
          ctx.stub,
          rawInput,
          ctx
        )) as Iterators.StateQueryIterator;
      }
      log.debug("Iterator acquired");

      resp.docs = (await this.resultIterator(log, iterator)) as any;
      log.debug(
        `returning ${Array.isArray(resp.docs) ? resp.docs.length : 1} results`
      );
    } else {
      // For fully segregated models, strip pagination fields from rawInput
      // so the segregated query below can re-apply them cleanly
      if (limit || skip) {
        delete rawInput["limit"];
        delete rawInput["skip"];
      }
      log.debug("Skipping public state query (fully segregated model)");
    }

    const collections = enableSegregates ? ctx.getReadCollections() : undefined;

    if (collections && collections.length) {
      // Build a fresh input with limit/skip/bookmark restored
      const segregatedInput = { ...rawInput };
      if (limit) segregatedInput.limit = limit;
      if (skip) segregatedInput.skip = skip;
      if (bookmark) segregatedInput["bookmark"] = bookmark;

      const segregated: any[] = [];
      for (const collection of collections) {
        segregated.push(
          await this.forPrivate(collection).raw(
            { ...segregatedInput },
            false,
            true,
            ...ctxArgs
          )
        );
      }
      // choose the response with the most results
      resp = segregated.reduce((acc, curr) => {
        if (!acc) return curr;
        if (curr.docs && curr.docs.length >= acc?.docs.length) return curr;
        return acc;
      }, resp);
    }

    if (docsOnly) {
      return resp.docs as any;
    }
    return resp as any;
  }

  async view<R>(
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ddoc: string,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    viewName: string,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    options: Record<string, any>,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    ..._args: ContextualArgs<FabricContractContext>
  ): Promise<ViewResponse<R>> {
    throw new UnsupportedError(
      "Fabric contracts do not support CouchDB views."
    );
  }

  override Statement<M extends Model>(): FabricStatement<M, any> {
    return new FabricStatement(this as any);
  }

  override async updateAll<M extends Model>(
    tableName: Constructor<M>,
    id: PrimaryKeyType[],
    model: Record<string, any>[],
    ...args: ContextualArgs<FabricContractContext>
  ): Promise<Record<string, any>[]> {
    if (id.length !== model.length)
      throw new InternalError("Ids and models must have the same length");
    const { log, ctxArgs } = this.logCtx(args, this.updateAll);
    const tableLabel = Model.tableName(tableName);
    log.debug(`Updating ${id.length} entries ${tableLabel} table`);
    return Promise.all(
      id.map((i, count) => this.update(tableName, i, model[count], ...ctxArgs))
    );
  }

  /**
   *
   * @param model
   * @param {string} pk
   * @param args
   */
  override prepare<M extends Model>(
    model: M,
    ...args: ContextualArgs<FabricContractContext>
  ): PreparedModel & {
    segregated?: Record<string, Record<string, any>>;
  } {
    const { log, ctx } = this.logCtx(args, this.prepare);
    const split: SegregatedModel<M> = Model.segregate(model);
    const tableName = Model.tableName(model.constructor as any);
    const pk = Model.pk(model.constructor as any);
    const id = model[pk as keyof M];

    const isMirror = ctx.getOrUndefined("mirror") as boolean | undefined;

    const mapToRecord = function (
      this: FabricContractAdapter,
      obj: Record<string, any>,
      keysOverride?: string[]
    ) {
      if (keysOverride)
        keysOverride = [...new Set([...keysOverride, pk as string])];
      const result = Object.entries(obj).reduce(
        (accum: Record<string, any>, [key, val]) => {
          if (typeof val === "undefined") return accum;
          if (keysOverride && !keysOverride.includes(key)) return accum;
          const mappedProp = Model.columnName(model, key as any);
          if (this.isReserved(mappedProp))
            throw new InternalError(`Property name ${mappedProp} is reserved`);
          val = val instanceof Date ? new Date(val) : val;
          accum[mappedProp] = val;
          return accum;
        },
        {}
      );
      if (Object.keys(result).filter((k) => Boolean(result[k])).length) {
        // Add table identifier
        result[CouchDBKeys.TABLE] = tableName;
      }
      return result;
    }.bind(this);

    log.silly(
      `Preparing record for ${tableName} table with pk ${(model as any)[pk]}`
    );

    const segregatedWriteKeys = ctx.getSegregatedWrites();
    const segregatedWrites: Record<string, any> = {};
    if (segregatedWriteKeys) {
      for (const collection in segregatedWriteKeys) {
        segregatedWrites[collection] = segregatedWrites[collection] || {};
        segregatedWrites[collection][id as any] = mapToRecord(
          ctx.getOrUndefined("forceSegregateWrite")
            ? split.model
            : (split.transient as any),
          segregatedWriteKeys[collection]
        );
      }
    }

    // In mirror mode, the record should contain ALL model properties (full copy)
    const record = isMirror ? mapToRecord(model) : mapToRecord(split.model);

    return {
      record,
      id: (model as any)[pk] as string,
      transient:
        !isMirror && split.transient && Object.keys(split.transient).length
          ? mapToRecord(split.transient)
          : undefined,
      segregated: isMirror ? undefined : segregatedWrites,
    };
  }

  override revert<M extends Model>(
    obj: Record<string, any>,
    clazz: Constructor<M>,
    id: PrimaryKeyType,
    transient?: Record<string, any>,
    ...args: ContextualArgs<FabricContractContext>
  ): M {
    const { log, ctx } = this.logCtx(args, this.revert);
    const ob: Record<string, any> = {};
    const pk = Model.pk(clazz);
    const pkProps = Model.pkProps(clazz);
    if (pkProps?.type === Number && typeof id === "string") {
      id = Number(id);
    }
    ob[pk as string] = id;
    log.silly(`Rebuilding model ${clazz.name} id ${id}`);

    function mapToModel(r: Record<any, any>) {
      const m = (
        typeof clazz === "string" ? Model.build(ob, clazz) : new clazz(ob)
      ) as M;
      const attributes = Model.getAttributes(clazz);
      const keys = attributes.length ? attributes : Object.keys(m);
      return keys
        .filter((k) => k !== (pk as string))
        .reduce((accum: M, key) => {
          (accum as Record<string, any>)[key] =
            r[Model.columnName(accum, key as any)];
          return accum;
        }, m);
    }

    let result = mapToModel(obj);

    if (transient && !this.shouldRebuildWithTransient(ctx)) {
      log.debug(
        `filtering transient properties: ${Object.keys(transient).join(", ")}`
      );
      result = Object.entries(result).reduce((acc, [key, v]) => {
        if (key === pk || !(key in transient)) {
          acc[key as keyof M] = v;
        }
        return acc;
      }, new clazz());
    }

    return result;
  }

  private shouldRebuildWithTransient(ctx: FabricContractContext): boolean {
    if (!ctx) return false;
    if (ctx.getOrUndefined("rebuildWithTransient")) return true;
    const operation = ctx.getOrUndefined("operation") as string | undefined;
    if (!operation) return true;
    const op = operation.toString().toLowerCase();
    return !TransactionOperationKeys.map((k) => k.toLowerCase()).includes(op);
  }

  private getContextMsp(context: FabricContractContext): string | undefined {
    const identity = context.get("identity") as
      | string
      | ClientIdentity
      | undefined;
    if (!identity) return undefined;
    if (typeof identity === "string") return identity;
    try {
      return identity.getMSPID();
    } catch {
      return undefined;
    }
  }

  private enforceMirrorAuthorization<M extends Model>(
    clazz: Constructor<M>,
    ctx: FabricContractContext
  ): void {
    const mirrorMeta = Model.mirroredAt(clazz);
    if (!mirrorMeta) return;
    const msp = this.getContextMsp(ctx);
    if (!msp) return;
    if (
      msp === mirrorMeta.mspId ||
      (mirrorMeta.condition && mirrorMeta.condition(msp))
    ) {
      throw new AuthorizationError(
        `Organization ${msp} is not authorized to modify mirrored data`
      );
    }
  }

  override createPrefix<M extends Model>(
    tableName: Constructor<M>,
    id: PrimaryKeyType,
    model: Record<string, any>,
    ...args: MaybeContextualArg<FabricContractContext>
  ) {
    const { ctxArgs } = this.logCtx(args, this.createPrefix);
    const record: Record<string, any> = {};
    record[CouchDBKeys.TABLE] = Model.tableName(tableName);
    Object.assign(record, model);

    return [tableName, id, record, ...ctxArgs] as [
      Constructor<M>,
      PrimaryKeyType,
      Record<string, any>,
      ...any[],
      FabricContractContext,
    ];
  }

  override updatePrefix<M extends Model>(
    tableName: Constructor<M>,
    id: PrimaryKeyType,
    model: Record<string, any>,
    ...args: MaybeContextualArg<FabricContractContext>
  ): any[] {
    const { ctxArgs } = this.logCtx(args, this.updatePrefix);
    const record: Record<string, any> = {};
    record[CouchDBKeys.TABLE] = Model.tableName(tableName);
    Object.assign(record, model);

    return [tableName, id, record, ...ctxArgs] as [
      Constructor<M>,
      PrimaryKeyType,
      Record<string, any>,
      ...any[],
      FabricContractContext,
    ];
  }

  protected override createAllPrefix<M extends Model>(
    tableName: Constructor<M>,
    ids: PrimaryKeyType[],
    models: Record<string, any>[],
    ...args: [...any, FabricContractContext]
  ): (string | string[] | number[] | Record<string, any>[])[] {
    if (ids.length !== models.length)
      throw new InternalError("Ids and models must have the same length");

    const ctx: FabricContractContext = args.pop();

    const records = ids.map((id, count) => {
      const record: Record<string, any> = {};
      record[CouchDBKeys.TABLE] = Model.tableName(tableName);
      Object.assign(record, models[count]);
      return record;
    });
    return [tableName, ids, records, ctx as any];
  }

  protected override updateAllPrefix<M extends Model>(
    tableName: Constructor<M>,
    ids: PrimaryKeyType[],
    models: Record<string, any>[],
    ...args: [...any, FabricContractContext]
  ) {
    if (ids.length !== models.length)
      throw new InternalError("Ids and models must have the same length");

    const ctx: FabricContractContext = args.pop();

    const records = ids.map((id, count) => {
      const record: Record<string, any> = {};
      record[CouchDBKeys.TABLE] = Model.tableName(tableName);
      Object.assign(record, models[count]);
      return record;
    });
    return [tableName, ids, records, ctx as any];
  }

  override parseError<E extends BaseError>(
    err: Error | string,
    reason?: string
  ): E {
    return FabricContractAdapter.parseError(reason || err);
  }

  protected override logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD
  ): FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>;
  protected override 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 override logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD,
    allowCreate: true,
    overrides?: Partial<FlagsOf<FabricContractContext>>
  ): Promise<
    FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
  >;
  protected override logCtx<
    ARGS extends any[] = any[],
    METHOD extends MethodOrOperation = MethodOrOperation,
  >(
    args: MaybeContextualArg<FabricContractContext, ARGS>,
    operation: METHOD,
    allowCreate: boolean = false,
    overrides?: Partial<FlagsOf<FabricContractContext>> | Ctx
  ):
    | Promise<
        FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
      >
    | FabricContextualizedArgs<ARGS, METHOD extends string ? true : false> {
    if (!allowCreate)
      return super.logCtx<ARGS, METHOD>(
        args,
        operation as any,
        allowCreate as any,
        overrides as any
      ) as any;

    return super.logCtx
      .call(this, args, operation as any, allowCreate, overrides as any)
      .then((res) => {
        if (!(res.ctx instanceof FabricContractContext))
          throw new InternalError(`Invalid context binding`);
        return Object.assign(res, {
          stub: res.ctx.stub,
          identity: res.ctx.identity,
        });
      }) as any;
  }

  override async updateObservers(
    table: Constructor<any> | string,
    event: OperationKeys | BulkCrudOperationKeys | string,
    id: EventIds,
    ...args: ContextualArgs<FabricContractContext>
  ): Promise<void> {
    if (!this.observerHandler)
      throw new InternalError(
        "ObserverHandler not initialized. Did you register any observables?"
      );
    const { ctx, ctxArgs } = this.logCtx(args, this.updateObservers);
    if (ctx.isFullySegregated) return;
    if (ctx.getOrUndefined("noEmit")) return;
    if (!ctx.stub) return;
    const isBulk = Array.isArray(id);
    const emitSingle = !ctx.getOrUndefined("noEmitSingle");
    const emitBulk = !ctx.getOrUndefined("noEmitBulk");
    if ((isBulk && emitBulk) || (!isBulk && emitSingle)) {
      // Pass the MSP owner from stub and the result payload (if any) in the
      // positions the handler expects: (table, event, id, owner?, payload?, ctx)
      const mspId = ctx.stub.getMspID ? ctx.stub.getMspID() : undefined;
      const nonCtxArgs = ctxArgs.slice(0, -1); // everything before ctx
      const payload = nonCtxArgs.length > 0 ? nonCtxArgs[0] : undefined;
      await this.observerHandler.updateObservers(
        table,
        event,
        id,
        mspId,
        payload,
        ctx
      );
    }
  }

  static override parseError<E extends BaseError>(err: Error | string): E {
    // if (
    //   MISSING_PRIVATE_DATA_REGEX.test(
    //     typeof err === "string" ? err : err.message
    //   )
    // )
    //   return new UnauthorizedPrivateDataAccess(err) as E;
    const msg = typeof err === "string" ? err : err.message;
    if (msg.includes(NotFoundError.name)) return new NotFoundError(err) as E;
    if (msg.includes(ConflictError.name)) return new ConflictError(err) as E;
    if (msg.includes(BadRequestError.name))
      return new BadRequestError(err) as E;
    if (msg.includes(QueryError.name)) return new QueryError(err) as E;
    if (msg.includes(PagingError.name)) return new PagingError(err) as E;
    if (msg.includes(UnsupportedError.name))
      return new UnsupportedError(err) as E;
    if (msg.includes(MigrationError.name)) return new MigrationError(err) as E;
    if (msg.includes(ObserverError.name)) return new ObserverError(err) as E;
    if (msg.includes(AuthorizationError.name))
      return new AuthorizationError(err) as E;
    if (msg.includes(ForbiddenError.name)) return new ForbiddenError(err) as E;
    if (msg.includes(ConnectionError.name))
      return new ConnectionError(err) as E;
    if (msg.includes(SerializationError.name))
      return new SerializationError(err) as E;
    if (msg.includes("no ledger context"))
      return new MissingContextError(
        `No context found. this can be caused by debugging: ${msg}`
      ) as E;

    return new InternalError(err) as E;
  }

  /**
   * @description Static method for decoration overrides
   * @summary Overrides/extends decaf decoration with Fabric-specific functionality
   * @static
   * @override
   * @return {void}
   */
  static override decoration(): void {
    super.decoration();
    Decoration.flavouredAs(FabricFlavour)
      .for(PersistenceKeys.CREATED_BY)
      .define({
        decorator: function createdBy() {
          return apply(
            onCreate(createdByOnFabricCreateUpdate),
            propMetadata(PersistenceKeys.CREATED_BY, {})
          );
        },
      } as any)
      .apply();

    Decoration.flavouredAs(FabricFlavour)
      .for(PersistenceKeys.UPDATED_BY)
      .define({
        decorator: function createdBy() {
          return apply(
            onCreateUpdate(createdByOnFabricCreateUpdate),
            propMetadata(PersistenceKeys.UPDATED_BY, {})
          );
        },
      } as any)
      .apply();

    Decoration.flavouredAs(FabricFlavour)
      .for(PersistenceKeys.COLUMN)
      .extend(FabricProperty())
      .apply();

    Decoration.flavouredAs(FabricFlavour)
      .for(ValidationKeys.DATE)
      .extend(function fabricProperty() {
        return (target: any, prop?: any) => {
          Property(prop, "string:date")(target, prop);
        };
      });

    Decoration.flavouredAs(FabricFlavour)
      .for(PersistenceKeys.TABLE)
      .extend(function table(obj: any) {
        const chain: any[] = [];
        let current =
          typeof obj === "function"
            ? Metadata.constr(obj)
            : Metadata.constr(obj.constructor);

        while (current && current !== Object && current.prototype) {
          chain.push(current);
          current = Object.getPrototypeOf(current);
        }

        console.log(chain.map((c) => c.name || c));

        // Apply from the base class down to the decorated class
        while (chain.length > 0) {
          const constructor = chain.pop();
          console.log(`Calling on ${constructor.name}`);
          FabricObject()(constructor);
        }

        return FabricObject()(obj);
      })
      .apply();
  }
}

FabricContractAdapter.decoration();
Adapter.setCurrent(FabricFlavour);