@open-mercato/shared 0.9.1-develop.7473.1.de5b22888f → 0.9.1-develop.7475.1.28ca93ed97

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 (36) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  4. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  5. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  6. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  7. package/dist/lib/catalog-visibility/index.js +4 -0
  8. package/dist/lib/catalog-visibility/index.js.map +2 -2
  9. package/dist/lib/localization/index.js +9 -1
  10. package/dist/lib/localization/index.js.map +2 -2
  11. package/dist/lib/localization/translatable-fields.js +26 -0
  12. package/dist/lib/localization/translatable-fields.js.map +2 -2
  13. package/dist/lib/query/engine.js +29 -2
  14. package/dist/lib/query/engine.js.map +2 -2
  15. package/dist/lib/query/join-utils.js +1 -1
  16. package/dist/lib/query/join-utils.js.map +2 -2
  17. package/dist/lib/query/overlap.js +58 -0
  18. package/dist/lib/query/overlap.js.map +7 -0
  19. package/dist/lib/query/types.js.map +2 -2
  20. package/dist/lib/version.js +1 -1
  21. package/dist/lib/version.js.map +1 -1
  22. package/dist/modules/search.js.map +2 -2
  23. package/package.json +2 -2
  24. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  25. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  26. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  27. package/src/lib/catalog-visibility/index.ts +2 -0
  28. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  29. package/src/lib/localization/index.ts +8 -1
  30. package/src/lib/localization/translatable-fields.ts +46 -0
  31. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  32. package/src/lib/query/engine.ts +33 -2
  33. package/src/lib/query/join-utils.ts +1 -1
  34. package/src/lib/query/overlap.ts +106 -0
  35. package/src/lib/query/types.ts +13 -1
  36. package/src/modules/search.ts +39 -0
@@ -23,3 +23,49 @@ export function getTranslatableFields(entityType: string): string[] | undefined
23
23
  export function getTranslatableFieldsRegistry(): TranslatableFieldsRegistry {
24
24
  return { ...getGlobal() }
25
25
  }
26
+
27
+ export type TranslatableFieldExpansion = {
28
+ key: string
29
+ label?: string
30
+ baseValue?: string
31
+ }
32
+
33
+ export type TranslatableFieldExpander = (record: Record<string, unknown>) => TranslatableFieldExpansion[]
34
+
35
+ type TranslatableFieldExpanderRegistry = Record<string, TranslatableFieldExpander>
36
+
37
+ const EXPANDER_GLOBAL_KEY = '__openMercatoTranslatableFieldExpanders__'
38
+
39
+ type ExpanderGlobalHost = { [EXPANDER_GLOBAL_KEY]?: TranslatableFieldExpanderRegistry }
40
+
41
+ function getExpanderGlobal(): TranslatableFieldExpanderRegistry {
42
+ return (globalThis as ExpanderGlobalHost)[EXPANDER_GLOBAL_KEY] ?? {}
43
+ }
44
+
45
+ export function registerTranslatableFieldExpander(entityType: string, expander: TranslatableFieldExpander): void {
46
+ const host = globalThis as ExpanderGlobalHost
47
+ host[EXPANDER_GLOBAL_KEY] = { ...getExpanderGlobal(), [entityType]: expander }
48
+ }
49
+
50
+ export function getTranslatableFieldExpander(entityType: string): TranslatableFieldExpander | undefined {
51
+ return getExpanderGlobal()[entityType]
52
+ }
53
+
54
+ type TranslatableEntityListPathRegistry = Record<string, string>
55
+
56
+ const LIST_PATH_GLOBAL_KEY = '__openMercatoTranslatableEntityListPaths__'
57
+
58
+ type ListPathGlobalHost = { [LIST_PATH_GLOBAL_KEY]?: TranslatableEntityListPathRegistry }
59
+
60
+ function getListPathGlobal(): TranslatableEntityListPathRegistry {
61
+ return (globalThis as ListPathGlobalHost)[LIST_PATH_GLOBAL_KEY] ?? {}
62
+ }
63
+
64
+ export function registerTranslatableEntityListPath(entityType: string, listPath: string): void {
65
+ const host = globalThis as ListPathGlobalHost
66
+ host[LIST_PATH_GLOBAL_KEY] = { ...getListPathGlobal(), [entityType]: listPath }
67
+ }
68
+
69
+ export function getTranslatableEntityListPath(entityType: string): string | undefined {
70
+ return getListPathGlobal()[entityType]
71
+ }
@@ -0,0 +1,219 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ DummyDriver,
4
+ Kysely,
5
+ PostgresAdapter,
6
+ PostgresIntrospector,
7
+ PostgresQueryCompiler,
8
+ sql,
9
+ type RawBuilder,
10
+ } from 'kysely'
11
+ import { BasicQueryEngine } from '../engine'
12
+ import { normalizeFilters } from '../join-utils'
13
+ import {
14
+ buildArrayColumnNoOverlapPredicate,
15
+ buildArrayColumnOverlapPredicate,
16
+ buildJsonbNoOverlapPredicate,
17
+ buildJsonbOverlapPredicate,
18
+ buildScalarOverlapPredicate,
19
+ jsonbDocKeyExpression,
20
+ normalizeOverlapValues,
21
+ } from '../overlap'
22
+ import type { FilterOp } from '../types'
23
+
24
+ function makeCompilingDb(): Kysely<any> {
25
+ return new Kysely<any>({
26
+ dialect: {
27
+ createAdapter: () => new PostgresAdapter(),
28
+ createDriver: () => new DummyDriver(),
29
+ createIntrospector: (db) => new PostgresIntrospector(db),
30
+ createQueryCompiler: () => new PostgresQueryCompiler(),
31
+ },
32
+ })
33
+ }
34
+
35
+ const db = makeCompilingDb()
36
+
37
+ function compileWhere(predicate: RawBuilder<boolean>): { sql: string; parameters: readonly unknown[] } {
38
+ const compiled = db.selectFrom('entity_indexes as ei').select('ei.id').where(predicate).compile()
39
+ return { sql: compiled.sql, parameters: compiled.parameters }
40
+ }
41
+
42
+ type EngineInternals = {
43
+ applyColumnOp: (builder: unknown, column: string, op: string, value: unknown) => { compile: () => { sql: string; parameters: readonly unknown[] } }
44
+ buildColumnOpExpression: (eb: unknown, column: string, op: string, value: unknown) => RawBuilder<boolean>
45
+ buildIndexDocOpExpression: (eb: unknown, opts: {
46
+ entity: string
47
+ field: string
48
+ op: FilterOp
49
+ value: unknown
50
+ recordIdColumn: string
51
+ tenantId?: string | null
52
+ withDeleted: boolean
53
+ }) => RawBuilder<boolean>
54
+ }
55
+
56
+ const engine = new BasicQueryEngine({} as EntityManager, () => db) as unknown as EngineInternals
57
+
58
+ describe('normalizeOverlapValues', () => {
59
+ it('flattens, stringifies, drops nullish and de-duplicates', () => {
60
+ expect(normalizeOverlapValues(['a', 'b', 'a', null, undefined, 3])).toEqual(['a', 'b', '3'])
61
+ expect(normalizeOverlapValues('a')).toEqual(['a'])
62
+ expect(normalizeOverlapValues(undefined)).toEqual([])
63
+ expect(normalizeOverlapValues(null)).toEqual([])
64
+ })
65
+ })
66
+
67
+ describe('overlap predicate builders', () => {
68
+ it('compiles a jsonb has-any-of with the values bound as one array parameter', () => {
69
+ const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
70
+ expect(compiled.sql).toContain(`("ei"."doc" -> 'scope_keys') ?| $1::text[]`)
71
+ expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
72
+ })
73
+
74
+ it('never interpolates values into the SQL text', () => {
75
+ const hostile = "x'); drop table entity_indexes; --"
76
+ const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [hostile]))
77
+ expect(compiled.sql).not.toContain('drop table')
78
+ expect(compiled.parameters).toEqual([[hostile]])
79
+ })
80
+
81
+ it('binds a key that is not a plain identifier instead of inlining it', () => {
82
+ const key = "scope'keys"
83
+ const expression = db.selectFrom('entity_indexes as ei').select(jsonbDocKeyExpression('ei.doc', key).as('value')).compile()
84
+ expect(expression.sql).toContain('("ei"."doc" -> $1)')
85
+ expect(expression.parameters).toEqual([key])
86
+ })
87
+
88
+ it.each([
89
+ ['jsonb', () => buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [])],
90
+ ['array column', () => buildArrayColumnOverlapPredicate('ei.tags', [])],
91
+ ['scalar', () => buildScalarOverlapPredicate(sql`ei.value`, [null, undefined])],
92
+ ])('compiles an empty %s value list to false (matches nothing)', (_label, build) => {
93
+ const compiled = compileWhere(build())
94
+ expect(compiled.sql).toMatch(/where false$/)
95
+ expect(compiled.parameters).toEqual([])
96
+ })
97
+
98
+ it('compiles array-column overlap to && with a bound array', () => {
99
+ const compiled = compileWhere(buildArrayColumnOverlapPredicate('ei.tags', ['a', 'b']))
100
+ expect(compiled.sql).toContain('"ei"."tags" && $1')
101
+ expect(compiled.parameters).toEqual([['a', 'b']])
102
+ })
103
+
104
+ it('compiles scalar overlap to an IN list of bound values', () => {
105
+ const compiled = compileWhere(buildScalarOverlapPredicate(sql`ei.value`, ['a', 'b']))
106
+ expect(compiled.sql).toContain('ei.value in ($1, $2)')
107
+ expect(compiled.parameters).toEqual(['a', 'b'])
108
+ })
109
+ })
110
+
111
+ describe('BasicQueryEngine overlap', () => {
112
+ it('normalizes $overlap from the object filter syntax', () => {
113
+ const filters = normalizeFilters({ scope_keys: { $overlap: ['cat:1'] } })
114
+ expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'overlap', value: ['cat:1'] })])
115
+ })
116
+
117
+ it('applies && on a base column', () => {
118
+ const builder = db.selectFrom('catalog_products as b').select('b.id')
119
+ const compiled = engine.applyColumnOp(builder, 'b.tags', 'overlap', ['x', 'y']).compile()
120
+ expect(compiled.sql).toContain('"b"."tags" && $1')
121
+ expect(compiled.parameters).toEqual([['x', 'y']])
122
+ })
123
+
124
+ it('compiles an index-document overlap inside the entity_indexes EXISTS', () => {
125
+ const compiled = db
126
+ .selectFrom('catalog_products as b')
127
+ .select('b.id')
128
+ .where((eb) => engine.buildIndexDocOpExpression(eb, {
129
+ entity: 'catalog:catalog_product',
130
+ field: 'scope_keys',
131
+ op: 'overlap',
132
+ value: ['cat:1'],
133
+ recordIdColumn: 'b.id',
134
+ tenantId: 'tenant-1',
135
+ withDeleted: false,
136
+ }))
137
+ .compile()
138
+ expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
139
+ expect(compiled.sql).toMatch(/\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]/)
140
+ expect(compiled.parameters).toContainEqual(['cat:1'])
141
+ })
142
+
143
+ it('leaves existing operators unchanged', () => {
144
+ const compiled = db
145
+ .selectFrom('catalog_products as b')
146
+ .select('b.id')
147
+ .where((eb) => eb.and([
148
+ engine.buildColumnOpExpression(eb, 'b.sku', 'in', ['a', 'b']),
149
+ engine.buildColumnOpExpression(eb, 'b.title', 'ilike', '%x%'),
150
+ engine.buildColumnOpExpression(eb, 'b.deleted_at', 'exists', false),
151
+ ]))
152
+ .compile()
153
+ expect(compiled.sql).toContain('"b"."sku" in ($1, $2)')
154
+ expect(compiled.sql).toContain('"b"."title" ilike $3')
155
+ expect(compiled.sql).toContain('"b"."deleted_at" is null')
156
+ expect(compiled.sql).not.toContain('&&')
157
+ expect(compiled.sql).not.toContain('?|')
158
+ })
159
+ })
160
+
161
+ describe('noverlap predicate builders', () => {
162
+ it('compiles a jsonb has-none-of that requires a present array', () => {
163
+ const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
164
+ expect(compiled.sql).toContain(
165
+ `(jsonb_typeof(("ei"."doc" -> 'scope_keys')) = 'array' and not (("ei"."doc" -> 'scope_keys') ?| $1::text[]))`,
166
+ )
167
+ expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
168
+ })
169
+
170
+ it('keeps only the array check for an empty jsonb value list', () => {
171
+ const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', []))
172
+ expect(compiled.sql).toMatch(/where jsonb_typeof\(\("ei"\."doc" -> 'scope_keys'\)\) = 'array'$/)
173
+ expect(compiled.parameters).toEqual([])
174
+ })
175
+
176
+ it('compiles array-column has-none-of to not && and an empty list to is not null', () => {
177
+ const compiled = compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', ['a']))
178
+ expect(compiled.sql).toContain('not ("ei"."tags" && $1)')
179
+ expect(compiled.parameters).toEqual([['a']])
180
+ expect(compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', [])).sql).toContain('"ei"."tags" is not null')
181
+ })
182
+ })
183
+
184
+ describe('BasicQueryEngine noverlap', () => {
185
+ it('normalizes $noverlap from the object filter syntax', () => {
186
+ const filters = normalizeFilters({ scope_keys: { $noverlap: ['cat:1'] } })
187
+ expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'noverlap', value: ['cat:1'] })])
188
+ })
189
+
190
+ it('compiles an index-document noverlap inside the entity_indexes EXISTS', () => {
191
+ const compiled = db
192
+ .selectFrom('catalog_products as b')
193
+ .select('b.id')
194
+ .where((eb) => engine.buildIndexDocOpExpression(eb, {
195
+ entity: 'catalog:catalog_product',
196
+ field: 'scope_keys',
197
+ op: 'noverlap',
198
+ value: ['cat:1'],
199
+ recordIdColumn: 'b.id',
200
+ tenantId: 'tenant-1',
201
+ withDeleted: false,
202
+ }))
203
+ .compile()
204
+ expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
205
+ expect(compiled.sql).toMatch(/not \(\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]\)/)
206
+ })
207
+
208
+ it('applies not && on a base column', () => {
209
+ const builder = db.selectFrom('catalog_products as b').select('b.id')
210
+ const compiled = engine.applyColumnOp(builder, 'b.tags', 'noverlap', ['x']).compile()
211
+ expect(compiled.sql).toContain('not ("b"."tags" && $1)')
212
+ const expression = db
213
+ .selectFrom('catalog_products as b')
214
+ .select('b.id')
215
+ .where((eb) => engine.buildColumnOpExpression(eb, 'b.tags', 'noverlap', ['x']))
216
+ .compile()
217
+ expect(expression.sql).toContain('not ("b"."tags" && $1)')
218
+ })
219
+ })
@@ -38,6 +38,13 @@ import { resolveListCountCap } from './count-cap'
38
38
  import { mapWithConcurrency } from './bounded-decrypt'
39
39
  import { parseNumberWithDefault } from '../number'
40
40
  import { createLogger } from '../logger'
41
+ import {
42
+ buildArrayColumnNoOverlapPredicate,
43
+ buildArrayColumnOverlapPredicate,
44
+ buildJsonbNoOverlapPredicate,
45
+ buildJsonbOverlapPredicate,
46
+ buildScalarOverlapPredicate,
47
+ } from './overlap'
41
48
 
42
49
  const logger = createLogger('shared').child({ component: 'query' })
43
50
 
@@ -1216,7 +1223,11 @@ export class BasicQueryEngine implements QueryEngine {
1216
1223
  })
1217
1224
  continue
1218
1225
  }
1219
- q = this.applyColumnOp(q, expr, f.op, f.value)
1226
+ q = f.op === 'overlap'
1227
+ ? q.where(buildScalarOverlapPredicate(expr, f.value))
1228
+ : f.op === 'noverlap'
1229
+ ? q.where(sql<boolean>`false`)
1230
+ : this.applyColumnOp(q, expr, f.op, f.value)
1220
1231
  }
1221
1232
 
1222
1233
  // OR groups are applied here, after the cf:* value expressions exist, so a
@@ -1263,7 +1274,10 @@ export class BasicQueryEngine implements QueryEngine {
1263
1274
  value: rf.value,
1264
1275
  })
1265
1276
  }
1266
- return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1277
+ if (rf.op === 'noverlap') return sql<boolean>`false`
1278
+ return rf.op === 'overlap'
1279
+ ? buildScalarOverlapPredicate(cfValueExprByKey[rf.key], rf.value)
1280
+ : this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1267
1281
  }
1268
1282
  return this.buildIndexDocOpExpression(eb, {
1269
1283
  entity: String(entity),
@@ -1601,6 +1615,10 @@ export class BasicQueryEngine implements QueryEngine {
1601
1615
  return value
1602
1616
  ? builder.where(column as any, 'is not', null)
1603
1617
  : builder.where(column as any, 'is', null)
1618
+ case 'overlap':
1619
+ return builder.where(buildArrayColumnOverlapPredicate(column, value))
1620
+ case 'noverlap':
1621
+ return builder.where(buildArrayColumnNoOverlapPredicate(column, value))
1604
1622
  default:
1605
1623
  return builder
1606
1624
  }
@@ -1734,6 +1752,11 @@ export class BasicQueryEngine implements QueryEngine {
1734
1752
  case 'exists':
1735
1753
  predicate = sql<boolean>`${caseExpr} is not null`
1736
1754
  break
1755
+ case 'overlap':
1756
+ predicate = buildScalarOverlapPredicate(caseExpr, value)
1757
+ break
1758
+ case 'noverlap':
1759
+ return sql<boolean>`false`
1737
1760
  default:
1738
1761
  // Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
1739
1762
  // predicate, so full and count shapes drop the same leaves.
@@ -1756,6 +1779,8 @@ export class BasicQueryEngine implements QueryEngine {
1756
1779
  case 'like': return eb(column, 'like', value)
1757
1780
  case 'ilike': return eb(column, 'ilike', value)
1758
1781
  case 'exists': return value ? eb(column, 'is not', null) : eb(column, 'is', null)
1782
+ case 'overlap': return buildArrayColumnOverlapPredicate(column, value)
1783
+ case 'noverlap': return buildArrayColumnNoOverlapPredicate(column, value)
1759
1784
  default: return eb.val(true)
1760
1785
  }
1761
1786
  }
@@ -1973,6 +1998,12 @@ export class BasicQueryEngine implements QueryEngine {
1973
1998
  ? sub.where(sql<boolean>`${textExpr} is not null`)
1974
1999
  : sub.where(sql<boolean>`${textExpr} is null`)
1975
2000
  break
2001
+ case 'overlap':
2002
+ sub = sub.where(buildJsonbOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
2003
+ break
2004
+ case 'noverlap':
2005
+ sub = sub.where(buildJsonbNoOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
2006
+ break
1976
2007
  default:
1977
2008
  break
1978
2009
  }
@@ -7,7 +7,7 @@ type AnyBuilder = any
7
7
 
8
8
  export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }
9
9
 
10
- const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const
10
+ const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists', 'overlap', 'noverlap'] as const
11
11
 
12
12
  const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)
13
13
 
@@ -0,0 +1,106 @@
1
+ import { sql, type RawBuilder } from 'kysely'
2
+
3
+ const LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/
4
+
5
+ /**
6
+ * Normalizes the right-hand side of an `overlap` filter into a de-duplicated list of strings.
7
+ *
8
+ * `null`/`undefined` entries are dropped; a scalar is treated as a one-element list. An empty
9
+ * result means the filter matches nothing — every `overlap` builder compiles it to `false`
10
+ * rather than dropping the predicate, so a caller that computes an empty inclusion set can
11
+ * never widen a query by accident.
12
+ */
13
+ export function normalizeOverlapValues(value: unknown): string[] {
14
+ const list: readonly unknown[] = Array.isArray(value)
15
+ ? value
16
+ : value === undefined || value === null
17
+ ? []
18
+ : [value]
19
+ const out: string[] = []
20
+ const seen = new Set<string>()
21
+ for (const entry of list) {
22
+ if (entry === undefined || entry === null) continue
23
+ const normalized = String(entry)
24
+ if (seen.has(normalized)) continue
25
+ seen.add(normalized)
26
+ out.push(normalized)
27
+ }
28
+ return out
29
+ }
30
+
31
+ /**
32
+ * `(<docColumn> -> '<key>')` — the jsonb sub-document of an index document.
33
+ *
34
+ * The key is emitted as an escaped SQL literal when it is a plain identifier so the expression is
35
+ * textually identical to a GIN expression index such as `((doc -> 'scope_keys'))` and the planner
36
+ * can match it. Any other key falls back to a bound parameter (still correct, just not indexable).
37
+ */
38
+ export function jsonbDocKeyExpression(docColumnRef: string, key: string): RawBuilder<unknown> {
39
+ const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`
40
+ return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`
41
+ }
42
+
43
+ /**
44
+ * jsonb "has any of" over a string-array key of an index document:
45
+ * `(<docColumn> -> '<key>') ?| $n::text[]`. The values are bound as one array parameter.
46
+ * A missing key or a JSON `null` evaluates to SQL NULL, i.e. the row does not match.
47
+ */
48
+ export function buildJsonbOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
49
+ const values = normalizeOverlapValues(value)
50
+ if (!values.length) return sql<boolean>`false`
51
+ return sql<boolean>`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`
52
+ }
53
+
54
+ /**
55
+ * jsonb "has none of" over a string-array key of an index document (`noverlap`):
56
+ * `(jsonb_typeof(<expr>) = 'array' and not (<expr> ?| $n::text[]))`.
57
+ *
58
+ * Only a document whose key holds a JSON array can match: a missing key, a JSON `null` or a
59
+ * scalar never satisfies it, so an unindexed document can never slip through an exclusion.
60
+ * An empty value list excludes nothing (only the array check remains).
61
+ */
62
+ export function buildJsonbNoOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
63
+ const values = normalizeOverlapValues(value)
64
+ const keyExpr = jsonbDocKeyExpression(docColumnRef, key)
65
+ const isArray = sql<boolean>`jsonb_typeof(${keyExpr}) = 'array'`
66
+ if (!values.length) return isArray
67
+ return sql<boolean>`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`
68
+ }
69
+
70
+ /**
71
+ * Postgres array overlap for array-typed columns: `<column> && $n`. The bound array takes its
72
+ * element type from the column, so `text[]`, `varchar[]` and `uuid[]` columns all work.
73
+ */
74
+ export function buildArrayColumnOverlapPredicate(
75
+ column: string | RawBuilder<unknown>,
76
+ value: unknown,
77
+ ): RawBuilder<boolean> {
78
+ const values = normalizeOverlapValues(value)
79
+ if (!values.length) return sql<boolean>`false`
80
+ const columnExpr = typeof column === 'string' ? sql.ref(column) : column
81
+ return sql<boolean>`${columnExpr} && ${values}`
82
+ }
83
+
84
+ /**
85
+ * Postgres array "has none of" for array-typed columns (`noverlap`): `not (<column> && $n)`.
86
+ * A NULL column never matches; an empty value list excludes nothing (`<column> is not null`).
87
+ */
88
+ export function buildArrayColumnNoOverlapPredicate(
89
+ column: string | RawBuilder<unknown>,
90
+ value: unknown,
91
+ ): RawBuilder<boolean> {
92
+ const values = normalizeOverlapValues(value)
93
+ const columnExpr = typeof column === 'string' ? sql.ref(column) : column
94
+ if (!values.length) return sql<boolean>`${columnExpr} is not null`
95
+ return sql<boolean>`not (${columnExpr} && ${values})`
96
+ }
97
+
98
+ /**
99
+ * `overlap` over a scalar expression evaluated once per stored value (custom-field value rows):
100
+ * the record matches when any of its values is one of the requested ones — `<expr> in (...)`.
101
+ */
102
+ export function buildScalarOverlapPredicate(expression: RawBuilder<unknown>, value: unknown): RawBuilder<boolean> {
103
+ const values = normalizeOverlapValues(value)
104
+ if (!values.length) return sql<boolean>`false`
105
+ return sql<boolean>`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`
106
+ }
@@ -2,7 +2,17 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
2
2
  import type { Profiler } from '../profiler'
3
3
  import type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'
4
4
 
5
- export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'
5
+ /**
6
+ * `overlap` (additive, 2026-10-05): "has any of" over a multi-valued field. An index-document
7
+ * string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base
8
+ * column to `&&`. An empty value list matches nothing.
9
+ *
10
+ * `noverlap` (additive, 2026-10-05): "has none of" — the negation of `overlap` on an index-document
11
+ * string array or an array-typed base column. Only a present array can match (a missing/null key or
12
+ * NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where
13
+ * it compiles to `false`.
14
+ */
15
+ export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'
6
16
 
7
17
  export enum SortDir {
8
18
  Asc = 'asc',
@@ -34,6 +44,8 @@ export type WhereOps<T> = {
34
44
  $like?: T extends string ? string : never
35
45
  $ilike?: T extends string ? string : never
36
46
  $exists?: boolean
47
+ $overlap?: T extends readonly (infer Element)[] ? Element[] : T[]
48
+ $noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]
37
49
  }
38
50
 
39
51
  // A field filter can be a direct value (equals) or ops object
@@ -94,6 +94,39 @@ export type SearchOptions = {
94
94
  offset?: number
95
95
  /** How to combine results: 'or' merges all, 'and' requires match in all strategies */
96
96
  combineMode?: 'or' | 'and'
97
+ /**
98
+ * Restricts results to records whose query-index row (`entity_indexes`) matches this predicate,
99
+ * evaluated inside the strategy's ranking query so a restrictive filter never starves the result
100
+ * set (additive, 2026-10-06). Only strategies with `supportsIndexDocFilter: true` receive a
101
+ * filtered search; any other strategy returns no results for it rather than unfiltered ones.
102
+ */
103
+ indexDocFilter?: SearchIndexDocFilter
104
+ }
105
+
106
+ /**
107
+ * One condition over a record's query-index document (`entity_indexes.doc`), with the query
108
+ * engine's index-document semantics:
109
+ * - `exists`: the key is present and not JSON `null`;
110
+ * - `eq`: the key's text value equals `value` (booleans and numbers compare by their JSON text);
111
+ * - `overlap`: the key holds an array sharing at least one entry with `values`; empty `values`
112
+ * matches nothing;
113
+ * - `noverlap`: the key holds an array sharing no entry with `values`; a missing key never matches;
114
+ * - `recordIdNotIn`: the record id is none of `values`.
115
+ */
116
+ export type SearchIndexDocCondition =
117
+ | { op: 'exists'; key: string }
118
+ | { op: 'eq'; key: string; value: string | number | boolean }
119
+ | { op: 'overlap'; key: string; values: string[] }
120
+ | { op: 'noverlap'; key: string; values: string[] }
121
+ | { op: 'recordIdNotIn'; values: string[] }
122
+
123
+ /**
124
+ * A predicate over a record's `entity_indexes` row in disjunctive normal form: the record matches
125
+ * when its index row is not deleted and every condition of at least one `anyOf` branch holds.
126
+ * An empty `anyOf` matches nothing; an empty branch matches every indexed record.
127
+ */
128
+ export type SearchIndexDocFilter = {
129
+ anyOf: SearchIndexDocCondition[][]
97
130
  }
98
131
 
99
132
  // =============================================================================
@@ -144,6 +177,12 @@ export interface SearchStrategy {
144
177
  /** Priority for result merging (higher = more prominent in results) */
145
178
  readonly priority: number
146
179
 
180
+ /**
181
+ * Whether `search()` applies `SearchOptions.indexDocFilter` inside its ranking query. Absent or
182
+ * `false` means the strategy is skipped for filtered searches (fail closed).
183
+ */
184
+ readonly supportsIndexDocFilter?: boolean
185
+
147
186
  /** Check if strategy is available and configured */
148
187
  isAvailable(): Promise<boolean>
149
188