@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.0

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 +1 -4
  3. package/dist/lib/auth/jwt.js +0 -6
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationAccess.js +4 -7
  6. package/dist/lib/auth/organizationAccess.js.map +2 -2
  7. package/dist/lib/auth/server.js +7 -38
  8. package/dist/lib/auth/server.js.map +2 -2
  9. package/dist/lib/commands/command-bus.js +1 -6
  10. package/dist/lib/commands/command-bus.js.map +2 -2
  11. package/dist/lib/crud/factory.js +8 -24
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/data/engine.js +2 -8
  14. package/dist/lib/data/engine.js.map +2 -2
  15. package/dist/lib/location/countries.js +0 -12
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/openapi/crud.js +1 -4
  18. package/dist/lib/openapi/crud.js.map +2 -2
  19. package/dist/lib/query/engine.js +34 -270
  20. package/dist/lib/query/engine.js.map +3 -3
  21. package/dist/lib/query/types.js.map +1 -1
  22. package/dist/lib/search/config.js +0 -1
  23. package/dist/lib/search/config.js.map +2 -2
  24. package/dist/lib/version.js +1 -1
  25. package/dist/lib/version.js.map +1 -1
  26. package/dist/modules/events/factory.js +15 -69
  27. package/dist/modules/events/factory.js.map +2 -2
  28. package/dist/modules/registry.js +0 -15
  29. package/dist/modules/registry.js.map +2 -2
  30. package/dist/modules/widgets/component-registry.js.map +2 -2
  31. package/package.json +3 -10
  32. package/src/lib/auth/__tests__/jwt.test.ts +0 -13
  33. package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
  34. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
  35. package/src/lib/auth/__tests__/server.test.ts +0 -104
  36. package/src/lib/auth/jwt.ts +0 -17
  37. package/src/lib/auth/organizationAccess.ts +3 -11
  38. package/src/lib/auth/server.ts +8 -78
  39. package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
  40. package/src/lib/commands/command-bus.ts +1 -8
  41. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
  42. package/src/lib/crud/factory.ts +7 -33
  43. package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
  44. package/src/lib/data/engine.ts +1 -7
  45. package/src/lib/location/__tests__/countries.test.ts +0 -15
  46. package/src/lib/location/countries.ts +0 -17
  47. package/src/lib/openapi/crud.ts +0 -3
  48. package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
  49. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
  50. package/src/lib/query/__tests__/engine.test.ts +7 -445
  51. package/src/lib/query/engine.ts +54 -434
  52. package/src/lib/query/types.ts +0 -15
  53. package/src/lib/search/config.ts +0 -10
  54. package/src/modules/events/factory.ts +19 -111
  55. package/src/modules/events/types.ts +0 -17
  56. package/src/modules/registry.ts +0 -40
  57. package/src/modules/widgets/component-registry.ts +0 -14
  58. package/dist/lib/auth/mfaPendingAccess.js +0 -42
  59. package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
  60. package/dist/lib/auth/principal-service.js +0 -1
  61. package/dist/lib/auth/principal-service.js.map +0 -7
  62. package/dist/lib/html/htmlToPlainText.js +0 -16
  63. package/dist/lib/html/htmlToPlainText.js.map +0 -7
  64. package/dist/lib/query/count-cap.js +0 -11
  65. package/dist/lib/query/count-cap.js.map +0 -7
  66. package/dist/lib/queue/dispatchOrigin.js +0 -20
  67. package/dist/lib/queue/dispatchOrigin.js.map +0 -7
  68. package/dist/lib/search/entityAccess.js +0 -44
  69. package/dist/lib/search/entityAccess.js.map +0 -7
  70. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
  71. package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
  72. package/src/lib/auth/mfaPendingAccess.ts +0 -70
  73. package/src/lib/auth/principal-service.ts +0 -110
  74. package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
  75. package/src/lib/html/htmlToPlainText.ts +0 -17
  76. package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
  77. package/src/lib/query/__tests__/count-cap.test.ts +0 -41
  78. package/src/lib/query/count-cap.ts +0 -19
  79. package/src/lib/queue/dispatchOrigin.ts +0 -35
  80. package/src/lib/search/entityAccess.ts +0 -132
  81. package/src/modules/events/__tests__/factory.test.ts +0 -88
@@ -2,14 +2,11 @@ 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'
6
5
 
7
6
  // The token-presence answer is cached process-wide (TTL); without clearing it,
8
7
  // probe-count assertions would observe hits from earlier tests in this file.
9
8
  beforeEach(() => {
10
9
  clearSearchTokenPresenceCache()
11
- clearEncryptedLikeFieldsCache()
12
- clearColumnExistsCache()
13
10
  })
14
11
 
15
12
  // Mock modules with one entity extension
@@ -179,13 +176,6 @@ function createFakeKysely(overrides?: FakeData) {
179
176
  return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
180
177
  }
181
178
  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
- }
189
179
  return { count: String((data[localOps.table] || []).length) }
190
180
  }
191
181
  const rows = data[localOps.table] || []
@@ -198,22 +188,6 @@ function createFakeKysely(overrides?: FakeData) {
198
188
  }
199
189
 
200
190
  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
- }
217
191
  const parsed = parseTableSpec(tableArg)
218
192
  const ops = {
219
193
  table: parsed.table,
@@ -719,10 +693,10 @@ describe('BasicQueryEngine (Kysely)', () => {
719
693
 
720
694
  expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
721
695
  const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
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
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
726
700
  // Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
727
701
  // set is fetched, decrypted, and sorted in memory.
728
702
  expect(phase1Call._ops.orderBys).toEqual([])
@@ -904,7 +878,7 @@ describe('BasicQueryEngine (Kysely)', () => {
904
878
  page: { page: 1, pageSize: 2 },
905
879
  })
906
880
  expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
907
- const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
881
+ const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
908
882
  expect(phase1Call._ops.limits).toBe(0)
909
883
  })
910
884
 
@@ -938,37 +912,10 @@ describe('BasicQueryEngine (Kysely)', () => {
938
912
  maxRows: 3,
939
913
  totalMatched: 5,
940
914
  })
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)
915
+ const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
916
+ expect(phase1Call._ops.limits).toBe(3)
945
917
  expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
946
918
  })
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
- })
972
919
  })
973
920
 
974
921
  test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
@@ -1007,80 +954,6 @@ describe('BasicQueryEngine (Kysely)', () => {
1007
954
  expect(baseCall._ops.offsets).toBe(10)
1008
955
  })
1009
956
 
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
-
1084
957
  // A tiebreak sort is only worth configuring if the engine actually emits it.
1085
958
  // `list.tiebreakSortField` (used by the sales line routes to keep lines with an
1086
959
  // equal `line_number` in a repeatable order) is the first caller to pass more
@@ -1261,314 +1134,3 @@ describe('BasicQueryEngine entity-extension joins', () => {
1261
1134
  expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
1262
1135
  })
1263
1136
  })
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
- })