@open-mercato/core 0.6.6-develop.5834.1.dc9eb0615e → 0.6.6-develop.5900.1.b3fb52925e

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.
@@ -1,4 +1,4 @@
1
- import type { QueryEngine, QueryOptions, QueryResult, FilterOp, Filter, QueryCustomFieldSource, PartialIndexWarning, QueryExtensionsConfig, Sort } from '@open-mercato/shared/lib/query/types'
1
+ import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, FilterOp, Filter, QueryCustomFieldSource, PartialIndexWarning, QueryExtensionsConfig, Sort } from '@open-mercato/shared/lib/query/types'
2
2
  import { SortDir } from '@open-mercato/shared/lib/query/types'
3
3
  import type { EntityId } from '@open-mercato/shared/modules/entities'
4
4
  import type { EntityManager } from '@mikro-orm/postgresql'
@@ -21,7 +21,10 @@ import {
21
21
  import { resolveSearchConfig, type SearchConfig } from '@open-mercato/shared/lib/search/config'
22
22
  import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
23
23
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from '@open-mercato/shared/lib/query/query-extension-runner'
24
- import { resolveEncryptedSortFields, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
24
+ import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
25
+ import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
26
+
27
+ const DECRYPT_CONCURRENCY = 8
25
28
 
26
29
  function buildFilterableCustomFieldJoins(
27
30
  sources: QueryCustomFieldSource[] | undefined,
@@ -795,9 +798,10 @@ export class HybridQueryEngine implements QueryEngine {
795
798
  }
796
799
  const selectFields = Array.from(selectFieldSet)
797
800
 
798
- const applySelection = (q: AnyBuilder): AnyBuilder => {
801
+ const applySelection = (q: AnyBuilder, fieldsOverride?: string[]): AnyBuilder => {
799
802
  let next = q
800
- for (const field of selectFields) {
803
+ const fields = fieldsOverride ?? selectFields
804
+ for (const field of fields) {
801
805
  const fieldName = String(field)
802
806
  if (fieldName.startsWith('cf:')) {
803
807
  const alias = this.sanitize(fieldName)
@@ -888,71 +892,131 @@ export class HybridQueryEngine implements QueryEngine {
888
892
  total = this.parseCount(countRow)
889
893
  }
890
894
 
891
- const dataRoot = db.selectFrom(`${baseTable} as b` as any)
892
- let dataBuilder = await applyQueryShape(dataRoot)
893
- dataBuilder = applySelection(dataBuilder)
894
- dataBuilder = applySort(dataBuilder)
895
- if (!requiresPlaintextSort) {
896
- dataBuilder = dataBuilder.limit(pageSize).offset((page - 1) * pageSize)
897
- }
895
+ const dekKeyCache = new Map<string | null, string | null>()
898
896
 
899
- if (debugEnabled && sqlDebugEnabled) {
900
- const compiled = dataBuilder.compile()
901
- this.debug('query:sql:data', { entity, sql: compiled.sql, bindings: compiled.parameters, page, pageSize })
897
+ const decryptRow = async (item: Record<string, unknown>): Promise<Record<string, unknown>> => {
898
+ let next = item
899
+ if (encSvc?.decryptEntityPayload) {
900
+ const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
901
+ entityId: EntityId, payload: Record<string, unknown>, tenantId: string | null, organizationId: string | null,
902
+ ) => Promise<Record<string, unknown>>
903
+ try {
904
+ const decrypted = await decrypt(
905
+ entity, next,
906
+ (next?.tenant_id ?? next?.tenantId ?? opts.tenantId ?? null) as string | null,
907
+ (next?.organization_id ?? next?.organizationId ?? fallbackOrgId ?? null) as string | null,
908
+ )
909
+ next = { ...next, ...decrypted }
910
+ } catch (err) {
911
+ console.error('Error decrypting entity payload', err)
912
+ }
913
+ }
914
+ if (encSvc) {
915
+ try {
916
+ next = await decryptIndexDocCustomFields(
917
+ next,
918
+ {
919
+ tenantId: (next?.tenant_id ?? next?.tenantId ?? opts.tenantId ?? null) as string | null,
920
+ organizationId: (next?.organization_id ?? next?.organizationId ?? null) as string | null,
921
+ },
922
+ encSvc as any, dekKeyCache,
923
+ )
924
+ } catch { /* keep next as-is */ }
925
+ }
926
+ return next
902
927
  }
903
- const itemsRaw = await this.captureSqlTiming(
904
- 'query:sql:data', entity,
905
- () => dataBuilder.execute(),
906
- { page, pageSize }, profiler,
907
- )
908
- if (debugEnabled) this.debug('query:complete', { entity, total, items: Array.isArray(itemsRaw) ? itemsRaw.length : 0 })
909
928
 
910
- let items = itemsRaw as any[]
911
- const dekKeyCache = new Map<string | null, string | null>()
912
- if (encSvc?.decryptEntityPayload) {
913
- const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
914
- entityId: EntityId, payload: Record<string, unknown>, tenantId: string | null, organizationId: string | null,
915
- ) => Promise<Record<string, unknown>>
916
- items = await Promise.all(
917
- items.map(async (item) => {
918
- try {
919
- const decrypted = await decrypt(
920
- entity, item,
921
- item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
922
- item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null,
923
- )
924
- return { ...item, ...decrypted }
925
- } catch (err) {
926
- console.error('Error decrypting entity payload', err)
927
- return item
928
- }
929
- })
930
- )
931
- }
932
- if (encSvc) {
933
- items = await Promise.all(
934
- items.map(async (item) => {
935
- try {
936
- return await decryptIndexDocCustomFields(
937
- item,
938
- {
939
- tenantId: item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
940
- organizationId: item?.organization_id ?? item?.organizationId ?? null,
941
- },
942
- encSvc as any, dekKeyCache,
943
- )
944
- } catch { return item }
945
- }),
946
- )
947
- }
929
+ let items: Record<string, unknown>[]
930
+ let encryptedSortRowCapWarning: EncryptedSortRowCapWarning | undefined
931
+
948
932
  if (requiresPlaintextSort) {
949
- items = sortRowsInMemory(items as Record<string, unknown>[], resolvedSorts)
933
+ // Phase 1: slim id+sort-columns candidate scan, bounded-concurrency decrypt,
934
+ // in-memory sort over the full candidate set, then slice to the page's ids.
935
+ const cap = resolveEncryptedSortMaxRows()
936
+ const phase1Root = db.selectFrom(`${baseTable} as b` as any)
937
+ let phase1 = await applyQueryShape(phase1Root)
938
+ const sortFieldNames = Array.from(new Set(['id', ...resolvedSorts.map((s) => String(s.field))]))
939
+ phase1 = applySelection(phase1, sortFieldNames)
940
+ if (cap !== null) {
941
+ phase1 = phase1.limit(cap).orderBy(qualify('id'), 'asc' as any)
942
+ }
943
+ if (debugEnabled && sqlDebugEnabled) {
944
+ const compiled = phase1.compile()
945
+ this.debug('query:sql:data:phase1', { entity, sql: compiled.sql, bindings: compiled.parameters })
946
+ }
947
+ const candidateRows = await this.captureSqlTiming(
948
+ 'query:sql:data:phase1', entity,
949
+ () => phase1.execute(),
950
+ { phase: 1 }, profiler,
951
+ ) as Record<string, unknown>[]
952
+ const decryptedCandidates = await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
953
+ const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts)
954
+ const pageIds = orderedCandidates
950
955
  .slice((page - 1) * pageSize, page * pageSize)
956
+ .map((row) => row.id)
957
+
958
+ if (cap !== null && total > cap) {
959
+ encryptedSortRowCapWarning = {
960
+ entity,
961
+ sortFields: resolvedSorts.map((s) => String(s.field)),
962
+ maxRows: cap,
963
+ totalMatched: total,
964
+ }
965
+ }
966
+
967
+ // Phase 2: fetch + decrypt full rows for just the page's ids, then reassemble
968
+ // in phase-1's order (SQL `WHERE id IN (...)` order is unspecified — never
969
+ // trust it for ordering).
970
+ if (pageIds.length === 0) {
971
+ items = []
972
+ } else {
973
+ const phase2Root = db.selectFrom(`${baseTable} as b` as any)
974
+ let phase2 = await applyQueryShape(phase2Root)
975
+ phase2 = applySelection(phase2)
976
+ phase2 = phase2.where(qualify('id'), 'in', pageIds)
977
+ if (debugEnabled && sqlDebugEnabled) {
978
+ const compiled = phase2.compile()
979
+ this.debug('query:sql:data:phase2', { entity, sql: compiled.sql, bindings: compiled.parameters })
980
+ }
981
+ const pageRowsRaw = await this.captureSqlTiming(
982
+ 'query:sql:data:phase2', entity,
983
+ () => phase2.execute(),
984
+ { phase: 2 }, profiler,
985
+ ) as Record<string, unknown>[]
986
+ const decryptedPageRows = await mapWithConcurrency(pageRowsRaw, DECRYPT_CONCURRENCY, decryptRow)
987
+ const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]))
988
+ items = pageIds
989
+ .map((id) => byId.get(String(id)))
990
+ .filter((row): row is Record<string, unknown> => row != null)
991
+ }
992
+ } else {
993
+ const dataRoot = db.selectFrom(`${baseTable} as b` as any)
994
+ let dataBuilder = await applyQueryShape(dataRoot)
995
+ dataBuilder = applySelection(dataBuilder)
996
+ dataBuilder = applySort(dataBuilder)
997
+ dataBuilder = dataBuilder.limit(pageSize).offset((page - 1) * pageSize)
998
+
999
+ if (debugEnabled && sqlDebugEnabled) {
1000
+ const compiled = dataBuilder.compile()
1001
+ this.debug('query:sql:data', { entity, sql: compiled.sql, bindings: compiled.parameters, page, pageSize })
1002
+ }
1003
+ const itemsRaw = await this.captureSqlTiming(
1004
+ 'query:sql:data', entity,
1005
+ () => dataBuilder.execute(),
1006
+ { page, pageSize }, profiler,
1007
+ ) as Record<string, unknown>[]
1008
+ items = await mapWithConcurrency(itemsRaw, DECRYPT_CONCURRENCY, decryptRow)
951
1009
  }
1010
+ if (debugEnabled) this.debug('query:complete', { entity, total, items: items.length })
952
1011
 
953
1012
  const typedItems = items as unknown as T[]
954
1013
  let result: QueryResult<T> = { items: typedItems, page, pageSize, total }
955
- if (partialIndexWarning) result.meta = { partialIndexWarning }
1014
+ if (partialIndexWarning || encryptedSortRowCapWarning) {
1015
+ const meta: QueryResultMeta = {}
1016
+ if (partialIndexWarning) meta.partialIndexWarning = partialIndexWarning
1017
+ if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
1018
+ result.meta = meta
1019
+ }
956
1020
 
957
1021
  result = await applyAfterExtensions(result)
958
1022
  finishProfile({
@@ -12,6 +12,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
12
  import { useCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
13
13
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
14
14
  import { Badge } from '@open-mercato/ui/primitives/badge'
15
+ import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'
15
16
  import { Button } from '@open-mercato/ui/primitives/button'
16
17
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@open-mercato/ui/primitives/card'
17
18
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -1171,54 +1172,67 @@ function MetricCard({ label, value }: { label: string; value: string }) {
1171
1172
  )
1172
1173
  }
1173
1174
 
1174
- function RunStatusBadge({
1175
+ type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'idle'
1176
+
1177
+ export const RUN_STATUS_BADGE_VARIANTS: StatusMap<RunStatus> = {
1178
+ completed: 'success',
1179
+ failed: 'error',
1180
+ cancelled: 'neutral',
1181
+ pending: 'info',
1182
+ running: 'info',
1183
+ idle: 'neutral',
1184
+ }
1185
+
1186
+ export function RunStatusBadge({
1175
1187
  status,
1176
1188
  t,
1177
1189
  }: {
1178
- status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'idle'
1190
+ status: RunStatus
1179
1191
  t: ReturnType<typeof useT>
1180
1192
  }) {
1193
+ const variant = RUN_STATUS_BADGE_VARIANTS[status] ?? 'neutral'
1194
+
1181
1195
  if (status === 'completed') {
1182
1196
  return (
1183
- <Badge variant="outline" className="gap-1.5 border-emerald-500/30 bg-emerald-500/10 text-emerald-300">
1197
+ <StatusBadge variant={variant} className="gap-1.5">
1184
1198
  <CheckCircle2 className="size-3.5" />
1185
1199
  {t('sync_excel.widget.run.status.completed', 'Completed')}
1186
- </Badge>
1200
+ </StatusBadge>
1187
1201
  )
1188
1202
  }
1189
1203
 
1190
1204
  if (status === 'failed') {
1191
1205
  return (
1192
- <Badge variant="outline" className="gap-1.5 border-red-500/30 bg-red-500/10 text-red-300">
1206
+ <StatusBadge variant={variant} className="gap-1.5">
1193
1207
  <AlertTriangle className="size-3.5" />
1194
1208
  {t('sync_excel.widget.run.status.failed', 'Failed')}
1195
- </Badge>
1209
+ </StatusBadge>
1196
1210
  )
1197
1211
  }
1198
1212
 
1199
1213
  if (status === 'cancelled') {
1200
1214
  return (
1201
- <Badge variant="outline" className="gap-1.5 border-zinc-500/30 bg-zinc-500/10 text-zinc-300">
1215
+ <StatusBadge variant={variant} className="gap-1.5">
1202
1216
  <XCircle className="size-3.5" />
1203
1217
  {t('sync_excel.widget.run.status.cancelled', 'Cancelled')}
1204
- </Badge>
1218
+ </StatusBadge>
1205
1219
  )
1206
1220
  }
1207
1221
 
1208
1222
  if (status === 'pending' || status === 'running') {
1209
1223
  return (
1210
- <Badge variant="outline" className="gap-1.5 border-blue-500/30 bg-blue-500/10 text-blue-300">
1224
+ <StatusBadge variant={variant} className="gap-1.5">
1211
1225
  <RefreshCw className={cn('size-3.5', status === 'running' ? 'animate-spin' : '')} />
1212
1226
  {status === 'pending'
1213
1227
  ? t('sync_excel.widget.run.status.pending', 'Pending')
1214
1228
  : t('sync_excel.widget.run.status.running', 'Running')}
1215
- </Badge>
1229
+ </StatusBadge>
1216
1230
  )
1217
1231
  }
1218
1232
 
1219
1233
  return (
1220
- <Badge variant="outline" className="gap-1.5 border-zinc-500/30 bg-zinc-500/10 text-zinc-300">
1234
+ <StatusBadge variant={variant}>
1221
1235
  {t('sync_excel.widget.run.status.idle', 'Idle')}
1222
- </Badge>
1236
+ </StatusBadge>
1223
1237
  )
1224
1238
  }