@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2699.f8b50c8046
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/dist/lib/api/crud.js +1 -1
- package/dist/lib/api/crud.js.map +2 -2
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/data/engine.js +68 -27
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +18 -22
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/indexers/error-log.js +10 -12
- package/dist/lib/indexers/error-log.js.map +2 -2
- package/dist/lib/indexers/status-log.js +14 -16
- package/dist/lib/indexers/status-log.js.map +2 -2
- package/dist/lib/query/engine.js +220 -228
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/join-utils.js +28 -23
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +4 -2
- package/package.json +1 -1
- package/src/lib/api/__tests__/crud.test.ts +5 -3
- package/src/lib/api/crud.ts +1 -1
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/types.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
- package/src/lib/data/engine.ts +95 -47
- package/src/lib/db/mikro.ts +26 -25
- package/src/lib/indexers/error-log.ts +23 -23
- package/src/lib/indexers/status-log.ts +36 -33
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
- package/src/lib/query/__tests__/engine.test.ts +206 -139
- package/src/lib/query/engine.ts +306 -263
- 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
|
|
207
|
-
const
|
|
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(
|
|
215
|
+
expect(persist).toHaveBeenCalledWith(entity)
|
|
216
|
+
expect(flush).toHaveBeenCalled()
|
|
215
217
|
})
|
|
216
218
|
})
|
package/src/lib/api/crud.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
94
|
+
expect(emFlush).toHaveBeenCalledTimes(1)
|
|
89
95
|
})
|
|
90
96
|
|
|
91
97
|
it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
8
|
+
import type { EntityClass, EntitySchema } from '@mikro-orm/core'
|
|
9
9
|
|
|
10
|
-
export type OrmEntity = EntityClass<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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
86
|
-
else await repo.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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',
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -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
|
|
207
|
-
const exists = await
|
|
208
|
-
.
|
|
209
|
-
.
|
|
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
|
|
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:
|
|
284
|
-
created_at:
|
|
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
|
|
291
|
-
.
|
|
292
|
-
.
|
|
293
|
-
.
|
|
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
|
|
298
|
-
.
|
|
299
|
-
.
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
|
340
|
-
.where(
|
|
341
|
-
.
|
|
342
|
-
|
|
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
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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:
|
|
356
|
-
updated_at:
|
|
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
|
|
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
|
|
385
|
-
.
|
|
386
|
-
|
|
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
|
|
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)
|
|
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
|
|
462
|
+
opts.entity as EntityName<T>,
|
|
463
|
+
opts.data as unknown as RequiredEntityData<T>
|
|
416
464
|
)
|
|
417
|
-
await this.em.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
493
|
+
await this.em.persist(current).flush()
|
|
446
494
|
}
|
|
447
495
|
} else {
|
|
448
|
-
await this.em.
|
|
496
|
+
await this.em.remove(current).flush()
|
|
449
497
|
}
|
|
450
498
|
return current
|
|
451
499
|
}
|
package/src/lib/db/mikro.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import 'dotenv/config'
|
|
2
2
|
import 'reflect-metadata'
|
|
3
3
|
import { MikroORM } from '@mikro-orm/core'
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
//
|
|
91
|
+
// Driver options are merged into pg.PoolConfig (ClientConfig + pg-pool).
|
|
82
92
|
driverOptions: {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
-
if (deps.
|
|
61
|
+
function pickDb(deps: RecordIndexerErrorDeps): Kysely<any> | null {
|
|
62
|
+
if (deps.db) return deps.db
|
|
63
63
|
if (deps.em) {
|
|
64
64
|
try {
|
|
65
|
-
|
|
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
|
|
78
|
-
if (!
|
|
79
|
-
console.error('[indexers] Unable to record indexer error (missing
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|