@open-mercato/core 0.8.1-develop.7207.1.901ef7e47a → 0.8.1-develop.7218.1.9f22882799

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +6 -2
  3. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/create/page.js +7 -3
  5. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  6. package/dist/modules/catalog/components/products/ProductUomSection.js +11 -22
  7. package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
  8. package/dist/modules/catalog/components/products/productForm.js +26 -1
  9. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  10. package/dist/modules/configs/lib/upgrade-actions.js +1 -0
  11. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  12. package/dist/modules/configs/services/upgradeActionsService.js +9 -2
  13. package/dist/modules/configs/services/upgradeActionsService.js.map +2 -2
  14. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +1 -4
  15. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
  16. package/dist/modules/customers/components/calendar/CalendarToolbar.js +3 -0
  17. package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
  18. package/dist/modules/customers/components/calendar/editor/RepeatField.js +1 -3
  19. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  20. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +1 -5
  21. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  22. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  23. package/dist/modules/entities/lib/definition-scope-where.js +18 -0
  24. package/dist/modules/entities/lib/definition-scope-where.js.map +7 -0
  25. package/dist/modules/entities/lib/definition-scope.js +2 -8
  26. package/dist/modules/entities/lib/definition-scope.js.map +2 -2
  27. package/dist/modules/entities/lib/helpers.js +6 -32
  28. package/dist/modules/entities/lib/helpers.js.map +2 -2
  29. package/dist/modules/entities/lib/scoped-field-defs.js +5 -10
  30. package/dist/modules/entities/lib/scoped-field-defs.js.map +2 -2
  31. package/dist/modules/query_index/lib/batch.js +2 -2
  32. package/dist/modules/query_index/lib/batch.js.map +2 -2
  33. package/dist/modules/query_index/lib/document.js +7 -1
  34. package/dist/modules/query_index/lib/document.js.map +2 -2
  35. package/dist/modules/query_index/lib/engine.js.map +1 -1
  36. package/dist/modules/query_index/lib/indexer.js +9 -2
  37. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  38. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  39. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +15 -1
  40. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
  41. package/package.json +7 -7
  42. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +6 -2
  43. package/src/modules/catalog/backend/catalog/products/create/page.tsx +7 -6
  44. package/src/modules/catalog/components/products/ProductUomSection.tsx +11 -31
  45. package/src/modules/catalog/components/products/productForm.ts +53 -0
  46. package/src/modules/configs/lib/upgrade-actions.ts +7 -0
  47. package/src/modules/configs/services/upgradeActionsService.ts +17 -2
  48. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +1 -4
  49. package/src/modules/customers/components/calendar/CalendarToolbar.tsx +3 -0
  50. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +0 -3
  51. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +1 -7
  52. package/src/modules/customers/components/calendar/editor/inputs.tsx +0 -1
  53. package/src/modules/entities/lib/definition-scope-where.ts +27 -0
  54. package/src/modules/entities/lib/definition-scope.ts +2 -10
  55. package/src/modules/entities/lib/helpers.ts +6 -35
  56. package/src/modules/entities/lib/scoped-field-defs.ts +6 -15
  57. package/src/modules/query_index/lib/batch.ts +8 -2
  58. package/src/modules/query_index/lib/document.ts +28 -0
  59. package/src/modules/query_index/lib/engine.ts +4 -4
  60. package/src/modules/query_index/lib/indexer.ts +15 -2
  61. package/src/modules/query_index/lib/search-tokens.ts +31 -0
  62. package/src/modules/wms/components/backend/AdjustInventoryDialog.tsx +21 -1
@@ -17,6 +17,7 @@ import {
17
17
  SelectTrigger,
18
18
  SelectValue,
19
19
  } from '@open-mercato/ui/primitives/select'
20
+ import { resolveDateFnsLocale } from '@open-mercato/ui/primitives/date-locale'
20
21
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
21
22
  import { formatDateRangeLabel } from '../../lib/calendar/format'
22
23
  import type {
@@ -51,6 +52,7 @@ export function CalendarToolbar(props: CalendarToolbarProps) {
51
52
  } = props
52
53
  const t = useT()
53
54
  const locale = useLocale()
55
+ const dateFnsLocale = resolveDateFnsLocale(locale)
54
56
  const [rangeOpen, setRangeOpen] = React.useState(false)
55
57
  const [filtersOpen, setFiltersOpen] = React.useState(false)
56
58
  const [pendingFilters, setPendingFilters] = React.useState<CalendarFiltersValue>(filters)
@@ -133,6 +135,7 @@ export function CalendarToolbar(props: CalendarToolbarProps) {
133
135
  mode="single"
134
136
  selected={anchor}
135
137
  defaultMonth={anchor}
138
+ locale={dateFnsLocale}
136
139
  onSelect={(date) => {
137
140
  if (!date) return
138
141
  onAnchorChange(date)
@@ -26,7 +26,6 @@ export function RepeatField({
26
26
  endType,
27
27
  count,
28
28
  untilDate,
29
- locale,
30
29
  onFreqChange,
31
30
  onToggleDay,
32
31
  onEndTypeChange,
@@ -38,7 +37,6 @@ export function RepeatField({
38
37
  endType: EditorRepeatEndType
39
38
  count: number
40
39
  untilDate: string
41
- locale: string
42
40
  onFreqChange(next: EditorRepeatFreq): void
43
41
  onToggleDay(index: number): void
44
42
  onEndTypeChange(next: EditorRepeatEndType): void
@@ -105,7 +103,6 @@ export function RepeatField({
105
103
  value={untilDate || ''}
106
104
  onChange={onUntilDateChange}
107
105
  ariaLabel={t('customers.calendar.editor.repeat.onDate', 'On date')}
108
- locale={locale}
109
106
  />
110
107
  ) : null}
111
108
  {endType === 'count' ? (
@@ -19,7 +19,6 @@ function DateTimeRow({
19
19
  date,
20
20
  time,
21
21
  showTime,
22
- locale,
23
22
  onDateChange,
24
23
  onTimeChange,
25
24
  }: {
@@ -27,7 +26,6 @@ function DateTimeRow({
27
26
  date: string
28
27
  time: string
29
28
  showTime: boolean
30
- locale: string
31
29
  onDateChange(next: string): void
32
30
  onTimeChange(next: string): void
33
31
  }) {
@@ -35,7 +33,7 @@ function DateTimeRow({
35
33
  <div className="flex w-full items-end gap-2.5">
36
34
  <div className="flex min-w-0 flex-1 flex-col gap-1.5">
37
35
  <span className={LABEL_CLASS}>{label}</span>
38
- <DateControl value={date} onChange={onDateChange} ariaLabel={label} locale={locale} />
36
+ <DateControl value={date} onChange={onDateChange} ariaLabel={label} />
39
37
  </div>
40
38
  {showTime ? <TimeControl value={time} onChange={onTimeChange} ariaLabel={label} /> : null}
41
39
  </div>
@@ -51,7 +49,6 @@ export function ScheduleSection({
51
49
  startTime,
52
50
  endDate,
53
51
  endTime,
54
- locale,
55
52
  endsError,
56
53
  onAllDayChange,
57
54
  onDateChange,
@@ -67,7 +64,6 @@ export function ScheduleSection({
67
64
  startTime: string
68
65
  endDate: string
69
66
  endTime: string
70
- locale: string
71
67
  endsError?: string | null
72
68
  onAllDayChange(next: boolean): void
73
69
  onDateChange(next: string): void
@@ -91,7 +87,6 @@ export function ScheduleSection({
91
87
  date={date}
92
88
  time={startTime}
93
89
  showTime={showTime}
94
- locale={locale}
95
90
  onDateChange={onDateChange}
96
91
  onTimeChange={onStartTimeChange}
97
92
  />
@@ -101,7 +96,6 @@ export function ScheduleSection({
101
96
  date={endDate}
102
97
  time={endTime}
103
98
  showTime={showTime}
104
- locale={locale}
105
99
  onDateChange={onEndDateChange}
106
100
  onTimeChange={onEndTimeChange}
107
101
  />
@@ -116,7 +116,6 @@ export function DateControl({
116
116
  value: string
117
117
  onChange(next: string): void
118
118
  ariaLabel: string
119
- locale: string
120
119
  className?: string
121
120
  }) {
122
121
  const [open, setOpen] = React.useState(false)
@@ -0,0 +1,27 @@
1
+ export type DefinitionScopeClauseInput = {
2
+ organizationId?: string | null
3
+ tenantId?: string | null
4
+ }
5
+
6
+ // SQL `IN (?, NULL)` never matches rows where the column IS NULL, so a scope
7
+ // filter that mixes a concrete id with a literal null has to be expressed as an
8
+ // `$or` of equality and IS NULL. Every query that resolves the definitions
9
+ // visible to a scope — the write path, the validation path, the single-key
10
+ // lookups — routes through this builder so they cannot drift apart again (#5919).
11
+ export function createVisibleDefinitionScopeClause(scope: DefinitionScopeClauseInput) {
12
+ const organizationId = scope.organizationId ?? null
13
+ const tenantId = scope.tenantId ?? null
14
+
15
+ const organizationCandidates = [{ organizationId: null as string | null }]
16
+ if (organizationId !== null) organizationCandidates.unshift({ organizationId })
17
+
18
+ const tenantCandidates = [{ tenantId: null as string | null }]
19
+ if (tenantId !== null) tenantCandidates.unshift({ tenantId })
20
+
21
+ return {
22
+ $and: [
23
+ { $or: organizationCandidates },
24
+ { $or: tenantCandidates },
25
+ ],
26
+ }
27
+ }
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
3
3
  import { CustomFieldDef } from '@open-mercato/core/modules/entities/data/entities'
4
+ import { createVisibleDefinitionScopeClause } from './definition-scope-where'
4
5
 
5
6
  type AuthScope = {
6
7
  tenantId?: string | null
@@ -84,20 +85,11 @@ export function createVisibleDefinitionWhere(
84
85
  scope: DefinitionMutationScope,
85
86
  options: DefinitionVisibilityOptions = {},
86
87
  ) {
87
- const organizationCandidates = [{ organizationId: null as string | null }]
88
- if (scope.organizationId) organizationCandidates.unshift({ organizationId: scope.organizationId })
89
-
90
- const tenantCandidates = [{ tenantId: null as string | null }]
91
- if (scope.tenantId) tenantCandidates.unshift({ tenantId: scope.tenantId })
92
-
93
88
  return {
94
89
  entityId,
95
90
  key,
96
91
  ...options,
97
- $and: [
98
- { $or: organizationCandidates },
99
- { $or: tenantCandidates },
100
- ],
92
+ ...createVisibleDefinitionScopeClause(scope),
101
93
  }
102
94
  }
103
95
 
@@ -6,6 +6,7 @@ import {
6
6
  TOO_MANY_CUSTOM_FIELDS_ERROR,
7
7
  } from '@open-mercato/shared/modules/entities/validation'
8
8
  import { CustomFieldDef, CustomFieldValue } from '../data/entities'
9
+ import { loadScopedCustomFieldDefs } from './scoped-field-defs'
9
10
 
10
11
  type Primitive = string | number | boolean | null | undefined
11
12
  type PrimitiveOrArray = Primitive | Primitive[]
@@ -70,39 +71,9 @@ export async function setRecordCustomFields(
70
71
  const tenantId = opts.tenantId ?? null
71
72
  const preferDefs = opts.preferDefs !== false
72
73
 
73
- let defsByKey: Record<string, CustomFieldDef> | undefined
74
+ let defsByKey: Map<string, CustomFieldDef> | undefined
74
75
  if (preferDefs) {
75
- const defs = await em.find(CustomFieldDef, {
76
- entityId,
77
- isActive: true,
78
- deletedAt: null,
79
- organizationId: { $in: [organizationId, null] as any },
80
- tenantId: { $in: [tenantId, null] as any },
81
- })
82
- const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
83
- defsByKey = {}
84
- for (const d of defs) {
85
- const existing = defsByKey[d.key]
86
- if (!existing) {
87
- defsByKey[d.key] = d
88
- continue
89
- }
90
- const nextScore = scopeScore(d)
91
- const existingScore = scopeScore(existing)
92
- if (nextScore > existingScore) {
93
- defsByKey[d.key] = d
94
- continue
95
- }
96
- if (nextScore < existingScore) continue
97
-
98
- const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
99
- const existingUpdatedAt = existing.updatedAt instanceof Date
100
- ? existing.updatedAt.getTime()
101
- : new Date(existing.updatedAt).getTime()
102
- if (nextUpdatedAt >= existingUpdatedAt) {
103
- defsByKey[d.key] = d
104
- }
105
- }
76
+ defsByKey = await loadScopedCustomFieldDefs(em, { entityId, organizationId, tenantId })
106
77
  }
107
78
 
108
79
  const toPersist: CustomFieldValue[] = []
@@ -147,7 +118,7 @@ export async function setRecordCustomFields(
147
118
  const raw = values[fieldKey]
148
119
  if (raw === undefined) continue
149
120
 
150
- const def = defsByKey?.[fieldKey]
121
+ const def = defsByKey?.get(fieldKey)
151
122
  const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
152
123
  const isArray = Array.isArray(raw)
153
124
  // When array (multi-value): replace all existing rows for the key. Delete
@@ -164,7 +135,7 @@ export async function setRecordCustomFields(
164
135
  const cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })
165
136
  clearValueColumns(cf)
166
137
  const stored = encrypted
167
- ? await encryptCustomFieldValue(val, tenantId, getEncryptionService(), encryptionCache)
138
+ ? await encryptCustomFieldValue(val, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })
168
139
  : val
169
140
  switch (col) {
170
141
  case 'valueText': cf.valueText = stored == null ? null : String(stored); break
@@ -181,7 +152,7 @@ export async function setRecordCustomFields(
181
152
 
182
153
  const column: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(raw as Primitive)
183
154
  const storedValue = encrypted
184
- ? await encryptCustomFieldValue(raw as Primitive, tenantId, getEncryptionService(), encryptionCache)
155
+ ? await encryptCustomFieldValue(raw as Primitive, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })
185
156
  : raw
186
157
 
187
158
  let cf = await em.findOne(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey })
@@ -1,5 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/core'
2
2
  import { CustomFieldDef } from '../data/entities'
3
+ import { createVisibleDefinitionScopeClause } from './definition-scope-where'
3
4
 
4
5
  export type ScopedCustomFieldDefsOptions = {
5
6
  entityId: string
@@ -19,25 +20,15 @@ export async function loadScopedCustomFieldDefs(
19
20
  em: EntityManager,
20
21
  opts: ScopedCustomFieldDefsOptions,
21
22
  ): Promise<Map<string, CustomFieldDef>> {
22
- const organizationId = opts.organizationId ?? null
23
- const tenantId = opts.tenantId ?? null
24
23
  const defs = await em.find(CustomFieldDef, {
25
24
  entityId: opts.entityId,
26
25
  isActive: true,
27
26
  deletedAt: null,
28
- $and: [
29
- {
30
- $or: organizationId === null
31
- ? [{ organizationId: null }]
32
- : [{ organizationId }, { organizationId: null }],
33
- },
34
- {
35
- $or: tenantId === null
36
- ? [{ tenantId: null }]
37
- : [{ tenantId }, { tenantId: null }],
38
- },
39
- ],
40
- } as any)
27
+ ...createVisibleDefinitionScopeClause({
28
+ organizationId: opts.organizationId,
29
+ tenantId: opts.tenantId,
30
+ }),
31
+ })
41
32
 
42
33
  const byKey = new Map<string, CustomFieldDef>()
43
34
  for (const def of defs) {
@@ -1,7 +1,7 @@
1
1
  import { type Kysely, sql } from 'kysely'
2
2
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
3
3
  import { isUniqueViolation } from '@open-mercato/shared/lib/db/pg-errors'
4
- import { buildIndexDocument, type IndexCustomFieldValue } from './document'
4
+ import { buildIndexDocument, rebuildAggregateSearchField, type IndexCustomFieldValue } from './document'
5
5
  import { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'
6
6
  import { createLogger } from '@open-mercato/shared/lib/logger'
7
7
  import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
@@ -304,7 +304,13 @@ export async function upsertIndexBatch(
304
304
  tenantId: scopeTenant ?? null,
305
305
  })
306
306
  if (decrypted && typeof decrypted === 'object') {
307
- tokenDoc = decrypted
307
+ // Rebuilt on the decrypted copy: `search_text` is on no encryption map, so the
308
+ // aggregate composed at document-build time still concatenates the ciphertext
309
+ // values the row carries at rest and tokenizes into rows nothing matches (#5625).
310
+ // The copy keeps `doc` — the encrypted row written to `entity_indexes` — untouched.
311
+ // A caller that passes no `decryptDoc` keeps the build-time aggregate by design:
312
+ // without a decryption step there is no plaintext to recompose it from.
313
+ tokenDoc = rebuildAggregateSearchField(decrypted, { entityType, config: searchConfig })
308
314
  }
309
315
  } catch (decryptError) {
310
316
  // Only affects the search tokens built below; the indexed document itself is
@@ -109,6 +109,34 @@ export function attachAggregateSearchField(
109
109
  return doc
110
110
  }
111
111
 
112
+ /**
113
+ * Recomputes the aggregate on a shallow copy of `doc`.
114
+ *
115
+ * Search tokens are built from the *decrypted* index document, while the document
116
+ * persisted in `entity_indexes` stays encrypted at rest. The aggregate composed during
117
+ * document build therefore concatenates ciphertext for every field an encryption map
118
+ * covers, and `decryptIndexDocForSearch` cannot repair it because `search_text` is on no
119
+ * map (#5625). Token writers call this on the decrypted document so the aggregate is
120
+ * tokenized from the same plaintext a user actually searches for; the copy keeps the
121
+ * caller's stored document untouched.
122
+ *
123
+ * A pre-existing aggregate is blanked before recomposition rather than deleted: an empty
124
+ * string yields no tokens, but it keeps the key on the document, and
125
+ * `replaceSearchTokensForRecord` scopes its DELETE to the document's own field names. A
126
+ * deleted key would drop `search_text` out of that scope and strand the ciphertext-derived
127
+ * rows with no writer left to remove them.
128
+ */
129
+ export function rebuildAggregateSearchField(
130
+ doc: Record<string, unknown>,
131
+ options: AggregateSearchOptions = {},
132
+ ): Record<string, unknown> {
133
+ const copy = { ...doc }
134
+ // `collectAggregateSearchValues` skips the aggregate's own key, so this blank is
135
+ // overwritten whenever anything survives the blocklist and stays blank otherwise.
136
+ if (AGGREGATE_SEARCH_FIELD in copy) copy[AGGREGATE_SEARCH_FIELD] = ''
137
+ return attachAggregateSearchField(copy, options)
138
+ }
139
+
112
140
  export function buildIndexDocument(
113
141
  baseRow: Record<string, unknown>,
114
142
  customFieldValues: Iterable<IndexCustomFieldValue> = [],
@@ -528,10 +528,10 @@ export class HybridQueryEngine implements QueryEngine {
528
528
  // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
529
529
  // the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
530
530
  // `organizationId: null` is deliberate, not an omission: the service then unions in every
531
- // organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays
532
- // on the token path -- a wider set fails safe. Passing the request's org instead would
533
- // silently break encrypted-column search for orgs without their own map. That union is an
534
- // UNCACHED `encryption_maps` read, one extra round-trip per searched list request.
531
+ // organization's map, so a field any org encrypts stays on the token path -- a wider set
532
+ // fails safe. Passing the request's org instead would silently break encrypted-column
533
+ // search for orgs without their own map. The service caches that union (#5949), and the
534
+ // TTL cache in `resolveEncryptedLikeFieldSet` keeps even the lookup off the request path.
535
535
  try {
536
536
  const encryptionService = this.getEncryptionService()
537
537
  const readEncryptedFieldNames = encryptionService?.getEncryptedFieldNames?.bind(encryptionService)
@@ -10,8 +10,9 @@ import {
10
10
  } from '@open-mercato/shared/lib/crud/custom-field-definition-index'
11
11
  import { type Kysely, type Transaction, sql } from 'kysely'
12
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
+ import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
13
14
  import { replaceSearchTokensForRecord, deleteSearchTokensForRecord } from './search-tokens'
14
- import { attachAggregateSearchField } from './document'
15
+ import { attachAggregateSearchField, rebuildAggregateSearchField } from './document'
15
16
 
16
17
  const logger = createLogger('query_index').child({ component: 'indexer' })
17
18
 
@@ -400,12 +401,24 @@ export async function reindexSearchTokensForRecord(
400
401
  dekKeyCache,
401
402
  )
402
403
  })()
404
+ // Rebuilt on the decrypted document: the aggregate stored in `entity_indexes` was
405
+ // composed from the row as it sits at rest, so for every encryption-mapped field it
406
+ // holds ciphertext no user query can ever match (#5625). `resolveSearchConfig` re-parses
407
+ // the environment on every call, so it is resolved once and threaded into both the
408
+ // rebuild and the token writer — that also makes the aggregate's blocklist filter and
409
+ // the per-field one provably read the same snapshot, as the batch path already does.
410
+ const searchConfig = resolveSearchConfig()
411
+ const searchDoc = rebuildAggregateSearchField(await tokenDoc, {
412
+ entityType: args.entityType,
413
+ config: searchConfig,
414
+ })
403
415
  await replaceSearchTokensForRecord(db, {
404
416
  entityType: args.entityType,
405
417
  recordId: args.recordId,
406
418
  organizationId: args.organizationId ?? null,
407
419
  tenantId: args.tenantId ?? null,
408
- doc: await tokenDoc,
420
+ doc: searchDoc,
421
+ config: searchConfig,
409
422
  }, { trx: args.trx })
410
423
  }
411
424
 
@@ -168,6 +168,37 @@ function shouldIndexField(
168
168
  return collectTextValues(value).some((text) => text.length > 0)
169
169
  }
170
170
 
171
+ /**
172
+ * Builds the `search_tokens` rows for one document.
173
+ *
174
+ * `maxTokensPerRecord` is spent in the iteration order of `params.doc`'s own keys: fields are
175
+ * tokenized one after another and the loop stops at the first field that exhausts the budget, so
176
+ * on an over-budget record the surviving fields are whichever ones come first. That order is a
177
+ * property of the object handed in, not of the entity, and the two write-path builders order it
178
+ * differently: `buildIndexDocument` (`lib/document.ts`, the batch reindex path) appends `cf:*` keys
179
+ * after the base columns and the aggregate `search_text` field last. `buildIndexDoc` (`lib/indexer.ts`,
180
+ * the incremental single-record write path — `upsertIndexRow` → `reindexSearchTokensForRecord`) adds
181
+ * `l10n:{locale}:{field}` translation keys between the `cf:*` keys and `search_text`. The budget is
182
+ * spent front-to-back, so the fields nearest the end of that order are starved first: `search_text`
183
+ * is last on both paths and starves first either way, but starves sooner on the incremental path,
184
+ * because the `l10n:*` keys ahead of it there consume budget the batch path would have spent on it.
185
+ * Those translation fields starve next, before any `cf:*` key or base column. The two paths can
186
+ * therefore keep different fields searchable for an otherwise equivalent record.
187
+ *
188
+ * Every write path tokenizes an in-memory document it is about to write — `TokenSearchStrategy.index`
189
+ * writes only `search_tokens` rows and never touches `entity_indexes.doc` — which keeps each path
190
+ * self-consistent on its own. A document read back out of the `entity_indexes.doc` `jsonb` column is
191
+ * not the same object: Postgres stores `jsonb` keys in a canonical order (by key length, then byte
192
+ * order), not in insertion order. Re-tokenizing such a document can therefore truncate at a different
193
+ * field than the write did, so code that verifies or recomputes a record's expected tokens must
194
+ * rebuild the document through the builder that matches the write path being checked — `buildIndexDoc`
195
+ * for incrementally-written records, `buildIndexDocument` for batch-reindexed ones — rather than
196
+ * reading it back from the database.
197
+ *
198
+ * Making truncation reproducible from a `jsonb` read — by sorting fields before tokenization —
199
+ * would change which terms stay searchable on over-budget records, so it is a behavior decision
200
+ * rather than a clarification (#5971).
201
+ */
171
202
  export function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {
172
203
  const config = params.config ?? resolveSearchConfig()
173
204
  if (!config.enabled) return []
@@ -141,6 +141,13 @@ export function AdjustInventoryDialog({
141
141
  )
142
142
  const [submitting, setSubmitting] = React.useState(false)
143
143
  const [form, setForm] = React.useState<AdjustFormValues>(EMPTY_FORM)
144
+ // Raw text the user is typing into the delta field, decoupled from `form.delta` (issue
145
+ // #5828): re-deriving the input's display from the committed number on every keystroke
146
+ // drops an in-progress decimal separator the instant it's typed (`2,5` collapses to `2`
147
+ // then the next digit appends, producing `25`). Synced from `form.delta` only while the
148
+ // field is not focused, mirroring `CrudForm.NumberInput`'s buffer.
149
+ const [deltaInputText, setDeltaInputText] = React.useState<string>(String(EMPTY_FORM.delta))
150
+ const deltaInputFocusedRef = React.useRef(false)
144
151
  const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
145
152
  const [onHand, setOnHand] = React.useState<number | null>(null)
146
153
  const [previewError, setPreviewError] = React.useState<string | null>(null)
@@ -189,6 +196,7 @@ export function AdjustInventoryDialog({
189
196
 
190
197
  const resetDialog = React.useCallback(() => {
191
198
  setForm(EMPTY_FORM)
199
+ setDeltaInputText(String(EMPTY_FORM.delta))
192
200
  setFieldErrors({})
193
201
  setOnHand(null)
194
202
  setPreviewError(null)
@@ -208,6 +216,10 @@ export function AdjustInventoryDialog({
208
216
  setFieldErrors({})
209
217
  }, [])
210
218
 
219
+ React.useEffect(() => {
220
+ if (!deltaInputFocusedRef.current) setDeltaInputText(String(form.delta))
221
+ }, [form.delta])
222
+
211
223
  React.useEffect(() => {
212
224
  if (!open) return
213
225
  const catalogVariantId = initialCatalogVariantId?.trim()
@@ -689,12 +701,20 @@ export function AdjustInventoryDialog({
689
701
  <Input
690
702
  type="text"
691
703
  inputMode="decimal"
692
- value={String(form.delta)}
704
+ value={deltaInputText}
693
705
  onChange={(event) => {
706
+ setDeltaInputText(event.target.value)
694
707
  const parsed = parseDeltaInput(event.target.value, locale)
695
708
  if (parsed == null) return
696
709
  patchForm({ delta: parsed })
697
710
  }}
711
+ onFocus={() => {
712
+ deltaInputFocusedRef.current = true
713
+ }}
714
+ onBlur={() => {
715
+ deltaInputFocusedRef.current = false
716
+ setDeltaInputText(String(form.delta))
717
+ }}
698
718
  className="h-8 w-auto min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0"
699
719
  inputClassName="text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
700
720
  disabled={submitting}