@open-mercato/shared 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba

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.
@@ -3,6 +3,10 @@ import type { AppDiRegistrar } from '../di/container'
3
3
  import { findAppRoot, type AppRoot } from './appResolver'
4
4
  import { registerEntityIds } from '../encryption/entityIds'
5
5
  import { createLogger } from '../logger'
6
+ import {
7
+ applyModuleOverridesFromEnabledModules,
8
+ type ModuleEntryWithOverrides,
9
+ } from '../../modules/overrides'
6
10
  import {
7
11
  ensureMikroOrmV7GeneratedCacheCompatibility,
8
12
  recoverMikroOrmV7GeneratedCacheFromImportError,
@@ -589,6 +593,95 @@ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null
589
593
  }
590
594
  }
591
595
 
596
+ /**
597
+ * Override domains whose applier is not registered by `registerBuiltInModuleOverrideAppliers()`
598
+ * but by importing a domain package for its side effect. `bootstrap-common.ts` does this with a
599
+ * static import right before it dispatches; the dynamic bootstrap path has no bundler to lean on,
600
+ * so it resolves the same modules here — lazily, and only when an app actually declares the
601
+ * domain, so `@open-mercato/shared` keeps its rule of never taking a runtime dependency on a
602
+ * domain package (soft-optional coupling, `packages/core/AGENTS.md` → Cross-Module Coupling).
603
+ */
604
+ const OPTIONAL_OVERRIDE_APPLIER_MODULES: Record<string, string> = {
605
+ ai: '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-overrides',
606
+ }
607
+
608
+ /**
609
+ * Import the side-effect module that registers the applier for every declared override domain
610
+ * that has no built-in one. A domain package the app does not install is not an error — there
611
+ * is nothing for that domain to apply to — so a failed resolution is logged and skipped, and the
612
+ * dispatcher's own "domain not yet wired" warning still fires behind it.
613
+ */
614
+ async function ensureOptionalOverrideAppliers(enabledModules: ModuleEntryWithOverrides[]): Promise<void> {
615
+ for (const [domain, specifier] of Object.entries(OPTIONAL_OVERRIDE_APPLIER_MODULES)) {
616
+ const declared = enabledModules.some((entry) => {
617
+ const overrides = entry?.overrides as Record<string, unknown> | undefined
618
+ return Boolean(overrides && overrides[domain])
619
+ })
620
+ if (!declared) continue
621
+ try {
622
+ await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier)
623
+ } catch (error) {
624
+ logger.debug('Optional override applier module is not installed; the domain has nothing to apply to', {
625
+ domain,
626
+ specifier,
627
+ err: error,
628
+ })
629
+ }
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Dispatch `entry.overrides` declared in the app's `src/modules.ts` for the dynamic
635
+ * bootstrap path.
636
+ *
637
+ * The Next.js runtime imports `enabledModules` statically from its own `src/modules.ts` and
638
+ * calls `applyModuleOverridesFromEnabledModules` from `bootstrap-common.ts` before any registry
639
+ * first-loads. Worker, scheduler and CLI processes bootstrap through `bootstrapFromAppRoot`
640
+ * instead, which only ever compiled the generated `modules.cli.generated.ts` — so an app's
641
+ * `entry.overrides` (encryption maps, ACL features, CLI commands, workers, event subscribers,
642
+ * setup, …) silently never applied there. `seed-encryption` seeding the base encryption maps
643
+ * instead of the app's `overrides.encryption.maps` was the concrete symptom (#5582).
644
+ *
645
+ * An app layout with no `src/modules.ts` at all is logged and skipped — that is a real
646
+ * compatibility case, handled the same way an absent `src/di.ts` is. A file that is *present*
647
+ * but fails to compile or import is not: it throws, matching how this same function treats
648
+ * every other mandatory input and how the Next.js runtime treats this same file (a static
649
+ * import in `bootstrap-common.ts`). Degrading there would put `seed-encryption` back on the
650
+ * base encryption maps while still printing success — #5582's outcome, only quieter.
651
+ */
652
+ async function loadAppModuleOverrides(appDir: string): Promise<void> {
653
+ const tsPath = path.join(appDir, 'src', 'modules.ts')
654
+ if (!fs.existsSync(tsPath)) {
655
+ logger.debug('App-level modules file not present, skipping entry.overrides dispatch', { filePath: tsPath })
656
+ return
657
+ }
658
+
659
+ let enabledModules: unknown
660
+ try {
661
+ const appModulesModule = await compileAndImport(tsPath, {
662
+ appRoot: appDir,
663
+ outFile: path.join(appDir, '.mercato', 'generated', 'app-modules-overrides.compiled.mjs'),
664
+ })
665
+ enabledModules = appModulesModule.enabledModules
666
+ } catch (error) {
667
+ throw new Error(
668
+ `[internal] Failed to load the app-level modules file (${tsPath}); entry.overrides cannot be applied. ` +
669
+ 'Refusing to bootstrap with a partial override set.',
670
+ { cause: error },
671
+ )
672
+ }
673
+
674
+ if (!Array.isArray(enabledModules)) {
675
+ throw new Error(
676
+ `[internal] The app-level modules file (${tsPath}) exports no enabledModules array; ` +
677
+ 'entry.overrides cannot be applied. Refusing to bootstrap with a partial override set.',
678
+ )
679
+ }
680
+
681
+ await ensureOptionalOverrideAppliers(enabledModules as ModuleEntryWithOverrides[])
682
+ applyModuleOverridesFromEnabledModules(enabledModules as ModuleEntryWithOverrides[])
683
+ }
684
+
592
685
  /**
593
686
  * Dynamically load bootstrap data from a resolved app directory.
594
687
  *
@@ -681,13 +774,20 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
681
774
  export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
682
775
  const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
683
776
  const resolved = resolveAppRootOrThrow(appRoot)
684
- // Both loads compile through esbuild, so they share one lifecycle scope: without it
777
+ // All three loads compile through esbuild, so they share one lifecycle scope: without it
685
778
  // `loadBootstrapData` releases the esbuild helper process and `loadAppDiRegistrar`
686
779
  // silently starts a second one that nothing ever stops.
687
- const { data, appDiRegistrar } = await withEsbuildLifecycle(async () => ({
688
- data: await loadBootstrapData(resolved.appDir),
689
- appDiRegistrar: await loadAppDiRegistrar(resolved.appDir),
690
- }))
780
+ const { data, appDiRegistrar } = await withEsbuildLifecycle(async () => {
781
+ // Dispatch the app's `entry.overrides` (src/modules.ts) BEFORE any registry
782
+ // first-loads — the `bootstrap()` call below runs `registerModules(data.modules)`,
783
+ // and `registerCliModules` in the mercato bin right after this function returns;
784
+ // both read the override side-registry this populates.
785
+ await loadAppModuleOverrides(resolved.appDir)
786
+ return {
787
+ data: await loadBootstrapData(resolved.appDir),
788
+ appDiRegistrar: await loadAppDiRegistrar(resolved.appDir),
789
+ }
790
+ })
691
791
  const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
692
792
  bootstrap()
693
793
  // In CLI context, wait for async registrations (UI widgets, search configs, etc.)
@@ -922,6 +922,29 @@ describe('CRUD Factory', () => {
922
922
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
923
923
  })
924
924
 
925
+ it('returns a correlated 409 without leaking the constraint name when a handler hits a foreign key violation', async () => {
926
+ setRecordCustomFields.mockImplementationOnce(async () => {
927
+ // Mirror MikroORM's wrapping: the pg error sits behind `previous`, and the
928
+ // wrapper only carries the message.
929
+ throw Object.assign(
930
+ new Error('update or delete on table "users" violates foreign key constraint "sidebar_variants_user_id_foreign" on table "sidebar_variants"'),
931
+ { previous: { code: '23503', constraint: 'sidebar_variants_user_id_foreign' } },
932
+ )
933
+ })
934
+ const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Referenced', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
935
+ expect(res.status).toBe(409)
936
+ const body = await res.json()
937
+ expect(body.code).toBe('FOREIGN_KEY_VIOLATION')
938
+ // Internal schema names stay in the server log, never in the client body.
939
+ expect(body.constraint).toBeUndefined()
940
+ expect(JSON.stringify(body)).not.toContain('sidebar_variants_user_id_foreign')
941
+ // Same correlation contract as the generic 500 path.
942
+ expect(typeof body.requestId).toBe('string')
943
+ expect(res.headers.get('x-request-id')).toBe(body.requestId)
944
+ expect(Object.values(db)).toHaveLength(0)
945
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
946
+ })
947
+
925
948
  it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
926
949
  mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
927
950
  throw new Error('index write failed')
@@ -37,6 +37,22 @@ describe('enricher-registry', () => {
37
37
  expect(result[0].enricher.id).toBe('a.tier')
38
38
  })
39
39
 
40
+ it('includes wildcard enrichers in priority order for a concrete entity', () => {
41
+ registerResponseEnrichers([
42
+ {
43
+ moduleId: 'mod-a',
44
+ enrichers: [
45
+ makeEnricher({ id: 'a.exact', targetEntity: 'customers.person', priority: 10 }),
46
+ makeEnricher({ id: 'a.wildcard', targetEntity: '*', priority: 50 }),
47
+ makeEnricher({ id: 'a.other', targetEntity: 'sales.order', priority: 100 }),
48
+ ],
49
+ },
50
+ ])
51
+
52
+ const result = getEnrichersForEntity('customers.person')
53
+ expect(result.map((entry) => entry.enricher.id)).toEqual(['a.wildcard', 'a.exact'])
54
+ })
55
+
40
56
  it('returns enrichers regardless of queryEngine config', () => {
41
57
  registerResponseEnrichers([
42
58
  {
@@ -0,0 +1,59 @@
1
+ import type { EnricherContext, ResponseEnricher } from '../response-enricher'
2
+ import { registerResponseEnrichers } from '../enricher-registry'
3
+ import { applyResponseEnricherToRecord } from '../enricher-runner'
4
+
5
+ describe('enricher runner', () => {
6
+ it('partitions wildcard read-through cache entries by concrete entity', async () => {
7
+ const cacheEntries = new Map<string, unknown>()
8
+ const cache = {
9
+ get: jest.fn(async (key: string) => cacheEntries.get(key)),
10
+ set: jest.fn(async (key: string, value: unknown) => {
11
+ cacheEntries.set(key, value)
12
+ }),
13
+ }
14
+ const enrichOne = jest.fn(
15
+ async (record: Record<string, unknown>, context: EnricherContext) => ({
16
+ ...record,
17
+ enrichedFrom: context.targetEntity,
18
+ }),
19
+ )
20
+ const enricher: ResponseEnricher<
21
+ Record<string, unknown>,
22
+ { enrichedFrom: string | undefined }
23
+ > = {
24
+ id: 'test.wildcard-cache',
25
+ targetEntity: '*',
26
+ timeout: 10,
27
+ cache: { strategy: 'read-through', ttl: 60_000 },
28
+ enrichOne,
29
+ }
30
+ registerResponseEnrichers([{ moduleId: 'test', enrichers: [enricher] }])
31
+
32
+ const context: EnricherContext = {
33
+ organizationId: 'org-1',
34
+ tenantId: 'tenant-1',
35
+ userId: 'user-1',
36
+ em: {},
37
+ container: { resolve: () => cache },
38
+ }
39
+
40
+ const personResult = await applyResponseEnricherToRecord(
41
+ { id: 'shared-id' },
42
+ 'customers.person',
43
+ context,
44
+ )
45
+ const orderResult = await applyResponseEnricherToRecord(
46
+ { id: 'shared-id' },
47
+ 'sales.order',
48
+ context,
49
+ )
50
+
51
+ expect(personResult.record).toMatchObject({ enrichedFrom: 'customers.person' })
52
+ expect(orderResult.record).toMatchObject({ enrichedFrom: 'sales.order' })
53
+ expect(enrichOne).toHaveBeenCalledTimes(2)
54
+ expect(Array.from(cacheEntries.keys())).toEqual([
55
+ expect.stringContaining('entity:customers.person'),
56
+ expect.stringContaining('entity:sales.order'),
57
+ ])
58
+ })
59
+ })
@@ -87,7 +87,8 @@ export function getEnrichersForEntity(
87
87
  selector?: EnricherSurfaceSelector,
88
88
  ): EnricherRegistryEntry[] {
89
89
  const entityEntries = getResponseEnrichers().filter(
90
- (entry) => entry.enricher.targetEntity === targetEntity,
90
+ (entry) =>
91
+ entry.enricher.targetEntity === targetEntity || entry.enricher.targetEntity === '*',
91
92
  )
92
93
 
93
94
  if (!selector || selector.surface === 'api-response') {
@@ -137,11 +137,12 @@ function resolveCache(context: EnricherContext): CacheLike | null {
137
137
  function buildCacheKey(
138
138
  enricher: ResponseEnricher,
139
139
  context: EnricherContext,
140
+ targetEntity: string,
140
141
  mode: 'one' | 'many',
141
142
  recordIds: string[],
142
143
  ): string {
143
144
  const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b))
144
- return `umes:enricher:${enricher.id}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`
145
+ return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`
145
146
  }
146
147
 
147
148
  function extractRecordId(record: Record<string, unknown>): string {
@@ -212,6 +213,7 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
212
213
  context: EnricherContext,
213
214
  preFilteredEntries?: EnricherRegistryEntry[],
214
215
  ): Promise<EnrichmentResult<T>> {
216
+ const enricherContext: EnricherContext = { ...context, targetEntity }
215
217
  const activeEntries = preFilteredEntries
216
218
  ? filterByACLAndTenant(preFilteredEntries, context)
217
219
  : getActiveEnrichers(targetEntity, context)
@@ -234,7 +236,9 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
234
236
  let result: T[]
235
237
  const recordIds = currentItems.map((item) => extractRecordId(item))
236
238
  const shouldUseCache = enricher.cache?.strategy === 'read-through'
237
- const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'many', recordIds) : null
239
+ const cacheKey = shouldUseCache
240
+ ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)
241
+ : null
238
242
  if (shouldUseCache && cacheKey) {
239
243
  const cached = await readEnricherCache<T[]>(cache, cacheKey)
240
244
  if (cached) {
@@ -246,7 +250,7 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
246
250
 
247
251
  if (enricher.enrichMany) {
248
252
  result = await Promise.race([
249
- enricher.enrichMany(currentItems, context) as Promise<T[]>,
253
+ enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,
250
254
  timeoutPromise(timeout),
251
255
  ])
252
256
  } else {
@@ -311,6 +315,7 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
311
315
  context: EnricherContext,
312
316
  preFilteredEntries?: EnricherRegistryEntry[],
313
317
  ): Promise<SingleEnrichmentResult<T>> {
318
+ const enricherContext: EnricherContext = { ...context, targetEntity }
314
319
  const activeEntries = preFilteredEntries
315
320
  ? filterByACLAndTenant(preFilteredEntries, context)
316
321
  : getActiveEnrichers(targetEntity, context)
@@ -332,7 +337,9 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
332
337
  try {
333
338
  const recordId = extractRecordId(currentRecord)
334
339
  const shouldUseCache = enricher.cache?.strategy === 'read-through'
335
- const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'one', [recordId]) : null
340
+ const cacheKey = shouldUseCache
341
+ ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])
342
+ : null
336
343
  if (shouldUseCache && cacheKey) {
337
344
  const cached = await readEnricherCache<T>(cache, cacheKey)
338
345
  if (cached) {
@@ -342,7 +349,7 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
342
349
  }
343
350
  }
344
351
  const result = await Promise.race([
345
- enricher.enrichOne(currentRecord, context) as Promise<T>,
352
+ enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,
346
353
  timeoutPromise(timeout),
347
354
  ])
348
355
 
@@ -75,7 +75,7 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
75
75
  import { createGenericOptimisticLockReader } from './optimistic-lock'
76
76
  import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
77
77
  import { createLogger } from '../logger'
78
- import { isTransientDbError } from '../db/pg-errors'
78
+ import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError } from '../db/pg-errors'
79
79
  import { getTelemetryRuntime } from '../telemetry/runtime'
80
80
  import { randomUUID } from 'node:crypto'
81
81
 
@@ -633,6 +633,35 @@ function handleError(err: unknown, request?: Request): Response {
633
633
  )
634
634
  }
635
635
 
636
+ if (isForeignKeyViolation(err)) {
637
+ // SQLSTATE 23503 covers both directions: a DELETE blocked by a dependent row
638
+ // and an INSERT/UPDATE pointing at a missing parent. Either way it is a
639
+ // data-state conflict the caller can act on, so answer 409 instead of 500.
640
+ // The constraint name stays in the log only: it maps internal table/column
641
+ // names and has no business in a client-facing body. The missing-parent
642
+ // direction is often a server-side defect, so the error is still reported to
643
+ // telemetry and carries a requestId exactly like the generic 500 below.
644
+ const requestId = resolveRequestId(request)
645
+ const constraint = getForeignKeyViolationConstraint(err)
646
+ logger.warn('Foreign key violation during CRUD handler', {
647
+ message: err instanceof Error ? err.message : undefined,
648
+ constraint,
649
+ requestId,
650
+ })
651
+ getTelemetryRuntime()?.reportError(err, {
652
+ module: 'crud',
653
+ attributes: { requestId, errorName: 'ForeignKeyViolation', constraint: constraint ?? undefined },
654
+ })
655
+ return json(
656
+ {
657
+ error: 'The record is still referenced by other data, or references a record that does not exist',
658
+ code: 'FOREIGN_KEY_VIOLATION',
659
+ requestId,
660
+ },
661
+ { status: 409, headers: { 'x-request-id': requestId } },
662
+ )
663
+ }
664
+
636
665
  // Unexpected exceptions still collapse into a generic 500 for the client (no internal
637
666
  // detail leaked), but a requestId ties that response to this log line and to whatever
638
667
  // reaches APM, so a client/support ticket citing it can be correlated with server-side
@@ -17,6 +17,8 @@ export interface EnricherContext {
17
17
  organizationId: string
18
18
  tenantId: string
19
19
  userId: string
20
+ /** Concrete entity currently being enriched, including for wildcard enrichers. */
21
+ targetEntity?: string
20
22
  em: unknown
21
23
  container: unknown
22
24
  requestedFields?: string[]
@@ -53,7 +55,7 @@ export interface ResponseEnricher<TRecord = any, TEnriched = any> {
53
55
  /** Unique identifier: `<module>.<enricher-name>` */
54
56
  id: string
55
57
 
56
- /** Target entity to enrich: `<module>.<entity>` (e.g., 'customers.person') */
58
+ /** Target entity to enrich: `<module>.<entity>` (e.g., 'customers.person') or `*` for all entities. */
57
59
  targetEntity: string
58
60
 
59
61
  /** ACL features required for this enricher to run */
@@ -1,4 +1,4 @@
1
- import { isTransientDbError, isUniqueViolation } from '../pg-errors'
1
+ import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, isUniqueViolation } from '../pg-errors'
2
2
 
3
3
  describe('isTransientDbError', () => {
4
4
  it('is true for the max_connections SQLSTATE', () => {
@@ -46,3 +46,61 @@ describe('isTransientDbError', () => {
46
46
  expect(isTransientDbError(uniqueErr)).toBe(false)
47
47
  })
48
48
  })
49
+
50
+ describe('isForeignKeyViolation', () => {
51
+ it('is true for the foreign_key_violation SQLSTATE', () => {
52
+ expect(isForeignKeyViolation({ code: '23503' })).toBe(true)
53
+ })
54
+
55
+ it('is true for ORM-wrapped messages that drop the SQLSTATE', () => {
56
+ expect(
57
+ isForeignKeyViolation(
58
+ new Error('update or delete on table "users" violates foreign key constraint "sidebar_variants_user_id_foreign" on table "sidebar_variants"'),
59
+ ),
60
+ ).toBe(true)
61
+ })
62
+
63
+ it('looks through MikroORM wrapper chains (cause / previous), including re-wrapped errors', () => {
64
+ expect(isForeignKeyViolation({ message: 'wrapped', cause: { code: '23503' } })).toBe(true)
65
+ expect(isForeignKeyViolation({ message: 'wrapped', previous: { code: '23503' } })).toBe(true)
66
+ expect(isForeignKeyViolation({ message: 'outer', cause: { message: 'inner', previous: { code: '23503' } } })).toBe(true)
67
+ })
68
+
69
+ it('stops on cyclic or very deep wrapper chains', () => {
70
+ const cyclic: Record<string, unknown> = { message: 'loop' }
71
+ cyclic.cause = cyclic
72
+ expect(isForeignKeyViolation(cyclic)).toBe(false)
73
+ const deep = { cause: { cause: { cause: { cause: { cause: { code: '23503' } } } } } }
74
+ expect(isForeignKeyViolation(deep)).toBe(false)
75
+ })
76
+
77
+ it('is false for unique violations, transient errors and non-DB errors', () => {
78
+ expect(isForeignKeyViolation({ code: '23505' })).toBe(false)
79
+ expect(isForeignKeyViolation({ code: '53300' })).toBe(false)
80
+ expect(isForeignKeyViolation(new Error('something unrelated broke'))).toBe(false)
81
+ expect(isForeignKeyViolation(null)).toBe(false)
82
+ })
83
+ })
84
+
85
+ describe('getForeignKeyViolationConstraint', () => {
86
+ const driverMessage = 'update or delete on table "users" violates foreign key constraint "sidebar_variants_user_id_foreign" on table "sidebar_variants"'
87
+
88
+ it('reads the pg constraint field from the top-level error', () => {
89
+ expect(getForeignKeyViolationConstraint({ code: '23503', constraint: 'user_roles_user_id_foreign' })).toBe('user_roles_user_id_foreign')
90
+ })
91
+
92
+ it('reads the constraint from a wrapped driver error', () => {
93
+ expect(getForeignKeyViolationConstraint({ message: 'wrapped', previous: { code: '23503', constraint: 'sessions_user_id_foreign' } })).toBe('sessions_user_id_foreign')
94
+ expect(getForeignKeyViolationConstraint({ message: 'wrapped', cause: { code: '23503', constraint: 'user_acls_user_id_foreign' } })).toBe('user_acls_user_id_foreign')
95
+ })
96
+
97
+ it('falls back to the quoted constraint in the driver message', () => {
98
+ expect(getForeignKeyViolationConstraint(new Error(driverMessage))).toBe('sidebar_variants_user_id_foreign')
99
+ })
100
+
101
+ it('is null when nothing identifies the constraint', () => {
102
+ expect(getForeignKeyViolationConstraint({ code: '23503' })).toBeNull()
103
+ expect(getForeignKeyViolationConstraint(new Error('something unrelated broke'))).toBeNull()
104
+ expect(getForeignKeyViolationConstraint(null)).toBeNull()
105
+ })
106
+ })
@@ -11,6 +11,63 @@ export function isUniqueViolation(err: unknown): boolean {
11
11
  return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)
12
12
  }
13
13
 
14
+ const FOREIGN_KEY_VIOLATION_MESSAGE = /violates foreign key constraint(?: "([^"]+)")?/i
15
+
16
+ const MAX_ERROR_CHAIN_DEPTH = 4
17
+
18
+ /**
19
+ * MikroORM wraps driver errors and copies the pg fields onto the wrapper, but
20
+ * the original error may also sit behind `cause` (Node) or `previous`
21
+ * (MikroORM), possibly re-wrapped by a transaction helper. Walk that chain,
22
+ * breadth-first with a small depth cap, so a check works on any layer.
23
+ */
24
+ function pgErrorCandidates(err: unknown): Array<Record<string, unknown>> {
25
+ const found: Array<Record<string, unknown>> = []
26
+ const seen = new Set<unknown>()
27
+ let layer: unknown[] = [err]
28
+ for (let depth = 0; depth < MAX_ERROR_CHAIN_DEPTH && layer.length > 0; depth += 1) {
29
+ const next: unknown[] = []
30
+ for (const candidate of layer) {
31
+ if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) continue
32
+ seen.add(candidate)
33
+ const record = candidate as Record<string, unknown>
34
+ found.push(record)
35
+ next.push(record.cause, record.previous)
36
+ }
37
+ layer = next
38
+ }
39
+ return found
40
+ }
41
+
42
+ /**
43
+ * Detect a Postgres foreign-key violation (SQLSTATE 23503): the row is still
44
+ * referenced by a dependent table, or the payload references a parent that
45
+ * does not exist. Looks through MikroORM's driver-error wrapping.
46
+ */
47
+ export function isForeignKeyViolation(err: unknown): boolean {
48
+ return pgErrorCandidates(err).some((candidate) => {
49
+ if (candidate.code === '23503') return true // Postgres foreign_key_violation
50
+ return typeof candidate.message === 'string' && FOREIGN_KEY_VIOLATION_MESSAGE.test(candidate.message)
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Name of the constraint behind a foreign-key violation, read from the pg
56
+ * `constraint` field on any layer of the wrapper chain, or parsed out of the
57
+ * quoted constraint in the driver message when the field is missing.
58
+ */
59
+ export function getForeignKeyViolationConstraint(err: unknown): string | null {
60
+ for (const candidate of pgErrorCandidates(err)) {
61
+ if (typeof candidate.constraint === 'string' && candidate.constraint.length > 0) return candidate.constraint
62
+ }
63
+ for (const candidate of pgErrorCandidates(err)) {
64
+ if (typeof candidate.message !== 'string') continue
65
+ const match = FOREIGN_KEY_VIOLATION_MESSAGE.exec(candidate.message)
66
+ if (match?.[1]) return match[1]
67
+ }
68
+ return null
69
+ }
70
+
14
71
  /**
15
72
  * Postgres SQLSTATEs for transient connection / availability failures — the
16
73
  * database (or its connection pool) is temporarily unreachable and the request
package/src/lib/number.ts CHANGED
@@ -1,3 +1,106 @@
1
+ type LocaleNumberSeparators = { group: string; decimal: string }
2
+
3
+ const DEFAULT_SEPARATORS: LocaleNumberSeparators = { group: ',', decimal: '.' }
4
+ const separatorCache = new Map<string, LocaleNumberSeparators>()
5
+
6
+ const UNICODE_MINUS_SIGNS = /[−‒–—]/g
7
+ const GROUP_LIKE_CHARACTER = /[\s'’ʼ]/
8
+ const GROUP_LIKE_SEPARATOR_IN_POSITION = /(\d)[\s'’ʼ](?=\d{3}(?!\d))/g
9
+ const NORMALIZED_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i
10
+
11
+ /**
12
+ * Group and decimal separators the given locale uses, derived from `Intl` rather than
13
+ * assumed, so grouping characters such as the narrow no-break space (`fr-FR`) are covered.
14
+ */
15
+ export function resolveLocaleNumberSeparators(locale?: string): LocaleNumberSeparators {
16
+ const cacheKey = locale ?? ''
17
+ const cached = separatorCache.get(cacheKey)
18
+ if (cached) return cached
19
+ let resolved = DEFAULT_SEPARATORS
20
+ try {
21
+ const parts = new Intl.NumberFormat(locale, {
22
+ useGrouping: true,
23
+ minimumFractionDigits: 1,
24
+ }).formatToParts(12345.6)
25
+ const group = parts.find((part) => part.type === 'group')?.value ?? DEFAULT_SEPARATORS.group
26
+ const decimal = parts.find((part) => part.type === 'decimal')?.value ?? DEFAULT_SEPARATORS.decimal
27
+ resolved = { group, decimal }
28
+ } catch {
29
+ resolved = DEFAULT_SEPARATORS
30
+ }
31
+ separatorCache.set(cacheKey, resolved)
32
+ return resolved
33
+ }
34
+
35
+ function isValidGrouping(integerPart: string, separator: string): boolean {
36
+ const digits = integerPart.replace(/^[+-]/, '')
37
+ const segments = digits.split(separator)
38
+ if (segments.length < 2) return true
39
+ const [first, ...rest] = segments
40
+ if (!/^\d{1,3}$/.test(first)) return false
41
+ return rest.every((segment) => /^\d{3}$/.test(segment))
42
+ }
43
+
44
+ /**
45
+ * Parses a user-typed number written in the conventions of `locale` — `110,70` under `pl-PL`,
46
+ * `1 234,56` under `fr-FR`, `1,234.56` under `en-US`. Returns `null` when the input is not a
47
+ * number, never a silent `0`, so callers can tell "unparseable" apart from "zero".
48
+ *
49
+ * Both `,` and `.` are accepted as the decimal separator whichever way the locale runs, because
50
+ * users type the shape their keyboard offers. A SINGLE `,` or `.` is therefore always the decimal
51
+ * point, in every locale: `1.500` is 1.5 under `de-DE` just as it is under `en-US`. Reading a lone
52
+ * separator as grouping instead would turn `1.500` into 1500 with no visible cue — a silent 1000×
53
+ * on a money field, and three- and four-decimal unit prices are ordinary here. Grouping is
54
+ * recognized only where it is unambiguous: at least two separators (`1.234.567`), or a whitespace
55
+ * or apostrophe separator standing in a valid group-of-three position (`1 234,56`, `1’234.5`).
56
+ * Whitespace and apostrophes anywhere else are not absorbed — `1 2` is rejected rather than read
57
+ * as 12 — so a mistyped or pasted value surfaces as an error instead of a different number.
58
+ *
59
+ * Use it only on strings a user typed. Values arriving from an API or the database are already
60
+ * numbers and MUST NOT go through it.
61
+ */
62
+ export function parseLocaleNumber(input: string | null | undefined, locale?: string): number | null {
63
+ if (input == null) return null
64
+ const trimmed = input.trim()
65
+ if (!trimmed) return null
66
+
67
+ const { group } = resolveLocaleNumberSeparators(locale)
68
+ let candidate = trimmed.replace(UNICODE_MINUS_SIGNS, '-')
69
+ if (group && group !== ',' && group !== '.' && !GROUP_LIKE_CHARACTER.test(group)) {
70
+ candidate = candidate.split(group).join(' ')
71
+ }
72
+ candidate = candidate.replace(GROUP_LIKE_SEPARATOR_IN_POSITION, '$1')
73
+ if (!candidate) return null
74
+
75
+ const hasComma = candidate.includes(',')
76
+ const hasDot = candidate.includes('.')
77
+ let decimalSeparator: string | null = null
78
+ if (hasComma && hasDot) {
79
+ decimalSeparator = candidate.lastIndexOf(',') > candidate.lastIndexOf('.') ? ',' : '.'
80
+ } else if (hasComma || hasDot) {
81
+ const separator = hasComma ? ',' : '.'
82
+ const segments = candidate.split(separator)
83
+ decimalSeparator = segments.length > 2 ? null : separator
84
+ }
85
+
86
+ const groupSeparator = decimalSeparator
87
+ ? decimalSeparator === ','
88
+ ? '.'
89
+ : ','
90
+ : hasComma
91
+ ? ','
92
+ : '.'
93
+ const [integerPart, ...fractionParts] = decimalSeparator ? candidate.split(decimalSeparator) : [candidate]
94
+ if (fractionParts.length > 1) return null
95
+ if (!isValidGrouping(integerPart, groupSeparator)) return null
96
+ if (fractionParts.length && fractionParts[0].includes(groupSeparator)) return null
97
+
98
+ const normalized = `${integerPart.split(groupSeparator).join('')}${fractionParts.length ? `.${fractionParts[0]}` : ''}`
99
+ if (!NORMALIZED_NUMBER.test(normalized)) return null
100
+ const parsed = Number(normalized)
101
+ return Number.isFinite(parsed) ? parsed : null
102
+ }
103
+
1
104
  export function parseNumberWithDefault(
2
105
  raw: string | null | undefined,
3
106
  fallback: number,