@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.
- package/AGENTS.md +1 -1
- package/dist/lib/bootstrap/dynamicLoader.js +62 -4
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/crud/enricher-registry.js +1 -1
- package/dist/lib/crud/enricher-registry.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +8 -6
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -1
- package/dist/lib/crud/factory.js.map +3 -3
- package/dist/lib/db/pg-errors.js +38 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/number.js +67 -1
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/__tests__/number.test.ts +91 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +279 -0
- package/src/lib/bootstrap/dynamicLoader.ts +105 -5
- package/src/lib/crud/__tests__/crud-factory.test.ts +23 -0
- package/src/lib/crud/__tests__/enricher-registry.test.ts +16 -0
- package/src/lib/crud/__tests__/enricher-runner.test.ts +59 -0
- package/src/lib/crud/enricher-registry.ts +2 -1
- package/src/lib/crud/enricher-runner.ts +12 -5
- package/src/lib/crud/factory.ts +30 -1
- package/src/lib/crud/response-enricher.ts +3 -1
- package/src/lib/db/__tests__/pg-errors.test.ts +59 -1
- package/src/lib/db/pg-errors.ts +57 -0
- package/src/lib/number.ts +103 -0
|
@@ -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
|
-
//
|
|
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
|
-
|
|
689
|
-
|
|
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) =>
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
352
|
+
enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,
|
|
346
353
|
timeoutPromise(timeout),
|
|
347
354
|
])
|
|
348
355
|
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/db/pg-errors.ts
CHANGED
|
@@ -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,
|