@open-mercato/shared 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7238.1.3abe669ea5

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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/availability/catalogOnlyProvider.js +65 -0
  3. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  4. package/dist/lib/availability/index.js +8 -0
  5. package/dist/lib/availability/index.js.map +7 -0
  6. package/dist/lib/availability/registry.js +47 -0
  7. package/dist/lib/availability/registry.js.map +7 -0
  8. package/dist/lib/availability/types.js +7 -0
  9. package/dist/lib/availability/types.js.map +7 -0
  10. package/dist/lib/crud/factory.js +21 -7
  11. package/dist/lib/crud/factory.js.map +2 -2
  12. package/dist/lib/db/pg-errors.js +9 -1
  13. package/dist/lib/db/pg-errors.js.map +2 -2
  14. package/dist/lib/encryption/aes.js +14 -0
  15. package/dist/lib/encryption/aes.js.map +2 -2
  16. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -1
  17. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  18. package/dist/lib/version.js +1 -1
  19. package/dist/lib/version.js.map +1 -1
  20. package/package.json +6 -2
  21. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +98 -0
  22. package/src/lib/availability/__tests__/registry.test.ts +115 -0
  23. package/src/lib/availability/catalogOnlyProvider.ts +119 -0
  24. package/src/lib/availability/index.ts +18 -0
  25. package/src/lib/availability/registry.ts +120 -0
  26. package/src/lib/availability/types.ts +71 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +91 -4
  28. package/src/lib/crud/factory.ts +37 -6
  29. package/src/lib/db/__tests__/pg-errors.test.ts +31 -1
  30. package/src/lib/db/pg-errors.ts +23 -0
  31. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +97 -9
  32. package/src/lib/encryption/aes.ts +43 -0
  33. package/src/lib/encryption/tenantDataEncryptionService.ts +44 -1
@@ -0,0 +1,115 @@
1
+ import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
2
+ import type { AvailabilityModuleConfigReader } from '../registry'
3
+ import type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from '../types'
4
+
5
+ function makeProvider(id: string, result: Partial<AvailabilityResult['byItem'][string]> = {}): AvailabilityProvider {
6
+ return {
7
+ id,
8
+ async getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
9
+ const byItem: AvailabilityResult['byItem'] = {}
10
+ for (const item of query.items) {
11
+ const key = `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
12
+ byItem[key] = {
13
+ state: 'in_stock',
14
+ availableQuantity: 100,
15
+ canFulfil: true,
16
+ leadTimeDays: null,
17
+ releaseAt: null,
18
+ isAuthoritative: true,
19
+ policySourceId: null,
20
+ ...result,
21
+ }
22
+ }
23
+ return { byItem }
24
+ },
25
+ }
26
+ }
27
+
28
+ function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
29
+ return {
30
+ tenantId: 'tenant-1',
31
+ organizationId: 'org-1',
32
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ describe('availabilityProviderRegistry', () => {
38
+ beforeEach(() => {
39
+ availabilityProviderRegistry.reset()
40
+ })
41
+
42
+ it('registers and retrieves providers by id', () => {
43
+ const provider = makeProvider('alpha')
44
+ availabilityProviderRegistry.register(provider)
45
+ expect(availabilityProviderRegistry.get('alpha')).toBe(provider)
46
+ expect(availabilityProviderRegistry.get('missing')).toBeNull()
47
+ })
48
+
49
+ it('is idempotent — replaces by id rather than duplicating', () => {
50
+ const first = makeProvider('alpha')
51
+ const second = makeProvider('alpha')
52
+ availabilityProviderRegistry.register(first)
53
+ availabilityProviderRegistry.register(second)
54
+ expect(availabilityProviderRegistry.list()).toHaveLength(1)
55
+ expect(availabilityProviderRegistry.get('alpha')).toBe(second)
56
+ })
57
+
58
+ it('lists providers in registration order', () => {
59
+ availabilityProviderRegistry.register(makeProvider('first'))
60
+ availabilityProviderRegistry.register(makeProvider('second'))
61
+ expect(availabilityProviderRegistry.list().map((p) => p.id)).toEqual(['first', 'second'])
62
+ })
63
+
64
+ it('rejects a provider with an empty id', () => {
65
+ expect(() => availabilityProviderRegistry.register({ id: '', getAvailability: async () => ({ byItem: {} }) })).toThrow()
66
+ })
67
+ })
68
+
69
+ describe('resolveAvailability', () => {
70
+ beforeEach(() => {
71
+ availabilityProviderRegistry.reset()
72
+ })
73
+
74
+ it('falls back to catalog-only when nothing else is registered and selection is auto', async () => {
75
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked', availableQuantity: null }))
76
+ const result = await resolveAvailability(makeQuery())
77
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
78
+ })
79
+
80
+ it("'auto' picks the highest-precedence non-catalog-only registrant", async () => {
81
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
82
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
83
+ const result = await resolveAvailability(makeQuery())
84
+ expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
85
+ })
86
+
87
+ it('honors an explicit selection via the injected module-config reader', async () => {
88
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
89
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
90
+ const moduleConfig: AvailabilityModuleConfigReader = {
91
+ getValue: async () => 'catalog-only',
92
+ }
93
+ const result = await resolveAvailability(makeQuery(), { moduleConfig })
94
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
95
+ })
96
+
97
+ it('falls back to catalog-only when the selected id is not currently registered', async () => {
98
+ availabilityProviderRegistry.register(makeProvider(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID, { state: 'not_tracked' }))
99
+ const moduleConfig: AvailabilityModuleConfigReader = {
100
+ getValue: async () => 'some-unregistered-provider',
101
+ }
102
+ const result = await resolveAvailability(makeQuery(), { moduleConfig })
103
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
104
+ })
105
+
106
+ it('defaults to auto when the module-config reader is omitted', async () => {
107
+ availabilityProviderRegistry.register(makeProvider('wms', { state: 'in_stock' }))
108
+ const result = await resolveAvailability(makeQuery())
109
+ expect(result.byItem['product-1:variant-1'].state).toBe('in_stock')
110
+ })
111
+
112
+ it('throws when no provider is registered at all', async () => {
113
+ await expect(resolveAvailability(makeQuery())).rejects.toThrow()
114
+ })
115
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The built-in `catalog-only` fallback provider. Always registered so a
3
+ * `wms`-less *and* `availability`-less storefront stays fully functional.
4
+ *
5
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.3
6
+ */
7
+
8
+ import { availabilityItemKey } from './types'
9
+ import type { AvailabilityItemResult, AvailabilityQuery, AvailabilityResult } from './types'
10
+ import { availabilityProviderRegistry, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from './registry'
11
+
12
+ /** Per-item policy signal the optional lookup hook may report. */
13
+ export type CatalogOnlyPolicyOverride = {
14
+ /** `true` → the item is explicitly opted into stock tracking with no data source behind it (§4.3, out_of_stock). */
15
+ isStockManaged?: boolean
16
+ /** `false` → the policy row is inactive; treated as out_of_stock. */
17
+ isActive?: boolean
18
+ /** ISO-8601. In the future → `preorder`. */
19
+ preorderReleaseAt?: string | null
20
+ /** The `AvailabilityPolicy` row id that produced this override. */
21
+ policySourceId?: string | null
22
+ }
23
+
24
+ /**
25
+ * Optional soft lookup into `AvailabilityPolicy` when the `availability`
26
+ * module is installed. Keyed by `availabilityItemKey()`. Returning `null` or
27
+ * omitting an item from the map means "no policy row — module default".
28
+ */
29
+ export type CatalogOnlyPolicyLookup = (
30
+ query: AvailabilityQuery,
31
+ ) => Promise<Record<string, CatalogOnlyPolicyOverride | null | undefined>>
32
+
33
+ let policyLookup: CatalogOnlyPolicyLookup | null = null
34
+
35
+ /**
36
+ * Wired by the `availability` module's `di.ts` at container-build time
37
+ * (closure captures the container, mirroring `wms/di.ts`'s own provider
38
+ * registration) — never a static import from `packages/shared`. Pass `null`
39
+ * to clear (test isolation).
40
+ */
41
+ export function setCatalogOnlyPolicyLookup(lookup: CatalogOnlyPolicyLookup | null): void {
42
+ policyLookup = lookup
43
+ }
44
+
45
+ function pureFallbackItem(): AvailabilityItemResult {
46
+ return {
47
+ state: 'not_tracked',
48
+ availableQuantity: null,
49
+ canFulfil: true,
50
+ leadTimeDays: null,
51
+ releaseAt: null,
52
+ isAuthoritative: true,
53
+ policySourceId: null,
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Applies decision 7's matrix (see PLAN.md § Key design decisions) for a
59
+ * resolved policy override on top of the pure fallback.
60
+ */
61
+ function applyOverride(override: CatalogOnlyPolicyOverride | null | undefined): AvailabilityItemResult {
62
+ const base = pureFallbackItem()
63
+ if (!override) return base
64
+
65
+ const policySourceId = override.policySourceId ?? null
66
+
67
+ if (override.preorderReleaseAt) {
68
+ const releaseAt = new Date(override.preorderReleaseAt)
69
+ if (!Number.isNaN(releaseAt.getTime()) && releaseAt.getTime() > Date.now()) {
70
+ return {
71
+ ...base,
72
+ state: 'preorder',
73
+ canFulfil: true,
74
+ releaseAt: override.preorderReleaseAt,
75
+ policySourceId,
76
+ }
77
+ }
78
+ }
79
+
80
+ if (override.isActive === false) {
81
+ return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
82
+ }
83
+
84
+ if (override.isStockManaged === true) {
85
+ // Opted into stock tracking with no data source to verify against — see
86
+ // decision 7: this is the "policy explicitly marks the item unavailable"
87
+ // case rather than a silent "in stock" claim (R5).
88
+ return { ...base, state: 'out_of_stock', canFulfil: false, policySourceId }
89
+ }
90
+
91
+ return { ...base, policySourceId }
92
+ }
93
+
94
+ async function getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
95
+ const byItem: AvailabilityResult['byItem'] = {}
96
+
97
+ let overrides: Record<string, CatalogOnlyPolicyOverride | null | undefined> = {}
98
+ if (policyLookup) {
99
+ try {
100
+ overrides = await policyLookup(query)
101
+ } catch {
102
+ // Degrade gracefully to the pure fallback — never let an optional
103
+ // policy lookup failure break the always-available fallback provider.
104
+ overrides = {}
105
+ }
106
+ }
107
+
108
+ for (const item of query.items) {
109
+ const key = availabilityItemKey(item)
110
+ byItem[key] = applyOverride(overrides[key])
111
+ }
112
+
113
+ return { byItem }
114
+ }
115
+
116
+ availabilityProviderRegistry.register({
117
+ id: AVAILABILITY_CATALOG_ONLY_PROVIDER_ID,
118
+ getAvailability,
119
+ })
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Public entrypoint for the Availability Contract's shared, dependency-free
3
+ * base — types, the provider registry, `resolveAvailability()`, and the
4
+ * built-in `catalog-only` fallback.
5
+ *
6
+ * Importing from this barrel (rather than `./registry` directly) guarantees
7
+ * the `catalog-only` provider is registered — it self-registers as an import
8
+ * side effect in `./catalogOnlyProvider`, which this file always pulls in.
9
+ *
10
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.1a
11
+ */
12
+
13
+ export * from './types'
14
+ export * from './registry'
15
+ export { setCatalogOnlyPolicyLookup } from './catalogOnlyProvider'
16
+ export type { CatalogOnlyPolicyLookup, CatalogOnlyPolicyOverride } from './catalogOnlyProvider'
17
+
18
+ import './catalogOnlyProvider'
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Availability provider registry — module-level singleton that collects
3
+ * registered providers and dispatches `resolveAvailability()` to the
4
+ * per-tenant selected one, falling back safely to the built-in
5
+ * `catalog-only` provider.
6
+ *
7
+ * Mirrors `packages/shared/src/lib/ai/llm-provider-registry.ts`.
8
+ *
9
+ * @see ./types
10
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.1a
11
+ */
12
+
13
+ import type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from './types'
14
+
15
+ export const AVAILABILITY_CATALOG_ONLY_PROVIDER_ID = 'catalog-only'
16
+
17
+ /** Public interface of the registry. Exposed as a singleton via {@link availabilityProviderRegistry}. */
18
+ export interface AvailabilityProviderRegistry {
19
+ /**
20
+ * Registers or replaces a provider. Registration is idempotent — calling
21
+ * with the same id replaces the existing entry. Never order-dependent.
22
+ */
23
+ register(provider: AvailabilityProvider): void
24
+
25
+ /** Returns the provider with the given id, or null when not registered. */
26
+ get(id: string): AvailabilityProvider | null
27
+
28
+ /** Returns all registered providers in registration order. */
29
+ list(): readonly AvailabilityProvider[]
30
+
31
+ /** Removes all registered providers. Intended for test isolation. */
32
+ reset(): void
33
+ }
34
+
35
+ class AvailabilityProviderRegistryImpl implements AvailabilityProviderRegistry {
36
+ // Preserves registration order via Map iteration semantics.
37
+ private readonly providers = new Map<string, AvailabilityProvider>()
38
+
39
+ register(provider: AvailabilityProvider): void {
40
+ if (!provider || typeof provider.id !== 'string' || provider.id.length === 0) {
41
+ throw new Error('[internal] AvailabilityProviderRegistry: provider must have a non-empty id')
42
+ }
43
+ // Idempotent: replace existing by id.
44
+ this.providers.set(provider.id, provider)
45
+ }
46
+
47
+ get(id: string): AvailabilityProvider | null {
48
+ return this.providers.get(id) ?? null
49
+ }
50
+
51
+ list(): readonly AvailabilityProvider[] {
52
+ return Array.from(this.providers.values())
53
+ }
54
+
55
+ reset(): void {
56
+ this.providers.clear()
57
+ }
58
+ }
59
+
60
+ /** Process-level singleton instance of the registry. */
61
+ export const availabilityProviderRegistry: AvailabilityProviderRegistry = new AvailabilityProviderRegistryImpl()
62
+
63
+ export type AvailabilityProviderSelection = 'auto' | 'catalog-only' | (string & {})
64
+
65
+ /**
66
+ * Narrow port for per-tenant provider selection — declared locally so this
67
+ * package stays dependency-free. A caller with DI access (an `availability`
68
+ * module route, a future `ecommerce`/`cart`/`checkout` consumer) passes in
69
+ * its resolved `ModuleConfigService` instance; omitting it always resolves
70
+ * `'auto'`.
71
+ */
72
+ export interface AvailabilityModuleConfigReader {
73
+ getValue<T = unknown>(
74
+ moduleId: string,
75
+ name: string,
76
+ options?: { defaultValue?: T | null; scope?: { tenantId?: string | null; organizationId?: string | null } },
77
+ ): Promise<T | null>
78
+ }
79
+
80
+ export interface ResolveAvailabilityOptions {
81
+ /** `ModuleConfigService('availability', 'selectedProvider')` reader. See {@link AvailabilityModuleConfigReader}. */
82
+ moduleConfig?: AvailabilityModuleConfigReader
83
+ }
84
+
85
+ function resolveAutoProvider(): AvailabilityProvider | null {
86
+ // 'auto' = "the highest-precedence registered provider" — the first
87
+ // non-catalog-only registrant, in registration order.
88
+ for (const provider of availabilityProviderRegistry.list()) {
89
+ if (provider.id !== AVAILABILITY_CATALOG_ONLY_PROVIDER_ID) return provider
90
+ }
91
+ return availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
92
+ }
93
+
94
+ /**
95
+ * The entry point every read-side consumer calls. Advisory only — see §4.1:
96
+ * `resolveAvailability()` is never a stock guarantee, `reserveAvailability()`
97
+ * (the `availability` module, not shipped by this contract) is.
98
+ */
99
+ export async function resolveAvailability(
100
+ query: AvailabilityQuery,
101
+ options?: ResolveAvailabilityOptions,
102
+ ): Promise<AvailabilityResult> {
103
+ const selection = options?.moduleConfig
104
+ ? await options.moduleConfig.getValue<AvailabilityProviderSelection>('availability', 'selectedProvider', {
105
+ defaultValue: 'auto',
106
+ scope: { tenantId: query.tenantId },
107
+ })
108
+ : 'auto'
109
+
110
+ const provider =
111
+ !selection || selection === 'auto'
112
+ ? resolveAutoProvider()
113
+ : availabilityProviderRegistry.get(selection) ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
114
+
115
+ const resolved = provider ?? availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)
116
+ if (!resolved) {
117
+ throw new Error('[internal] No availability provider registered, including the built-in catalog-only fallback')
118
+ }
119
+ return resolved.getAvailability(query)
120
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Availability Contract — base types.
3
+ *
4
+ * Zero module dependencies, so `ecommerce`, `cart`, `checkout` and `catalog`
5
+ * can consume this without a `requires` edge on either `availability` or `wms`.
6
+ *
7
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.1a
8
+ */
9
+
10
+ export type AvailabilityState =
11
+ | 'in_stock'
12
+ | 'low_stock'
13
+ | 'out_of_stock'
14
+ | 'backorder'
15
+ | 'preorder'
16
+ | 'not_tracked'
17
+
18
+ export type AvailabilityItemQuery = {
19
+ catalogProductId: string
20
+ /** `null` or omitted → product-level rollup over active variants. */
21
+ catalogVariantId?: string | null
22
+ /** The quantity being asked about; state is relative to it. */
23
+ quantity: number
24
+ }
25
+
26
+ export type AvailabilityQuery = {
27
+ tenantId: string
28
+ organizationId: string
29
+ items: AvailabilityItemQuery[]
30
+ /** Selects the policy chain. */
31
+ storeId?: string | null
32
+ channelId?: string | null
33
+ /** `null`/omitted → every in-scope location. */
34
+ locationIds?: string[] | null
35
+ /**
36
+ * Additive, optional. When true, a provider MUST skip any internal read
37
+ * cache and compute a live result — the §6 "cart re-validation" row.
38
+ */
39
+ bypassCache?: boolean
40
+ }
41
+
42
+ export type AvailabilityItemResult = {
43
+ state: AvailabilityState
44
+ /** Sellable quantity; `null` when not tracked. */
45
+ availableQuantity: number | null
46
+ /** Whether the requested quantity can be met, including a backorder/preorder path. */
47
+ canFulfil: boolean
48
+ /** Set for `'backorder'`. */
49
+ leadTimeDays: number | null
50
+ /** ISO-8601; set for `'preorder'`. */
51
+ releaseAt: string | null
52
+ /** `false` for a cached browse-time read — never a guarantee. */
53
+ isAuthoritative: boolean
54
+ /** The `AvailabilityPolicy` row that decided, or `null` for a module default. */
55
+ policySourceId: string | null
56
+ }
57
+
58
+ export type AvailabilityResult = {
59
+ /** Key: `${catalogProductId}:${catalogVariantId ?? ''}` — stable and caller-derivable. */
60
+ byItem: Record<string, AvailabilityItemResult>
61
+ }
62
+
63
+ export interface AvailabilityProvider {
64
+ id: string
65
+ getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult>
66
+ }
67
+
68
+ /** Builds the stable `AvailabilityResult.byItem` key for an item. */
69
+ export function availabilityItemKey(item: { catalogProductId: string; catalogVariantId?: string | null }): string {
70
+ return `${item.catalogProductId}:${item.catalogVariantId ?? ''}`
71
+ }
@@ -23,6 +23,7 @@ import {
23
23
  type TelemetryRuntime,
24
24
  } from '@open-mercato/shared/lib/telemetry/runtime'
25
25
  import { z } from 'zod'
26
+ import { NotFoundError as MikroOrmNotFoundError, ValidationError as MikroOrmValidationError } from '@mikro-orm/core'
26
27
 
27
28
  // Keep the real custom-field helpers but spy on the definition loader so we can
28
29
  // assert the factory skips the second DB round-trip when the query engine has
@@ -917,11 +918,90 @@ describe('CRUD Factory', () => {
917
918
  const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
918
919
  expect(res.status).toBe(503)
919
920
  expect(res.headers.get('Retry-After')).toBe('2')
921
+ const body = await res.json()
922
+ expect(body.code).toBe('DATABASE_UNAVAILABLE')
923
+ expect(typeof body.requestId).toBe('string')
924
+ expect(res.headers.get('x-request-id')).toBe(body.requestId)
920
925
  // The failed write is still rolled back — no created event/index leaks out.
921
926
  expect(Object.values(db)).toHaveLength(0)
922
927
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
923
928
  })
924
929
 
930
+ it('echoes an inbound x-request-id on the 503 transient-DB response', async () => {
931
+ setRecordCustomFields.mockImplementationOnce(async () => {
932
+ throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
933
+ })
934
+ const res = await route.POST(new Request('http://x/api/example/todos', {
935
+ method: 'POST',
936
+ body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }),
937
+ headers: { 'content-type': 'application/json', 'x-request-id': 'req-fixed-503' },
938
+ }))
939
+ expect(res.status).toBe(503)
940
+ const body = await res.json()
941
+ expect(body.requestId).toBe('req-fixed-503')
942
+ expect(res.headers.get('x-request-id')).toBe('req-fixed-503')
943
+ })
944
+
945
+ it('returns DATABASE_ERROR for an unmapped Postgres SQLSTATE without leaking driver detail', async () => {
946
+ setRecordCustomFields.mockImplementationOnce(async () => {
947
+ throw Object.assign(new Error('relation "missing_table" does not exist'), { code: '42P01' })
948
+ })
949
+ const res = await route.POST(new Request('http://x/api/example/todos', {
950
+ method: 'POST',
951
+ body: JSON.stringify({ title: 'Undefined table', is_done: true, cf_priority: 3 }),
952
+ headers: { 'content-type': 'application/json' },
953
+ }))
954
+ expect(res.status).toBe(500)
955
+ const body = await res.json()
956
+ expect(body.code).toBe('DATABASE_ERROR')
957
+ expect(typeof body.requestId).toBe('string')
958
+ expect(JSON.stringify(body)).not.toContain('42P01')
959
+ expect(JSON.stringify(body)).not.toContain('missing_table')
960
+ })
961
+
962
+ it('returns PERSISTENCE_ERROR for a MikroORM ValidationError with no Postgres SQLSTATE', async () => {
963
+ setRecordCustomFields.mockImplementationOnce(async () => {
964
+ throw new MikroOrmValidationError('entity failed validation')
965
+ })
966
+ const res = await route.POST(new Request('http://x/api/example/todos', {
967
+ method: 'POST',
968
+ body: JSON.stringify({ title: 'Invalid entity', is_done: true, cf_priority: 3 }),
969
+ headers: { 'content-type': 'application/json' },
970
+ }))
971
+ expect(res.status).toBe(500)
972
+ const body = await res.json()
973
+ expect(body.code).toBe('PERSISTENCE_ERROR')
974
+ expect(typeof body.requestId).toBe('string')
975
+ })
976
+
977
+ it('returns PERSISTENCE_ERROR for a MikroORM NotFoundError with no Postgres SQLSTATE', async () => {
978
+ setRecordCustomFields.mockImplementationOnce(async () => {
979
+ throw new MikroOrmNotFoundError('entity not found')
980
+ })
981
+ const res = await route.POST(new Request('http://x/api/example/todos', {
982
+ method: 'POST',
983
+ body: JSON.stringify({ title: 'Missing entity', is_done: true, cf_priority: 3 }),
984
+ headers: { 'content-type': 'application/json' },
985
+ }))
986
+ expect(res.status).toBe(500)
987
+ const body = await res.json()
988
+ expect(body.code).toBe('PERSISTENCE_ERROR')
989
+ })
990
+
991
+ it('does not misclassify a Node system error code as a database error', async () => {
992
+ setRecordCustomFields.mockImplementationOnce(async () => {
993
+ throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:80'), { code: 'ECONNREFUSED' })
994
+ })
995
+ const res = await route.POST(new Request('http://x/api/example/todos', {
996
+ method: 'POST',
997
+ body: JSON.stringify({ title: 'Unrelated socket failure', is_done: true, cf_priority: 3 }),
998
+ headers: { 'content-type': 'application/json' },
999
+ }))
1000
+ expect(res.status).toBe(500)
1001
+ const body = await res.json()
1002
+ expect(body.code).toBe('INTERNAL_ERROR')
1003
+ })
1004
+
925
1005
  it('returns a correlated 409 without leaking the constraint name when a handler hits a foreign key violation', async () => {
926
1006
  setRecordCustomFields.mockImplementationOnce(async () => {
927
1007
  // Mirror MikroORM's wrapping: the pg error sits behind `previous`, and the
@@ -1353,6 +1433,7 @@ describe('CRUD Factory', () => {
1353
1433
  error: 'Internal server error',
1354
1434
  message: 'Something went wrong. Please try again later.',
1355
1435
  requestId: expect.any(String),
1436
+ code: 'INTERNAL_ERROR',
1356
1437
  })
1357
1438
  })
1358
1439
 
@@ -1392,6 +1473,7 @@ describe('CRUD Factory', () => {
1392
1473
  error: 'Internal server error',
1393
1474
  message: 'Something went wrong. Please try again later.',
1394
1475
  requestId: expect.any(String),
1476
+ code: 'INTERNAL_ERROR',
1395
1477
  })
1396
1478
  })
1397
1479
 
@@ -1497,7 +1579,7 @@ describe('CRUD Factory', () => {
1497
1579
  expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
1498
1580
  })
1499
1581
 
1500
- it('reports the error to telemetry with the same requestId', async () => {
1582
+ it('reports the error to telemetry with the same requestId and a groupable code', async () => {
1501
1583
  commandBus.execute.mockRejectedValue(new Error('boom'))
1502
1584
 
1503
1585
  const res = await postWithRequestId('req-fixed-123')
@@ -1507,12 +1589,17 @@ describe('CRUD Factory', () => {
1507
1589
  expect(reportError).toHaveBeenCalledTimes(1)
1508
1590
  expect(reportError).toHaveBeenCalledWith(
1509
1591
  expect.any(Error),
1510
- { module: 'crud', attributes: { requestId: 'req-fixed-123', errorName: 'Error' } },
1592
+ {
1593
+ module: 'crud',
1594
+ code: 'crud.internal_error',
1595
+ attributes: { requestId: 'req-fixed-123', errorName: 'Error', code: 'INTERNAL_ERROR', pgCode: undefined },
1596
+ },
1511
1597
  )
1512
1598
  })
1513
1599
 
1514
- // The 503/422 branches deliberately stay outside this change (issue #5608) — lock that
1515
- // in so a later refactor cannot quietly widen the correlation id across every branch.
1600
+ // The 422 interceptor-rejection branch deliberately stays outside this change (issue
1601
+ // #5608) — lock that in so a later refactor cannot quietly widen the correlation id
1602
+ // across every branch. The 503 transient-DB branch gained requestId/code separately.
1516
1603
  it('leaves the interceptor-rejection branch without a requestId', async () => {
1517
1604
  commandBus.execute.mockRejectedValue(
1518
1605
  new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
@@ -75,9 +75,10 @@ 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 { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError } from '../db/pg-errors'
78
+ import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, readPgSqlState } from '../db/pg-errors'
79
79
  import { getTelemetryRuntime } from '../telemetry/runtime'
80
80
  import { randomUUID } from 'node:crypto'
81
+ import { NotFoundError as MikroOrmNotFoundError, ValidationError as MikroOrmValidationError } from '@mikro-orm/core'
81
82
 
82
83
  type RbacServiceLike = {
83
84
  getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
@@ -609,6 +610,30 @@ function resolveRequestId(request?: Request): string {
609
610
  return randomUUID()
610
611
  }
611
612
 
613
+ /**
614
+ * Stable, UPPER_SNAKE classification codes for the generic 500/503 fallback
615
+ * bodies below. See `apps/docs/docs/framework/runtime/request-lifecycle.mdx`
616
+ * for the full contract.
617
+ *
618
+ * - `DATABASE_UNAVAILABLE` — `isTransientDbError` matched (503 branch).
619
+ * - `DATABASE_ERROR` — a Postgres SQLSTATE is present (via `readPgSqlState`)
620
+ * but was not already classified as transient (503) or a foreign-key
621
+ * violation (409).
622
+ * - `PERSISTENCE_ERROR` — a MikroORM `ValidationError`/`NotFoundError` that
623
+ * was not already handled by a more specific branch.
624
+ * - `INTERNAL_ERROR` — default fallback for anything else.
625
+ */
626
+ type CrudErrorCode = 'INTERNAL_ERROR' | 'DATABASE_ERROR' | 'PERSISTENCE_ERROR' | 'DATABASE_UNAVAILABLE'
627
+
628
+ function classifyCrudError(err: unknown): { code: CrudErrorCode; pgSqlState: string | null } {
629
+ const pgSqlState = readPgSqlState(err)
630
+ if (pgSqlState) return { code: 'DATABASE_ERROR', pgSqlState }
631
+ if (err instanceof MikroOrmValidationError || err instanceof MikroOrmNotFoundError) {
632
+ return { code: 'PERSISTENCE_ERROR', pgSqlState: null }
633
+ }
634
+ return { code: 'INTERNAL_ERROR', pgSqlState: null }
635
+ }
636
+
612
637
  function handleError(err: unknown, request?: Request): Response {
613
638
  if (err instanceof Response) return err
614
639
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
@@ -623,13 +648,16 @@ function handleError(err: unknown, request?: Request): Response {
623
648
  if (isTransientDbError(err)) {
624
649
  // Transient DB unavailability (pool exhausted, `max_connections` reached, DB
625
650
  // restarting) is retryable — surface a 503 with a Retry-After hint instead of
626
- // a generic 500 so clients back off and retry once the DB recovers.
651
+ // a generic 500 so clients back off and retry once the DB recovers. Carries
652
+ // the same requestId correlation contract as the other branches below.
653
+ const requestId = resolveRequestId(request)
627
654
  logger.warn('Transient DB failure during CRUD handler', {
628
655
  message: err instanceof Error ? err.message : undefined,
656
+ requestId,
629
657
  })
630
658
  return json(
631
- { error: 'Service temporarily unavailable' },
632
- { status: 503, headers: { 'Retry-After': '2' } },
659
+ { error: 'Service temporarily unavailable', code: 'DATABASE_UNAVAILABLE', requestId },
660
+ { status: 503, headers: { 'Retry-After': '2', 'x-request-id': requestId } },
633
661
  )
634
662
  }
635
663
 
@@ -670,15 +698,18 @@ function handleError(err: unknown, request?: Request): Response {
670
698
  const stack = err instanceof Error ? err.stack : undefined
671
699
  const errorName = err instanceof Error ? err.name : undefined
672
700
  const requestId = resolveRequestId(request)
673
- logger.error('Unexpected CRUD error', { message, stack, err, requestId })
701
+ const { code, pgSqlState } = classifyCrudError(err)
702
+ logger.error('Unexpected CRUD error', { message, stack, err, requestId, code })
674
703
  getTelemetryRuntime()?.reportError(err, {
675
704
  module: 'crud',
676
- attributes: { requestId, errorName },
705
+ code: `crud.${code.toLowerCase()}`,
706
+ attributes: { requestId, errorName, code, pgCode: pgSqlState ?? undefined },
677
707
  })
678
708
  const body: Record<string, unknown> = {
679
709
  error: 'Internal server error',
680
710
  message: 'Something went wrong. Please try again later.',
681
711
  requestId,
712
+ code,
682
713
  }
683
714
  return json(body, { status: 500, headers: { 'x-request-id': requestId } })
684
715
  }