import type { Constructable, CreateResult, FilterType, FindOptions, PartialResult, PhysicalStore, WithOptionalId, } from '@furystack/core' import { NotFoundError } from '@furystack/core' import { EventHub } from '@furystack/utils' import type { Collection, Filter, MongoClient, OptionalUnlessRequiredId, Sort } from 'mongodb' import { ObjectId } from 'mongodb' import type { MongoClientFactory } from './mongo-client-factory.js' // Improved type safety for hasObjectId const hasObjectId = (value: T): value is T & { _id: ObjectId } => { return value && typeof value === 'object' && value._id instanceof ObjectId } /** * {@link PhysicalStore} backed by a MongoDB collection. * * The collection handle is resolved lazily on the first call via * {@link getCollection}; if the primary key is not `_id` a unique index is * created on it during init. Init failure clears the cached promise so the * next call retries. * * When `primaryKey` is `_id`, string keys are transparently coerced to * `ObjectId` for filters and back to hex strings on results so callers can * stay in plain string-key territory. * * The store has no `[Symbol.asyncDispose]` — client lifetime belongs to * {@link MongoClientFactory}, which closes pooled clients on injector teardown. */ export class MongodbStore< T extends object, TPrimaryKey extends keyof T, TWriteableData = WithOptionalId, > extends EventHub<{ onEntityAdded: { entity: T } onEntityUpdated: { id: T[TPrimaryKey]; change: Partial } onEntityRemoved: { key: T[TPrimaryKey] } }> implements PhysicalStore { public readonly primaryKey: TPrimaryKey public readonly model: Constructable private initPromise: Promise> | null = null private collection?: Collection private createIdFilter(...values: Array): Filter { // 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 } 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): Filter { if (!filter) { return {} } // Only handle _id conversion if present if (Object.prototype.hasOwnProperty.call(filter, '_id')) { const f = { ...filter } as Record 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 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 } return filter as Filter } public async getCollection(): Promise> { if (this.collection) { return this.collection } if (!this.initPromise) { this.initPromise = this.initializeCollection() } return this.initPromise } private async initializeCollection(): Promise> { try { const client = this.options.mongoClient() const collection = client.db(this.options.db).collection(this.options.collection) if (this.primaryKey !== '_id') { await collection.createIndex({ [this.primaryKey]: 1 }, { unique: true }) } this.collection = collection return collection } catch (error) { this.initPromise = null throw error } } constructor( private readonly options: { model: Constructable primaryKey: TPrimaryKey db: string collection: string mongoClient: () => MongoClient }, ) { super() this.primaryKey = options.primaryKey this.model = options.model } public async add(...entries: TWriteableData[]): Promise> { const collection = await this.getCollection() const result = await collection.insertMany(entries.map((e) => ({ ...e }) as OptionalUnlessRequiredId)) 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): Promise { const collection = await this.getCollection() const updateResult = await collection.updateOne(this.createIdFilter(id), { $set: data }) if (updateResult.matchedCount < 1) { throw new NotFoundError(`Entity not found with id '${String(id)}', cannot update`) } this.emit('onEntityUpdated', { id, change: data }) } public async count(filter?: FilterType): Promise { const collection = await this.getCollection() return await collection.countDocuments(this.parseFilter(filter), {}) } public async find>( filter: FindOptions, ): Promise>> { 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) { return { ...(this.primaryKey !== '_id' ? { _id: 0 } : {}), ...(fields ? Object.fromEntries(fields.map((field) => [field, 1])) : {}), } } public async get(key: T[TPrimaryKey], select?: Array): Promise { 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): Promise { const collection = await this.getCollection() await collection.deleteMany(this.createIdFilter(...keys)) keys.forEach((key) => this.emit('onEntityRemoved', { key })) } }