@open-mercato/shared 0.6.8-develop.7091.1.15ffbe30ce → 0.6.8-develop.7093.1.700c5c8d96
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/lib/crud/factory.js +23 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/query/engine.js +33 -8
- package/dist/lib/query/engine.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/crud/__tests__/crud-factory.test.ts +143 -0
- package/src/lib/crud/factory.ts +32 -7
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +8 -1
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +158 -1
- package/src/lib/query/engine.ts +59 -9
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.7093.1.700c5c8d96';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7093.1.700c5c8d96",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"@mikro-orm/core": "^7.1.8",
|
|
110
110
|
"@mikro-orm/decorators": "^7.1.8",
|
|
111
111
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
112
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
112
|
+
"@open-mercato/cache": "0.6.8-develop.7093.1.700c5c8d96",
|
|
113
113
|
"@types/html-to-text": "^9.0.4",
|
|
114
114
|
"@types/sanitize-html": "^2.16.1",
|
|
115
115
|
"dotenv": "^17.4.2",
|
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
13
|
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
14
14
|
import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
|
|
15
|
+
import {
|
|
16
|
+
registerLoggerExtension,
|
|
17
|
+
resetLoggerExtension,
|
|
18
|
+
type LoggerExtensionRecord,
|
|
19
|
+
} from '@open-mercato/shared/lib/logger'
|
|
20
|
+
import {
|
|
21
|
+
registerTelemetryRuntime,
|
|
22
|
+
resetTelemetryRuntime,
|
|
23
|
+
type TelemetryRuntime,
|
|
24
|
+
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
15
25
|
import { z } from 'zod'
|
|
16
26
|
|
|
17
27
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -1076,6 +1086,7 @@ describe('CRUD Factory', () => {
|
|
|
1076
1086
|
await expect(res.json()).resolves.toEqual({
|
|
1077
1087
|
error: 'Internal server error',
|
|
1078
1088
|
message: 'Something went wrong. Please try again later.',
|
|
1089
|
+
requestId: expect.any(String),
|
|
1079
1090
|
})
|
|
1080
1091
|
})
|
|
1081
1092
|
|
|
@@ -1114,6 +1125,138 @@ describe('CRUD Factory', () => {
|
|
|
1114
1125
|
await expect(res.json()).resolves.toEqual({
|
|
1115
1126
|
error: 'Internal server error',
|
|
1116
1127
|
message: 'Something went wrong. Please try again later.',
|
|
1128
|
+
requestId: expect.any(String),
|
|
1129
|
+
})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
// Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
|
|
1133
|
+
// that same id must appear on the server log line so the two can be correlated.
|
|
1134
|
+
describe('generic 500 requestId correlation', () => {
|
|
1135
|
+
const logRecords: LoggerExtensionRecord[] = []
|
|
1136
|
+
const reportError = jest.fn()
|
|
1137
|
+
|
|
1138
|
+
const postWithRequestId = (requestId: string) => interceptorErrorRoute().POST(
|
|
1139
|
+
new Request('http://x/api/example/todos/command', {
|
|
1140
|
+
method: 'POST',
|
|
1141
|
+
body: JSON.stringify({ title: 'A' }),
|
|
1142
|
+
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
|
|
1143
|
+
}),
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
beforeEach(() => {
|
|
1147
|
+
logRecords.length = 0
|
|
1148
|
+
reportError.mockClear()
|
|
1149
|
+
registerLoggerExtension({ emit: (record) => logRecords.push(record) })
|
|
1150
|
+
registerTelemetryRuntime({
|
|
1151
|
+
canUseGlobalTracePropagation: () => false,
|
|
1152
|
+
captureTraceContext: () => ({}),
|
|
1153
|
+
continueTrace: (_carrier, _name, fn) => fn(),
|
|
1154
|
+
recordHttpDuration: () => {},
|
|
1155
|
+
reportError,
|
|
1156
|
+
shutdown: async () => {},
|
|
1157
|
+
} satisfies TelemetryRuntime)
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
afterEach(() => {
|
|
1161
|
+
resetLoggerExtension()
|
|
1162
|
+
resetTelemetryRuntime()
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
it('includes a requestId in the body that matches the server log line', async () => {
|
|
1166
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1167
|
+
|
|
1168
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1169
|
+
const body = await res.json()
|
|
1170
|
+
|
|
1171
|
+
expect(res.status).toBe(500)
|
|
1172
|
+
expect(typeof body.requestId).toBe('string')
|
|
1173
|
+
expect(body.requestId.length).toBeGreaterThan(0)
|
|
1174
|
+
|
|
1175
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1176
|
+
expect(logRecord?.fields.requestId).toBe(body.requestId)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
it('echoes the requestId on an x-request-id response header', async () => {
|
|
1180
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1181
|
+
|
|
1182
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1183
|
+
const body = await res.json()
|
|
1184
|
+
|
|
1185
|
+
expect(res.headers.get('x-request-id')).toBe(body.requestId)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it('reuses an inbound x-request-id header instead of generating a new one', async () => {
|
|
1189
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1190
|
+
|
|
1191
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1192
|
+
const body = await res.json()
|
|
1193
|
+
|
|
1194
|
+
expect(res.status).toBe(500)
|
|
1195
|
+
expect(body.requestId).toBe('req-fixed-123')
|
|
1196
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1197
|
+
expect(logRecord?.fields.requestId).toBe('req-fixed-123')
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
// `Headers.get()` returns '' for an empty or whitespace-only header, which a plain
|
|
1201
|
+
// `?? randomUUID()` would hand straight through as a blank correlation id.
|
|
1202
|
+
it.each([
|
|
1203
|
+
['an empty inbound header', ''],
|
|
1204
|
+
['a whitespace-only inbound header', ' '],
|
|
1205
|
+
])('generates a fresh id for %s', async (_label, inbound) => {
|
|
1206
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1207
|
+
|
|
1208
|
+
const res = await postWithRequestId(inbound)
|
|
1209
|
+
const body = await res.json()
|
|
1210
|
+
|
|
1211
|
+
expect(res.status).toBe(500)
|
|
1212
|
+
expect(typeof body.requestId).toBe('string')
|
|
1213
|
+
expect(body.requestId.length).toBeGreaterThan(0)
|
|
1214
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1215
|
+
expect(logRecord?.fields.requestId).toBe(body.requestId)
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
// A caller-controlled id lands verbatim in the unquoted `key=value` log line, so an
|
|
1219
|
+
// over-long one or one carrying spaces/`=` is discarded rather than echoed.
|
|
1220
|
+
it.each([
|
|
1221
|
+
['a value carrying log-field separators', 'a=1 tenantId=victim'],
|
|
1222
|
+
['an over-long value', 'x'.repeat(129)],
|
|
1223
|
+
])('discards %s in favor of a generated id', async (_label, inbound) => {
|
|
1224
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1225
|
+
|
|
1226
|
+
const res = await postWithRequestId(inbound)
|
|
1227
|
+
const body = await res.json()
|
|
1228
|
+
|
|
1229
|
+
expect(res.status).toBe(500)
|
|
1230
|
+
expect(body.requestId).not.toBe(inbound)
|
|
1231
|
+
expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('reports the error to telemetry with the same requestId', async () => {
|
|
1235
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1236
|
+
|
|
1237
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1238
|
+
const body = await res.json()
|
|
1239
|
+
|
|
1240
|
+
expect(body.requestId).toBe('req-fixed-123')
|
|
1241
|
+
expect(reportError).toHaveBeenCalledTimes(1)
|
|
1242
|
+
expect(reportError).toHaveBeenCalledWith(
|
|
1243
|
+
expect.any(Error),
|
|
1244
|
+
{ module: 'crud', attributes: { requestId: 'req-fixed-123', errorName: 'Error' } },
|
|
1245
|
+
)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
// The 503/422 branches deliberately stay outside this change (issue #5608) — lock that
|
|
1249
|
+
// in so a later refactor cannot quietly widen the correlation id across every branch.
|
|
1250
|
+
it('leaves the interceptor-rejection branch without a requestId', async () => {
|
|
1251
|
+
commandBus.execute.mockRejectedValue(
|
|
1252
|
+
new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1256
|
+
|
|
1257
|
+
expect(res.status).toBe(422)
|
|
1258
|
+
await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
|
|
1259
|
+
expect(res.headers.get('x-request-id')).toBeNull()
|
|
1117
1260
|
})
|
|
1118
1261
|
})
|
|
1119
1262
|
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -75,6 +75,8 @@ import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
|
75
75
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
76
76
|
import { createLogger } from '../logger'
|
|
77
77
|
import { isTransientDbError } from '../db/pg-errors'
|
|
78
|
+
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
79
|
+
import { randomUUID } from 'node:crypto'
|
|
78
80
|
|
|
79
81
|
type RbacServiceLike = {
|
|
80
82
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -594,7 +596,19 @@ function attachOperationHeader(res: Response, logEntry: any) {
|
|
|
594
596
|
return res
|
|
595
597
|
}
|
|
596
598
|
|
|
597
|
-
|
|
599
|
+
// An inbound `x-request-id` is caller-controlled, so it is only reused when it still
|
|
600
|
+
// looks like an id. `Headers.get()` yields '' for an empty or whitespace-only header —
|
|
601
|
+
// which `??` would not replace — and an unbounded value carrying spaces or `=` would
|
|
602
|
+
// forge fields in the unquoted `key=value` log line this id exists to be read from.
|
|
603
|
+
const INBOUND_REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/
|
|
604
|
+
|
|
605
|
+
function resolveRequestId(request?: Request): string {
|
|
606
|
+
const inbound = request?.headers.get('x-request-id')?.trim()
|
|
607
|
+
if (inbound && INBOUND_REQUEST_ID_PATTERN.test(inbound)) return inbound
|
|
608
|
+
return randomUUID()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function handleError(err: unknown, request?: Request): Response {
|
|
598
612
|
if (err instanceof Response) return err
|
|
599
613
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
600
614
|
// A command interceptor that blocked with an explicit status is a deliberate business
|
|
@@ -618,14 +632,25 @@ function handleError(err: unknown): Response {
|
|
|
618
632
|
)
|
|
619
633
|
}
|
|
620
634
|
|
|
635
|
+
// Unexpected exceptions still collapse into a generic 500 for the client (no internal
|
|
636
|
+
// detail leaked), but a requestId ties that response to this log line and to whatever
|
|
637
|
+
// reaches APM, so a client/support ticket citing it can be correlated with server-side
|
|
638
|
+
// detail (issue #5608).
|
|
621
639
|
const message = err instanceof Error ? err.message : undefined
|
|
622
640
|
const stack = err instanceof Error ? err.stack : undefined
|
|
623
|
-
|
|
641
|
+
const errorName = err instanceof Error ? err.name : undefined
|
|
642
|
+
const requestId = resolveRequestId(request)
|
|
643
|
+
logger.error('Unexpected CRUD error', { message, stack, err, requestId })
|
|
644
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
645
|
+
module: 'crud',
|
|
646
|
+
attributes: { requestId, errorName },
|
|
647
|
+
})
|
|
624
648
|
const body: Record<string, unknown> = {
|
|
625
649
|
error: 'Internal server error',
|
|
626
650
|
message: 'Something went wrong. Please try again later.',
|
|
651
|
+
requestId,
|
|
627
652
|
}
|
|
628
|
-
return json(body, { status: 500 })
|
|
653
|
+
return json(body, { status: 500, headers: { 'x-request-id': requestId } })
|
|
629
654
|
}
|
|
630
655
|
|
|
631
656
|
const LIFECYCLE_ACTION_MAP: Record<string, { before: string; after: string }> = {
|
|
@@ -2163,7 +2188,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2163
2188
|
return response
|
|
2164
2189
|
} catch (e) {
|
|
2165
2190
|
finishProfile({ result: 'error' })
|
|
2166
|
-
return handleError(e)
|
|
2191
|
+
return handleError(e, request)
|
|
2167
2192
|
}
|
|
2168
2193
|
}
|
|
2169
2194
|
|
|
@@ -2477,7 +2502,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2477
2502
|
payload = await enrichSingleRecord(payload, ctx)
|
|
2478
2503
|
return json(payload, { status: 201 })
|
|
2479
2504
|
} catch (e) {
|
|
2480
|
-
return handleError(e)
|
|
2505
|
+
return handleError(e, request)
|
|
2481
2506
|
}
|
|
2482
2507
|
}
|
|
2483
2508
|
|
|
@@ -2815,7 +2840,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2815
2840
|
}
|
|
2816
2841
|
return json(payload)
|
|
2817
2842
|
} catch (e) {
|
|
2818
|
-
return handleError(e)
|
|
2843
|
+
return handleError(e, request)
|
|
2819
2844
|
}
|
|
2820
2845
|
}
|
|
2821
2846
|
|
|
@@ -3104,7 +3129,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
3104
3129
|
}
|
|
3105
3130
|
return json(payload)
|
|
3106
3131
|
} catch (e) {
|
|
3107
|
-
return handleError(e)
|
|
3132
|
+
return handleError(e, request)
|
|
3108
3133
|
}
|
|
3109
3134
|
}
|
|
3110
3135
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { BasicQueryEngine } from '../engine'
|
|
1
|
+
import { BasicQueryEngine, clearColumnExistsCache } from '../engine'
|
|
2
2
|
import { registerModules } from '../../i18n/server'
|
|
3
3
|
|
|
4
|
+
// The column-existence answer is memoized on the module, not the instance (#5605), so
|
|
5
|
+
// the per-test fake schemas below would otherwise inherit whatever the first test
|
|
6
|
+
// probed for the same table names.
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
clearColumnExistsCache()
|
|
9
|
+
})
|
|
10
|
+
|
|
4
11
|
// One entity extension on auth:user so includeExtensions exercises the joined-aggregate path.
|
|
5
12
|
registerModules([
|
|
6
13
|
{ id: 'auth', entityExtensions: [{ base: 'auth:user', extension: 'my_module:user_profile', join: { baseKey: 'id', extensionKey: 'user_id' } }] },
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import { BasicQueryEngine } from '../engine'
|
|
1
|
+
import { BasicQueryEngine, clearColumnExistsCache } from '../engine'
|
|
2
2
|
import { normalizeFilters } from '../join-utils'
|
|
3
3
|
|
|
4
|
+
// The column-existence answer is memoized on the module, not the instance (#5605), and
|
|
5
|
+
// the fixtures below declare mutually contradictory `information_schema.columns` shapes
|
|
6
|
+
// for the same `scheduled_jobs` table. Without this clear, whichever test ran first
|
|
7
|
+
// decides the scoping every later test sees — the automatic tenant/organization guard
|
|
8
|
+
// would silently drop out of a query and the assertion for it would still be reading a
|
|
9
|
+
// stale `false`.
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
clearColumnExistsCache()
|
|
12
|
+
})
|
|
13
|
+
|
|
4
14
|
type FakeData = Record<string, any[]>
|
|
5
15
|
|
|
6
16
|
function cloneRows(rows: any[] | undefined): any[] {
|
|
@@ -2,13 +2,14 @@ import { BasicQueryEngine } from '../engine'
|
|
|
2
2
|
import { SortDir } from '../types'
|
|
3
3
|
import { registerModules } from '../../i18n/server'
|
|
4
4
|
import { clearSearchTokenPresenceCache } from '../../search/availability'
|
|
5
|
-
import { clearEncryptedLikeFieldsCache } from '../engine'
|
|
5
|
+
import { clearEncryptedLikeFieldsCache, clearColumnExistsCache, columnExistsCacheSize } from '../engine'
|
|
6
6
|
|
|
7
7
|
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
8
8
|
// probe-count assertions would observe hits from earlier tests in this file.
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
clearSearchTokenPresenceCache()
|
|
11
11
|
clearEncryptedLikeFieldsCache()
|
|
12
|
+
clearColumnExistsCache()
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
// Mock modules with one entity extension
|
|
@@ -1415,3 +1416,159 @@ describe('BasicQueryEngine like/ilike routing by column encryption', () => {
|
|
|
1415
1416
|
})
|
|
1416
1417
|
})
|
|
1417
1418
|
})
|
|
1419
|
+
|
|
1420
|
+
describe('module-scoped column-existence cache (#5605)', () => {
|
|
1421
|
+
const columnProbes = (fakeDb: any) =>
|
|
1422
|
+
fakeDb._calls.filter((b: any) => b._ops.table === 'information_schema.columns')
|
|
1423
|
+
|
|
1424
|
+
const columnProbesFor = (fakeDb: any, column: string) =>
|
|
1425
|
+
columnProbes(fakeDb).filter((b: any) =>
|
|
1426
|
+
b._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'column_name' && w[1] === '=' && w[2] === column)
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
const hasTenantGuard = (fakeDb: any) => {
|
|
1430
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
|
|
1431
|
+
return !!baseCall?._ops.wheres.some(
|
|
1432
|
+
(w: any) => Array.isArray(w) && w[0] === 'customer_entities.tenant_id' && w[1] === '=' && w[2] === 't1',
|
|
1433
|
+
)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const originalTtl = process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS
|
|
1437
|
+
const originalMaxEntries = process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES
|
|
1438
|
+
|
|
1439
|
+
afterEach(() => {
|
|
1440
|
+
if (originalTtl === undefined) delete process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS
|
|
1441
|
+
else process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS = originalTtl
|
|
1442
|
+
if (originalMaxEntries === undefined) delete process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES
|
|
1443
|
+
else process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES = originalMaxEntries
|
|
1444
|
+
jest.restoreAllMocks()
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
test('the column-existence cache is shared across separate engine instances', async () => {
|
|
1448
|
+
// `createRequestContainer()` builds a fresh `BasicQueryEngine` per HTTP request.
|
|
1449
|
+
// The cache must live on the module, not the instance, so a later "request" (a
|
|
1450
|
+
// second engine here) reuses the first request's answer instead of re-probing
|
|
1451
|
+
// `information_schema.columns`.
|
|
1452
|
+
const fakeDb = createFakeKysely({
|
|
1453
|
+
customer_entities: [],
|
|
1454
|
+
'information_schema.columns': [
|
|
1455
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1456
|
+
],
|
|
1457
|
+
})
|
|
1458
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1459
|
+
|
|
1460
|
+
const engine1 = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1461
|
+
await engine1.query('customers:customer_entity', queryOpts)
|
|
1462
|
+
const probesAfterFirstEngine = columnProbes(fakeDb).length
|
|
1463
|
+
expect(probesAfterFirstEngine).toBeGreaterThan(0)
|
|
1464
|
+
|
|
1465
|
+
const engine2 = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1466
|
+
await engine2.query('customers:customer_entity', queryOpts)
|
|
1467
|
+
expect(columnProbes(fakeDb).length).toBe(probesAfterFirstEngine)
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
test('a missing column is remembered as false instead of being re-queried on every call', async () => {
|
|
1471
|
+
// Before the fix, `columnExists` deleted a `false` result instead of caching it,
|
|
1472
|
+
// so `organization_id` (absent here) was re-queried once per query projection
|
|
1473
|
+
// ('full' and 'count') within a single `.query()` call.
|
|
1474
|
+
const fakeDb = createFakeKysely({
|
|
1475
|
+
customer_entities: [],
|
|
1476
|
+
'information_schema.columns': [],
|
|
1477
|
+
})
|
|
1478
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1479
|
+
|
|
1480
|
+
await engine.query('customers:customer_entity', {
|
|
1481
|
+
tenantId: 't1',
|
|
1482
|
+
organizationId: 'org1',
|
|
1483
|
+
fields: ['id'],
|
|
1484
|
+
page: { page: 1, pageSize: 10 },
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
expect(columnProbesFor(fakeDb, 'organization_id').length).toBe(1)
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
test('a cached negative expires, so a migrated-in scope column is picked up without a process restart', async () => {
|
|
1491
|
+
// A cached `false` is consumed where the tenant/organization/soft-delete predicates
|
|
1492
|
+
// are applied, so a stale one does not merely slow a query down — it silently drops
|
|
1493
|
+
// a scope guard. The TTL bounds that window: a migration applied against a running
|
|
1494
|
+
// process (`yarn dev`, or pods not recycled by a separate migration release step)
|
|
1495
|
+
// converges once the entry expires instead of never.
|
|
1496
|
+
const now = 1_700_000_000_000
|
|
1497
|
+
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now)
|
|
1498
|
+
|
|
1499
|
+
const beforeMigration = createFakeKysely({
|
|
1500
|
+
customer_entities: [],
|
|
1501
|
+
'information_schema.columns': [],
|
|
1502
|
+
})
|
|
1503
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1504
|
+
await new BasicQueryEngine({} as any, () => beforeMigration as any).query('customers:customer_entity', queryOpts)
|
|
1505
|
+
expect(columnProbesFor(beforeMigration, 'tenant_id').length).toBe(1)
|
|
1506
|
+
expect(hasTenantGuard(beforeMigration)).toBe(false)
|
|
1507
|
+
|
|
1508
|
+
// Same process, same cache — a second request while the entry is still fresh reuses
|
|
1509
|
+
// the negative and does not re-probe, even though the column now exists.
|
|
1510
|
+
const afterMigration = createFakeKysely({
|
|
1511
|
+
customer_entities: [],
|
|
1512
|
+
'information_schema.columns': [
|
|
1513
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1514
|
+
],
|
|
1515
|
+
})
|
|
1516
|
+
await new BasicQueryEngine({} as any, () => afterMigration as any).query('customers:customer_entity', queryOpts)
|
|
1517
|
+
expect(columnProbesFor(afterMigration, 'tenant_id').length).toBe(0)
|
|
1518
|
+
expect(hasTenantGuard(afterMigration)).toBe(false)
|
|
1519
|
+
|
|
1520
|
+
nowSpy.mockReturnValue(now + 300_001)
|
|
1521
|
+
|
|
1522
|
+
const afterTtl = createFakeKysely({
|
|
1523
|
+
customer_entities: [],
|
|
1524
|
+
'information_schema.columns': [
|
|
1525
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1526
|
+
],
|
|
1527
|
+
})
|
|
1528
|
+
await new BasicQueryEngine({} as any, () => afterTtl as any).query('customers:customer_entity', queryOpts)
|
|
1529
|
+
expect(columnProbesFor(afterTtl, 'tenant_id').length).toBe(1)
|
|
1530
|
+
expect(hasTenantGuard(afterTtl)).toBe(true)
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
test('the cache is bounded, so caller-supplied sort fields cannot grow it without limit', async () => {
|
|
1534
|
+
// `columnExists` is reached with raw request input through `resolveBaseColumn` —
|
|
1535
|
+
// `sortField` arrives from the query string and many list schemas type it as a plain
|
|
1536
|
+
// string. Without a cap, one distinct name per request would be retained for the
|
|
1537
|
+
// lifetime of the process.
|
|
1538
|
+
process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES = '4'
|
|
1539
|
+
const fakeDb = createFakeKysely({
|
|
1540
|
+
customer_entities: [],
|
|
1541
|
+
'information_schema.columns': [],
|
|
1542
|
+
})
|
|
1543
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1544
|
+
|
|
1545
|
+
for (let index = 0; index < 20; index += 1) {
|
|
1546
|
+
await engine.query('customers:customer_entity', {
|
|
1547
|
+
tenantId: 't1',
|
|
1548
|
+
fields: ['id'],
|
|
1549
|
+
sort: [{ field: `attacker_supplied_${index}`, dir: SortDir.Asc }],
|
|
1550
|
+
page: { page: 1, pageSize: 10 },
|
|
1551
|
+
})
|
|
1552
|
+
expect(columnExistsCacheSize()).toBeLessThanOrEqual(4)
|
|
1553
|
+
}
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
test('OM_QUERY_COLUMN_EXISTS_CACHE_MS=0 disables the memo and probes per request again', async () => {
|
|
1557
|
+
process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS = '0'
|
|
1558
|
+
const fakeDb = createFakeKysely({
|
|
1559
|
+
customer_entities: [],
|
|
1560
|
+
'information_schema.columns': [
|
|
1561
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1562
|
+
],
|
|
1563
|
+
})
|
|
1564
|
+
const queryOpts = { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 10 } }
|
|
1565
|
+
|
|
1566
|
+
await new BasicQueryEngine({} as any, () => fakeDb as any).query('customers:customer_entity', queryOpts)
|
|
1567
|
+
const probesAfterFirstEngine = columnProbesFor(fakeDb, 'tenant_id').length
|
|
1568
|
+
expect(probesAfterFirstEngine).toBeGreaterThan(0)
|
|
1569
|
+
expect(columnExistsCacheSize()).toBe(0)
|
|
1570
|
+
|
|
1571
|
+
await new BasicQueryEngine({} as any, () => fakeDb as any).query('customers:customer_entity', queryOpts)
|
|
1572
|
+
expect(columnProbesFor(fakeDb, 'tenant_id').length).toBeGreaterThan(probesAfterFirstEngine)
|
|
1573
|
+
})
|
|
1574
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
|
33
33
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
34
34
|
import { resolveListCountCap } from './count-cap'
|
|
35
35
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
36
|
+
import { parseNumberWithDefault } from '../number'
|
|
36
37
|
import { createLogger } from '../logger'
|
|
37
38
|
|
|
38
39
|
const logger = createLogger('shared').child({ component: 'query' })
|
|
@@ -89,6 +90,58 @@ export function clearEncryptedLikeFieldsCache(): void {
|
|
|
89
90
|
encryptedLikeFieldsCache.clear()
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// Module-scoped on purpose: `createRequestContainer` builds a fresh `BasicQueryEngine`
|
|
94
|
+
// per request, so an instance field alone re-pays the `information_schema` probe on
|
|
95
|
+
// every request (#5605). Schema shape (does a table have this column?) is not
|
|
96
|
+
// per-request state — unlike `tenantEncryptionService` — so sharing the answer across
|
|
97
|
+
// requests is safe. One map per module instance rather than a true process singleton:
|
|
98
|
+
// standalone builds can duplicate this package, which for a memo is harmless (two
|
|
99
|
+
// caches, both correct), so nothing may be built on top of singleton semantics here.
|
|
100
|
+
//
|
|
101
|
+
// Bounded and TTL'd for two reasons. `columnExists` is reached with caller-supplied
|
|
102
|
+
// field names via `resolveBaseColumn` (sort fields and base filter keys arrive raw from
|
|
103
|
+
// the HTTP layer), so an unbounded map would grow monotonically on request input. And a
|
|
104
|
+
// cached `false` is consumed where the tenant/organization/soft-delete predicates are
|
|
105
|
+
// applied, so a schema change that adds one of those columns must not stay invisible
|
|
106
|
+
// until the process restarts — a migration applied against a running `yarn dev` or
|
|
107
|
+
// not-yet-recycled pods converges within the TTL instead. The TTL still removes
|
|
108
|
+
// essentially all of the traffic: a hot column is probed twelve times an hour rather
|
|
109
|
+
// than tens of thousands. Set OM_QUERY_COLUMN_EXISTS_CACHE_MS=0 to disable and probe
|
|
110
|
+
// per request again; OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES tunes the bound.
|
|
111
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS = 300_000
|
|
112
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES = 10_000
|
|
113
|
+
const columnExistsCache = new Map<string, { value: boolean; expiresAt: number }>()
|
|
114
|
+
|
|
115
|
+
function resolveColumnExistsCacheTtlMs(): number {
|
|
116
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS, COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveColumnExistsCacheMaxEntries(): number {
|
|
120
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES, COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES, { integer: true, min: 1 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function storeColumnExists(key: string, value: boolean, ttlMs: number): void {
|
|
124
|
+
const maxEntries = resolveColumnExistsCacheMaxEntries()
|
|
125
|
+
if (columnExistsCache.size >= maxEntries) {
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
for (const [entryKey, entry] of columnExistsCache) {
|
|
128
|
+
if (entry.expiresAt <= now) columnExistsCache.delete(entryKey)
|
|
129
|
+
}
|
|
130
|
+
if (columnExistsCache.size >= maxEntries) columnExistsCache.clear()
|
|
131
|
+
}
|
|
132
|
+
columnExistsCache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Test-only: the module-scoped memo would otherwise leak state across specs. */
|
|
136
|
+
export function clearColumnExistsCache(): void {
|
|
137
|
+
columnExistsCache.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Test-only: entry count of the column-existence memo, for the cap regression test. */
|
|
141
|
+
export function columnExistsCacheSize(): number {
|
|
142
|
+
return columnExistsCache.size
|
|
143
|
+
}
|
|
144
|
+
|
|
92
145
|
type ResolvedCustomFieldSource = {
|
|
93
146
|
entityId: EntityId
|
|
94
147
|
alias: string
|
|
@@ -271,7 +324,6 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
|
|
|
271
324
|
* {@link HybridQueryEngine} when the query index is unavailable or incomplete.
|
|
272
325
|
*/
|
|
273
326
|
export class BasicQueryEngine implements QueryEngine {
|
|
274
|
-
private columnCache = new Map<string, boolean>()
|
|
275
327
|
private searchAliasSeq = 0
|
|
276
328
|
private searchAvailabilityInstance: SearchTokenAvailability | null = null
|
|
277
329
|
|
|
@@ -661,7 +713,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
661
713
|
// and cf filters are expressed as correlated EXISTS semi-joins, so nothing can
|
|
662
714
|
// multiply base rows and a LIMIT above the query is an enforceable bound.
|
|
663
715
|
// Re-running the WHERE/JOIN logic per projection is cheap: every `columnExists`
|
|
664
|
-
// check is memoized on `
|
|
716
|
+
// check is memoized on the module-scoped `columnExistsCache`, so later passes
|
|
717
|
+
// hit no extra DB calls.
|
|
665
718
|
const buildQuery = async (projection: 'full' | 'sortKeys' | 'count'): Promise<BuiltQuery> => {
|
|
666
719
|
const isSortKeysProjection = projection === 'sortKeys'
|
|
667
720
|
const isCountProjection = projection === 'count'
|
|
@@ -1545,11 +1598,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1545
1598
|
|
|
1546
1599
|
private async columnExists(table: string, column: string): Promise<boolean> {
|
|
1547
1600
|
const key = `${table}.${column}`
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
this.columnCache.delete(key)
|
|
1552
|
-
}
|
|
1601
|
+
const ttlMs = resolveColumnExistsCacheTtlMs()
|
|
1602
|
+
const cached = columnExistsCache.get(key)
|
|
1603
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value
|
|
1553
1604
|
const db = this.getDb()
|
|
1554
1605
|
const exists = await db
|
|
1555
1606
|
.selectFrom('information_schema.columns' as any)
|
|
@@ -1559,8 +1610,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1559
1610
|
.limit(1)
|
|
1560
1611
|
.executeTakeFirst()
|
|
1561
1612
|
const present = !!exists
|
|
1562
|
-
if (
|
|
1563
|
-
else this.columnCache.delete(key)
|
|
1613
|
+
if (ttlMs > 0) storeColumnExists(key, present, ttlMs)
|
|
1564
1614
|
return present
|
|
1565
1615
|
}
|
|
1566
1616
|
|