import {
Adapter,
PersistenceKeys,
ConnectionError,
Paginator,
RawResult,
} from "@decaf-ts/core";
import { CouchDBKeys, reservedAttributes } from "./constants";
import {
BaseError,
ConflictError,
InternalError,
NotFoundError,
prefixMethod,
type PrimaryKeyType,
} from "@decaf-ts/db-decorators";
import { Model } from "@decaf-ts/decorator-validation";
import { IndexError } from "./errors";
import { type MangoQuery } from "./types";
import { CouchDBPaginator, CouchDBStatement } from "./query";
import { type MaybeContextualArg, Context } from "@decaf-ts/core";
import { type Constructor } from "@decaf-ts/decoration";
import { final } from "@decaf-ts/logging";
import { CouchDBRepository } from "./repository";
import { Repository } from "@decaf-ts/core";
/**
* @description Abstract adapter for CouchDB database operations
* @summary Provides a base implementation for CouchDB database operations, including CRUD operations, sequence management, and error handling
* @template Y - The scope type
* @template F - The repository flags type
* @template C - The context type
* @param {Y} scope - The scope for the adapter
* @param {string} flavour - The flavour of the adapter
* @param {string} [alias] - Optional alias for the adapter
* @class
* @example
* // Example of extending CouchDBAdapter
* class MyCouchDBAdapter extends CouchDBAdapter<MyScope, MyFlags, MyContext> {
* constructor(scope: MyScope) {
* super(scope, 'my-couchdb', 'my-alias');
* }
*
* // Implement abstract methods
* async index<M extends Model>(...models: Constructor<M>[]): Promise<void> {
* // Implementation
* }
*
* async raw<R>(rawInput: MangoQuery, docsOnly: boolean): Promise<R> {
* // Implementation
* }
*
* async create(tableName: string, id: string | number, model: Record<string, any>, ...args: any[]): Promise<Record<string, any>> {
* // Implementation
* }
*
* async read(tableName: string, id: string | number, ...args: any[]): Promise<Record<string, any>> {
* // Implementation
* }
*
* async update(tableName: string, id: string | number, model: Record<string, any>, ...args: any[]): Promise<Record<string, any>> {
* // Implementation
* }
*
* async delete(tableName: string, id: string | number, ...args: any[]): Promise<Record<string, any>> {
* // Implementation
* }
* }
*/
export abstract class CouchDBAdapter<
CONF,
CONN,
C extends Context<any>,
> extends Adapter<CONF, CONN, MangoQuery, C> {
protected constructor(scope: CONF, flavour: string, alias?: string) {
super(scope, flavour, alias);
[this.create, this.createAll, this.update, this.updateAll].forEach((m) => {
const name = m.name;
prefixMethod(this, m, (this as any)[name + "Prefix"]);
});
}
/**
* @description Creates a new CouchDB statement for querying
* @summary Factory method that creates a new CouchDBStatement instance for building queries
* @template M - The model type
* @return {CouchDBStatement<M, any>} A new CouchDBStatement instance
*/
@final()
Statement<M extends Model>(): CouchDBStatement<
M,
Adapter<CONF, CONN, MangoQuery, C>,
any
> {
return new CouchDBStatement(this);
}
override Paginator<M extends Model>(
query: MangoQuery,
size: number,
clazz: Constructor<M>
): Paginator<M, any, MangoQuery> {
return new CouchDBPaginator(this, query, size, clazz);
}
/**
* @description Initializes the adapter by creating indexes for all managed models
* @summary Sets up the necessary database indexes for all models managed by this adapter
* @return {Promise<void>} A promise that resolves when initialization is complete
*/
override async initialize(): Promise<void> {
const managedModels = Adapter.models(this.flavour);
return this.index(...managedModels);
}
override repository<
R extends Repository<any, Adapter<CONF, CONN, MangoQuery, C>>,
>(): Constructor<R> {
return CouchDBRepository as unknown as Constructor<R>;
}
/**
* @description Creates indexes for the given models
* @summary Abstract method that must be implemented to create database indexes for the specified models
* @template M - The model type
* @param {...Constructor<M>} models - The model constructors to create indexes for
* @return {Promise<void>} A promise that resolves when all indexes are created
*/
protected abstract index<M extends Model>(
...models: Constructor<M>[]
): Promise<void>;
/**
* @description Executes a raw Mango query against the database
* @summary Abstract method that must be implemented to execute raw Mango queries. Implementations may treat the first
* additional argument as a boolean `docsOnly` flag before the contextual arguments provided by repositories.
* @template R - The result type
* @param {MangoQuery} rawInput - The raw Mango query to execute
* @param {...MaybeContextualArg<C>} args - Optional `docsOnly` flag followed by contextual arguments
* @return {Promise<R>} A promise that resolves to the query result
*/
abstract override raw<R, D extends boolean>(
rawInput: MangoQuery,
docsOnly: D,
...args: MaybeContextualArg<C>
): Promise<RawResult<R, D>>;
/**
* @description Assigns metadata to a model
* @summary Adds revision metadata to a model as a non-enumerable property
* @param {Record<string, any>} model - The model to assign metadata to
* @param {string} rev - The revision string to assign
* @return {Record<string, any>} The model with metadata assigned
*/
@final()
protected assignMetadata(
model: Record<string, any>,
rev: string
): Record<string, any> {
if (!rev) return model;
CouchDBAdapter.setMetadata(model as any, rev);
return model;
}
/**
* @description Assigns metadata to multiple models
* @summary Adds revision metadata to multiple models as non-enumerable properties
* @param models - The models to assign metadata to
* @param {string[]} revs - The revision strings to assign
* @return The models with metadata assigned
*/
@final()
protected assignMultipleMetadata(
models: Record<string, any>[],
revs: string[]
): Record<string, any>[] {
models.forEach((m, i) => {
CouchDBAdapter.setMetadata(m as any, revs[i]);
return m;
});
return models;
}
/**
* @description Prepares a record for creation
* @summary Adds necessary CouchDB fields to a record before creation
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @param {Record<string, any>} model - The model to prepare
* @return A tuple containing the tableName, id, and prepared record
*/
@final()
protected createPrefix<M extends Model>(
clazz: Constructor<M>,
id: PrimaryKeyType,
model: Record<string, any>,
...args: MaybeContextualArg<C>
): [Constructor<M>, PrimaryKeyType, Record<string, any>, ...any[], Context] {
const { ctxArgs } = this.logCtx(args, this.createPrefix);
const tableName = Model.tableName(clazz);
const record: Record<string, any> = {};
record[CouchDBKeys.TABLE] = tableName;
record[CouchDBKeys.ID] = this.generateId(tableName, id as any);
Object.assign(record, model);
return [clazz, id, record, ...ctxArgs];
}
/**
* @description Creates a new record in the database
* @summary Abstract method that must be implemented to create a new record
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @param {Record<string, any>} model - The model to create
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves to the created record
*/
abstract override create<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
model: Record<string, any>,
...args: MaybeContextualArg<C>
): Promise<Record<string, any>>;
/**
* @description Prepares multiple records for creation
* @summary Adds necessary CouchDB fields to multiple records before creation
* @param {string} tableName - The name of the table
* @param {string[]|number[]} ids - The IDs of the records
* @param models - The models to prepare
* @return A tuple containing the tableName, ids, and prepared records
* @throws {InternalError} If ids and models arrays have different lengths
*/
@final()
protected createAllPrefix<M extends Model>(
clazz: Constructor<M>,
ids: string[] | number[],
models: Record<string, any>[],
...args: MaybeContextualArg<C>
) {
const tableName = Model.tableName(clazz);
if (ids.length !== models.length)
throw new InternalError("Ids and models must have the same length");
const { ctxArgs } = this.logCtx(args, this.createAllPrefix);
const records = ids.map((id, count) => {
const record: Record<string, any> = {};
record[CouchDBKeys.TABLE] = tableName;
record[CouchDBKeys.ID] = this.generateId(tableName, id);
Object.assign(record, models[count]);
return record;
});
return [clazz, ids, records, ...ctxArgs];
}
/**
* @description Reads a record from the database
* @summary Abstract method that must be implemented to read a record
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves to the read record
*/
abstract override read<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
...args: MaybeContextualArg<C>
): Promise<Record<string, any>>;
/**
* @description Prepares a record for update
* @summary Adds necessary CouchDB fields to a record before update
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @param model - The model to prepare
* @param [args] - optional args for subclassing
* @return A tuple containing the tableName, id, and prepared record
* @throws {InternalError} If no revision number is found in the model
*/
@final()
updatePrefix<M extends Model>(
clazz: Constructor<M>,
id: PrimaryKeyType,
model: Record<string, any>,
...args: MaybeContextualArg<C>
) {
const tableName = Model.tableName(clazz);
const { ctxArgs } = this.logCtx(args, this.updatePrefix);
const record: Record<string, any> = {};
record[CouchDBKeys.TABLE] = tableName;
record[CouchDBKeys.ID] = this.generateId(tableName, id);
const rev = model[PersistenceKeys.METADATA];
if (!rev)
throw new InternalError(
`No revision number found for record with id ${id}`
);
Object.assign(record, model);
record[CouchDBKeys.REV] = rev;
return [clazz, id, record, ...ctxArgs];
}
/**
* @description Updates a record in the database
* @summary Abstract method that must be implemented to update a record
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @param {Record<string, any>} model - The model to update
* @param {any[]} args - Additional arguments
* @return A promise that resolves to the updated record
*/
abstract override update<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
model: Record<string, any>,
...args: MaybeContextualArg<C>
): Promise<Record<string, any>>;
/**
* @description Prepares multiple records for update
* @summary Adds necessary CouchDB fields to multiple records before update
* @param {string} tableName - The name of the table
* @param {string[]|number[]} ids - The IDs of the records
* @param models - The models to prepare
* @return A tuple containing the tableName, ids, and prepared records
* @throws {InternalError} If ids and models arrays have different lengths or if no revision number is found in a model
*/
@final()
protected updateAllPrefix<M extends Model>(
clazz: Constructor<M>,
ids: PrimaryKeyType[],
models: Record<string, any>[],
...args: MaybeContextualArg<C>
) {
const tableName = Model.tableName(clazz);
if (ids.length !== models.length)
throw new InternalError("Ids and models must have the same length");
const { ctxArgs } = this.logCtx(args, this.updateAllPrefix);
const records = ids.map((id, count) => {
const record: Record<string, any> = {};
record[CouchDBKeys.TABLE] = tableName;
record[CouchDBKeys.ID] = this.generateId(tableName, id);
const rev = models[count][PersistenceKeys.METADATA];
if (!rev)
throw new InternalError(
`No revision number found for record with id ${id}`
);
Object.assign(record, models[count]);
record[CouchDBKeys.REV] = rev;
return record;
});
return [clazz, ids, records, ...ctxArgs];
}
/**
* @description Deletes a record from the database
* @summary Abstract method that must be implemented to delete a record
* @param {Constructor<M>} tableName - The name of the table
* @param {PrimaryKeyType} id - The ID of the record
* @param {any[]} args - Additional arguments
* @return A promise that resolves to the deleted record
*/
abstract override delete<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
...args: MaybeContextualArg<C>
): Promise<Record<string, any>>;
/**
* @description Generates a CouchDB document ID
* @summary Combines the table name and ID to create a CouchDB document ID
* @param {string} tableName - The name of the table
* @param {string|number} id - The ID of the record
* @return {string} The generated CouchDB document ID
*/
protected generateId(tableName: string, id: PrimaryKeyType) {
return [tableName, id].join(CouchDBKeys.SEPARATOR);
}
/**
* @description Parses an error and converts it to a BaseError
* @summary Converts various error types to appropriate BaseError subtypes
* @param {Error|string} err - The error to parse
* @param {string} [reason] - Optional reason for the error
* @return {BaseError} The parsed error as a BaseError
*/
parseError<E extends BaseError>(err: Error | string, reason?: string): E {
return CouchDBAdapter.parseError(err, reason);
}
/**
* @description Checks if an attribute is reserved
* @summary Determines if an attribute name is reserved in CouchDB
* @param {string} attr - The attribute name to check
* @return {boolean} True if the attribute is reserved, false otherwise
*/
protected override isReserved(attr: string): boolean {
return !!attr.match(reservedAttributes);
}
/**
* @description Static method to parse an error and convert it to a BaseError
* @summary Converts various error types to appropriate BaseError subtypes based on error codes and messages
* @param {Error|string} err - The error to parse
* @param {string} [reason] - Optional reason for the error
* @return {BaseError} The parsed error as a BaseError
* @mermaid
* sequenceDiagram
* participant Caller
* participant parseError
* participant ErrorTypes
*
* Caller->>parseError: err, reason
* Note over parseError: Check if err is already a BaseError
* alt err is BaseError
* parseError-->>Caller: return err
* else err is string
* Note over parseError: Extract code from string
* alt code matches "already exist|update conflict"
* parseError->>ErrorTypes: new ConflictError(code)
* ErrorTypes-->>Caller: ConflictError
* else code matches "missing|deleted"
* parseError->>ErrorTypes: new NotFoundError(code)
* ErrorTypes-->>Caller: NotFoundError
* end
* else err has code property
* Note over parseError: Extract code and reason
* else err has statusCode property
* Note over parseError: Extract code and reason
* else
* Note over parseError: Use err.message as code
* end
*
* Note over parseError: Switch on code
* alt code is 401, 412, or 409
* parseError->>ErrorTypes: new ConflictError(reason)
* ErrorTypes-->>Caller: ConflictError
* else code is 404
* parseError->>ErrorTypes: new NotFoundError(reason)
* ErrorTypes-->>Caller: NotFoundError
* else code is 400
* alt code matches "No index exists"
* parseError->>ErrorTypes: new IndexError(err)
* ErrorTypes-->>Caller: IndexError
* else
* parseError->>ErrorTypes: new InternalError(err)
* ErrorTypes-->>Caller: InternalError
* end
* else code matches "ECONNREFUSED"
* parseError->>ErrorTypes: new ConnectionError(err)
* ErrorTypes-->>Caller: ConnectionError
* else
* parseError->>ErrorTypes: new InternalError(err)
* ErrorTypes-->>Caller: InternalError
* end
*/
protected static parseError<E extends BaseError>(
err: Error | string,
reason?: string
): E {
if (err instanceof BaseError) return err as any;
let code: string = "";
if (typeof err === "string") {
code = err;
if (code.match(/already exist|update conflict/g))
return new ConflictError(code) as E;
if (code.match(/missing|deleted/g)) return new NotFoundError(code) as E;
} else if ((err as any).code) {
code = (err as any).code;
reason = reason || err.message;
} else if ((err as any).statusCode) {
code = (err as any).statusCode;
reason = reason || err.message;
} else {
code = err.message;
}
switch (code.toString()) {
case "401":
case "412":
case "409":
return new ConflictError(reason as string) as E;
case "404":
return new NotFoundError(reason as string) as E;
case "400":
if (code.toString().match(/No\sindex\sexists/g))
return new IndexError(err) as E;
return new InternalError(err) as E;
default:
if (code.toString().match(/ECONNREFUSED/g))
return new ConnectionError(err) as E;
return new InternalError(err) as E;
}
}
// TODO why do we need this?
/**
* @description Sets metadata on a model instance.
* @summary Attaches metadata to a model instance using a non-enumerable property.
* @template M - The model type that extends Model.
* @param {M} model - The model instance.
* @param {any} metadata - The metadata to attach to the model.
*/
static setMetadata<M extends Model>(model: M, metadata: any) {
Object.defineProperty(model, PersistenceKeys.METADATA, {
enumerable: false,
configurable: true,
writable: true,
value: metadata,
});
}
/**
* @description Gets metadata from a model instance.
* @summary Retrieves previously attached metadata from a model instance.
* @template M - The model type that extends Model.
* @param {M} model - The model instance.
* @return {any} The metadata or undefined if not found.
*/
static getMetadata<M extends Model>(model: M) {
const descriptor = Object.getOwnPropertyDescriptor(
model,
PersistenceKeys.METADATA
);
return descriptor ? descriptor.value : undefined;
}
/**
* @description Removes metadata from a model instance.
* @summary Deletes the metadata property from a model instance.
* @template M - The model type that extends Model.
* @param {M} model - The model instance.
*/
static removeMetadata<M extends Model>(model: M) {
const descriptor = Object.getOwnPropertyDescriptor(
model,
PersistenceKeys.METADATA
);
if (descriptor) delete (model as any)[PersistenceKeys.METADATA];
}
}
Source