@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
@@ -1,5 +1,7 @@
1
- import { type APIRequestContext, type APIResponse, expect } from '@playwright/test'
1
+ import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test'
2
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
3
  import { apiRequest } from './api'
4
+ import { expectId, readJsonSafe } from './generalFixtures'
3
5
 
4
6
  /**
5
7
  * Shared harness for verifying Undo/Redo correctness against the real command bus.
@@ -14,6 +16,7 @@ const HEADER_PREFIX = 'omop:'
14
16
  const UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'
15
17
  const REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'
16
18
  const ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'
19
+ export const UNDO_TESTS_DISABLED_ENV = 'OM_INTEGRATION_UNDO_TESTS_DISABLED'
17
20
 
18
21
  export type Operation = {
19
22
  logId: string
@@ -23,6 +26,26 @@ export type Operation = {
23
26
  resourceId: string | null
24
27
  }
25
28
 
29
+ export type CrudUndoEntityConfig = {
30
+ label: string
31
+ collectionPath: string
32
+ field: string
33
+ createPayload: (stamp: string) => Record<string, unknown>
34
+ updatePayload: (id: string, stamp: string) => Record<string, unknown>
35
+ readPath?: (id: string) => string
36
+ deletePath?: (id: string) => string
37
+ createStatus?: number
38
+ updateStatus?: number
39
+ }
40
+
41
+ export function undoTestsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
42
+ return parseBooleanWithDefault(env[UNDO_TESTS_DISABLED_ENV], false)
43
+ }
44
+
45
+ export function skipIfUndoTestsDisabled(): void {
46
+ test.skip(undoTestsDisabled(), `${UNDO_TESTS_DISABLED_ENV} is set — undo/redo integration tests skipped`)
47
+ }
48
+
26
49
  /** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */
27
50
  export function extractOperation(response: APIResponse): Operation | null {
28
51
  const header = response.headers()['x-om-operation']
@@ -109,3 +132,111 @@ export function assertFieldsEqual(
109
132
  ).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))
110
133
  }
111
134
  }
135
+
136
+ function findRecord(body: unknown, id: string): Record<string, unknown> | null {
137
+ if (!body || typeof body !== 'object') return null
138
+ if (!Array.isArray(body) && (body as Record<string, unknown>).id === id) {
139
+ return body as Record<string, unknown>
140
+ }
141
+ for (const value of Array.isArray(body) ? body : Object.values(body)) {
142
+ const found = findRecord(value, id)
143
+ if (found) return found
144
+ }
145
+ return null
146
+ }
147
+
148
+ async function readRecord(
149
+ request: APIRequestContext,
150
+ token: string,
151
+ entity: CrudUndoEntityConfig,
152
+ id: string,
153
+ ): Promise<Record<string, unknown> | null> {
154
+ const path = entity.readPath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`
155
+ const response = await apiRequest(request, 'GET', path, { token })
156
+ const body = await readJsonSafe(response)
157
+ if (!response.ok()) return null
158
+ return findRecord(body, id)
159
+ }
160
+
161
+ function fieldValue(record: Record<string, unknown> | null, field: string): unknown {
162
+ return record?.[field]
163
+ }
164
+
165
+ async function deleteEntity(
166
+ request: APIRequestContext,
167
+ token: string,
168
+ entity: CrudUndoEntityConfig,
169
+ id: string,
170
+ ): Promise<APIResponse> {
171
+ const path = entity.deletePath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`
172
+ return apiRequest(request, 'DELETE', path, { token })
173
+ }
174
+
175
+ export async function runCrudUndoRoundTrip(
176
+ request: APIRequestContext,
177
+ token: string,
178
+ entity: CrudUndoEntityConfig,
179
+ ): Promise<void> {
180
+ const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`
181
+ let createUndoId: string | null = null
182
+ let cycleId: string | null = null
183
+
184
+ try {
185
+ const createUndoRes = await apiRequest(request, 'POST', entity.collectionPath, {
186
+ token,
187
+ data: entity.createPayload(`${stamp}a`),
188
+ })
189
+ expect(createUndoRes.status(), `${entity.label} create-for-undo status`).toBe(entity.createStatus ?? 201)
190
+ const createUndoOp = expectOperation(createUndoRes, `${entity.label}.create`)
191
+ createUndoId = createUndoOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createUndoRes))?.id, `${entity.label} create id`)
192
+ expect(fieldValue(await readRecord(request, token, entity, createUndoId), entity.field), `${entity.label} field readable after create`).toBeDefined()
193
+
194
+ await undoOk(request, token, createUndoOp.undoToken, `${entity.label} undo create`)
195
+ expect(await readRecord(request, token, entity, createUndoId), `${entity.label} create→undo soft-deletes/removes the record (I3)`).toBeNull()
196
+ await expectTokenConsumed(request, token, createUndoOp.undoToken, `${entity.label} create token consumed (I5)`)
197
+
198
+ const createRes = await apiRequest(request, 'POST', entity.collectionPath, {
199
+ token,
200
+ data: entity.createPayload(`${stamp}b`),
201
+ })
202
+ expect(createRes.status(), `${entity.label} create status`).toBe(entity.createStatus ?? 201)
203
+ const createOp = expectOperation(createRes, `${entity.label}.create`)
204
+ cycleId = createOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createRes))?.id, `${entity.label} cycle id`)
205
+
206
+ const beforeUpdate = await readRecord(request, token, entity, cycleId)
207
+ const beforeValue = fieldValue(beforeUpdate, entity.field)
208
+ expect(beforeValue, `${entity.label} field readable before update`).toBeDefined()
209
+
210
+ const updateRes = await apiRequest(request, 'PUT', entity.collectionPath, {
211
+ token,
212
+ data: entity.updatePayload(cycleId, stamp),
213
+ })
214
+ expect(updateRes.status(), `${entity.label} update status`).toBe(entity.updateStatus ?? 200)
215
+ const updateOp = expectOperation(updateRes, `${entity.label}.update`)
216
+ const afterUpdate = await readRecord(request, token, entity, cycleId)
217
+ const afterUpdateValue = fieldValue(afterUpdate, entity.field)
218
+ expect(JSON.stringify(afterUpdateValue), `${entity.label} field changed by update`).not.toBe(JSON.stringify(beforeValue))
219
+
220
+ await new Promise((resolve) => setTimeout(resolve, 10))
221
+ await undoOk(request, token, updateOp.undoToken, `${entity.label} undo update`)
222
+ const afterUndo = await readRecord(request, token, entity, cycleId)
223
+ expect(JSON.stringify(fieldValue(afterUndo, entity.field)), `${entity.label} update→undo restores ${entity.field} (I1)`).toBe(JSON.stringify(beforeValue))
224
+ if (typeof beforeUpdate?.updatedAt === 'string' && typeof afterUndo?.updatedAt === 'string') {
225
+ expect(afterUndo.updatedAt, `${entity.label} undo bumps updatedAt`).not.toBe(beforeUpdate.updatedAt)
226
+ }
227
+
228
+ await redoOk(request, token, updateOp.logId, `${entity.label} redo update`)
229
+ expect(JSON.stringify(fieldValue(await readRecord(request, token, entity, cycleId), entity.field)), `${entity.label} redo re-applies update (I6)`).toBe(JSON.stringify(afterUpdateValue))
230
+
231
+ const deleteRes = await deleteEntity(request, token, entity, cycleId)
232
+ expect(deleteRes.ok(), `${entity.label} delete status ${deleteRes.status()}`).toBeTruthy()
233
+ const deleteOp = expectOperation(deleteRes, `${entity.label}.delete`)
234
+ expect(await readRecord(request, token, entity, cycleId), `${entity.label} deleted record should not read`).toBeNull()
235
+
236
+ await undoOk(request, token, deleteOp.undoToken, `${entity.label} undo delete`)
237
+ expect(fieldValue(await readRecord(request, token, entity, cycleId), entity.field), `${entity.label} delete→undo re-materializes (I2)`).toBeDefined()
238
+ } finally {
239
+ if (createUndoId) await deleteEntity(request, token, entity, createUndoId).catch(() => {})
240
+ if (cycleId) await deleteEntity(request, token, entity, cycleId).catch(() => {})
241
+ }
242
+ }
@@ -87,6 +87,7 @@ Commands (`commands/people.ts`) demonstrate:
87
87
  3. Restore via `buildCustomFieldResetMap(before.custom, after.custom)` in undo
88
88
  4. Side effects with `emitCrudSideEffects` and `emitCrudUndoSideEffects`
89
89
  5. Include `indexer: { entityType, cacheAliases }` in both directions
90
+ 6. **Prefer `runCrudCommandWrite` for new commands** that combine entity writes + custom fields + side effects in one logical step. Reference: the migrated `updateDealCommand.execute` in `commands/deals.ts`. See `packages/core/AGENTS.md` → Entity Update Safety for the contract and `packages/shared/AGENTS.md` → `commands/runCrudCommandWrite` for the import.
90
91
 
91
92
  ## Transaction Safety
92
93
 
@@ -9,6 +9,7 @@ import {
9
9
  normalizeAuthorUserId,
10
10
  } from '@open-mercato/shared/lib/commands/helpers'
11
11
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
12
+ import { runCrudCommandWrite } from '@open-mercato/shared/lib/commands/runCrudCommandWrite'
12
13
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
13
14
  import type { EntityManager } from '@mikro-orm/postgresql'
14
15
  import {
@@ -569,120 +570,114 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
569
570
  let nextPipelineStageLabel: string | null = null
570
571
  let resolvedCurrentPipelineStageLabel: string | null = null
571
572
 
572
- await withAtomicFlush(em, [
573
- async () => {
574
- const pipelineAssignmentChanged =
575
- parsed.pipelineId !== undefined || parsed.pipelineStageId !== undefined
576
- const requestedPipelineStageId =
577
- parsed.pipelineStageId !== undefined
578
- ? parsed.pipelineStageId ?? null
579
- : record.pipelineStageId ?? null
580
- const requestedPipelineId =
581
- parsed.pipelineId !== undefined ? parsed.pipelineId ?? null : record.pipelineId ?? null
582
-
583
- nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage)
584
- ? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId)
585
- : null
586
- if (pipelineAssignmentChanged) {
587
- nextPipelineAssignment = resolvePipelineAssignment({
588
- pipelineId: requestedPipelineId,
589
- pipelineStageId: requestedPipelineStageId,
590
- stageSnapshot: nextStageSnapshot,
591
- })
592
- }
593
- nextPipelineStageLabel = nextStageSnapshot
594
- ? (await ensureDictionaryEntry(em, {
595
- tenantId: record.tenantId,
596
- organizationId: record.organizationId,
597
- kind: 'pipeline_stage',
598
- value: nextStageSnapshot.label,
599
- }))?.value ?? nextStageSnapshot.label
600
- : null
601
- resolvedCurrentPipelineStageLabel =
602
- !nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== undefined || !record.pipelineStage)
603
- ? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId)
604
- : null
605
- },
606
- () => {
607
- if (parsed.title !== undefined) record.title = parsed.title
608
- if (parsed.description !== undefined) record.description = parsed.description ?? null
609
- if (parsed.status !== undefined) record.status = parsed.status ?? record.status
610
- if (parsed.pipelineStage !== undefined) record.pipelineStage = parsed.pipelineStage ?? null
611
- if (parsed.pipelineId !== undefined || (parsed.pipelineStageId !== undefined && nextStageSnapshot)) {
612
- record.pipelineId = nextPipelineAssignment.pipelineId
613
- }
614
- if (parsed.pipelineStageId !== undefined) record.pipelineStageId = nextPipelineAssignment.pipelineStageId
615
-
616
- if (nextPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
617
- record.pipelineStage = nextPipelineStageLabel
618
- } else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
619
- record.pipelineStage = resolvedCurrentPipelineStageLabel
620
- }
621
-
622
- if (parsed.valueAmount !== undefined) record.valueAmount = toNumericString(parsed.valueAmount)
623
- if (parsed.valueCurrency !== undefined) record.valueCurrency = parsed.valueCurrency ?? null
624
- if (parsed.probability !== undefined) record.probability = parsed.probability ?? null
625
- if (parsed.expectedCloseAt !== undefined) record.expectedCloseAt = parsed.expectedCloseAt ?? null
626
- if (parsed.ownerUserId !== undefined) record.ownerUserId = parsed.ownerUserId ?? null
627
- if (parsed.source !== undefined) record.source = parsed.source ?? null
628
- if (parsed.closureOutcome !== undefined) record.closureOutcome = parsed.closureOutcome ?? null
629
- if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
630
- if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
631
- },
632
- async () => {
633
- // CRITICAL: persist the scalar mutations above before any further `em.findOne` / sync
634
- // helpers run inside this transaction. MikroORM v7's identity-map silently discards
635
- // pending scalar changes on `record` if a query (such as the stage-transition lookup
636
- // inside `upsertDealStageTransition`, or the linked-entity finds inside
637
- // `syncDealPeople` / `syncDealCompanies`) executes on the same `EntityManager`
638
- // before we explicitly flush. Without this flush, the entire kanban drag-and-drop
639
- // returns 200 OK but never actually updates `customer_deals` rows — the card
640
- // snaps back to its source lane on the next refetch (see SPEC-018).
641
- await em.flush()
642
- },
643
- async () => {
644
- const snapshot = nextStageSnapshot
645
- if (!snapshot) return
646
- const shouldRecord =
647
- parsed.pipelineStageId !== undefined &&
648
- parsed.pipelineStageId !== null &&
649
- parsed.pipelineStageId !== previousPipelineStageId
650
- if (!shouldRecord) return
651
- await upsertDealStageTransition(em, {
652
- deal: record,
653
- pipelineId: snapshot.pipelineId,
654
- stageId: snapshot.id,
655
- stageLabel: nextPipelineStageLabel ?? snapshot.label,
656
- stageOrder: snapshot.order,
657
- transitionedByUserId: normalizedTransitionAuthorUserId,
658
- })
659
- },
660
- () => syncDealPeople(em, record, parsed.personIds),
661
- () => syncDealCompanies(em, record, parsed.companyIds),
662
- ], { transaction: true })
663
-
664
- const de = (ctx.container.resolve('dataEngine') as DataEngine)
665
- await setCustomFieldsIfAny({
666
- dataEngine: de,
573
+ await runCrudCommandWrite({
574
+ ctx,
575
+ em,
667
576
  entityId: DEAL_ENTITY_ID,
668
- recordId: record.id,
669
- organizationId: record.organizationId,
670
- tenantId: record.tenantId,
671
- values: custom,
672
- notify: false,
673
- })
674
-
675
- await emitCrudSideEffects({
676
- dataEngine: de,
677
577
  action: 'updated',
678
- entity: record,
679
- identifiers: {
680
- id: record.id,
681
- organizationId: record.organizationId,
682
- tenantId: record.tenantId,
683
- },
684
- indexer: dealCrudIndexer,
578
+ scope: { tenantId: record.tenantId, organizationId: record.organizationId },
579
+ customFields: custom,
685
580
  events: dealCrudEvents,
581
+ indexer: dealCrudIndexer,
582
+ sideEffect: () => ({
583
+ entity: record,
584
+ identifiers: {
585
+ id: record.id,
586
+ organizationId: record.organizationId,
587
+ tenantId: record.tenantId,
588
+ },
589
+ }),
590
+ phases: [
591
+ async () => {
592
+ const pipelineAssignmentChanged =
593
+ parsed.pipelineId !== undefined || parsed.pipelineStageId !== undefined
594
+ const requestedPipelineStageId =
595
+ parsed.pipelineStageId !== undefined
596
+ ? parsed.pipelineStageId ?? null
597
+ : record.pipelineStageId ?? null
598
+ const requestedPipelineId =
599
+ parsed.pipelineId !== undefined ? parsed.pipelineId ?? null : record.pipelineId ?? null
600
+
601
+ nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage)
602
+ ? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId)
603
+ : null
604
+ if (pipelineAssignmentChanged) {
605
+ nextPipelineAssignment = resolvePipelineAssignment({
606
+ pipelineId: requestedPipelineId,
607
+ pipelineStageId: requestedPipelineStageId,
608
+ stageSnapshot: nextStageSnapshot,
609
+ })
610
+ }
611
+ nextPipelineStageLabel = nextStageSnapshot
612
+ ? (await ensureDictionaryEntry(em, {
613
+ tenantId: record.tenantId,
614
+ organizationId: record.organizationId,
615
+ kind: 'pipeline_stage',
616
+ value: nextStageSnapshot.label,
617
+ }))?.value ?? nextStageSnapshot.label
618
+ : null
619
+ resolvedCurrentPipelineStageLabel =
620
+ !nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== undefined || !record.pipelineStage)
621
+ ? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId)
622
+ : null
623
+ },
624
+ () => {
625
+ if (parsed.title !== undefined) record.title = parsed.title
626
+ if (parsed.description !== undefined) record.description = parsed.description ?? null
627
+ if (parsed.status !== undefined) record.status = parsed.status ?? record.status
628
+ if (parsed.pipelineStage !== undefined) record.pipelineStage = parsed.pipelineStage ?? null
629
+ if (parsed.pipelineId !== undefined || (parsed.pipelineStageId !== undefined && nextStageSnapshot)) {
630
+ record.pipelineId = nextPipelineAssignment.pipelineId
631
+ }
632
+ if (parsed.pipelineStageId !== undefined) record.pipelineStageId = nextPipelineAssignment.pipelineStageId
633
+
634
+ if (nextPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
635
+ record.pipelineStage = nextPipelineStageLabel
636
+ } else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
637
+ record.pipelineStage = resolvedCurrentPipelineStageLabel
638
+ }
639
+
640
+ if (parsed.valueAmount !== undefined) record.valueAmount = toNumericString(parsed.valueAmount)
641
+ if (parsed.valueCurrency !== undefined) record.valueCurrency = parsed.valueCurrency ?? null
642
+ if (parsed.probability !== undefined) record.probability = parsed.probability ?? null
643
+ if (parsed.expectedCloseAt !== undefined) record.expectedCloseAt = parsed.expectedCloseAt ?? null
644
+ if (parsed.ownerUserId !== undefined) record.ownerUserId = parsed.ownerUserId ?? null
645
+ if (parsed.source !== undefined) record.source = parsed.source ?? null
646
+ if (parsed.closureOutcome !== undefined) record.closureOutcome = parsed.closureOutcome ?? null
647
+ if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
648
+ if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
649
+ },
650
+ async () => {
651
+ // CRITICAL: persist the scalar mutations above before any further `em.findOne` / sync
652
+ // helpers run inside this transaction. MikroORM v7's identity-map silently discards
653
+ // pending scalar changes on `record` if a query (such as the stage-transition lookup
654
+ // inside `upsertDealStageTransition`, or the linked-entity finds inside
655
+ // `syncDealPeople` / `syncDealCompanies`) executes on the same `EntityManager`
656
+ // before we explicitly flush. Without this flush, the entire kanban drag-and-drop
657
+ // returns 200 OK but never actually updates `customer_deals` rows — the card
658
+ // snaps back to its source lane on the next refetch (see SPEC-018).
659
+ await em.flush()
660
+ },
661
+ async () => {
662
+ const snapshot = nextStageSnapshot
663
+ if (!snapshot) return
664
+ const shouldRecord =
665
+ parsed.pipelineStageId !== undefined &&
666
+ parsed.pipelineStageId !== null &&
667
+ parsed.pipelineStageId !== previousPipelineStageId
668
+ if (!shouldRecord) return
669
+ await upsertDealStageTransition(em, {
670
+ deal: record,
671
+ pipelineId: snapshot.pipelineId,
672
+ stageId: snapshot.id,
673
+ stageLabel: nextPipelineStageLabel ?? snapshot.label,
674
+ stageOrder: snapshot.order,
675
+ transitionedByUserId: normalizedTransitionAuthorUserId,
676
+ })
677
+ },
678
+ () => syncDealPeople(em, record, parsed.personIds),
679
+ () => syncDealCompanies(em, record, parsed.companyIds),
680
+ ],
686
681
  })
687
682
 
688
683
  // Emit a lifecycle event for deal won/lost status changes; the notifications
@@ -118,6 +118,30 @@ export async function setRecordCustomFields(
118
118
  throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)
119
119
  }
120
120
 
121
+ // Run the per-key delete+insert work inside ONE database transaction so a
122
+ // multi-value replacement is atomic and isolated. The array branch deletes the
123
+ // existing rows for a key and inserts the replacements; without an enclosing
124
+ // transaction those can land in separate commit boundaries under MikroORM's
125
+ // FlushMode.AUTO (a query elsewhere in the unit auto-flushes part of the work),
126
+ // which intermittently left the field with the delete applied but the inserts
127
+ // missing — the multi-select EDIT reverted to []. The single commit below makes
128
+ // it all-or-nothing. We only open our own transaction when the caller has not
129
+ // already started one (commands fork the request em and may run setCustomFields
130
+ // outside their own withAtomicFlush tx); join an ambient transaction otherwise.
131
+ const txEm = em as {
132
+ begin?: () => Promise<void>
133
+ commit?: () => Promise<void>
134
+ rollback?: () => Promise<void>
135
+ isInTransaction?: () => boolean
136
+ }
137
+ const txCapable =
138
+ typeof txEm.begin === 'function' &&
139
+ typeof txEm.commit === 'function' &&
140
+ typeof txEm.rollback === 'function' &&
141
+ typeof txEm.isInTransaction === 'function'
142
+ const ownCustomFieldTransaction = txCapable && !txEm.isInTransaction!()
143
+ if (ownCustomFieldTransaction) await txEm.begin!()
144
+ try {
121
145
  for (const fieldKey of keys) {
122
146
  const raw = values[fieldKey]
123
147
  if (raw === undefined) continue
@@ -125,10 +149,19 @@ export async function setRecordCustomFields(
125
149
  const def = defsByKey?.[fieldKey]
126
150
  const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
127
151
  const isArray = Array.isArray(raw)
128
- // When array: remove existing values for key and create multiple rows
152
+ // When array (multi-value): replace all existing rows for the key. The old
153
+ // rows are removed via em.remove (a DEFERRED delete keyed by primary id),
154
+ // not nativeDelete, so the DELETE and the replacement INSERTs are applied in
155
+ // the SAME em.flush() — MikroORM wraps a flush in one transaction, making the
156
+ // replacement atomic (the field can never be left empty by a partial
157
+ // failure between delete and insert). It also removes the FlushMode.AUTO
158
+ // footgun the old code had: a nativeDelete issued after em.create()
159
+ // auto-flushed the new rows and then deleted them by fieldKey, wiping the
160
+ // value on EDIT. Regression: TC-CAT-CF-MULTI-EDIT-001 / TC-CRM-CF-MULTI-EDIT-001.
129
161
  if (isArray) {
130
162
  const arr = raw as Primitive[]
131
- const replacements: CustomFieldValue[] = []
163
+ const existing = await em.find(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey })
164
+ for (const stale of existing) em.remove(stale)
132
165
  for (const val of arr) {
133
166
  const col: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(val)
134
167
  const cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })
@@ -144,10 +177,8 @@ export async function setRecordCustomFields(
144
177
  case 'valueBool': cf.valueBool = stored == null ? null : Boolean(stored); break
145
178
  default: cf.valueText = stored == null ? null : String(stored); break
146
179
  }
147
- replacements.push(cf)
180
+ toPersist.push(cf)
148
181
  }
149
- await em.nativeDelete(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey })
150
- toPersist.push(...replacements)
151
182
  continue
152
183
  }
153
184
 
@@ -186,24 +217,15 @@ export async function setRecordCustomFields(
186
217
 
187
218
  if (toPersist.length) em.persist(toPersist)
188
219
  await em.flush()
189
- if (process.env.OM_CF_DEBUG) {
190
- try {
191
- const conn = em.getConnection()
192
- for (const fieldKey of keys) {
193
- if (values[fieldKey] === undefined) continue
194
- const rows = await conn.execute(
195
- 'select value_text, value_multiline, value_int, value_float, value_bool from custom_field_values where entity_id = ? and record_id = ? and field_key = ? and ((organization_id is null and ? is null) or organization_id = ?) and ((tenant_id is null and ? is null) or tenant_id = ?)',
196
- [entityId, recordId, fieldKey, organizationId, organizationId, tenantId, tenantId],
197
- 'all',
198
- ) as Array<Record<string, unknown>>
199
- const persisted = rows.map((row) => row.value_text ?? row.value_multiline ?? row.value_int ?? row.value_float ?? row.value_bool)
200
- console.warn(`[CF_DEBUG] setRecordCustomFields entityId=${entityId} recordId=${recordId} fieldKey=${fieldKey} input=${JSON.stringify(values[fieldKey])} persistedRows=${rows.length} persisted=${JSON.stringify(persisted)}`)
201
- }
202
- } catch (err) {
203
- console.warn(`[CF_DEBUG] re-query failed: ${(err as Error)?.message ?? String(err)}`)
220
+ if (ownCustomFieldTransaction) await txEm.commit!()
221
+ } catch (err) {
222
+ if (ownCustomFieldTransaction) {
223
+ try { await txEm.rollback!() } catch { /* surface the original error, not a rollback failure */ }
204
224
  }
225
+ throw err
205
226
  }
206
- // Emit hook for indexing if requested (outside CRUD flows)
227
+ // Emit hook for indexing if requested (outside CRUD flows). Runs AFTER the
228
+ // transaction commits so consumers observe the persisted rows.
207
229
  try {
208
230
  if (typeof opts.onChanged === 'function') {
209
231
  await opts.onChanged({ entityId, recordId, organizationId, tenantId })
@@ -158,7 +158,7 @@ function scopeEntityIndexes<QB extends { where: (...args: any[]) => QB }>(
158
158
 
159
159
  export async function upsertIndexRow(
160
160
  em: EntityManager,
161
- args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null }
161
+ args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean }
162
162
  ): Promise<UpsertIndexResult> {
163
163
  const db = (em as any).getKysely()
164
164
 
@@ -172,14 +172,19 @@ export async function upsertIndexRow(
172
172
 
173
173
  const doc = await buildIndexDoc(em, args)
174
174
  if (!doc) {
175
- try {
176
- await deleteSearchTokensForRecord(db, {
177
- entityType: args.entityType,
178
- recordId: args.recordId,
179
- organizationId: args.organizationId ?? null,
180
- tenantId: args.tenantId ?? null,
181
- })
182
- } catch {}
175
+ // When the caller defers token work it owns the matching token cleanup; the
176
+ // projection-row removal below stays synchronous so list reads converge immediately.
177
+ if (!args.deferSearchTokens) {
178
+ try {
179
+ await reindexSearchTokensForRecord(em, {
180
+ entityType: args.entityType,
181
+ recordId: args.recordId,
182
+ organizationId: args.organizationId ?? null,
183
+ tenantId: args.tenantId ?? null,
184
+ doc: null,
185
+ })
186
+ } catch {}
187
+ }
183
188
  if (existed) {
184
189
  await scopeEntityIndexes(
185
190
  db.deleteFrom('entity_indexes' as any) as any,
@@ -233,27 +238,69 @@ export async function upsertIndexRow(
233
238
 
234
239
  const created = !existed
235
240
  const revived = existed && wasDeleted
236
- try {
237
- const tokenDoc = args.searchTokenDoc ?? (() => {
238
- const encryption = resolveTenantEncryptionService(em as any)
239
- const dekKeyCache = new Map<string | null, string | null>()
240
- return decryptIndexDocForSearch(
241
- args.entityType,
241
+ // The search-token rebuild (DELETE + chunked INSERT) is the heavy tail of indexing.
242
+ // Callers that defer it (the upsert subscriber) run `reindexSearchTokensForRecord`
243
+ // asynchronously after this projection update so write latency stays bounded.
244
+ if (!args.deferSearchTokens) {
245
+ try {
246
+ await reindexSearchTokensForRecord(em, {
247
+ entityType: args.entityType,
248
+ recordId: args.recordId,
249
+ organizationId: args.organizationId ?? null,
250
+ tenantId: args.tenantId ?? null,
242
251
  doc,
243
- { tenantId: args.tenantId ?? null, organizationId: args.organizationId ?? null },
244
- encryption,
245
- dekKeyCache,
246
- )
247
- })()
248
- await replaceSearchTokensForRecord(db, {
252
+ searchTokenDoc: args.searchTokenDoc ?? null,
253
+ })
254
+ } catch {}
255
+ }
256
+ return { doc, existed, wasDeleted, created, revived }
257
+ }
258
+
259
+ /**
260
+ * Rebuilds (or clears, when `doc` is null) the search-token rows for a single record.
261
+ * This is the asynchronous-friendly tail of `upsertIndexRow`: it does not touch the
262
+ * `entity_indexes` projection that list endpoints read, so it can run out-of-band
263
+ * without making query-index reads inconsistent.
264
+ */
265
+ export async function reindexSearchTokensForRecord(
266
+ em: EntityManager,
267
+ args: {
268
+ entityType: string
269
+ recordId: string
270
+ organizationId?: string | null
271
+ tenantId?: string | null
272
+ doc: Record<string, any> | null
273
+ searchTokenDoc?: Record<string, unknown> | null
274
+ },
275
+ ): Promise<void> {
276
+ const db = (em as any).getKysely()
277
+ if (!args.doc) {
278
+ await deleteSearchTokensForRecord(db, {
249
279
  entityType: args.entityType,
250
280
  recordId: args.recordId,
251
281
  organizationId: args.organizationId ?? null,
252
282
  tenantId: args.tenantId ?? null,
253
- doc: await tokenDoc,
254
283
  })
255
- } catch {}
256
- return { doc, existed, wasDeleted, created, revived }
284
+ return
285
+ }
286
+ const tokenDoc = args.searchTokenDoc ?? (() => {
287
+ const encryption = resolveTenantEncryptionService(em as any)
288
+ const dekKeyCache = new Map<string | null, string | null>()
289
+ return decryptIndexDocForSearch(
290
+ args.entityType,
291
+ args.doc,
292
+ { tenantId: args.tenantId ?? null, organizationId: args.organizationId ?? null },
293
+ encryption,
294
+ dekKeyCache,
295
+ )
296
+ })()
297
+ await replaceSearchTokensForRecord(db, {
298
+ entityType: args.entityType,
299
+ recordId: args.recordId,
300
+ organizationId: args.organizationId ?? null,
301
+ tenantId: args.tenantId ?? null,
302
+ doc: await tokenDoc,
303
+ })
257
304
  }
258
305
 
259
306
  export async function markDeleted(