@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
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/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.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/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/di/container.ts +17 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSS optimistic-locking guard service.
|
|
3
|
+
*
|
|
4
|
+
* Registered as `crudMutationGuardService` (by the platform DI bootstrap and
|
|
5
|
+
* by hand-wiring modules that override the default reader). Compares the
|
|
6
|
+
* client-sent expected `updated_at` (carried via the extension header
|
|
7
|
+
* defined in `optimistic-lock-headers.ts`) against the current DB
|
|
8
|
+
* `updated_at` for the target entity; on mismatch returns HTTP 409 with the
|
|
9
|
+
* structured `OptimisticLockConflictBody`.
|
|
10
|
+
*
|
|
11
|
+
* **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via
|
|
12
|
+
* `OM_OPTIMISTIC_LOCK`:
|
|
13
|
+
* - unset / empty / whitespace → ON for every CRUD entity (`{ mode: 'all' }`)
|
|
14
|
+
* - `all` → all entities (explicit form of the default)
|
|
15
|
+
* - `customers.company,sales.order` → allow-list (lowercased, trimmed, deduped)
|
|
16
|
+
* - `off` / `false` / `0` / `no` / `disabled` / `none` → fully disabled
|
|
17
|
+
*
|
|
18
|
+
* The guard is still strictly additive at runtime: clients that do not send
|
|
19
|
+
* the `x-om-ext-optimistic-lock-expected-updated-at` header pass through
|
|
20
|
+
* unchanged, so flipping the default to ON cannot introduce new 409s on
|
|
21
|
+
* existing API consumers. Pages that opt into the round-trip (via
|
|
22
|
+
* `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling
|
|
23
|
+
* `buildOptimisticLockHeader`) gain protection without any per-deployment
|
|
24
|
+
* env change.
|
|
25
|
+
*
|
|
26
|
+
* Cannot be registered as a static `data/guards.ts` `MutationGuard` because
|
|
27
|
+
* the static `validate(input)` receives only `MutationGuardInput` — no
|
|
28
|
+
* container / em access. Stateful checks that need to read current DB state
|
|
29
|
+
* MUST go through the DI service path (this file).
|
|
30
|
+
*
|
|
31
|
+
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
32
|
+
*/
|
|
33
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
34
|
+
import type {
|
|
35
|
+
CrudMutationGuardValidateInput,
|
|
36
|
+
CrudMutationGuardValidationResult,
|
|
37
|
+
CrudMutationGuardAfterSuccessInput,
|
|
38
|
+
} from './mutation-guard'
|
|
39
|
+
import {
|
|
40
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
41
|
+
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
42
|
+
OPTIMISTIC_LOCK_ENV_VAR,
|
|
43
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
44
|
+
type OptimisticLockConflictBody,
|
|
45
|
+
} from './optimistic-lock-headers'
|
|
46
|
+
import { getAllOptimisticLockReaders } from './optimistic-lock-store'
|
|
47
|
+
|
|
48
|
+
export type OptimisticLockConfig =
|
|
49
|
+
| { mode: 'off' }
|
|
50
|
+
| { mode: 'all' }
|
|
51
|
+
| { mode: 'allowlist'; entities: ReadonlySet<string> }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tokens (case-insensitive, single-token-only) that explicitly disable the
|
|
55
|
+
* guard. Spelled out as a fixed set so tests can pin them; deliberately the
|
|
56
|
+
* same shape `parseBooleanToken` recognises so operators can mirror existing
|
|
57
|
+
* habit. Mixing an off-token with other entities is invalid input — we treat
|
|
58
|
+
* any presence of an off-token in the comma list as a request to disable.
|
|
59
|
+
*/
|
|
60
|
+
const OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([
|
|
61
|
+
'off',
|
|
62
|
+
'false',
|
|
63
|
+
'0',
|
|
64
|
+
'no',
|
|
65
|
+
'disabled',
|
|
66
|
+
'none',
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can
|
|
71
|
+
* exercise the grammar without spinning up the full service.
|
|
72
|
+
*
|
|
73
|
+
* Default is **ON** (`{ mode: 'all' }`) — unset / empty / whitespace input
|
|
74
|
+
* activates the guard for every CRUD entity. Operators opt out via
|
|
75
|
+
* `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).
|
|
76
|
+
*/
|
|
77
|
+
export function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {
|
|
78
|
+
if (raw == null) return { mode: 'all' }
|
|
79
|
+
const trimmed = String(raw).trim()
|
|
80
|
+
if (trimmed === '') return { mode: 'all' }
|
|
81
|
+
|
|
82
|
+
const tokens = trimmed
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((token) => token.trim().toLowerCase())
|
|
85
|
+
.filter((token) => token.length > 0)
|
|
86
|
+
|
|
87
|
+
if (tokens.length === 0) return { mode: 'all' }
|
|
88
|
+
if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }
|
|
89
|
+
if (tokens.includes('all')) return { mode: 'all' }
|
|
90
|
+
|
|
91
|
+
return { mode: 'allowlist', entities: new Set(tokens) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type OptimisticLockResolverInput = {
|
|
95
|
+
expectedFromHeader: string | null
|
|
96
|
+
resourceKind: string
|
|
97
|
+
resourceId: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook reserved for the enterprise `record_locks` module to override token
|
|
102
|
+
* resolution (e.g. read the expected token from a lock record instead of
|
|
103
|
+
* the request header). OSS keeps the default = "what the client sent".
|
|
104
|
+
*
|
|
105
|
+
* Documented as part of the enterprise extension contract; not used in OSS
|
|
106
|
+
* itself.
|
|
107
|
+
*/
|
|
108
|
+
export type ResolveExpectedUpdatedAt = (
|
|
109
|
+
input: OptimisticLockResolverInput,
|
|
110
|
+
) => Promise<string | null> | string | null
|
|
111
|
+
|
|
112
|
+
const defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>
|
|
113
|
+
expectedFromHeader
|
|
114
|
+
|
|
115
|
+
export type OptimisticLockCurrentReader = (
|
|
116
|
+
em: EntityManager,
|
|
117
|
+
input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },
|
|
118
|
+
) => Promise<string | null>
|
|
119
|
+
|
|
120
|
+
export type GenericOptimisticLockReaderOptions = {
|
|
121
|
+
/** MikroORM entity class. */
|
|
122
|
+
entity: unknown
|
|
123
|
+
/** Primary key field. Defaults to `id`. */
|
|
124
|
+
idField?: string
|
|
125
|
+
/** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare — only when the entity itself has no `tenantId` column). */
|
|
126
|
+
tenantField?: string | null
|
|
127
|
+
/** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */
|
|
128
|
+
orgField?: string | null
|
|
129
|
+
/** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */
|
|
130
|
+
softDeleteField?: string | null
|
|
131
|
+
/** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */
|
|
132
|
+
extraFilter?: Record<string, unknown>
|
|
133
|
+
/** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */
|
|
134
|
+
updatedAtField?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build a generic optimistic-lock reader for any ORM entity that follows the
|
|
139
|
+
* platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +
|
|
140
|
+
* `updatedAt`). The reader projects only the timestamp column so PII never
|
|
141
|
+
* materializes.
|
|
142
|
+
*
|
|
143
|
+
* Used by `makeCrudRoute` to auto-register one reader per CRUD route at
|
|
144
|
+
* module-load time (see Phase 13 of the OSS optimistic-locking spec).
|
|
145
|
+
* Module authors who need bespoke filtering (e.g. discriminator on a shared
|
|
146
|
+
* table) keep registering their own reader via `registerOptimisticLockReaders`
|
|
147
|
+
* — those hand-wired registrations win because they land first.
|
|
148
|
+
*
|
|
149
|
+
* Fail-open contract: if the underlying `findOne` throws (missing column,
|
|
150
|
+
* schema drift, mid-migration) the reader returns `null`, which the guard
|
|
151
|
+
* treats as "entity already gone" and lets the CRUD path's own 404 fire.
|
|
152
|
+
* We MUST NOT throw out of the reader — that would 500 every mutation on
|
|
153
|
+
* the affected entity instead of opting it out of the optimistic check.
|
|
154
|
+
*/
|
|
155
|
+
export function createGenericOptimisticLockReader(
|
|
156
|
+
opts: GenericOptimisticLockReaderOptions,
|
|
157
|
+
): OptimisticLockCurrentReader {
|
|
158
|
+
const idField = opts.idField ?? 'id'
|
|
159
|
+
const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'
|
|
160
|
+
const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'
|
|
161
|
+
const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'
|
|
162
|
+
const updatedAtField = opts.updatedAtField ?? 'updatedAt'
|
|
163
|
+
const extraFilter = opts.extraFilter ?? {}
|
|
164
|
+
|
|
165
|
+
return async (em, { resourceId, tenantId, organizationId }) => {
|
|
166
|
+
const filter: Record<string, unknown> = { [idField]: resourceId }
|
|
167
|
+
if (tenantField) filter[tenantField] = tenantId
|
|
168
|
+
if (orgField && organizationId) filter[orgField] = organizationId
|
|
169
|
+
if (softDeleteField) filter[softDeleteField] = null
|
|
170
|
+
for (const [key, value] of Object.entries(extraFilter)) filter[key] = value
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const row = await em.findOne(opts.entity as never, filter as never, {
|
|
174
|
+
fields: [updatedAtField] as never,
|
|
175
|
+
})
|
|
176
|
+
if (!row || typeof row !== 'object') return null
|
|
177
|
+
const value = (row as Record<string, unknown>)[updatedAtField]
|
|
178
|
+
if (value instanceof Date) return value.toISOString()
|
|
179
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
180
|
+
return null
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type OptimisticLockGuardOptions = {
|
|
188
|
+
/** EntityManager resolver. Container-bound via DI in real usage. */
|
|
189
|
+
getEm: () => EntityManager
|
|
190
|
+
/**
|
|
191
|
+
* Maps `resourceKind` → reader that returns the current
|
|
192
|
+
* `updated_at` as an ISO string (or null when not found).
|
|
193
|
+
*
|
|
194
|
+
* The reader receives the EM so module authors can choose the
|
|
195
|
+
* right `findOne` shape for their entity (`findOneWithDecryption`
|
|
196
|
+
* when sensitive, plain `findOne` otherwise — but only requesting
|
|
197
|
+
* `updated_at` so no PII materializes).
|
|
198
|
+
*
|
|
199
|
+
* When omitted, the service pulls readers from the shared
|
|
200
|
+
* `optimistic-lock-store` (the recommended pattern for multi-module
|
|
201
|
+
* deployments — each module registers its own readers via
|
|
202
|
+
* `registerOptimisticLockReaders(...)` at module-load time).
|
|
203
|
+
*/
|
|
204
|
+
readers?: Record<string, OptimisticLockCurrentReader>
|
|
205
|
+
/** Override env source (mostly for tests). Defaults to `process.env`. */
|
|
206
|
+
envValue?: string | null
|
|
207
|
+
/** Override the token resolver. Defaults to "use the header value". */
|
|
208
|
+
resolveExpected?: ResolveExpectedUpdatedAt
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type OptimisticLockGuardService = {
|
|
212
|
+
validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
|
|
213
|
+
afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
|
|
214
|
+
/** Exposed for tests / introspection. */
|
|
215
|
+
getConfig: () => OptimisticLockConfig
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readHeader(headers: Headers, name: string): string | null {
|
|
219
|
+
const direct = headers.get(name)
|
|
220
|
+
if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`
|
|
226
|
+
* when the input cannot be parsed. Exported so the command-level helper
|
|
227
|
+
* (`optimistic-lock-command.ts`) compares timestamps with the EXACT same
|
|
228
|
+
* normalization as the CRUD guard — otherwise the same instant could compare
|
|
229
|
+
* unequal across the two paths.
|
|
230
|
+
*/
|
|
231
|
+
export function normalizeIsoToken(raw: string): string | null {
|
|
232
|
+
const ms = Date.parse(raw)
|
|
233
|
+
if (!Number.isFinite(ms)) return null
|
|
234
|
+
return new Date(ms).toISOString()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {
|
|
238
|
+
return {
|
|
239
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
240
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
241
|
+
currentUpdatedAt: currentIso,
|
|
242
|
+
expectedUpdatedAt: expectedIso,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Factory for the optimistic-lock guard service.
|
|
248
|
+
*
|
|
249
|
+
* Usage from a module's `di.ts`:
|
|
250
|
+
*
|
|
251
|
+
* ```ts
|
|
252
|
+
* import { asFunction } from 'awilix'
|
|
253
|
+
* import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
254
|
+
*
|
|
255
|
+
* container.register({
|
|
256
|
+
* crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({
|
|
257
|
+
* getEm: () => cradle.em,
|
|
258
|
+
* readers: {
|
|
259
|
+
* 'customers.company': async (em, { resourceId, tenantId }) => {
|
|
260
|
+
* const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })
|
|
261
|
+
* return row?.updatedAt ? row.updatedAt.toISOString() : null
|
|
262
|
+
* },
|
|
263
|
+
* },
|
|
264
|
+
* })).singleton(),
|
|
265
|
+
* })
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function createOptimisticLockGuardService(
|
|
269
|
+
opts: OptimisticLockGuardOptions,
|
|
270
|
+
): OptimisticLockGuardService {
|
|
271
|
+
const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]
|
|
272
|
+
const config = parseOptimisticLockEnv(envValue)
|
|
273
|
+
const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt
|
|
274
|
+
const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'
|
|
275
|
+
|
|
276
|
+
function isEntityEnabled(resourceKind: string): boolean {
|
|
277
|
+
if (config.mode === 'off') return false
|
|
278
|
+
if (config.mode === 'all') return true
|
|
279
|
+
return config.entities.has(resourceKind.toLowerCase())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function validateMutation(
|
|
283
|
+
input: CrudMutationGuardValidateInput,
|
|
284
|
+
): Promise<CrudMutationGuardValidationResult> {
|
|
285
|
+
if (config.mode === 'off') {
|
|
286
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
287
|
+
}
|
|
288
|
+
if (input.operation !== 'update' && input.operation !== 'delete') {
|
|
289
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
290
|
+
}
|
|
291
|
+
if (!isEntityEnabled(input.resourceKind)) {
|
|
292
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
293
|
+
}
|
|
294
|
+
const readers = opts.readers ?? getAllOptimisticLockReaders()
|
|
295
|
+
const reader = readers[input.resourceKind]
|
|
296
|
+
if (!reader) {
|
|
297
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)
|
|
301
|
+
const resolvedExpected = await resolveExpected({
|
|
302
|
+
expectedFromHeader: expectedRaw,
|
|
303
|
+
resourceKind: input.resourceKind,
|
|
304
|
+
resourceId: input.resourceId,
|
|
305
|
+
})
|
|
306
|
+
if (resolvedExpected == null) {
|
|
307
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const expectedIso = normalizeIsoToken(resolvedExpected)
|
|
311
|
+
if (expectedIso == null) {
|
|
312
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const em = opts.getEm()
|
|
316
|
+
const currentRaw = await reader(em, {
|
|
317
|
+
resourceKind: input.resourceKind,
|
|
318
|
+
resourceId: input.resourceId,
|
|
319
|
+
tenantId: input.tenantId,
|
|
320
|
+
organizationId: input.organizationId ?? null,
|
|
321
|
+
})
|
|
322
|
+
if (currentRaw == null) {
|
|
323
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
324
|
+
}
|
|
325
|
+
const currentIso = normalizeIsoToken(currentRaw)
|
|
326
|
+
if (currentIso == null) {
|
|
327
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (currentIso === expectedIso) {
|
|
331
|
+
if (debugEnabled) {
|
|
332
|
+
// eslint-disable-next-line no-console
|
|
333
|
+
console.log('[optimistic-lock] match', {
|
|
334
|
+
resourceKind: input.resourceKind,
|
|
335
|
+
resourceId: input.resourceId,
|
|
336
|
+
operation: input.operation,
|
|
337
|
+
currentIso,
|
|
338
|
+
expectedIso,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (debugEnabled) {
|
|
345
|
+
// eslint-disable-next-line no-console
|
|
346
|
+
console.log('[optimistic-lock] CONFLICT', {
|
|
347
|
+
resourceKind: input.resourceKind,
|
|
348
|
+
resourceId: input.resourceId,
|
|
349
|
+
operation: input.operation,
|
|
350
|
+
tenantId: input.tenantId,
|
|
351
|
+
organizationId: input.organizationId ?? null,
|
|
352
|
+
expectedRaw: resolvedExpected,
|
|
353
|
+
expectedIso,
|
|
354
|
+
currentRaw,
|
|
355
|
+
currentIso,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
status: 409,
|
|
362
|
+
body: buildConflictBody(currentIso, expectedIso),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {
|
|
367
|
+
// no-op: optimistic check has no post-success cleanup
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getConfig(): OptimisticLockConfig {
|
|
371
|
+
return config
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
validateMutation,
|
|
376
|
+
afterMutationSuccess,
|
|
377
|
+
getConfig,
|
|
378
|
+
}
|
|
379
|
+
}
|
package/src/lib/di/container.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'
|
|
1
|
+
import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'
|
|
2
2
|
import { RequestContext } from '@mikro-orm/core'
|
|
3
3
|
import { getOrm } from '@open-mercato/shared/lib/db/mikro'
|
|
4
4
|
import { EntityManager } from '@mikro-orm/postgresql'
|
|
@@ -6,6 +6,8 @@ import { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'
|
|
|
6
6
|
import { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
7
7
|
import { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
8
|
import { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'
|
|
9
|
+
import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
10
|
+
import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
9
11
|
|
|
10
12
|
type DynamicCradle = Record<string, any>
|
|
11
13
|
|
|
@@ -155,6 +157,20 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
155
157
|
dataEngine: asValue(new DefaultDataEngine(em, container as any)),
|
|
156
158
|
commandRegistry: asValue(commandRegistry),
|
|
157
159
|
commandBus: asValue(new CommandBus()),
|
|
160
|
+
// Default OSS optimistic-lock guard. Reads from the global reader store
|
|
161
|
+
// (populated by `makeCrudRoute` auto-registration + any module-DI
|
|
162
|
+
// hand-wired calls to `registerOptimisticLockReaders`). Service is
|
|
163
|
+
// strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is
|
|
164
|
+
// sent) it short-circuits at validateMutation. Module-level di.ts
|
|
165
|
+
// registrations override this default via Awilix replace semantics —
|
|
166
|
+
// see the enterprise `record_locks` module for the canonical override.
|
|
167
|
+
// Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
168
|
+
crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>
|
|
169
|
+
createOptimisticLockGuardService({
|
|
170
|
+
getEm: () => scopedEm,
|
|
171
|
+
readers: getAllOptimisticLockReaders(),
|
|
172
|
+
}),
|
|
173
|
+
).scoped(),
|
|
158
174
|
})
|
|
159
175
|
// Allow modules to override/extend
|
|
160
176
|
for (const reg of diRegistrars) {
|