@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -4
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/bootstrap/factory.js +4 -3
  8. package/dist/lib/bootstrap/factory.js.map +2 -2
  9. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  10. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  11. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  12. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  13. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  14. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  15. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  16. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  17. package/dist/lib/catalog-visibility/index.js +4 -0
  18. package/dist/lib/catalog-visibility/index.js.map +2 -2
  19. package/dist/lib/commands/command-bus.js +45 -13
  20. package/dist/lib/commands/command-bus.js.map +2 -2
  21. package/dist/lib/commands/types.js.map +2 -2
  22. package/dist/lib/crud/exporters.js +1 -1
  23. package/dist/lib/crud/exporters.js.map +2 -2
  24. package/dist/lib/crud/ids.js +13 -1
  25. package/dist/lib/crud/ids.js.map +2 -2
  26. package/dist/lib/db/advisoryLock.js +172 -0
  27. package/dist/lib/db/advisoryLock.js.map +7 -0
  28. package/dist/lib/db/pg-errors.js +14 -0
  29. package/dist/lib/db/pg-errors.js.map +2 -2
  30. package/dist/lib/localization/index.js +9 -1
  31. package/dist/lib/localization/index.js.map +2 -2
  32. package/dist/lib/localization/translatable-fields.js +26 -0
  33. package/dist/lib/localization/translatable-fields.js.map +2 -2
  34. package/dist/lib/number.js +6 -0
  35. package/dist/lib/number.js.map +2 -2
  36. package/dist/lib/openapi/generator.js +84 -67
  37. package/dist/lib/openapi/generator.js.map +2 -2
  38. package/dist/lib/query/engine.js +45 -10
  39. package/dist/lib/query/engine.js.map +2 -2
  40. package/dist/lib/query/join-utils.js +1 -1
  41. package/dist/lib/query/join-utils.js.map +2 -2
  42. package/dist/lib/query/overlap.js +58 -0
  43. package/dist/lib/query/overlap.js.map +7 -0
  44. package/dist/lib/query/types.js.map +2 -2
  45. package/dist/lib/url.js +24 -0
  46. package/dist/lib/url.js.map +2 -2
  47. package/dist/lib/version.js +1 -1
  48. package/dist/lib/version.js.map +1 -1
  49. package/dist/lib/webhooks/body.js +7 -3
  50. package/dist/lib/webhooks/body.js.map +2 -2
  51. package/dist/lib/webhooks/index.js +2 -0
  52. package/dist/lib/webhooks/index.js.map +2 -2
  53. package/dist/modules/integrations/types.js.map +2 -2
  54. package/dist/modules/notifications/safeHref.js +20 -0
  55. package/dist/modules/notifications/safeHref.js.map +7 -0
  56. package/dist/modules/notifications/types.js +4 -0
  57. package/dist/modules/notifications/types.js.map +3 -3
  58. package/dist/modules/payment_gateways/types.js +14 -1
  59. package/dist/modules/payment_gateways/types.js.map +2 -2
  60. package/dist/modules/registry.js.map +2 -2
  61. package/dist/modules/search.js.map +2 -2
  62. package/dist/modules/widgets/injection-loader.js +14 -3
  63. package/dist/modules/widgets/injection-loader.js.map +2 -2
  64. package/package.json +9 -2
  65. package/src/lib/__tests__/number.test.ts +24 -1
  66. package/src/lib/__tests__/url.cross-site.test.ts +140 -0
  67. package/src/lib/auth/__tests__/server.test.ts +14 -0
  68. package/src/lib/auth/server.ts +1 -1
  69. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  70. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  71. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  72. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  75. package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
  76. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  77. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  78. package/src/lib/bootstrap/factory.ts +8 -3
  79. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  80. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  81. package/src/lib/bootstrap/types.ts +5 -1
  82. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  83. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  84. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  85. package/src/lib/catalog-visibility/index.ts +2 -0
  86. package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
  87. package/src/lib/commands/command-bus.ts +42 -10
  88. package/src/lib/commands/command-interceptor.ts +4 -0
  89. package/src/lib/commands/types.ts +14 -0
  90. package/src/lib/crud/__tests__/exporters.test.ts +18 -0
  91. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  92. package/src/lib/crud/exporters.ts +1 -1
  93. package/src/lib/crud/ids.ts +24 -0
  94. package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
  95. package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
  96. package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
  97. package/src/lib/db/advisoryLock.ts +271 -0
  98. package/src/lib/db/pg-errors.ts +35 -0
  99. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
  100. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  101. package/src/lib/localization/index.ts +8 -1
  102. package/src/lib/localization/translatable-fields.ts +46 -0
  103. package/src/lib/number.ts +13 -0
  104. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  105. package/src/lib/openapi/generator.ts +106 -77
  106. package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
  107. package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
  108. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  109. package/src/lib/query/engine.ts +58 -8
  110. package/src/lib/query/join-utils.ts +1 -1
  111. package/src/lib/query/overlap.ts +106 -0
  112. package/src/lib/query/types.ts +13 -1
  113. package/src/lib/url.ts +30 -0
  114. package/src/lib/webhooks/__tests__/body.test.ts +48 -0
  115. package/src/lib/webhooks/body.ts +11 -4
  116. package/src/lib/webhooks/index.ts +1 -0
  117. package/src/modules/integrations/types.ts +6 -0
  118. package/src/modules/notifications/safeHref.ts +19 -0
  119. package/src/modules/notifications/types.ts +7 -0
  120. package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
  121. package/src/modules/payment_gateways/types.ts +52 -6
  122. package/src/modules/registry.ts +5 -0
  123. package/src/modules/search.ts +39 -0
  124. package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
  125. package/src/modules/widgets/injection-loader.ts +33 -3
@@ -0,0 +1,271 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { calculateBackoffDelayMs } from '../delivery/retry'
3
+ import { isTransientLockDbError } from './pg-errors'
4
+
5
+ /** @internal */
6
+ export type AdvisoryLockWaitResult<T> = { done: true; value: T } | { done: false }
7
+
8
+ export type AdvisoryLockOptions<T> = {
9
+ /** Total waiting budget from the call start. Default 15_000. */
10
+ waitDeadlineMs?: number
11
+ /** @internal Called after each failed attempt, with no connection held; `done: true` short-circuits. */
12
+ onWait?: () => Promise<AdvisoryLockWaitResult<T>>
13
+ /** Per process and key namespace: at most this many lock transactions at once (attempts included).
14
+ * Waiting for a slot holds no connection and counts against `waitDeadlineMs`. Default: unlimited. */
15
+ maxConcurrentHolders?: number
16
+ }
17
+
18
+ const ADVISORY_LOCK_UNAVAILABLE_BRAND = Symbol.for('@open-mercato/shared:AdvisoryLockUnavailableError')
19
+
20
+ /**
21
+ * The helper's own work failed with an error `isTransientLockDbError` accepts (kept as `cause`), or
22
+ * the wait ran out. `deadline`: the wait budget ran out (contention or no free holder slot), so `fn`
23
+ * never ran. `transient_db`: the begin or the lock query failed, so `fn` never ran. `commit_unknown`:
24
+ * `fn` returned and the flush or commit after it failed, so its writes may or may not be committed.
25
+ * `reason` is additive-only; branch on it, never on the message.
26
+ */
27
+ export type AdvisoryLockUnavailableReason = 'deadline' | 'transient_db' | 'commit_unknown'
28
+
29
+ const ADVISORY_LOCK_UNAVAILABLE_MESSAGES: Record<AdvisoryLockUnavailableReason, string> = {
30
+ deadline: '[internal] Advisory lock not acquired before the wait deadline',
31
+ transient_db: '[internal] Advisory lock transaction failed with a transient database error',
32
+ commit_unknown: '[internal] Advisory lock transaction failed after its callback returned; the commit outcome is unknown',
33
+ }
34
+
35
+ export class AdvisoryLockUnavailableError extends Error {
36
+ static [Symbol.hasInstance](value: unknown): boolean {
37
+ return typeof value === 'object' && value !== null && Reflect.get(value, ADVISORY_LOCK_UNAVAILABLE_BRAND) === true
38
+ }
39
+
40
+ readonly reason: AdvisoryLockUnavailableReason
41
+ readonly key: string
42
+ constructor(reason: AdvisoryLockUnavailableReason, key: string, cause?: unknown) {
43
+ super(ADVISORY_LOCK_UNAVAILABLE_MESSAGES[reason], cause === undefined ? undefined : { cause })
44
+ Object.defineProperty(this, ADVISORY_LOCK_UNAVAILABLE_BRAND, { value: true, enumerable: false })
45
+ this.name = 'AdvisoryLockUnavailableError'
46
+ this.reason = reason
47
+ this.key = key
48
+ }
49
+ }
50
+
51
+ const DEFAULT_WAIT_DEADLINE_MS = 15_000
52
+ const BACKOFF_ATTEMPT_CAP = 5
53
+ const BACKOFF_OPTIONS = { baseDelayMs: 50, maxJitterMs: 25 }
54
+ const KEY_NAMESPACE_PATTERN = /^[a-z][a-z0-9_]*$/
55
+ const ADVISORY_LOCK_SQL = 'select pg_try_advisory_xact_lock(hashtextextended(?, 0)) as locked'
56
+
57
+ /**
58
+ * `disableContextResolution` keeps the fork off an outer transaction resolved from the
59
+ * async context, `useContext: false` keeps the fork's own transaction from joining one
60
+ * later, `disableTransactions: false` keeps a source EntityManager with transactions disabled
61
+ * from turning the lock into a statement-scoped no-op, and the cloned event manager carries
62
+ * the request's subscribers (tenant field encryption among them) into the lock transaction.
63
+ */
64
+ const LOCK_FORK_OPTIONS = {
65
+ disableContextResolution: true,
66
+ clear: true,
67
+ useContext: false,
68
+ disableTransactions: false,
69
+ cloneEventManager: true,
70
+ }
71
+
72
+ /**
73
+ * Holder slots live on `globalThis` so duplicated module instances in one process (dev
74
+ * HMR, standalone server chunks) count against one limit. The map is created once and
75
+ * never replaced; each namespace entry is created on first use and kept.
76
+ */
77
+ const GLOBAL_SLOTS_KEY = '__openMercatoAdvisoryLockSlots__'
78
+
79
+ type SlotWaiter = {
80
+ limit: number
81
+ grant: () => void
82
+ }
83
+
84
+ type NamespaceSlots = {
85
+ held: number
86
+ waiters: SlotWaiter[]
87
+ }
88
+
89
+ type SlotRelease = () => void
90
+
91
+ type AttemptOutcome<T> = { acquired: true; value: T } | { acquired: false }
92
+
93
+ const NOT_ACQUIRED: AttemptOutcome<never> = { acquired: false }
94
+
95
+ const releaseNothing: SlotRelease = () => undefined
96
+
97
+ function getSlotRegistry(): Map<string, NamespaceSlots> {
98
+ const globals = globalThis as Record<string, unknown>
99
+ const existing = globals[GLOBAL_SLOTS_KEY]
100
+ if (existing instanceof Map) return existing as Map<string, NamespaceSlots>
101
+ const created = new Map<string, NamespaceSlots>()
102
+ globals[GLOBAL_SLOTS_KEY] = created
103
+ return created
104
+ }
105
+
106
+ function getNamespaceSlots(namespace: string): NamespaceSlots {
107
+ const registry = getSlotRegistry()
108
+ const existing = registry.get(namespace)
109
+ if (existing) return existing
110
+ const created: NamespaceSlots = { held: 0, waiters: [] }
111
+ registry.set(namespace, created)
112
+ return created
113
+ }
114
+
115
+ function handOffSlots(slots: NamespaceSlots): void {
116
+ let index = 0
117
+ while (index < slots.waiters.length) {
118
+ const waiter = slots.waiters[index]
119
+ if (slots.held >= waiter.limit) {
120
+ index += 1
121
+ continue
122
+ }
123
+ slots.waiters.splice(index, 1)
124
+ slots.held += 1
125
+ waiter.grant()
126
+ }
127
+ }
128
+
129
+ function createSlotRelease(slots: NamespaceSlots): SlotRelease {
130
+ let released = false
131
+ return () => {
132
+ if (released) return
133
+ released = true
134
+ slots.held -= 1
135
+ handOffSlots(slots)
136
+ }
137
+ }
138
+
139
+ function acquireSlot(namespace: string, limit: number, key: string, deadlineAt: number): Promise<SlotRelease> {
140
+ const slots = getNamespaceSlots(namespace)
141
+ if (slots.held < limit) {
142
+ slots.held += 1
143
+ return Promise.resolve(createSlotRelease(slots))
144
+ }
145
+ const remainingMs = deadlineAt - Date.now()
146
+ if (remainingMs <= 0) return Promise.reject(new AdvisoryLockUnavailableError('deadline', key))
147
+ return new Promise<SlotRelease>((resolve, reject) => {
148
+ const waiter: SlotWaiter = {
149
+ limit,
150
+ grant: () => {
151
+ clearTimeout(deadlineTimer)
152
+ resolve(createSlotRelease(slots))
153
+ },
154
+ }
155
+ const deadlineTimer = setTimeout(() => {
156
+ const position = slots.waiters.indexOf(waiter)
157
+ if (position >= 0) slots.waiters.splice(position, 1)
158
+ reject(new AdvisoryLockUnavailableError('deadline', key))
159
+ }, remainingMs)
160
+ slots.waiters.push(waiter)
161
+ })
162
+ }
163
+
164
+ function parseKeyNamespace(key: unknown): string {
165
+ const separator = typeof key === 'string' ? key.indexOf(':') : -1
166
+ if (typeof key === 'string' && separator > 0 && separator < key.length - 1) {
167
+ const namespace = key.slice(0, separator)
168
+ if (KEY_NAMESPACE_PATTERN.test(namespace)) return namespace
169
+ }
170
+ throw new TypeError('[internal] Advisory lock key must be "<namespace>:<parts>" with a namespace matching ^[a-z][a-z0-9_]*$')
171
+ }
172
+
173
+ function resolveWaitDeadlineMs(value: number | undefined): number {
174
+ if (value === undefined) return DEFAULT_WAIT_DEADLINE_MS
175
+ if (Number.isFinite(value) && value >= 0) return value
176
+ throw new TypeError('[internal] Advisory lock waitDeadlineMs must be a finite number of at least 0')
177
+ }
178
+
179
+ function resolveMaxConcurrentHolders(value: number | undefined): number | null {
180
+ if (value === undefined) return null
181
+ if (Number.isInteger(value) && value > 0) return value
182
+ throw new TypeError('[internal] Advisory lock maxConcurrentHolders must be a positive integer')
183
+ }
184
+
185
+ function sleep(delayMs: number): Promise<void> {
186
+ return new Promise((resolve) => setTimeout(resolve, delayMs))
187
+ }
188
+
189
+ async function attemptLock<T>(
190
+ lockEm: EntityManager,
191
+ key: string,
192
+ fn: (txEm: EntityManager) => Promise<T>,
193
+ ): Promise<AttemptOutcome<T>> {
194
+ const callback: { thrown: boolean; returned: boolean; error: unknown } = { thrown: false, returned: false, error: undefined }
195
+ try {
196
+ return await lockEm.transactional(async (txEm): Promise<AttemptOutcome<T>> => {
197
+ const rows = await txEm.execute<Array<{ locked: boolean }>>(ADVISORY_LOCK_SQL, [key])
198
+ if (rows[0]?.locked !== true) return NOT_ACQUIRED
199
+ try {
200
+ const value = await fn(txEm)
201
+ callback.returned = true
202
+ return { acquired: true, value }
203
+ } catch (error) {
204
+ callback.thrown = true
205
+ callback.error = error
206
+ throw error
207
+ }
208
+ })
209
+ } catch (error) {
210
+ if (callback.thrown) throw callback.error
211
+ if (isTransientLockDbError(error)) {
212
+ throw new AdvisoryLockUnavailableError(callback.returned ? 'commit_unknown' : 'transient_db', key, error)
213
+ }
214
+ throw error
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Runs `fn` in one transaction that holds `pg_try_advisory_xact_lock(hashtextextended(key, 0))`.
220
+ * - `key` is `<namespace>:<parts>` with namespace `^[a-z][a-z0-9_]*$`; anything else throws `TypeError`.
221
+ * - `waitDeadlineMs` must be a finite number ≥ 0 and `maxConcurrentHolders` a positive integer;
222
+ * anything else throws `TypeError` before any connection is taken.
223
+ * - Pass the request (container) EntityManager. The helper forks it with
224
+ * `{ disableContextResolution: true, clear: true, useContext: false, disableTransactions: false, cloneEventManager: true }`,
225
+ * so the lock transaction never nests in a caller's transaction and commits on its own.
226
+ * - `fn` MUST do all DB I/O on `txEm`, MUST flush its own writes, MUST be bounded (≤ one external
227
+ * call), MUST NOT call `transactional` again (it only opens a savepoint in the lock transaction) and
228
+ * MUST NOT fork or use `getConnection().execute` (another pooled connection, outside the lock).
229
+ * - `txEm` carries the request EntityManager's subscribers (cloned event manager).
230
+ * - A failed attempt ends its empty transaction, so waiters hold no connection between attempts.
231
+ * They back off 50 ms doubling up to 800 ms (plus up to 24 ms jitter), never past
232
+ * `waitDeadlineMs` measured from the call start, then retry.
233
+ * - With `maxConcurrentHolders`, each attempt first takes a per-process slot for the key
234
+ * namespace and gives it back when the attempt fails or the lock transaction settles.
235
+ * - Errors from the helper's own begin, lock query and commit that `isTransientLockDbError` accepts,
236
+ * and the deadline, throw `AdvisoryLockUnavailableError` (its `reason` says whether `fn` ran); any
237
+ * other error from them, and every error thrown by `fn`, propagates unchanged (a throw from `fn`
238
+ * rolls back).
239
+ */
240
+ export async function withAdvisoryXactLock<T>(
241
+ em: EntityManager,
242
+ key: string,
243
+ fn: (txEm: EntityManager) => Promise<T>,
244
+ options: AdvisoryLockOptions<T> = {},
245
+ ): Promise<T> {
246
+ const namespace = parseKeyNamespace(key)
247
+ const waitDeadlineMs = resolveWaitDeadlineMs(options.waitDeadlineMs)
248
+ const maxConcurrentHolders = resolveMaxConcurrentHolders(options.maxConcurrentHolders)
249
+ const deadlineAt = Date.now() + waitDeadlineMs
250
+ const lockEm = em.fork(LOCK_FORK_OPTIONS)
251
+ for (let attempt = 1; ; attempt += 1) {
252
+ const releaseSlot = maxConcurrentHolders === null
253
+ ? releaseNothing
254
+ : await acquireSlot(namespace, maxConcurrentHolders, key, deadlineAt)
255
+ let outcome: AttemptOutcome<T>
256
+ try {
257
+ outcome = await attemptLock(lockEm, key, fn)
258
+ } finally {
259
+ releaseSlot()
260
+ }
261
+ if (outcome.acquired) return outcome.value
262
+ const remainingMs = deadlineAt - Date.now()
263
+ if (remainingMs <= 0) throw new AdvisoryLockUnavailableError('deadline', key)
264
+ await sleep(Math.min(calculateBackoffDelayMs(Math.min(attempt, BACKOFF_ATTEMPT_CAP), BACKOFF_OPTIONS), remainingMs))
265
+ if (options.onWait) {
266
+ const waitResult = await options.onWait()
267
+ if (waitResult.done) return waitResult.value
268
+ }
269
+ if (Date.now() >= deadlineAt) throw new AdvisoryLockUnavailableError('deadline', key)
270
+ }
271
+ }
@@ -148,3 +148,38 @@ export function isTransientDbError(err: unknown): boolean {
148
148
  }
149
149
  return false
150
150
  }
151
+
152
+ /**
153
+ * SQLSTATEs that end a lock transaction early but say nothing about the data it
154
+ * would have written, so the work can be retried: lock_not_available (55P03,
155
+ * raised by `DB_LOCK_TIMEOUT_MS`), query_canceled (57014, raised by the opt-in
156
+ * `DB_STATEMENT_TIMEOUT_MS`) and idle_in_transaction_session_timeout (25P03).
157
+ */
158
+ const TRANSIENT_LOCK_SQLSTATES = new Set(['55P03', '57014', '25P03'])
159
+
160
+ /**
161
+ * Driver messages for a connection that never arrived or was lost mid-transaction:
162
+ * the pg-pool acquire timeout (`connectionTimeoutMillis`) and node-postgres's
163
+ * error for a client whose connection already failed.
164
+ */
165
+ const TRANSIENT_LOCK_MESSAGE_PATTERNS = [
166
+ /timeout exceeded when trying to connect/i,
167
+ /client has encountered a connection error and is not queryable/i,
168
+ ]
169
+
170
+ /**
171
+ * @internal Detect a transient failure of a short lock transaction: everything
172
+ * `isTransientDbError` matches, plus lock, statement and idle-in-transaction
173
+ * timeouts, the pg-pool acquire timeout and node-postgres's "not queryable"
174
+ * error. Checks every layer of the driver-error wrapper chain (`cause` /
175
+ * `previous`, see `pgErrorCandidates`). `isTransientDbError` keeps its narrower
176
+ * scope because its callers answer 503 "service unavailable".
177
+ */
178
+ export function isTransientLockDbError(err: unknown): boolean {
179
+ return pgErrorCandidates(err).some((candidate) => {
180
+ if (isTransientDbError(candidate)) return true
181
+ if (typeof candidate.code === 'string' && TRANSIENT_LOCK_SQLSTATES.has(candidate.code)) return true
182
+ const message = candidate.message
183
+ return typeof message === 'string' && TRANSIENT_LOCK_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))
184
+ })
185
+ }
@@ -883,3 +883,26 @@ describe('TenantDataEncryptionService map read failures (issue #6334)', () => {
883
883
  expect(execute).toHaveBeenCalledTimes(2)
884
884
  })
885
885
  })
886
+
887
+ describe('TenantDataEncryptionService transaction-aware map reads', () => {
888
+ it('prefers the EntityManager executor over the bare connection', async () => {
889
+ const entityId = 'test:transaction_aware_map_read'
890
+ const execute = jest.fn(async () => [{
891
+ entity_id: entityId,
892
+ fields_json: [{ field: 'display_name' }],
893
+ }])
894
+ const bareExecute = jest.fn(async () => {
895
+ throw new Error('bare connection must not be used')
896
+ })
897
+ const service = new TenantDataEncryptionService({
898
+ execute,
899
+ getConnection: () => ({ execute: bareExecute }),
900
+ } as never)
901
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
902
+
903
+ await expect(service.getEncryptedFieldNames(entityId, 'tenant-transaction', 'org-transaction'))
904
+ .resolves.toEqual(['display_name'])
905
+ expect(execute).toHaveBeenCalled()
906
+ expect(bareExecute).not.toHaveBeenCalled()
907
+ })
908
+ })
@@ -2,6 +2,8 @@ import {
2
2
  registerTranslatableFields,
3
3
  getTranslatableFields,
4
4
  getTranslatableFieldsRegistry,
5
+ registerTranslatableFieldExpander,
6
+ getTranslatableFieldExpander,
5
7
  } from '../translatable-fields'
6
8
 
7
9
  // The registry is global mutable state — we snapshot before all tests
@@ -67,3 +69,18 @@ describe('translatable fields registry', () => {
67
69
  expect(getTranslatableFields('b:second')).toEqual(['f2'])
68
70
  })
69
71
  })
72
+
73
+ describe('translatable field expander registry', () => {
74
+ it('returns undefined for an entity type without an expander', () => {
75
+ expect(getTranslatableFieldExpander('expander_test:none')).toBeUndefined()
76
+ })
77
+
78
+ it('returns the registered expander and keeps earlier registrations', () => {
79
+ const first = jest.fn(() => [{ key: 'a.b' }])
80
+ const second = jest.fn(() => [])
81
+ registerTranslatableFieldExpander('expander_test:first', first)
82
+ registerTranslatableFieldExpander('expander_test:second', second)
83
+ expect(getTranslatableFieldExpander('expander_test:first')).toBe(first)
84
+ expect(getTranslatableFieldExpander('expander_test:second')).toBe(second)
85
+ })
86
+ })
@@ -1,5 +1,12 @@
1
1
  export type { TranslationRecord, LocaleCode } from './types'
2
2
  export { applyLocalizedContent } from './resolver'
3
- export { registerTranslatableFields, getTranslatableFields, getTranslatableFieldsRegistry } from './translatable-fields'
3
+ export {
4
+ registerTranslatableFields,
5
+ getTranslatableFields,
6
+ getTranslatableFieldsRegistry,
7
+ registerTranslatableFieldExpander,
8
+ getTranslatableFieldExpander,
9
+ } from './translatable-fields'
10
+ export type { TranslatableFieldExpansion, TranslatableFieldExpander } from './translatable-fields'
4
11
  export { registerTranslationOverlayPlugin, getTranslationOverlayPlugin } from './overlay-plugin'
5
12
  export type { TranslationOverlayFn, ResolveLocaleFromRequestFn } from './overlay-plugin'
@@ -23,3 +23,49 @@ export function getTranslatableFields(entityType: string): string[] | undefined
23
23
  export function getTranslatableFieldsRegistry(): TranslatableFieldsRegistry {
24
24
  return { ...getGlobal() }
25
25
  }
26
+
27
+ export type TranslatableFieldExpansion = {
28
+ key: string
29
+ label?: string
30
+ baseValue?: string
31
+ }
32
+
33
+ export type TranslatableFieldExpander = (record: Record<string, unknown>) => TranslatableFieldExpansion[]
34
+
35
+ type TranslatableFieldExpanderRegistry = Record<string, TranslatableFieldExpander>
36
+
37
+ const EXPANDER_GLOBAL_KEY = '__openMercatoTranslatableFieldExpanders__'
38
+
39
+ type ExpanderGlobalHost = { [EXPANDER_GLOBAL_KEY]?: TranslatableFieldExpanderRegistry }
40
+
41
+ function getExpanderGlobal(): TranslatableFieldExpanderRegistry {
42
+ return (globalThis as ExpanderGlobalHost)[EXPANDER_GLOBAL_KEY] ?? {}
43
+ }
44
+
45
+ export function registerTranslatableFieldExpander(entityType: string, expander: TranslatableFieldExpander): void {
46
+ const host = globalThis as ExpanderGlobalHost
47
+ host[EXPANDER_GLOBAL_KEY] = { ...getExpanderGlobal(), [entityType]: expander }
48
+ }
49
+
50
+ export function getTranslatableFieldExpander(entityType: string): TranslatableFieldExpander | undefined {
51
+ return getExpanderGlobal()[entityType]
52
+ }
53
+
54
+ type TranslatableEntityListPathRegistry = Record<string, string>
55
+
56
+ const LIST_PATH_GLOBAL_KEY = '__openMercatoTranslatableEntityListPaths__'
57
+
58
+ type ListPathGlobalHost = { [LIST_PATH_GLOBAL_KEY]?: TranslatableEntityListPathRegistry }
59
+
60
+ function getListPathGlobal(): TranslatableEntityListPathRegistry {
61
+ return (globalThis as ListPathGlobalHost)[LIST_PATH_GLOBAL_KEY] ?? {}
62
+ }
63
+
64
+ export function registerTranslatableEntityListPath(entityType: string, listPath: string): void {
65
+ const host = globalThis as ListPathGlobalHost
66
+ host[LIST_PATH_GLOBAL_KEY] = { ...getListPathGlobal(), [entityType]: listPath }
67
+ }
68
+
69
+ export function getTranslatableEntityListPath(entityType: string): string | undefined {
70
+ return getListPathGlobal()[entityType]
71
+ }
package/src/lib/number.ts CHANGED
@@ -101,6 +101,19 @@ export function parseLocaleNumber(input: string | null | undefined, locale?: str
101
101
  return Number.isFinite(parsed) ? parsed : null
102
102
  }
103
103
 
104
+ /**
105
+ * Renders a number as the text an input field should show under `locale` — `2,5` under
106
+ * `pl-PL`, `2.5` under `en-US`. Unlike `String()`, which always emits a dot, the result uses
107
+ * the locale's decimal separator, so a value redisplayed after blur or reload matches what the
108
+ * user typed and what the rest of the UI shows. No grouping is applied, and the output always
109
+ * round-trips through `parseLocaleNumber`.
110
+ */
111
+ export function formatLocaleNumberInput(value: number, locale?: string): string {
112
+ const serialized = String(value)
113
+ const { decimal } = resolveLocaleNumberSeparators(locale)
114
+ return decimal === '.' ? serialized : serialized.replace('.', decimal)
115
+ }
116
+
104
117
  export function parseNumberWithDefault(
105
118
  raw: string | null | undefined,
106
119
  fallback: number,
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod'
2
+ import { buildOpenApiDocument } from '../generator'
3
+ import type { Module } from '../../../modules/registry'
4
+
5
+ type ParameterNode = { name?: string; in?: string; required?: boolean }
6
+ type OperationNode = { operationId?: string; parameters?: ParameterNode[] }
7
+
8
+ const noopHandler = async () => new Response(null)
9
+
10
+ function imageModule(extraApis: unknown[] = [], operationId?: string): Module {
11
+ return {
12
+ id: 'attachments',
13
+ apis: [
14
+ {
15
+ path: '/attachments/image/[id]/[[...slug]]',
16
+ handlers: { GET: noopHandler },
17
+ docs: {
18
+ methods: {
19
+ GET: {
20
+ summary: 'Serve image',
21
+ ...(operationId ? { operationId } : {}),
22
+ pathParams: z.object({ id: z.string().uuid() }),
23
+ responses: [{ status: 200, description: 'Image' }],
24
+ },
25
+ },
26
+ },
27
+ },
28
+ ...extraApis,
29
+ ],
30
+ } as unknown as Module
31
+ }
32
+
33
+ function operation(doc: ReturnType<typeof buildOpenApiDocument>, path: string): OperationNode | undefined {
34
+ return (doc.paths[path] as Record<string, OperationNode> | undefined)?.get
35
+ }
36
+
37
+ function collectPathParameterViolations(doc: ReturnType<typeof buildOpenApiDocument>): string[] {
38
+ const violations: string[] = []
39
+ const operationIds = new Set<string>()
40
+ for (const [path, item] of Object.entries(doc.paths)) {
41
+ const templateNames = Array.from(path.matchAll(/\{([^}]+)\}/g), (match) => match[1])
42
+ for (const [method, op] of Object.entries(item as Record<string, OperationNode>)) {
43
+ const pathParams = (op.parameters ?? []).filter((param) => param.in === 'path')
44
+ for (const param of pathParams) {
45
+ if (param.required !== true) violations.push(`${method} ${path}: path parameter ${param.name} is not required`)
46
+ if (!templateNames.includes(param.name ?? '')) violations.push(`${method} ${path}: path parameter ${param.name} is not in the template`)
47
+ }
48
+ for (const name of templateNames) {
49
+ if (!pathParams.some((param) => param.name === name)) violations.push(`${method} ${path}: template variable ${name} is not declared`)
50
+ }
51
+ if (op.operationId) {
52
+ if (operationIds.has(op.operationId)) violations.push(`${method} ${path}: duplicate operationId ${op.operationId}`)
53
+ operationIds.add(op.operationId)
54
+ }
55
+ }
56
+ }
57
+ return violations
58
+ }
59
+
60
+ describe('buildOpenApiDocument optional catch-all path parameters', () => {
61
+ it('emits id-only and id-plus-slug paths with only required path parameters', () => {
62
+ const doc = buildOpenApiDocument([imageModule()])
63
+
64
+ expect(collectPathParameterViolations(doc)).toEqual([])
65
+
66
+ const withSlug = operation(doc, '/attachments/image/{id}/{slug}')
67
+ const withoutSlug = operation(doc, '/attachments/image/{id}')
68
+ expect(withSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id', 'slug'])
69
+ expect(withoutSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id'])
70
+ })
71
+
72
+ it('keeps the published operationId on the slug path and gives the id-only path a distinct one', () => {
73
+ const doc = buildOpenApiDocument([imageModule()])
74
+
75
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('attachments_get_attachments_image_id_slug')
76
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('attachments_get_attachments_image_id')
77
+ })
78
+
79
+ it('derives a distinct id-only operationId from an explicit operationId', () => {
80
+ const doc = buildOpenApiDocument([imageModule([], 'serveImage')])
81
+
82
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('serveImage')
83
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('serveImage_without_slug')
84
+ })
85
+
86
+ it('never replaces a concrete route that owns the id-only path, whichever is registered first', () => {
87
+ const concreteRoute = {
88
+ path: '/attachments/image/[id]',
89
+ handlers: { GET: noopHandler },
90
+ docs: { methods: { GET: { summary: 'Concrete', operationId: 'concreteImage', responses: [{ status: 200, description: 'OK' }] } } },
91
+ }
92
+ const after = buildOpenApiDocument([imageModule([concreteRoute])])
93
+ const before = buildOpenApiDocument([
94
+ { id: 'other', apis: [concreteRoute] } as unknown as Module,
95
+ imageModule(),
96
+ ])
97
+
98
+ expect(operation(after, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
99
+ expect(operation(before, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
100
+ expect(collectPathParameterViolations(after)).toEqual([])
101
+ })
102
+ })