@open-mercato/shared 0.6.6-develop.6317.1.b3be10ab84 → 0.6.6-develop.6331.1.a33b8e99b7

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.
@@ -469,20 +469,31 @@ describe('createGenericOptimisticLockReader', () => {
469
469
  expect(captures[0].options).toEqual({ fields: ['modifiedAt'] })
470
470
  })
471
471
 
472
- it('fails open (returns null) when findOne throws never 500s the mutation', async () => {
472
+ it('fails open (returns null) AND logs loudly with the resourceKind when findOne throws', async () => {
473
473
  const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
474
474
  const em = {
475
475
  async findOne() {
476
- throw new Error('column "updated_at" does not exist')
476
+ throw new Error('column "deleted_at" does not exist')
477
477
  },
478
478
  } as never
479
- const result = await reader(em, {
480
- resourceKind: 'k',
481
- resourceId: 'r',
482
- tenantId: 't',
483
- organizationId: 'o',
484
- })
485
- expect(result).toBeNull()
479
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
480
+ try {
481
+ const result = await reader(em, {
482
+ resourceKind: 'customers.tag',
483
+ resourceId: 'r',
484
+ tenantId: 't',
485
+ organizationId: 'o',
486
+ })
487
+ // Fail-open control flow preserved: a query error must never 500 the mutation.
488
+ expect(result).toBeNull()
489
+ // ...but a misconfig must be visible, naming the affected resourceKind.
490
+ expect(errorSpy).toHaveBeenCalledTimes(1)
491
+ const [message] = errorSpy.mock.calls[0]
492
+ expect(String(message)).toContain('customers.tag')
493
+ expect(String(message)).toContain('[optimistic-lock]')
494
+ } finally {
495
+ errorSpy.mockRestore()
496
+ }
486
497
  })
487
498
 
488
499
  it('returns null when the projected updatedAt is missing / null / non-Date', async () => {
@@ -27,7 +27,8 @@
27
27
  * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
28
28
  * .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)
29
29
  */
30
- import { CrudHttpError } from './errors'
30
+ import type { AwilixContainer } from 'awilix'
31
+ import { CrudHttpError, isCrudHttpError } from './errors'
31
32
  import {
32
33
  OPTIMISTIC_LOCK_CONFLICT_CODE,
33
34
  OPTIMISTIC_LOCK_CONFLICT_ERROR,
@@ -303,3 +304,61 @@ export function createCommandOptimisticLockGuardService(
303
304
  },
304
305
  }
305
306
  }
307
+
308
+ const COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY = 'commandOptimisticLockGuardService'
309
+
310
+ function resolveCommandOptimisticLockGuardService(
311
+ container: AwilixContainer,
312
+ ): CommandOptimisticLockGuardService | null {
313
+ try {
314
+ const service = container.resolve<CommandOptimisticLockGuardService>(
315
+ COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY,
316
+ )
317
+ if (!service || typeof service.enforce !== 'function') return null
318
+ return service
319
+ } catch {
320
+ return null
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Async, DI-aware command-level optimistic-lock runner (Phase 0 / S1). This is
326
+ * the additive seam command handlers migrate to so BOTH the OSS floor and the
327
+ * optional enterprise `record_locks` enrichment protect Command-pattern writes
328
+ * through one call:
329
+ *
330
+ * 1. The OSS `updated_at` floor runs **unconditionally first** via the
331
+ * synchronous {@link enforceCommandOptimisticLock} — a stale write 409s
332
+ * here regardless of any record-lock token / widget state (H2). The legacy
333
+ * helper is reused verbatim, so the floor behaves identically to existing
334
+ * direct call sites.
335
+ * 2. If the floor passes, the optional `commandOptimisticLockGuardService` is
336
+ * resolved from the request container and awaited for enrichment.
337
+ *
338
+ * Fail-closed delegation (H3): a `record_lock_conflict`/409 from the enterprise
339
+ * service is rethrown (the write must be blocked), but ANY non-conflict error
340
+ * from a broken/unregistered enterprise guard is swallowed — the request
341
+ * degrades to OSS-only protection, never to "skip the guard".
342
+ *
343
+ * Strictly additive: with no enterprise service registered (OSS-only build) this
344
+ * is exactly the OSS compare. The synchronous {@link enforceCommandOptimisticLock}
345
+ * helper is left untouched for external callers.
346
+ */
347
+ export async function enforceCommandOptimisticLockWithGuards(
348
+ container: AwilixContainer,
349
+ input: EnforceCommandOptimisticLockInput,
350
+ ): Promise<void> {
351
+ // 1. OSS floor — unconditional, synchronous, identical to direct call sites.
352
+ enforceCommandOptimisticLock(input)
353
+
354
+ // 2. Optional enterprise enrichment, fail-closed.
355
+ const guard = resolveCommandOptimisticLockGuardService(container)
356
+ if (!guard) return
357
+
358
+ try {
359
+ await guard.enforce(input)
360
+ } catch (error) {
361
+ // A real conflict must block the write; anything else degrades to OSS-only.
362
+ if (isCrudHttpError(error) && error.status === 409) throw error
363
+ }
364
+ }
@@ -151,6 +151,16 @@ export type GenericOptimisticLockReaderOptions = {
151
151
  * treats as "entity already gone" and lets the CRUD path's own 404 fire.
152
152
  * We MUST NOT throw out of the reader — that would 500 every mutation on
153
153
  * the affected entity instead of opting it out of the optimistic check.
154
+ *
155
+ * Because a genuine not-found resolves to `null` *without* throwing (MikroORM's
156
+ * `findOne` returns `null`, it does not raise), the catch below only fires on a
157
+ * real query failure — most commonly a `softDeleteField`/`tenantField`/`orgField`
158
+ * misconfig that filters on a column the entity's table does not have. That class
159
+ * of bug silently disables locking for the whole entity, so the catch logs loudly
160
+ * with the `resourceKind` instead of swallowing the error. The control flow stays
161
+ * fail-open (returns `null`) to honor the no-500 contract; the durable defense is
162
+ * the static reader-resolution guard (`optimistic-lock-editable-entities.test.ts`)
163
+ * which fails the build when a route would land in this path.
154
164
  */
155
165
  export function createGenericOptimisticLockReader(
156
166
  opts: GenericOptimisticLockReaderOptions,
@@ -162,7 +172,7 @@ export function createGenericOptimisticLockReader(
162
172
  const updatedAtField = opts.updatedAtField ?? 'updatedAt'
163
173
  const extraFilter = opts.extraFilter ?? {}
164
174
 
165
- return async (em, { resourceId, tenantId, organizationId }) => {
175
+ return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {
166
176
  const filter: Record<string, unknown> = { [idField]: resourceId }
167
177
  if (tenantField) filter[tenantField] = tenantId
168
178
  if (orgField && organizationId) filter[orgField] = organizationId
@@ -178,7 +188,18 @@ export function createGenericOptimisticLockReader(
178
188
  if (value instanceof Date) return value.toISOString()
179
189
  if (typeof value === 'string' && value.length > 0) return value
180
190
  return null
181
- } catch {
191
+ } catch (err) {
192
+ // A genuine not-found returns null above without throwing; reaching here
193
+ // means the query itself failed (most likely a softDeleteField/tenant/org
194
+ // misconfig filtering on a column the table lacks), which silently disables
195
+ // locking for `resourceKind`. Log loudly so the misconfig is visible.
196
+ // Control flow stays fail-open (return null) to honor the no-500 contract.
197
+ // eslint-disable-next-line no-console
198
+ console.error(
199
+ `[optimistic-lock] reader query failed for resourceKind="${resourceKind}" — optimistic locking is DISABLED for this entity until fixed. ` +
200
+ `Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,
201
+ err,
202
+ )
182
203
  return null
183
204
  }
184
205
  }
@@ -8,6 +8,7 @@ import { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'
8
8
  import { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'
9
9
  import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
10
10
  import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
11
+ import { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
11
12
 
12
13
  type DynamicCradle = Record<string, any>
13
14
 
@@ -171,6 +172,16 @@ export async function createRequestContainer(): Promise<AppContainer> {
171
172
  readers: getAllOptimisticLockReaders(),
172
173
  }),
173
174
  ).scoped(),
175
+ // Default OSS command-level optimistic-lock guard, awaited by
176
+ // `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.
177
+ // Header/explicit-token compare only (no `resolveExpected`), so it is
178
+ // behaviourally identical to calling `enforceCommandOptimisticLock`
179
+ // directly. The enterprise `record_locks` module overrides this DI key
180
+ // with a lock-backed `resolveExpected` via Awilix replace semantics.
181
+ // Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)
182
+ commandOptimisticLockGuardService: asFunction(() =>
183
+ createCommandOptimisticLockGuardService(),
184
+ ).scoped(),
174
185
  })
175
186
  // Allow modules to override/extend
176
187
  for (const reg of diRegistrars) {
@@ -0,0 +1,58 @@
1
+ /** @jest-environment node */
2
+ import { normalizeFilters } from '../join-utils'
3
+
4
+ /**
5
+ * The custom-entity records API builds a pagination-aware server-side search as a
6
+ * top-level `$or` of `$ilike` clauses (one per searchable field) ANDed with the
7
+ * tenant/org scope (#3229). This verifies the filter shape it produces normalizes
8
+ * into the orGroup disjuncts + lifted common clause that the query engine consumes,
9
+ * so the search applies before pagination instead of only on the current page.
10
+ */
11
+ describe('records server-side search filter normalization', () => {
12
+ it('expands an $or of $ilike clauses into orGroup-tagged disjuncts', () => {
13
+ const filters = {
14
+ $or: [
15
+ { id: { $ilike: '%berlin%' } },
16
+ { title: { $ilike: '%berlin%' } },
17
+ { location: { $ilike: '%berlin%' } },
18
+ ],
19
+ }
20
+ const normalized = normalizeFilters(filters)
21
+ const groups = new Set(normalized.map((f) => f.orGroup))
22
+ expect(groups.size).toBe(3)
23
+ expect(normalized).toEqual(
24
+ expect.arrayContaining([
25
+ expect.objectContaining({ field: 'id', op: 'ilike', value: '%berlin%' }),
26
+ expect.objectContaining({ field: 'title', op: 'ilike', value: '%berlin%' }),
27
+ expect.objectContaining({ field: 'location', op: 'ilike', value: '%berlin%' }),
28
+ ]),
29
+ )
30
+ })
31
+
32
+ it('lifts the ANDed org scope out of the search disjuncts as a common clause', () => {
33
+ const filters = {
34
+ organization_id: { $in: ['org-1'] },
35
+ $or: [
36
+ { title: { $ilike: '%abc%' } },
37
+ { location: { $ilike: '%abc%' } },
38
+ ],
39
+ }
40
+ const normalized = normalizeFilters(filters)
41
+ const common = normalized.filter((f) => !f.orGroup)
42
+ const disjuncts = normalized.filter((f) => f.orGroup)
43
+ expect(common).toEqual([
44
+ expect.objectContaining({ field: 'organization_id', op: 'in', value: ['org-1'] }),
45
+ ])
46
+ expect(new Set(disjuncts.map((f) => f.orGroup)).size).toBe(2)
47
+ expect(disjuncts.map((f) => f.field).sort()).toEqual(['location', 'title'])
48
+ })
49
+
50
+ it('keeps a single-field search as a plain AND clause (no orGroup)', () => {
51
+ const filters = { $or: [{ id: { $ilike: '%x%' } }] }
52
+ const normalized = normalizeFilters(filters)
53
+ expect(normalized).toEqual([
54
+ expect.objectContaining({ field: 'id', op: 'ilike', value: '%x%' }),
55
+ ])
56
+ expect(normalized.every((f) => !f.orGroup)).toBe(true)
57
+ })
58
+ })
@@ -518,18 +518,21 @@ export class BasicQueryEngine implements QueryEngine {
518
518
  group.push(f)
519
519
  groups.set(f.orGroup!, group)
520
520
  }
521
- const resolvedGroupFilters: Array<Array<{ qualified: string; op: string; value: unknown; fieldName: string }>> = []
521
+ type ResolvedOrClause =
522
+ | { kind: 'column'; qualified: string; op: NormalizedFilter['op']; value: unknown }
523
+ | { kind: 'doc'; field: string; op: NormalizedFilter['op']; value: unknown }
524
+ const resolvedGroupFilters: ResolvedOrClause[][] = []
522
525
  for (const [, groupFilters] of groups) {
523
- const resolved: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = []
526
+ const resolved: ResolvedOrClause[] = []
524
527
  for (const filter of groupFilters) {
525
528
  const column = await this.resolveBaseColumn(table, String(filter.field))
526
529
  if (column) {
527
- resolved.push({
528
- qualified: qualify(column),
529
- op: filter.op,
530
- value: filter.value,
531
- fieldName: String(filter.field),
532
- })
530
+ resolved.push({ kind: 'column', qualified: qualify(column), op: filter.op, value: filter.value })
531
+ } else {
532
+ // Field is not a base column — for custom-entity records it lives in
533
+ // entity_indexes.doc. Build an EXISTS sub-filter so `$or` searches
534
+ // across doc fields resolve instead of being silently dropped (#3229).
535
+ resolved.push({ kind: 'doc', field: String(filter.field), op: filter.op, value: filter.value })
533
536
  }
534
537
  }
535
538
  if (resolved.length > 0) resolvedGroupFilters.push(resolved)
@@ -537,7 +540,18 @@ export class BasicQueryEngine implements QueryEngine {
537
540
  if (resolvedGroupFilters.length > 0) {
538
541
  q = q.where((eb: any) => eb.or(
539
542
  resolvedGroupFilters.map((group) => {
540
- const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
543
+ const parts = group.map((rf) => rf.kind === 'column'
544
+ ? this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
545
+ : this.buildIndexDocOpExpression(eb, {
546
+ entity: String(entity),
547
+ field: rf.field,
548
+ op: rf.op,
549
+ value: rf.value,
550
+ recordIdColumn,
551
+ tenantId: opts.tenantId ?? null,
552
+ organizationScope: orgScope,
553
+ withDeleted: opts.withDeleted === true,
554
+ }))
541
555
  return parts.length === 1 ? parts[0] : eb.and(parts)
542
556
  })
543
557
  ))
@@ -1276,9 +1290,29 @@ export class BasicQueryEngine implements QueryEngine {
1276
1290
  return q
1277
1291
  }
1278
1292
 
1293
+ return q.where((eb: any) => this.buildIndexDocOpExpression(eb, opts))
1294
+ }
1295
+
1296
+ // Builds the entity_indexes EXISTS expression for a single doc-field operator,
1297
+ // shared by the regular doc-filter path (applyIndexDocFilter) and the OR-group
1298
+ // path so `$or` queries over custom-entity doc fields resolve instead of being
1299
+ // silently dropped (#3229).
1300
+ private buildIndexDocOpExpression(
1301
+ eb: any,
1302
+ opts: {
1303
+ entity: string
1304
+ field: string
1305
+ op: NormalizedFilter['op']
1306
+ value: unknown
1307
+ recordIdColumn: string
1308
+ tenantId?: string | null
1309
+ organizationScope?: { ids: string[]; includeNull: boolean } | null
1310
+ withDeleted: boolean
1311
+ }
1312
+ ): any {
1279
1313
  const alias = `ei_${this.searchAliasSeq++}`
1280
1314
  const engine = this
1281
- return q.where((eb: any) => eb.exists((() => {
1315
+ return eb.exists((() => {
1282
1316
  let sub: AnyBuilder = eb
1283
1317
  .selectFrom(`entity_indexes as ${alias}`)
1284
1318
  .select(sql<number>`1`.as('one'))
@@ -1331,7 +1365,7 @@ export class BasicQueryEngine implements QueryEngine {
1331
1365
  break
1332
1366
  }
1333
1367
  return sub
1334
- })()))
1368
+ })())
1335
1369
  }
1336
1370
 
1337
1371
  private configureCustomFieldSources(