@open-mercato/core 0.6.5-develop.4674.1.bf258550ce → 0.6.5-develop.4695.1.42ee0ddf0e
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +31 -0
- package/dist/helpers/integration/standaloneEnv.js +58 -0
- package/dist/helpers/integration/standaloneEnv.js.map +7 -0
- package/dist/helpers/integration/undoHarness.js +97 -2
- package/dist/helpers/integration/undoHarness.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +80 -83
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +79 -82
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +50 -24
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +28 -15
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js +31 -13
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -0
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/standaloneEnv.ts +62 -0
- package/src/helpers/integration/undoHarness.ts +132 -1
- package/src/modules/customers/AGENTS.md +1 -0
- package/src/modules/customers/commands/deals.ts +106 -111
- package/src/modules/entities/lib/helpers.ts +43 -21
- package/src/modules/query_index/lib/indexer.ts +71 -24
- package/src/modules/query_index/subscribers/delete_one.ts +36 -16
- package/src/modules/query_index/subscribers/upsert_one.ts +44 -15
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +11 -0
|
@@ -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
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
85
|
+
const coverageRefreshDelay = coverageDelayMs ?? 0
|
|
86
|
+
void (async () => {
|
|
79
87
|
try {
|
|
80
88
|
const bus = ctx.resolve<any>('eventBus')
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|