@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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/commands/flush.js +23 -1
  3. package/dist/lib/commands/flush.js.map +2 -2
  4. package/dist/lib/crud/factory.js +16 -0
  5. package/dist/lib/crud/factory.js.map +2 -2
  6. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  7. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  8. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  9. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  10. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  11. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  12. package/dist/lib/crud/optimistic-lock.js +172 -0
  13. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  14. package/dist/lib/di/container.js +18 -2
  15. package/dist/lib/di/container.js.map +2 -2
  16. package/dist/lib/version.js +1 -1
  17. package/dist/lib/version.js.map +1 -1
  18. package/package.json +2 -2
  19. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  20. package/src/lib/commands/flush.ts +79 -2
  21. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  22. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  23. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  24. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  25. package/src/lib/crud/factory.ts +23 -0
  26. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  27. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  28. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  29. package/src/lib/crud/optimistic-lock.ts +379 -0
  30. 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
+ }
@@ -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) {