@open-mercato/core 0.6.4-develop.4110.1.836aafde58 → 0.6.4-develop.4121.1.0d7f20d229

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.
@@ -131,29 +131,56 @@ export function getSelectedTenantFromRequest(
131
131
  return parseSelectedTenantCookie(header)
132
132
  }
133
133
 
134
- async function collectWithDescendants(em: EntityManager, tenantId: string, ids: string[]): Promise<Set<string>> {
135
- if (!ids.length) return new Set()
136
- const unique = Array.from(new Set(
134
+ function normalizeOrganizationIds(ids: string[]): string[] {
135
+ return Array.from(new Set(
137
136
  ids.map((value) => normalizeOrganizationId(value)).filter((value): value is string => {
138
137
  if (!value) return false
139
138
  if (isAllOrganizationsSelection(value)) return false
140
139
  return true
141
140
  })
142
141
  ))
143
- if (!unique.length) return new Set()
142
+ }
143
+
144
+ // Map each organization id to itself plus its persisted descendant ids. Only
145
+ // orgs that exist for the tenant and are not soft-deleted are included, so an
146
+ // unknown/inaccessible id simply has no entry (matching the per-id query that
147
+ // returned an empty set for it).
148
+ type OrgDescendantMap = Map<string, string[]>
149
+
150
+ // Issue #2228 — single round-trip for org-scope resolution. Instead of issuing
151
+ // one `organizations` SELECT per `collectWithDescendants` call (up to 3-4
152
+ // sequential queries per request: accessible set, fallback set, selected set),
153
+ // gather every candidate id up front and fetch their descendant expansions in
154
+ // one `em.find(Organization, { id: $in })`. Expansion then happens in-memory.
155
+ async function loadOrgDescendantMap(em: EntityManager, tenantId: string, ids: string[]): Promise<OrgDescendantMap> {
156
+ const unique = normalizeOrganizationIds(ids)
157
+ if (!unique.length) return new Map()
144
158
  const filter: FilterQuery<Organization> = {
145
159
  tenant: tenantId,
146
160
  id: { $in: unique },
147
161
  deletedAt: null,
148
162
  }
149
163
  const orgs = await em.find(Organization, filter)
150
- const set = new Set<string>()
164
+ const map: OrgDescendantMap = new Map()
151
165
  for (const org of orgs) {
152
166
  const id = String(org.id)
153
- set.add(id)
167
+ const expansion = [id]
154
168
  if (Array.isArray(org.descendantIds)) {
155
- for (const desc of org.descendantIds) set.add(String(desc))
169
+ for (const desc of org.descendantIds) expansion.push(String(desc))
156
170
  }
171
+ map.set(id, expansion)
172
+ }
173
+ return map
174
+ }
175
+
176
+ function expandWithDescendants(map: OrgDescendantMap, ids: string[]): Set<string> {
177
+ const set = new Set<string>()
178
+ for (const value of ids) {
179
+ const id = normalizeOrganizationId(value)
180
+ if (!id || isAllOrganizationsSelection(id)) continue
181
+ const expansion = map.get(id)
182
+ if (!expansion) continue
183
+ for (const entry of expansion) set.add(entry)
157
184
  }
158
185
  return set
159
186
  }
@@ -214,14 +241,18 @@ export async function resolveOrganizationScope({
214
241
 
215
242
  const accountOrgId = actorTenantId && actorTenantId === tenantId ? normalizeOrganizationId(auth.orgId) : null
216
243
  const fallbackOrgId = accountOrgId ?? null
217
- let fallbackSet: Set<string> | null = null
218
- const loadFallbackSet = async (): Promise<Set<string> | null> => {
219
- if (!fallbackOrgId) return null
220
- if (!fallbackSet) {
221
- fallbackSet = await collectWithDescendants(em, tenantId, [fallbackOrgId])
222
- }
223
- return fallbackSet
224
- }
244
+
245
+ // Every id that could be expanded below accessible set, fallback (account)
246
+ // org, and the requested selection — is known up front, so fetch them all in
247
+ // a single `organizations` query and expand from the in-memory map.
248
+ const candidateIds = [
249
+ ...(accessibleList ?? []),
250
+ ...(fallbackOrgId ? [fallbackOrgId] : []),
251
+ ...(normalizedSelectedId ? [normalizedSelectedId] : []),
252
+ ]
253
+ const orgDescendants = await loadOrgDescendantMap(em, tenantId, candidateIds)
254
+ const loadFallbackSet = (): Set<string> | null =>
255
+ fallbackOrgId ? expandWithDescendants(orgDescendants, [fallbackOrgId]) : null
225
256
 
226
257
  let allowedSet: Set<string> | null = null
227
258
  if (accessibleList === null) {
@@ -229,11 +260,11 @@ export async function resolveOrganizationScope({
229
260
  } else if (accessibleList.length === 0) {
230
261
  allowedSet = new Set()
231
262
  } else {
232
- allowedSet = await collectWithDescendants(em, tenantId, accessibleList)
263
+ allowedSet = expandWithDescendants(orgDescendants, accessibleList)
233
264
  }
234
265
 
235
266
  if (allowedSet && allowedSet.size === 0 && fallbackOrgId) {
236
- const computed = await loadFallbackSet()
267
+ const computed = loadFallbackSet()
237
268
  if (computed && computed.size > 0) {
238
269
  allowedSet = computed
239
270
  }
@@ -256,17 +287,17 @@ export async function resolveOrganizationScope({
256
287
 
257
288
  let filterSet: Set<string> | null = null
258
289
  if (effectiveSelected) {
259
- filterSet = await collectWithDescendants(em, tenantId, [effectiveSelected])
290
+ filterSet = expandWithDescendants(orgDescendants, [effectiveSelected])
260
291
  } else if (allowedSet !== null) {
261
292
  filterSet = allowedSet
262
293
  } else if (widenToAllOrgs) {
263
294
  filterSet = null
264
295
  } else if (auth.orgId) {
265
- filterSet = await loadFallbackSet()
296
+ filterSet = loadFallbackSet()
266
297
  }
267
298
 
268
299
  if ((!filterSet || filterSet.size === 0) && fallbackOrgId && !widenToAllOrgs) {
269
- const computed = await loadFallbackSet()
300
+ const computed = loadFallbackSet()
270
301
  if (computed && computed.size > 0) {
271
302
  filterSet = computed
272
303
  if (!effectiveSelected) {
@@ -65,6 +65,20 @@ export async function POST(req: Request) {
65
65
 
66
66
  await em.begin()
67
67
  try {
68
+ // Prefetch every existing definition for this entity in a single query, then index
69
+ // by key so the per-definition loop resolves create/update without round trips.
70
+ const defByKey = new Map<string, any>()
71
+ const keys = definitions.map((d) => d.key)
72
+ if (keys.length > 0) {
73
+ const existingDefs = await em.find(CustomFieldDef, {
74
+ entityId,
75
+ key: { $in: keys },
76
+ organizationId: auth.orgId ?? null,
77
+ tenantId: auth.tenantId ?? null,
78
+ })
79
+ for (const existing of existingDefs) defByKey.set(existing.key, existing)
80
+ }
81
+
68
82
  for (const [idx, d] of definitions.entries()) {
69
83
  const where: any = {
70
84
  entityId,
@@ -72,8 +86,11 @@ export async function POST(req: Request) {
72
86
  organizationId: auth.orgId ?? null,
73
87
  tenantId: auth.tenantId ?? null,
74
88
  }
75
- let def = await em.findOne(CustomFieldDef, where)
76
- if (!def) def = em.create(CustomFieldDef, { ...where, createdAt: new Date() })
89
+ let def = defByKey.get(d.key)
90
+ if (!def) {
91
+ def = em.create(CustomFieldDef, { ...where, createdAt: new Date() })
92
+ defByKey.set(d.key, def)
93
+ }
77
94
  def.kind = d.kind
78
95
 
79
96
  const inCfg = (d as any).configJson ?? {}
@@ -73,15 +73,28 @@ export async function ensureCustomFieldDefinitions(
73
73
  let updated = 0
74
74
  let unchanged = 0
75
75
 
76
+ // Prefetch every existing definition the batch could touch in a single query,
77
+ // then index by composite key so the nested loop never issues per-field lookups.
78
+ const entityIds = Array.from(new Set(sets.map((set) => set.entity)))
79
+ const fieldKeys = Array.from(new Set(sets.flatMap((set) => set.fields.map((field) => field.key))))
80
+ const existingByKey = new Map<string, CustomFieldDef>()
81
+ if (entityIds.length > 0 && fieldKeys.length > 0) {
82
+ const existingDefs = await em.find(CustomFieldDef, {
83
+ entityId: { $in: entityIds },
84
+ organizationId: scope.organizationId,
85
+ tenantId: scope.tenantId,
86
+ key: { $in: fieldKeys },
87
+ })
88
+ for (const def of existingDefs) {
89
+ existingByKey.set(`${def.entityId}|${def.key}`, def)
90
+ }
91
+ }
92
+
93
+ let dirty = false
94
+
76
95
  for (const set of sets) {
77
96
  for (const field of set.fields) {
78
- const where = {
79
- entityId: set.entity,
80
- organizationId: scope.organizationId,
81
- tenantId: scope.tenantId,
82
- key: field.key,
83
- }
84
- const existing = await em.findOne(CustomFieldDef, where)
97
+ const existing = existingByKey.get(`${set.entity}|${field.key}`) ?? null
85
98
  const configJson: Record<string, unknown> = {}
86
99
 
87
100
  for (const key of CONFIG_PASSTHROUGH_KEYS) {
@@ -91,19 +104,21 @@ export async function ensureCustomFieldDefinitions(
91
104
 
92
105
  if (!existing) {
93
106
  if (!scope.dryRun) {
94
- await em.persist(
95
- em.create(CustomFieldDef, {
96
- entityId: set.entity,
97
- organizationId: scope.organizationId,
98
- tenantId: scope.tenantId,
99
- key: field.key,
100
- kind: field.kind,
101
- configJson,
102
- isActive: true,
103
- createdAt: new Date(),
104
- updatedAt: new Date(),
105
- })
106
- ).flush()
107
+ const createdDef = em.create(CustomFieldDef, {
108
+ entityId: set.entity,
109
+ organizationId: scope.organizationId,
110
+ tenantId: scope.tenantId,
111
+ key: field.key,
112
+ kind: field.kind,
113
+ configJson,
114
+ isActive: true,
115
+ createdAt: new Date(),
116
+ updatedAt: new Date(),
117
+ })
118
+ em.persist(createdDef)
119
+ // Track so duplicate (entity, key) pairs within the batch update in memory instead of double-inserting.
120
+ existingByKey.set(`${set.entity}|${field.key}`, createdDef)
121
+ dirty = true
107
122
  }
108
123
  created++
109
124
  continue
@@ -127,11 +142,17 @@ export async function ensureCustomFieldDefinitions(
127
142
  existing.isActive = true
128
143
  existing.updatedAt = new Date()
129
144
  if (existing.deletedAt) existing.deletedAt = null
130
- await em.flush()
145
+ em.persist(existing)
146
+ dirty = true
131
147
  }
132
148
  updated++
133
149
  }
134
150
  }
135
151
 
152
+ if (dirty) {
153
+ // Single flush for the whole batch instead of one round trip per field.
154
+ await em.flush()
155
+ }
156
+
136
157
  return { created, updated, unchanged }
137
158
  }
@@ -339,15 +339,23 @@ export async function saveRolePerspectives(
339
339
 
340
340
  const results: ResolvedRolePerspective[] = []
341
341
 
342
- for (const roleId of input.roleIds) {
343
- let record = await em.findOne(RolePerspective, {
344
- roleId,
342
+ // Prefetch every matching role perspective in a single query, then index by role id
343
+ // so the loop resolves create/update without a lookup per role.
344
+ const recordByRole = new Map<string, RolePerspective>()
345
+ if (input.roleIds.length) {
346
+ const existingRecords = await em.find(RolePerspective, {
347
+ roleId: { $in: input.roleIds },
345
348
  tableId,
346
349
  tenantId,
347
350
  organizationId,
348
351
  name: input.name,
349
352
  deletedAt: null,
350
353
  })
354
+ for (const existing of existingRecords) recordByRole.set(existing.roleId, existing)
355
+ }
356
+
357
+ for (const roleId of input.roleIds) {
358
+ let record = recordByRole.get(roleId) ?? null
351
359
  if (!record) {
352
360
  record = em.create(RolePerspective, {
353
361
  roleId,
@@ -361,6 +369,7 @@ export async function saveRolePerspectives(
361
369
  updatedAt: now,
362
370
  })
363
371
  em.persist(record)
372
+ recordByRole.set(roleId, record)
364
373
  } else {
365
374
  record.settingsJson = input.settings
366
375
  record.updatedAt = now