@open-mercato/shared 0.6.4-develop.4210.1.d412061cfe → 0.6.4-develop.4236.1.9fa6806b34
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/lib/commands/flush.js +17 -12
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/custom-field-definition-index.js +146 -0
- package/dist/lib/crud/custom-field-definition-index.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +19 -102
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js +95 -68
- package/dist/lib/crud/factory.js.map +3 -3
- package/dist/lib/query/engine.js +35 -1
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/flush.test.ts +78 -2
- package/src/lib/commands/flush.ts +72 -19
- package/src/lib/crud/__tests__/crud-factory.test.ts +99 -0
- package/src/lib/crud/__tests__/custom-field-definition-index.test.ts +136 -0
- package/src/lib/crud/custom-field-definition-index.ts +233 -0
- package/src/lib/crud/custom-fields.ts +24 -146
- package/src/lib/crud/factory.ts +92 -55
- package/src/lib/query/__tests__/engine.test.ts +80 -0
- package/src/lib/query/engine.ts +57 -4
- package/src/lib/query/types.ts +9 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Core-free building blocks for the custom-field definition index.
|
|
2
|
+
//
|
|
3
|
+
// This module intentionally has ZERO dependency on `@open-mercato/core` (no ORM
|
|
4
|
+
// entity imports) so that infrastructure code such as the query engine can build
|
|
5
|
+
// the same definition index that `custom-fields.ts` produces via MikroORM, without
|
|
6
|
+
// pulling a domain package into the query layer.
|
|
7
|
+
|
|
8
|
+
export type CustomFieldDefinitionSummary = {
|
|
9
|
+
key: string
|
|
10
|
+
label: string | null
|
|
11
|
+
kind: string | null
|
|
12
|
+
multi: boolean
|
|
13
|
+
dictionaryId?: string | null
|
|
14
|
+
organizationId?: string | null
|
|
15
|
+
tenantId?: string | null
|
|
16
|
+
priority: number
|
|
17
|
+
updatedAt: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CustomFieldDefinitionIndex = Map<string, CustomFieldDefinitionSummary[]>
|
|
21
|
+
|
|
22
|
+
// Plain-row representation of a `custom_field_defs` record. Both the ORM-backed
|
|
23
|
+
// loader (`custom-fields.ts`) and the Kysely-backed query engine map their native
|
|
24
|
+
// rows into this shape before building an index, so the two paths stay in lockstep.
|
|
25
|
+
export type CustomFieldDefinitionRow = {
|
|
26
|
+
key: string
|
|
27
|
+
entityId: string
|
|
28
|
+
kind: string | null
|
|
29
|
+
configJson: unknown
|
|
30
|
+
organizationId: string | null
|
|
31
|
+
tenantId: string | null
|
|
32
|
+
deletedAt: Date | string | number | null
|
|
33
|
+
updatedAt: Date | string | number | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The resolved definition index the query engine threads onto its result so the
|
|
37
|
+
// CRUD factory can decorate list rows without reloading definitions (issue #2133).
|
|
38
|
+
export type ResolvedCustomFieldDefinitions = {
|
|
39
|
+
index: CustomFieldDefinitionIndex
|
|
40
|
+
entityIds: string[]
|
|
41
|
+
tenantId: string | null
|
|
42
|
+
organizationIds: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeDefinitionKey(key: unknown): string {
|
|
46
|
+
if (typeof key !== 'string') return ''
|
|
47
|
+
const trimmed = key.trim()
|
|
48
|
+
return trimmed.length ? trimmed.toLowerCase() : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeDefinitionConfig(raw: unknown): Record<string, any> {
|
|
52
|
+
if (!raw) return {}
|
|
53
|
+
if (typeof raw === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw)
|
|
56
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
57
|
+
return { ...(parsed as Record<string, any>) }
|
|
58
|
+
}
|
|
59
|
+
return {}
|
|
60
|
+
} catch {
|
|
61
|
+
return {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
65
|
+
return { ...(raw as Record<string, any>) }
|
|
66
|
+
}
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeFieldsetFilter(input?: string | string[] | null): Set<string | null> | null {
|
|
71
|
+
if (input == null) return null
|
|
72
|
+
const values = Array.isArray(input) ? input : [input]
|
|
73
|
+
const normalized = new Set<string | null>()
|
|
74
|
+
for (const raw of values) {
|
|
75
|
+
if (raw == null) continue
|
|
76
|
+
const trimmed = String(raw).trim()
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
normalized.add(null)
|
|
79
|
+
} else {
|
|
80
|
+
normalized.add(trimmed)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return normalized.size ? normalized : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toTimeMs(value: Date | string | number | null | undefined): number {
|
|
87
|
+
if (value == null) return 0
|
|
88
|
+
if (value instanceof Date) return value.getTime()
|
|
89
|
+
const parsed = new Date(value as any).getTime()
|
|
90
|
+
return Number.isNaN(parsed) ? 0 : parsed
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function summarizeDefinitionRow(row: CustomFieldDefinitionRow): CustomFieldDefinitionSummary | null {
|
|
94
|
+
const normalizedKey = normalizeDefinitionKey(row.key)
|
|
95
|
+
if (!normalizedKey) return null
|
|
96
|
+
const cfg = normalizeDefinitionConfig(row.configJson)
|
|
97
|
+
const label =
|
|
98
|
+
typeof cfg.label === 'string' && cfg.label.trim().length
|
|
99
|
+
? cfg.label.trim()
|
|
100
|
+
: row.key
|
|
101
|
+
const dictionaryId =
|
|
102
|
+
typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length
|
|
103
|
+
? cfg.dictionaryId.trim()
|
|
104
|
+
: null
|
|
105
|
+
const multi = cfg.multi !== undefined ? Boolean(cfg.multi) : false
|
|
106
|
+
const priority = typeof cfg.priority === 'number' ? cfg.priority : 0
|
|
107
|
+
return {
|
|
108
|
+
key: row.key,
|
|
109
|
+
label,
|
|
110
|
+
kind: typeof row.kind === 'string' ? row.kind : null,
|
|
111
|
+
multi,
|
|
112
|
+
dictionaryId,
|
|
113
|
+
organizationId: row.organizationId ?? null,
|
|
114
|
+
tenantId: row.tenantId ?? null,
|
|
115
|
+
priority,
|
|
116
|
+
updatedAt: toTimeMs(row.updatedAt),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function sortDefinitionSummaries(defs: CustomFieldDefinitionSummary[]): CustomFieldDefinitionSummary[] {
|
|
121
|
+
return [...defs].sort((a, b) => {
|
|
122
|
+
const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0)
|
|
123
|
+
if (priorityDiff !== 0) return priorityDiff
|
|
124
|
+
const updatedDiff = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
|
|
125
|
+
if (updatedDiff !== 0) return updatedDiff
|
|
126
|
+
return a.key.localeCompare(b.key)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function selectDefinitionForRecord(
|
|
131
|
+
defs: CustomFieldDefinitionSummary[],
|
|
132
|
+
organizationId: string | null,
|
|
133
|
+
tenantId: string | null,
|
|
134
|
+
): CustomFieldDefinitionSummary | null {
|
|
135
|
+
if (!defs.length) return null
|
|
136
|
+
const prioritizedForOrg = defs.filter(
|
|
137
|
+
(def) => def.organizationId && organizationId && def.organizationId === organizationId,
|
|
138
|
+
)
|
|
139
|
+
if (prioritizedForOrg.length) return sortDefinitionSummaries(prioritizedForOrg)[0]
|
|
140
|
+
const prioritizedForTenant = defs.filter(
|
|
141
|
+
(def) => def.tenantId && tenantId && def.tenantId === tenantId && !def.organizationId,
|
|
142
|
+
)
|
|
143
|
+
if (prioritizedForTenant.length) return sortDefinitionSummaries(prioritizedForTenant)[0]
|
|
144
|
+
const global = defs.filter((def) => !def.organizationId)
|
|
145
|
+
if (global.length) return sortDefinitionSummaries(global)[0]
|
|
146
|
+
return sortDefinitionSummaries(defs)[0] ?? null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve the effective list of organization candidates for a definition index
|
|
150
|
+
// lookup, mirroring `loadCustomFieldDefinitionIndex`: when explicit org ids are
|
|
151
|
+
// present they win, otherwise the fallback (selected org) is used. Null/empty
|
|
152
|
+
// entries are dropped — the null-org branch is always allowed by the index filter.
|
|
153
|
+
export function resolveCfDefIndexOrgCandidates(
|
|
154
|
+
organizationIds: Array<string | null | undefined> | null | undefined,
|
|
155
|
+
fallbackOrganizationId: string | null | undefined,
|
|
156
|
+
): string[] {
|
|
157
|
+
const source = Array.isArray(organizationIds) && organizationIds.length
|
|
158
|
+
? organizationIds
|
|
159
|
+
: [fallbackOrganizationId ?? null]
|
|
160
|
+
return source
|
|
161
|
+
.map((id) => (typeof id === 'string' ? id.trim() : id))
|
|
162
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function matchesFieldset(configJson: unknown, fieldsetFilter: Set<string | null>): boolean {
|
|
166
|
+
const config = normalizeDefinitionConfig(configJson)
|
|
167
|
+
const fieldsets = Array.isArray(config.fieldsets)
|
|
168
|
+
? config.fieldsets
|
|
169
|
+
.filter((entry: unknown): entry is string => typeof entry === 'string')
|
|
170
|
+
.map((entry: string) => entry.trim())
|
|
171
|
+
.filter((entry: string) => entry.length > 0)
|
|
172
|
+
: []
|
|
173
|
+
const fieldset = typeof config.fieldset === 'string' && config.fieldset.trim().length > 0
|
|
174
|
+
? config.fieldset.trim()
|
|
175
|
+
: null
|
|
176
|
+
return fieldsets.length > 0
|
|
177
|
+
? fieldsets.some((entry: string) => fieldsetFilter.has(entry))
|
|
178
|
+
: fieldsetFilter.has(fieldset)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build the grouped + sorted definition index from plain rows. Callers must
|
|
182
|
+
// pre-filter rows by tenant + is_active (both the ORM loader and the query engine
|
|
183
|
+
// already do so in SQL); this function additionally applies the org candidate
|
|
184
|
+
// filter, the soft-delete guard, and the optional fieldset filter so its output is
|
|
185
|
+
// byte-for-byte identical to the ORM-backed loader for the same logical scope.
|
|
186
|
+
export function buildCustomFieldDefinitionIndexFromRows(
|
|
187
|
+
rows: CustomFieldDefinitionRow[],
|
|
188
|
+
opts: { organizationIds?: string[] | null; fieldset?: string | string[] | null } = {},
|
|
189
|
+
): CustomFieldDefinitionIndex {
|
|
190
|
+
const orgCandidates = opts.organizationIds ?? []
|
|
191
|
+
const fieldsetFilter = normalizeFieldsetFilter(opts.fieldset)
|
|
192
|
+
const index: CustomFieldDefinitionIndex = new Map()
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
if (row.deletedAt != null) continue
|
|
195
|
+
const org = row.organizationId ?? null
|
|
196
|
+
if (org !== null && !(orgCandidates.length > 0 && orgCandidates.includes(org))) continue
|
|
197
|
+
if (fieldsetFilter && !matchesFieldset(row.configJson, fieldsetFilter)) continue
|
|
198
|
+
const summary = summarizeDefinitionRow(row)
|
|
199
|
+
if (!summary) continue
|
|
200
|
+
const normalizedKey = normalizeDefinitionKey(summary.key)
|
|
201
|
+
if (!normalizedKey) continue
|
|
202
|
+
if (!index.has(normalizedKey)) index.set(normalizedKey, [])
|
|
203
|
+
index.get(normalizedKey)!.push(summary)
|
|
204
|
+
}
|
|
205
|
+
index.forEach((entries, key) => {
|
|
206
|
+
index.set(key, sortDefinitionSummaries(entries))
|
|
207
|
+
})
|
|
208
|
+
return index
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
|
|
212
|
+
const left = new Set(a)
|
|
213
|
+
const right = new Set(b)
|
|
214
|
+
if (left.size !== right.size) return false
|
|
215
|
+
for (const value of left) {
|
|
216
|
+
if (!right.has(value)) return false
|
|
217
|
+
}
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Decide whether a precomputed definition index from a QueryEngine result can be
|
|
222
|
+
// reused for a decoration request. Reuse is only safe when the engine resolved
|
|
223
|
+
// definitions for exactly the same entity-id set, tenant, and org candidates.
|
|
224
|
+
export function canReuseCustomFieldDefinitions(
|
|
225
|
+
resolved: ResolvedCustomFieldDefinitions | null | undefined,
|
|
226
|
+
request: { entityIds: string[]; tenantId: string | null; organizationIds: string[] },
|
|
227
|
+
): boolean {
|
|
228
|
+
if (!resolved) return false
|
|
229
|
+
if ((resolved.tenantId ?? null) !== (request.tenantId ?? null)) return false
|
|
230
|
+
if (!sameStringSet(resolved.entityIds.map(String), request.entityIds.map(String))) return false
|
|
231
|
+
if (!sameStringSet(resolved.organizationIds, request.organizationIds)) return false
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
@@ -6,6 +6,17 @@ import type { TenantDataEncryptionService } from '../encryption/tenantDataEncryp
|
|
|
6
6
|
import { decryptCustomFieldValue, resolveTenantEncryptionService } from '../encryption/customFieldValues'
|
|
7
7
|
import { parseBooleanToken } from '../boolean'
|
|
8
8
|
import { extractCustomFieldEntries } from './custom-fields-client'
|
|
9
|
+
import {
|
|
10
|
+
buildCustomFieldDefinitionIndexFromRows,
|
|
11
|
+
normalizeDefinitionKey,
|
|
12
|
+
normalizeFieldsetFilter,
|
|
13
|
+
selectDefinitionForRecord,
|
|
14
|
+
type CustomFieldDefinitionIndex,
|
|
15
|
+
type CustomFieldDefinitionRow,
|
|
16
|
+
type CustomFieldDefinitionSummary,
|
|
17
|
+
} from './custom-field-definition-index'
|
|
18
|
+
|
|
19
|
+
export type { CustomFieldDefinitionSummary, CustomFieldDefinitionIndex } from './custom-field-definition-index'
|
|
9
20
|
|
|
10
21
|
export type CustomFieldSelectors = {
|
|
11
22
|
keys: string[]
|
|
@@ -18,20 +29,6 @@ export type SplitCustomFieldPayload = {
|
|
|
18
29
|
custom: Record<string, unknown>
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
export type CustomFieldDefinitionSummary = {
|
|
22
|
-
key: string
|
|
23
|
-
label: string | null
|
|
24
|
-
kind: string | null
|
|
25
|
-
multi: boolean
|
|
26
|
-
dictionaryId?: string | null
|
|
27
|
-
organizationId?: string | null
|
|
28
|
-
tenantId?: string | null
|
|
29
|
-
priority: number
|
|
30
|
-
updatedAt: number
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type CustomFieldDefinitionIndex = Map<string, CustomFieldDefinitionSummary[]>
|
|
34
|
-
|
|
35
32
|
export type CustomFieldDisplayEntry = {
|
|
36
33
|
key: string
|
|
37
34
|
label: string | null
|
|
@@ -93,22 +90,6 @@ export function extractAllCustomFieldEntries(item: Record<string, unknown>): Rec
|
|
|
93
90
|
return extractCustomFieldEntries(item)
|
|
94
91
|
}
|
|
95
92
|
|
|
96
|
-
function normalizeFieldsetFilter(input?: string | string[] | null): Set<string | null> | null {
|
|
97
|
-
if (input == null) return null
|
|
98
|
-
const values = Array.isArray(input) ? input : [input]
|
|
99
|
-
const normalized = new Set<string | null>()
|
|
100
|
-
for (const raw of values) {
|
|
101
|
-
if (raw == null) continue
|
|
102
|
-
const trimmed = String(raw).trim()
|
|
103
|
-
if (!trimmed) {
|
|
104
|
-
normalized.add(null)
|
|
105
|
-
} else {
|
|
106
|
-
normalized.add(trimmed)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return normalized.size ? normalized : null
|
|
110
|
-
}
|
|
111
|
-
|
|
112
93
|
export async function buildCustomFieldFiltersFromQuery(opts: {
|
|
113
94
|
entityId?: EntityId
|
|
114
95
|
entityIds?: EntityId[]
|
|
@@ -250,93 +231,6 @@ export function extractCustomFieldValuesFromPayload(raw: Record<string, unknown>
|
|
|
250
231
|
return splitCustomFieldPayload(raw).custom
|
|
251
232
|
}
|
|
252
233
|
|
|
253
|
-
function normalizeDefinitionKey(key: unknown): string {
|
|
254
|
-
if (typeof key !== 'string') return ''
|
|
255
|
-
const trimmed = key.trim()
|
|
256
|
-
return trimmed.length ? trimmed.toLowerCase() : ''
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function normalizeDefinitionConfig(raw: unknown): Record<string, any> {
|
|
260
|
-
if (!raw) return {}
|
|
261
|
-
if (typeof raw === 'string') {
|
|
262
|
-
try {
|
|
263
|
-
const parsed = JSON.parse(raw)
|
|
264
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
265
|
-
return { ...(parsed as Record<string, any>) }
|
|
266
|
-
}
|
|
267
|
-
return {}
|
|
268
|
-
} catch {
|
|
269
|
-
return {}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
273
|
-
return { ...(raw as Record<string, any>) }
|
|
274
|
-
}
|
|
275
|
-
return {}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function summarizeDefinition(def: CustomFieldDef): CustomFieldDefinitionSummary | null {
|
|
279
|
-
const normalizedKey = normalizeDefinitionKey(def.key)
|
|
280
|
-
if (!normalizedKey) return null
|
|
281
|
-
const cfg = normalizeDefinitionConfig((def as any).configJson)
|
|
282
|
-
const label =
|
|
283
|
-
typeof cfg.label === 'string' && cfg.label.trim().length
|
|
284
|
-
? cfg.label.trim()
|
|
285
|
-
: def.key
|
|
286
|
-
const dictionaryId =
|
|
287
|
-
typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length
|
|
288
|
-
? cfg.dictionaryId.trim()
|
|
289
|
-
: null
|
|
290
|
-
const multi =
|
|
291
|
-
cfg.multi !== undefined ? Boolean(cfg.multi) : false
|
|
292
|
-
const priority =
|
|
293
|
-
typeof cfg.priority === 'number' ? cfg.priority : 0
|
|
294
|
-
const updatedAt =
|
|
295
|
-
def.updatedAt instanceof Date
|
|
296
|
-
? def.updatedAt.getTime()
|
|
297
|
-
: new Date(def.updatedAt as any).getTime()
|
|
298
|
-
return {
|
|
299
|
-
key: def.key,
|
|
300
|
-
label,
|
|
301
|
-
kind: typeof def.kind === 'string' ? def.kind : null,
|
|
302
|
-
multi,
|
|
303
|
-
dictionaryId,
|
|
304
|
-
organizationId: def.organizationId ?? null,
|
|
305
|
-
tenantId: def.tenantId ?? null,
|
|
306
|
-
priority,
|
|
307
|
-
updatedAt: Number.isNaN(updatedAt) ? 0 : updatedAt,
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function sortDefinitionSummaries(defs: CustomFieldDefinitionSummary[]): CustomFieldDefinitionSummary[] {
|
|
312
|
-
return [...defs].sort((a, b) => {
|
|
313
|
-
const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0)
|
|
314
|
-
if (priorityDiff !== 0) return priorityDiff
|
|
315
|
-
const updatedDiff = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
|
|
316
|
-
if (updatedDiff !== 0) return updatedDiff
|
|
317
|
-
return a.key.localeCompare(b.key)
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function selectDefinitionForRecord(
|
|
322
|
-
defs: CustomFieldDefinitionSummary[],
|
|
323
|
-
organizationId: string | null,
|
|
324
|
-
tenantId: string | null,
|
|
325
|
-
): CustomFieldDefinitionSummary | null {
|
|
326
|
-
if (!defs.length) return null
|
|
327
|
-
const prioritizedForOrg = defs.filter(
|
|
328
|
-
(def) => def.organizationId && organizationId && def.organizationId === organizationId,
|
|
329
|
-
)
|
|
330
|
-
if (prioritizedForOrg.length) return sortDefinitionSummaries(prioritizedForOrg)[0]
|
|
331
|
-
const prioritizedForTenant = defs.filter(
|
|
332
|
-
(def) => def.tenantId && tenantId && def.tenantId === tenantId && !def.organizationId,
|
|
333
|
-
)
|
|
334
|
-
if (prioritizedForTenant.length) return sortDefinitionSummaries(prioritizedForTenant)[0]
|
|
335
|
-
const global = defs.filter((def) => !def.organizationId)
|
|
336
|
-
if (global.length) return sortDefinitionSummaries(global)[0]
|
|
337
|
-
return sortDefinitionSummaries(defs)[0] ?? null
|
|
338
|
-
}
|
|
339
|
-
|
|
340
234
|
type LoadCustomFieldDefinitionIndexOptions = {
|
|
341
235
|
em: EntityManager
|
|
342
236
|
entityIds: string | string[]
|
|
@@ -463,36 +357,20 @@ async function loadCustomFieldDefinitionIndexFresh(
|
|
|
463
357
|
$and: scopeClauses,
|
|
464
358
|
}
|
|
465
359
|
const defs = await em.find(CustomFieldDef, where as any)
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
: null
|
|
480
|
-
const matches = fieldsets.length > 0
|
|
481
|
-
? fieldsets.some((entry: string) => fieldsetFilter.has(entry))
|
|
482
|
-
: fieldsetFilter.has(fieldset)
|
|
483
|
-
if (!matches) return
|
|
484
|
-
}
|
|
485
|
-
const summary = summarizeDefinition(def)
|
|
486
|
-
if (!summary) return
|
|
487
|
-
const normalizedKey = normalizeDefinitionKey(summary.key)
|
|
488
|
-
if (!normalizedKey) return
|
|
489
|
-
if (!index.has(normalizedKey)) index.set(normalizedKey, [])
|
|
490
|
-
index.get(normalizedKey)!.push(summary)
|
|
491
|
-
})
|
|
492
|
-
index.forEach((entries, key) => {
|
|
493
|
-
index.set(key, sortDefinitionSummaries(entries))
|
|
360
|
+
const rows: CustomFieldDefinitionRow[] = defs.map((def) => ({
|
|
361
|
+
key: def.key,
|
|
362
|
+
entityId: String((def as any).entityId),
|
|
363
|
+
kind: typeof def.kind === 'string' ? def.kind : null,
|
|
364
|
+
configJson: (def as any).configJson,
|
|
365
|
+
organizationId: def.organizationId ?? null,
|
|
366
|
+
tenantId: def.tenantId ?? null,
|
|
367
|
+
deletedAt: (def as any).deletedAt ?? null,
|
|
368
|
+
updatedAt: (def as any).updatedAt ?? null,
|
|
369
|
+
}))
|
|
370
|
+
return buildCustomFieldDefinitionIndexFromRows(rows, {
|
|
371
|
+
organizationIds: orgCandidates,
|
|
372
|
+
fieldset: opts.fieldset,
|
|
494
373
|
})
|
|
495
|
-
return index
|
|
496
374
|
}
|
|
497
375
|
|
|
498
376
|
export async function loadCustomFieldDefinitionIndex(opts: LoadCustomFieldDefinitionIndexOptions & {
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -33,6 +33,12 @@ import {
|
|
|
33
33
|
applyCustomFieldsNormalization,
|
|
34
34
|
loadCustomFieldDefinitionIndex,
|
|
35
35
|
} from './custom-fields'
|
|
36
|
+
import {
|
|
37
|
+
canReuseCustomFieldDefinitions,
|
|
38
|
+
resolveCfDefIndexOrgCandidates,
|
|
39
|
+
type CustomFieldDefinitionIndex,
|
|
40
|
+
type ResolvedCustomFieldDefinitions,
|
|
41
|
+
} from './custom-field-definition-index'
|
|
36
42
|
import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
|
|
37
43
|
import { CrudHttpError, isCrudHttpError } from './errors'
|
|
38
44
|
import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
|
|
@@ -956,7 +962,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
956
962
|
return null
|
|
957
963
|
}
|
|
958
964
|
|
|
959
|
-
const decorateItemsWithCustomFields = async (
|
|
965
|
+
const decorateItemsWithCustomFields = async (
|
|
966
|
+
items: any[],
|
|
967
|
+
ctx: CrudCtx,
|
|
968
|
+
precomputedDefinitions?: ResolvedCustomFieldDefinitions,
|
|
969
|
+
): Promise<any[]> => {
|
|
960
970
|
if (!listCustomFieldDecorator || !Array.isArray(items) || items.length === 0) return items
|
|
961
971
|
const entityIds = Array.isArray(listCustomFieldDecorator.entityIds)
|
|
962
972
|
? listCustomFieldDecorator.entityIds
|
|
@@ -976,19 +986,33 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
976
986
|
Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
|
|
977
987
|
? ctx.organizationIds
|
|
978
988
|
: [ctx.selectedOrganizationId ?? null]
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
tenantId: ctx.auth?.tenantId ?? null,
|
|
987
|
-
organizationIds,
|
|
988
|
-
cache: cfDefCache ?? null,
|
|
989
|
-
requestScope: ctx,
|
|
989
|
+
const tenantId = ctx.auth?.tenantId ?? null
|
|
990
|
+
// Reuse the index the query engine already resolved for this same scope
|
|
991
|
+
// (#2133) instead of issuing a second `custom_field_defs` round-trip.
|
|
992
|
+
const reusable = canReuseCustomFieldDefinitions(precomputedDefinitions, {
|
|
993
|
+
entityIds: entityIds.map(String),
|
|
994
|
+
tenantId,
|
|
995
|
+
organizationIds: resolveCfDefIndexOrgCandidates(ctx.organizationIds, ctx.selectedOrganizationId ?? null),
|
|
990
996
|
})
|
|
991
|
-
|
|
997
|
+
let definitionIndex: CustomFieldDefinitionIndex
|
|
998
|
+
if (reusable && precomputedDefinitions) {
|
|
999
|
+
definitionIndex = precomputedDefinitions.index
|
|
1000
|
+
cfProfiler.mark('definitions_reused', { definitionCount: definitionIndex.size })
|
|
1001
|
+
} else {
|
|
1002
|
+
let cfDefCache: CacheStrategy | null = null
|
|
1003
|
+
try {
|
|
1004
|
+
cfDefCache = ctx.container.resolve('cache') as CacheStrategy
|
|
1005
|
+
} catch {}
|
|
1006
|
+
definitionIndex = await loadCustomFieldDefinitionIndex({
|
|
1007
|
+
em,
|
|
1008
|
+
entityIds,
|
|
1009
|
+
tenantId,
|
|
1010
|
+
organizationIds,
|
|
1011
|
+
cache: cfDefCache ?? null,
|
|
1012
|
+
requestScope: ctx,
|
|
1013
|
+
})
|
|
1014
|
+
cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
|
|
1015
|
+
}
|
|
992
1016
|
const decoratedItems = items.map((raw) => {
|
|
993
1017
|
if (!raw || typeof raw !== 'object') return raw
|
|
994
1018
|
const item = raw as Record<string, unknown>
|
|
@@ -1572,7 +1596,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1572
1596
|
const rawItems = res.items || []
|
|
1573
1597
|
let transformedItems = rawItems.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
|
|
1574
1598
|
profiler.mark('transform_complete', { itemCount: transformedItems.length })
|
|
1575
|
-
transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx)
|
|
1599
|
+
transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx, res.customFieldDefinitions)
|
|
1576
1600
|
profiler.mark('custom_fields_complete', { itemCount: transformedItems.length })
|
|
1577
1601
|
|
|
1578
1602
|
if (opts.list?.entityId && request) {
|
|
@@ -1630,7 +1654,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1630
1654
|
const nextItemsRaw = nextRes.items || []
|
|
1631
1655
|
if (!nextItemsRaw.length) break
|
|
1632
1656
|
let nextTransformed = nextItemsRaw.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
|
|
1633
|
-
nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx)
|
|
1657
|
+
nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx, nextRes.customFieldDefinitions)
|
|
1634
1658
|
const nextExportItems = exportFullRequested
|
|
1635
1659
|
? nextItemsRaw.map(normalizeFullRecordForExport)
|
|
1636
1660
|
: nextTransformed
|
|
@@ -2094,25 +2118,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2094
2118
|
if (!ctx.auth.tenantId) return json({ error: 'Tenant context is required' }, { status: 400 })
|
|
2095
2119
|
entityData[ormCfg.tenantField] = ctx.auth.tenantId
|
|
2096
2120
|
}
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2121
|
+
const em = (ctx.container.resolve('em') as EntityManager)
|
|
2122
|
+
const writeTenantId = ctx.auth.tenantId!
|
|
2123
|
+
const entity = await em.transactional(async () => {
|
|
2124
|
+
const created = await de.createOrmEntity({ entity: ormCfg.entity, data: entityData })
|
|
2125
|
+
|
|
2126
|
+
// Custom fields
|
|
2127
|
+
if (createConfig.customFields && (createConfig.customFields as any).enabled) {
|
|
2128
|
+
const cfc = createConfig.customFields as Exclude<CustomFieldsConfig, false>
|
|
2129
|
+
const values = cfc.map
|
|
2130
|
+
? cfc.map(body)
|
|
2131
|
+
: (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
|
|
2132
|
+
if (values && Object.keys(values).length > 0) {
|
|
2133
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
2134
|
+
await de.setCustomFields({
|
|
2135
|
+
entityId: cfc.entityId as any,
|
|
2136
|
+
recordId: String((created as any)[ormCfg.idField!]),
|
|
2137
|
+
organizationId: targetOrgId,
|
|
2138
|
+
tenantId: writeTenantId,
|
|
2139
|
+
values,
|
|
2140
|
+
})
|
|
2141
|
+
}
|
|
2114
2142
|
}
|
|
2115
|
-
|
|
2143
|
+
|
|
2144
|
+
return created
|
|
2145
|
+
})
|
|
2116
2146
|
|
|
2117
2147
|
await opts.hooks?.afterCreate?.(entity, { ...ctx, input: input as any })
|
|
2118
2148
|
|
|
@@ -2414,31 +2444,38 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2414
2444
|
softDeleteField: ormCfg.softDeleteField,
|
|
2415
2445
|
}
|
|
2416
2446
|
)
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2447
|
+
const em = (ctx.container.resolve('em') as EntityManager)
|
|
2448
|
+
const writeTenantId = ctx.auth.tenantId!
|
|
2449
|
+
const entity = await em.transactional(async () => {
|
|
2450
|
+
const updated = await de.updateOrmEntity({
|
|
2451
|
+
entity: ormCfg.entity,
|
|
2452
|
+
where,
|
|
2453
|
+
apply: (e: any) => updateConfig.applyToEntity(e, input as any, ctx),
|
|
2454
|
+
})
|
|
2455
|
+
if (!updated) return null
|
|
2456
|
+
|
|
2457
|
+
// Custom fields
|
|
2458
|
+
if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
|
|
2459
|
+
const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
|
|
2460
|
+
const values = cfc.map
|
|
2461
|
+
? cfc.map(body)
|
|
2462
|
+
: (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
|
|
2463
|
+
if (values && Object.keys(values).length > 0) {
|
|
2464
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
2465
|
+
await de.setCustomFields({
|
|
2466
|
+
entityId: cfc.entityId as any,
|
|
2467
|
+
recordId: String((updated as any)[ormCfg.idField!]),
|
|
2468
|
+
organizationId: targetOrgId,
|
|
2469
|
+
tenantId: writeTenantId,
|
|
2470
|
+
values,
|
|
2471
|
+
})
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
return updated
|
|
2421
2476
|
})
|
|
2422
2477
|
if (!entity) return json({ error: 'Not found' }, { status: 404 })
|
|
2423
2478
|
|
|
2424
|
-
// Custom fields
|
|
2425
|
-
if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
|
|
2426
|
-
const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
|
|
2427
|
-
const values = cfc.map
|
|
2428
|
-
? cfc.map(body)
|
|
2429
|
-
: (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
|
|
2430
|
-
if (values && Object.keys(values).length > 0) {
|
|
2431
|
-
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
2432
|
-
await de.setCustomFields({
|
|
2433
|
-
entityId: cfc.entityId as any,
|
|
2434
|
-
recordId: String((entity as any)[ormCfg.idField!]),
|
|
2435
|
-
organizationId: targetOrgId,
|
|
2436
|
-
tenantId: ctx.auth.tenantId!,
|
|
2437
|
-
values,
|
|
2438
|
-
})
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
2479
|
await opts.hooks?.afterUpdate?.(entity, { ...ctx, input: input as any })
|
|
2443
2480
|
|
|
2444
2481
|
// Guard afterSuccess callbacks (multi)
|