@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2694.732417c5ec

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.
Files changed (34) hide show
  1. package/dist/lib/api/crud.js +1 -1
  2. package/dist/lib/api/crud.js.map +2 -2
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/data/engine.js +68 -27
  6. package/dist/lib/data/engine.js.map +2 -2
  7. package/dist/lib/db/mikro.js +18 -22
  8. package/dist/lib/db/mikro.js.map +2 -2
  9. package/dist/lib/indexers/error-log.js +10 -12
  10. package/dist/lib/indexers/error-log.js.map +2 -2
  11. package/dist/lib/indexers/status-log.js +14 -16
  12. package/dist/lib/indexers/status-log.js.map +2 -2
  13. package/dist/lib/query/engine.js +220 -228
  14. package/dist/lib/query/engine.js.map +3 -3
  15. package/dist/lib/query/join-utils.js +28 -23
  16. package/dist/lib/query/join-utils.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/jest.config.cjs +4 -2
  20. package/package.json +1 -1
  21. package/src/lib/api/__tests__/crud.test.ts +5 -3
  22. package/src/lib/api/crud.ts +1 -1
  23. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
  24. package/src/lib/auth/server.ts +1 -1
  25. package/src/lib/bootstrap/types.ts +2 -2
  26. package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
  27. package/src/lib/data/engine.ts +95 -47
  28. package/src/lib/db/mikro.ts +26 -25
  29. package/src/lib/indexers/error-log.ts +23 -23
  30. package/src/lib/indexers/status-log.ts +36 -33
  31. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
  32. package/src/lib/query/__tests__/engine.test.ts +206 -139
  33. package/src/lib/query/engine.ts +306 -263
  34. package/src/lib/query/join-utils.ts +38 -30
@@ -203,14 +203,16 @@ describe('findOneScoped', () => {
203
203
  describe('softDelete', () => {
204
204
  it('sets deletedAt and persists the updated entity', async () => {
205
205
  const entity = new ExampleEntity()
206
- const persistAndFlush = jest.fn(async () => undefined)
207
- const em = { persistAndFlush } as unknown as EntityManager
206
+ const flush = jest.fn(async () => undefined)
207
+ const persist = jest.fn((_entity: ExampleEntity) => ({ flush }))
208
+ const em = { persist } as unknown as EntityManager
208
209
  const before = Date.now()
209
210
 
210
211
  await softDelete(em, entity)
211
212
 
212
213
  expect(entity.deletedAt).toBeInstanceOf(Date)
213
214
  expect((entity.deletedAt as Date).getTime()).toBeGreaterThanOrEqual(before)
214
- expect(persistAndFlush).toHaveBeenCalledWith(entity)
215
+ expect(persist).toHaveBeenCalledWith(entity)
216
+ expect(flush).toHaveBeenCalled()
215
217
  })
216
218
  })
@@ -57,5 +57,5 @@ export async function softDelete<T extends { deletedAt?: Date | null }>(
57
57
  entity: T
58
58
  ): Promise<void> {
59
59
  ;(entity as any).deletedAt = new Date()
60
- await em.persistAndFlush(entity)
60
+ await em.persist(entity).flush()
61
61
  }
@@ -5,7 +5,8 @@ const createRequestContainer = jest.fn()
5
5
  const findApiKeyBySecret = jest.fn()
6
6
  const emFind = jest.fn()
7
7
  const emFindOne = jest.fn()
8
- const emPersistAndFlush = jest.fn()
8
+ const emPersist = jest.fn()
9
+ const emFlush = jest.fn()
9
10
 
10
11
  jest.mock('next/headers', () => ({
11
12
  cookies: async () => ({ get: () => undefined }),
@@ -41,7 +42,11 @@ jest.mock('@open-mercato/core/modules/directory/data/entities', () => ({
41
42
  const em = {
42
43
  find: (...args: unknown[]) => emFind(...args),
43
44
  findOne: (...args: unknown[]) => emFindOne(...args),
44
- persistAndFlush: (...args: unknown[]) => emPersistAndFlush(...args),
45
+ persist: (...args: unknown[]) => {
46
+ emPersist(...args)
47
+ return { flush: (...flushArgs: unknown[]) => emFlush(...flushArgs) }
48
+ },
49
+ flush: (...args: unknown[]) => emFlush(...args),
45
50
  }
46
51
 
47
52
  describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
@@ -52,7 +57,8 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
52
57
  })
53
58
  emFind.mockResolvedValue([])
54
59
  emFindOne.mockResolvedValue(null)
55
- emPersistAndFlush.mockResolvedValue(undefined)
60
+ emPersist.mockReturnValue(undefined)
61
+ emFlush.mockResolvedValue(undefined)
56
62
  const { resetSharedApiKeyAuthCacheForTests } = await import('@open-mercato/shared/lib/auth/apiKeyAuthCache')
57
63
  resetSharedApiKeyAuthCacheForTests()
58
64
  })
@@ -85,7 +91,7 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
85
91
  expect(second).toEqual(first)
86
92
  expect(third).toEqual(first)
87
93
  expect(findApiKeyBySecret).toHaveBeenCalledTimes(1)
88
- expect(emPersistAndFlush).toHaveBeenCalledTimes(1)
94
+ expect(emFlush).toHaveBeenCalledTimes(1)
89
95
  })
90
96
 
91
97
  it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
@@ -150,7 +150,7 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
150
150
  if (cache.shouldWriteLastUsed(record.id)) {
151
151
  try {
152
152
  record.lastUsedAt = new Date()
153
- await em.persistAndFlush(record)
153
+ await em.persist(record).flush()
154
154
  } catch {
155
155
  // best-effort update; ignore write failures
156
156
  }
@@ -5,9 +5,9 @@ import type { Module, ModuleDashboardWidgetEntry, ModuleInjectionWidgetEntry } f
5
5
  import type { ModuleInjectionTable } from '../../modules/widgets/injection'
6
6
  import type { SearchModuleConfig } from '../../modules/search'
7
7
  import type { AnalyticsModuleConfig } from '../../modules/analytics'
8
- import type { EntityClass, EntityClassGroup } from '@mikro-orm/core'
8
+ import type { EntityClass, EntitySchema } from '@mikro-orm/core'
9
9
 
10
- export type OrmEntity = EntityClass<unknown> | EntityClassGroup<unknown>
10
+ export type OrmEntity = EntityClass<unknown> | EntitySchema<unknown>
11
11
 
12
12
  export interface InjectionTableEntry {
13
13
  moduleId: string
@@ -22,7 +22,14 @@ let mockOrganizationScopeOverride: MockOrganizationScope | null
22
22
 
23
23
  const em = {
24
24
  create: (_cls: any, data: any) => ({ ...data, id: `id-${idSeq++}` }),
25
- persistAndFlush: async (entity: Rec) => { db[entity.id] = { ...(db[entity.id] || {} as any), ...entity } },
25
+ persist(entity: Rec) {
26
+ db[entity.id] = { ...(db[entity.id] || {} as any), ...entity }
27
+ return { flush: async () => undefined }
28
+ },
29
+ remove(entity: Rec) {
30
+ delete db[entity.id]
31
+ return { flush: async () => undefined }
32
+ },
26
33
  findOne: async (_entity: any, where: any) => (em.getRepository(_entity).findOne(where) as any),
27
34
  getRepository: (_cls: any) => ({
28
35
  find: async (where: any) => Object.values(db).filter((r) => {
@@ -56,7 +63,10 @@ const em = {
56
63
  : r.organizationId === orgClause
57
64
  return matchesOrg && r.tenantId === where.tenantId
58
65
  }) || null,
59
- removeAndFlush: async (entity: Rec) => { delete db[entity.id] },
66
+ remove(entity: Rec) {
67
+ delete db[entity.id]
68
+ return { flush: async () => undefined }
69
+ },
60
70
  }),
61
71
  }
62
72
 
@@ -68,22 +78,22 @@ const mockDataEngine = {
68
78
  __pendingSideEffects: [] as any[],
69
79
  createOrmEntity: jest.fn(async ({ entity, data }: any) => {
70
80
  const created = em.create(entity, data)
71
- await em.persistAndFlush(created as any)
81
+ await em.persist(created as any).flush()
72
82
  return created
73
83
  }),
74
84
  updateOrmEntity: jest.fn(async ({ entity, where, apply }: any) => {
75
85
  const current = await (em.getRepository(entity).findOne(where) as any)
76
86
  if (!current) return null
77
87
  await apply(current)
78
- await em.persistAndFlush(current)
88
+ await em.persist(current).flush()
79
89
  return current
80
90
  }),
81
91
  deleteOrmEntity: jest.fn(async ({ entity, where, soft, softDeleteField }: any) => {
82
92
  const repo = em.getRepository(entity)
83
93
  const current = await (repo.findOne(where) as any)
84
94
  if (!current) return null
85
- if (soft !== false) { (current as any)[softDeleteField || 'deletedAt'] = new Date(); await em.persistAndFlush(current) }
86
- else await repo.removeAndFlush(current)
95
+ if (soft !== false) { (current as any)[softDeleteField || 'deletedAt'] = new Date(); await em.persist(current).flush() }
96
+ else await repo.remove(current).flush()
87
97
  return current
88
98
  }),
89
99
  setCustomFields: jest.fn(async (args: any) => {
@@ -278,10 +288,10 @@ describe('CRUD Factory', () => {
278
288
 
279
289
  const first = em.create(Todo, { title: 'One', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
280
290
  first.id = '550e8400-e29b-41d4-a716-446655440010'
281
- await em.persistAndFlush(first)
291
+ await em.persist(first).flush()
282
292
  const second = em.create(Todo, { title: 'Two', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
283
293
  second.id = '550e8400-e29b-41d4-a716-446655440011'
284
- await em.persistAndFlush(second)
294
+ await em.persist(second).flush()
285
295
 
286
296
  const res = await fallbackRoute.GET(new Request(`http://x/api/example/todos?ids=${first.id}`))
287
297
  expect(res.status).toBe(200)
@@ -302,10 +312,10 @@ describe('CRUD Factory', () => {
302
312
 
303
313
  const mine = em.create(Todo, { title: 'Mine', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
304
314
  mine.id = '550e8400-e29b-41d4-a716-446655440020'
305
- await em.persistAndFlush(mine)
315
+ await em.persist(mine).flush()
306
316
  const other = em.create(Todo, { title: 'Other', organizationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', tenantId: defaultTenantId }) as Rec
307
317
  other.id = '550e8400-e29b-41d4-a716-446655440021'
308
- await em.persistAndFlush(other)
318
+ await em.persist(other).flush()
309
319
 
310
320
  const res = await fallbackRoute.GET(new Request('http://x/api/example/todos'))
311
321
  expect(res.status).toBe(200)
@@ -328,13 +338,13 @@ describe('CRUD Factory', () => {
328
338
 
329
339
  const mine = em.create(Todo, { title: 'Mine', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
330
340
  mine.id = '550e8400-e29b-41d4-a716-446655440030'
331
- await em.persistAndFlush(mine)
341
+ await em.persist(mine).flush()
332
342
  const otherOrg = em.create(Todo, { title: 'OtherOrg', organizationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', tenantId: defaultTenantId }) as Rec
333
343
  otherOrg.id = '550e8400-e29b-41d4-a716-446655440031'
334
- await em.persistAndFlush(otherOrg)
344
+ await em.persist(otherOrg).flush()
335
345
  const otherTenant = em.create(Todo, { title: 'OtherTenant', organizationId: defaultOrganizationId, tenantId: 'ffffffff-ffff-4fff-8fff-ffffffffffff' }) as Rec
336
346
  otherTenant.id = '550e8400-e29b-41d4-a716-446655440032'
337
- await em.persistAndFlush(otherTenant)
347
+ await em.persist(otherTenant).flush()
338
348
 
339
349
  const res = await fallbackRoute.GET(new Request('http://x/api/example/todos'))
340
350
  expect(res.status).toBe(200)
@@ -433,7 +443,7 @@ describe('CRUD Factory', () => {
433
443
  const created = em.create(Todo, { title: 'X', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
434
444
  // Force UUID id to satisfy validation
435
445
  created.id = '123e4567-e89b-12d3-a456-426614174001'
436
- await em.persistAndFlush(created)
446
+ await em.persist(created).flush()
437
447
  const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
438
448
  expect(res.status).toBe(200)
439
449
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
@@ -450,7 +460,7 @@ describe('CRUD Factory', () => {
450
460
  it('DELETE soft-deletes entity and emits deleted event', async () => {
451
461
  const created = em.create(Todo, { title: 'Y', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
452
462
  created.id = '123e4567-e89b-12d3-a456-426614174002'
453
- await em.persistAndFlush(created)
463
+ await em.persist(created).flush()
454
464
  const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}`, { method: 'DELETE' }))
455
465
  expect(res.status).toBe(200)
456
466
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
@@ -466,7 +476,7 @@ describe('CRUD Factory', () => {
466
476
  it('trims padded selected organization ids when scope resolution falls back from empty filter ids', async () => {
467
477
  const created = em.create(Todo, { title: 'Scoped', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
468
478
  created.id = '123e4567-e89b-12d3-a456-426614174052'
469
- await em.persistAndFlush(created)
479
+ await em.persist(created).flush()
470
480
  mockOrganizationScopeOverride = {
471
481
  selectedId: ` ${defaultOrganizationId} `,
472
482
  filterIds: [],
@@ -518,7 +528,7 @@ describe('CRUD Factory', () => {
518
528
  tenantId: '123e4567-e89b-12d3-a456-426614174000',
519
529
  }) as Rec
520
530
  created.id = '123e4567-e89b-12d3-a456-426614174051'
521
- await em.persistAndFlush(created)
531
+ await em.persist(created).flush()
522
532
 
523
533
  const res = await route.PUT(new Request('http://x/api/example/todos', {
524
534
  method: 'PUT',
@@ -1,6 +1,7 @@
1
1
  import type { EntityData, EntityName, FilterQuery, RequiredEntityData } from '@mikro-orm/core'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { AwilixContainer } from 'awilix'
4
+ import { type Kysely, sql } from 'kysely'
4
5
  import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/helpers'
5
6
  import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
6
7
  import { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'
@@ -202,11 +203,17 @@ export class DefaultDataEngine implements DataEngine {
202
203
  } catch { return false }
203
204
  }
204
205
 
206
+ private getKysely(): Kysely<any> {
207
+ return this.em.getKysely<any>()
208
+ }
209
+
205
210
  private async ensureStorageTableExists(): Promise<void> {
206
- const knex = this.em.getConnection().getKnex()
207
- const exists = await knex('information_schema.tables')
208
- .where({ table_name: 'custom_entities_storage' })
209
- .first()
211
+ const db = this.getKysely()
212
+ const exists = await db
213
+ .selectFrom('information_schema.tables' as any)
214
+ .select(sql`1`.as('present'))
215
+ .where('table_name' as any, '=', 'custom_entities_storage')
216
+ .executeTakeFirst()
210
217
  if (!exists) {
211
218
  throw new Error('custom_entities_storage table is missing. Run migrations (yarn db:migrate).')
212
219
  }
@@ -247,7 +254,7 @@ export class DefaultDataEngine implements DataEngine {
247
254
  }
248
255
 
249
256
  async createCustomEntityRecord(opts: Parameters<DataEngine['createCustomEntityRecord']>[0]): Promise<{ id: string }> {
250
- const knex = this.em.getConnection().getKnex()
257
+ const db = this.getKysely()
251
258
  await this.ensureStorageTableExists()
252
259
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
253
260
  entityId: opts.entityId,
@@ -274,31 +281,47 @@ export class DefaultDataEngine implements DataEngine {
274
281
  const tenantId = opts.tenantId ?? null
275
282
  const doc: Record<string, unknown> = { id, ...this.normalizeDocValues(sanitizedValues || {}) }
276
283
 
284
+ const now = sql`now()`
277
285
  const payload = {
278
286
  entity_type: opts.entityId,
279
287
  entity_id: id,
280
288
  organization_id: orgId,
281
289
  tenant_id: tenantId,
282
- doc,
283
- updated_at: knex.fn.now(),
284
- created_at: knex.fn.now(),
290
+ doc: sql`${JSON.stringify(doc)}::jsonb`,
291
+ updated_at: now,
292
+ created_at: now,
285
293
  deleted_at: null,
286
294
  }
287
295
 
288
296
  // Upsert by scoped uniqueness
289
297
  try {
290
- await knex('custom_entities_storage')
291
- .insert(payload)
292
- .onConflict(['entity_type', 'entity_id', 'organization_id'])
293
- .merge({ doc: payload.doc, updated_at: knex.fn.now(), deleted_at: null })
298
+ await db
299
+ .insertInto('custom_entities_storage' as any)
300
+ .values(payload as any)
301
+ .onConflict((oc) => oc
302
+ .columns(['entity_type', 'entity_id', 'organization_id'])
303
+ .doUpdateSet({
304
+ doc: sql`${JSON.stringify(doc)}::jsonb`,
305
+ updated_at: sql`now()`,
306
+ deleted_at: null,
307
+ } as any))
308
+ .execute()
294
309
  } catch {
295
310
  // Fallback for global scope uniqueness
296
311
  try {
297
- const updated = await knex('custom_entities_storage')
298
- .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
299
- .update({ doc: payload.doc, updated_at: knex.fn.now(), deleted_at: null })
300
- if (!updated) {
301
- await knex('custom_entities_storage').insert(payload)
312
+ const updated = await db
313
+ .updateTable('custom_entities_storage' as any)
314
+ .set({
315
+ doc: sql`${JSON.stringify(doc)}::jsonb`,
316
+ updated_at: sql`now()`,
317
+ deleted_at: null,
318
+ } as any)
319
+ .where('entity_type' as any, '=', opts.entityId)
320
+ .where('entity_id' as any, '=', id)
321
+ .where('organization_id' as any, orgId === null ? 'is' : '=', orgId as any)
322
+ .executeTakeFirst()
323
+ if (!updated || Number(updated.numUpdatedRows ?? 0) === 0) {
324
+ await db.insertInto('custom_entities_storage' as any).values(payload as any).execute()
302
325
  }
303
326
  } catch (err) {
304
327
  // Surface a clear error so it doesn't silently fall back only to EAV
@@ -322,7 +345,7 @@ export class DefaultDataEngine implements DataEngine {
322
345
  }
323
346
 
324
347
  async updateCustomEntityRecord(opts: Parameters<DataEngine['updateCustomEntityRecord']>[0]): Promise<void> {
325
- const knex = this.em.getConnection().getKnex()
348
+ const db = this.getKysely()
326
349
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
327
350
  entityId: opts.entityId,
328
351
  organizationId: opts.organizationId ?? null,
@@ -336,26 +359,38 @@ export class DefaultDataEngine implements DataEngine {
336
359
 
337
360
  // Merge doc shallowly: load existing doc and overlay
338
361
  await this.ensureStorageTableExists()
339
- const row = await knex('custom_entities_storage')
340
- .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
341
- .first()
342
- const prevDoc: Record<string, unknown> = row?.doc || { id }
362
+ const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {
363
+ let chain = q.where('entity_type' as any, '=', opts.entityId)
364
+ chain = chain.where('entity_id' as any, '=', id)
365
+ chain = orgId === null
366
+ ? chain.where('organization_id' as any, 'is', null as any)
367
+ : chain.where('organization_id' as any, '=', orgId)
368
+ return chain
369
+ }
370
+ const row = await applyScope(
371
+ db.selectFrom('custom_entities_storage' as any).select(['doc' as any])
372
+ ).executeTakeFirst()
373
+ const prevDoc: Record<string, unknown> = (row as any)?.doc || { id }
343
374
  const nextDoc: Record<string, unknown> = { ...prevDoc, ...this.normalizeDocValues(sanitizedValues || {}), id }
344
375
  try {
345
- const updated = await knex('custom_entities_storage')
346
- .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
347
- .update({ doc: nextDoc, updated_at: knex.fn.now(), deleted_at: null })
348
- if (!updated) {
349
- await knex('custom_entities_storage').insert({
376
+ const updated = await applyScope(
377
+ db.updateTable('custom_entities_storage' as any).set({
378
+ doc: sql`${JSON.stringify(nextDoc)}::jsonb`,
379
+ updated_at: sql`now()`,
380
+ deleted_at: null,
381
+ } as any) as any
382
+ ).executeTakeFirst()
383
+ if (!updated || Number((updated as any).numUpdatedRows ?? 0) === 0) {
384
+ await db.insertInto('custom_entities_storage' as any).values({
350
385
  entity_type: opts.entityId,
351
386
  entity_id: id,
352
387
  organization_id: orgId,
353
388
  tenant_id: tenantId,
354
- doc: nextDoc,
355
- created_at: knex.fn.now(),
356
- updated_at: knex.fn.now(),
389
+ doc: sql`${JSON.stringify(nextDoc)}::jsonb`,
390
+ created_at: sql`now()`,
391
+ updated_at: sql`now()`,
357
392
  deleted_at: null,
358
- })
393
+ } as any).execute()
359
394
  }
360
395
  } catch (err) {
361
396
  throw err
@@ -375,19 +410,29 @@ export class DefaultDataEngine implements DataEngine {
375
410
  }
376
411
 
377
412
  async deleteCustomEntityRecord(opts: Parameters<DataEngine['deleteCustomEntityRecord']>[0]): Promise<void> {
378
- const knex = this.em.getConnection().getKnex()
413
+ const db = this.getKysely()
379
414
  const id = String(opts.recordId)
380
415
  const orgId = opts.organizationId ?? null
381
416
  const soft = opts.soft !== false
382
417
 
418
+ const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {
419
+ let chain = q.where('entity_type' as any, '=', opts.entityId)
420
+ chain = chain.where('entity_id' as any, '=', id)
421
+ chain = orgId === null
422
+ ? chain.where('organization_id' as any, 'is', null as any)
423
+ : chain.where('organization_id' as any, '=', orgId)
424
+ return chain
425
+ }
426
+
383
427
  if (soft) {
384
- await knex('custom_entities_storage')
385
- .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
386
- .update({ deleted_at: knex.fn.now(), updated_at: knex.fn.now() })
428
+ await applyScope(
429
+ db.updateTable('custom_entities_storage' as any).set({
430
+ deleted_at: sql`now()`,
431
+ updated_at: sql`now()`,
432
+ } as any) as any
433
+ ).execute()
387
434
  } else {
388
- await knex('custom_entities_storage')
389
- .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
390
- .delete()
435
+ await applyScope(db.deleteFrom('custom_entities_storage' as any) as any).execute()
391
436
  }
392
437
 
393
438
  // Soft-delete EAV values to preserve current behavior
@@ -405,16 +450,19 @@ export class DefaultDataEngine implements DataEngine {
405
450
  record.deletedAt = now
406
451
  return true
407
452
  })
408
- if (mutated.length) await this.em.persistAndFlush(values)
453
+ if (mutated.length) {
454
+ for (const record of values) this.em.persist(record)
455
+ await this.em.flush()
456
+ }
409
457
  } catch { /* non-blocking */ }
410
458
  }
411
459
 
412
460
  async createOrmEntity<T extends object>(opts: { entity: EntityName<T>; data: EntityData<T> }): Promise<T> {
413
461
  const entity = this.em.create(
414
- opts.entity,
415
- opts.data as RequiredEntityData<T, never, true>
462
+ opts.entity as EntityName<T>,
463
+ opts.data as unknown as RequiredEntityData<T>
416
464
  )
417
- await this.em.persistAndFlush(entity)
465
+ await this.em.persist(entity).flush()
418
466
  return entity
419
467
  }
420
468
 
@@ -423,10 +471,10 @@ export class DefaultDataEngine implements DataEngine {
423
471
  where: FilterQuery<T>
424
472
  apply: (current: T) => Promise<void> | void
425
473
  }): Promise<T | null> {
426
- const current = await this.em.findOne(opts.entity, opts.where)
474
+ const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)
427
475
  if (!current) return null
428
476
  await opts.apply(current)
429
- await this.em.persistAndFlush(current)
477
+ await this.em.persist(current).flush()
430
478
  return current
431
479
  }
432
480
 
@@ -436,16 +484,16 @@ export class DefaultDataEngine implements DataEngine {
436
484
  soft?: boolean
437
485
  softDeleteField?: keyof T & string
438
486
  }): Promise<T | null> {
439
- const current = await this.em.findOne(opts.entity, opts.where)
487
+ const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)
440
488
  if (!current) return null
441
489
  if (opts.soft !== false) {
442
490
  const field = opts.softDeleteField || ('deletedAt' as keyof T & string)
443
491
  if (typeof current === 'object' && current !== null) {
444
492
  ;(current as Record<string, unknown>)[field] = new Date()
445
- await this.em.persistAndFlush(current)
493
+ await this.em.persist(current).flush()
446
494
  }
447
495
  } else {
448
- await this.em.removeAndFlush(current)
496
+ await this.em.remove(current).flush()
449
497
  }
450
498
  return current
451
499
  }
@@ -1,10 +1,13 @@
1
1
  import 'dotenv/config'
2
2
  import 'reflect-metadata'
3
3
  import { MikroORM } from '@mikro-orm/core'
4
- import { PostgreSqlDriver } from '@mikro-orm/postgresql'
4
+ import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'
5
+ import { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'
5
6
  import { getSslConfig } from './ssl'
6
7
 
7
- let ormInstance: MikroORM<PostgreSqlDriver> | null = null
8
+ export type AppMikroORM = MikroORM<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>
9
+
10
+ let ormInstance: AppMikroORM | null = null
8
11
 
9
12
  // Use globalThis so standalone apps survive duplicated shared package module instances.
10
13
  const GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'
@@ -14,7 +17,7 @@ function getRegisteredEntities(): any[] | null {
14
17
  }
15
18
 
16
19
  function setRegisteredEntities(entities: any[]): void {
17
- ;(globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities
20
+ (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities
18
21
  }
19
22
 
20
23
  export function registerOrmEntities(entities: any[]) {
@@ -36,10 +39,13 @@ export async function getOrm() {
36
39
  if (ormInstance) {
37
40
  return ormInstance
38
41
  }
42
+
39
43
  const entities = getOrmEntities()
40
44
  const clientUrl = process.env.DATABASE_URL
41
- if (!clientUrl) throw new Error('DATABASE_URL is not set')
42
-
45
+ if (!clientUrl) {
46
+ throw new Error('DATABASE_URL is not set')
47
+ }
48
+
43
49
  // Parse connection pool settings from environment
44
50
  const poolMin = parseInt(process.env.DB_POOL_MIN || '2')
45
51
  const poolMax = parseInt(process.env.DB_POOL_MAX || '20')
@@ -64,38 +70,33 @@ export async function getOrm() {
64
70
 
65
71
  const sslConfig = getSslConfig()
66
72
 
67
- ormInstance = await MikroORM.init<PostgreSqlDriver>({
73
+ ormInstance = await MikroORM.init<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>({
68
74
  driver: PostgreSqlDriver,
69
75
  clientUrl,
70
76
  entities,
71
77
  debug: false,
72
- // Connection pooling configuration
78
+ // v7 no longer defaults to ReflectMetadataProvider. Entities in this repo use
79
+ // `@mikro-orm/decorators/legacy`, which relies on TypeScript `emitDecoratorMetadata`
80
+ // + reflect-metadata for type inference (nullability, column types). Without this,
81
+ // inferred types are silently wrong at runtime.
82
+ metadataProvider: ReflectMetadataProvider,
83
+ // MikroORM v7 pool shape (min/max/idleTimeoutMillis). Knex-era `acquireTimeoutMillis` /
84
+ // `destroyTimeoutMillis` were removed; acquire wait maps to pg `connectionTimeoutMillis`
85
+ // below under `driverOptions`.
73
86
  pool: {
74
87
  min: poolMin,
75
88
  max: poolMax,
76
89
  idleTimeoutMillis: poolIdleTimeout,
77
- acquireTimeoutMillis: poolAcquireTimeout,
78
- // Close idle connections after 30 seconds
79
- destroyTimeoutMillis: process.env.NODE_ENV === 'production' ? 30000 : 3000,
80
90
  },
81
- // Connection options
91
+ // Driver options are merged into pg.PoolConfig (ClientConfig + pg-pool).
82
92
  driverOptions: {
83
- // Enable connection pooling
84
- connection: {
85
- // Maximum number of connections in the pool
86
- max: poolMax,
87
- // Minimum number of connections in the pool
88
- min: poolMin,
89
- // Close connections after this many milliseconds of inactivity
90
- idleTimeoutMillis: poolIdleTimeout,
91
- // Maximum time to wait for a connection from the pool
92
- acquireTimeoutMillis: poolAcquireTimeout,
93
- idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,
94
- options: connectionOptions,
95
- ssl: sslConfig,
96
- },
93
+ connectionTimeoutMillis: poolAcquireTimeout,
94
+ idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,
95
+ options: connectionOptions,
96
+ ssl: sslConfig,
97
97
  },
98
98
  })
99
+
99
100
  return ormInstance
100
101
  }
101
102
 
@@ -1,5 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import type { Knex } from 'knex'
2
+ import { type Kysely, sql } from 'kysely'
3
3
 
4
4
  export type IndexerErrorSource = 'query_index' | 'vector' | 'fulltext'
5
5
 
@@ -16,7 +16,7 @@ export type RecordIndexerErrorInput = {
16
16
 
17
17
  type RecordIndexerErrorDeps = {
18
18
  em?: EntityManager
19
- knex?: Knex
19
+ db?: Kysely<any>
20
20
  }
21
21
 
22
22
  const MAX_MESSAGE_LENGTH = 8_192
@@ -58,14 +58,11 @@ function safeJson(value: unknown): unknown {
58
58
  }
59
59
  }
60
60
 
61
- function pickKnex(deps: RecordIndexerErrorDeps): Knex | null {
62
- if (deps.knex) return deps.knex
61
+ function pickDb(deps: RecordIndexerErrorDeps): Kysely<any> | null {
62
+ if (deps.db) return deps.db
63
63
  if (deps.em) {
64
64
  try {
65
- const connection = deps.em.getConnection()
66
- if (connection && typeof connection.getKnex === 'function') {
67
- return connection.getKnex()
68
- }
65
+ return deps.em.getKysely<any>()
69
66
  } catch {
70
67
  return null
71
68
  }
@@ -74,9 +71,9 @@ function pickKnex(deps: RecordIndexerErrorDeps): Knex | null {
74
71
  }
75
72
 
76
73
  export async function recordIndexerError(deps: RecordIndexerErrorDeps, input: RecordIndexerErrorInput): Promise<void> {
77
- const knex = pickKnex(deps)
78
- if (!knex) {
79
- console.error('[indexers] Unable to record indexer error (missing knex connection)', {
74
+ const db = pickDb(deps)
75
+ if (!db) {
76
+ console.error('[indexers] Unable to record indexer error (missing db connection)', {
80
77
  source: input.source,
81
78
  handler: input.handler,
82
79
  })
@@ -88,18 +85,21 @@ export async function recordIndexerError(deps: RecordIndexerErrorDeps, input: Re
88
85
  const now = new Date()
89
86
 
90
87
  try {
91
- await knex('indexer_error_logs').insert({
92
- source: input.source,
93
- handler: input.handler,
94
- entity_type: input.entityType ?? null,
95
- record_id: input.recordId ?? null,
96
- tenant_id: input.tenantId ?? null,
97
- organization_id: input.organizationId ?? null,
98
- payload,
99
- message: truncate(message, MAX_MESSAGE_LENGTH),
100
- stack: truncate(stack, MAX_STACK_LENGTH),
101
- occurred_at: now,
102
- })
88
+ await db
89
+ .insertInto('indexer_error_logs' as any)
90
+ .values({
91
+ source: input.source,
92
+ handler: input.handler,
93
+ entity_type: input.entityType ?? null,
94
+ record_id: input.recordId ?? null,
95
+ tenant_id: input.tenantId ?? null,
96
+ organization_id: input.organizationId ?? null,
97
+ payload: payload === null ? null : sql`${JSON.stringify(payload)}::jsonb`,
98
+ message: truncate(message, MAX_MESSAGE_LENGTH),
99
+ stack: truncate(stack, MAX_STACK_LENGTH),
100
+ occurred_at: now,
101
+ } as any)
102
+ .execute()
103
103
  } catch (loggingError) {
104
104
  console.error('[indexers] Failed to persist indexer error', loggingError)
105
105
  }