@open-mercato/shared 0.7.1-develop.7123.1.25365e718d → 0.7.1-develop.7130.1.fef2396fd8
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/db/duplicateEntities.js +52 -0
- package/dist/lib/db/duplicateEntities.js.map +7 -0
- package/dist/lib/db/duplicateEntityClassNames.js +66 -0
- package/dist/lib/db/duplicateEntityClassNames.js.map +7 -0
- package/dist/lib/db/mikro.js +31 -0
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +6 -2
- package/src/lib/db/__tests__/duplicateEntities.test.ts +260 -0
- package/src/lib/db/__tests__/duplicateEntityClassNames.test.ts +172 -0
- package/src/lib/db/__tests__/duplicateEntityClassNamesExport.test.ts +63 -0
- package/src/lib/db/__tests__/fixtures/invoiceBilling.ts +7 -0
- package/src/lib/db/__tests__/fixtures/invoiceReporting.ts +7 -0
- package/src/lib/db/__tests__/fixtures/invoiceSubscriptions.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerBilling.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerReporting.ts +7 -0
- package/src/lib/db/__tests__/fixtures/ledgerSubscriptions.ts +7 -0
- package/src/lib/db/__tests__/registerOrmEntities.duplicates.test.ts +164 -0
- package/src/lib/db/duplicateEntities.ts +89 -0
- package/src/lib/db/duplicateEntityClassNames.ts +150 -0
- package/src/lib/db/mikro.ts +55 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection and reporting for entity class names contributed by more than one module.
|
|
3
|
+
*
|
|
4
|
+
* MikroORM keys metadata by the JS class name. Discovery does keep a separate metadata
|
|
5
|
+
* entry per constructor, so class-based lookups such as `em.find(Invoice)` stay correct,
|
|
6
|
+
* but every name-based resolution — a string relation target, `getRepository('<Name>')`,
|
|
7
|
+
* relation discovery, serialization — goes through the name-keyed map, where only one of
|
|
8
|
+
* the same-named classes survives. Which one wins is decided by registration order, and
|
|
9
|
+
* the loser is reachable by class only. Nothing fails; the wrong table is simply used.
|
|
10
|
+
*
|
|
11
|
+
* Nothing upstream catches it: `discovery.checkDuplicateEntities` is declared as a
|
|
12
|
+
* default in @mikro-orm/core 7.1.9 but is read nowhere, and the one live check compares
|
|
13
|
+
* table names rather than class names, which never collide here because every entity
|
|
14
|
+
* declares an explicit `tableName`.
|
|
15
|
+
*
|
|
16
|
+
* This module is deliberately dependency-free so both the build-time generator and the
|
|
17
|
+
* runtime bootstrap can share it without pulling the ORM into the generator. Detection
|
|
18
|
+
* is kept separate from reporting so each caller decides whether a collision warns or
|
|
19
|
+
* throws.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stable, constant sentences shared by both reporting surfaces: the structured runtime
|
|
24
|
+
* log line puts them in fields, the generator renders them inline.
|
|
25
|
+
*/
|
|
26
|
+
export const DUPLICATE_ENTITY_CLASS_NAMES_REASON =
|
|
27
|
+
"MikroORM keeps one metadata entry per constructor, so class-based lookups such as em.find(<Name>) stay correct, but every name-based resolution — string relation targets, getRepository('<Name>'), relation discovery and serialization — goes through the name-keyed map, where only one of the same-named classes survives. Which one wins depends on registration order, so the other silently reads and writes the surviving class's table."
|
|
28
|
+
|
|
29
|
+
export const DUPLICATE_ENTITY_CLASS_NAMES_REMEDIATION =
|
|
30
|
+
'Rename all but one of the colliding classes so every entity class name is unique across enabled modules, then update their exports, relation targets and imports. Table names may stay as they are.'
|
|
31
|
+
|
|
32
|
+
export type EntityClassNameEntry = {
|
|
33
|
+
className: string
|
|
34
|
+
moduleId?: string
|
|
35
|
+
sourcePath?: string
|
|
36
|
+
/**
|
|
37
|
+
* Runtime identity of the class. Two entries sharing a target are the same class
|
|
38
|
+
* reached twice — re-exported through a second import path, or re-registered by an
|
|
39
|
+
* HMR reload — and never count as a collision. Absent at build time, where the
|
|
40
|
+
* declaring module and file identify the class instead.
|
|
41
|
+
*/
|
|
42
|
+
target?: object
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DuplicateEntityClassNameSource = {
|
|
46
|
+
moduleId?: string
|
|
47
|
+
sourcePath?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DuplicateEntityClassNameGroup = {
|
|
51
|
+
className: string
|
|
52
|
+
sources: DuplicateEntityClassNameSource[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Prefer the runtime class, then the declaring module and file. When an entry carries
|
|
57
|
+
* none of those, fall back to the entry itself so it stays distinct: an unidentifiable
|
|
58
|
+
* entry should fail open and surface a possible collision rather than collapse into a
|
|
59
|
+
* shared bucket key and hide one.
|
|
60
|
+
*/
|
|
61
|
+
function identify(entry: EntityClassNameEntry): unknown {
|
|
62
|
+
if (entry.target) return entry.target
|
|
63
|
+
if (entry.moduleId || entry.sourcePath) return `${entry.moduleId ?? ''}|${entry.sourcePath ?? ''}`
|
|
64
|
+
return entry
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Group entries by class name, keeping only names contributed by two or more distinct
|
|
69
|
+
* classes. Order follows first appearance, which is the module registration order.
|
|
70
|
+
*/
|
|
71
|
+
export function findDuplicateEntityClassNames(
|
|
72
|
+
entries: readonly EntityClassNameEntry[],
|
|
73
|
+
): DuplicateEntityClassNameGroup[] {
|
|
74
|
+
const buckets = new Map<string, Map<unknown, DuplicateEntityClassNameSource>>()
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.className) continue
|
|
77
|
+
let bucket = buckets.get(entry.className)
|
|
78
|
+
if (!bucket) {
|
|
79
|
+
bucket = new Map<unknown, DuplicateEntityClassNameSource>()
|
|
80
|
+
buckets.set(entry.className, bucket)
|
|
81
|
+
}
|
|
82
|
+
const identity = identify(entry)
|
|
83
|
+
if (!bucket.has(identity)) {
|
|
84
|
+
bucket.set(identity, { moduleId: entry.moduleId, sourcePath: entry.sourcePath })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const groups: DuplicateEntityClassNameGroup[] = []
|
|
88
|
+
for (const [className, bucket] of buckets) {
|
|
89
|
+
if (bucket.size < 2) continue
|
|
90
|
+
groups.push({ className, sources: Array.from(bucket.values()) })
|
|
91
|
+
}
|
|
92
|
+
return groups
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* At runtime the source path comes from MikroORM's decorator, which derives it by
|
|
97
|
+
* parsing a stack trace and falls back to the bare class name when that parse fails, so
|
|
98
|
+
* only render a value that still looks like a path.
|
|
99
|
+
*/
|
|
100
|
+
function formatSource(source: DuplicateEntityClassNameSource): string {
|
|
101
|
+
const path = source.sourcePath && /[\\/]/.test(source.sourcePath) ? source.sourcePath : undefined
|
|
102
|
+
if (source.moduleId && path) return ` - ${source.moduleId} (${path})`
|
|
103
|
+
if (source.moduleId) return ` - ${source.moduleId}`
|
|
104
|
+
if (path) return ` - ${path}`
|
|
105
|
+
return ' - unknown module'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render every collision in one message, so a fix does not have to be discovered one
|
|
110
|
+
* rerun at a time. Callers prepend their own surface prefix.
|
|
111
|
+
*/
|
|
112
|
+
export function formatDuplicateEntityClassNamesWarning(
|
|
113
|
+
groups: readonly DuplicateEntityClassNameGroup[],
|
|
114
|
+
): string {
|
|
115
|
+
const names = groups.map((group) => `"${group.className}"`).join(', ')
|
|
116
|
+
const lines = [
|
|
117
|
+
`Duplicate entity class name(s) defined by more than one enabled module: ${names}.`,
|
|
118
|
+
DUPLICATE_ENTITY_CLASS_NAMES_REASON,
|
|
119
|
+
DUPLICATE_ENTITY_CLASS_NAMES_REMEDIATION,
|
|
120
|
+
]
|
|
121
|
+
for (const group of groups) {
|
|
122
|
+
lines.push(` ${group.className}`)
|
|
123
|
+
for (const source of group.sources) {
|
|
124
|
+
lines.push(formatSource(source))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return lines.join('\n')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type DuplicateEntityClassNameFields = {
|
|
131
|
+
classNames: string[]
|
|
132
|
+
duplicates: Array<{ className: string; sources: DuplicateEntityClassNameSource[] }>
|
|
133
|
+
reason: string
|
|
134
|
+
remediation: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The same collisions as queryable fields, for callers logging through the structured
|
|
139
|
+
* facade, where the message must stay constant and the dynamic values live beside it.
|
|
140
|
+
*/
|
|
141
|
+
export function toDuplicateEntityClassNameFields(
|
|
142
|
+
groups: readonly DuplicateEntityClassNameGroup[],
|
|
143
|
+
): DuplicateEntityClassNameFields {
|
|
144
|
+
return {
|
|
145
|
+
classNames: groups.map((group) => group.className),
|
|
146
|
+
duplicates: groups.map((group) => ({ className: group.className, sources: group.sources })),
|
|
147
|
+
reason: DUPLICATE_ENTITY_CLASS_NAMES_REASON,
|
|
148
|
+
remediation: DUPLICATE_ENTITY_CLASS_NAMES_REMEDIATION,
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/lib/db/mikro.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'
|
|
|
5
5
|
import { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'
|
|
6
6
|
import { getSslConfig } from './ssl'
|
|
7
7
|
import { createLogger } from '../logger'
|
|
8
|
+
import { findDuplicateRegisteredEntityClassNames } from './duplicateEntities'
|
|
9
|
+
import {
|
|
10
|
+
toDuplicateEntityClassNameFields,
|
|
11
|
+
type DuplicateEntityClassNameGroup,
|
|
12
|
+
} from './duplicateEntityClassNames'
|
|
8
13
|
|
|
9
14
|
const logger = createLogger('shared').child({ component: 'orm' })
|
|
10
15
|
|
|
@@ -14,6 +19,30 @@ let ormInstance: AppMikroORM | null = null
|
|
|
14
19
|
|
|
15
20
|
// Use globalThis so standalone apps survive duplicated shared package module instances.
|
|
16
21
|
const GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'
|
|
22
|
+
// Same reason, plus HMR: a module-level map would reset on the very reloads it exists to
|
|
23
|
+
// deduplicate across.
|
|
24
|
+
const GLOBAL_REPORTED_DUPLICATE_ENTITY_NAMES_KEY = '__openMercatoReportedDuplicateEntityClassNames__'
|
|
25
|
+
|
|
26
|
+
function getReportedDuplicateEntityClassNames(): Map<string, string> {
|
|
27
|
+
const globals = globalThis as Record<string, unknown>
|
|
28
|
+
const existing = globals[GLOBAL_REPORTED_DUPLICATE_ENTITY_NAMES_KEY]
|
|
29
|
+
if (existing instanceof Map) return existing as Map<string, string>
|
|
30
|
+
const created = new Map<string, string>()
|
|
31
|
+
globals[GLOBAL_REPORTED_DUPLICATE_ENTITY_NAMES_KEY] = created
|
|
32
|
+
return created
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Identifies a collision by the modules and files that contribute to it, so a
|
|
37
|
+
* re-registration reporting the same name from a different pair of modules is a new
|
|
38
|
+
* collision rather than a repeat.
|
|
39
|
+
*/
|
|
40
|
+
function fingerprintCollision(group: DuplicateEntityClassNameGroup): string {
|
|
41
|
+
return group.sources
|
|
42
|
+
.map((source) => `${source.moduleId ?? ''}|${source.sourcePath ?? ''}`)
|
|
43
|
+
.sort((left, right) => left.localeCompare(right))
|
|
44
|
+
.join(',')
|
|
45
|
+
}
|
|
17
46
|
|
|
18
47
|
function getRegisteredEntities(): any[] | null {
|
|
19
48
|
return (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] as any[] | null ?? null
|
|
@@ -23,7 +52,33 @@ function setRegisteredEntities(entities: any[]): void {
|
|
|
23
52
|
(globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities
|
|
24
53
|
}
|
|
25
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A duplicate entity class name across modules corrupts entity resolution silently, and
|
|
57
|
+
* no build step catches it. Report it here — the one point every registration path goes
|
|
58
|
+
* through — so the logs name the cause instead of only its distant symptoms.
|
|
59
|
+
*/
|
|
60
|
+
function warnOnDuplicateEntityClassNames(entities: readonly unknown[]): void {
|
|
61
|
+
try {
|
|
62
|
+
const duplicates = findDuplicateRegisteredEntityClassNames(entities)
|
|
63
|
+
// Development re-runs registration on every HMR reload, so report a collision only
|
|
64
|
+
// when it appears or its contributing modules change. Reprinting the same warning on
|
|
65
|
+
// every reload buries it, while tracking the previous registration rather than every
|
|
66
|
+
// name ever seen keeps a collision that was fixed and reintroduced reportable.
|
|
67
|
+
const reported = getReportedDuplicateEntityClassNames()
|
|
68
|
+
const current = new Map(duplicates.map((group) => [group.className, fingerprintCollision(group)]))
|
|
69
|
+
const fresh = duplicates.filter((group) => reported.get(group.className) !== current.get(group.className))
|
|
70
|
+
reported.clear()
|
|
71
|
+
for (const [className, fingerprint] of current) reported.set(className, fingerprint)
|
|
72
|
+
if (fresh.length === 0) return
|
|
73
|
+
logger.warn('Duplicate entity class names across enabled modules', toDuplicateEntityClassNameFields(fresh))
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// This check is a diagnostic. It must never be the reason a bootstrap fails.
|
|
76
|
+
logger.debug('Duplicate entity class name check skipped', { err })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
26
80
|
export function registerOrmEntities(entities: any[]) {
|
|
81
|
+
warnOnDuplicateEntityClassNames(entities)
|
|
27
82
|
if (getRegisteredEntities() !== null && process.env.NODE_ENV === 'development') {
|
|
28
83
|
logger.debug('ORM entities re-registered (this may occur during HMR)')
|
|
29
84
|
}
|