@furystack/mongodb-store 11.0.4 → 11.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,207 +1,207 @@
1
- import type {
2
- Constructable,
3
- CreateResult,
4
- FilterType,
5
- FindOptions,
6
- PartialResult,
7
- PhysicalStore,
8
- WithOptionalId,
9
- } from '@furystack/core'
10
- import { NotFoundError } from '@furystack/core'
11
- import { EventHub } from '@furystack/utils'
12
- import type { Collection, Filter, MongoClient, OptionalUnlessRequiredId, Sort } from 'mongodb'
13
- import { ObjectId } from 'mongodb'
14
- import type { MongoClientFactory } from './mongo-client-factory.js'
15
-
16
- // Improved type safety for hasObjectId
17
- const hasObjectId = <T extends { _id?: unknown }>(value: T): value is T & { _id: ObjectId } => {
18
- return value && typeof value === 'object' && value._id instanceof ObjectId
19
- }
20
-
21
- /**
22
- * {@link PhysicalStore} backed by a MongoDB collection.
23
- *
24
- * The collection handle is resolved lazily on the first call via
25
- * {@link getCollection}; if the primary key is not `_id` a unique index is
26
- * created on it during init. Init failure clears the cached promise so the
27
- * next call retries.
28
- *
29
- * When `primaryKey` is `_id`, string keys are transparently coerced to
30
- * `ObjectId` for filters and back to hex strings on results so callers can
31
- * stay in plain string-key territory.
32
- *
33
- * The store has no `[Symbol.asyncDispose]` — client lifetime belongs to
34
- * {@link MongoClientFactory}, which closes pooled clients on injector teardown.
35
- */
36
- export class MongodbStore<
37
- T extends object,
38
- TPrimaryKey extends keyof T,
39
- TWriteableData = WithOptionalId<T, TPrimaryKey>,
40
- >
41
- extends EventHub<{
42
- onEntityAdded: { entity: T }
43
- onEntityUpdated: { id: T[TPrimaryKey]; change: Partial<T> }
44
- onEntityRemoved: { key: T[TPrimaryKey] }
45
- }>
46
- implements PhysicalStore<T, TPrimaryKey, TWriteableData>
47
- {
48
- public readonly primaryKey: TPrimaryKey
49
- public readonly model: Constructable<T>
50
- private initPromise: Promise<Collection<T>> | null = null
51
- private collection?: Collection<T>
52
-
53
- private createIdFilter(...values: Array<T[TPrimaryKey]>): Filter<T> {
54
- // If primaryKey is _id, convert string values to ObjectId
55
- return {
56
- [this.primaryKey]: {
57
- $in: this.primaryKey === '_id' ? values.map((value) => new ObjectId(value as string)) : values,
58
- },
59
- } as Filter<T>
60
- }
61
-
62
- private stringifyResultId(item: T): T {
63
- // If _id is ObjectId, convert to string
64
- if (this.primaryKey === '_id' && hasObjectId(item)) {
65
- return {
66
- ...item,
67
- _id: item._id.toHexString(),
68
- }
69
- }
70
- return item
71
- }
72
-
73
- private parseFilter(filter?: FilterType<T>): Filter<T> {
74
- if (!filter) {
75
- return {}
76
- }
77
- // Only handle _id conversion if present
78
- if (Object.prototype.hasOwnProperty.call(filter, '_id')) {
79
- const f = { ...filter } as Record<string, unknown>
80
- if (typeof f._id === 'string') {
81
- f._id = new ObjectId(f._id)
82
- } else if (typeof f._id === 'object' && f._id !== null) {
83
- const idObj = f._id as Record<string, unknown>
84
- if (idObj.$eq && typeof idObj.$eq === 'string') {
85
- idObj.$eq = new ObjectId(idObj.$eq)
86
- }
87
- if (Array.isArray(idObj.$in)) {
88
- idObj.$in = idObj.$in.map((id: string) => new ObjectId(id))
89
- }
90
- if (Array.isArray(idObj.$nin)) {
91
- idObj.$nin = idObj.$nin.map((id: string) => new ObjectId(id))
92
- }
93
- }
94
- return f as Filter<T>
95
- }
96
- return filter as Filter<T>
97
- }
98
-
99
- public async getCollection(): Promise<Collection<T>> {
100
- if (this.collection) {
101
- return this.collection
102
- }
103
- if (!this.initPromise) {
104
- this.initPromise = this.initializeCollection()
105
- }
106
- return this.initPromise
107
- }
108
-
109
- private async initializeCollection(): Promise<Collection<T>> {
110
- try {
111
- const client = this.options.mongoClient()
112
- const collection = client.db(this.options.db).collection<T>(this.options.collection)
113
- if (this.primaryKey !== '_id') {
114
- await collection.createIndex({ [this.primaryKey]: 1 }, { unique: true })
115
- }
116
- this.collection = collection
117
- return collection
118
- } catch (error) {
119
- this.initPromise = null
120
- throw error
121
- }
122
- }
123
-
124
- constructor(
125
- private readonly options: {
126
- model: Constructable<T>
127
- primaryKey: TPrimaryKey
128
- db: string
129
- collection: string
130
- mongoClient: () => MongoClient
131
- },
132
- ) {
133
- super()
134
- this.primaryKey = options.primaryKey
135
- this.model = options.model
136
- }
137
-
138
- public async add(...entries: TWriteableData[]): Promise<CreateResult<T>> {
139
- const collection = await this.getCollection()
140
- const result = await collection.insertMany(entries.map((e) => ({ ...e }) as OptionalUnlessRequiredId<T>))
141
- const created =
142
- this.primaryKey === '_id'
143
- ? Object.values(result.insertedIds).map((insertedId, index) =>
144
- // Use 'unknown' as intermediate cast to satisfy TypeScript
145
- this.stringifyResultId({ _id: insertedId, ...entries[index] } as unknown as T),
146
- )
147
- : Object.values(result.insertedIds).map((insertedId, index) => {
148
- // Use 'unknown' as intermediate cast to satisfy TypeScript
149
- const entity = { _id: insertedId, ...entries[index] } as unknown as T & { _id: unknown }
150
- const { _id, ...r } = entity
151
- return r as T
152
- })
153
- created.forEach((entity) => this.emit('onEntityAdded', { entity }))
154
- return { created }
155
- }
156
-
157
- public async update(id: T[TPrimaryKey], data: Partial<T>): Promise<void> {
158
- const collection = await this.getCollection()
159
- const updateResult = await collection.updateOne(this.createIdFilter(id), { $set: data })
160
- if (updateResult.matchedCount < 1) {
161
- throw new NotFoundError(`Entity not found with id '${String(id)}', cannot update`)
162
- }
163
- this.emit('onEntityUpdated', { id, change: data })
164
- }
165
-
166
- public async count(filter?: FilterType<T>): Promise<number> {
167
- const collection = await this.getCollection()
168
- return await collection.countDocuments(this.parseFilter(filter), {})
169
- }
170
-
171
- public async find<TFields extends Array<keyof T>>(
172
- filter: FindOptions<T, TFields>,
173
- ): Promise<Array<PartialResult<T, TFields>>> {
174
- const collection = await this.getCollection()
175
- const sort: Sort = filter.order
176
- ? Object.fromEntries(Object.entries(filter.order).map(([key, value]) => [key, value === 'ASC' ? 1 : -1]))
177
- : {}
178
- const result = await collection
179
- .find(this.parseFilter(filter.filter))
180
- .project(this.getProjection(filter.select))
181
- .skip(filter.skip || 0)
182
- .limit(filter.top || Number.MAX_SAFE_INTEGER)
183
- .sort(sort)
184
- .toArray()
185
- return result.map((entity) => this.stringifyResultId(entity as T))
186
- }
187
-
188
- private getProjection(fields?: Array<keyof T>) {
189
- return {
190
- ...(this.primaryKey !== '_id' ? { _id: 0 } : {}),
191
- ...(fields ? Object.fromEntries(fields.map((field) => [field, 1])) : {}),
192
- }
193
- }
194
-
195
- public async get(key: T[TPrimaryKey], select?: Array<keyof T>): Promise<T | undefined> {
196
- const collection = await this.getCollection()
197
- const projection = this.getProjection(select)
198
- const result = await collection.findOne(this.createIdFilter(key), { projection })
199
- return result ? this.stringifyResultId(result as T) : undefined
200
- }
201
-
202
- public async remove(...keys: Array<T[TPrimaryKey]>): Promise<void> {
203
- const collection = await this.getCollection()
204
- await collection.deleteMany(this.createIdFilter(...keys))
205
- keys.forEach((key) => this.emit('onEntityRemoved', { key }))
206
- }
207
- }
1
+ import type {
2
+ Constructable,
3
+ CreateResult,
4
+ FilterType,
5
+ FindOptions,
6
+ PartialResult,
7
+ PhysicalStore,
8
+ WithOptionalId,
9
+ } from '@furystack/core'
10
+ import { NotFoundError } from '@furystack/core'
11
+ import { EventHub } from '@furystack/utils'
12
+ import type { Collection, Filter, MongoClient, OptionalUnlessRequiredId, Sort } from 'mongodb'
13
+ import { ObjectId } from 'mongodb'
14
+ import type { MongoClientFactory } from './mongo-client-factory.js'
15
+
16
+ // Improved type safety for hasObjectId
17
+ const hasObjectId = <T extends { _id?: unknown }>(value: T): value is T & { _id: ObjectId } => {
18
+ return value && typeof value === 'object' && value._id instanceof ObjectId
19
+ }
20
+
21
+ /**
22
+ * {@link PhysicalStore} backed by a MongoDB collection.
23
+ *
24
+ * The collection handle is resolved lazily on the first call via
25
+ * {@link getCollection}; if the primary key is not `_id` a unique index is
26
+ * created on it during init. Init failure clears the cached promise so the
27
+ * next call retries.
28
+ *
29
+ * When `primaryKey` is `_id`, string keys are transparently coerced to
30
+ * `ObjectId` for filters and back to hex strings on results so callers can
31
+ * stay in plain string-key territory.
32
+ *
33
+ * The store has no `[Symbol.asyncDispose]` — client lifetime belongs to
34
+ * {@link MongoClientFactory}, which closes pooled clients on injector teardown.
35
+ */
36
+ export class MongodbStore<
37
+ T extends object,
38
+ TPrimaryKey extends keyof T,
39
+ TWriteableData = WithOptionalId<T, TPrimaryKey>,
40
+ >
41
+ extends EventHub<{
42
+ onEntityAdded: { entity: T }
43
+ onEntityUpdated: { id: T[TPrimaryKey]; change: Partial<T> }
44
+ onEntityRemoved: { key: T[TPrimaryKey] }
45
+ }>
46
+ implements PhysicalStore<T, TPrimaryKey, TWriteableData>
47
+ {
48
+ public readonly primaryKey: TPrimaryKey
49
+ public readonly model: Constructable<T>
50
+ private initPromise: Promise<Collection<T>> | null = null
51
+ private collection?: Collection<T>
52
+
53
+ private createIdFilter(...values: Array<T[TPrimaryKey]>): Filter<T> {
54
+ // If primaryKey is _id, convert string values to ObjectId
55
+ return {
56
+ [this.primaryKey]: {
57
+ $in: this.primaryKey === '_id' ? values.map((value) => new ObjectId(value as string)) : values,
58
+ },
59
+ } as Filter<T>
60
+ }
61
+
62
+ private stringifyResultId(item: T): T {
63
+ // If _id is ObjectId, convert to string
64
+ if (this.primaryKey === '_id' && hasObjectId(item)) {
65
+ return {
66
+ ...item,
67
+ _id: item._id.toHexString(),
68
+ }
69
+ }
70
+ return item
71
+ }
72
+
73
+ private parseFilter(filter?: FilterType<T>): Filter<T> {
74
+ if (!filter) {
75
+ return {}
76
+ }
77
+ // Only handle _id conversion if present
78
+ if (Object.prototype.hasOwnProperty.call(filter, '_id')) {
79
+ const f = { ...filter } as Record<string, unknown>
80
+ if (typeof f._id === 'string') {
81
+ f._id = new ObjectId(f._id)
82
+ } else if (typeof f._id === 'object' && f._id !== null) {
83
+ const idObj = f._id as Record<string, unknown>
84
+ if (idObj.$eq && typeof idObj.$eq === 'string') {
85
+ idObj.$eq = new ObjectId(idObj.$eq)
86
+ }
87
+ if (Array.isArray(idObj.$in)) {
88
+ idObj.$in = idObj.$in.map((id: string) => new ObjectId(id))
89
+ }
90
+ if (Array.isArray(idObj.$nin)) {
91
+ idObj.$nin = idObj.$nin.map((id: string) => new ObjectId(id))
92
+ }
93
+ }
94
+ return f as Filter<T>
95
+ }
96
+ return filter as Filter<T>
97
+ }
98
+
99
+ public async getCollection(): Promise<Collection<T>> {
100
+ if (this.collection) {
101
+ return this.collection
102
+ }
103
+ if (!this.initPromise) {
104
+ this.initPromise = this.initializeCollection()
105
+ }
106
+ return this.initPromise
107
+ }
108
+
109
+ private async initializeCollection(): Promise<Collection<T>> {
110
+ try {
111
+ const client = this.options.mongoClient()
112
+ const collection = client.db(this.options.db).collection<T>(this.options.collection)
113
+ if (this.primaryKey !== '_id') {
114
+ await collection.createIndex({ [this.primaryKey]: 1 }, { unique: true })
115
+ }
116
+ this.collection = collection
117
+ return collection
118
+ } catch (error) {
119
+ this.initPromise = null
120
+ throw error
121
+ }
122
+ }
123
+
124
+ constructor(
125
+ private readonly options: {
126
+ model: Constructable<T>
127
+ primaryKey: TPrimaryKey
128
+ db: string
129
+ collection: string
130
+ mongoClient: () => MongoClient
131
+ },
132
+ ) {
133
+ super()
134
+ this.primaryKey = options.primaryKey
135
+ this.model = options.model
136
+ }
137
+
138
+ public async add(...entries: TWriteableData[]): Promise<CreateResult<T>> {
139
+ const collection = await this.getCollection()
140
+ const result = await collection.insertMany(entries.map((e) => ({ ...e }) as OptionalUnlessRequiredId<T>))
141
+ const created =
142
+ this.primaryKey === '_id'
143
+ ? Object.values(result.insertedIds).map((insertedId, index) =>
144
+ // Use 'unknown' as intermediate cast to satisfy TypeScript
145
+ this.stringifyResultId({ _id: insertedId, ...entries[index] } as unknown as T),
146
+ )
147
+ : Object.values(result.insertedIds).map((insertedId, index) => {
148
+ // Use 'unknown' as intermediate cast to satisfy TypeScript
149
+ const entity = { _id: insertedId, ...entries[index] } as unknown as T & { _id: unknown }
150
+ const { _id, ...r } = entity
151
+ return r as T
152
+ })
153
+ created.forEach((entity) => this.emit('onEntityAdded', { entity }))
154
+ return { created }
155
+ }
156
+
157
+ public async update(id: T[TPrimaryKey], data: Partial<T>): Promise<void> {
158
+ const collection = await this.getCollection()
159
+ const updateResult = await collection.updateOne(this.createIdFilter(id), { $set: data })
160
+ if (updateResult.matchedCount < 1) {
161
+ throw new NotFoundError(`Entity not found with id '${String(id)}', cannot update`)
162
+ }
163
+ this.emit('onEntityUpdated', { id, change: data })
164
+ }
165
+
166
+ public async count(filter?: FilterType<T>): Promise<number> {
167
+ const collection = await this.getCollection()
168
+ return await collection.countDocuments(this.parseFilter(filter), {})
169
+ }
170
+
171
+ public async find<TFields extends Array<keyof T>>(
172
+ filter: FindOptions<T, TFields>,
173
+ ): Promise<Array<PartialResult<T, TFields>>> {
174
+ const collection = await this.getCollection()
175
+ const sort: Sort = filter.order
176
+ ? Object.fromEntries(Object.entries(filter.order).map(([key, value]) => [key, value === 'ASC' ? 1 : -1]))
177
+ : {}
178
+ const result = await collection
179
+ .find(this.parseFilter(filter.filter))
180
+ .project(this.getProjection(filter.select))
181
+ .skip(filter.skip || 0)
182
+ .limit(filter.top || Number.MAX_SAFE_INTEGER)
183
+ .sort(sort)
184
+ .toArray()
185
+ return result.map((entity) => this.stringifyResultId(entity as T))
186
+ }
187
+
188
+ private getProjection(fields?: Array<keyof T>) {
189
+ return {
190
+ ...(this.primaryKey !== '_id' ? { _id: 0 } : {}),
191
+ ...(fields ? Object.fromEntries(fields.map((field) => [field, 1])) : {}),
192
+ }
193
+ }
194
+
195
+ public async get(key: T[TPrimaryKey], select?: Array<keyof T>): Promise<T | undefined> {
196
+ const collection = await this.getCollection()
197
+ const projection = this.getProjection(select)
198
+ const result = await collection.findOne(this.createIdFilter(key), { projection })
199
+ return result ? this.stringifyResultId(result as T) : undefined
200
+ }
201
+
202
+ public async remove(...keys: Array<T[TPrimaryKey]>): Promise<void> {
203
+ const collection = await this.getCollection()
204
+ await collection.deleteMany(this.createIdFilter(...keys))
205
+ keys.forEach((key) => this.emit('onEntityRemoved', { key }))
206
+ }
207
+ }