@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.
- package/dist/modules/query_index/lib/engine.js +116 -62
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +17 -5
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/query_index/lib/engine.ts +124 -60
- package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +26 -12
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
</
|
|
1200
|
+
</StatusBadge>
|
|
1187
1201
|
)
|
|
1188
1202
|
}
|
|
1189
1203
|
|
|
1190
1204
|
if (status === 'failed') {
|
|
1191
1205
|
return (
|
|
1192
|
-
<
|
|
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
|
-
</
|
|
1209
|
+
</StatusBadge>
|
|
1196
1210
|
)
|
|
1197
1211
|
}
|
|
1198
1212
|
|
|
1199
1213
|
if (status === 'cancelled') {
|
|
1200
1214
|
return (
|
|
1201
|
-
<
|
|
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
|
-
</
|
|
1218
|
+
</StatusBadge>
|
|
1205
1219
|
)
|
|
1206
1220
|
}
|
|
1207
1221
|
|
|
1208
1222
|
if (status === 'pending' || status === 'running') {
|
|
1209
1223
|
return (
|
|
1210
|
-
<
|
|
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
|
-
</
|
|
1229
|
+
</StatusBadge>
|
|
1216
1230
|
)
|
|
1217
1231
|
}
|
|
1218
1232
|
|
|
1219
1233
|
return (
|
|
1220
|
-
<
|
|
1234
|
+
<StatusBadge variant={variant}>
|
|
1221
1235
|
{t('sync_excel.widget.run.status.idle', 'Idle')}
|
|
1222
|
-
</
|
|
1236
|
+
</StatusBadge>
|
|
1223
1237
|
)
|
|
1224
1238
|
}
|