UNPKG

@furystack/mongodb-store

Version:

MongoDB Store implementation for FuryStack

191 lines (176 loc) 6.63 kB
import type { CreateResult, FilterType, FindOptions, PartialResult, PhysicalStore, WithOptionalId, } from '@furystack/core' import type { Constructable } from '@furystack/inject' import { EventHub } from '@furystack/utils' import type { Collection, Filter, MongoClient, OptionalUnlessRequiredId, Sort, UpdateFilter } from 'mongodb' import { ObjectId } from 'mongodb' import { Lock } from 'semaphore-async-await' // Improved type safety for hasObjectId const hasObjectId = <T extends { _id?: unknown }>(value: T): value is T & { _id: ObjectId } => { return value && typeof value === 'object' && value._id instanceof ObjectId } /** * MongoDB Store implementation for FuryStack */ export class MongodbStore< T extends object, TPrimaryKey extends keyof T, TWriteableData = WithOptionalId<T, TPrimaryKey>, > extends EventHub<{ onEntityAdded: { entity: T } onEntityUpdated: { id: T[TPrimaryKey]; change: Partial<T> } onEntityRemoved: { key: T[TPrimaryKey] } }> implements PhysicalStore<T, TPrimaryKey, TWriteableData> { public readonly primaryKey: TPrimaryKey public readonly model: Constructable<T> private initLock = new Lock() private collection?: Collection<T> private createIdFilter(...values: Array<T[TPrimaryKey]>): Filter<T> { // If primaryKey is _id, convert string values to ObjectId return { [this.primaryKey]: { $in: this.primaryKey === '_id' ? values.map((value) => new ObjectId(value as string)) : values, }, } as Filter<T> } private stringifyResultId(item: T): T { // If _id is ObjectId, convert to string if (this.primaryKey === '_id' && hasObjectId(item)) { return { ...item, _id: item._id.toHexString(), } } return item } private parseFilter(filter?: FilterType<T>): Filter<T> { if (!filter) { return {} } // Only handle _id conversion if present if (Object.prototype.hasOwnProperty.call(filter, '_id')) { const f = { ...filter } as Record<string, unknown> if (typeof f._id === 'string') { f._id = new ObjectId(f._id) } else if (typeof f._id === 'object' && f._id !== null) { const idObj = f._id as Record<string, unknown> if (idObj.$eq && typeof idObj.$eq === 'string') { idObj.$eq = new ObjectId(idObj.$eq) } if (Array.isArray(idObj.$in)) { idObj.$in = idObj.$in.map((id: string) => new ObjectId(id)) } if (Array.isArray(idObj.$nin)) { idObj.$nin = idObj.$nin.map((id: string) => new ObjectId(id)) } } return f as Filter<T> } return filter as Filter<T> } public async getCollection(): Promise<Collection<T>> { if (this.collection) { return this.collection } await this.initLock.acquire() if (this.collection) { return this.collection } try { const client = this.options.mongoClient() const collection = client.db(this.options.db).collection<T>(this.options.collection) if (this.primaryKey !== '_id') { await collection.createIndex({ [this.primaryKey]: 1 }, { unique: true }) } this.collection = collection return collection } finally { this.initLock.release() } } constructor( private readonly options: { model: Constructable<T> primaryKey: TPrimaryKey db: string collection: string mongoClient: () => MongoClient }, ) { super() this.primaryKey = options.primaryKey this.model = options.model } public async add(...entries: TWriteableData[]): Promise<CreateResult<T>> { const collection = await this.getCollection() const result = await collection.insertMany(entries.map((e) => ({ ...e }) as OptionalUnlessRequiredId<T>)) const created = this.primaryKey === '_id' ? Object.values(result.insertedIds).map((insertedId, index) => // Use 'unknown' as intermediate cast to satisfy TypeScript this.stringifyResultId({ _id: insertedId, ...entries[index] } as unknown as T), ) : Object.values(result.insertedIds).map((insertedId, index) => { // Use 'unknown' as intermediate cast to satisfy TypeScript const entity = { _id: insertedId, ...entries[index] } as unknown as T & { _id: unknown } const { _id, ...r } = entity return r as T }) created.forEach((entity) => this.emit('onEntityAdded', { entity })) return { created } } public async update(id: T[TPrimaryKey], data: Partial<T>): Promise<void> { const collection = await this.getCollection() const updateResult = await collection.updateOne(this.createIdFilter(id), { $set: data } as UpdateFilter<T>) if (updateResult.matchedCount < 1) { throw Error(`Entity not found with id '${String(id)}', cannot update!`) } this.emit('onEntityUpdated', { id, change: data }) } public async count(filter?: FilterType<T>): Promise<number> { const collection = await this.getCollection() return await collection.countDocuments(this.parseFilter(filter), {}) } public async find<TFields extends Array<keyof T>>( filter: FindOptions<T, TFields>, ): Promise<Array<PartialResult<T, TFields>>> { const collection = await this.getCollection() const sort: Sort = filter.order ? Object.fromEntries(Object.entries(filter.order).map(([key, value]) => [key, value === 'ASC' ? 1 : -1])) : {} const result = await collection .find(this.parseFilter(filter.filter)) .project(this.getProjection(filter.select)) .skip(filter.skip || 0) .limit(filter.top || Number.MAX_SAFE_INTEGER) .sort(sort) .toArray() return result.map((entity) => this.stringifyResultId(entity as T)) } private getProjection(fields?: Array<keyof T>) { return { ...(this.primaryKey !== '_id' ? { _id: 0 } : {}), ...(fields ? Object.fromEntries(fields.map((field) => [field, 1])) : {}), } } public async get(key: T[TPrimaryKey], select?: Array<keyof T>): Promise<T | undefined> { const collection = await this.getCollection() const projection = this.getProjection(select) const result = await collection.findOne(this.createIdFilter(key), { projection }) return result ? this.stringifyResultId(result as T) : undefined } public async remove(...keys: Array<T[TPrimaryKey]>): Promise<void> { const collection = await this.getCollection() await collection.deleteMany(this.createIdFilter(...keys)) keys.forEach((key) => this.emit('onEntityRemoved', { key })) } }