@open-mercato/shared 0.6.6-develop.5757.1.f5cc26cf92 → 0.6.6-develop.5876.1.6a45c370ac

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.
@@ -12,7 +12,9 @@ import {
12
12
  buildCustomFieldDefinitionIndexFromRows,
13
13
  resolveCfDefIndexOrgCandidates
14
14
  } from "../crud/custom-field-definition-index.js";
15
- import { resolveEncryptedSortFields, sortRowsInMemory } from "./encrypted-sort.js";
15
+ import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
16
+ import { mapWithConcurrency } from "./bounded-decrypt.js";
17
+ const DECRYPT_CONCURRENCY = 8;
16
18
  const entityTableCache = /* @__PURE__ */ new Map();
17
19
  const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
18
20
  const isValidEntityIdShape = (value) => ENTITY_ID_PATTERN.test(value);
@@ -176,7 +178,6 @@ class BasicQueryEngine {
176
178
  opts = coreOpts;
177
179
  const table = resolveEntityTableName(this.em, entity);
178
180
  const db = this.getDb();
179
- let q = db.selectFrom(table);
180
181
  const qualify = (col) => `${table}.${col}`;
181
182
  const orgScope = this.resolveOrganizationScope(opts);
182
183
  this.searchAliasSeq = 0;
@@ -186,15 +187,6 @@ class BasicQueryEngine {
186
187
  );
187
188
  }
188
189
  const skipAutoScope = opts.omitAutomaticTenantOrgScope === true;
189
- if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
190
- q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
191
- }
192
- if (!skipAutoScope && await this.columnExists(table, "tenant_id")) {
193
- q = q.where(qualify("tenant_id"), "=", opts.tenantId);
194
- }
195
- if (!opts.withDeleted && await this.columnExists(table, "deleted_at")) {
196
- q = q.where(qualify("deleted_at"), "is", null);
197
- }
198
190
  const normalizedFilters = normalizeFilters(opts.filters);
199
191
  const resolvedJoins = resolveJoins(
200
192
  table,
@@ -307,62 +299,6 @@ class BasicQueryEngine {
307
299
  };
308
300
  const regularBaseFilters = baseFilters.filter((f) => !f.orGroup);
309
301
  const orGroupFilters = baseFilters.filter((f) => f.orGroup);
310
- for (const filter of regularBaseFilters) {
311
- const fieldName = String(filter.field);
312
- let qualified = filter.qualified ?? null;
313
- if (!qualified) {
314
- const column = await this.resolveBaseColumn(table, fieldName);
315
- if (!column) {
316
- q = this.applyIndexDocFilter(q, {
317
- entity: String(entity),
318
- field: fieldName,
319
- op: filter.op,
320
- value: filter.value,
321
- recordIdColumn,
322
- tenantId: opts.tenantId ?? null,
323
- organizationScope: orgScope,
324
- withDeleted: opts.withDeleted === true,
325
- searchActive,
326
- searchConfig
327
- });
328
- continue;
329
- }
330
- qualified = qualify(column);
331
- }
332
- q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName);
333
- }
334
- if (orGroupFilters.length > 0) {
335
- const groups = /* @__PURE__ */ new Map();
336
- for (const f of orGroupFilters) {
337
- const group = groups.get(f.orGroup) ?? [];
338
- group.push(f);
339
- groups.set(f.orGroup, group);
340
- }
341
- const resolvedGroupFilters = [];
342
- for (const [, groupFilters] of groups) {
343
- const resolved = [];
344
- for (const filter of groupFilters) {
345
- const column = await this.resolveBaseColumn(table, String(filter.field));
346
- if (column) {
347
- resolved.push({
348
- qualified: qualify(column),
349
- op: filter.op,
350
- value: filter.value,
351
- fieldName: String(filter.field)
352
- });
353
- }
354
- }
355
- if (resolved.length > 0) resolvedGroupFilters.push(resolved);
356
- }
357
- if (resolvedGroupFilters.length > 0) {
358
- q = q.where((eb) => eb.or(
359
- resolvedGroupFilters.map((group) => {
360
- const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value));
361
- return parts.length === 1 ? parts[0] : eb.and(parts);
362
- })
363
- ));
364
- }
365
- }
366
302
  const applyAliasScopes = async (builder, aliasName) => {
367
303
  const targetTable = aliasTables.get(aliasName);
368
304
  if (!targetTable) return builder;
@@ -375,19 +311,6 @@ class BasicQueryEngine {
375
311
  }
376
312
  return next;
377
313
  };
378
- q = await applyJoinFilters({
379
- db,
380
- baseTable: table,
381
- builder: q,
382
- joinMap,
383
- joinFilters,
384
- aliasTables,
385
- qualifyBase: (column) => qualify(column),
386
- applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
387
- applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
388
- applyJoinFilterOp,
389
- columnExists: (tbl, column) => this.columnExists(tbl, column)
390
- });
391
314
  const fallbackOrgId = opts.organizationId ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null);
392
315
  const encryptionService = this.getEncryptionService();
393
316
  const resolvedSorts = [];
@@ -407,267 +330,380 @@ class BasicQueryEngine {
407
330
  fallbackOrgId
408
331
  );
409
332
  const requiresPlaintextSort = encryptedSortFields.size > 0;
410
- if (opts.fields && opts.fields.length) {
411
- const cols = new Set(opts.fields.filter((f) => !f.startsWith("cf:")));
412
- if (requiresPlaintextSort) {
413
- for (const field of encryptedSortFields) cols.add(field);
414
- }
415
- for (const c of cols) {
416
- q = q.select(sql.ref(qualify(c)).as(c));
417
- }
418
- } else {
419
- q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
420
- }
421
333
  const tenantId = opts.tenantId;
422
334
  const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
423
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
424
- q = cfSourcesResult.builder;
425
- const cfSources = cfSourcesResult.sources;
426
- const entityIdToSource = /* @__PURE__ */ new Map();
427
- for (const source of cfSources) {
428
- entityIdToSource.set(String(source.entityId), source);
429
- }
430
- const requestedCustomFieldKeys = Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
431
- const cfKeys = /* @__PURE__ */ new Set();
432
- const keySource = /* @__PURE__ */ new Map();
433
- let resolvedCustomFieldDefinitions;
434
- for (const f of opts.fields || []) {
435
- if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
436
- }
437
- for (const f of cfFilters) {
438
- if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
439
- }
440
- if (opts.includeCustomFields === true) {
441
- if (entityIdToSource.size > 0) {
442
- const entityIdList = Array.from(entityIdToSource.keys());
443
- const entityOrder = /* @__PURE__ */ new Map();
444
- entityIdList.forEach((id, idx) => entityOrder.set(id, idx));
445
- const rows = await db.selectFrom("custom_field_defs").select([
446
- "key",
447
- "entity_id",
448
- "config_json",
449
- "kind",
450
- "organization_id",
451
- "tenant_id",
452
- "updated_at",
453
- "deleted_at"
454
- ]).where("entity_id", "in", entityIdList).where("is_active", "=", true).where((eb) => eb.or([
455
- eb("tenant_id", "=", tenantId),
456
- eb("tenant_id", "is", null)
457
- ])).execute();
458
- const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null);
459
- const definitionRows = rows.map((row) => ({
460
- key: String(row.key),
461
- entityId: String(row.entity_id),
462
- kind: row.kind == null ? null : String(row.kind),
463
- configJson: row.config_json,
464
- organizationId: row.organization_id == null ? null : String(row.organization_id),
465
- tenantId: row.tenant_id == null ? null : String(row.tenant_id),
466
- deletedAt: row.deleted_at ?? null,
467
- updatedAt: row.updated_at ?? null
468
- }));
469
- resolvedCustomFieldDefinitions = {
470
- index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
471
- entityIds: entityIdList,
472
- tenantId: tenantId ?? null,
473
- organizationIds: orgCandidates
474
- };
475
- const sorted = rows.map((row) => {
476
- const raw = row.config_json;
477
- let cfg = {};
478
- if (raw && typeof raw === "string") {
479
- try {
480
- cfg = JSON.parse(raw);
481
- } catch {
482
- cfg = {};
335
+ const buildQuery = async (projection) => {
336
+ const isSortKeysProjection = projection === "sortKeys";
337
+ let q = db.selectFrom(table);
338
+ if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
339
+ q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
340
+ }
341
+ if (!skipAutoScope && await this.columnExists(table, "tenant_id")) {
342
+ q = q.where(qualify("tenant_id"), "=", opts.tenantId);
343
+ }
344
+ if (!opts.withDeleted && await this.columnExists(table, "deleted_at")) {
345
+ q = q.where(qualify("deleted_at"), "is", null);
346
+ }
347
+ for (const filter of regularBaseFilters) {
348
+ const fieldName = String(filter.field);
349
+ let qualified = filter.qualified ?? null;
350
+ if (!qualified) {
351
+ const column = await this.resolveBaseColumn(table, fieldName);
352
+ if (!column) {
353
+ q = this.applyIndexDocFilter(q, {
354
+ entity: String(entity),
355
+ field: fieldName,
356
+ op: filter.op,
357
+ value: filter.value,
358
+ recordIdColumn,
359
+ tenantId: opts.tenantId ?? null,
360
+ organizationScope: orgScope,
361
+ withDeleted: opts.withDeleted === true,
362
+ searchActive,
363
+ searchConfig
364
+ });
365
+ continue;
366
+ }
367
+ qualified = qualify(column);
368
+ }
369
+ q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName);
370
+ }
371
+ if (orGroupFilters.length > 0) {
372
+ const groups = /* @__PURE__ */ new Map();
373
+ for (const f of orGroupFilters) {
374
+ const group = groups.get(f.orGroup) ?? [];
375
+ group.push(f);
376
+ groups.set(f.orGroup, group);
377
+ }
378
+ const resolvedGroupFilters = [];
379
+ for (const [, groupFilters] of groups) {
380
+ const resolved = [];
381
+ for (const filter of groupFilters) {
382
+ const column = await this.resolveBaseColumn(table, String(filter.field));
383
+ if (column) {
384
+ resolved.push({
385
+ qualified: qualify(column),
386
+ op: filter.op,
387
+ value: filter.value,
388
+ fieldName: String(filter.field)
389
+ });
483
390
  }
484
- } else if (raw && typeof raw === "object") {
485
- cfg = raw;
486
391
  }
487
- return {
392
+ if (resolved.length > 0) resolvedGroupFilters.push(resolved);
393
+ }
394
+ if (resolvedGroupFilters.length > 0) {
395
+ q = q.where((eb) => eb.or(
396
+ resolvedGroupFilters.map((group) => {
397
+ const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value));
398
+ return parts.length === 1 ? parts[0] : eb.and(parts);
399
+ })
400
+ ));
401
+ }
402
+ }
403
+ q = await applyJoinFilters({
404
+ db,
405
+ baseTable: table,
406
+ builder: q,
407
+ joinMap,
408
+ joinFilters,
409
+ aliasTables,
410
+ qualifyBase: (column) => qualify(column),
411
+ applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
412
+ applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
413
+ applyJoinFilterOp,
414
+ columnExists: (tbl, column) => this.columnExists(tbl, column)
415
+ });
416
+ if (isSortKeysProjection) {
417
+ q = q.select(sql.ref(qualify("id")).as("id"));
418
+ for (const s of resolvedSorts) {
419
+ if (!s.field.startsWith("cf:")) q = q.select(sql.ref(qualify(s.field)).as(s.field));
420
+ }
421
+ } else if (opts.fields && opts.fields.length) {
422
+ const cols = new Set(opts.fields.filter((f) => !f.startsWith("cf:")));
423
+ if (requiresPlaintextSort) {
424
+ for (const field of encryptedSortFields) cols.add(field);
425
+ }
426
+ for (const c of cols) {
427
+ q = q.select(sql.ref(qualify(c)).as(c));
428
+ }
429
+ } else {
430
+ q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
431
+ }
432
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
433
+ q = cfSourcesResult.builder;
434
+ const cfSources = cfSourcesResult.sources;
435
+ const entityIdToSource = /* @__PURE__ */ new Map();
436
+ for (const source of cfSources) {
437
+ entityIdToSource.set(String(source.entityId), source);
438
+ }
439
+ const requestedCustomFieldKeys = !isSortKeysProjection && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
440
+ const cfKeys = /* @__PURE__ */ new Set();
441
+ const keySource = /* @__PURE__ */ new Map();
442
+ let resolvedCustomFieldDefinitions2;
443
+ if (!isSortKeysProjection) {
444
+ for (const f of opts.fields || []) {
445
+ if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
446
+ }
447
+ }
448
+ for (const f of cfFilters) {
449
+ if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
450
+ }
451
+ if (!isSortKeysProjection && opts.includeCustomFields === true) {
452
+ if (entityIdToSource.size > 0) {
453
+ const entityIdList = Array.from(entityIdToSource.keys());
454
+ const entityOrder = /* @__PURE__ */ new Map();
455
+ entityIdList.forEach((id, idx) => entityOrder.set(id, idx));
456
+ const rows = await db.selectFrom("custom_field_defs").select([
457
+ "key",
458
+ "entity_id",
459
+ "config_json",
460
+ "kind",
461
+ "organization_id",
462
+ "tenant_id",
463
+ "updated_at",
464
+ "deleted_at"
465
+ ]).where("entity_id", "in", entityIdList).where("is_active", "=", true).where((eb) => eb.or([
466
+ eb("tenant_id", "=", tenantId),
467
+ eb("tenant_id", "is", null)
468
+ ])).execute();
469
+ const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null);
470
+ const definitionRows = rows.map((row) => ({
488
471
  key: String(row.key),
489
472
  entityId: String(row.entity_id),
490
- kind: String(row.kind || ""),
491
- config: cfg
473
+ kind: row.kind == null ? null : String(row.kind),
474
+ configJson: row.config_json,
475
+ organizationId: row.organization_id == null ? null : String(row.organization_id),
476
+ tenantId: row.tenant_id == null ? null : String(row.tenant_id),
477
+ deletedAt: row.deleted_at ?? null,
478
+ updatedAt: row.updated_at ?? null
479
+ }));
480
+ resolvedCustomFieldDefinitions2 = {
481
+ index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
482
+ entityIds: entityIdList,
483
+ tenantId: tenantId ?? null,
484
+ organizationIds: orgCandidates
492
485
  };
493
- });
494
- sorted.sort((a, b) => {
495
- const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER;
496
- const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER;
497
- if (ai !== bi) return ai - bi;
498
- return a.key.localeCompare(b.key);
499
- });
500
- const selectedSources = /* @__PURE__ */ new Map();
501
- for (const row of sorted) {
502
- const source = entityIdToSource.get(row.entityId);
503
- if (!source) continue;
504
- const cfg = row.config || {};
505
- const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER;
506
- const scores = computeCustomFieldScore(cfg, row.kind, entityIndex);
507
- const existing = selectedSources.get(row.key);
508
- if (!existing || scores.base > existing.score || scores.base === existing.score && (scores.penalty < existing.penalty || scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)) {
509
- selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex });
486
+ const sorted = rows.map((row) => {
487
+ const raw = row.config_json;
488
+ let cfg = {};
489
+ if (raw && typeof raw === "string") {
490
+ try {
491
+ cfg = JSON.parse(raw);
492
+ } catch {
493
+ cfg = {};
494
+ }
495
+ } else if (raw && typeof raw === "object") {
496
+ cfg = raw;
497
+ }
498
+ return {
499
+ key: String(row.key),
500
+ entityId: String(row.entity_id),
501
+ kind: String(row.kind || ""),
502
+ config: cfg
503
+ };
504
+ });
505
+ sorted.sort((a, b) => {
506
+ const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER;
507
+ const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER;
508
+ if (ai !== bi) return ai - bi;
509
+ return a.key.localeCompare(b.key);
510
+ });
511
+ const selectedSources = /* @__PURE__ */ new Map();
512
+ for (const row of sorted) {
513
+ const source = entityIdToSource.get(row.entityId);
514
+ if (!source) continue;
515
+ const cfg = row.config || {};
516
+ const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER;
517
+ const scores = computeCustomFieldScore(cfg, row.kind, entityIndex);
518
+ const existing = selectedSources.get(row.key);
519
+ if (!existing || scores.base > existing.score || scores.base === existing.score && (scores.penalty < existing.penalty || scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)) {
520
+ selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex });
521
+ }
522
+ cfKeys.add(row.key);
523
+ }
524
+ for (const [key, entry] of selectedSources.entries()) {
525
+ keySource.set(key, entry.source);
510
526
  }
511
- cfKeys.add(row.key);
512
527
  }
513
- for (const [key, entry] of selectedSources.entries()) {
514
- keySource.set(key, entry.source);
528
+ } else if (!isSortKeysProjection && requestedCustomFieldKeys.length > 0) {
529
+ for (const key of requestedCustomFieldKeys) cfKeys.add(key);
530
+ }
531
+ const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key));
532
+ if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
533
+ const rows = await db.selectFrom("custom_field_defs").select(["key", "entity_id"]).where("entity_id", "in", Array.from(entityIdToSource.keys())).where("key", "in", unresolvedKeys).where("is_active", "=", true).where((eb) => eb.or([
534
+ eb("tenant_id", "=", tenantId),
535
+ eb("tenant_id", "is", null)
536
+ ])).execute();
537
+ for (const row of rows) {
538
+ const source = entityIdToSource.get(String(row.entity_id));
539
+ if (!source) continue;
540
+ if (!keySource.has(row.key)) keySource.set(row.key, source);
515
541
  }
516
542
  }
517
- } else if (requestedCustomFieldKeys.length > 0) {
518
- for (const key of requestedCustomFieldKeys) cfKeys.add(key);
519
- }
520
- const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key));
521
- if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
522
- const rows = await db.selectFrom("custom_field_defs").select(["key", "entity_id"]).where("entity_id", "in", Array.from(entityIdToSource.keys())).where("key", "in", unresolvedKeys).where("is_active", "=", true).where((eb) => eb.or([
523
- eb("tenant_id", "=", tenantId),
524
- eb("tenant_id", "is", null)
525
- ])).execute();
526
- for (const row of rows) {
527
- const source = entityIdToSource.get(String(row.entity_id));
543
+ const cfValueExprByKey = {};
544
+ const cfSelectedAliases = [];
545
+ const cfJsonAliases2 = /* @__PURE__ */ new Set();
546
+ const cfMultiAliasByAlias2 = /* @__PURE__ */ new Map();
547
+ for (const key of cfKeys) {
548
+ const source = keySource.get(key);
528
549
  if (!source) continue;
529
- if (!keySource.has(row.key)) keySource.set(row.key, source);
530
- }
531
- }
532
- const cfValueExprByKey = {};
533
- const cfSelectedAliases = [];
534
- const cfJsonAliases = /* @__PURE__ */ new Set();
535
- const cfMultiAliasByAlias = /* @__PURE__ */ new Map();
536
- for (const key of cfKeys) {
537
- const source = keySource.get(key);
538
- if (!source) continue;
539
- const entityIdForKey = source.entityId;
540
- const recordIdExpr = source.recordIdExpr;
541
- const sourceAliasSafe = sanitize(source.alias || "src");
542
- const keyAliasSafe = sanitize(key);
543
- const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`;
544
- const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`;
545
- q = q.leftJoin(
546
- `custom_field_defs as ${defAlias}`,
547
- (jb) => jb.on(`${defAlias}.entity_id`, "=", String(entityIdForKey)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((eb) => eb.or([
548
- eb(`${defAlias}.tenant_id`, "=", tenantId),
549
- eb(`${defAlias}.tenant_id`, "is", null)
550
- ]))
551
- );
552
- q = q.leftJoin(
553
- `custom_field_values as ${valAlias}`,
554
- (jb) => jb.on(`${valAlias}.entity_id`, "=", String(entityIdForKey)).on(`${valAlias}.field_key`, "=", key).onRef(`${valAlias}.record_id`, "=", recordIdExpr).on((eb) => eb.or([
555
- eb(`${valAlias}.tenant_id`, "=", tenantId),
556
- eb(`${valAlias}.tenant_id`, "is", null)
557
- ]))
558
- );
559
- const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
560
- WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
561
- WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
562
- WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
563
- WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
564
- ELSE (${sql.ref(`${valAlias}.value_text`)})::text
565
- END`;
566
- cfValueExprByKey[key] = caseExpr;
567
- const alias = sanitize(`cf:${key}`);
568
- if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key)) {
569
- const multiAlias = `${alias}__is_multi`;
570
- const isMultiExpr = sql`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`;
571
- const aggregatedArray = sql`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`;
572
- const projExpr = sql`CASE WHEN ${isMultiExpr}
573
- THEN to_jsonb(${aggregatedArray})
574
- ELSE to_jsonb(max(${caseExpr}))
550
+ const entityIdForKey = source.entityId;
551
+ const recordIdExpr = source.recordIdExpr;
552
+ const sourceAliasSafe = sanitize(source.alias || "src");
553
+ const keyAliasSafe = sanitize(key);
554
+ const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`;
555
+ const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`;
556
+ q = q.leftJoin(
557
+ `custom_field_defs as ${defAlias}`,
558
+ (jb) => jb.on(`${defAlias}.entity_id`, "=", String(entityIdForKey)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((eb) => eb.or([
559
+ eb(`${defAlias}.tenant_id`, "=", tenantId),
560
+ eb(`${defAlias}.tenant_id`, "is", null)
561
+ ]))
562
+ );
563
+ q = q.leftJoin(
564
+ `custom_field_values as ${valAlias}`,
565
+ (jb) => jb.on(`${valAlias}.entity_id`, "=", String(entityIdForKey)).on(`${valAlias}.field_key`, "=", key).onRef(`${valAlias}.record_id`, "=", recordIdExpr).on((eb) => eb.or([
566
+ eb(`${valAlias}.tenant_id`, "=", tenantId),
567
+ eb(`${valAlias}.tenant_id`, "is", null)
568
+ ]))
569
+ );
570
+ const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
571
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
572
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
573
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
574
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
575
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
575
576
  END`;
576
- q = q.select(projExpr.as(alias));
577
- q = q.select(isMultiExpr.as(multiAlias));
578
- cfSelectedAliases.push(alias);
579
- cfJsonAliases.add(alias);
580
- cfMultiAliasByAlias.set(alias, multiAlias);
577
+ cfValueExprByKey[key] = caseExpr;
578
+ const alias = sanitize(`cf:${key}`);
579
+ if (!isSortKeysProjection && ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
580
+ const multiAlias = `${alias}__is_multi`;
581
+ const isMultiExpr = sql`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`;
582
+ const aggregatedArray = sql`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`;
583
+ const projExpr = sql`CASE WHEN ${isMultiExpr}
584
+ THEN to_jsonb(${aggregatedArray})
585
+ ELSE to_jsonb(max(${caseExpr}))
586
+ END`;
587
+ q = q.select(projExpr.as(alias));
588
+ q = q.select(isMultiExpr.as(multiAlias));
589
+ cfSelectedAliases.push(alias);
590
+ cfJsonAliases2.add(alias);
591
+ cfMultiAliasByAlias2.set(alias, multiAlias);
592
+ }
581
593
  }
582
- }
583
- for (const f of cfFilters) {
584
- if (!f.field.startsWith("cf:")) continue;
585
- const key = f.field.slice(3);
586
- const expr = cfValueExprByKey[key];
587
- if (!expr) continue;
588
- if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
589
- const tokens = tokenizeText(String(f.value), searchConfig);
590
- const hashes = tokens.hashes;
591
- if (hashes.length) {
592
- const result = this.applySearchTokens(q, {
593
- entity: String(entity),
594
- field: f.field,
595
- hashes,
596
- recordIdColumn,
597
- tenantId: opts.tenantId ?? null,
598
- organizationScope: orgScope,
599
- tokens: tokens.tokens
600
- });
601
- this.logSearchDebug("search:cf-filter", {
602
- entity: String(entity),
603
- field: f.field,
604
- tokens: tokens.tokens,
605
- hashes,
606
- applied: result.applied,
607
- tenantId: opts.tenantId ?? null,
608
- organizationScope: orgScope
609
- });
610
- if (result.applied) {
611
- q = result.builder;
612
- continue;
594
+ for (const f of cfFilters) {
595
+ if (!f.field.startsWith("cf:")) continue;
596
+ const key = f.field.slice(3);
597
+ const expr = cfValueExprByKey[key];
598
+ if (!expr) continue;
599
+ if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
600
+ const tokens = tokenizeText(String(f.value), searchConfig);
601
+ const hashes = tokens.hashes;
602
+ if (hashes.length) {
603
+ const result = this.applySearchTokens(q, {
604
+ entity: String(entity),
605
+ field: f.field,
606
+ hashes,
607
+ recordIdColumn,
608
+ tenantId: opts.tenantId ?? null,
609
+ organizationScope: orgScope,
610
+ tokens: tokens.tokens
611
+ });
612
+ this.logSearchDebug("search:cf-filter", {
613
+ entity: String(entity),
614
+ field: f.field,
615
+ tokens: tokens.tokens,
616
+ hashes,
617
+ applied: result.applied,
618
+ tenantId: opts.tenantId ?? null,
619
+ organizationScope: orgScope
620
+ });
621
+ if (result.applied) {
622
+ q = result.builder;
623
+ continue;
624
+ }
625
+ } else {
626
+ this.logSearchDebug("search:cf-skip-empty-hashes", {
627
+ entity: String(entity),
628
+ field: f.field,
629
+ value: f.value
630
+ });
613
631
  }
614
- } else {
615
- this.logSearchDebug("search:cf-skip-empty-hashes", {
616
- entity: String(entity),
617
- field: f.field,
618
- value: f.value
619
- });
620
632
  }
633
+ q = this.applyColumnOp(q, expr, f.op, f.value);
621
634
  }
622
- q = this.applyColumnOp(q, expr, f.op, f.value);
623
- }
624
- if (opts.includeExtensions) {
625
- const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
626
- const allMods = getModules();
627
- const allExts = allMods.flatMap((m) => m.entityExtensions || []);
628
- const exts = allExts.filter((e) => e.base === entity);
629
- const chosen = Array.isArray(opts.includeExtensions) ? exts.filter((e) => opts.includeExtensions.includes(e.extension)) : exts;
630
- for (const e of chosen) {
631
- const [, extName] = e.extension.split(":");
632
- const extTable = extName.endsWith("s") ? extName : `${extName}s`;
633
- const alias = `ext_${sanitize(extName)}`;
634
- q = q.leftJoin(
635
- `${extTable} as ${alias}`,
636
- (jb) => jb.onRef(`${alias}.${e.join.extensionKey}`, "=", `${table}.${e.join.baseKey}`)
637
- );
635
+ if (opts.includeExtensions) {
636
+ const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
637
+ const allMods = getModules();
638
+ const allExts = allMods.flatMap((m) => m.entityExtensions || []);
639
+ const exts = allExts.filter((e) => e.base === entity);
640
+ const chosen = Array.isArray(opts.includeExtensions) ? exts.filter((e) => opts.includeExtensions.includes(e.extension)) : exts;
641
+ for (const e of chosen) {
642
+ const [, extName] = e.extension.split(":");
643
+ const extTable = extName.endsWith("s") ? extName : `${extName}s`;
644
+ const alias = `ext_${sanitize(extName)}`;
645
+ q = q.leftJoin(
646
+ `${extTable} as ${alias}`,
647
+ (jb) => jb.onRef(`${alias}.${e.join.extensionKey}`, "=", `${table}.${e.join.baseKey}`)
648
+ );
649
+ }
638
650
  }
639
- }
640
- for (const s of resolvedSorts) {
641
- if (s.field.startsWith("cf:")) {
642
- const key = s.field.slice(3);
643
- const alias = sanitize(`cf:${key}`);
644
- if (!cfSelectedAliases.includes(alias)) {
645
- const expr = cfValueExprByKey[key];
646
- if (expr) {
647
- q = q.select(sql`max(${expr})`.as(alias));
648
- cfSelectedAliases.push(alias);
651
+ for (const s of resolvedSorts) {
652
+ if (s.field.startsWith("cf:")) {
653
+ const key = s.field.slice(3);
654
+ const alias = sanitize(`cf:${key}`);
655
+ if (!cfSelectedAliases.includes(alias)) {
656
+ const expr = cfValueExprByKey[key];
657
+ if (expr) {
658
+ q = q.select(sql`max(${expr})`.as(alias));
659
+ cfSelectedAliases.push(alias);
660
+ }
649
661
  }
662
+ if (!requiresPlaintextSort) q = q.orderBy(alias, s.dir ?? "asc");
663
+ } else {
664
+ if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
650
665
  }
651
- if (!requiresPlaintextSort) q = q.orderBy(alias, s.dir ?? "asc");
652
- } else {
653
- if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
654
666
  }
655
- }
667
+ const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
668
+ if (hasJoinedAggregates2) {
669
+ q = q.groupBy(`${table}.id`);
670
+ }
671
+ return { builder: q, hasJoinedAggregates: hasJoinedAggregates2, cfJsonAliases: cfJsonAliases2, cfMultiAliasByAlias: cfMultiAliasByAlias2, resolvedCustomFieldDefinitions: resolvedCustomFieldDefinitions2 };
672
+ };
656
673
  const page = opts.page?.page ?? 1;
657
674
  const pageSize = opts.page?.pageSize ?? 20;
658
- const hasJoinedAggregates = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
659
- if (hasJoinedAggregates) {
660
- q = q.groupBy(`${table}.id`);
661
- }
675
+ const {
676
+ builder: qFull,
677
+ hasJoinedAggregates,
678
+ cfJsonAliases,
679
+ cfMultiAliasByAlias,
680
+ resolvedCustomFieldDefinitions
681
+ } = await buildQuery("full");
662
682
  const mayMultiplyBaseRows = hasJoinedAggregates || Array.isArray(opts.joins) && opts.joins.length > 0 || Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0;
663
683
  const countExpr = mayMultiplyBaseRows ? sql`count(distinct ${sql.ref(`${table}.id`)})` : sql`count(*)`;
664
- const countBuilder = hasJoinedAggregates ? q.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as("count")) : q.clearSelect().clearOrderBy().select(countExpr.as("count"));
684
+ const countBuilder = hasJoinedAggregates ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as("count")) : qFull.clearSelect().clearOrderBy().select(countExpr.as("count"));
665
685
  const countRow = await countBuilder.executeTakeFirst();
666
686
  const total = Number(countRow?.count ?? 0);
667
- const dataQuery = requiresPlaintextSort ? q : q.limit(pageSize).offset((page - 1) * pageSize);
668
- const items = await dataQuery.execute();
669
- if (cfJsonAliases.size > 0) {
670
- for (const row of items) {
687
+ const svc = encryptionService;
688
+ const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
689
+ const decryptRow = async (item) => {
690
+ if (!decryptPayload) return item;
691
+ try {
692
+ const decrypted = await decryptPayload(
693
+ entity,
694
+ item,
695
+ item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
696
+ item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null
697
+ );
698
+ return { ...item, ...decrypted };
699
+ } catch (err) {
700
+ console.error("QueryEngine: error decrypting entity payload", err);
701
+ return item;
702
+ }
703
+ };
704
+ const normalizeCfJsonAliases = (rows) => {
705
+ if (cfJsonAliases.size === 0) return;
706
+ for (const row of rows) {
671
707
  for (const alias of cfJsonAliases) {
672
708
  const multiAlias = cfMultiAliasByAlias.get(alias);
673
709
  const isMulti = multiAlias ? Boolean(row[multiAlias]) : false;
@@ -689,30 +725,47 @@ class BasicQueryEngine {
689
725
  if (multiAlias) delete row[multiAlias];
690
726
  }
691
727
  }
728
+ };
729
+ let pagedItems;
730
+ let encryptedSortRowCapWarning;
731
+ if (requiresPlaintextSort) {
732
+ const cap = resolveEncryptedSortMaxRows();
733
+ let qSort = (await buildQuery("sortKeys")).builder;
734
+ if (cap !== null) {
735
+ qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
736
+ }
737
+ const candidateRows = await qSort.execute();
738
+ const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
739
+ const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
740
+ const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
741
+ if (cap !== null && total > cap) {
742
+ encryptedSortRowCapWarning = {
743
+ entity,
744
+ sortFields: resolvedSorts.map((s) => s.field),
745
+ maxRows: cap,
746
+ totalMatched: total
747
+ };
748
+ }
749
+ if (pageIds.length === 0) {
750
+ pagedItems = [];
751
+ } else {
752
+ const pageRows = await qFull.where(qualify("id"), "in", pageIds).execute();
753
+ normalizeCfJsonAliases(pageRows);
754
+ const decryptedPageRows = decryptPayload ? await mapWithConcurrency(pageRows, DECRYPT_CONCURRENCY, decryptRow) : pageRows;
755
+ const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]));
756
+ pagedItems = pageIds.map((id) => byId.get(String(id))).filter((row) => row != null);
757
+ }
758
+ } else {
759
+ const dataQuery = qFull.limit(pageSize).offset((page - 1) * pageSize);
760
+ const items = await dataQuery.execute();
761
+ normalizeCfJsonAliases(items);
762
+ pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
692
763
  }
693
- const svc = encryptionService;
694
- const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
695
- let decryptedItems = items;
696
- if (decryptPayload) {
697
- decryptedItems = await Promise.all(
698
- items.map(async (item) => {
699
- try {
700
- const decrypted = await decryptPayload(
701
- entity,
702
- item,
703
- item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
704
- item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null
705
- );
706
- return { ...item, ...decrypted };
707
- } catch (err) {
708
- console.error("QueryEngine: error decrypting entity payload", err);
709
- return item;
710
- }
711
- })
712
- );
713
- }
714
- const pagedItems = requiresPlaintextSort ? sortRowsInMemory(decryptedItems, resolvedSorts).slice((page - 1) * pageSize, page * pageSize) : decryptedItems;
715
764
  let queryResult = { items: pagedItems, page, pageSize, total };
765
+ if (encryptedSortRowCapWarning) {
766
+ const meta = { encryptedSortRowCapWarning };
767
+ queryResult.meta = meta;
768
+ }
716
769
  if (ext && extensionCtx) {
717
770
  const diCtx = ext.resolve ? { resolve: ext.resolve } : noop;
718
771
  queryResult = await runAfterQueryPipeline(