@open-mercato/core 0.6.8-develop.7004.1.7e7dd67d56 → 0.6.8-develop.7008.1.1ab31c0ca9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.7004.1.7e7dd67d56",
3
+ "version": "0.6.8-develop.7008.1.1ab31c0ca9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.7004.1.7e7dd67d56",
258
- "@open-mercato/shared": "0.6.8-develop.7004.1.7e7dd67d56",
259
- "@open-mercato/ui": "0.6.8-develop.7004.1.7e7dd67d56",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.7008.1.1ab31c0ca9",
258
+ "@open-mercato/shared": "0.6.8-develop.7008.1.1ab31c0ca9",
259
+ "@open-mercato/ui": "0.6.8-develop.7008.1.1ab31c0ca9",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.7004.1.7e7dd67d56",
265
- "@open-mercato/shared": "0.6.8-develop.7004.1.7e7dd67d56",
266
- "@open-mercato/ui": "0.6.8-develop.7004.1.7e7dd67d56",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.7008.1.1ab31c0ca9",
265
+ "@open-mercato/shared": "0.6.8-develop.7008.1.1ab31c0ca9",
266
+ "@open-mercato/ui": "0.6.8-develop.7008.1.1ab31c0ca9",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.1",
@@ -6,6 +6,7 @@ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
6
6
  import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { cn, slugifyTagLabel } from '@open-mercato/shared/lib/utils'
9
+ import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
9
10
  import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
11
12
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
@@ -388,8 +389,19 @@ export function EntityTagsDialog({
388
389
  const [creatingKind, setCreatingKind] = React.useState<string | null>(null)
389
390
  const [manageTagsOpen, setManageTagsOpen] = React.useState(false)
390
391
  const [activeCategoryPage, setActiveCategoryPage] = React.useState(1)
391
- const [activeCategoryTotalPages, setActiveCategoryTotalPages] = React.useState(1)
392
+ // Short-page termination instead of `page < totalPages` — see
393
+ // `hasMoreFromPage`. Like the page number it belongs to whichever category is
394
+ // active: the fetch effect below rewrites it on every category, search and
395
+ // page change, and the reset effect clears it alongside the page so a
396
+ // category with more entries never leaves the affordance behind on one
397
+ // without.
398
+ const [activeCategoryHasMore, setActiveCategoryHasMore] = React.useState(false)
392
399
  const [activeCategoryLoading, setActiveCategoryLoading] = React.useState(false)
400
+ // A transport failure is not an end-of-list signal: it leaves the affordance
401
+ // in place and turns the next click into a retry of the page that failed,
402
+ // rather than advancing past it.
403
+ const [activeCategoryLoadFailed, setActiveCategoryLoadFailed] = React.useState(false)
404
+ const [activeCategoryReloadToken, setActiveCategoryReloadToken] = React.useState(0)
393
405
  const creationInFlightRef = React.useRef<string | null>(null)
394
406
  const mutationContextId = React.useMemo(
395
407
  () => `customer-tags:${entityType}:${entityId}`,
@@ -713,12 +725,15 @@ export function EntityTagsDialog({
713
725
  React.useEffect(() => {
714
726
  if (!open) return
715
727
  setActiveCategoryPage(1)
728
+ setActiveCategoryHasMore(false)
729
+ setActiveCategoryLoadFailed(false)
716
730
  }, [activeCategoryKind, open, searchValue])
717
731
 
718
732
  React.useEffect(() => {
719
733
  if (!open || !activeCategoryKindValue || (activeCategorySource !== 'tags' && activeCategorySource !== 'labels')) {
720
734
  setActiveCategoryLoading(false)
721
- setActiveCategoryTotalPages(1)
735
+ setActiveCategoryHasMore(false)
736
+ setActiveCategoryLoadFailed(false)
722
737
  return
723
738
  }
724
739
 
@@ -752,16 +767,36 @@ export function EntityTagsDialog({
752
767
  }))
753
768
 
754
769
  setActiveCategoryLoading(true)
755
- void apiCall<{ items?: Array<DictEntry | LabelItem>; totalPages?: number }>(endpoint, {
770
+ void apiCall<{ items?: Array<DictEntry | LabelItem>; page?: number }>(endpoint, {
756
771
  cache: 'no-store',
757
772
  headers: { 'x-om-unauthorized-redirect': '0' },
758
773
  })
759
774
  .then((response) => {
760
- if (!response.ok || cancelled) return
761
- const fetchedEntries = mapEntries(Array.isArray(response.result?.items) ? response.result.items : [])
762
- setActiveCategoryTotalPages(
763
- typeof response.result?.totalPages === 'number' ? response.result.totalPages : 1,
775
+ if (cancelled) return
776
+ if (!response.ok) {
777
+ setActiveCategoryLoadFailed(true)
778
+ return
779
+ }
780
+ setActiveCategoryLoadFailed(false)
781
+ const servedEntries = Array.isArray(response.result?.items) ? response.result.items : []
782
+ const fetchedEntries = mapEntries(servedEntries)
783
+ // The two sources paginate differently. `/api/customers/tags` is a
784
+ // query-engine list, so a page past the end comes back empty and the
785
+ // served count alone terminates the sequence. `/api/customers/labels`
786
+ // clamps the requested page to the last one and re-serves it in full
787
+ // forever, so short-page termination needs the second half of the
788
+ // helper's obligation 2: take an echoed page below the one asked for as
789
+ // the end of the list. Same guard as `AttachmentsSection`, whose
790
+ // endpoint clamps the same way.
791
+ const returnedPage =
792
+ typeof response.result?.page === 'number' ? response.result.page : activeCategoryPage
793
+ const servedRequestedPage = returnedPage >= activeCategoryPage
794
+ // Measured on what the endpoint served, before `mergeOptions` folds the
795
+ // page into the entries already on screen.
796
+ setActiveCategoryHasMore(
797
+ servedRequestedPage && hasMoreFromPage(servedEntries.length, REMOTE_CATEGORY_PAGE_SIZE),
764
798
  )
799
+ if (!servedRequestedPage) return
765
800
  updateCategoryEntries(activeCategoryKindValue, (currentEntries) =>
766
801
  activeCategoryPage <= 1
767
802
  ? mergeOptions(seedEntries, fetchedEntries)
@@ -770,8 +805,10 @@ export function EntityTagsDialog({
770
805
  })
771
806
  .catch(() => {
772
807
  if (cancelled) return
773
- setActiveCategoryTotalPages(1)
774
- updateCategoryEntries(activeCategoryKindValue, () => seedEntries)
808
+ setActiveCategoryLoadFailed(true)
809
+ if (activeCategoryPage <= 1) {
810
+ updateCategoryEntries(activeCategoryKindValue, () => seedEntries)
811
+ }
775
812
  })
776
813
  .finally(() => {
777
814
  if (!cancelled) {
@@ -785,6 +822,7 @@ export function EntityTagsDialog({
785
822
  }, [
786
823
  activeCategoryKindValue,
787
824
  activeCategoryPage,
825
+ activeCategoryReloadToken,
788
826
  activeCategorySource,
789
827
  entityId,
790
828
  entityOrganizationId,
@@ -1250,15 +1288,23 @@ export function EntityTagsDialog({
1250
1288
  {t('customers.personTags.loading', 'Loading...')}
1251
1289
  </div>
1252
1290
  ) : null}
1253
- {(activeCategory.source === 'tags' || activeCategory.source === 'labels') && activeCategoryPage < activeCategoryTotalPages ? (
1291
+ {(activeCategory.source === 'tags' || activeCategory.source === 'labels') && activeCategoryHasMore ? (
1254
1292
  <Button
1255
1293
  type="button"
1256
1294
  variant="outline"
1257
1295
  size="sm"
1258
1296
  className="rounded-lg px-3 text-xs"
1259
- onClick={() => setActiveCategoryPage((current) => current + 1)}
1297
+ onClick={() => {
1298
+ if (activeCategoryLoadFailed) {
1299
+ setActiveCategoryReloadToken((current) => current + 1)
1300
+ return
1301
+ }
1302
+ setActiveCategoryPage((current) => current + 1)
1303
+ }}
1260
1304
  >
1261
- {t('customers.activities.loadMore', 'Load more')}
1305
+ {activeCategoryLoadFailed
1306
+ ? t('customers.personTags.retry', 'Retry')
1307
+ : t('customers.activities.loadMore', 'Load more')}
1262
1308
  </Button>
1263
1309
  ) : null}
1264
1310
  </div>
@@ -7,6 +7,7 @@ import { resolveTodoApiPath } from '../utils'
7
7
  import type { TodoLinkSummary } from '../types'
8
8
  import { generateTempId } from '@open-mercato/core/modules/customers/lib/detailHelpers'
9
9
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
10
+ import { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'
10
11
  import { CUSTOMER_INTERACTION_TASK_SOURCE } from '../../../lib/interactionCompatibility'
11
12
 
12
13
  const DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE
@@ -29,12 +30,15 @@ type CustomerTodoRow = {
29
30
  createdAt: string
30
31
  }
31
32
 
33
+ // `/api/customers/todos` also reports `totalPages`, deliberately left out of
34
+ // this type: the load-more affordance terminates on a short page instead — see
35
+ // `hasMoreFromPage`. `total` stays because it feeds the section's count badge,
36
+ // where an under-report is cosmetic rather than a way to strand rows.
32
37
  type CustomerTodosResponse = {
33
38
  items: CustomerTodoRow[]
34
39
  total: number
35
40
  page: number
36
41
  pageSize: number
37
- totalPages: number
38
42
  }
39
43
 
40
44
  export type TaskFormPayload = {
@@ -172,9 +176,9 @@ export function usePersonTasks({
172
176
  pageSize = 20,
173
177
  }: UsePersonTasksOptions): UsePersonTasksResult {
174
178
  const [tasks, setTasks] = React.useState<TodoLinkSummary[]>(initialTasks)
175
- const [pageInfo, setPageInfo] = React.useState<{ page: number; totalPages: number; total: number }>({
179
+ const [pageInfo, setPageInfo] = React.useState<{ page: number; hasMore: boolean; total: number }>({
176
180
  page: 1,
177
- totalPages: 1,
181
+ hasMore: false,
178
182
  total: initialTasks.length,
179
183
  })
180
184
  const [isInitialLoading, setIsInitialLoading] = React.useState<boolean>(() => Boolean(entityId))
@@ -187,12 +191,18 @@ export function usePersonTasks({
187
191
  const mapped = Array.isArray(payload.items) ? payload.items.map(mapRowToSummary) : []
188
192
  setPageInfo({
189
193
  page: payload.page ?? 1,
190
- totalPages: payload.totalPages ?? 0,
194
+ // Short-page termination instead of `page < totalPages` see
195
+ // `hasMoreFromPage`. `mapRowToSummary` is a 1:1 map and the `mergeUnique`
196
+ // dedupe happens after this, so `mapped.length` is what the server served.
197
+ // Measured against the page size the server echoed rather than the one
198
+ // requested, so a page size the endpoint narrows server-side cannot make a
199
+ // full page read as short and silently end the sequence.
200
+ hasMore: hasMoreFromPage(mapped.length, payload.pageSize ?? pageSize),
191
201
  total: payload.total ?? mapped.length,
192
202
  })
193
203
  setError(null)
194
204
  return mapped
195
- }, [])
205
+ }, [pageSize])
196
206
 
197
207
  const fetchPage = React.useCallback(
198
208
  async (page: number): Promise<CustomerTodosResponse> => {
@@ -202,7 +212,6 @@ export function usePersonTasks({
202
212
  total: 0,
203
213
  page: 1,
204
214
  pageSize,
205
- totalPages: 1,
206
215
  }
207
216
  }
208
217
  const params = new URLSearchParams({
@@ -222,7 +231,7 @@ export function usePersonTasks({
222
231
  const refresh = React.useCallback(async () => {
223
232
  if (!entityId) {
224
233
  setTasks([])
225
- setPageInfo({ page: 1, totalPages: 1, total: 0 })
234
+ setPageInfo({ page: 1, hasMore: false, total: 0 })
226
235
  return
227
236
  }
228
237
  setIsInitialLoading(true)
@@ -242,7 +251,7 @@ export function usePersonTasks({
242
251
  const loadMore = React.useCallback(async () => {
243
252
  if (!entityId) return
244
253
  if (isLoadingMore) return
245
- if (pageInfo.page >= pageInfo.totalPages) return
254
+ if (!pageInfo.hasMore) return
246
255
  setIsLoadingMore(true)
247
256
  try {
248
257
  const payload = await fetchPage(pageInfo.page + 1)
@@ -255,12 +264,12 @@ export function usePersonTasks({
255
264
  } finally {
256
265
  setIsLoadingMore(false)
257
266
  }
258
- }, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.page, pageInfo.totalPages])
267
+ }, [entityId, fetchPage, isLoadingMore, mapResponse, pageInfo.hasMore, pageInfo.page])
259
268
 
260
269
  React.useEffect(() => {
261
270
  if (!entityId) {
262
271
  setTasks([])
263
- setPageInfo({ page: 1, totalPages: 1, total: 0 })
272
+ setPageInfo({ page: 1, hasMore: false, total: 0 })
264
273
  setError(null)
265
274
  setIsInitialLoading(false)
266
275
  return
@@ -268,7 +277,7 @@ export function usePersonTasks({
268
277
  setTasks(initialTasks)
269
278
  setPageInfo({
270
279
  page: 1,
271
- totalPages: 1,
280
+ hasMore: false,
272
281
  total: initialTasks.length,
273
282
  })
274
283
  setError(null)
@@ -342,7 +351,7 @@ export function usePersonTasks({
342
351
  setTasks((prev) => [newTask, ...prev])
343
352
  setPageInfo((prev) => ({
344
353
  page: 1,
345
- totalPages: prev.totalPages,
354
+ hasMore: prev.hasMore,
346
355
  total: prev.total + 1,
347
356
  }))
348
357
  await refresh()
@@ -466,7 +475,7 @@ export function usePersonTasks({
466
475
  setTasks((prev) => prev.filter((item) => item.id !== task.id))
467
476
  setPageInfo((prev) => ({
468
477
  page: prev.page,
469
- totalPages: prev.totalPages,
478
+ hasMore: prev.hasMore,
470
479
  total: Math.max(0, prev.total - 1),
471
480
  }))
472
481
  } finally {
@@ -476,7 +485,7 @@ export function usePersonTasks({
476
485
  [],
477
486
  )
478
487
 
479
- const hasMore = entityId != null && pageInfo.page < pageInfo.totalPages
488
+ const hasMore = entityId != null && pageInfo.hasMore
480
489
 
481
490
  return {
482
491
  tasks,
@@ -2409,6 +2409,7 @@
2409
2409
  "customers.personTags.newLabel": "New label",
2410
2410
  "customers.personTags.newLabelPlaceholder": "Label name...",
2411
2411
  "customers.personTags.newTag": "Neuer Tag",
2412
+ "customers.personTags.retry": "Erneut versuchen",
2412
2413
  "customers.personTags.save": "Save",
2413
2414
  "customers.personTags.saveError": "Failed to save tags",
2414
2415
  "customers.personTags.saveSuccess": "Tags updated.",
@@ -2409,6 +2409,7 @@
2409
2409
  "customers.personTags.newLabel": "New label",
2410
2410
  "customers.personTags.newLabelPlaceholder": "Label name...",
2411
2411
  "customers.personTags.newTag": "New tag",
2412
+ "customers.personTags.retry": "Retry",
2412
2413
  "customers.personTags.save": "Save",
2413
2414
  "customers.personTags.saveError": "Failed to save tags",
2414
2415
  "customers.personTags.saveSuccess": "Tags updated.",
@@ -2409,6 +2409,7 @@
2409
2409
  "customers.personTags.newLabel": "New label",
2410
2410
  "customers.personTags.newLabelPlaceholder": "Label name...",
2411
2411
  "customers.personTags.newTag": "Nueva etiqueta",
2412
+ "customers.personTags.retry": "Reintentar",
2412
2413
  "customers.personTags.save": "Save",
2413
2414
  "customers.personTags.saveError": "Failed to save tags",
2414
2415
  "customers.personTags.saveSuccess": "Tags updated.",
@@ -2409,6 +2409,7 @@
2409
2409
  "customers.personTags.newLabel": "새 라벨",
2410
2410
  "customers.personTags.newLabelPlaceholder": "라벨 이름...",
2411
2411
  "customers.personTags.newTag": "새 태그",
2412
+ "customers.personTags.retry": "다시 시도",
2412
2413
  "customers.personTags.save": "저장",
2413
2414
  "customers.personTags.saveError": "태그 저장에 실패했습니다",
2414
2415
  "customers.personTags.saveSuccess": "태그가 업데이트되었습니다.",
@@ -2409,6 +2409,7 @@
2409
2409
  "customers.personTags.newLabel": "Nowa etykieta",
2410
2410
  "customers.personTags.newLabelPlaceholder": "Nazwa etykiety...",
2411
2411
  "customers.personTags.newTag": "Nowy tag",
2412
+ "customers.personTags.retry": "Ponów próbę",
2412
2413
  "customers.personTags.save": "Zapisz",
2413
2414
  "customers.personTags.saveError": "Nie udało się zapisać tagów",
2414
2415
  "customers.personTags.saveSuccess": "Tagi zostały zaktualizowane.",
@@ -20,6 +20,8 @@ import {
20
20
  type ResolvedJoin,
21
21
  } from '@open-mercato/shared/lib/query/join-utils'
22
22
  import { resolveSearchConfig, type SearchConfig } from '@open-mercato/shared/lib/search/config'
23
+ import { isEncryptedLikeField, resolveEncryptedLikeFieldSet } from '@open-mercato/shared/lib/query/engine'
24
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
23
25
  import {
24
26
  createSearchTokenAvailability,
25
27
  isSearchFilterOp,
@@ -148,13 +150,25 @@ type SearchRuntime = {
148
150
  organizationScope?: { ids: string[]; includeNull: boolean } | null
149
151
  tenantId?: string | null
150
152
  searchSources?: SearchTokenSource[]
153
+ /**
154
+ * Base-column fields whose stored value is ciphertext, so a like/ilike on them can only be
155
+ * answered via search tokens. A plaintext column keeps exact SQL ILIKE instead: the token
156
+ * rewrite is approximate -- it splits on non-alphanumerics and drops tokens shorter than
157
+ * minTokenLength, so a document-number search like "ZK 1/2026" degrades to the tokens
158
+ * {202, 2026} and matches every record from that year, and an all-short term like "ZK"
159
+ * produces no tokens and silently drops the predicate. `null`/absent = the encryption
160
+ * service could not answer (or a caller predates this field); keep the old rewrite then,
161
+ * because guessing "plaintext" would turn encrypted-column search into an
162
+ * ILIKE-on-ciphertext that matches nothing.
163
+ */
164
+ encryptedFields?: Set<string> | null
151
165
  /** Per-`query()` alias minter for `search_tokens` subqueries (see #2738). */
152
166
  mintAlias: () => string
153
167
  }
154
168
 
155
169
  type EncryptionResolver = () => {
156
170
  decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
157
- getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
171
+ getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null, options?: { ignoreRuntimeHealth?: boolean }) => Promise<readonly string[]>
158
172
  isEnabled?: () => boolean
159
173
  } | null
160
174
 
@@ -496,6 +510,51 @@ export class HybridQueryEngine implements QueryEngine {
496
510
  ? await this.searchAvailability().anySourceHasTokens(searchSources, opts.tenantId ?? null, orgScope)
497
511
  : false
498
512
  const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens }
513
+ if (
514
+ searchRuntime.enabled &&
515
+ searchConfig.useIlikeForNonEncryptedFields === true &&
516
+ sourceSearchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
517
+ ) {
518
+ // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
519
+ // the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
520
+ // `organizationId: null` is deliberate, not an omission: the service then unions in every
521
+ // organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays
522
+ // on the token path -- a wider set fails safe. Passing the request's org instead would
523
+ // silently break encrypted-column search for orgs without their own map. That union is an
524
+ // UNCACHED `encryption_maps` read, one extra round-trip per searched list request.
525
+ try {
526
+ const encryptionService = this.getEncryptionService()
527
+ const readEncryptedFieldNames = encryptionService?.getEncryptedFieldNames?.bind(encryptionService)
528
+ if (readEncryptedFieldNames) {
529
+ searchRuntime.encryptedFields = await resolveEncryptedLikeFieldSet(
530
+ () => readEncryptedFieldNames(
531
+ entity as EntityId,
532
+ opts.tenantId ?? null,
533
+ null,
534
+ { ignoreRuntimeHealth: true },
535
+ ),
536
+ String(entity),
537
+ opts.tenantId ?? null,
538
+ )
539
+ } else if (isTenantDataEncryptionEnabled()) {
540
+ // Encryption is on but the service is unreachable (a swallowed DI failure looks
541
+ // exactly like "no service"): treat the map as UNKNOWN and keep the token rewrite,
542
+ // rather than guessing "plaintext" and running ILIKE against ciphertext.
543
+ searchRuntime.encryptedFields = null
544
+ } else {
545
+ // Encryption disabled: nothing is ciphertext at rest, exact ILIKE is always right.
546
+ searchRuntime.encryptedFields = new Set()
547
+ }
548
+ } catch (err) {
549
+ // The fallback is safe (the old rewrite-everything behavior), but taking it silently
550
+ // would hide that the gate has stopped working.
551
+ logger.warn('search: encrypted-field map unavailable; keeping the token rewrite for all columns', {
552
+ entity: String(entity),
553
+ error: err instanceof Error ? err.message : String(err),
554
+ })
555
+ searchRuntime.encryptedFields = null
556
+ }
557
+ }
499
558
  if (searchFilters.length) {
500
559
  this.logSearchDebug('search:init', {
501
560
  entity,
@@ -1743,11 +1802,13 @@ export class HybridQueryEngine implements QueryEngine {
1743
1802
  return this.buildIndexDocFilterExpression(eb, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime)
1744
1803
  }
1745
1804
  // For like/ilike with active search-tokens, route through hashed-token EXISTS subquery
1746
- // so encrypted-at-rest columns can still be searched.
1805
+ // so encrypted-at-rest columns can still be searched. Plaintext base columns keep exact
1806
+ // SQL ILIKE -- see SearchRuntime.encryptedFields.
1747
1807
  if (
1748
1808
  (filter.op === 'like' || filter.op === 'ilike') &&
1749
1809
  searchRuntime?.enabled &&
1750
- typeof filter.value === 'string'
1810
+ typeof filter.value === 'string' &&
1811
+ (searchRuntime.encryptedFields == null || isEncryptedLikeField(searchRuntime.encryptedFields, fieldName))
1751
1812
  ) {
1752
1813
  const tokens = tokenizeText(String(filter.value), searchRuntime.config)
1753
1814
  if (tokens.hashes.length) {
@@ -1771,10 +1832,14 @@ export class HybridQueryEngine implements QueryEngine {
1771
1832
  )
1772
1833
  }
1773
1834
  }
1774
- // Tokenizer produced no hashes (e.g. value too short). Match the regular-base-filter
1775
- // path's behavior of skipping the predicate (no filter), which is preferable to
1776
- // silently turning into a plain `ilike` against an encrypted column.
1777
- return sql<boolean>`true`
1835
+ // Tokenizer produced no hashes (e.g. value too short) or no source is usable. For a
1836
+ // column KNOWN to be encrypted, `false` is the honest answer for an OR leaf -- `true`
1837
+ // would widen the whole disjunction to match everything, on exactly the columns ILIKE
1838
+ // cannot serve. Every other case (gate off, custom-entity runtime, resolution failure)
1839
+ // keeps the legacy predicate-skipping `true`.
1840
+ return searchRuntime?.encryptedFields != null && isEncryptedLikeField(searchRuntime.encryptedFields, fieldName)
1841
+ ? sql<boolean>`false`
1842
+ : sql<boolean>`true`
1778
1843
  }
1779
1844
  return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
1780
1845
  }
@@ -1853,6 +1918,9 @@ export class HybridQueryEngine implements QueryEngine {
1853
1918
  const hasSearchTokens = searchEnabled && hasSearchFilter(normalizedFilters)
1854
1919
  ? await this.searchAvailability().hasTokens(entity, opts.tenantId ?? null, orgScope)
1855
1920
  : false
1921
+ // `encryptedFields` is deliberately NOT resolved here: custom-entity rows live in the
1922
+ // `entity_indexes` doc store, whose fields the base-column encryption map does not describe,
1923
+ // so the ILIKE gate stays inert on this path and like/ilike keeps its previous semantics.
1856
1924
  const searchRuntime: SearchRuntime = {
1857
1925
  enabled: searchEnabled && hasSearchTokens,
1858
1926
  config: searchConfig,
@@ -2384,7 +2452,11 @@ export class HybridQueryEngine implements QueryEngine {
2384
2452
  if (
2385
2453
  (filter.op === 'like' || filter.op === 'ilike') &&
2386
2454
  search?.enabled &&
2387
- typeof filter.value === 'string'
2455
+ typeof filter.value === 'string' &&
2456
+ // Plaintext base columns keep exact SQL ILIKE -- see SearchRuntime.encryptedFields.
2457
+ // Membership runs across name-shape candidates: maps may declare `displayName` while the
2458
+ // filter carries the column name `display_name`.
2459
+ (search.encryptedFields == null || isEncryptedLikeField(search.encryptedFields, search.field))
2388
2460
  ) {
2389
2461
  const tokens = tokenizeText(String(filter.value), search.config)
2390
2462
  const hashes = tokens.hashes
@@ -2413,10 +2485,23 @@ export class HybridQueryEngine implements QueryEngine {
2413
2485
  })
2414
2486
  return q
2415
2487
  }
2488
+ // Hashes exist but no usable search source: same reasoning as the no-hash branch below --
2489
+ // a KNOWN-encrypted column must fail closed rather than drop the predicate (which would
2490
+ // return the full list on exactly the columns ILIKE cannot serve).
2491
+ if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
2492
+ return q.where(sql<boolean>`false`)
2493
+ }
2416
2494
  } else {
2417
2495
  this.logSearchDebug('search:skip-empty-hashes', {
2418
2496
  entity: search.entity, field: search.field, value: filter.value,
2419
2497
  })
2498
+ // A column KNOWN to be encrypted has no way to match the term except the token index:
2499
+ // dropping the predicate would return every row for a term merely too short to tokenize.
2500
+ // Every other case (gate off, custom-entity runtime, resolution failure) keeps the
2501
+ // legacy behavior of skipping the predicate.
2502
+ if (search.encryptedFields != null && isEncryptedLikeField(search.encryptedFields, search.field)) {
2503
+ return q.where(sql<boolean>`false`)
2504
+ }
2420
2505
  }
2421
2506
  return q
2422
2507
  }