UNPKG

@decaf-ts/for-nano

Version:

decaf-ts persistence adapter for CouchDB via nano

766 lines 30.4 kB
import { ConflictError, InternalError, onCreate, onCreateUpdate, } from "@decaf-ts/db-decorators"; import { CouchDBAdapter, CouchDBKeys, generateIndexes, generateViews, wrapDocumentScope, } from "@decaf-ts/for-couchdb"; import Nano from "nano"; import { Model } from "@decaf-ts/decorator-validation"; import { Adapter, PersistenceKeys, UnsupportedError, } from "@decaf-ts/core"; import { NanoFlavour } from "./constants.js"; import { NanoRepository } from "./NanoRepository.js"; import { NanoDispatch } from "./NanoDispatch.js"; import { Decoration, propMetadata } from "@decaf-ts/decoration"; /** * @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 {Context<NanoFlags>} context - The operation context containing user information * @param {V} data - The relation metadata * @param 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 createdByOnNanoCreateUpdate * @memberOf module:for-nano * @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 createdByOnNanoCreateUpdate(context, data, key, model) { try { const user = context.get("user"); model[key] = (user.name || user); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { throw new UnsupportedError("No User found in context. Please provide a user in the context"); } } /** * @description Adapter for interacting with Nano databases * @summary Provides a standardized interface for performing CRUD operations on Nano databases, * extending the CouchDB adapter with Nano-specific functionality. This adapter handles document * creation, reading, updating, and deletion, as well as bulk operations and index management. * @template DocumentScope - The Nano document scope type * @template NanoFlags - Configuration flags for Nano operations * @template Context - Context type for operations * @param {DocumentScope<any>} scope - The Nano document scope to use for database operations * @param {string} [alias] - Optional alias for the adapter * @class NanoAdapter * @example * ```typescript * // Connect to a Nano database * const server = NanoAdapter.connect('admin', 'password', 'localhost:5984'); * const db = server.db.use('my_database'); * * // Create an adapter instance * const adapter = new NanoAdapter(db); * * // Use the adapter for database operations * const document = await adapter.read('users', '123'); * ``` * @mermaid * classDiagram * class CouchDBAdapter { * +flags() * +Dispatch() * +index() * +create() * +read() * +update() * +delete() * } * class NanoAdapter { * +flags() * +Dispatch() * +index() * +create() * +createAll() * +read() * +readAll() * +update() * +updateAll() * +delete() * +deleteAll() * +raw() * +static connect() * +static createDatabase() * +static deleteDatabase() * +static createUser() * +static deleteUser() * +static decoration() * } * CouchDBAdapter <|-- NanoAdapter */ export class NanoAdapter extends CouchDBAdapter { constructor(scope, alias) { super(scope, NanoFlavour, alias); } /** * @description Shuts down the adapter instance * @summary Cleans up internal resources and clears the cached Nano client instance * @return {Promise<void>} A promise that resolves when shutdown completes */ async shutdown(...args) { const { ctxArgs } = (await this.logCtx(args, PersistenceKeys.SHUTDOWN, true)).for(this.shutdown); await super.shutdown(...ctxArgs); if (this._client) { NanoAdapter.closeConnection(this._client[CouchDBKeys.NATIVE]); this._client = undefined; } } /** * @description Lazily creates and returns the Nano DocumentScope client * @summary Uses the adapter configuration to establish a connection and wrap a database scope with credentials * @return {DocumentScope<any>} The ready-to-use Nano DocumentScope for the configured database */ getClient() { const { couchUser, couchPassword, host, dbName, protocol } = this.config; const con = NanoAdapter.connect(couchUser, couchPassword, host, protocol); return wrapDocumentScope(con, dbName, couchUser, couchPassword); } /** * @description Generates flags for database operations * @summary Creates a set of flags for a specific operation, including user information * @template M - Type extending Model * @param {OperationKeys} operation - The operation being performed (create, read, update, delete) * @param {Constructor<M>} model - The model constructor * @param {Partial<NanoFlags>} flags - Partial flags to be merged * @return {Promise<NanoFlags>} Complete flags for the operation */ async flags(operation, model, flags, ...args) { return super.flags(operation, model, Object.assign({ user: { name: flags.user?.name || this.config.couchUser, }, }, flags, ...args)); } /** * @description Creates a new NanoDispatch instance * @summary Returns a dispatcher for handling Nano-specific operations * @return {NanoDispatch} A new NanoDispatch instance */ Dispatch() { return new NanoDispatch(); } repository() { return NanoRepository; } /** * @description Creates database indexes for models * @summary Generates and creates indexes in the Nano database based on the provided models * @template M - Type extending Model * @param models - Model constructors to create indexes for * @return {Promise<void>} A promise that resolves when all indexes are created * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant G as generateIndexes * participant DB as Nano Database * A->>G: generateIndexes(models) * G-->>A: indexes * loop For each index * A->>DB: createIndex(index) * DB-->>A: response * Note over A: Check if index already exists * alt Index exists * A-->>A: throw ConflictError * end * end */ async index(...models) { const indexes = generateIndexes(models); for (const index of indexes) { const res = await this.client.createIndex(index); const { result, id, name } = res; if (result === "existing") throw new ConflictError(`Index for table ${name} with id ${id}`); } const views = generateViews(models); for (const view of views) { try { await this.client.insert(view); } catch (e) { if (e?.statusCode === 409 || e?.error === "conflict") { const existing = await this.client.get(view._id); await this.client.insert({ ...view, _rev: existing._rev, }); } else { throw e; } } } } /** * @description Creates a new document in the database * @summary Inserts a new document into the Nano database with the provided data * @param {string} tableName - The name of the table/collection * @param {string | number} id - The document identifier * @param {Record<string, any>} model - The document data to insert * @return {Promise<Record<string, any>>} A promise that resolves to the created document with metadata * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Database * A->>DB: insert(model) * alt Success * DB-->>A: response with ok=true * A->>A: assignMetadata(model, response.rev) * A-->>A: return document with metadata * else Error * DB-->>A: error * A-->>A: throw parseError(e) * else Not OK * DB-->>A: response with ok=false * A-->>A: throw InternalError * end */ async create(tableName, id, model, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { let response; try { response = await this.client.insert(model); } catch (e) { throw this.parseError(e); } if (!response.ok) throw new InternalError(`Failed to insert doc id: ${id} in table ${tableName}`); return this.assignMetadata(model, response.rev); } /** * @description Creates multiple documents in the database * @summary Inserts multiple documents into the Nano database in a single bulk operation * @param {string} tableName - The name of the table/collection * @param {string[] | number[]} ids - Array of document identifiers * @param models - Array of document data to insert * @return A promise that resolves to an array of created documents with metadata * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Database * A->>DB: bulk({docs: models}) * alt Success * DB-->>A: response array * A->>A: Check if all responses have no errors * alt All OK * A->>A: assignMultipleMetadata(models, revs) * A-->>A: return documents with metadata * else Some errors * A->>A: Collect error messages * A-->>A: throw InternalError with collected messages * end * else Error * DB-->>A: error * A-->>A: throw parseError(e) * end */ async createAll(tableName, ids, models, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { let response; try { response = await this.client.bulk({ docs: models }); } catch (e) { throw this.parseError(e); } if (!response.every((r) => !r.error)) { const errors = response.reduce((accum, el, i) => { if (el.error) accum.push(`el ${i}: ${el.error}${el.reason ? ` - ${el.reason}` : ""}`); return accum; }, []); throw new InternalError(errors.join("\n")); } return this.assignMultipleMetadata(models, response.map((r) => r.rev)); } /** * @description Retrieves a document from the database * @summary Fetches a single document from the Nano database by its ID * @param {string} tableName - The name of the table/collection * @param {string | number} id - The document identifier * @return {Promise<Record<string, any>>} A promise that resolves to the retrieved document with metadata * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Database * A->>A: generateId(tableName, id) * A->>DB: get(_id) * alt Success * DB-->>A: record * A->>A: assignMetadata(record, record._rev) * A-->>A: return document with metadata * else Error * DB-->>A: error * A-->>A: throw parseError(e) * end */ async read(tableName, id, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { const _id = this.generateId(Model.tableName(tableName), id); let record; try { record = await this.client.get(_id); } catch (e) { throw this.parseError(e); } return this.assignMetadata(record, record._rev); } /** * @description Retrieves multiple documents from the database * @summary Fetches multiple documents from the Nano database by their IDs in a single operation * @param {string} tableName - The name of the table/collection * @param {Array<string | number | bigint>} ids - Array of document identifiers * @return A promise that resolves to an array of retrieved documents with metadata * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Database * A->>A: Map ids to generateId(tableName, id) * A->>DB: fetch({keys: mappedIds}, {}) * DB-->>A: results * A->>A: Process each result row * loop For each row * alt Row has error * A-->>A: throw InternalError * else Row has document * A->>A: assignMetadata(doc, doc._rev) * else No document * A-->>A: throw InternalError * end * end * A-->>A: return documents with metadata */ async readAll(tableName, ids, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { const table = Model.tableName(tableName); const results = await this.client.fetch({ keys: ids.map((id) => this.generateId(table, id)) }, {}); return results.rows.map((r) => { if (r.error) throw new InternalError(r.error); if (r.doc) { const res = Object.assign({}, r.doc); return this.assignMetadata(res, r.doc[CouchDBKeys.REV]); } throw new InternalError("Should be impossible"); }); } /** * @description Updates a document in the database * @summary Updates an existing document in the Nano database with the provided data * @param {string} tableName - The name of the table/collection * @param {string | number} id - The document identifier * @param {Record<string, any>} model - The updated document data * @return {Promise<Record<string, any>>} A promise that resolves to the updated document with metadata * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Database * A->>DB: insert(model) * alt Success * DB-->>A: response with ok=true * A->>A: assignMetadata(model, response.rev) * A-->>A: return document with metadata * else Error * DB-->>A: error * A-->>A: throw parseError(e) * else Not OK * DB-->>A: response with ok=false * A-->>A: throw InternalError * end */ async update(tableName, id, model, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { let response; try { response = await this.client.insert(model); } catch (e) { throw this.parseError(e); } if (!response.ok) throw new InternalError(`Failed to update doc id: ${id} in table ${tableName}`); return this.assignMetadata(model, response.rev); } /** * @description Updates multiple documents in the database * @summary Performs a bulk update operation on the Nano database for the provided documents * @param {string} tableName - The name of the table/collection * @param {Array<string|number>} ids - Array of document identifiers * @param {Promise<Array<Record<string, any>>>} models - Array of updated document data * @return {Promise<Promise<Array<Record<string, any>>>>} A promise that resolves to the updated documents with metadata */ async updateAll(tableName, ids, models, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { let response; try { response = await this.client.bulk({ docs: models }); } catch (e) { throw this.parseError(e); } if (!response.every((r) => !r.error)) { const errors = response.reduce((accum, el, i) => { if (el.error) accum.push(`el ${i}: ${el.error}${el.reason ? ` - ${el.reason}` : ""}`); return accum; }, []); throw new InternalError(errors.join("\n")); } return this.assignMultipleMetadata(models, response.map((r) => r.rev)); } /** * @description Deletes a document from the database * @summary Removes a single document from the Nano database by its ID and returns the deleted document metadata * @param {string} tableName - The name of the table/collection * @param {string|number} id - The document identifier * @return {Promise<Record<string, any>>} A promise that resolves to the deleted document with metadata */ async delete(tableName, id, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { const _id = this.generateId(Model.tableName(tableName), id); let record; try { record = await this.client.get(_id); await this.client.destroy(_id, record._rev); } catch (e) { throw this.parseError(e); } return this.assignMetadata(record, record._rev); } /** * @description Deletes multiple documents from the database * @summary Performs a bulk delete operation for the provided IDs and returns the deleted documents metadata * @param {string} tableName - The name of the table/collection * @param {Array<string|number|bigint>} ids - Array of document identifiers to delete * @return {Promise<Array<Record<string, any>>>} A promise resolving to the deleted documents with metadata */ async deleteAll(tableName, ids, ...args) { const { log } = this.logCtx(args, this.deleteAll); const table = Model.tableName(tableName); const keys = ids.map((id) => this.generateId(table, id)); const results = await this.client.fetch({ keys }, { include_docs: true }); const docs = results.rows.map((row, index) => { if (row.error) throw new InternalError(row.error); if (!row.doc) throw new InternalError(`Unable to load document ${row.id ?? keys[index]}`); return Object.assign({}, row.doc); }); const deletion = await this.client.bulk({ docs: docs.map((doc) => ({ _id: doc[CouchDBKeys.ID], _rev: doc[CouchDBKeys.REV], [CouchDBKeys.DELETED]: true, })), }); deletion.forEach((d, index) => { if (d.error) log.error(`Failed to delete document ${docs[index][CouchDBKeys.ID]}: ${d.error} ${d.reason ? `- ${d.reason}` : ""}`); }); return docs.map((doc) => this.assignMetadata({ ...doc }, doc[CouchDBKeys.REV])); } /** * @description Executes a raw Mango query against the database * @summary Runs a Mango query using Nano's find API and optionally returns only the documents array * @template R - The expected response or document array type * @param {MangoQuery} rawInput - The Mango query to execute * @param {boolean} [docsOnly=true] - Whether to return only the docs array or the full response * @return {Promise<R>} A promise that resolves to the query result, shaped according to docsOnly */ async raw(rawInput, docsOnly = true, ...args) { try { const response = await this.client.find(rawInput); if (response.warning) { const { log } = this.logCtx(args, this.raw); // Nano/CouchDB warnings here usually mean "no matching index found". // Include the query details to make it actionable when it happens. const details = { warning: response.warning, selector: rawInput?.selector, sort: rawInput?.sort, use_index: rawInput?.use_index, fields: rawInput?.fields, limit: rawInput?.limit, skip: rawInput?.skip, }; log.warn(`${response.warning} query=${JSON.stringify(details, null, 2)}`); } if (docsOnly) return response.docs; return response; } catch (e) { throw this.parseError(e); } } async view(ddoc, viewName, options, ..._args) { void _args; try { return (await this.client.view(ddoc, viewName, options)); } catch (e) { throw this.parseError(e); } } /** * @description Establishes a connection to a Nano (CouchDB) server * @summary Creates and returns a Nano ServerScope using the given credentials, host, and protocol * @param {string} user - Username used for authentication * @param {string} pass - Password used for authentication * @param {string} [host="localhost:5984"] - Host and port of the CouchDB server * @param {("http"|"https")} [protocol="http"] - Protocol to use for the connection * @return {ServerScope} The Nano ServerScope connection */ static connect(user, pass, host = "localhost:5984", protocol = "http", agent) { const requestDefaults = agent ? { agent } : undefined; const con = Nano({ url: `${protocol}://${user}:${pass}@${host}`, ...(requestDefaults ? { requestDefaults } : {}), }); if (con && agent) { con._decafAgent = agent; } return con; } /** * @description Creates a new database on the Nano server * @summary Creates a new database with the specified name on the connected Nano server * @param {ServerScope} con - The Nano server connection * @param {string} name - The name of the database to create * @return {Promise<void>} A promise that resolves when the database is created * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Server * A->>DB: db.create(name) * alt Success * DB-->>A: result with ok=true * else Error * DB-->>A: error * A-->>A: throw parseError(e) * else Not OK * DB-->>A: result with ok=false * A-->>A: throw parseError(error, reason) * end */ static async createDatabase(con, name) { let result; try { result = await con.db.create(name); } catch (e) { throw CouchDBAdapter.parseError(e); } const { ok, error, reason } = result; if (!ok) throw CouchDBAdapter.parseError(error, reason); } /** * @description Deletes a database from the Nano server * @summary Removes an existing database with the specified name from the connected Nano server * @param {ServerScope} con - The Nano server connection * @param {string} name - The name of the database to delete * @return {Promise<void>} A promise that resolves when the database is deleted * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant DB as Nano Server * A->>DB: db.destroy(name) * alt Success * DB-->>A: result with ok=true * else Error * DB-->>A: error * A-->>A: throw parseError(e) * else Not OK * DB-->>A: result with ok=false * A-->>A: throw InternalError * end */ static async deleteDatabase(con, name) { let result; try { result = await con.db.destroy(name); } catch (e) { throw CouchDBAdapter.parseError(e); } const { ok } = result; if (!ok) throw new InternalError(`Failed to delete database with name ${name}`); } /** * @description Closes the keep-alive agent associated with a Nano connection * @summary Destroys the custom HTTP/HTTPS agent that was created during connection setup * @param {ServerScope} con - The Nano server connection whose agent should be destroyed */ static closeConnection(con) { if (!con) return; const agent = con?._decafAgent; if (agent && typeof agent.destroy === "function") { agent.destroy(); } } /** * @description Creates a new user and grants access to a database * @summary Creates a new user in the Nano server and configures security to grant the user access to a specific database * @param {ServerScope} con - The Nano server connection * @param {string} dbName - The name of the database to grant access to * @param {string} user - The username to create * @param {string} pass - The password for the new user * @param {string[]} [roles=["reader", "writer"]] - The roles to assign to the user * @return {Promise<void>} A promise that resolves when the user is created and granted access * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant U as _users Database * participant S as Security API * A->>A: Create user object * A->>U: insert(user) * alt Success * U-->>A: response with ok=true * A->>S: PUT _security with user permissions * alt Security Success * S-->>A: security response with ok=true * else Security Failure * S-->>A: security response with ok=false * A-->>A: throw InternalError * end * else Error * U-->>A: error * A-->>A: throw parseError(e) * else Not OK * U-->>A: response with ok=false * A-->>A: throw InternalError * end */ static async createUser(con, dbName, user, pass, roles = ["reader", "writer"]) { const users = con.db.use("_users"); const usr = { _id: "org.couchdb.user:" + user, name: user, password: pass, roles: roles, type: "user", }; try { const created = await users.insert(usr); const { ok } = created; if (!ok) throw new InternalError(`Failed to create user ${user}`); const security = await con.request({ db: dbName, method: "put", path: "_security", // headers: { // // }, body: { admins: { names: [user], roles: [], }, members: { names: [user], roles: roles, }, }, }); if (!security.ok) throw new InternalError(`Failed to authorize user ${user} to db ${dbName}`); } catch (e) { throw CouchDBAdapter.parseError(e); } } /** * @description Deletes a user from the Nano server * @summary Removes an existing user from the Nano server * @param {ServerScope} con - The Nano server connection * @param {string} dbName - The name of the database (used for logging purposes) * @param {string} user - The username to delete * @return {Promise<void>} A promise that resolves when the user is deleted * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant U as _users Database * A->>A: Generate user ID * A->>U: get(id) * U-->>A: user document * A->>U: destroy(id, user._rev) * alt Success * U-->>A: success response * else Error * U-->>A: error * A-->>A: throw parseError(e) * end */ static async deleteUser(con, dbName, user) { const users = con.db.use("_users"); const id = "org.couchdb.user:" + user; try { const usr = await users.get(id); await users.destroy(id, usr._rev); } catch (e) { throw CouchDBAdapter.parseError(e); } } /** * @description Sets up decorations for Nano-specific model properties * @summary Configures decorators for created_by and updated_by fields in models to be automatically * populated with the user from the context when documents are created or updated * @return {void} * @mermaid * sequenceDiagram * participant A as NanoAdapter * participant D as Decoration * participant R as Repository * A->>R: key(PersistenceKeys.CREATED_BY) * R-->>A: createdByKey * A->>D: flavouredAs("nano") * A->>D: for(createdByKey) * A->>D: define(onCreate(createdByOnNanoCreateUpdate), propMetadata) * A->>D: apply() * A->>R: key(PersistenceKeys.UPDATED_BY) * R-->>A: updatedByKey * A->>D: flavouredAs("nano") * A->>D: for(updatedByKey) * A->>D: define(onCreate(createdByOnNanoCreateUpdate), propMetadata) * A->>D: apply() */ static decoration() { super.decoration(); Decoration.flavouredAs(NanoFlavour) .for(PersistenceKeys.CREATED_BY) .define(onCreate(createdByOnNanoCreateUpdate), propMetadata(PersistenceKeys.CREATED_BY, {})) .apply(); Decoration.flavouredAs(NanoFlavour) .for(PersistenceKeys.UPDATED_BY) .define(onCreateUpdate(createdByOnNanoCreateUpdate), propMetadata(PersistenceKeys.UPDATED_BY, {})) .apply(); } } Adapter.setCurrent(NanoFlavour); //# sourceMappingURL=adapter.js.map