@open-mercato/shared 0.6.8-develop.6889.1.af4501ca97 → 0.6.8-develop.6892.1.f179677788

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 (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +5 -0
  3. package/dist/lib/bootstrap/dynamicLoader.js +40 -4
  4. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  5. package/dist/lib/bootstrap/factory.js +18 -13
  6. package/dist/lib/bootstrap/factory.js.map +2 -2
  7. package/dist/lib/encryption/find.js +22 -0
  8. package/dist/lib/encryption/find.js.map +2 -2
  9. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  10. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  11. package/dist/lib/i18n/dictionary-cache.js +5 -0
  12. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  13. package/dist/lib/modules/registry.js +31 -5
  14. package/dist/lib/modules/registry.js.map +2 -2
  15. package/dist/lib/query/ciphertext-search-warning.js +7 -1
  16. package/dist/lib/query/ciphertext-search-warning.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  21. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  22. package/src/lib/bootstrap/dynamicLoader.ts +52 -4
  23. package/src/lib/bootstrap/factory.ts +21 -15
  24. package/src/lib/bootstrap/types.ts +6 -0
  25. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  26. package/src/lib/encryption/find.ts +22 -0
  27. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  28. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  29. package/src/lib/i18n/dictionary-cache.ts +5 -0
  30. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  31. package/src/lib/modules/registry.ts +36 -7
  32. package/src/lib/query/ciphertext-search-warning.ts +17 -6
@@ -0,0 +1,167 @@
1
+ import { collectLikeFilterFields, warnOnEncryptedLikeFilter } from '../likeFilterWarning'
2
+ import { registerEntityIds } from '../entityIds'
3
+ import { resetCiphertextLikeWarnCache } from '../../query/ciphertext-search-warning'
4
+
5
+ jest.mock('../../logger', () => {
6
+ const warn = jest.fn()
7
+ const debug = jest.fn()
8
+ const child = jest.fn(() => ({ warn, debug }))
9
+ return { createLogger: jest.fn(() => ({ child })), __warn: warn, __debug: debug }
10
+ })
11
+
12
+ jest.mock('../customFieldValues', () => ({
13
+ resolveTenantEncryptionService: jest.fn(() => null),
14
+ }))
15
+
16
+ const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock }
17
+
18
+ describe('collectLikeFilterFields', () => {
19
+ it('finds string operators on a top-level property', () => {
20
+ expect(collectLikeFilterFields({ title: { $ilike: '%q%' } })).toEqual(['title'])
21
+ expect(collectLikeFilterFields({ title: { $like: 'q%' } })).toEqual(['title'])
22
+ expect(collectLikeFilterFields({ title: { $re: '^q' } })).toEqual(['title'])
23
+ })
24
+
25
+ it('ignores filters that do not use a string operator', () => {
26
+ expect(collectLikeFilterFields({ title: 'Renewal', status: { $in: ['open'] } })).toEqual([])
27
+ expect(collectLikeFilterFields({ createdAt: { $gte: new Date(0) } })).toEqual([])
28
+ })
29
+
30
+ it('descends into $and, $or and $not branches', () => {
31
+ const where = {
32
+ $and: [
33
+ { tenantId: 't1' },
34
+ { $or: [{ title: { $ilike: '%q%' } }, { subject: { $ilike: '%q%' } }] },
35
+ ],
36
+ }
37
+ expect(collectLikeFilterFields(where).sort()).toEqual(['subject', 'title'])
38
+ expect(collectLikeFilterFields({ title: { $not: { $ilike: '%q%' } } })).toEqual(['title'])
39
+ })
40
+
41
+ it('does not attribute a nested relation filter to the root entity', () => {
42
+ expect(collectLikeFilterFields({ customer: { displayName: { $ilike: '%q%' } } })).toEqual([])
43
+ })
44
+
45
+ it('tolerates empty, null and non-object filters', () => {
46
+ expect(collectLikeFilterFields(undefined)).toEqual([])
47
+ expect(collectLikeFilterFields(null)).toEqual([])
48
+ expect(collectLikeFilterFields('id-1')).toEqual([])
49
+ expect(collectLikeFilterFields({})).toEqual([])
50
+ })
51
+
52
+ it('reports each property once regardless of how many branches match', () => {
53
+ const where = { $or: [{ title: { $ilike: '%a%' } }, { title: { $ilike: '%b%' } }] }
54
+ expect(collectLikeFilterFields(where)).toEqual(['title'])
55
+ })
56
+ })
57
+
58
+ describe('warnOnEncryptedLikeFilter', () => {
59
+ const originalNodeEnv = process.env.NODE_ENV
60
+
61
+ const makeEm = () => ({
62
+ getMetadata: () => ({ find: (name: string) => (name === 'MailMessage' ? { className: 'MailMessage' } : undefined) }),
63
+ })
64
+
65
+ const makeService = (encryptedFields: string[]) => ({
66
+ isEnabled: () => true,
67
+ getEncryptedFieldNames: jest.fn(async () => encryptedFields),
68
+ })
69
+
70
+ beforeAll(() => {
71
+ registerEntityIds({ mail: { mail_message: 'mail:mail_message' } })
72
+ })
73
+
74
+ beforeEach(() => {
75
+ resetCiphertextLikeWarnCache()
76
+ loggerModule.__warn.mockClear()
77
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', configurable: true })
78
+ })
79
+
80
+ afterAll(() => {
81
+ Object.defineProperty(process.env, 'NODE_ENV', { value: originalNodeEnv, configurable: true })
82
+ })
83
+
84
+ it('warns when the filtered property is covered by an encryption map', async () => {
85
+ await warnOnEncryptedLikeFilter({
86
+ em: makeEm() as any,
87
+ entityName: 'MailMessage',
88
+ where: { subject: { $ilike: '%invoice%' } },
89
+ tenantId: 'tenant-1',
90
+ encryptionService: makeService(['subject']) as any,
91
+ })
92
+
93
+ expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
94
+ expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({
95
+ entity: 'mail:mail_message',
96
+ field: 'subject',
97
+ reason: 'raw-orm-filter',
98
+ })
99
+ expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('findEntityIdsBySearchTokens')
100
+ })
101
+
102
+ it('stays quiet for properties outside the encryption map', async () => {
103
+ await warnOnEncryptedLikeFilter({
104
+ em: makeEm() as any,
105
+ entityName: 'MailMessage',
106
+ where: { subject: { $ilike: '%invoice%' } },
107
+ tenantId: 'tenant-1',
108
+ encryptionService: makeService(['body']) as any,
109
+ })
110
+ expect(loggerModule.__warn).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('skips the encryption lookup entirely in production', async () => {
114
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', configurable: true })
115
+ const service = makeService(['subject'])
116
+ await warnOnEncryptedLikeFilter({
117
+ em: makeEm() as any,
118
+ entityName: 'MailMessage',
119
+ where: { subject: { $ilike: '%invoice%' } },
120
+ tenantId: 'tenant-1',
121
+ encryptionService: service as any,
122
+ })
123
+ expect(service.getEncryptedFieldNames).not.toHaveBeenCalled()
124
+ expect(loggerModule.__warn).not.toHaveBeenCalled()
125
+ })
126
+
127
+ it('skips the encryption lookup when no string operator is present', async () => {
128
+ const service = makeService(['subject'])
129
+ await warnOnEncryptedLikeFilter({
130
+ em: makeEm() as any,
131
+ entityName: 'MailMessage',
132
+ where: { subject: 'Invoice' },
133
+ tenantId: 'tenant-1',
134
+ encryptionService: service as any,
135
+ })
136
+ expect(service.getEncryptedFieldNames).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it('stays quiet when the entity id cannot be resolved', async () => {
140
+ await warnOnEncryptedLikeFilter({
141
+ em: makeEm() as any,
142
+ entityName: 'UnknownEntity',
143
+ where: { subject: { $ilike: '%invoice%' } },
144
+ tenantId: 'tenant-1',
145
+ encryptionService: makeService(['subject']) as any,
146
+ })
147
+ expect(loggerModule.__warn).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('never rethrows when metadata access fails', async () => {
151
+ const em = {
152
+ getMetadata: () => {
153
+ throw new Error('orm not initialized')
154
+ },
155
+ }
156
+ await expect(
157
+ warnOnEncryptedLikeFilter({
158
+ em: em as any,
159
+ entityName: 'MailMessage',
160
+ where: { subject: { $ilike: '%invoice%' } },
161
+ tenantId: 'tenant-1',
162
+ encryptionService: makeService(['subject']) as any,
163
+ }),
164
+ ).resolves.toBeUndefined()
165
+ expect(loggerModule.__warn).not.toHaveBeenCalled()
166
+ })
167
+ })
@@ -5,6 +5,7 @@ import type {
5
5
  FindOneOptions,
6
6
  FindOptions,
7
7
  } from '@mikro-orm/postgresql'
8
+ import { warnOnEncryptedLikeFilter } from './likeFilterWarning'
8
9
  import { decryptEntitiesWithFallbackScope } from './subscriber'
9
10
  import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
10
11
 
@@ -24,6 +25,13 @@ export async function findWithDecryption<Entity extends object, Hint extends str
24
25
  options?: AnyFindOptions<Entity, Hint>,
25
26
  scope?: DecryptionScope,
26
27
  ): Promise<Entity[]> {
28
+ await warnOnEncryptedLikeFilter({
29
+ em,
30
+ entityName,
31
+ where,
32
+ tenantId: scope?.tenantId,
33
+ encryptionService: scope?.encryptionService,
34
+ })
27
35
  const records = (await em.find<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
28
36
  | Entity[]
29
37
  | undefined
@@ -44,6 +52,13 @@ export async function findOneWithDecryption<Entity extends object, Hint extends
44
52
  options?: AnyFindOneOptions<Entity, Hint>,
45
53
  scope?: DecryptionScope,
46
54
  ): Promise<Entity | null> {
55
+ await warnOnEncryptedLikeFilter({
56
+ em,
57
+ entityName,
58
+ where,
59
+ tenantId: scope?.tenantId,
60
+ encryptionService: scope?.encryptionService,
61
+ })
47
62
  const record = (await em.findOne<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
48
63
  | Entity
49
64
  | null
@@ -64,6 +79,13 @@ export async function findAndCountWithDecryption<Entity extends object, Hint ext
64
79
  options?: AnyFindOptions<Entity, Hint>,
65
80
  scope?: DecryptionScope,
66
81
  ): Promise<[Entity[], number]> {
82
+ await warnOnEncryptedLikeFilter({
83
+ em,
84
+ entityName,
85
+ where,
86
+ tenantId: scope?.tenantId,
87
+ encryptionService: scope?.encryptionService,
88
+ })
67
89
  const [recordsRaw, count] = await em.findAndCount<Entity, Hint, any, any>(
68
90
  entityName as any,
69
91
  where as any,
@@ -0,0 +1,102 @@
1
+ import type { EntityManager, EntityName } from '@mikro-orm/postgresql'
2
+ import { warnOnCiphertextLikeFallback } from '../query/ciphertext-search-warning'
3
+ import { resolveTenantEncryptionService } from './customFieldValues'
4
+ import { resolveEntityIdFromMetadata } from './entityIds'
5
+ import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
6
+
7
+ const LIKE_OPERATORS = new Set(['$like', '$ilike', '$re', '$fulltext'])
8
+ const LOGICAL_OPERATORS = new Set(['$and', '$or', '$not', '$every', '$some', '$none'])
9
+
10
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)
12
+
13
+ // An operator object holds only `$`-prefixed keys. Anything else is a nested
14
+ // relation filter, whose property names belong to another entity's encryption
15
+ // map — attributing them to the root entity would produce false warnings.
16
+ const isOperatorObject = (value: unknown): value is Record<string, unknown> => {
17
+ if (!isPlainObject(value)) return false
18
+ const keys = Object.keys(value)
19
+ return keys.length > 0 && keys.every((key) => key.startsWith('$'))
20
+ }
21
+
22
+ function walkFilter(node: unknown, field: string | null, out: Set<string>): void {
23
+ if (Array.isArray(node)) {
24
+ for (const item of node) walkFilter(item, field, out)
25
+ return
26
+ }
27
+ if (!isPlainObject(node)) return
28
+ for (const [key, value] of Object.entries(node)) {
29
+ if (LIKE_OPERATORS.has(key)) {
30
+ if (field) out.add(field)
31
+ continue
32
+ }
33
+ if (LOGICAL_OPERATORS.has(key)) {
34
+ walkFilter(value, field, out)
35
+ continue
36
+ }
37
+ if (key.startsWith('$')) continue
38
+ if (isOperatorObject(value)) walkFilter(value, key, out)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Lists the root-entity properties a filter targets with a string-matching
44
+ * operator (`$like`, `$ilike`, `$re`, `$fulltext`), including inside
45
+ * `$and`/`$or`/`$not` branches.
46
+ */
47
+ export function collectLikeFilterFields(where: unknown): string[] {
48
+ const found = new Set<string>()
49
+ walkFilter(where, null, found)
50
+ return Array.from(found)
51
+ }
52
+
53
+ // The map lookup behind the warning issues an uncached read against
54
+ // `encryption_maps`, and this path runs on every matching query rather than in a
55
+ // rare fallback branch — so the check stays out of production entirely.
56
+ const isWarningEnabled = (): boolean => process.env.NODE_ENV !== 'production'
57
+
58
+ function resolveEntityId(em: EntityManager, entityName: EntityName<any>): string | null {
59
+ const metadata = (em as any)?.getMetadata?.()
60
+ if (!metadata || typeof metadata.find !== 'function') return null
61
+ const name = typeof entityName === 'string' ? entityName : (entityName as any)?.name
62
+ if (!name) return null
63
+ return resolveEntityIdFromMetadata(metadata.find(name) ?? undefined)
64
+ }
65
+
66
+ /**
67
+ * Warn, outside production, when a `where` clause matches an encrypted-at-rest
68
+ * column with a string operator.
69
+ *
70
+ * Decryption happens on load, not on filter, so the predicate compares a
71
+ * plaintext pattern against ciphertext: it matches nothing and does not raise,
72
+ * which is indistinguishable from a genuine empty result. The query engines
73
+ * already warn about their own `ILIKE` fallback; this covers the raw-ORM path
74
+ * that never reaches them. Issue #5051, related to #2990.
75
+ *
76
+ * Never throws and never blocks the query.
77
+ */
78
+ export async function warnOnEncryptedLikeFilter(params: {
79
+ em: EntityManager
80
+ entityName: EntityName<any>
81
+ where: unknown
82
+ tenantId?: string | null
83
+ encryptionService?: TenantDataEncryptionService | null
84
+ }): Promise<void> {
85
+ if (!isWarningEnabled()) return
86
+ const fields = collectLikeFilterFields(params.where)
87
+ if (!fields.length) return
88
+ try {
89
+ const entity = resolveEntityId(params.em, params.entityName)
90
+ if (!entity) return
91
+ const service = params.encryptionService ?? resolveTenantEncryptionService(params.em)
92
+ await warnOnCiphertextLikeFallback({
93
+ entity,
94
+ fields,
95
+ tenantId: params.tenantId ?? null,
96
+ reason: 'raw-orm-filter',
97
+ service,
98
+ })
99
+ } catch {
100
+ // A diagnostic must never break the query it is diagnosing.
101
+ }
102
+ }
@@ -41,6 +41,29 @@ describe('i18n dictionary memoization', () => {
41
41
  expect(loader).toHaveBeenCalledTimes(2)
42
42
  })
43
43
 
44
+ it('keeps other locale dictionaries cached after locale-scoped module registration', async () => {
45
+ const loader = jest.fn(async (locale: Locale) => ({ greeting: locale }))
46
+ registerAppDictionaryLoader(loader)
47
+ registerModules([
48
+ { id: 'demo', translations: { en: { module: 'English' }, pl: { module: 'Polski' } } },
49
+ ] as any)
50
+
51
+ const englishBefore = await loadDictionary('en')
52
+ const polishBefore = await loadDictionary('pl')
53
+
54
+ registerModules([
55
+ { id: 'demo', translations: { pl: { module: 'Polski 2' } } },
56
+ ] as any)
57
+
58
+ const englishAfter = await loadDictionary('en')
59
+ const polishAfter = await loadDictionary('pl')
60
+
61
+ expect(englishAfter).toBe(englishBefore)
62
+ expect(polishAfter).not.toBe(polishBefore)
63
+ expect(polishAfter.module).toBe('Polski 2')
64
+ expect(loader).toHaveBeenCalledTimes(3)
65
+ })
66
+
44
67
  it('rebuilds after modules are re-registered', async () => {
45
68
  registerAppDictionaryLoader(async () => ({}))
46
69
  registerModules([{ translations: { en: { a: 'one' } } }] as any)
@@ -33,3 +33,8 @@ export function setCachedDictionary(locale: Locale, dict: Dict): void {
33
33
  export function invalidateDictionaryCache(): void {
34
34
  getCache().clear()
35
35
  }
36
+
37
+ export function invalidateDictionaryCacheLocales(locales: readonly Locale[]): void {
38
+ const cache = getCache()
39
+ for (const locale of locales) cache.delete(locale)
40
+ }
@@ -145,4 +145,30 @@ describe('shared modules registry', () => {
145
145
  }),
146
146
  ])
147
147
  })
148
+
149
+ it('preserves translations when runtime modules register after locale shards', () => {
150
+ const registry = loadRegistry()
151
+ const handler = jest.fn()
152
+ registry.registerModules([
153
+ {
154
+ id: 'checkout',
155
+ translations: { pl: { title: 'Kasa' } },
156
+ } as Module,
157
+ ])
158
+
159
+ registry.registerModules([
160
+ {
161
+ id: 'checkout',
162
+ subscribers: [{ id: 'checkout-gateway-payment-failed', event: 'payment_gateways.payment.failed', handler }],
163
+ } as Module,
164
+ ])
165
+
166
+ expect(registry.getModules()).toEqual([
167
+ expect.objectContaining({
168
+ id: 'checkout',
169
+ subscribers: [expect.objectContaining({ id: 'checkout-gateway-payment-failed' })],
170
+ translations: { pl: { title: 'Kasa' } },
171
+ }),
172
+ ])
173
+ })
148
174
  })
@@ -1,6 +1,7 @@
1
1
  import type { Module } from '@open-mercato/shared/modules/registry'
2
2
  import { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'
3
- import { invalidateDictionaryCache } from '../i18n/dictionary-cache'
3
+ import type { Locale } from '../i18n/config'
4
+ import { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from '../i18n/dictionary-cache'
4
5
  import { createLogger } from '../logger'
5
6
 
6
7
  const logger = createLogger('shared').child({ component: 'modules-registry' })
@@ -38,7 +39,10 @@ function mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {
38
39
  if (!i18nModule?.translations) return entry
39
40
  return {
40
41
  ...entry,
41
- translations: i18nModule.translations,
42
+ translations: {
43
+ ...(entry.translations ?? {}),
44
+ ...i18nModule.translations,
45
+ },
42
46
  }
43
47
  })
44
48
 
@@ -49,17 +53,42 @@ function mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {
49
53
  return merged
50
54
  }
51
55
 
56
+ function getTranslationLocales(modules: Module[]): Locale[] {
57
+ const locales = new Set<Locale>()
58
+ for (const entry of modules) {
59
+ for (const locale of Object.keys(entry.translations ?? {})) {
60
+ locales.add(locale as Locale)
61
+ }
62
+ }
63
+ return [...locales]
64
+ }
65
+
66
+ function preserveExistingTranslations(existing: Module[], incoming: Module[]): Module[] {
67
+ const existingById = new Map(existing.map((entry) => [entry.id, entry]))
68
+ return incoming.map((entry) => {
69
+ if (entry.translations) return entry
70
+ const translations = existingById.get(entry.id)?.translations
71
+ return translations ? { ...entry, translations } : entry
72
+ })
73
+ }
74
+
52
75
  export function registerModules(modules: Module[]) {
53
76
  const existing = getGlobalModules()
54
77
  if (existing !== null && process.env.NODE_ENV === 'development') {
55
78
  logger.debug('Modules re-registered (this may occur during HMR)')
56
79
  }
57
80
  const nextModules = applyModuleOverridesToModules(modules)
58
- const shouldMergeI18nOnly = existing !== null
59
- && existing.some(hasRuntimeContracts)
60
- && isI18nOnlyRegistration(nextModules)
61
- setGlobalModules(shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : nextModules)
62
- invalidateDictionaryCache()
81
+ const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules)
82
+ const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration
83
+ const registeredModules = shouldMergeI18nOnly
84
+ ? mergeI18nModules(existing, nextModules)
85
+ : preserveExistingTranslations(existing ?? [], nextModules)
86
+ setGlobalModules(registeredModules)
87
+ if (i18nOnlyRegistration) {
88
+ invalidateDictionaryCacheLocales(getTranslationLocales(nextModules))
89
+ } else {
90
+ invalidateDictionaryCache()
91
+ }
63
92
  }
64
93
 
65
94
  export function getModules(): Module[] {
@@ -16,7 +16,22 @@ export type CiphertextWarningEncryptionService = {
16
16
  isEnabled?: () => boolean
17
17
  } | null | undefined
18
18
 
19
- export type CiphertextLikeFallbackReason = 'no-indexable-tokens' | 'no-search-tokens' | 'search-disabled'
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
+ }
20
35
 
21
36
  // One warning per entity/tenant/field per process — the fallback repeats on
22
37
  // every request, and an operator only needs to learn about it once.
@@ -79,11 +94,7 @@ export async function warnOnCiphertextLikeFallback(params: {
79
94
  entity,
80
95
  field,
81
96
  reason,
82
- hint: reason === 'search-disabled'
83
- ? 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.'
84
- : reason === 'no-indexable-tokens'
85
- ? '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.'
86
- : '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.',
97
+ hint: REASON_HINTS[reason],
87
98
  })
88
99
  }
89
100
  } catch (err) {