@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +24 -8
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/data/engine.js +8 -2
  18. package/dist/lib/data/engine.js.map +2 -2
  19. package/dist/lib/html/htmlToPlainText.js +16 -0
  20. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  21. package/dist/lib/location/countries.js +12 -0
  22. package/dist/lib/location/countries.js.map +2 -2
  23. package/dist/lib/openapi/crud.js +4 -1
  24. package/dist/lib/openapi/crud.js.map +2 -2
  25. package/dist/lib/query/count-cap.js +11 -0
  26. package/dist/lib/query/count-cap.js.map +7 -0
  27. package/dist/lib/query/engine.js +270 -34
  28. package/dist/lib/query/engine.js.map +3 -3
  29. package/dist/lib/query/types.js.map +1 -1
  30. package/dist/lib/queue/dispatchOrigin.js +20 -0
  31. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  32. package/dist/lib/search/config.js +1 -0
  33. package/dist/lib/search/config.js.map +2 -2
  34. package/dist/lib/search/entityAccess.js +44 -0
  35. package/dist/lib/search/entityAccess.js.map +7 -0
  36. package/dist/lib/version.js +1 -1
  37. package/dist/lib/version.js.map +1 -1
  38. package/dist/modules/events/factory.js +69 -15
  39. package/dist/modules/events/factory.js.map +2 -2
  40. package/dist/modules/registry.js +15 -0
  41. package/dist/modules/registry.js.map +2 -2
  42. package/dist/modules/widgets/component-registry.js.map +2 -2
  43. package/package.json +10 -3
  44. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  45. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  46. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  47. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  48. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  49. package/src/lib/auth/__tests__/server.test.ts +104 -0
  50. package/src/lib/auth/jwt.ts +17 -0
  51. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  52. package/src/lib/auth/organizationAccess.ts +11 -3
  53. package/src/lib/auth/principal-service.ts +110 -0
  54. package/src/lib/auth/server.ts +78 -8
  55. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  56. package/src/lib/commands/command-bus.ts +8 -1
  57. package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
  58. package/src/lib/crud/factory.ts +33 -7
  59. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  60. package/src/lib/data/engine.ts +7 -1
  61. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  62. package/src/lib/html/htmlToPlainText.ts +17 -0
  63. package/src/lib/location/__tests__/countries.test.ts +15 -0
  64. package/src/lib/location/countries.ts +17 -0
  65. package/src/lib/openapi/crud.ts +3 -0
  66. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  67. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  68. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  69. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  70. package/src/lib/query/__tests__/engine.test.ts +445 -7
  71. package/src/lib/query/count-cap.ts +19 -0
  72. package/src/lib/query/engine.ts +434 -54
  73. package/src/lib/query/types.ts +15 -0
  74. package/src/lib/queue/dispatchOrigin.ts +35 -0
  75. package/src/lib/search/config.ts +10 -0
  76. package/src/lib/search/entityAccess.ts +132 -0
  77. package/src/modules/events/__tests__/factory.test.ts +88 -0
  78. package/src/modules/events/factory.ts +111 -19
  79. package/src/modules/events/types.ts +17 -0
  80. package/src/modules/registry.ts +40 -0
  81. package/src/modules/widgets/component-registry.ts +14 -0
@@ -2,11 +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, clearColumnExistsCache, columnExistsCacheSize } from '../engine'
5
6
 
6
7
  // The token-presence answer is cached process-wide (TTL); without clearing it,
7
8
  // probe-count assertions would observe hits from earlier tests in this file.
8
9
  beforeEach(() => {
9
10
  clearSearchTokenPresenceCache()
11
+ clearEncryptedLikeFieldsCache()
12
+ clearColumnExistsCache()
10
13
  })
11
14
 
12
15
  // Mock modules with one entity extension
@@ -176,6 +179,13 @@ function createFakeKysely(overrides?: FakeData) {
176
179
  return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
177
180
  }
178
181
  if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
182
+ // An aggregate over a recorded subquery (the capped-count probe) counts
183
+ // the subquery's source rows bounded by its LIMIT, mirroring Postgres.
184
+ if (localOps.subquery) {
185
+ const sourceRows = (data[localOps.subquery.table] || []).length
186
+ const innerLimit = localOps.subquery.limits
187
+ return { count: String(innerLimit ? Math.min(sourceRows, innerLimit) : sourceRows) }
188
+ }
179
189
  return { count: String((data[localOps.table] || []).length) }
180
190
  }
181
191
  const rows = data[localOps.table] || []
@@ -188,6 +198,22 @@ function createFakeKysely(overrides?: FakeData) {
188
198
  }
189
199
 
190
200
  function builderFor(tableArg: any): any {
201
+ if (tableArg && typeof tableArg === 'object' && tableArg._ops) {
202
+ // selectFrom(subquery.as(alias)) — the capped-count probe shape.
203
+ const ops = {
204
+ table: '__subquery__',
205
+ alias: tableArg._ops.alias ?? null,
206
+ subquery: tableArg._ops,
207
+ wheres: [] as any[],
208
+ joins: [] as any[],
209
+ selects: [] as any[],
210
+ orderBys: [] as any[],
211
+ groups: [] as any[],
212
+ limits: 0,
213
+ offsets: 0,
214
+ }
215
+ return makeBuilder(ops, true)
216
+ }
191
217
  const parsed = parseTableSpec(tableArg)
192
218
  const ops = {
193
219
  table: parsed.table,
@@ -693,10 +719,10 @@ describe('BasicQueryEngine (Kysely)', () => {
693
719
 
694
720
  expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
695
721
  const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
696
- expect(baseCalls.length).toBe(2)
697
- // qFull ('full' projection) is built first (used for count + phase 2);
698
- // qSort ('sortKeys' projection) is built second (phase 1).
699
- const [phase2Call, phase1Call] = baseCalls
722
+ expect(baseCalls.length).toBe(3)
723
+ // qFull ('full' projection) is built first (used for phase 2), then the
724
+ // 'count' projection (the bounded count probe), then 'sortKeys' (phase 1).
725
+ const [phase2Call, , phase1Call] = baseCalls
700
726
  // Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
701
727
  // set is fetched, decrypted, and sorted in memory.
702
728
  expect(phase1Call._ops.orderBys).toEqual([])
@@ -878,7 +904,7 @@ describe('BasicQueryEngine (Kysely)', () => {
878
904
  page: { page: 1, pageSize: 2 },
879
905
  })
880
906
  expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
881
- const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
907
+ const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
882
908
  expect(phase1Call._ops.limits).toBe(0)
883
909
  })
884
910
 
@@ -912,10 +938,37 @@ describe('BasicQueryEngine (Kysely)', () => {
912
938
  maxRows: 3,
913
939
  totalMatched: 5,
914
940
  })
915
- const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
916
- expect(phase1Call._ops.limits).toBe(3)
941
+ const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
942
+ // cap + 1 probe: truncation is detected from the candidate scan itself,
943
+ // not by comparing against a (possibly capped) total.
944
+ expect(phase1Call._ops.limits).toBe(4)
917
945
  expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
918
946
  })
947
+
948
+ test('still warns when the list count itself is capped below the matched set', async () => {
949
+ // The old detection compared `total > sortCap`; with OM_LIST_COUNT_CAP at or
950
+ // below the sort cap that comparison can never fire. The probe must warn anyway.
951
+ process.env.OM_ENCRYPTED_SORT_MAX_ROWS = '3'
952
+ process.env.OM_LIST_COUNT_CAP = '3'
953
+ try {
954
+ const { engine } = buildFixture()
955
+ const result = await engine.query('customers:customer_entity', {
956
+ tenantId: 't1',
957
+ organizationId: 'org1',
958
+ fields: ['id', 'display_name'],
959
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
960
+ page: { page: 1, pageSize: 2 },
961
+ })
962
+ expect(result.total).toBe(3)
963
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 3 })
964
+ expect(result.meta?.encryptedSortRowCapWarning).toMatchObject({
965
+ entity: 'customers:customer_entity',
966
+ maxRows: 3,
967
+ })
968
+ } finally {
969
+ delete process.env.OM_LIST_COUNT_CAP
970
+ }
971
+ })
919
972
  })
920
973
 
921
974
  test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
@@ -954,6 +1007,80 @@ describe('BasicQueryEngine (Kysely)', () => {
954
1007
  expect(baseCall._ops.offsets).toBe(10)
955
1008
  })
956
1009
 
1010
+ describe('OM_LIST_COUNT_CAP boundary', () => {
1011
+ const originalCap = process.env.OM_LIST_COUNT_CAP
1012
+ afterEach(() => {
1013
+ if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
1014
+ else process.env.OM_LIST_COUNT_CAP = originalCap
1015
+ })
1016
+
1017
+ function buildFixture(rowCount: number) {
1018
+ const rows = Array.from({ length: rowCount }, (_, i) => ({
1019
+ id: String(i + 1), tenant_id: 't1', organization_id: 'org1', display_name: `Row ${i + 1}`,
1020
+ }))
1021
+ const fakeDb = createFakeKysely({
1022
+ customer_entities: rows,
1023
+ 'information_schema.columns': [
1024
+ { table_name: 'customer_entities', column_name: 'id' },
1025
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
1026
+ { table_name: 'customer_entities', column_name: 'organization_id' },
1027
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
1028
+ { table_name: 'customer_entities', column_name: 'display_name' },
1029
+ ],
1030
+ })
1031
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
1032
+ return { fakeDb, engine }
1033
+ }
1034
+
1035
+ const query = (engine: BasicQueryEngine) => engine.query('customers:customer_entity', {
1036
+ tenantId: 't1',
1037
+ organizationId: 'org1',
1038
+ fields: ['id'],
1039
+ page: { page: 1, pageSize: 2 },
1040
+ })
1041
+
1042
+ test('cap - 1 matching rows: exact total, no flag', async () => {
1043
+ process.env.OM_LIST_COUNT_CAP = '5'
1044
+ const { engine } = buildFixture(4)
1045
+ const result = await query(engine)
1046
+ expect(result.total).toBe(4)
1047
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1048
+ })
1049
+
1050
+ test('exactly cap matching rows: exact total, no flag — a genuine total of cap is not mislabeled', async () => {
1051
+ process.env.OM_LIST_COUNT_CAP = '5'
1052
+ const { engine } = buildFixture(5)
1053
+ const result = await query(engine)
1054
+ expect(result.total).toBe(5)
1055
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1056
+ })
1057
+
1058
+ test('cap + 1 matching rows: total reports cap (not the probe value) with the warning', async () => {
1059
+ process.env.OM_LIST_COUNT_CAP = '5'
1060
+ const { engine } = buildFixture(6)
1061
+ const result = await query(engine)
1062
+ expect(result.total).toBe(5)
1063
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 5 })
1064
+ })
1065
+
1066
+ test('OM_LIST_COUNT_CAP=0: exact totals however large the set', async () => {
1067
+ process.env.OM_LIST_COUNT_CAP = '0'
1068
+ const { engine } = buildFixture(7)
1069
+ const result = await query(engine)
1070
+ expect(result.total).toBe(7)
1071
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1072
+ })
1073
+
1074
+ test('unparseable value falls back to the default cap rather than disabling it', async () => {
1075
+ process.env.OM_LIST_COUNT_CAP = 'not-a-number'
1076
+ const { fakeDb, engine } = buildFixture(3)
1077
+ const result = await query(engine)
1078
+ expect(result.total).toBe(3)
1079
+ const outer = fakeDb._calls.find((call: any) => call._ops.table === '__subquery__')
1080
+ expect(outer._ops.subquery.limits).toBe(10_001)
1081
+ })
1082
+ })
1083
+
957
1084
  // A tiebreak sort is only worth configuring if the engine actually emits it.
958
1085
  // `list.tiebreakSortField` (used by the sales line routes to keep lines with an
959
1086
  // equal `line_number` in a repeatable order) is the first caller to pass more
@@ -1134,3 +1261,314 @@ describe('BasicQueryEngine entity-extension joins', () => {
1134
1261
  expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
1135
1262
  })
1136
1263
  })
1264
+
1265
+ describe('BasicQueryEngine like/ilike routing by column encryption', () => {
1266
+ // The gate is opt-in: OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS defaults to false and the
1267
+ // legacy rewrite-everything behavior stays. These cases flip it on; the last one pins the
1268
+ // default off.
1269
+ beforeEach(() => {
1270
+ process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS = 'true'
1271
+ })
1272
+ afterEach(() => {
1273
+ delete process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS
1274
+ })
1275
+
1276
+ // The token rewrite exists because ILIKE against ciphertext cannot match. On a plaintext
1277
+ // column SQL ILIKE is exact, and the rewrite silently changes the result set: tokenization
1278
+ // splits on non-alphanumerics and drops tokens shorter than minTokenLength, so a
1279
+ // document-number search like "ZK 1/2026" degrades to the tokens {202, 2026} and matches
1280
+ // every record from that year instead of the one document.
1281
+ const fakeDbWithTokens = () => createFakeKysely({
1282
+ customer_entities: [],
1283
+ search_tokens: [{ one: 1 }],
1284
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
1285
+ 'information_schema.columns': [
1286
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
1287
+ { table_name: 'customer_entities', column_name: 'display_name' },
1288
+ ],
1289
+ })
1290
+
1291
+ test('a plaintext base column keeps exact SQL ILIKE even when tokens are available', async () => {
1292
+ const fakeDb = fakeDbWithTokens()
1293
+ const engine = new BasicQueryEngine(
1294
+ {} as any,
1295
+ () => fakeDb as any,
1296
+ () => ({ getEncryptedFieldNames: async () => [] }) as any,
1297
+ )
1298
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1299
+
1300
+ await engine.query('customers:customer_entity', {
1301
+ tenantId: 't1',
1302
+ fields: ['id'],
1303
+ filters: { display_name: { $ilike: '%ZK 1/2026%' } },
1304
+ page: { page: 1, pageSize: 10 },
1305
+ })
1306
+
1307
+ expect(applySearchTokensSpy).not.toHaveBeenCalled()
1308
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
1309
+ expect(baseCall).toBeTruthy()
1310
+ const ilikeWhere = baseCall._ops.wheres.some(
1311
+ (w: any) => Array.isArray(w) && String(w[0]).includes('display_name') && w[1] === 'ilike' && w[2] === '%ZK 1/2026%',
1312
+ )
1313
+ expect(ilikeWhere).toBe(true)
1314
+ })
1315
+
1316
+ test('an encrypted base column still routes through search tokens', async () => {
1317
+ const engine = new BasicQueryEngine(
1318
+ {} as any,
1319
+ () => fakeDbWithTokens() as any,
1320
+ () => ({ getEncryptedFieldNames: async () => ['display_name'] }) as any,
1321
+ )
1322
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1323
+
1324
+ await engine.query('customers:customer_entity', {
1325
+ tenantId: 't1',
1326
+ fields: ['id'],
1327
+ filters: { display_name: { $ilike: '%avision%' } },
1328
+ page: { page: 1, pageSize: 10 },
1329
+ })
1330
+
1331
+ expect(applySearchTokensSpy).toHaveBeenCalled()
1332
+ })
1333
+
1334
+ test('no service + encryption disabled: nothing is ciphertext, ILIKE stays exact', async () => {
1335
+ process.env.TENANT_DATA_ENCRYPTION = 'no'
1336
+ try {
1337
+ const fakeDb = fakeDbWithTokens()
1338
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
1339
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1340
+
1341
+ await engine.query('customers:customer_entity', {
1342
+ tenantId: 't1',
1343
+ fields: ['id'],
1344
+ filters: { display_name: { $ilike: '%avision%' } },
1345
+ page: { page: 1, pageSize: 10 },
1346
+ })
1347
+
1348
+ expect(applySearchTokensSpy).not.toHaveBeenCalled()
1349
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
1350
+ const ilikeWhere = baseCall._ops.wheres.some(
1351
+ (w: any) => Array.isArray(w) && String(w[0]).includes('display_name') && w[1] === 'ilike' && w[2] === '%avision%',
1352
+ )
1353
+ expect(ilikeWhere).toBe(true)
1354
+ } finally {
1355
+ delete process.env.TENANT_DATA_ENCRYPTION
1356
+ }
1357
+ })
1358
+
1359
+ test('no service + encryption enabled: the map is unknown, the token rewrite is kept', async () => {
1360
+ // A swallowed DI failure looks exactly like "no service". Guessing "plaintext" here would
1361
+ // run ILIKE against ciphertext (zero rows, silently) -- so the gate stays inert instead.
1362
+ const engine = new BasicQueryEngine({} as any, () => fakeDbWithTokens() as any)
1363
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1364
+
1365
+ await engine.query('customers:customer_entity', {
1366
+ tenantId: 't1',
1367
+ fields: ['id'],
1368
+ filters: { display_name: { $ilike: '%avision%' } },
1369
+ page: { page: 1, pageSize: 10 },
1370
+ })
1371
+
1372
+ expect(applySearchTokensSpy).toHaveBeenCalled()
1373
+ })
1374
+
1375
+ test('a camelCase encryption-map entry still routes its column through tokens', async () => {
1376
+ // Encryption maps may declare `displayName` while the filter carries the column name
1377
+ // `display_name` (TenantDataEncryptionService resolves both). A raw name comparison would
1378
+ // misread that ciphertext column as plaintext and run ILIKE against ciphertext -- zero rows,
1379
+ // silently. The gate must match across name shapes.
1380
+ const engine = new BasicQueryEngine(
1381
+ {} as any,
1382
+ () => fakeDbWithTokens() as any,
1383
+ () => ({ getEncryptedFieldNames: async () => ['displayName'] }) as any,
1384
+ )
1385
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1386
+
1387
+ await engine.query('customers:customer_entity', {
1388
+ tenantId: 't1',
1389
+ fields: ['id'],
1390
+ filters: { display_name: { $ilike: '%avision%' } },
1391
+ page: { page: 1, pageSize: 10 },
1392
+ })
1393
+
1394
+ expect(applySearchTokensSpy).toHaveBeenCalled()
1395
+ })
1396
+
1397
+ test('with the flag off (default) the token rewrite is kept even for plaintext columns', () => {
1398
+ delete process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS
1399
+ const fakeDb = fakeDbWithTokens()
1400
+ const engine = new BasicQueryEngine(
1401
+ {} as any,
1402
+ () => fakeDb as any,
1403
+ () => ({ getEncryptedFieldNames: async () => [] }) as any,
1404
+ )
1405
+ const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
1406
+
1407
+ return engine
1408
+ .query('customers:customer_entity', {
1409
+ tenantId: 't1',
1410
+ fields: ['id'],
1411
+ filters: { display_name: { $ilike: '%avision%' } },
1412
+ page: { page: 1, pageSize: 10 },
1413
+ })
1414
+ .then(() => {
1415
+ expect(applySearchTokensSpy).toHaveBeenCalled()
1416
+ })
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
+ })
@@ -0,0 +1,19 @@
1
+ import { parseNumberWithDefault } from '../number'
2
+
3
+ export const DEFAULT_LIST_COUNT_CAP = 10_000
4
+
5
+ /**
6
+ * Cap on how many matching rows a list COUNT may visit before reporting
7
+ * `total: cap` with `meta.listCountCapWarning` (surfaced to clients as
8
+ * `totalIsCapped`). Returns the cap as a number, or `null` when capping is
9
+ * disabled.
10
+ *
11
+ * Resolution of `OM_LIST_COUNT_CAP`:
12
+ * - unset / blank / unparseable → `DEFAULT_LIST_COUNT_CAP` (the cap is on by
13
+ * default, and bad input must not silently disable it)
14
+ * - `0` (or negative) → `null` — capping disabled, exact counts everywhere
15
+ */
16
+ export function resolveListCountCap(): number | null {
17
+ const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })
18
+ return parsed <= 0 ? null : parsed
19
+ }