@furystack/mongodb-store 11.0.3 → 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.
- package/CHANGELOG.md +215 -199
- package/LICENSE +339 -339
- package/README.md +51 -51
- package/esm/store-manager-helpers.d.ts +33 -0
- package/esm/store-manager-helpers.d.ts.map +1 -0
- package/esm/store-manager-helpers.js +15 -0
- package/esm/store-manager-helpers.js.map +1 -0
- package/package.json +7 -7
- package/src/define-mongodb-store.spec.ts +108 -108
- package/src/define-mongodb-store.ts +58 -58
- package/src/index.ts +3 -3
- package/src/mongo-client-factory.ts +45 -45
- package/src/mongodb-store.spec.ts +140 -140
- package/src/mongodb-store.ts +207 -207
package/src/mongodb-store.ts
CHANGED
|
@@ -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
|
+
}
|