@open-mercato/shared 0.7.1-develop.7154.1.981330c924 → 0.7.1-develop.7171.1.9b31dbba45

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.
@@ -137,18 +137,21 @@ 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
 
148
+ const UNKNOWN_RECORD_ID = 'unknown'
149
+
147
150
  function extractRecordId(record: Record<string, unknown>): string {
148
151
  const idValue = record.id
149
152
  if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()
150
153
  if (typeof idValue === 'number') return String(idValue)
151
- return 'unknown'
154
+ return UNKNOWN_RECORD_ID
152
155
  }
153
156
 
154
157
  function getEnricherCacheTtl(enricher: ResponseEnricher): number {
@@ -185,6 +188,18 @@ async function readEnricherCache<T>(
185
188
  }
186
189
  }
187
190
 
191
+ /**
192
+ * The cache write was skipped because no safe envelope could be built. Logged
193
+ * rather than swallowed: from outside the runner a silently-skipped write is
194
+ * indistinguishable from a broken cache, and "this enricher is not purely
195
+ * additive" is the answer an author needs to see.
196
+ */
197
+ function logSkippedCacheWrite(enricher: ResponseEnricher): void {
198
+ logger.debug('Skipped enricher cache write — enrichment is not purely additive or lacks usable record ids', {
199
+ enricherId: enricher.id,
200
+ })
201
+ }
202
+
188
203
  async function writeEnricherCache(
189
204
  cache: CacheLike | null,
190
205
  key: string,
@@ -200,6 +215,103 @@ async function writeEnricherCache(
200
215
  }
201
216
  }
202
217
 
218
+ /**
219
+ * Cached read-through payload: the fields each enricher ADDED, keyed by record id.
220
+ *
221
+ * Caching whole records would replace the freshly-read record with the snapshot
222
+ * taken at write time, so an edit to a base field (a product's name, an order's
223
+ * status) would not surface until the entry expired, and a cached array would
224
+ * also carry — and therefore overwrite — whatever the previous enricher in the
225
+ * chain contributed. The additive delta is a pure function of the enricher, the
226
+ * tenant/organization scope and the record ids, which is exactly what the cache
227
+ * key already encodes, so it is the only part of the result that is safe to reuse.
228
+ */
229
+ type EnricherCacheEnvelope = {
230
+ version: 1
231
+ deltas: Record<string, Record<string, unknown>>
232
+ }
233
+
234
+ const ENRICHER_CACHE_VERSION = 1
235
+
236
+ function isEnricherCacheEnvelope(value: unknown): value is EnricherCacheEnvelope {
237
+ if (typeof value !== 'object' || value === null) return false
238
+ const candidate = value as { version?: unknown; deltas?: unknown }
239
+ if (candidate.version !== ENRICHER_CACHE_VERSION) return false
240
+ return typeof candidate.deltas === 'object' && candidate.deltas !== null
241
+ }
242
+
243
+ /**
244
+ * The keys an enricher added to a record, or `null` when the enrichment was not
245
+ * purely additive — it changed or dropped a key that was already there. A
246
+ * non-additive enricher is never cached: replaying only its added keys onto a
247
+ * later record would silently lose the change it made to the existing ones.
248
+ *
249
+ * Comparison is by identity at the top level only, so an enricher that mutates a
250
+ * nested object in place is indistinguishable from one that left the record
251
+ * alone: its nested change is absent from the delta and therefore lost on a
252
+ * later hit. That fails safe — the served record is under-enriched, never
253
+ * stale-wrong — and a deep clone of every record on every enriched response is
254
+ * not worth paying for the case.
255
+ */
256
+ function computeAdditiveDelta(
257
+ input: Record<string, unknown>,
258
+ output: Record<string, unknown>,
259
+ ): Record<string, unknown> | null {
260
+ const delta: Record<string, unknown> = {}
261
+ for (const key of Object.keys(input)) {
262
+ if (!Object.prototype.hasOwnProperty.call(output, key)) return null
263
+ if (output[key] !== input[key]) return null
264
+ }
265
+ for (const key of Object.keys(output)) {
266
+ if (Object.prototype.hasOwnProperty.call(input, key)) continue
267
+ delta[key] = output[key]
268
+ }
269
+ return delta
270
+ }
271
+
272
+ /**
273
+ * Build the cacheable envelope for a batch, or `null` when it cannot be built
274
+ * safely — an unusable record id, a duplicate id (the deltas would collide), or
275
+ * a non-additive enrichment. Every failure mode skips the cache write and leaves
276
+ * the enricher running on every request, which is the pre-cache behavior.
277
+ */
278
+ function buildCacheEnvelope<T extends Record<string, unknown>>(
279
+ inputs: T[],
280
+ outputs: T[],
281
+ ): EnricherCacheEnvelope | null {
282
+ if (inputs.length !== outputs.length) return null
283
+ const deltas: Record<string, Record<string, unknown>> = {}
284
+ for (let index = 0; index < inputs.length; index += 1) {
285
+ const recordId = extractRecordId(inputs[index])
286
+ if (recordId === UNKNOWN_RECORD_ID) return null
287
+ if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null
288
+ const delta = computeAdditiveDelta(inputs[index], outputs[index])
289
+ if (!delta) return null
290
+ deltas[recordId] = delta
291
+ }
292
+ return { version: ENRICHER_CACHE_VERSION, deltas }
293
+ }
294
+
295
+ /**
296
+ * Merge a cached envelope onto freshly-read records. Returns `null` — a miss —
297
+ * when the envelope does not cover every record, so a partially-cached batch
298
+ * re-runs the enricher rather than returning some records unenriched.
299
+ */
300
+ function applyCacheEnvelope<T extends Record<string, unknown>>(
301
+ envelope: EnricherCacheEnvelope,
302
+ records: T[],
303
+ ): T[] | null {
304
+ const merged: T[] = []
305
+ for (const record of records) {
306
+ const recordId = extractRecordId(record)
307
+ if (recordId === UNKNOWN_RECORD_ID) return null
308
+ const delta = envelope.deltas[recordId]
309
+ if (!delta || typeof delta !== 'object') return null
310
+ merged.push({ ...record, ...delta } as T)
311
+ }
312
+ return merged
313
+ }
314
+
203
315
  /**
204
316
  * Apply response enrichers to a list of records.
205
317
  *
@@ -212,6 +324,7 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
212
324
  context: EnricherContext,
213
325
  preFilteredEntries?: EnricherRegistryEntry[],
214
326
  ): Promise<EnrichmentResult<T>> {
327
+ const enricherContext: EnricherContext = { ...context, targetEntity }
215
328
  const activeEntries = preFilteredEntries
216
329
  ? filterByACLAndTenant(preFilteredEntries, context)
217
330
  : getActiveEnrichers(targetEntity, context)
@@ -234,19 +347,29 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
234
347
  let result: T[]
235
348
  const recordIds = currentItems.map((item) => extractRecordId(item))
236
349
  const shouldUseCache = enricher.cache?.strategy === 'read-through'
237
- const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'many', recordIds) : null
350
+ const cacheKey = shouldUseCache
351
+ ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)
352
+ : null
353
+ // Snapshot BEFORE enrichment: the contract does not forbid an enricher
354
+ // from mutating the records it was handed, and comparing a mutated record
355
+ // against itself would yield an empty delta — caching "this enricher adds
356
+ // nothing" and serving unenriched records for the rest of the TTL.
357
+ const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems
238
358
  if (shouldUseCache && cacheKey) {
239
- const cached = await readEnricherCache<T[]>(cache, cacheKey)
240
- if (cached) {
241
- currentItems = cached
242
- enrichedBy.push(enricher.id)
243
- continue
359
+ const cached = await readEnricherCache<unknown>(cache, cacheKey)
360
+ if (isEnricherCacheEnvelope(cached)) {
361
+ const merged = applyCacheEnvelope(cached, currentItems)
362
+ if (merged) {
363
+ currentItems = merged
364
+ enrichedBy.push(enricher.id)
365
+ continue
366
+ }
244
367
  }
245
368
  }
246
369
 
247
370
  if (enricher.enrichMany) {
248
371
  result = await Promise.race([
249
- enricher.enrichMany(currentItems, context) as Promise<T[]>,
372
+ enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,
250
373
  timeoutPromise(timeout),
251
374
  ])
252
375
  } else {
@@ -265,13 +388,18 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
265
388
 
266
389
  currentItems = result
267
390
  if (shouldUseCache && cacheKey) {
268
- await writeEnricherCache(
269
- cache,
270
- cacheKey,
271
- result,
272
- getEnricherCacheTtl(enricher),
273
- getEnricherCacheTags(enricher, context),
274
- )
391
+ const envelope = buildCacheEnvelope(inputItems, result)
392
+ if (envelope) {
393
+ await writeEnricherCache(
394
+ cache,
395
+ cacheKey,
396
+ envelope,
397
+ getEnricherCacheTtl(enricher),
398
+ getEnricherCacheTags(enricher, context),
399
+ )
400
+ } else {
401
+ logSkippedCacheWrite(enricher)
402
+ }
275
403
  }
276
404
  enrichedBy.push(enricher.id)
277
405
  } catch (err) {
@@ -311,6 +439,7 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
311
439
  context: EnricherContext,
312
440
  preFilteredEntries?: EnricherRegistryEntry[],
313
441
  ): Promise<SingleEnrichmentResult<T>> {
442
+ const enricherContext: EnricherContext = { ...context, targetEntity }
314
443
  const activeEntries = preFilteredEntries
315
444
  ? filterByACLAndTenant(preFilteredEntries, context)
316
445
  : getActiveEnrichers(targetEntity, context)
@@ -332,17 +461,24 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
332
461
  try {
333
462
  const recordId = extractRecordId(currentRecord)
334
463
  const shouldUseCache = enricher.cache?.strategy === 'read-through'
335
- const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, 'one', [recordId]) : null
464
+ const cacheKey = shouldUseCache
465
+ ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])
466
+ : null
467
+ // Snapshot before enrichment — see the list path for why.
468
+ const inputRecord = shouldUseCache ? ({ ...currentRecord } as T) : currentRecord
336
469
  if (shouldUseCache && cacheKey) {
337
- const cached = await readEnricherCache<T>(cache, cacheKey)
338
- if (cached) {
339
- currentRecord = cached
340
- enrichedBy.push(enricher.id)
341
- continue
470
+ const cached = await readEnricherCache<unknown>(cache, cacheKey)
471
+ if (isEnricherCacheEnvelope(cached)) {
472
+ const merged = applyCacheEnvelope(cached, [currentRecord])
473
+ if (merged) {
474
+ currentRecord = merged[0]
475
+ enrichedBy.push(enricher.id)
476
+ continue
477
+ }
342
478
  }
343
479
  }
344
480
  const result = await Promise.race([
345
- enricher.enrichOne(currentRecord, context) as Promise<T>,
481
+ enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,
346
482
  timeoutPromise(timeout),
347
483
  ])
348
484
 
@@ -351,13 +487,18 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
351
487
 
352
488
  currentRecord = result
353
489
  if (shouldUseCache && cacheKey) {
354
- await writeEnricherCache(
355
- cache,
356
- cacheKey,
357
- result,
358
- getEnricherCacheTtl(enricher),
359
- getEnricherCacheTags(enricher, context),
360
- )
490
+ const envelope = buildCacheEnvelope([inputRecord], [result])
491
+ if (envelope) {
492
+ await writeEnricherCache(
493
+ cache,
494
+ cacheKey,
495
+ envelope,
496
+ getEnricherCacheTtl(enricher),
497
+ getEnricherCacheTags(enricher, context),
498
+ )
499
+ } else {
500
+ logSkippedCacheWrite(enricher)
501
+ }
361
502
  }
362
503
  enrichedBy.push(enricher.id)
363
504
  } catch (err) {
@@ -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 */
@@ -91,11 +93,33 @@ export interface ResponseEnricher<TRecord = any, TEnriched = any> {
91
93
  /** Tenant IDs where this enricher should be disabled. */
92
94
  disabledTenantIds?: string[]
93
95
 
94
- /** Optional cache configuration for read-through enrichment results. */
96
+ /**
97
+ * Optional cache configuration for read-through enrichment results.
98
+ *
99
+ * The runner caches the **additive delta** — the keys this enricher adds to a
100
+ * record — not the record itself, so a cache hit still serves freshly-read
101
+ * base fields and cannot overwrite what an earlier enricher in the chain
102
+ * contributed. That makes the cache usable only by an enricher whose output is
103
+ * purely additive: one that changes or drops a key the record already carried
104
+ * is never cached and simply re-runs on every request. Declaring `cache` on
105
+ * such an enricher is silently a no-op rather than an error, so an enricher
106
+ * that appears never to cache is usually mutating an existing key.
107
+ */
95
108
  cache?: {
96
109
  strategy: 'read-through'
97
110
  ttl: number
98
111
  tags?: string[]
112
+ /**
113
+ * NOT IMPLEMENTED — nothing in the runner reads this field, so declaring it
114
+ * has no effect and an enricher relying on it will serve stale enrichment
115
+ * until the TTL expires. It is kept rather than removed so any existing
116
+ * declaration keeps compiling.
117
+ *
118
+ * Wire invalidation with an event subscriber that calls `deleteByTags` on
119
+ * the tags above; see
120
+ * `packages/core/src/modules/wms/subscribers/invalidate-enricher-cache-*.ts`
121
+ * for the reference implementation.
122
+ */
99
123
  invalidateOn?: string[]
100
124
  }
101
125
 
@@ -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,