@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.
- package/dist/lib/crud/optimistic-lock-command.js +24 -1
- package/dist/lib/crud/optimistic-lock-command.js.map +2 -2
- package/dist/lib/crud/optimistic-lock.js +6 -2
- package/dist/lib/crud/optimistic-lock.js.map +2 -2
- package/dist/lib/di/container.js +11 -0
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/query/engine.js +22 -9
- package/dist/lib/query/engine.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/crud/__tests__/optimistic-lock-command-with-guards.test.ts +128 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +20 -9
- package/src/lib/crud/optimistic-lock-command.ts +60 -1
- package/src/lib/crud/optimistic-lock.ts +23 -2
- package/src/lib/di/container.ts +11 -0
- package/src/lib/query/__tests__/records-search-filter.test.ts +58 -0
- package/src/lib/query/engine.ts +45 -11
|
@@ -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)
|
|
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 "
|
|
476
|
+
throw new Error('column "deleted_at" does not exist')
|
|
477
477
|
},
|
|
478
478
|
} as never
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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 {
|
|
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
|
}
|
package/src/lib/di/container.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -518,18 +518,21 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
518
518
|
group.push(f)
|
|
519
519
|
groups.set(f.orGroup!, group)
|
|
520
520
|
}
|
|
521
|
-
|
|
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:
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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) =>
|
|
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
|
|
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(
|