@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.
- 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 +98 -29
- 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.read-through-cache.test.ts +291 -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 +171 -30
- package/src/lib/crud/factory.ts +30 -1
- package/src/lib/crud/response-enricher.ts +26 -2
- 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
|
@@ -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
|
|
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
|
|
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<
|
|
240
|
-
if (cached) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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,
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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<
|
|
338
|
-
if (cached) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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,
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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) {
|
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 */
|
|
@@ -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
|
-
/**
|
|
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
|
+
})
|
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,
|