@open-mercato/core 0.6.5-develop.4670.1.afe50dfd5c → 0.6.5-develop.4691.1.bb409545b3

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +31 -0
  3. package/dist/helpers/integration/standaloneEnv.js +58 -0
  4. package/dist/helpers/integration/standaloneEnv.js.map +7 -0
  5. package/dist/helpers/integration/undoHarness.js +97 -2
  6. package/dist/helpers/integration/undoHarness.js.map +2 -2
  7. package/dist/modules/customers/commands/deals.js +80 -83
  8. package/dist/modules/customers/commands/deals.js.map +2 -2
  9. package/dist/modules/entities/lib/helpers.js +79 -82
  10. package/dist/modules/entities/lib/helpers.js.map +2 -2
  11. package/dist/modules/query_index/lib/indexer.js +50 -24
  12. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  13. package/dist/modules/query_index/subscribers/delete_one.js +28 -15
  14. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  15. package/dist/modules/query_index/subscribers/upsert_one.js +31 -13
  16. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  17. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -0
  18. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  19. package/dist/modules/workflows/lib/workflow-executor.js +15 -0
  20. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  21. package/package.json +7 -7
  22. package/src/helpers/integration/standaloneEnv.ts +62 -0
  23. package/src/helpers/integration/undoHarness.ts +132 -1
  24. package/src/modules/customers/AGENTS.md +1 -0
  25. package/src/modules/customers/commands/deals.ts +106 -111
  26. package/src/modules/entities/lib/helpers.ts +43 -21
  27. package/src/modules/query_index/lib/indexer.ts +71 -24
  28. package/src/modules/query_index/subscribers/delete_one.ts +36 -16
  29. package/src/modules/query_index/subscribers/upsert_one.ts +44 -15
  30. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +11 -0
  31. package/src/modules/workflows/lib/workflow-executor.ts +17 -0
@@ -8,7 +8,11 @@ import { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/sub
8
8
  export const metadata = { event: 'query_index.delete_one', persistent: false }
9
9
 
10
10
  export default async function handle(payload: any, ctx: { resolve: <T=any>(name: string) => T }) {
11
- const em = ctx.resolve<any>('em')
11
+ // Forked EntityManager — this awaited subscriber runs synchronously on the request
12
+ // `em`; isolating it prevents our queries/writes from resetting the originating CRUD
13
+ // write's UnitOfWork and dropping its pending changes. See upsert_one.ts for detail.
14
+ const baseEm = ctx.resolve<any>('em')
15
+ const em = typeof baseEm?.fork === 'function' ? baseEm.fork() : baseEm
12
16
  const entityType = String(payload?.entityType || '')
13
17
  const recordId = String(payload?.recordId || '')
14
18
  if (!entityType || !recordId) return
@@ -73,24 +77,40 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
73
77
  }
74
78
  }
75
79
 
80
+ // The projection row + token removal above are synchronous (the data engine
81
+ // awaits this subscriber) so list reads are consistent immediately. The coverage
82
+ // recompute (a COUNT, run inline when delayMs is 0) and the fulltext delete are
83
+ // secondary, so defer them fire-and-forget to keep write/bulk-delete latency bounded.
76
84
  const shouldRefreshCoverage = coverageDelayMs === undefined || coverageDelayMs >= 0
77
- if (shouldRefreshCoverage) {
78
- const delay = coverageDelayMs ?? 0
85
+ const coverageRefreshDelay = coverageDelayMs ?? 0
86
+ void (async () => {
79
87
  try {
80
88
  const bus = ctx.resolve<any>('eventBus')
81
- await bus.emitEvent('query_index.coverage.refresh', {
82
- entityType,
83
- tenantId: tenantId ?? null,
84
- organizationId: organizationId ?? null,
85
- delayMs: delay,
86
- })
87
- } catch {}
88
- }
89
- // Emit search delete event
90
- try {
91
- const bus = ctx.resolve<any>('eventBus')
92
- await bus.emitEvent('search.delete_record', { entityId: entityType, recordId, organizationId, tenantId })
93
- } catch {}
89
+ if (shouldRefreshCoverage) {
90
+ await bus.emitEvent('query_index.coverage.refresh', {
91
+ entityType,
92
+ tenantId: tenantId ?? null,
93
+ organizationId: organizationId ?? null,
94
+ delayMs: coverageRefreshDelay,
95
+ })
96
+ }
97
+ await bus.emitEvent('search.delete_record', { entityId: entityType, recordId, organizationId, tenantId })
98
+ } catch (error) {
99
+ await recordIndexerError(
100
+ { em },
101
+ {
102
+ source: 'query_index',
103
+ handler: 'event:query_index.delete_one:coverage_search',
104
+ error,
105
+ entityType,
106
+ recordId,
107
+ tenantId: tenantId ?? null,
108
+ organizationId: organizationId ?? null,
109
+ payload,
110
+ },
111
+ ).catch(() => {})
112
+ }
113
+ })()
94
114
  } catch (error) {
95
115
  await recordIndexerError(
96
116
  { em },
@@ -1,12 +1,20 @@
1
1
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
2
- import { upsertIndexRow } from '../lib/indexer'
2
+ import { upsertIndexRow, reindexSearchTokensForRecord } from '../lib/indexer'
3
3
  import { applyCoverageAdjustments, createCoverageAdjustments } from '../lib/coverage'
4
4
  import { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/subscriber-scope'
5
5
 
6
6
  export const metadata = { event: 'query_index.upsert_one', persistent: false }
7
7
 
8
8
  export default async function handle(payload: any, ctx: { resolve: <T=any>(name: string) => T }) {
9
- const em = ctx.resolve<any>('em')
9
+ // Run index maintenance on a FORKED EntityManager (fresh identity map + UnitOfWork)
10
+ // so it can never disturb the originating CRUD write's `em`. The data engine awaits
11
+ // this emit for read-your-writes consistency, which means the subscriber runs
12
+ // synchronously on the request `em`; sharing it would let our `em.find` / raw
13
+ // `getKysely()` queries reset the caller's UoW change-tracking and silently drop the
14
+ // caller's pending write (e.g. the deal's `setCustomFields` insert). The fork reads
15
+ // the same committed DB rows via the shared connection but keeps its own UoW.
16
+ const baseEm = ctx.resolve<any>('em')
17
+ const em = typeof baseEm?.fork === 'function' ? baseEm.fork() : baseEm
10
18
  const entityType = String(payload?.entityType || '')
11
19
  const recordId = String(payload?.recordId || '')
12
20
  if (!entityType || !recordId) return
@@ -28,14 +36,18 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
28
36
  organizationId = resolvedScope.organizationId
29
37
  tenantId = resolvedScope.tenantId
30
38
 
39
+ const searchTokenDoc = typeof payload?.searchTokenDoc === 'object' && payload.searchTokenDoc && !Array.isArray(payload.searchTokenDoc)
40
+ ? (payload.searchTokenDoc as Record<string, unknown>)
41
+ : null
42
+ // Update the projection row synchronously so list reads (`customValues`) are
43
+ // consistent the moment the write returns; defer the heavy search-token rebuild.
31
44
  const result = await upsertIndexRow(em, {
32
45
  entityType,
33
46
  recordId,
34
47
  organizationId,
35
48
  tenantId,
36
- searchTokenDoc: typeof payload?.searchTokenDoc === 'object' && payload.searchTokenDoc && !Array.isArray(payload.searchTokenDoc)
37
- ? payload.searchTokenDoc
38
- : null,
49
+ searchTokenDoc,
50
+ deferSearchTokens: true,
39
51
  })
40
52
  if (!suppressCoverage) {
41
53
  const doc = result.doc
@@ -84,16 +96,33 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
84
96
  } catch {}
85
97
  }
86
98
  }
87
- // Kick off secondary pass (vectorize) asynchronously
88
- try {
89
- const bus = ctx.resolve<any>('eventBus')
90
- await bus.emitEvent('query_index.vectorize_one', { entityType, recordId, organizationId, tenantId })
91
- } catch {}
92
- // Emit search indexing event
93
- try {
94
- const bus = ctx.resolve<any>('eventBus')
95
- await bus.emitEvent('search.index_record', { entityId: entityType, recordId, organizationId, tenantId })
96
- } catch {}
99
+ // Defer the heavy, eventually-consistent tail: search-token rebuild + vectorize +
100
+ // fulltext indexing. The data engine awaits this subscriber for projection
101
+ // consistency, so this work runs fire-and-forget to keep write latency bounded.
102
+ const deferredScope = { entityType, recordId, organizationId, tenantId }
103
+ const resolvedDoc = result.doc
104
+ void (async () => {
105
+ try {
106
+ await reindexSearchTokensForRecord(em, { ...deferredScope, doc: resolvedDoc, searchTokenDoc })
107
+ const bus = ctx.resolve<any>('eventBus')
108
+ await bus.emitEvent('query_index.vectorize_one', deferredScope)
109
+ await bus.emitEvent('search.index_record', { entityId: entityType, recordId, organizationId, tenantId })
110
+ } catch (error) {
111
+ await recordIndexerError(
112
+ { em },
113
+ {
114
+ source: 'query_index',
115
+ handler: 'event:query_index.upsert_one:search_tokens',
116
+ error,
117
+ entityType,
118
+ recordId,
119
+ tenantId: tenantId ?? null,
120
+ organizationId: organizationId ?? null,
121
+ payload,
122
+ },
123
+ ).catch(() => {})
124
+ }
125
+ })()
97
126
  } catch (error) {
98
127
  await recordIndexerError(
99
128
  { em },
@@ -85,6 +85,13 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
85
85
  const searchParams = useSearchParams()
86
86
  const [initialValues, setInitialValues] = React.useState<Record<string, unknown> | null>(null)
87
87
  const [isNotFound, setIsNotFound] = React.useState(false)
88
+ // Capture the record (incl. its optimistic-lock `updatedAt`) exactly ONCE per
89
+ // resource. The load effect's deps include identity-unstable values (`t`,
90
+ // `resolveFieldsetCode`), so without this guard a re-render would re-fetch and
91
+ // overwrite `initialValues.updatedAt` with a newer server value mid-edit —
92
+ // silently defeating optimistic locking (a concurrent change would no longer be
93
+ // detected) and making the conflict flaky.
94
+ const loadedResourceIdRef = React.useRef<string | null>(null)
88
95
  const [tags, setTags] = React.useState<TagOption[]>([])
89
96
  const [activeTab, setActiveTab] = React.useState<'details' | 'availability'>('details')
90
97
  const [activeDetailTab, setActiveDetailTab] = React.useState<'notes' | 'activities'>('notes')
@@ -413,6 +420,9 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
413
420
 
414
421
  React.useEffect(() => {
415
422
  if (!resourceId || !resourceTypesLoaded) return
423
+ // Load once per resource — never re-fetch (and thereby refresh the captured
424
+ // optimistic-lock token) on subsequent re-renders. See loadedResourceIdRef.
425
+ if (loadedResourceIdRef.current === resourceId) return
416
426
  setIsNotFound(false)
417
427
  let cancelled = false
418
428
  async function loadResource() {
@@ -429,6 +439,7 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
429
439
  return
430
440
  }
431
441
  if (!cancelled) {
442
+ loadedResourceIdRef.current = resourceId ?? null
432
443
  const customValues = extractCustomFieldEntries(resource)
433
444
  setTags(Array.isArray(resource.tags) ? resource.tags : [])
434
445
  setAvailabilityRuleSetId(
@@ -433,6 +433,14 @@ export async function executeWorkflow(
433
433
  console.error(`[WORKFLOW] Transition rejected (instance: ${currentInstance.id}, workflow: ${currentInstance.workflowId}, step: ${currentInstance.currentStepId} → ${selectedTransition.toStepId}): ${rejectionMessage}`)
434
434
  errors.push(rejectionMessage)
435
435
 
436
+ await completeWorkflow(trx, container, instanceId, 'FAILED', {
437
+ error: rejectionMessage,
438
+ })
439
+ events.push({
440
+ eventType: 'WORKFLOW_FAILED',
441
+ occurredAt: new Date(),
442
+ })
443
+
436
444
  return {
437
445
  status: 'FAILED',
438
446
  currentStep: currentInstance.currentStepId,
@@ -501,6 +509,15 @@ export async function executeWorkflow(
501
509
  },
502
510
  })
503
511
 
512
+ await completeWorkflow(trx, container, instanceId, 'FAILED', {
513
+ error: errorMessage,
514
+ details: error instanceof WorkflowExecutionError ? error.details : undefined,
515
+ })
516
+ events.push({
517
+ eventType: 'WORKFLOW_FAILED',
518
+ occurredAt: new Date(),
519
+ })
520
+
504
521
  return {
505
522
  status: 'FAILED',
506
523
  currentStep: currentInstance.currentStepId,