@open-mercato/shared 0.9.1-develop.7574.1.837288eec1 → 0.10.1-develop.7576.1.21d5b50435
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +6 -46
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +3 -4
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/catalog-visibility/index.js +0 -4
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/commands/command-bus.js +13 -45
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +1 -13
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -14
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/localization/index.js +1 -9
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +0 -26
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/number.js +0 -6
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +67 -84
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +10 -45
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/url.js +0 -24
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +3 -7
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +0 -2
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/types.js +0 -4
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +1 -14
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +3 -14
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/__tests__/number.test.ts +1 -24
- package/src/lib/auth/__tests__/server.test.ts +0 -14
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +6 -18
- package/src/lib/bootstrap/dynamicLoader.ts +5 -63
- package/src/lib/bootstrap/factory.ts +3 -8
- package/src/lib/bootstrap/types.ts +1 -5
- package/src/lib/catalog-visibility/index.ts +0 -2
- package/src/lib/commands/__tests__/command-bus.test.ts +1 -100
- package/src/lib/commands/command-bus.ts +10 -42
- package/src/lib/commands/command-interceptor.ts +0 -4
- package/src/lib/commands/types.ts +0 -14
- package/src/lib/crud/__tests__/exporters.test.ts +0 -18
- package/src/lib/crud/__tests__/ids.test.ts +1 -34
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +0 -24
- package/src/lib/db/__tests__/pg-errors.test.ts +1 -68
- package/src/lib/db/pg-errors.ts +0 -35
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -23
- package/src/lib/localization/__tests__/translatable-fields.test.ts +0 -17
- package/src/lib/localization/index.ts +1 -8
- package/src/lib/localization/translatable-fields.ts +0 -46
- package/src/lib/number.ts +0 -13
- package/src/lib/openapi/generator.ts +77 -106
- package/src/lib/query/engine.ts +8 -58
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/types.ts +1 -13
- package/src/lib/url.ts +0 -30
- package/src/lib/webhooks/__tests__/body.test.ts +0 -48
- package/src/lib/webhooks/body.ts +4 -11
- package/src/lib/webhooks/index.ts +0 -1
- package/src/modules/integrations/types.ts +0 -6
- package/src/modules/notifications/types.ts +0 -7
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -44
- package/src/modules/payment_gateways/types.ts +6 -52
- package/src/modules/registry.ts +0 -5
- package/src/modules/search.ts +0 -39
- package/src/modules/widgets/injection-loader.ts +3 -33
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +0 -6
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +0 -7
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +0 -62
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +0 -7
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +0 -33
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +0 -7
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +0 -9
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +0 -7
- package/dist/lib/db/advisoryLock.js +0 -172
- package/dist/lib/db/advisoryLock.js.map +0 -7
- package/dist/lib/query/overlap.js +0 -58
- package/dist/lib/query/overlap.js.map +0 -7
- package/dist/modules/notifications/safeHref.js +0 -20
- package/dist/modules/notifications/safeHref.js.map +0 -7
- package/src/lib/__tests__/url.cross-site.test.ts +0 -140
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +0 -141
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +0 -174
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +0 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +0 -130
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +0 -121
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +0 -41
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +0 -8
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +0 -343
- package/src/lib/db/__tests__/advisoryLock.test.ts +0 -564
- package/src/lib/db/advisoryLock.ts +0 -271
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +0 -102
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +0 -89
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +0 -79
- package/src/lib/query/__tests__/overlap.test.ts +0 -219
- package/src/lib/query/overlap.ts +0 -106
- package/src/modules/notifications/safeHref.ts +0 -19
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +0 -78
|
@@ -1,271 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* End-to-end proof for #7502 against a real PostgreSQL: a `?ids=` filter that
|
|
3
|
-
* leaves no valid id (`{ id: { $in: [] } }`, the shape `mergeIdFilter` produces
|
|
4
|
-
* for an unparseable or disjoint id list) must answer an empty page, not a
|
|
5
|
-
* 42601 syntax error from Kysely compiling `in ()`.
|
|
6
|
-
*
|
|
7
|
-
* Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
|
|
8
|
-
* drops its own table). Example:
|
|
9
|
-
* docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
|
|
10
|
-
* OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest empty-ids-filter.pg
|
|
11
|
-
*/
|
|
12
|
-
import { Kysely, PostgresDialect, sql } from 'kysely'
|
|
13
|
-
import { BasicQueryEngine } from '../engine'
|
|
14
|
-
|
|
15
|
-
const PG_URL = process.env.OM_COUNT_CAP_PG_URL
|
|
16
|
-
const maybe = PG_URL ? describe : describe.skip
|
|
17
|
-
|
|
18
|
-
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
19
|
-
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
20
|
-
const ENTITY = 'capcheck:om_emptyids_order'
|
|
21
|
-
const TABLE = 'om_emptyids_orders'
|
|
22
|
-
const ID_A = '33333333-3333-4333-8333-000000000001'
|
|
23
|
-
const ID_B = '33333333-3333-4333-8333-000000000002'
|
|
24
|
-
|
|
25
|
-
maybe('empty $in/$nin filter against PostgreSQL', () => {
|
|
26
|
-
jest.setTimeout(30_000)
|
|
27
|
-
let db: Kysely<any>
|
|
28
|
-
|
|
29
|
-
beforeAll(async () => {
|
|
30
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
31
|
-
const { Pool } = require('pg')
|
|
32
|
-
db = new Kysely<any>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) })
|
|
33
|
-
await sql`drop table if exists om_emptyids_orders`.execute(db)
|
|
34
|
-
await sql`create table om_emptyids_orders (
|
|
35
|
-
id uuid primary key,
|
|
36
|
-
tenant_id uuid not null,
|
|
37
|
-
organization_id uuid not null,
|
|
38
|
-
deleted_at timestamptz,
|
|
39
|
-
status text not null
|
|
40
|
-
)`.execute(db)
|
|
41
|
-
await sql`insert into om_emptyids_orders (id, tenant_id, organization_id, deleted_at, status) values
|
|
42
|
-
(${ID_A}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'open'),
|
|
43
|
-
(${ID_B}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'closed')`.execute(db)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
afterAll(async () => {
|
|
47
|
-
await sql`drop table if exists om_emptyids_orders`.execute(db)
|
|
48
|
-
await db.destroy()
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
|
|
52
|
-
const baseOpts = {
|
|
53
|
-
tenantId: TENANT,
|
|
54
|
-
organizationId: ORG,
|
|
55
|
-
fields: ['id'],
|
|
56
|
-
page: { page: 1, pageSize: 10 },
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
test('?ids= with no valid id returns an empty page instead of a 500 (AND path)', async () => {
|
|
60
|
-
const result = await makeEngine().query(ENTITY, {
|
|
61
|
-
...baseOpts,
|
|
62
|
-
filters: { id: { $in: [] } },
|
|
63
|
-
})
|
|
64
|
-
expect(result.items).toEqual([])
|
|
65
|
-
expect(result.total).toBe(0)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('a disjoint ?id=&?ids= intersection inside an $or group still matches the other disjunct', async () => {
|
|
69
|
-
const result = await makeEngine().query(ENTITY, {
|
|
70
|
-
...baseOpts,
|
|
71
|
-
filters: {
|
|
72
|
-
$or: [
|
|
73
|
-
{ id: { $in: [] } },
|
|
74
|
-
{ status: { $eq: 'closed' } },
|
|
75
|
-
],
|
|
76
|
-
},
|
|
77
|
-
})
|
|
78
|
-
expect(result.items.map((row: any) => row.id)).toEqual([ID_B])
|
|
79
|
-
expect(result.total).toBe(1)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('an empty $nin excludes nothing, matching every row', async () => {
|
|
83
|
-
const result = await makeEngine().query(ENTITY, {
|
|
84
|
-
...baseOpts,
|
|
85
|
-
filters: { id: { $nin: [] } },
|
|
86
|
-
})
|
|
87
|
-
expect(result.total).toBe(2)
|
|
88
|
-
})
|
|
89
|
-
})
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #7502: `mergeIdFilter` deliberately produces `{ $in: [] }` for a `?ids=` param
|
|
3
|
-
* that left no valid UUID (or a disjoint `?id=`+`?ids=` intersection) — the
|
|
4
|
-
* correct "match nothing" semantics. Both engine where-builders used to pass
|
|
5
|
-
* that empty array straight to Kysely's `.where(column, 'in', [])`, which
|
|
6
|
-
* compiles to invalid SQL (`in ()`) and makes Postgres answer 42601/500
|
|
7
|
-
* instead of an empty page. These tests pin the guard directly on the two
|
|
8
|
-
* private builder methods — no real SQL execution needed to prove the shape
|
|
9
|
-
* changed; `count-cap-plan.test.ts`-style Postgres-gated tests cover the
|
|
10
|
-
* end-to-end compiled-SQL behavior.
|
|
11
|
-
*/
|
|
12
|
-
import { BasicQueryEngine } from '../engine'
|
|
13
|
-
|
|
14
|
-
describe('applyColumnOp empty $in/$nin guard (#7502)', () => {
|
|
15
|
-
const engine = new BasicQueryEngine({} as any, () => ({}) as any)
|
|
16
|
-
|
|
17
|
-
function callApplyColumnOp(op: string, value: unknown) {
|
|
18
|
-
const calls: any[] = []
|
|
19
|
-
const builder: any = {
|
|
20
|
-
where: (...args: any[]) => {
|
|
21
|
-
calls.push(args)
|
|
22
|
-
return builder
|
|
23
|
-
},
|
|
24
|
-
}
|
|
25
|
-
;(engine as any).applyColumnOp(builder, 'id', op, value)
|
|
26
|
-
return calls
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
test('empty $in does not reach Kysely as an empty in-list', () => {
|
|
30
|
-
const calls = callApplyColumnOp('in', [])
|
|
31
|
-
expect(calls).toHaveLength(1)
|
|
32
|
-
// Pre-fix this called builder.where('id', 'in', []) -- three arguments, the
|
|
33
|
-
// shape Kysely compiles to invalid SQL. Post-fix it is a single predicate arg.
|
|
34
|
-
expect(calls[0]).not.toEqual(['id', 'in', []])
|
|
35
|
-
expect(calls[0]).toHaveLength(1)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('non-empty $in keeps the original in-list predicate', () => {
|
|
39
|
-
const calls = callApplyColumnOp('in', ['a'])
|
|
40
|
-
expect(calls[0]).toEqual(['id', 'in', ['a']])
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('empty $nin does not reach Kysely as an empty not-in-list', () => {
|
|
44
|
-
const calls = callApplyColumnOp('nin', [])
|
|
45
|
-
expect(calls[0]).toHaveLength(1)
|
|
46
|
-
expect(calls[0]).not.toEqual(['id', 'not in', []])
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('non-empty $nin keeps the original not-in-list predicate', () => {
|
|
50
|
-
const calls = callApplyColumnOp('nin', ['a'])
|
|
51
|
-
expect(calls[0]).toEqual(['id', 'not in', ['a']])
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
describe('buildColumnOpExpression empty $in/$nin guard (#7502, OR-group path)', () => {
|
|
56
|
-
const engine = new BasicQueryEngine({} as any, () => ({}) as any)
|
|
57
|
-
|
|
58
|
-
function callBuildColumnOpExpression(op: string, value: unknown) {
|
|
59
|
-
const eb: any = (...args: any[]) => ({ kind: 'cmp', args })
|
|
60
|
-
eb.val = (v: any) => ({ kind: 'val', value: v })
|
|
61
|
-
return (engine as any).buildColumnOpExpression(eb, 'id', op, value)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
test('empty $in compiles to a never-match literal, not an empty in-list comparison', () => {
|
|
65
|
-
expect(callBuildColumnOpExpression('in', [])).toEqual({ kind: 'val', value: false })
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('non-empty $in keeps the original in-list comparison', () => {
|
|
69
|
-
expect(callBuildColumnOpExpression('in', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'in', ['a']] })
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('empty $nin compiles to an always-match literal, not an empty not-in-list comparison', () => {
|
|
73
|
-
expect(callBuildColumnOpExpression('nin', [])).toEqual({ kind: 'val', value: true })
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('non-empty $nin keeps the original not-in-list comparison', () => {
|
|
77
|
-
expect(callBuildColumnOpExpression('nin', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'not in', ['a']] })
|
|
78
|
-
})
|
|
79
|
-
})
|