• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aelassas / wexcommerce / 15977923854

30 Jun 2025 04:04PM UTC coverage: 95.326% (+0.3%) from 94.988%
15977923854

push

github

aelassas
fix(backend): improve TTL index handling and logging for updates

341 of 350 branches covered (97.43%)

Branch coverage included in aggregate %.

10 of 11 new or added lines in 1 file covered. (90.91%)

5 existing lines in 1 file now uncovered.

2331 of 2453 relevant lines covered (95.03%)

13.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.0
/backend/src/common/databaseHelper.ts
1
import mongoose, { ConnectOptions, Model } from 'mongoose'
2
import * as env from '../config/env.config'
3
import * as logger from './logger'
4
import Notification from '../models/Notification'
5
import NotificationCounter from '../models/NotificationCounter'
6
import Token, { TOKEN_EXPIRE_AT_INDEX_NAME } from '../models/Token'
7
import User, { USER_EXPIRE_AT_INDEX_NAME } from '../models/User'
8
import Cart from '../models/Cart'
9
import CartItem from '../models/CartItem'
10
import Category from '../models/Category'
11
import DeliveryType from '../models/DeliveryType'
12
import Order, { ORDER_EXPIRE_AT_INDEX_NAME } from '../models/Order'
13
import OrderItem, { ORDER_ITEM_EXPIRE_AT_INDEX_NAME } from '../models/OrderItem'
14
import PaymentType from '../models/PaymentType'
15
import Product from '../models/Product'
16
import Setting from '../models/Setting'
17
import Value from '../models/Value'
18
import * as deliveryTypeController from '../controllers/deliveryTypeController'
19
import * as paymentTypeController from '../controllers/paymentTypeController'
20
import * as settingController from '../controllers/settingController'
21

22
/**
23
 * Tracks the current database connection status to prevent redundant connections.
24
 * Set to true after a successful connection is established via `connect()`,
25
 * and reset to false after `close()` is called.
26
 * 
27
 * @type {boolean}
28
 */
29
let isConnected = false
18✔
30

31
/**
32
 * Connects to database.
33
 *
34
 * @async
35
 * @param {string} uri 
36
 * @param {boolean} ssl 
37
 * @param {boolean} debug 
38
 * @returns {Promise<boolean>} 
39
 */
40
export const connect = async (uri: string, ssl: boolean, debug: boolean): Promise<boolean> => {
18✔
41
  if (isConnected) {
32✔
42
    return true
1✔
43
  }
44

45
  const options: ConnectOptions = ssl
31✔
46
    ? {
47
      tls: true,
48
      tlsCertificateKeyFile: env.DB_SSL_CERT,
49
      tlsCAFile: env.DB_SSL_CA,
50
    }
51
    : {}
52

53
  mongoose.set('debug', debug)
31✔
54
  mongoose.set('autoIndex', process.env.NODE_ENV !== 'production')
31✔
55
  mongoose.Promise = globalThis.Promise
31✔
56

57
  try {
31✔
58
    await mongoose.connect(uri, options)
31✔
59
    await mongoose.connection.asPromise() // Explicitly wait for connection to be open
30✔
60
    logger.success('Database connected')
30✔
61
    isConnected = true
30✔
62
    return true
30✔
63
  } catch (err) {
64
    logger.error(' Database connection failed:', err instanceof Error ? err.message : err)
1✔
65
    return false
1✔
66
  }
67
}
68

69
/**
70
 * Closes database connection.
71
 *
72
 * @async
73
 * @param {boolean} [force=false] 
74
 * @returns {Promise<void>} 
75
 */
76
export const close = async (force = false): Promise<void> => {
18✔
77
  await mongoose.connection.close(force)
31✔
78
  isConnected = false
31✔
79
  logger.success('Database connection closed')
31✔
80
}
81

82
/**
83
 * Creates a text index on a model's field.
84
 *
85
 * @param {Model<T>} model - The Mongoose model.
86
 * @param {string} field - The field to index.
87
 * @param {string} indexName - The desired index name.
88
 */
89
export const createTextIndex = async <T>(model: Model<T>, field: string, indexName: string) => {
18✔
90
  const collection = model.collection
18✔
91
  const fallbackOptions = {
18✔
92
    name: indexName,
93
    default_language: 'none', // This disables stemming
94
    language_override: '_none', // Prevent MongoDB from expecting a language field
95
    background: true,
96
    weights: { [field]: 1 },
97
  }
98

99
  try {
18✔
100
    // Drop the existing text index if it exists
101
    const indexes = await collection.indexes()
18✔
102
    const existingIndex = indexes.find((index) => index.name === indexName)
128✔
103
    if (existingIndex) {
18✔
104
      const sameOptions =
105
        existingIndex.default_language === fallbackOptions.default_language &&
18✔
106
        existingIndex.language_override === fallbackOptions.language_override
107
      if (!sameOptions) {
18✔
108
        await collection.dropIndex(indexName)
1✔
109
        logger.success(`Dropped old text index "${indexName}" due to option mismatch`)
1✔
110
      } else {
111
        logger.info(`Text index "${indexName}" already exists and is up to date`)
17✔
112
        return
17✔
113
      }
114
    }
115

116
    // Create new text index with fallback options
117
    await collection.createIndex({ [field]: 'text' }, fallbackOptions)
1✔
118
    logger.success(`Created text index "${indexName}" on "${field}" with fallback options`)
1✔
119
  } catch (err) {
UNCOV
120
    logger.error('Failed to create text index:', err)
×
121
  }
122
}
123

124
/**
125
 * Synchronizes multilingual Value entries for a given collection (such as Location, Country, or ParkingSpot) 
126
 * to ensure that each document has language-specific values for all supported languages defined in env.LANGUAGES.
127
 *
128
 * @async
129
 * @param {Model<T>} collection 
130
 * @param {string} label 
131
 * @returns {Promise<boolean>}
132
 */
133
const syncLanguageValues = async <T extends { values: (mongoose.Types.ObjectId | env.Value)[] }>(
18✔
134
  collection: Model<T>,
135
  label: string
136
): Promise<boolean> => {
137
  try {
6✔
138
    // Load all documents with populated 'values' field
139
    const docs = await collection.find({}).populate<{ values: env.Value[] }>({
6✔
140
      path: 'values',
141
      model: 'Value',
142
    })
143

144
    const newValues: env.Value[] = []
6✔
145
    const updates: { id: string; pushIds: string[] }[] = []
6✔
146

147
    for (const doc of docs) {
6✔
148
      // Ensure English value exists to copy from
149
      const en = doc.values.find((v) => v.language === 'en')
2✔
150
      if (!en) {
2✔
151
        logger.warn(`English value missing for ${label} document:`, doc.id)
1✔
152
        continue
1✔
153
      }
154

155
      // Find which languages are missing
156
      const missingLangs = env.LANGUAGES.filter((lang) => !doc.values.some((v) => v.language === lang))
3✔
157

158
      if (missingLangs.length > 0) {
1✔
159
        const additions: string[] = []
1✔
160
        for (const lang of missingLangs) {
1✔
161
          // Create new Value with English value as fallback
162
          const val = new Value({ language: lang, value: en.value })
1✔
163
          newValues.push(val)
1✔
164
          additions.push(val.id)
1✔
165
        }
166
        updates.push({ id: doc.id, pushIds: additions })
1✔
167
      }
168
    }
169

170
    // Insert all newly created Values in one batch for efficiency
171
    if (newValues.length > 0) {
6✔
172
      await Value.insertMany(newValues)
1✔
173
      logger.info(`Inserted ${newValues.length} new Value docs for ${label}`)
1✔
174
    }
175

176
    // Update documents by pushing the new Value references
177
    if (updates.length > 0) {
6✔
178
      const bulkOps = updates.map(({ id, pushIds }) => ({
1✔
179
        updateOne: {
180
          filter: { _id: id },
181
          update: { $push: { values: { $each: pushIds } } },
182
        },
183
      }))
184
      await collection.bulkWrite(bulkOps)
1✔
185
      logger.info(`Updated ${updates.length} ${label} documents with new language values`)
1✔
186
    }
187

188
    // Cleanup: Delete Values with unsupported languages and remove references
189
    const cursor = Value.find({ language: { $nin: env.LANGUAGES } }, { _id: 1 }).cursor()
6✔
190
    let obsoleteIdsBatch: string[] = []
6✔
191

192
    for await (const obsoleteVal of cursor) {
6✔
193
      obsoleteIdsBatch.push(obsoleteVal.id)
1,002✔
194

195
      if (obsoleteIdsBatch.length >= env.BATCH_SIZE) {
1,002✔
196
        await Promise.all([
1✔
197
          collection.updateMany({ values: { $in: obsoleteIdsBatch } }, { $pull: { values: { $in: obsoleteIdsBatch } } }),
198
          Value.deleteMany({ _id: { $in: obsoleteIdsBatch } }),
199
        ])
200
        logger.info(`Cleaned up batch of ${obsoleteIdsBatch.length} obsolete Values in ${label}`)
1✔
201
        obsoleteIdsBatch = []
1✔
202
      }
203
    }
204

205
    // Final cleanup for any remaining obsolete ids after loop
206
    if (obsoleteIdsBatch.length > 0) {
6✔
207
      await Promise.all([
1✔
208
        collection.updateMany({ values: { $in: obsoleteIdsBatch } }, { $pull: { values: { $in: obsoleteIdsBatch } } }),
209
        Value.deleteMany({ _id: { $in: obsoleteIdsBatch } }),
210
      ])
211
      logger.info(`Cleaned up final batch of ${obsoleteIdsBatch.length} obsolete Values in ${label}`)
1✔
212
    }
213

214
    logger.success(`${label} initialized successfully`)
6✔
215
    return true
6✔
216
  } catch (err) {
217
    logger.error(`Failed to initialize ${label}:`, err)
×
UNCOV
218
    return false
×
219
  }
220
}
221

222
/**
223
 * Initialiazes categories.
224
 *
225
 * @returns {Promise<boolean>} 
226
 */
227
export const initializeCategories = () => syncLanguageValues(Category, 'categories')
18✔
228

229
/**
230
 * Creates TTL index.
231
 *
232
 * @async
233
 * @param {Model<T>} model 
234
 * @param {string} name 
235
 * @param {number} expireAfterSeconds 
236
 * @returns {Promise<void>} 
237
 */
238
const createTTLIndex = async <T>(model: Model<T>, name: string, expireAfterSeconds: number) => {
18✔
239
  await model.collection.createIndex(
2✔
240
    { [env.expireAt]: 1 },
241
    { name, expireAfterSeconds, background: true },
242
  )
243
}
244

245
/**
246
 * Updates TTL index.
247
 *
248
 * @async
249
 * @param {Model<T>} model 
250
 * @param {string} name 
251
 * @param {number} seconds 
252
 * @returns {Promise<void>} 
253
 */
254
export const checkAndUpdateTTL = async <T>(model: Model<T>, name: string, seconds: number) => {
18✔
255
  const indexTag = `${model.modelName}.${name}`
24✔
256
  const indexes = await model.collection.indexes()
24✔
257
  const existing = indexes.find((index) => index.name === name && index.expireAfterSeconds !== seconds)
138✔
258

259
  if (existing) {
24✔
260
    try {
2✔
261
      await model.collection.dropIndex(name)
2✔
262
    } catch (err) {
NEW
263
      logger.error(`Failed to drop TTL index "${name}":`, err)
×
264
    }
265
    await createTTLIndex(model, name, seconds)
2✔
266
  } else {
267
    logger.info(`TTL index "${indexTag}" is already up to date`)
22✔
268
  }
269
}
270

271
/**
272
 * Creates a Model.
273
 *
274
 * @async
275
 * @template T 
276
 * @param {Model<T>} model 
277
 * @param {boolean} createIndexes
278
 * @returns {Promise<void>} 
279
 */
280
const createCollection = async <T>(model: Model<T>, createIndexes: boolean = true): Promise<void> => {
18✔
281
  const modelName = model.modelName
84✔
282
  const collections = await model.db.listCollections()
84✔
283
  const exists = collections.some((col) => col.name === modelName)
714✔
284
  if (!exists) {
84✔
UNCOV
285
    await model.createCollection()
×
UNCOV
286
    logger.success(`Created collection: ${modelName}`) // Optionally log success
×
287
  }
288

289
  if (createIndexes) {
84✔
290
    await model.createIndexes()
42✔
291
    logger.success(`Indexes created for collection: ${modelName}`)
42✔
292
  }
293
}
294

295
/**
296
 * Helper utility to infer the union type of array elements.
297
 *
298
 * @template {readonly unknown[]} T 
299
 * @param {T} models 
300
 * @returns {T} 
301
 */
302
const defineModels = <T extends readonly unknown[]>(models: T) => models
18✔
303

304
/**
305
 * Array of Mongoose model constructors used throughout the application.
306
 * Each element corresponds to a Mongoose model imported from the respective model files.
307
 *
308
 * The array is a readonly tuple preserving the exact model constructor types.
309
 * 
310
 */
311
export const models = defineModels([
18✔
312
  Cart,
313
  CartItem,
314
  Category,
315
  DeliveryType,
316
  Notification,
317
  NotificationCounter,
318
  Order,
319
  OrderItem,
320
  PaymentType,
321
  Product,
322
  Setting,
323
  Token,
324
  User,
325
  Value,
326
] as const)
327

328
/**
329
 * Initializes database.
330
 *
331
 * @async
332
 * @param {boolean} createIndexes
333
 * @returns {Promise<boolean>} 
334
 */
335
export const initialize = async (createIndexes: boolean = true): Promise<boolean> => {
18✔
336
  try {
7✔
337
    //
338
    // Check if connection is ready
339
    //
340
    if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) {
7✔
341
      throw new Error('Mongoose connection is not ready')
1✔
342
    }
343

344
    //
345
    // Create collections
346
    //
347
    await Promise.all(models.map((model) => createCollection(model as Model<unknown>, createIndexes)))
84✔
348

349
    //
350
    // Feature detection and conditional text index creation (backward compatible with older versions)
351
    //
352
    await createTextIndex(Order, 'orderItems.product.name', 'orderItems.product.name_text')
6✔
353
    await createTextIndex(Product, 'name', 'name_text')
6✔
354
    await createTextIndex(Value, 'value', 'value_text')
6✔
355

356
    //
357
    // Update TTL index if configuration changes
358
    //
359
    await Promise.all([
6✔
360
      checkAndUpdateTTL(OrderItem, ORDER_ITEM_EXPIRE_AT_INDEX_NAME, env.ORDER_EXPIRE_AT),
361
      checkAndUpdateTTL(Order, ORDER_EXPIRE_AT_INDEX_NAME, env.ORDER_EXPIRE_AT),
362
      checkAndUpdateTTL(User, USER_EXPIRE_AT_INDEX_NAME, env.USER_EXPIRE_AT),
363
      checkAndUpdateTTL(Token, TOKEN_EXPIRE_AT_INDEX_NAME, env.TOKEN_EXPIRE_AT),
364
    ])
365

366
    //
367
    // Initialize collections
368
    //
369
    const results = await Promise.all([
6✔
370
      deliveryTypeController.init(),
371
      paymentTypeController.init(),
372
      settingController.init(),
373
      initializeCategories(),
374
    ])
375

376
    const res = results.every(Boolean)
6✔
377

378
    if (res) {
6✔
379
      logger.info('Database initialized successfully')
6✔
380
    } else {
UNCOV
381
      logger.error('Some parts of the database failed to initialize')
×
382
    }
383

384
    return res
6✔
385
  } catch (err) {
386
    logger.error('Database initialization error:', err)
1✔
387
    await close()
1✔
388
    return false
1✔
389
  }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc