@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +6 -2
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/create/page.js +7 -3
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductUomSection.js +11 -22
- package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
- package/dist/modules/catalog/components/products/productForm.js +26 -1
- package/dist/modules/catalog/components/products/productForm.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +1 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/configs/services/upgradeActionsService.js +9 -2
- package/dist/modules/configs/services/upgradeActionsService.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js +1 -4
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarToolbar.js +3 -0
- package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/RepeatField.js +1 -3
- package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +1 -5
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
- package/dist/modules/entities/lib/definition-scope-where.js +18 -0
- package/dist/modules/entities/lib/definition-scope-where.js.map +7 -0
- package/dist/modules/entities/lib/definition-scope.js +2 -8
- package/dist/modules/entities/lib/definition-scope.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +6 -32
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/scoped-field-defs.js +5 -10
- package/dist/modules/entities/lib/scoped-field-defs.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +2 -2
- package/dist/modules/query_index/lib/batch.js.map +2 -2
- package/dist/modules/query_index/lib/document.js +7 -1
- package/dist/modules/query_index/lib/document.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js.map +1 -1
- package/dist/modules/query_index/lib/indexer.js +9 -2
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +15 -1
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +6 -2
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +7 -6
- package/src/modules/catalog/components/products/ProductUomSection.tsx +11 -31
- package/src/modules/catalog/components/products/productForm.ts +53 -0
- package/src/modules/configs/lib/upgrade-actions.ts +7 -0
- package/src/modules/configs/services/upgradeActionsService.ts +17 -2
- package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +1 -4
- package/src/modules/customers/components/calendar/CalendarToolbar.tsx +3 -0
- package/src/modules/customers/components/calendar/editor/RepeatField.tsx +0 -3
- package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +1 -7
- package/src/modules/customers/components/calendar/editor/inputs.tsx +0 -1
- package/src/modules/entities/lib/definition-scope-where.ts +27 -0
- package/src/modules/entities/lib/definition-scope.ts +2 -10
- package/src/modules/entities/lib/helpers.ts +6 -35
- package/src/modules/entities/lib/scoped-field-defs.ts +6 -15
- package/src/modules/query_index/lib/batch.ts +8 -2
- package/src/modules/query_index/lib/document.ts +28 -0
- package/src/modules/query_index/lib/engine.ts +4 -4
- package/src/modules/query_index/lib/indexer.ts +15 -2
- package/src/modules/query_index/lib/search-tokens.ts +31 -0
- 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}
|
|
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
|
/>
|
|
@@ -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
|
-
|
|
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:
|
|
74
|
+
let defsByKey: Map<string, CustomFieldDef> | undefined
|
|
74
75
|
if (preferDefs) {
|
|
75
|
-
|
|
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?.
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
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:
|
|
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={
|
|
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}
|