@open-mercato/shared 0.7.1-develop.7122.1.421cefe668 → 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.
@@ -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
+ }
@@ -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
  }