@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.
Files changed (28) 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/package.json +7 -7
  20. package/src/helpers/integration/standaloneEnv.ts +62 -0
  21. package/src/helpers/integration/undoHarness.ts +132 -1
  22. package/src/modules/customers/AGENTS.md +1 -0
  23. package/src/modules/customers/commands/deals.ts +106 -111
  24. package/src/modules/entities/lib/helpers.ts +43 -21
  25. package/src/modules/query_index/lib/indexer.ts +71 -24
  26. package/src/modules/query_index/subscribers/delete_one.ts +36 -16
  27. package/src/modules/query_index/subscribers/upsert_one.ts +44 -15
  28. 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 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(
@@ -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(