@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +62 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +190 -70
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -3
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +9 -4
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +104 -23
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +36 -6
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +51 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +60 -43
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +20 -10
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +64 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +346 -96
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
- package/src/lib/commands/command-bus.ts +9 -3
- package/src/lib/commands/command-interceptor-runner.ts +27 -6
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +157 -22
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +46 -7
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
- package/src/lib/query/__tests__/engine.test.ts +213 -13
- package/src/lib/query/ciphertext-search-warning.ts +106 -0
- package/src/lib/query/engine.ts +102 -61
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/entities.ts +15 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -282,6 +282,73 @@ describe('BasicQueryEngine — null equality', () => {
|
|
|
282
282
|
})
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
+
describe('BasicQueryEngine — doc-field null equality (issue #4841)', () => {
|
|
286
|
+
function serializeNode(value: any): string {
|
|
287
|
+
return JSON.stringify(value, (_key, inner) =>
|
|
288
|
+
inner && typeof inner.toOperationNode === 'function' ? inner.toOperationNode() : inner,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The doc-backed predicates land inside the entity_indexes EXISTS sub-builder;
|
|
293
|
+
// pick out the one that reads the JSONB doc (`doc ->> 'field'`).
|
|
294
|
+
function docPredicates(fakeDb: any, table: string): string[] {
|
|
295
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === table)
|
|
296
|
+
expect(baseCall).toBeTruthy()
|
|
297
|
+
const existsEntries = (baseCall._ops.wheres as any[]).filter(
|
|
298
|
+
(where: any) => Array.isArray(where) && where[0] === 'exists',
|
|
299
|
+
)
|
|
300
|
+
expect(existsEntries.length).toBeGreaterThan(0)
|
|
301
|
+
return existsEntries
|
|
302
|
+
.flatMap((entry: any) => (entry[1]?._ops?.wheres ?? []) as any[])
|
|
303
|
+
.map((where: any) => serializeNode(where))
|
|
304
|
+
.filter((serialized: string) => serialized.includes('->>'))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function runDocFilter(filters: Record<string, unknown>) {
|
|
308
|
+
const fakeDb = createFakeKysely({
|
|
309
|
+
scheduled_jobs: [],
|
|
310
|
+
// `ended_at` is deliberately absent, so the field resolves to the
|
|
311
|
+
// entity_indexes doc rather than to a base column.
|
|
312
|
+
'information_schema.columns': [
|
|
313
|
+
{ table_name: 'scheduled_jobs', column_name: 'id' },
|
|
314
|
+
],
|
|
315
|
+
'information_schema.tables': [{ table_name: 'entity_indexes' }],
|
|
316
|
+
})
|
|
317
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
318
|
+
await engine.query('scheduler:scheduled_job', {
|
|
319
|
+
tenantId: 't1',
|
|
320
|
+
fields: ['id'],
|
|
321
|
+
omitAutomaticTenantOrgScope: true,
|
|
322
|
+
filters,
|
|
323
|
+
})
|
|
324
|
+
return docPredicates(fakeDb, 'scheduled_jobs')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
test('$eq null on a doc field compiles to "is null", never "= null"', async () => {
|
|
328
|
+
const predicates = await runDocFilter({ ended_at: { $eq: null } })
|
|
329
|
+
|
|
330
|
+
expect(predicates.length).toBeGreaterThan(0)
|
|
331
|
+
expect(predicates.some((sql: string) => sql.includes('is null'))).toBe(true)
|
|
332
|
+
// `(doc ->> 'ended_at') = NULL` is never TRUE in SQL, so it silently
|
|
333
|
+
// returns zero rows instead of matching unset values.
|
|
334
|
+
expect(predicates.some((sql: string) => / = /.test(sql))).toBe(false)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('bare null on a doc field compiles to "is null"', async () => {
|
|
338
|
+
const predicates = await runDocFilter({ ended_at: null })
|
|
339
|
+
|
|
340
|
+
expect(predicates.some((sql: string) => sql.includes('is null'))).toBe(true)
|
|
341
|
+
expect(predicates.some((sql: string) => / = /.test(sql))).toBe(false)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('$ne null on a doc field compiles to "is not null", never "<> null"', async () => {
|
|
345
|
+
const predicates = await runDocFilter({ started_at: { $ne: null } })
|
|
346
|
+
|
|
347
|
+
expect(predicates.some((sql: string) => sql.includes('is not null'))).toBe(true)
|
|
348
|
+
expect(predicates.some((sql: string) => sql.includes('<>'))).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
285
352
|
describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
|
|
286
353
|
test('skips automatic tenant and organization guards when flag is set', async () => {
|
|
287
354
|
const fakeDb = createFakeKysely({
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { BasicQueryEngine } from '../engine'
|
|
2
2
|
import { SortDir } from '../types'
|
|
3
3
|
import { registerModules } from '../../i18n/server'
|
|
4
|
+
import { clearSearchTokenPresenceCache } from '../../search/availability'
|
|
5
|
+
|
|
6
|
+
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
7
|
+
// probe-count assertions would observe hits from earlier tests in this file.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
clearSearchTokenPresenceCache()
|
|
10
|
+
})
|
|
4
11
|
|
|
5
12
|
// Mock modules with one entity extension
|
|
6
13
|
const mockModules = [
|
|
7
14
|
{ id: 'auth', entityExtensions: [ { base: 'auth:user', extension: 'my_module:user_profile', join: { baseKey: 'id', extensionKey: 'user_id' } } ] },
|
|
15
|
+
{
|
|
16
|
+
id: 'example',
|
|
17
|
+
entityExtensions: [
|
|
18
|
+
// Declares `table` explicitly; the derived plural now agrees with it.
|
|
19
|
+
{ base: 'customers:customer_entity', extension: 'example:example_customer_priority', join: { baseKey: 'id', extensionKey: 'customer_id' }, table: 'example_customer_priorities' },
|
|
20
|
+
// No `table`: exercises the derived-plural fallback.
|
|
21
|
+
{ base: 'auth:role', extension: 'example:example_role_policy', join: { baseKey: 'id', extensionKey: 'role_id' } },
|
|
22
|
+
// `table` is not a bare identifier, so the engine must refuse it.
|
|
23
|
+
{ base: 'auth:session', extension: 'example:example_session_note', join: { baseKey: 'id', extensionKey: 'session_id' }, table: 'notes"; drop table users --' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
8
26
|
]
|
|
9
27
|
|
|
10
28
|
// Register modules for the registration-based pattern
|
|
@@ -355,6 +373,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
355
373
|
const fakeDb = createFakeKysely({
|
|
356
374
|
customer_entities: [],
|
|
357
375
|
customer_people: [],
|
|
376
|
+
search_tokens: [{ one: 1 }],
|
|
377
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
358
378
|
'information_schema.columns': [
|
|
359
379
|
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
360
380
|
{ table_name: 'customer_people', column_name: 'id' },
|
|
@@ -362,8 +382,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
362
382
|
],
|
|
363
383
|
})
|
|
364
384
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
365
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
366
|
-
jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
367
385
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
368
386
|
|
|
369
387
|
await engine.query('customers:customer_entity', {
|
|
@@ -409,7 +427,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
409
427
|
],
|
|
410
428
|
})
|
|
411
429
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
412
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
|
|
413
430
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
414
431
|
|
|
415
432
|
await engine.query('customers:customer_entity', {
|
|
@@ -445,10 +462,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
445
462
|
test('uses search tokens for index document fields on base entities', async () => {
|
|
446
463
|
const fakeDb = createFakeKysely({
|
|
447
464
|
todos: [],
|
|
465
|
+
search_tokens: [{ one: 1 }],
|
|
466
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
448
467
|
})
|
|
449
468
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
450
|
-
const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
451
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
452
469
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
453
470
|
|
|
454
471
|
await engine.query('example:todo', {
|
|
@@ -461,12 +478,15 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
461
478
|
page: { page: 1, pageSize: 10 },
|
|
462
479
|
})
|
|
463
480
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
)
|
|
481
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string; wheres: unknown[][] } }>
|
|
482
|
+
const tableProbe = calls.find((call) =>
|
|
483
|
+
call._ops.table === 'information_schema.tables' &&
|
|
484
|
+
call._ops.wheres.some((where) => where[0] === 'table_name' && where[2] === 'search_tokens'))
|
|
485
|
+
expect(tableProbe).toBeTruthy()
|
|
486
|
+
const tokenProbe = calls.find((call) =>
|
|
487
|
+
call._ops.table === 'search_tokens' &&
|
|
488
|
+
call._ops.wheres.some((where) => where[0] === 'entity_type' && where[2] === 'example:todo'))
|
|
489
|
+
expect(tokenProbe).toBeTruthy()
|
|
470
490
|
expect(applySearchTokensSpy).toHaveBeenCalledWith(
|
|
471
491
|
expect.anything(),
|
|
472
492
|
expect.objectContaining({
|
|
@@ -488,7 +508,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
488
508
|
],
|
|
489
509
|
})
|
|
490
510
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
491
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
492
511
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
493
512
|
|
|
494
513
|
await engine.query('example:todo', {
|
|
@@ -501,7 +520,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
501
520
|
page: { page: 1, pageSize: 10 },
|
|
502
521
|
})
|
|
503
522
|
|
|
504
|
-
|
|
523
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string } }>
|
|
524
|
+
expect(calls.some((call) => call._ops.table === 'search_tokens')).toBe(false)
|
|
505
525
|
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
506
526
|
const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === 'todos')
|
|
507
527
|
expect(baseCall?._ops.wheres).toContainEqual(['todos.search_text', 'ilike', '%avision%'])
|
|
@@ -933,4 +953,184 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
933
953
|
expect(baseCall._ops.limits).toBe(10)
|
|
934
954
|
expect(baseCall._ops.offsets).toBe(10)
|
|
935
955
|
})
|
|
956
|
+
|
|
957
|
+
// A tiebreak sort is only worth configuring if the engine actually emits it.
|
|
958
|
+
// `list.tiebreakSortField` (used by the sales line routes to keep lines with an
|
|
959
|
+
// equal `line_number` in a repeatable order) is the first caller to pass more
|
|
960
|
+
// than one sort element, so pin that every element reaches ORDER BY in order —
|
|
961
|
+
// dropping sort[1] would silently restore the non-determinism it exists to fix.
|
|
962
|
+
test('emits every sort element as an ORDER BY column, in order', async () => {
|
|
963
|
+
const fakeDb = createFakeKysely({
|
|
964
|
+
sales_order_lines: [
|
|
965
|
+
{ id: 'b', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
|
|
966
|
+
{ id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
|
|
967
|
+
],
|
|
968
|
+
'information_schema.columns': [
|
|
969
|
+
{ table_name: 'sales_order_lines', column_name: 'id' },
|
|
970
|
+
{ table_name: 'sales_order_lines', column_name: 'tenant_id' },
|
|
971
|
+
{ table_name: 'sales_order_lines', column_name: 'organization_id' },
|
|
972
|
+
{ table_name: 'sales_order_lines', column_name: 'deleted_at' },
|
|
973
|
+
{ table_name: 'sales_order_lines', column_name: 'line_number' },
|
|
974
|
+
],
|
|
975
|
+
})
|
|
976
|
+
const engine = new BasicQueryEngine(
|
|
977
|
+
{} as any,
|
|
978
|
+
() => fakeDb as any,
|
|
979
|
+
() => ({
|
|
980
|
+
isEnabled: () => true,
|
|
981
|
+
getEncryptedFieldNames: async () => [],
|
|
982
|
+
}),
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
await engine.query('sales:sales_order_line', {
|
|
986
|
+
tenantId: 't1',
|
|
987
|
+
organizationId: 'org1',
|
|
988
|
+
fields: ['id', 'line_number'],
|
|
989
|
+
sort: [
|
|
990
|
+
{ field: 'line_number', dir: SortDir.Asc },
|
|
991
|
+
{ field: 'id', dir: SortDir.Asc },
|
|
992
|
+
],
|
|
993
|
+
page: { page: 1, pageSize: 10 },
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
|
|
997
|
+
expect(baseCall._ops.orderBys).toEqual([
|
|
998
|
+
['sales_order_lines.line_number', 'asc'],
|
|
999
|
+
['sales_order_lines.id', 'asc'],
|
|
1000
|
+
])
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
test('keeps each sort element on its own direction', async () => {
|
|
1004
|
+
const fakeDb = createFakeKysely({
|
|
1005
|
+
sales_order_lines: [
|
|
1006
|
+
{ id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 1 },
|
|
1007
|
+
],
|
|
1008
|
+
'information_schema.columns': [
|
|
1009
|
+
{ table_name: 'sales_order_lines', column_name: 'id' },
|
|
1010
|
+
{ table_name: 'sales_order_lines', column_name: 'tenant_id' },
|
|
1011
|
+
{ table_name: 'sales_order_lines', column_name: 'organization_id' },
|
|
1012
|
+
{ table_name: 'sales_order_lines', column_name: 'deleted_at' },
|
|
1013
|
+
{ table_name: 'sales_order_lines', column_name: 'line_number' },
|
|
1014
|
+
],
|
|
1015
|
+
})
|
|
1016
|
+
const engine = new BasicQueryEngine(
|
|
1017
|
+
{} as any,
|
|
1018
|
+
() => fakeDb as any,
|
|
1019
|
+
() => ({
|
|
1020
|
+
isEnabled: () => true,
|
|
1021
|
+
getEncryptedFieldNames: async () => [],
|
|
1022
|
+
}),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
await engine.query('sales:sales_order_line', {
|
|
1026
|
+
tenantId: 't1',
|
|
1027
|
+
organizationId: 'org1',
|
|
1028
|
+
fields: ['id', 'line_number'],
|
|
1029
|
+
sort: [
|
|
1030
|
+
{ field: 'line_number', dir: SortDir.Desc },
|
|
1031
|
+
{ field: 'id', dir: SortDir.Asc },
|
|
1032
|
+
],
|
|
1033
|
+
page: { page: 1, pageSize: 10 },
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
|
|
1037
|
+
expect(baseCall._ops.orderBys).toEqual([
|
|
1038
|
+
['sales_order_lines.line_number', 'desc'],
|
|
1039
|
+
['sales_order_lines.id', 'asc'],
|
|
1040
|
+
])
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
describe('search_tokens coverage probe (#4723 parity)', () => {
|
|
1044
|
+
type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
|
|
1045
|
+
|
|
1046
|
+
const countProbes = (fakeDb: ProbeDbLog): number =>
|
|
1047
|
+
fakeDb._calls.filter((call) => call._ops.table === 'search_tokens').length
|
|
1048
|
+
|
|
1049
|
+
const buildEngine = (fakeDb: unknown): BasicQueryEngine => new BasicQueryEngine(
|
|
1050
|
+
{} as ConstructorParameters<typeof BasicQueryEngine>[0],
|
|
1051
|
+
(() => fakeDb) as unknown as NonNullable<ConstructorParameters<typeof BasicQueryEngine>[1]>,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
const buildDb = () => createFakeKysely({
|
|
1055
|
+
users: [],
|
|
1056
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
test('is skipped when the query carries no like/ilike filter', async () => {
|
|
1060
|
+
const fakeDb = buildDb()
|
|
1061
|
+
const engine = buildEngine(fakeDb)
|
|
1062
|
+
|
|
1063
|
+
await engine.query('auth:user', {
|
|
1064
|
+
tenantId: 't1',
|
|
1065
|
+
organizationId: 'org1',
|
|
1066
|
+
filters: { is_active: { $eq: true } },
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
expect(countProbes(fakeDb)).toBe(0)
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
test('still runs when the query actually searches', async () => {
|
|
1073
|
+
const fakeDb = buildDb()
|
|
1074
|
+
const engine = buildEngine(fakeDb)
|
|
1075
|
+
|
|
1076
|
+
await engine.query('auth:user', {
|
|
1077
|
+
tenantId: 't1',
|
|
1078
|
+
organizationId: 'org1',
|
|
1079
|
+
filters: { email: { $ilike: '%abc%' } },
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
expect(countProbes(fakeDb)).toBeGreaterThan(0)
|
|
1083
|
+
})
|
|
1084
|
+
})
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
describe('BasicQueryEngine entity-extension joins', () => {
|
|
1088
|
+
function extensionJoins(fakeDb: any, baseTable: string): any[] {
|
|
1089
|
+
const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === baseTable)
|
|
1090
|
+
expect(baseCall).toBeTruthy()
|
|
1091
|
+
return baseCall._ops.joins.filter((entry: any) => Object.keys(entry.aliasObj)[0].startsWith('ext_'))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function joinFor(entity: string, baseTable: string): Promise<any> {
|
|
1095
|
+
const fakeDb = createFakeKysely()
|
|
1096
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
1097
|
+
await engine.query(entity, {
|
|
1098
|
+
tenantId: 't1',
|
|
1099
|
+
organizationId: 'org1',
|
|
1100
|
+
fields: ['id'],
|
|
1101
|
+
includeExtensions: true,
|
|
1102
|
+
})
|
|
1103
|
+
const joins = extensionJoins(fakeDb, baseTable)
|
|
1104
|
+
expect(joins).toHaveLength(1)
|
|
1105
|
+
return joins[0]
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
test('prefers a declared table over the derived plural', async () => {
|
|
1109
|
+
const join = await joinFor('customers:customer_entity', 'customer_entities')
|
|
1110
|
+
expect(join.aliasObj).toEqual({ ext_example_customer_priority: 'example_customer_priorities' })
|
|
1111
|
+
expect(join.conditions).toEqual([
|
|
1112
|
+
{
|
|
1113
|
+
method: 'on',
|
|
1114
|
+
args: ['ext_example_customer_priority.customer_id', '=', 'customer_entities.id'],
|
|
1115
|
+
},
|
|
1116
|
+
])
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
test('falls back to the derived plural when no table is declared', async () => {
|
|
1120
|
+
// `policy` ends in `y`, so the correct plural is `policies`. This previously asserted
|
|
1121
|
+
// `policys`, pinning a separate inline `+s` pluralizer that the extension-join path used
|
|
1122
|
+
// instead of the file's own `pluralizeBaseName` — the very bug that made
|
|
1123
|
+
// `example_customer_priority` derive `example_customer_prioritys` and forced the
|
|
1124
|
+
// `table` override into existence.
|
|
1125
|
+
const join = await joinFor('auth:role', 'roles')
|
|
1126
|
+
expect(join.aliasObj).toEqual({ ext_example_role_policy: 'example_role_policies' })
|
|
1127
|
+
expect(join.conditions).toEqual([
|
|
1128
|
+
{ method: 'on', args: ['ext_example_role_policy.role_id', '=', 'roles.id'] },
|
|
1129
|
+
])
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
test('ignores a declared table that is not a bare identifier', async () => {
|
|
1133
|
+
const join = await joinFor('auth:session', 'sessions')
|
|
1134
|
+
expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
|
|
1135
|
+
})
|
|
936
1136
|
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createLogger } from '../logger'
|
|
2
|
+
import type { EntityId } from '../../modules/entities'
|
|
3
|
+
|
|
4
|
+
const logger = createLogger('shared').child({ component: 'query' })
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal slice of `TenantDataEncryptionService` needed to decide whether a
|
|
8
|
+
* text filter targets an encrypted column.
|
|
9
|
+
*/
|
|
10
|
+
export type CiphertextWarningEncryptionService = {
|
|
11
|
+
getEncryptedFieldNames?: (
|
|
12
|
+
entityId: EntityId,
|
|
13
|
+
tenantId?: string | null,
|
|
14
|
+
organizationId?: string | null,
|
|
15
|
+
) => Promise<readonly string[]>
|
|
16
|
+
isEnabled?: () => boolean
|
|
17
|
+
} | null | undefined
|
|
18
|
+
|
|
19
|
+
export type CiphertextLikeFallbackReason =
|
|
20
|
+
| 'no-indexable-tokens'
|
|
21
|
+
| 'no-search-tokens'
|
|
22
|
+
| 'search-disabled'
|
|
23
|
+
| 'raw-orm-filter'
|
|
24
|
+
|
|
25
|
+
const REASON_HINTS: Record<CiphertextLikeFallbackReason, string> = {
|
|
26
|
+
'search-disabled':
|
|
27
|
+
'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.',
|
|
28
|
+
'no-indexable-tokens':
|
|
29
|
+
'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.',
|
|
30
|
+
'no-search-tokens':
|
|
31
|
+
'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',
|
|
32
|
+
'raw-orm-filter':
|
|
33
|
+
'This filter bypasses the query engine, so it runs as ILIKE against ciphertext and matches nothing. Resolve ids through findEntityIdsBySearchTokens (@open-mercato/shared/lib/search/tokenLookup), filter on the deterministic hash column, or compare in memory after decryption.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One warning per entity/tenant/field per process — the fallback repeats on
|
|
37
|
+
// every request, and an operator only needs to learn about it once.
|
|
38
|
+
const WARN_CACHE_CAP = 5000
|
|
39
|
+
const warned = new Set<string>()
|
|
40
|
+
|
|
41
|
+
const warnKey = (entity: string, tenantId: string | null, field: string): string =>
|
|
42
|
+
`${entity}|${tenantId ?? 'null'}|${field}`
|
|
43
|
+
|
|
44
|
+
export const normalizeColumnName = (value: string): string =>
|
|
45
|
+
value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
46
|
+
|
|
47
|
+
/** Test seam: the warn-once cache is process-global by design. */
|
|
48
|
+
export function resetCiphertextLikeWarnCache(): void {
|
|
49
|
+
warned.clear()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Warn when a `like`/`ilike` filter is about to run against a column that an
|
|
54
|
+
* encryption map covers while the token index is unavailable.
|
|
55
|
+
*
|
|
56
|
+
* Without this the query degrades silently: the predicate compares a plaintext
|
|
57
|
+
* pattern against ciphertext, matches nothing, and the endpoint returns an
|
|
58
|
+
* empty page that is indistinguishable from a genuine no-result. Issue #2990.
|
|
59
|
+
*
|
|
60
|
+
* Never throws and never blocks the query — a failure here is logged at debug
|
|
61
|
+
* level and the caller proceeds unchanged.
|
|
62
|
+
*/
|
|
63
|
+
export async function warnOnCiphertextLikeFallback(params: {
|
|
64
|
+
entity: string
|
|
65
|
+
fields: readonly string[]
|
|
66
|
+
tenantId: string | null
|
|
67
|
+
reason: CiphertextLikeFallbackReason
|
|
68
|
+
service: CiphertextWarningEncryptionService
|
|
69
|
+
}): Promise<void> {
|
|
70
|
+
const { entity, tenantId, reason, service } = params
|
|
71
|
+
if (!service || service.isEnabled?.() === false) return
|
|
72
|
+
if (typeof service.getEncryptedFieldNames !== 'function') return
|
|
73
|
+
|
|
74
|
+
const candidates = params.fields
|
|
75
|
+
.filter((field) => typeof field === 'string' && !field.startsWith('cf:'))
|
|
76
|
+
.map((field) => normalizeColumnName(field))
|
|
77
|
+
.filter((field, index, all) => field.length > 0 && all.indexOf(field) === index)
|
|
78
|
+
.filter((field) => !warned.has(warnKey(entity, tenantId, field)))
|
|
79
|
+
if (!candidates.length) return
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// `organizationId: null` resolves the global map plus every per-organization
|
|
83
|
+
// override, so a field encrypted for any organization is reported.
|
|
84
|
+
const encrypted = new Set(
|
|
85
|
+
(await service.getEncryptedFieldNames(entity, tenantId, null)).map((field) =>
|
|
86
|
+
normalizeColumnName(String(field)),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
for (const field of candidates) {
|
|
90
|
+
if (!encrypted.has(field)) continue
|
|
91
|
+
if (warned.size >= WARN_CACHE_CAP) warned.clear()
|
|
92
|
+
warned.add(warnKey(entity, tenantId, field))
|
|
93
|
+
logger.warn('Text search filter cannot match an encrypted column', {
|
|
94
|
+
entity,
|
|
95
|
+
field,
|
|
96
|
+
reason,
|
|
97
|
+
hint: REASON_HINTS[reason],
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.debug('Ciphertext search warning check failed', {
|
|
102
|
+
entity,
|
|
103
|
+
err: err instanceof Error ? err.message : String(err),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|