@zenith-open/zenithcms-db-mongodb 0.1.0 → 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/MongooseAdapter.d.ts +8 -19
- package/dist/MongooseAdapter.js +254 -220
- package/dist/MongooseAdapter.js.map +1 -1
- package/dist/model-factory.js +3 -0
- package/dist/model-factory.js.map +1 -1
- package/package.json +26 -23
- package/eslint.config.mjs +0 -26
- package/src/MongooseAdapter.ts +0 -643
- package/src/index.ts +0 -2
- package/src/model-factory.ts +0 -179
- package/tsconfig.eslint.json +0 -8
- package/tsconfig.json +0 -11
package/src/MongooseAdapter.ts
DELETED
|
@@ -1,643 +0,0 @@
|
|
|
1
|
-
import mongoose, { Model } from 'mongoose'
|
|
2
|
-
import { CollectionConfig, DatabaseAdapter, FindOptions, BaseOptions, AuditLogData, VersionData, WebhookDeliveryData, WebhookDeliveryRecord } from '@zenith-open/zenithcms-types'
|
|
3
|
-
import { getModelForCollection } from './model-factory'
|
|
4
|
-
import NodeCache from 'node-cache'
|
|
5
|
-
import Redis from 'ioredis'
|
|
6
|
-
import pino from 'pino'
|
|
7
|
-
|
|
8
|
-
const logger = pino()
|
|
9
|
-
|
|
10
|
-
// Hard ceiling on query result size to prevent memory exhaustion / DoS
|
|
11
|
-
const MAX_QUERY_LIMIT = 500
|
|
12
|
-
const DEFAULT_QUERY_LIMIT = 100
|
|
13
|
-
|
|
14
|
-
export interface CacheLayer {
|
|
15
|
-
get<T>(key: string): Promise<T | undefined>
|
|
16
|
-
set<T>(key: string, value: T, collection: string): Promise<void>
|
|
17
|
-
invalidate(collection: string): Promise<void>
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class LocalCacheLayer implements CacheLayer {
|
|
21
|
-
private cache: NodeCache
|
|
22
|
-
constructor() {
|
|
23
|
-
this.cache = new NodeCache({ stdTTL: 60, checkperiod: 120 })
|
|
24
|
-
}
|
|
25
|
-
async get<T>(key: string): Promise<T | undefined> {
|
|
26
|
-
return this.cache.get<T>(key)
|
|
27
|
-
}
|
|
28
|
-
async set<T>(key: string, value: T, collection: string): Promise<void> {
|
|
29
|
-
this.cache.set(key, value)
|
|
30
|
-
}
|
|
31
|
-
async invalidate(collection: string): Promise<void> {
|
|
32
|
-
const keys = this.cache.keys()
|
|
33
|
-
const targets = keys.filter((k) => k.startsWith(`${collection}:`))
|
|
34
|
-
this.cache.del(targets)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export class RedisCacheLayer implements CacheLayer {
|
|
39
|
-
private redis: Redis
|
|
40
|
-
constructor(redisUrl: string) {
|
|
41
|
-
this.redis = new Redis(redisUrl, {
|
|
42
|
-
maxRetriesPerRequest: 3,
|
|
43
|
-
})
|
|
44
|
-
logger.info('MongooseAdapter: Redis_Cache_Layer Initialized')
|
|
45
|
-
}
|
|
46
|
-
async get<T>(key: string): Promise<T | undefined> {
|
|
47
|
-
try {
|
|
48
|
-
const data = await this.redis.get(key)
|
|
49
|
-
return data ? JSON.parse(data) : undefined
|
|
50
|
-
} catch (error: any) {
|
|
51
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Get failed')
|
|
52
|
-
return undefined
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
async set<T>(key: string, value: T, collection: string): Promise<void> {
|
|
56
|
-
try {
|
|
57
|
-
const setKey = `zenith:cache:collection:${collection}`
|
|
58
|
-
await this.redis.setex(key, 60, JSON.stringify(value))
|
|
59
|
-
await this.redis.sadd(setKey, key)
|
|
60
|
-
await this.redis.expire(setKey, 120)
|
|
61
|
-
} catch (error: any) {
|
|
62
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Set failed')
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
async invalidate(collection: string): Promise<void> {
|
|
66
|
-
try {
|
|
67
|
-
const setKey = `zenith:cache:collection:${collection}`
|
|
68
|
-
const keys = await this.redis.smembers(setKey)
|
|
69
|
-
if (keys.length > 0) {
|
|
70
|
-
await this.redis.del(...keys)
|
|
71
|
-
}
|
|
72
|
-
await this.redis.del(setKey)
|
|
73
|
-
} catch (error: any) {
|
|
74
|
-
logger.warn({ error: error.message }, 'RedisCacheLayer: Invalidate failed')
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Mongoose Database Adapter — Hardened Edition
|
|
81
|
-
* ──────────────────────────────────────────
|
|
82
|
-
* High-performance implementation for MongoDB.
|
|
83
|
-
* Features: Neural Cache Layer, automatic session management, and health monitoring.
|
|
84
|
-
*/
|
|
85
|
-
export class MongooseAdapter implements DatabaseAdapter {
|
|
86
|
-
name = 'mongoose'
|
|
87
|
-
private models: Record<string, Model<unknown>> = {}
|
|
88
|
-
private cache: CacheLayer
|
|
89
|
-
|
|
90
|
-
constructor(private uri: string) {
|
|
91
|
-
const redisUrl = process.env.REDIS_URL
|
|
92
|
-
if (redisUrl) {
|
|
93
|
-
this.cache = new RedisCacheLayer(redisUrl)
|
|
94
|
-
} else {
|
|
95
|
-
this.cache = new LocalCacheLayer()
|
|
96
|
-
logger.warn('MongooseAdapter: Local_Cache_Layer Initialized (Warning: Cache desync risk under horizontal scaling)')
|
|
97
|
-
}
|
|
98
|
-
logger.info('MongooseAdapter: Neural_Cache_Layer Initialized')
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async connect(): Promise<void> {
|
|
102
|
-
try {
|
|
103
|
-
const poolMax = parseInt(process.env.DB_POOL_SIZE || '10', 10)
|
|
104
|
-
await mongoose.connect(this.uri, {
|
|
105
|
-
serverSelectionTimeoutMS: 5000,
|
|
106
|
-
socketTimeoutMS: 45000,
|
|
107
|
-
maxPoolSize: poolMax,
|
|
108
|
-
})
|
|
109
|
-
logger.info('MongooseAdapter: Connected to MongoDB')
|
|
110
|
-
this._initSystemModels()
|
|
111
|
-
} catch (error: any) {
|
|
112
|
-
logger.error({ error: error.message }, 'MongooseAdapter: Connection failed')
|
|
113
|
-
throw error
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async disconnect(): Promise<void> {
|
|
118
|
-
await mongoose.disconnect()
|
|
119
|
-
logger.info('MongooseAdapter: Disconnected')
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
getHealth(): 'ok' | 'connecting' | 'disconnected' | 'error' {
|
|
123
|
-
const state = mongoose.connection.readyState
|
|
124
|
-
switch (state) {
|
|
125
|
-
case 0:
|
|
126
|
-
return 'disconnected'
|
|
127
|
-
case 1:
|
|
128
|
-
return 'ok'
|
|
129
|
-
case 2:
|
|
130
|
-
return 'connecting'
|
|
131
|
-
case 3:
|
|
132
|
-
return 'disconnected'
|
|
133
|
-
default:
|
|
134
|
-
return 'error'
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private _initSystemModels() {
|
|
139
|
-
// Ensure system models are indexed for performance
|
|
140
|
-
if (!mongoose.models['AuditLog']) {
|
|
141
|
-
const schema = new mongoose.Schema(
|
|
142
|
-
{
|
|
143
|
-
timestamp: { type: Date, default: Date.now, index: true },
|
|
144
|
-
collectionName: { type: String, index: true },
|
|
145
|
-
documentId: { type: String, index: true },
|
|
146
|
-
userId: { type: String, index: true },
|
|
147
|
-
userEmail: { type: String },
|
|
148
|
-
userName: { type: String },
|
|
149
|
-
action: { type: String, index: true },
|
|
150
|
-
changes: { type: mongoose.Schema.Types.Mixed },
|
|
151
|
-
ip: { type: String },
|
|
152
|
-
userAgent: { type: String },
|
|
153
|
-
status: { type: String, index: true },
|
|
154
|
-
resource: { type: String },
|
|
155
|
-
siteId: { type: String, index: true },
|
|
156
|
-
hash: { type: String },
|
|
157
|
-
previousHash: { type: String },
|
|
158
|
-
},
|
|
159
|
-
{ strict: false }
|
|
160
|
-
)
|
|
161
|
-
schema.index({ siteId: 1, timestamp: -1 })
|
|
162
|
-
schema.index({ action: 1, timestamp: -1 })
|
|
163
|
-
mongoose.model('AuditLog', schema)
|
|
164
|
-
}
|
|
165
|
-
if (!mongoose.models['Version']) {
|
|
166
|
-
const schema = new mongoose.Schema(
|
|
167
|
-
{
|
|
168
|
-
timestamp: { type: Date, default: Date.now, index: true },
|
|
169
|
-
collectionSlug: { type: String, index: true },
|
|
170
|
-
documentId: { type: String, index: true },
|
|
171
|
-
},
|
|
172
|
-
{ strict: false }
|
|
173
|
-
)
|
|
174
|
-
mongoose.model('Version', schema)
|
|
175
|
-
}
|
|
176
|
-
if (!mongoose.models['flows']) {
|
|
177
|
-
const schema = new mongoose.Schema(
|
|
178
|
-
{
|
|
179
|
-
name: { type: String, required: true },
|
|
180
|
-
description: { type: String },
|
|
181
|
-
active: { type: Boolean, default: false },
|
|
182
|
-
trigger: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
183
|
-
steps: { type: mongoose.Schema.Types.Mixed, default: [] },
|
|
184
|
-
},
|
|
185
|
-
{ timestamps: true, strict: false }
|
|
186
|
-
)
|
|
187
|
-
mongoose.model('flows', schema)
|
|
188
|
-
}
|
|
189
|
-
if (!mongoose.models['z_migrations']) {
|
|
190
|
-
const schema = new mongoose.Schema(
|
|
191
|
-
{
|
|
192
|
-
name: { type: String, required: true, unique: true, index: true },
|
|
193
|
-
batch: { type: Number, required: true },
|
|
194
|
-
executedAt: { type: Date, default: Date.now },
|
|
195
|
-
},
|
|
196
|
-
{ strict: true }
|
|
197
|
-
)
|
|
198
|
-
mongoose.model('z_migrations', schema)
|
|
199
|
-
}
|
|
200
|
-
if (!mongoose.models['z_collections']) {
|
|
201
|
-
const schema = new mongoose.Schema(
|
|
202
|
-
{
|
|
203
|
-
name: { type: String, required: true },
|
|
204
|
-
slug: { type: String, required: true, unique: true, index: true },
|
|
205
|
-
labels: { singular: { type: String }, plural: { type: String } },
|
|
206
|
-
drafts: { type: Boolean, default: false },
|
|
207
|
-
timestamps: { type: Boolean, default: true },
|
|
208
|
-
fields: { type: mongoose.Schema.Types.Mixed, default: [] },
|
|
209
|
-
},
|
|
210
|
-
{ timestamps: true, strict: false }
|
|
211
|
-
)
|
|
212
|
-
mongoose.model('z_collections', schema)
|
|
213
|
-
}
|
|
214
|
-
if (!mongoose.models['z_components']) {
|
|
215
|
-
const schema = new mongoose.Schema(
|
|
216
|
-
{
|
|
217
|
-
slug: { type: String, required: true, unique: true, index: true },
|
|
218
|
-
displayName: { type: String, required: true },
|
|
219
|
-
category: { type: String, default: 'General' },
|
|
220
|
-
icon: { type: String, default: 'Box' },
|
|
221
|
-
description: { type: String },
|
|
222
|
-
fields: { type: mongoose.Schema.Types.Mixed, default: [] },
|
|
223
|
-
},
|
|
224
|
-
{ timestamps: true, strict: false }
|
|
225
|
-
)
|
|
226
|
-
mongoose.model('z_components', schema)
|
|
227
|
-
}
|
|
228
|
-
if (!mongoose.models['z_presence']) {
|
|
229
|
-
const schema = new mongoose.Schema(
|
|
230
|
-
{
|
|
231
|
-
userId: { type: String, required: true },
|
|
232
|
-
email: { type: String, required: true },
|
|
233
|
-
collectionName: { type: String, required: true },
|
|
234
|
-
documentId: { type: String, required: true },
|
|
235
|
-
lastActive: { type: Number, required: true },
|
|
236
|
-
},
|
|
237
|
-
{ timestamps: false, strict: true }
|
|
238
|
-
)
|
|
239
|
-
mongoose.model('z_presence', schema)
|
|
240
|
-
}
|
|
241
|
-
if (!mongoose.models['Lock']) {
|
|
242
|
-
const schema = new mongoose.Schema(
|
|
243
|
-
{
|
|
244
|
-
collectionName: { type: String, required: true, index: true },
|
|
245
|
-
documentId: { type: String, required: true, index: true },
|
|
246
|
-
siteId: { type: String, index: true },
|
|
247
|
-
lockedBy: { type: String, required: true },
|
|
248
|
-
lockedByEmail: { type: String, required: true },
|
|
249
|
-
lockedAt: { type: Date, default: Date.now },
|
|
250
|
-
lockExpiresAt: { type: Date, required: true },
|
|
251
|
-
},
|
|
252
|
-
{ collection: 'z_locks', timestamps: false }
|
|
253
|
-
)
|
|
254
|
-
schema.index({ collectionName: 1, documentId: 1, siteId: 1 }, { unique: true })
|
|
255
|
-
mongoose.model('Lock', schema)
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async registerCollection(config: CollectionConfig): Promise<void> {
|
|
260
|
-
console.log(`[MongooseAdapter] Registering collection: ${config.slug}`)
|
|
261
|
-
const model = getModelForCollection(config)
|
|
262
|
-
console.log(`[MongooseAdapter] Successfully registered model: ${model.modelName}`)
|
|
263
|
-
this.models[config.slug] = model
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async getExistingCollections(): Promise<string[]> {
|
|
267
|
-
const db = mongoose.connection.db
|
|
268
|
-
if (!db) return []
|
|
269
|
-
const collections = await db.listCollections().toArray()
|
|
270
|
-
return collections.map((c) => c.name)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private getModel(collection: string): Model<unknown> {
|
|
274
|
-
if (collection === 'flows') return mongoose.models['flows']
|
|
275
|
-
|
|
276
|
-
let resolvedCollection = collection
|
|
277
|
-
if (collection === 'users') resolvedCollection = 'User'
|
|
278
|
-
if (collection === 'z_sites' || collection === 'sites') resolvedCollection = 'Site'
|
|
279
|
-
if (collection === 'z_workspaces' || collection === 'workspaces') resolvedCollection = 'Workspace'
|
|
280
|
-
if (collection === 'z_password_resets') resolvedCollection = 'z_password_resets'
|
|
281
|
-
if (collection === 'z_api_keys') resolvedCollection = 'z_api_keys'
|
|
282
|
-
if (collection === 'z_migrations') resolvedCollection = 'z_migrations'
|
|
283
|
-
if (collection === 'z_collections') resolvedCollection = 'z_collections'
|
|
284
|
-
if (collection === 'z_components') resolvedCollection = 'z_components'
|
|
285
|
-
if (collection === 'z_presence') resolvedCollection = 'z_presence'
|
|
286
|
-
if (collection === 'z_locks' || collection === 'locks') resolvedCollection = 'Lock'
|
|
287
|
-
if (collection === 'z_webhook_configs') resolvedCollection = 'WebhookConfig'
|
|
288
|
-
if (collection === 'z_plugins') resolvedCollection = 'Plugin'
|
|
289
|
-
if (collection === 'audit_logs' || collection === 'z_audit_logs') resolvedCollection = 'AuditLog'
|
|
290
|
-
if (collection === 'versions' || collection === 'z_versions') resolvedCollection = 'Version'
|
|
291
|
-
|
|
292
|
-
const model = this.models[resolvedCollection] || mongoose.models[resolvedCollection]
|
|
293
|
-
if (!model) throw new Error(`Collection "${collection}" not registered`)
|
|
294
|
-
return model
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private _getCacheKey(collection: string, query: unknown, options: unknown): string {
|
|
298
|
-
const sortObject = (obj: any): any => {
|
|
299
|
-
if (obj === null || typeof obj !== 'object') return obj
|
|
300
|
-
if (Array.isArray(obj)) return obj.map(sortObject)
|
|
301
|
-
return Object.keys(obj).sort().reduce((acc: any, key: string) => {
|
|
302
|
-
acc[key] = sortObject(obj[key])
|
|
303
|
-
return acc
|
|
304
|
-
}, {})
|
|
305
|
-
}
|
|
306
|
-
const siteId = (options as any)?.siteId || (options as any)?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
307
|
-
const enrichedQuery = siteId ? { ...(query as Record<string, unknown>), siteId } : query
|
|
308
|
-
return `${collection}:${JSON.stringify(sortObject(enrichedQuery))}:${JSON.stringify(sortObject(options))}`
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async find<T = unknown>(
|
|
312
|
-
collection: string,
|
|
313
|
-
query: Record<string, unknown>,
|
|
314
|
-
options: FindOptions = {}
|
|
315
|
-
): Promise<T[]> {
|
|
316
|
-
const cacheKey = this._getCacheKey(collection, query, options)
|
|
317
|
-
const cached = await this.cache.get<T[]>(cacheKey)
|
|
318
|
-
if (cached) return cached
|
|
319
|
-
|
|
320
|
-
const globalAot = (globalThis as any).zenithAotBridge
|
|
321
|
-
if (globalAot && globalAot.hasQuery(collection, 'find')) {
|
|
322
|
-
const model = this.getModel(collection)
|
|
323
|
-
const docs = await globalAot.executeQuery(collection, 'find', mongoose.connection.db, model, this._normalizeQuery(query, options), options)
|
|
324
|
-
await this.cache.set(cacheKey, docs, collection)
|
|
325
|
-
return docs
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const model = this.getModel(collection)
|
|
329
|
-
const normalizedQuery = this._normalizeQuery(query, options);
|
|
330
|
-
const q = model.find(normalizedQuery)
|
|
331
|
-
|
|
332
|
-
if (options.select) q.select(options.select)
|
|
333
|
-
if (options.populate) {
|
|
334
|
-
const populateArr = Array.isArray(options.populate) ? options.populate : [options.populate]
|
|
335
|
-
populateArr.forEach((p: any) => q.populate(p))
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const requestedLimit = options.limit ?? DEFAULT_QUERY_LIMIT
|
|
339
|
-
const limit = Math.min(requestedLimit, MAX_QUERY_LIMIT)
|
|
340
|
-
const docs = (await q
|
|
341
|
-
.sort((options.sort as any) || { createdAt: -1 })
|
|
342
|
-
.skip(options.skip || 0)
|
|
343
|
-
.limit(limit)
|
|
344
|
-
.session(options.session as any)
|
|
345
|
-
.lean()
|
|
346
|
-
.exec()) as T[]
|
|
347
|
-
|
|
348
|
-
await this.cache.set(cacheKey, docs, collection)
|
|
349
|
-
return docs
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async findOne<T = unknown>(
|
|
353
|
-
collection: string,
|
|
354
|
-
query: Record<string, unknown>,
|
|
355
|
-
options: FindOptions = {}
|
|
356
|
-
): Promise<T | null> {
|
|
357
|
-
const cacheKey = this._getCacheKey(collection, query, options)
|
|
358
|
-
const cached = await this.cache.get<T>(cacheKey)
|
|
359
|
-
if (cached) return cached
|
|
360
|
-
|
|
361
|
-
const model = this.getModel(collection)
|
|
362
|
-
const q = model.findOne(this._normalizeQuery(query, options))
|
|
363
|
-
|
|
364
|
-
if (options.select) q.select(options.select)
|
|
365
|
-
if (options.populate) {
|
|
366
|
-
const populateArr = Array.isArray(options.populate) ? options.populate : [options.populate]
|
|
367
|
-
populateArr.forEach((p: any) => q.populate(p))
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const doc = (await q
|
|
371
|
-
.session(options.session as any)
|
|
372
|
-
.lean()
|
|
373
|
-
.exec()) as T | null
|
|
374
|
-
if (doc) await this.cache.set(cacheKey, doc, collection)
|
|
375
|
-
return doc
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
private async _invalidateCache(collection: string) {
|
|
379
|
-
await this.cache.invalidate(collection)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
async create<T = unknown>(
|
|
383
|
-
collection: string,
|
|
384
|
-
data: Partial<T>,
|
|
385
|
-
options: BaseOptions = {}
|
|
386
|
-
): Promise<T> {
|
|
387
|
-
// Inject tenant scoping into created documents
|
|
388
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
389
|
-
const enrichedData = siteId && !(data as any).siteId
|
|
390
|
-
? { ...data, siteId }
|
|
391
|
-
: data
|
|
392
|
-
|
|
393
|
-
const globalAot = (globalThis as any).zenithAotBridge
|
|
394
|
-
if (globalAot && globalAot.hasQuery(collection, 'create')) {
|
|
395
|
-
const model = this.getModel(collection)
|
|
396
|
-
const doc = await globalAot.executeQuery(collection, 'create', mongoose.connection.db, model, enrichedData, options)
|
|
397
|
-
await this._invalidateCache(collection)
|
|
398
|
-
return doc as T
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const model = this.getModel(collection)
|
|
402
|
-
const [doc] = await model.create([enrichedData] as any, { session: options.session as any })
|
|
403
|
-
await this._invalidateCache(collection)
|
|
404
|
-
return doc.toObject() as T
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
async update<T = unknown>(
|
|
408
|
-
collection: string,
|
|
409
|
-
id: string,
|
|
410
|
-
data: Partial<T>,
|
|
411
|
-
options: BaseOptions = {}
|
|
412
|
-
): Promise<T | null> {
|
|
413
|
-
const model = this.getModel(collection)
|
|
414
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
415
|
-
const filter: Record<string, unknown> = { _id: id }
|
|
416
|
-
if (siteId) filter.siteId = siteId
|
|
417
|
-
// Atomic optimistic locking: include expected _version in the filter
|
|
418
|
-
if (options.expectedVersion !== undefined) {
|
|
419
|
-
filter._version = options.expectedVersion
|
|
420
|
-
}
|
|
421
|
-
const doc = await model
|
|
422
|
-
.findOneAndUpdate(
|
|
423
|
-
filter,
|
|
424
|
-
{ $set: data },
|
|
425
|
-
{
|
|
426
|
-
new: true,
|
|
427
|
-
session: options.session as any,
|
|
428
|
-
runValidators: true,
|
|
429
|
-
}
|
|
430
|
-
)
|
|
431
|
-
.lean()
|
|
432
|
-
.exec()
|
|
433
|
-
await this._invalidateCache(collection)
|
|
434
|
-
return doc as T | null
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
private _normalizeQuery(query: Record<string, unknown>, options?: BaseOptions): Record<string, unknown> {
|
|
438
|
-
const normalized = { ...query }
|
|
439
|
-
if ('id' in normalized) {
|
|
440
|
-
normalized._id = normalized.id
|
|
441
|
-
delete normalized.id
|
|
442
|
-
}
|
|
443
|
-
// Inject tenant scoping from options to prevent cross-tenant data access
|
|
444
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
445
|
-
if (siteId && !normalized.siteId) {
|
|
446
|
-
normalized.siteId = siteId
|
|
447
|
-
}
|
|
448
|
-
return normalized
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async findOneAndUpdate<T = unknown>(
|
|
452
|
-
collection: string,
|
|
453
|
-
query: Record<string, unknown>,
|
|
454
|
-
update: Record<string, unknown>,
|
|
455
|
-
options?: BaseOptions & { returnDocument?: 'before' | 'after' }
|
|
456
|
-
): Promise<T | null> {
|
|
457
|
-
const model = this.getModel(collection)
|
|
458
|
-
const normalized = this._normalizeQuery(query, options)
|
|
459
|
-
const returnDoc = options?.returnDocument === 'after' ? true : false
|
|
460
|
-
const doc = await model
|
|
461
|
-
.findOneAndUpdate(
|
|
462
|
-
normalized,
|
|
463
|
-
{ $set: update },
|
|
464
|
-
{
|
|
465
|
-
new: returnDoc,
|
|
466
|
-
session: options?.session as any,
|
|
467
|
-
runValidators: true,
|
|
468
|
-
}
|
|
469
|
-
)
|
|
470
|
-
.lean()
|
|
471
|
-
.exec()
|
|
472
|
-
return doc as T | null
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async updateMany(
|
|
476
|
-
collection: string,
|
|
477
|
-
query: Record<string, unknown>,
|
|
478
|
-
data: unknown,
|
|
479
|
-
options: BaseOptions = {}
|
|
480
|
-
): Promise<number> {
|
|
481
|
-
const model = this.getModel(collection)
|
|
482
|
-
const result = await model.updateMany(this._normalizeQuery(query, options), { $set: data } as any, {
|
|
483
|
-
session: options.session as any,
|
|
484
|
-
})
|
|
485
|
-
await this._invalidateCache(collection)
|
|
486
|
-
return result.modifiedCount
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async delete(collection: string, id: string, options: BaseOptions = {}): Promise<boolean> {
|
|
490
|
-
const model = this.getModel(collection)
|
|
491
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
492
|
-
const filter: Record<string, unknown> = { _id: id }
|
|
493
|
-
if (siteId) filter.siteId = siteId
|
|
494
|
-
const result = await model.findOneAndDelete(filter, { session: options.session as any })
|
|
495
|
-
await this._invalidateCache(collection)
|
|
496
|
-
return !!result
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
async deleteMany(
|
|
500
|
-
collection: string,
|
|
501
|
-
query: Record<string, unknown>,
|
|
502
|
-
options: BaseOptions = {}
|
|
503
|
-
): Promise<number> {
|
|
504
|
-
const model = this.getModel(collection)
|
|
505
|
-
const result = await model.deleteMany(this._normalizeQuery(query, options), { session: options.session as any })
|
|
506
|
-
await this._invalidateCache(collection)
|
|
507
|
-
return result.deletedCount
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
async count(collection: string, query: Record<string, unknown>, options?: BaseOptions): Promise<number> {
|
|
511
|
-
const model = this.getModel(collection)
|
|
512
|
-
return model.countDocuments(this._normalizeQuery(query, options))
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async aggregate<T = unknown>(collection: string, pipeline: unknown[], options?: BaseOptions): Promise<T[]> {
|
|
516
|
-
const model = this.getModel(collection)
|
|
517
|
-
const enrichedPipeline = [...pipeline] as any[]
|
|
518
|
-
// Inject tenant scoping — prepend $match stage to prevent cross-tenant data leaks
|
|
519
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
520
|
-
if (siteId) {
|
|
521
|
-
enrichedPipeline.unshift({ $match: { siteId } })
|
|
522
|
-
}
|
|
523
|
-
return model.aggregate(enrichedPipeline).exec() as Promise<T[]>
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async transaction<T>(fn: (session: any) => Promise<T>): Promise<T> {
|
|
527
|
-
try {
|
|
528
|
-
const session = await mongoose.startSession()
|
|
529
|
-
try {
|
|
530
|
-
let result: T
|
|
531
|
-
await session.withTransaction(async () => {
|
|
532
|
-
result = await fn(session)
|
|
533
|
-
})
|
|
534
|
-
return result! as T
|
|
535
|
-
} catch (error: any) {
|
|
536
|
-
// Fallback for standalone MongoDB (no replica set)
|
|
537
|
-
if (error.message?.includes('replica set') || error.codeName === 'NotAReplicaSet') {
|
|
538
|
-
if (process.env.NODE_ENV === 'production') {
|
|
539
|
-
throw new Error('FATAL: MongoDB must be running as a Replica Set in production to guarantee ACID transactions. Standalone MongoDB instances are strictly forbidden because they silently drop transactions and risk data corruption.')
|
|
540
|
-
}
|
|
541
|
-
logger.warn(
|
|
542
|
-
'Transactions not supported on this MongoDB instance. Running without transaction.'
|
|
543
|
-
)
|
|
544
|
-
return await fn(undefined)
|
|
545
|
-
}
|
|
546
|
-
throw error
|
|
547
|
-
} finally {
|
|
548
|
-
session.endSession()
|
|
549
|
-
}
|
|
550
|
-
} catch (sessionError: any) {
|
|
551
|
-
// If we can't even start a session
|
|
552
|
-
if (process.env.NODE_ENV === 'production') {
|
|
553
|
-
throw new Error(`FATAL: Failed to start MongoDB session in production: ${sessionError.message}. Replica Set is required.`)
|
|
554
|
-
}
|
|
555
|
-
logger.warn(
|
|
556
|
-
{ err: sessionError.message },
|
|
557
|
-
'Failed to start MongoDB session. Running without transaction.'
|
|
558
|
-
)
|
|
559
|
-
return await fn(undefined)
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
async createAuditLog(data: AuditLogData, options?: BaseOptions): Promise<void> {
|
|
564
|
-
const AuditModel = mongoose.models['AuditLog']
|
|
565
|
-
if (AuditModel) {
|
|
566
|
-
if (options?.session) {
|
|
567
|
-
await AuditModel.create([data], { session: options.session as any })
|
|
568
|
-
} else {
|
|
569
|
-
await AuditModel.create(data)
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
async createVersion(data: VersionData, options?: BaseOptions): Promise<void> {
|
|
575
|
-
const VersionModel = mongoose.models['Version']
|
|
576
|
-
if (VersionModel) {
|
|
577
|
-
if (options?.session) {
|
|
578
|
-
await VersionModel.create([data], { session: options.session as any })
|
|
579
|
-
} else {
|
|
580
|
-
await VersionModel.create(data)
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async getVersions(collection: string, documentId: string): Promise<VersionData[]> {
|
|
586
|
-
const VersionModel = mongoose.models['Version']
|
|
587
|
-
if (!VersionModel) return []
|
|
588
|
-
return VersionModel.find({ collectionName: collection, documentId })
|
|
589
|
-
.sort({ timestamp: -1 })
|
|
590
|
-
.lean()
|
|
591
|
-
.exec() as any as Promise<VersionData[]>
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
async createWebhookDelivery(data: WebhookDeliveryData): Promise<void> {
|
|
595
|
-
const WebhookModel = mongoose.models['WebhookDelivery']
|
|
596
|
-
if (WebhookModel) await WebhookModel.create(data)
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async getWebhookDeliveries(webhookId: string, limit = 50): Promise<WebhookDeliveryRecord[]> {
|
|
600
|
-
const WebhookModel = mongoose.models['WebhookDelivery']
|
|
601
|
-
if (!WebhookModel) return []
|
|
602
|
-
const docs = await WebhookModel.find({ webhookId })
|
|
603
|
-
.sort({ timestamp: -1 })
|
|
604
|
-
.limit(limit)
|
|
605
|
-
.lean()
|
|
606
|
-
return docs.map((d: any) => ({
|
|
607
|
-
id: d._id?.toString() || d.id,
|
|
608
|
-
webhookId: d.webhookId,
|
|
609
|
-
collectionSlug: d.collectionSlug,
|
|
610
|
-
event: d.event,
|
|
611
|
-
url: d.url,
|
|
612
|
-
payload: d.payload,
|
|
613
|
-
success: d.success,
|
|
614
|
-
responseStatus: d.responseStatus,
|
|
615
|
-
timestamp: d.timestamp,
|
|
616
|
-
}))
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
async search<T = unknown>(
|
|
620
|
-
collection: string,
|
|
621
|
-
query: string,
|
|
622
|
-
fields: string[],
|
|
623
|
-
limit = 10,
|
|
624
|
-
options?: BaseOptions
|
|
625
|
-
): Promise<T[]> {
|
|
626
|
-
const model = this.getModel(collection)
|
|
627
|
-
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
628
|
-
const regex = { $regex: escaped, $options: 'i' }
|
|
629
|
-
const orQuery = fields.map((f) => ({ [f]: regex }))
|
|
630
|
-
|
|
631
|
-
const findQuery: Record<string, any> = { $or: orQuery }
|
|
632
|
-
const siteId = options?.siteId || options?.tenantId || (globalThis as any).zenithAls?.getStore()?.siteId
|
|
633
|
-
if (siteId) {
|
|
634
|
-
findQuery.siteId = siteId
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return model
|
|
638
|
-
.find(findQuery)
|
|
639
|
-
.limit(Math.min(limit, 50))
|
|
640
|
-
.lean()
|
|
641
|
-
.exec() as Promise<T[]>
|
|
642
|
-
}
|
|
643
|
-
}
|
package/src/index.ts
DELETED