@open-mercato/search 0.10.1-develop.7776.1.3032ea0741 → 0.10.1-develop.7781.1.079f42c863

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.
@@ -43,7 +43,10 @@ function buildIndexDocFilterPredicate(filter) {
43
43
  }
44
44
  function buildIndexDocFilterExists(filter, target) {
45
45
  const alias = (column) => sql.ref(`${INDEX_DOC_FILTER_ALIAS}.${column}`);
46
- return sql`exists (select 1 from entity_indexes as ${sql.id(INDEX_DOC_FILTER_ALIAS)} where ${alias("entity_type")} = ${sql.ref(target.entityTypeRef)} and ${alias("entity_id")} = (${sql.ref(target.recordIdRef)})::text and ${alias("tenant_id")} = ${sql.ref(target.tenantIdRef)} and ${alias("deleted_at")} is null and ${buildIndexDocFilterPredicate(filter)})`;
46
+ const globalTypes = target.tenantGlobalEntityTypes ?? [];
47
+ const sameTenant = sql`${alias("tenant_id")} is not distinct from ${sql.ref(target.tenantIdRef)}`;
48
+ const tenantScope = globalTypes.length ? sql`((${alias("entity_type")} in (${sql.join(globalTypes)}) and ${alias("tenant_id")} is null and ${alias("organization_id")} is null) or (${alias("entity_type")} not in (${sql.join(globalTypes)}) and ${sameTenant}))` : sameTenant;
49
+ return sql`exists (select 1 from entity_indexes as ${sql.id(INDEX_DOC_FILTER_ALIAS)} where ${alias("entity_type")} = ${sql.ref(target.entityTypeRef)} and ${alias("entity_id")} = (${sql.ref(target.recordIdRef)})::text and ${tenantScope} and ${alias("deleted_at")} is null and ${buildIndexDocFilterPredicate(filter)})`;
47
50
  }
48
51
  let compileOnlyDb = null;
49
52
  function compiler() {
@@ -60,7 +63,10 @@ function compiler() {
60
63
  return compileOnlyDb;
61
64
  }
62
65
  function compileIndexDocFilterExists(filter, target, firstParameterIndex) {
63
- const compiled = buildIndexDocFilterExists(filter, target).compile(compiler());
66
+ return compileSearchScopePredicate(buildIndexDocFilterExists(filter, target), firstParameterIndex);
67
+ }
68
+ function compileSearchScopePredicate(predicate, firstParameterIndex) {
69
+ const compiled = predicate.compile(compiler());
64
70
  const offset = firstParameterIndex - 1;
65
71
  return {
66
72
  sql: compiled.sql.replace(/\$(\d+)/g, (_match, index) => `$${Number(index) + offset}`),
@@ -71,6 +77,7 @@ export {
71
77
  INDEX_DOC_FILTER_ALIAS,
72
78
  buildIndexDocFilterExists,
73
79
  buildIndexDocFilterPredicate,
74
- compileIndexDocFilterExists
80
+ compileIndexDocFilterExists,
81
+ compileSearchScopePredicate
75
82
  };
76
83
  //# sourceMappingURL=index-doc-filter.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/index-doc-filter.ts"],
4
- "sourcesContent": ["import {\n DummyDriver,\n Kysely,\n PostgresAdapter,\n PostgresIntrospector,\n PostgresQueryCompiler,\n sql,\n type RawBuilder,\n} from 'kysely'\nimport type { SearchIndexDocCondition, SearchIndexDocFilter } from '@open-mercato/shared/modules/search'\nimport {\n buildJsonbNoOverlapPredicate,\n buildJsonbOverlapPredicate,\n jsonbDocKeyExpression,\n normalizeOverlapValues,\n} from '@open-mercato/shared/lib/query/overlap'\n\n/**\n * `SearchOptions.indexDocFilter` compiled to one correlated `exists` over `entity_indexes`, so a\n * strategy can AND it into the query that ranks \u2014 the predicate decides which rows are ranked,\n * never which ranked rows are kept, and a restrictive filter cannot starve the result set (except\n * under a pgvector `ivfflat` index scan, which applies it to the probed lists' candidates only).\n */\n\nexport const INDEX_DOC_FILTER_ALIAS = 'om_index_doc'\n\nexport type IndexDocFilterTarget = {\n /** Column of the ranked table holding the entity type (`module:entity`). */\n entityTypeRef: string\n /** Column of the ranked table holding the record id. */\n recordIdRef: string\n /** Column of the ranked table holding the tenant id. */\n tenantIdRef: string\n}\n\nconst DOC_REF = `${INDEX_DOC_FILTER_ALIAS}.doc`\n\nfunction conditionPredicate(condition: SearchIndexDocCondition): RawBuilder<boolean> {\n switch (condition.op) {\n case 'exists': {\n const keyExpr = jsonbDocKeyExpression(DOC_REF, condition.key)\n return sql<boolean>`(jsonb_typeof(${keyExpr}) is not null and jsonb_typeof(${keyExpr}) <> 'null')`\n }\n case 'eq':\n return sql<boolean>`(${jsonbDocKeyExpression(DOC_REF, condition.key)} #>> '{}') = ${String(condition.value)}`\n case 'overlap':\n return buildJsonbOverlapPredicate(DOC_REF, condition.key, condition.values)\n case 'noverlap':\n return buildJsonbNoOverlapPredicate(DOC_REF, condition.key, condition.values)\n case 'recordIdNotIn': {\n const values = normalizeOverlapValues(condition.values)\n if (!values.length) return sql<boolean>`true`\n return sql<boolean>`not (${sql.ref(`${INDEX_DOC_FILTER_ALIAS}.entity_id`)} = any(${values}::text[]))`\n }\n }\n}\n\nfunction branchPredicate(branch: SearchIndexDocCondition[]): RawBuilder<boolean> {\n if (!branch.length) return sql<boolean>`true`\n return sql<boolean>`(${sql.join(branch.map(conditionPredicate), sql` and `)})`\n}\n\n/** The filter's DNF over the `entity_indexes` row aliased `INDEX_DOC_FILTER_ALIAS`. */\nexport function buildIndexDocFilterPredicate(filter: SearchIndexDocFilter): RawBuilder<boolean> {\n if (!filter.anyOf.length) return sql<boolean>`false`\n return sql<boolean>`(${sql.join(filter.anyOf.map(branchPredicate), sql` or `)})`\n}\n\n/**\n * `exists (select 1 from entity_indexes \u2026 )` correlated to the ranked row by entity type, record id\n * and tenant, restricted to live index rows and to the filter's DNF.\n */\nexport function buildIndexDocFilterExists(filter: SearchIndexDocFilter, target: IndexDocFilterTarget): RawBuilder<boolean> {\n const alias = (column: string) => sql.ref(`${INDEX_DOC_FILTER_ALIAS}.${column}`)\n return sql<boolean>`exists (select 1 from entity_indexes as ${sql.id(INDEX_DOC_FILTER_ALIAS)} where ${alias('entity_type')} = ${sql.ref(target.entityTypeRef)} and ${alias('entity_id')} = (${sql.ref(target.recordIdRef)})::text and ${alias('tenant_id')} = ${sql.ref(target.tenantIdRef)} and ${alias('deleted_at')} is null and ${buildIndexDocFilterPredicate(filter)})`\n}\n\nlet compileOnlyDb: Kysely<Record<string, never>> | null = null\n\nfunction compiler(): Kysely<Record<string, never>> {\n if (!compileOnlyDb) {\n compileOnlyDb = new Kysely<Record<string, never>>({\n dialect: {\n createAdapter: () => new PostgresAdapter(),\n createDriver: () => new DummyDriver(),\n createIntrospector: (db) => new PostgresIntrospector(db),\n createQueryCompiler: () => new PostgresQueryCompiler(),\n },\n })\n }\n return compileOnlyDb\n}\n\n/**\n * The `exists` clause as Postgres text for drivers that issue raw SQL: placeholders start at\n * `$<firstParameterIndex>` so the caller appends `parameters` after its own.\n */\nexport function compileIndexDocFilterExists(\n filter: SearchIndexDocFilter,\n target: IndexDocFilterTarget,\n firstParameterIndex: number,\n): { sql: string; parameters: unknown[] } {\n const compiled = buildIndexDocFilterExists(filter, target).compile(compiler())\n const offset = firstParameterIndex - 1\n return {\n sql: compiled.sql.replace(/\\$(\\d+)/g, (_match, index: string) => `$${Number(index) + offset}`),\n parameters: [...compiled.parameters],\n }\n}\n"],
5
- "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASA,MAAM,yBAAyB;AAWtC,MAAM,UAAU,GAAG,sBAAsB;AAEzC,SAAS,mBAAmB,WAAyD;AACnF,UAAQ,UAAU,IAAI;AAAA,IACpB,KAAK,UAAU;AACb,YAAM,UAAU,sBAAsB,SAAS,UAAU,GAAG;AAC5D,aAAO,oBAA6B,OAAO,kCAAkC,OAAO;AAAA,IACtF;AAAA,IACA,KAAK;AACH,aAAO,OAAgB,sBAAsB,SAAS,UAAU,GAAG,CAAC,gBAAgB,OAAO,UAAU,KAAK,CAAC;AAAA,IAC7G,KAAK;AACH,aAAO,2BAA2B,SAAS,UAAU,KAAK,UAAU,MAAM;AAAA,IAC5E,KAAK;AACH,aAAO,6BAA6B,SAAS,UAAU,KAAK,UAAU,MAAM;AAAA,IAC9E,KAAK,iBAAiB;AACpB,YAAM,SAAS,uBAAuB,UAAU,MAAM;AACtD,UAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,aAAO,WAAoB,IAAI,IAAI,GAAG,sBAAsB,YAAY,CAAC,UAAU,MAAM;AAAA,IAC3F;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,QAAwD;AAC/E,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,OAAgB,IAAI,KAAK,OAAO,IAAI,kBAAkB,GAAG,UAAU,CAAC;AAC7E;AAGO,SAAS,6BAA6B,QAAmD;AAC9F,MAAI,CAAC,OAAO,MAAM,OAAQ,QAAO;AACjC,SAAO,OAAgB,IAAI,KAAK,OAAO,MAAM,IAAI,eAAe,GAAG,SAAS,CAAC;AAC/E;AAMO,SAAS,0BAA0B,QAA8B,QAAmD;AACzH,QAAM,QAAQ,CAAC,WAAmB,IAAI,IAAI,GAAG,sBAAsB,IAAI,MAAM,EAAE;AAC/E,SAAO,8CAAuD,IAAI,GAAG,sBAAsB,CAAC,UAAU,MAAM,aAAa,CAAC,MAAM,IAAI,IAAI,OAAO,aAAa,CAAC,QAAQ,MAAM,WAAW,CAAC,OAAO,IAAI,IAAI,OAAO,WAAW,CAAC,eAAe,MAAM,WAAW,CAAC,MAAM,IAAI,IAAI,OAAO,WAAW,CAAC,QAAQ,MAAM,YAAY,CAAC,gBAAgB,6BAA6B,MAAM,CAAC;AAC5W;AAEA,IAAI,gBAAsD;AAE1D,SAAS,WAA0C;AACjD,MAAI,CAAC,eAAe;AAClB,oBAAgB,IAAI,OAA8B;AAAA,MAChD,SAAS;AAAA,QACP,eAAe,MAAM,IAAI,gBAAgB;AAAA,QACzC,cAAc,MAAM,IAAI,YAAY;AAAA,QACpC,oBAAoB,CAAC,OAAO,IAAI,qBAAqB,EAAE;AAAA,QACvD,qBAAqB,MAAM,IAAI,sBAAsB;AAAA,MACvD;AAAA,IACF,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAMO,SAAS,4BACd,QACA,QACA,qBACwC;AACxC,QAAM,WAAW,0BAA0B,QAAQ,MAAM,EAAE,QAAQ,SAAS,CAAC;AAC7E,QAAM,SAAS,sBAAsB;AACrC,SAAO;AAAA,IACL,KAAK,SAAS,IAAI,QAAQ,YAAY,CAAC,QAAQ,UAAkB,IAAI,OAAO,KAAK,IAAI,MAAM,EAAE;AAAA,IAC7F,YAAY,CAAC,GAAG,SAAS,UAAU;AAAA,EACrC;AACF;",
4
+ "sourcesContent": ["import {\n DummyDriver,\n Kysely,\n PostgresAdapter,\n PostgresIntrospector,\n PostgresQueryCompiler,\n sql,\n type RawBuilder,\n} from 'kysely'\nimport type { SearchIndexDocCondition, SearchIndexDocFilter } from '@open-mercato/shared/modules/search'\nimport {\n buildJsonbNoOverlapPredicate,\n buildJsonbOverlapPredicate,\n jsonbDocKeyExpression,\n normalizeOverlapValues,\n} from '@open-mercato/shared/lib/query/overlap'\n\n/**\n * `SearchOptions.indexDocFilter` compiled to one correlated `exists` over `entity_indexes`, so a\n * strategy can AND it into the query that ranks \u2014 the predicate decides which rows are ranked,\n * never which ranked rows are kept, and a restrictive filter cannot starve the result set (except\n * under a pgvector `ivfflat` index scan, which applies it to the probed lists' candidates only).\n */\n\nexport const INDEX_DOC_FILTER_ALIAS = 'om_index_doc'\n\nexport type IndexDocFilterTarget = {\n /** Column of the ranked table holding the entity type (`module:entity`). */\n entityTypeRef: string\n /** Column of the ranked table holding the record id. */\n recordIdRef: string\n /** Column of the ranked table holding the tenant id. */\n tenantIdRef: string\n tenantGlobalEntityTypes?: readonly string[]\n}\n\nconst DOC_REF = `${INDEX_DOC_FILTER_ALIAS}.doc`\n\nfunction conditionPredicate(condition: SearchIndexDocCondition): RawBuilder<boolean> {\n switch (condition.op) {\n case 'exists': {\n const keyExpr = jsonbDocKeyExpression(DOC_REF, condition.key)\n return sql<boolean>`(jsonb_typeof(${keyExpr}) is not null and jsonb_typeof(${keyExpr}) <> 'null')`\n }\n case 'eq':\n return sql<boolean>`(${jsonbDocKeyExpression(DOC_REF, condition.key)} #>> '{}') = ${String(condition.value)}`\n case 'overlap':\n return buildJsonbOverlapPredicate(DOC_REF, condition.key, condition.values)\n case 'noverlap':\n return buildJsonbNoOverlapPredicate(DOC_REF, condition.key, condition.values)\n case 'recordIdNotIn': {\n const values = normalizeOverlapValues(condition.values)\n if (!values.length) return sql<boolean>`true`\n return sql<boolean>`not (${sql.ref(`${INDEX_DOC_FILTER_ALIAS}.entity_id`)} = any(${values}::text[]))`\n }\n }\n}\n\nfunction branchPredicate(branch: SearchIndexDocCondition[]): RawBuilder<boolean> {\n if (!branch.length) return sql<boolean>`true`\n return sql<boolean>`(${sql.join(branch.map(conditionPredicate), sql` and `)})`\n}\n\n/** The filter's DNF over the `entity_indexes` row aliased `INDEX_DOC_FILTER_ALIAS`. */\nexport function buildIndexDocFilterPredicate(filter: SearchIndexDocFilter): RawBuilder<boolean> {\n if (!filter.anyOf.length) return sql<boolean>`false`\n return sql<boolean>`(${sql.join(filter.anyOf.map(branchPredicate), sql` or `)})`\n}\n\n/**\n * `exists (select 1 from entity_indexes \u2026 )` correlated to the ranked row by entity type, record id\n * and tenant, restricted to live index rows and to the filter's DNF.\n */\nexport function buildIndexDocFilterExists(filter: SearchIndexDocFilter, target: IndexDocFilterTarget): RawBuilder<boolean> {\n const alias = (column: string) => sql.ref(`${INDEX_DOC_FILTER_ALIAS}.${column}`)\n const globalTypes = target.tenantGlobalEntityTypes ?? []\n const sameTenant = sql<boolean>`${alias('tenant_id')} is not distinct from ${sql.ref(target.tenantIdRef)}`\n const tenantScope = globalTypes.length\n ? sql<boolean>`((${alias('entity_type')} in (${sql.join(globalTypes)}) and ${alias('tenant_id')} is null and ${alias('organization_id')} is null) or (${alias('entity_type')} not in (${sql.join(globalTypes)}) and ${sameTenant}))`\n : sameTenant\n return sql<boolean>`exists (select 1 from entity_indexes as ${sql.id(INDEX_DOC_FILTER_ALIAS)} where ${alias('entity_type')} = ${sql.ref(target.entityTypeRef)} and ${alias('entity_id')} = (${sql.ref(target.recordIdRef)})::text and ${tenantScope} and ${alias('deleted_at')} is null and ${buildIndexDocFilterPredicate(filter)})`\n}\n\nlet compileOnlyDb: Kysely<Record<string, never>> | null = null\n\nfunction compiler(): Kysely<Record<string, never>> {\n if (!compileOnlyDb) {\n compileOnlyDb = new Kysely<Record<string, never>>({\n dialect: {\n createAdapter: () => new PostgresAdapter(),\n createDriver: () => new DummyDriver(),\n createIntrospector: (db) => new PostgresIntrospector(db),\n createQueryCompiler: () => new PostgresQueryCompiler(),\n },\n })\n }\n return compileOnlyDb\n}\n\n/**\n * The `exists` clause as Postgres text for drivers that issue raw SQL: placeholders start at\n * `$<firstParameterIndex>` so the caller appends `parameters` after its own.\n */\nexport function compileIndexDocFilterExists(\n filter: SearchIndexDocFilter,\n target: IndexDocFilterTarget,\n firstParameterIndex: number,\n): { sql: string; parameters: unknown[] } {\n return compileSearchScopePredicate(buildIndexDocFilterExists(filter, target), firstParameterIndex)\n}\n\nexport function compileSearchScopePredicate(predicate: RawBuilder<boolean>, firstParameterIndex: number): { sql: string; parameters: unknown[] } {\n const compiled = predicate.compile(compiler())\n const offset = firstParameterIndex - 1\n return {\n sql: compiled.sql.replace(/\\$(\\d+)/g, (_match, index: string) => `$${Number(index) + offset}`),\n parameters: [...compiled.parameters],\n }\n}\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASA,MAAM,yBAAyB;AAYtC,MAAM,UAAU,GAAG,sBAAsB;AAEzC,SAAS,mBAAmB,WAAyD;AACnF,UAAQ,UAAU,IAAI;AAAA,IACpB,KAAK,UAAU;AACb,YAAM,UAAU,sBAAsB,SAAS,UAAU,GAAG;AAC5D,aAAO,oBAA6B,OAAO,kCAAkC,OAAO;AAAA,IACtF;AAAA,IACA,KAAK;AACH,aAAO,OAAgB,sBAAsB,SAAS,UAAU,GAAG,CAAC,gBAAgB,OAAO,UAAU,KAAK,CAAC;AAAA,IAC7G,KAAK;AACH,aAAO,2BAA2B,SAAS,UAAU,KAAK,UAAU,MAAM;AAAA,IAC5E,KAAK;AACH,aAAO,6BAA6B,SAAS,UAAU,KAAK,UAAU,MAAM;AAAA,IAC9E,KAAK,iBAAiB;AACpB,YAAM,SAAS,uBAAuB,UAAU,MAAM;AACtD,UAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,aAAO,WAAoB,IAAI,IAAI,GAAG,sBAAsB,YAAY,CAAC,UAAU,MAAM;AAAA,IAC3F;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,QAAwD;AAC/E,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,OAAgB,IAAI,KAAK,OAAO,IAAI,kBAAkB,GAAG,UAAU,CAAC;AAC7E;AAGO,SAAS,6BAA6B,QAAmD;AAC9F,MAAI,CAAC,OAAO,MAAM,OAAQ,QAAO;AACjC,SAAO,OAAgB,IAAI,KAAK,OAAO,MAAM,IAAI,eAAe,GAAG,SAAS,CAAC;AAC/E;AAMO,SAAS,0BAA0B,QAA8B,QAAmD;AACzH,QAAM,QAAQ,CAAC,WAAmB,IAAI,IAAI,GAAG,sBAAsB,IAAI,MAAM,EAAE;AAC/E,QAAM,cAAc,OAAO,2BAA2B,CAAC;AACvD,QAAM,aAAa,MAAe,MAAM,WAAW,CAAC,yBAAyB,IAAI,IAAI,OAAO,WAAW,CAAC;AACxG,QAAM,cAAc,YAAY,SAC5B,QAAiB,MAAM,aAAa,CAAC,QAAQ,IAAI,KAAK,WAAW,CAAC,SAAS,MAAM,WAAW,CAAC,gBAAgB,MAAM,iBAAiB,CAAC,iBAAiB,MAAM,aAAa,CAAC,YAAY,IAAI,KAAK,WAAW,CAAC,SAAS,UAAU,OAC9N;AACJ,SAAO,8CAAuD,IAAI,GAAG,sBAAsB,CAAC,UAAU,MAAM,aAAa,CAAC,MAAM,IAAI,IAAI,OAAO,aAAa,CAAC,QAAQ,MAAM,WAAW,CAAC,OAAO,IAAI,IAAI,OAAO,WAAW,CAAC,eAAe,WAAW,QAAQ,MAAM,YAAY,CAAC,gBAAgB,6BAA6B,MAAM,CAAC;AACpU;AAEA,IAAI,gBAAsD;AAE1D,SAAS,WAA0C;AACjD,MAAI,CAAC,eAAe;AAClB,oBAAgB,IAAI,OAA8B;AAAA,MAChD,SAAS;AAAA,QACP,eAAe,MAAM,IAAI,gBAAgB;AAAA,QACzC,cAAc,MAAM,IAAI,YAAY;AAAA,QACpC,oBAAoB,CAAC,OAAO,IAAI,qBAAqB,EAAE;AAAA,QACvD,qBAAqB,MAAM,IAAI,sBAAsB;AAAA,MACvD;AAAA,IACF,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAMO,SAAS,4BACd,QACA,QACA,qBACwC;AACxC,SAAO,4BAA4B,0BAA0B,QAAQ,MAAM,GAAG,mBAAmB;AACnG;AAEO,SAAS,4BAA4B,WAAgC,qBAAqE;AAC/I,QAAM,WAAW,UAAU,QAAQ,SAAS,CAAC;AAC7C,QAAM,SAAS,sBAAsB;AACrC,SAAO;AAAA,IACL,KAAK,SAAS,IAAI,QAAQ,YAAY,CAAC,QAAQ,UAAkB,IAAI,OAAO,KAAK,IAAI,MAAM,EAAE;AAAA,IAC7F,YAAY,CAAC,GAAG,SAAS,UAAU;AAAA,EACrC;AACF;",
6
6
  "names": []
7
7
  }
@@ -60,6 +60,9 @@ async function fetchDocsBatch(db, byEntityType, tenantId, organizationId) {
60
60
  }
61
61
  }
62
62
  if (allPairs.length === 0) return allDocs;
63
+ const { resolveQueryIndexSearchScope, buildQueryIndexSearchScopePredicate } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
64
+ const scope = await resolveQueryIndexSearchScope();
65
+ const globalTypes = new Set(scope.tenantGlobalEntityTypes);
63
66
  const chunks = chunk(allPairs, BATCH_SIZE);
64
67
  for (const pairChunk of chunks) {
65
68
  const chunkByType = /* @__PURE__ */ new Map();
@@ -68,7 +71,14 @@ async function fetchDocsBatch(db, byEntityType, tenantId, organizationId) {
68
71
  ids.push(recordId);
69
72
  chunkByType.set(entityType, ids);
70
73
  }
71
- let query = db.selectFrom("entity_indexes").select(["entity_type", "entity_id", "doc"]).where("tenant_id", "=", tenantId).where("deleted_at", "is", null).where((eb) => eb.or(
74
+ const globalEntityTypes = Array.from(chunkByType.keys()).filter((entityType) => globalTypes.has(entityType));
75
+ let query = db.selectFrom("entity_indexes").select(["entity_type", "entity_id", "doc"]).where(buildQueryIndexSearchScopePredicate(scope, { entityTypeRef: "entity_indexes.entity_type", recordIdRef: "entity_indexes.entity_id", organizationIdRef: "entity_indexes.organization_id" }, tenantId)).where((eb) => globalEntityTypes.length === 0 ? eb("tenant_id", "=", tenantId) : eb.or([
76
+ eb("tenant_id", "=", tenantId),
77
+ eb.and([
78
+ eb("tenant_id", "is", null),
79
+ eb("entity_type", "in", globalEntityTypes)
80
+ ])
81
+ ])).where("deleted_at", "is", null).where((eb) => eb.or(
72
82
  Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([
73
83
  eb("entity_type", "=", entityType),
74
84
  eb("entity_id", "in", recordIds)
@@ -208,6 +218,13 @@ async function computePresenterAndLinks(doc, entityId, recordId, config, tenantI
208
218
  }
209
219
  function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService) {
210
220
  return async (results, tenantId, organizationId) => {
221
+ const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
222
+ const scope = await resolveQueryIndexSearchScope();
223
+ const persistedSources = /* @__PURE__ */ new Set(["tokens", "vector", "fulltext"]);
224
+ const candidates = results.filter((result) => persistedSources.has(result.source)).map((result) => ({ entityId: result.entityId, recordId: result.recordId, tenantId, organizationId: result.organizationId ?? null, result }));
225
+ const allowed = await filterQueryIndexSearchRecords(db, scope, candidates);
226
+ const allowedResults = new Set(allowed.map((candidate) => candidate.result));
227
+ results = results.filter((result) => !scope.blockedEntityTypes.includes(result.entityId) && (!persistedSources.has(result.source) || allowedResults.has(result)));
211
228
  const shouldEnrich = (result) => needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId);
212
229
  const missingResults = results.filter(shouldEnrich);
213
230
  if (missingResults.length === 0) return results;
@@ -228,12 +245,12 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
228
245
  try {
229
246
  const docData = row.doc;
230
247
  const docOrgId = docData.organization_id ?? organizationId;
231
- const scope = { tenantId, organizationId: docOrgId };
248
+ const scope2 = { tenantId, organizationId: docOrgId };
232
249
  const resolveKinds = kindResolversByEntityType.get(row.entity_type);
233
250
  const decryptedDoc = await decryptIndexDocForSearch(
234
251
  row.entity_type,
235
252
  row.doc,
236
- scope,
253
+ scope2,
237
254
  encryptionService ?? null,
238
255
  dekCache,
239
256
  resolveKinds ? resolveKinds(docOrgId, tenantId) : null
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/presenter-enricher.ts"],
4
- "sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport {\n buildCustomFieldKindIndex,\n createCustomFieldKindMapResolver,\n type CustomFieldKindMapResolver,\n} from '@open-mercato/shared/lib/custom-fields/kinds'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { extractSearchableFields } from './field-policy'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Load the `kind` of every active custom field on the entity types in this batch, as a\n * per-entity-type resolver. Without it `decryptIndexDocForSearch` runs `JSON.parse` over\n * decrypted string-typed values and returns `123` where `\"123\"` was stored (issue #5968).\n *\n * The definitions' scope columns come along so each result row can be typed by the\n * definition that applies to *its* organization: global search spans organizations, and one\n * of them may override a key with a different `kind` than another.\n *\n * Fails open: a lookup error yields an empty map, which keeps the previous behavior rather\n * than breaking search.\n */\nasync function fetchCustomFieldKindResolversByEntityType(\n db: Kysely<any>,\n entityTypes: string[],\n tenantId: string,\n): Promise<Map<string, CustomFieldKindMapResolver>> {\n const byEntityType = new Map<string, CustomFieldKindMapResolver>()\n if (!entityTypes.length) return byEntityType\n try {\n const rows = await db\n .selectFrom('custom_field_defs')\n .select(['entity_id', 'key', 'kind', 'organization_id', 'tenant_id', 'config_json', 'updated_at'])\n .where('entity_id', 'in', entityTypes)\n .where('is_active', '=', true)\n .where((eb: any) => eb.or([\n eb('tenant_id', '=', tenantId),\n eb('tenant_id', 'is', null),\n ]))\n .execute() as Array<Record<string, unknown>>\n const grouped = new Map<string, Array<Record<string, unknown>>>()\n for (const row of rows) {\n const entityType = typeof row.entity_id === 'string' ? row.entity_id : String(row.entity_id ?? '')\n if (!entityType) continue\n const group = grouped.get(entityType) ?? []\n group.push(row)\n grouped.set(entityType, group)\n }\n for (const [entityType, group] of grouped) {\n const index = buildCustomFieldKindIndex(group.map((row) => ({\n key: row.key,\n kind: row.kind,\n organizationId: row.organization_id,\n tenantId: row.tenant_id,\n configJson: row.config_json,\n updatedAt: row.updated_at,\n })))\n byEntityType.set(entityType, createCustomFieldKindMapResolver(index))\n }\n } catch (err) {\n logWarning('Failed to resolve custom field kinds', { err })\n }\n return byEntityType\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\nfunction primaryNavigationHref(result: SearchResult): string | null {\n if (typeof result.url === 'string' && result.url.trim().length > 0) {\n return result.url.trim()\n }\n const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)\n return primaryLink?.href.trim() ?? null\n}\n\nfunction directNavigationRecordId(href: string): string | null {\n try {\n const url = new URL(href, 'http://search.local')\n if (url.search || url.hash) return null\n const segments = url.pathname.split('/').filter(Boolean)\n const lastSegment = segments.at(-1)\n return lastSegment ? decodeURIComponent(lastSegment) : null\n } catch {\n return null\n }\n}\n\nfunction presenterTitle(result: SearchResult): string | null {\n const title = result.presenter?.title?.trim()\n return title?.length ? title : null\n}\n\nfunction resultScopeKey(result: SearchResult, recordId: string): string {\n return `${result.organizationId ?? ''}:${recordId}`\n}\n\nfunction mergeResultMetadata(\n targetMetadata: SearchResult['metadata'],\n linkedMetadata: SearchResult['metadata'],\n): SearchResult['metadata'] {\n if (!targetMetadata && !linkedMetadata) return undefined\n return {\n ...targetMetadata,\n ...linkedMetadata,\n }\n}\n\nfunction mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {\n const indexesByRecord = new Map<string, number[]>()\n for (let index = 0; index < results.length; index += 1) {\n const result = results[index]\n const key = resultScopeKey(result, result.recordId)\n const indexes = indexesByRecord.get(key) ?? []\n indexes.push(index)\n indexesByRecord.set(key, indexes)\n }\n\n const replacements = new Map<number, SearchResult>()\n const removedIndexes = new Set<number>()\n\n for (let index = 0; index < results.length; index += 1) {\n const linkedResult = results[index]\n const href = primaryNavigationHref(linkedResult)\n const targetRecordId = href ? directNavigationRecordId(href) : null\n if (!targetRecordId || targetRecordId === linkedResult.recordId) continue\n\n const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])\n .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))\n if (targetIndexes.length !== 1) continue\n\n const targetIndex = targetIndexes[0]\n const targetResult = replacements.get(targetIndex) ?? results[targetIndex]\n if (primaryNavigationHref(targetResult)) continue\n\n const linkedTitle = presenterTitle(linkedResult)\n const targetTitle = presenterTitle(targetResult)\n if (!linkedTitle || linkedTitle !== targetTitle) continue\n\n replacements.set(targetIndex, {\n ...targetResult,\n score: Math.max(targetResult.score, linkedResult.score),\n source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,\n presenter: linkedResult.presenter ?? targetResult.presenter,\n url: linkedResult.url ?? targetResult.url,\n links: linkedResult.links ?? targetResult.links,\n metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),\n })\n removedIndexes.add(index)\n }\n\n return results\n .map((result, index) => replacements.get(index) ?? result)\n .filter((_, index) => !removedIndexes.has(index))\n .sort((left, right) => right.score - left.score)\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n if (config?.formatResult || config?.buildSource) {\n if (config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(extractSearchableFields(doc, { fieldPolicy: config?.fieldPolicy }), entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // The kind lookup only matters to decryption, so skip the round trip entirely when no\n // encryption service will run \u2014 search is a hot path and the map would be dead weight.\n const decryptionActive = Boolean(encryptionService) && encryptionService?.isEnabled?.() !== false\n\n // Single batch query for all docs across all entity types\n const [rawDocs, kindResolversByEntityType] = await Promise.all([\n fetchDocsBatch(db, byEntityType, tenantId, organizationId),\n decryptionActive\n ? fetchCustomFieldKindResolversByEntityType(db, Array.from(byEntityType.keys()), tenantId)\n : Promise.resolve(new Map<string, CustomFieldKindMapResolver>()),\n ])\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n // Same scope for the kind as for the decryption itself \u2014 a result from another\n // organization must not be typed by this organization's definition.\n const resolveKinds = kindResolversByEntityType.get(row.entity_type)\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n resolveKinds ? resolveKinds(docOrgId, tenantId) : null,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n const enrichedResults = results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n\n return mergeLinkedDuplicateResults(enrichedResults)\n }\n}\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport {\n buildCustomFieldKindIndex,\n createCustomFieldKindMapResolver,\n type CustomFieldKindMapResolver,\n} from '@open-mercato/shared/lib/custom-fields/kinds'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { extractSearchableFields } from './field-policy'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Load the `kind` of every active custom field on the entity types in this batch, as a\n * per-entity-type resolver. Without it `decryptIndexDocForSearch` runs `JSON.parse` over\n * decrypted string-typed values and returns `123` where `\"123\"` was stored (issue #5968).\n *\n * The definitions' scope columns come along so each result row can be typed by the\n * definition that applies to *its* organization: global search spans organizations, and one\n * of them may override a key with a different `kind` than another.\n *\n * Fails open: a lookup error yields an empty map, which keeps the previous behavior rather\n * than breaking search.\n */\nasync function fetchCustomFieldKindResolversByEntityType(\n db: Kysely<any>,\n entityTypes: string[],\n tenantId: string,\n): Promise<Map<string, CustomFieldKindMapResolver>> {\n const byEntityType = new Map<string, CustomFieldKindMapResolver>()\n if (!entityTypes.length) return byEntityType\n try {\n const rows = await db\n .selectFrom('custom_field_defs')\n .select(['entity_id', 'key', 'kind', 'organization_id', 'tenant_id', 'config_json', 'updated_at'])\n .where('entity_id', 'in', entityTypes)\n .where('is_active', '=', true)\n .where((eb: any) => eb.or([\n eb('tenant_id', '=', tenantId),\n eb('tenant_id', 'is', null),\n ]))\n .execute() as Array<Record<string, unknown>>\n const grouped = new Map<string, Array<Record<string, unknown>>>()\n for (const row of rows) {\n const entityType = typeof row.entity_id === 'string' ? row.entity_id : String(row.entity_id ?? '')\n if (!entityType) continue\n const group = grouped.get(entityType) ?? []\n group.push(row)\n grouped.set(entityType, group)\n }\n for (const [entityType, group] of grouped) {\n const index = buildCustomFieldKindIndex(group.map((row) => ({\n key: row.key,\n kind: row.kind,\n organizationId: row.organization_id,\n tenantId: row.tenant_id,\n configJson: row.config_json,\n updatedAt: row.updated_at,\n })))\n byEntityType.set(entityType, createCustomFieldKindMapResolver(index))\n }\n } catch (err) {\n logWarning('Failed to resolve custom field kinds', { err })\n }\n return byEntityType\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n *\n * The tenant predicate carries the same NULL branch the organization predicate\n * below already has, and for the same reason: a row stored under a null scope is\n * one shared record, not a record belonging to nobody. `entity_indexes` holds at\n * most one row per (entity_type, entity_id, organization_id_coalesced), so a\n * platform-wide catalogue whose source table has neither scope column has exactly\n * one row to serve every tenant from, and an exact-match tenant predicate resolves\n * it for at most one of them; the rest get a search hit with no presenter, which\n * the UI renders as a raw record id.\n *\n * The branch is deliberately NOT `tenant_id IS NULL` on its own. `tenant_id` is\n * null on the projection of every source table with neither scope column \u2014\n * `resolveQueryIndexRecordScope()` calls that shape `global` and requires an\n * explicitly null scope for it \u2014 and most of those tables are private\n * (`directory:tenant`, `auth:user_role`, `auth:session`). An unqualified NULL\n * branch would serve all of them to every tenant. Only entity types declared\n * through `registerTenantGlobalEntityTypes()` are widened.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Dynamically imported to match the sibling reader in `strategies/token.strategy.ts`\n // and keep this module free of a load-time edge back into `@open-mercato/core`.\n const { resolveQueryIndexSearchScope, buildQueryIndexSearchScopePredicate } = await import('@open-mercato/core/modules/query_index/lib/search-scope')\n const scope = await resolveQueryIndexSearchScope()\n const globalTypes = new Set(scope.tenantGlobalEntityTypes)\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Entity types in this chunk that are declared platform-wide catalogues, and so\n // may also be served from a row stored under the null tenant.\n const globalEntityTypes = Array.from(chunkByType.keys()).filter((entityType) => globalTypes.has(entityType))\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where(buildQueryIndexSearchScopePredicate(scope, { entityTypeRef: 'entity_indexes.entity_type', recordIdRef: 'entity_indexes.entity_id', organizationIdRef: 'entity_indexes.organization_id' }, tenantId))\n .where((eb) => (globalEntityTypes.length === 0\n ? eb('tenant_id' as any, '=', tenantId)\n : eb.or([\n eb('tenant_id' as any, '=', tenantId),\n eb.and([\n eb('tenant_id' as any, 'is', null),\n eb('entity_type' as any, 'in', globalEntityTypes),\n ]),\n ])))\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\nfunction primaryNavigationHref(result: SearchResult): string | null {\n if (typeof result.url === 'string' && result.url.trim().length > 0) {\n return result.url.trim()\n }\n const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)\n return primaryLink?.href.trim() ?? null\n}\n\nfunction directNavigationRecordId(href: string): string | null {\n try {\n const url = new URL(href, 'http://search.local')\n if (url.search || url.hash) return null\n const segments = url.pathname.split('/').filter(Boolean)\n const lastSegment = segments.at(-1)\n return lastSegment ? decodeURIComponent(lastSegment) : null\n } catch {\n return null\n }\n}\n\nfunction presenterTitle(result: SearchResult): string | null {\n const title = result.presenter?.title?.trim()\n return title?.length ? title : null\n}\n\nfunction resultScopeKey(result: SearchResult, recordId: string): string {\n return `${result.organizationId ?? ''}:${recordId}`\n}\n\nfunction mergeResultMetadata(\n targetMetadata: SearchResult['metadata'],\n linkedMetadata: SearchResult['metadata'],\n): SearchResult['metadata'] {\n if (!targetMetadata && !linkedMetadata) return undefined\n return {\n ...targetMetadata,\n ...linkedMetadata,\n }\n}\n\nfunction mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {\n const indexesByRecord = new Map<string, number[]>()\n for (let index = 0; index < results.length; index += 1) {\n const result = results[index]\n const key = resultScopeKey(result, result.recordId)\n const indexes = indexesByRecord.get(key) ?? []\n indexes.push(index)\n indexesByRecord.set(key, indexes)\n }\n\n const replacements = new Map<number, SearchResult>()\n const removedIndexes = new Set<number>()\n\n for (let index = 0; index < results.length; index += 1) {\n const linkedResult = results[index]\n const href = primaryNavigationHref(linkedResult)\n const targetRecordId = href ? directNavigationRecordId(href) : null\n if (!targetRecordId || targetRecordId === linkedResult.recordId) continue\n\n const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])\n .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))\n if (targetIndexes.length !== 1) continue\n\n const targetIndex = targetIndexes[0]\n const targetResult = replacements.get(targetIndex) ?? results[targetIndex]\n if (primaryNavigationHref(targetResult)) continue\n\n const linkedTitle = presenterTitle(linkedResult)\n const targetTitle = presenterTitle(targetResult)\n if (!linkedTitle || linkedTitle !== targetTitle) continue\n\n replacements.set(targetIndex, {\n ...targetResult,\n score: Math.max(targetResult.score, linkedResult.score),\n source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,\n presenter: linkedResult.presenter ?? targetResult.presenter,\n url: linkedResult.url ?? targetResult.url,\n links: linkedResult.links ?? targetResult.links,\n metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),\n })\n removedIndexes.add(index)\n }\n\n return results\n .map((result, index) => replacements.get(index) ?? result)\n .filter((_, index) => !removedIndexes.has(index))\n .sort((left, right) => right.score - left.score)\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n if (config?.formatResult || config?.buildSource) {\n if (config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(extractSearchableFields(doc, { fieldPolicy: config?.fieldPolicy }), entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import('@open-mercato/core/modules/query_index/lib/search-scope')\n const scope = await resolveQueryIndexSearchScope()\n const persistedSources = new Set(['tokens', 'vector', 'fulltext'])\n const candidates = results.filter((result) => persistedSources.has(result.source))\n .map((result) => ({ entityId: result.entityId, recordId: result.recordId, tenantId, organizationId: result.organizationId ?? null, result }))\n const allowed = await filterQueryIndexSearchRecords(db, scope, candidates)\n const allowedResults = new Set(allowed.map((candidate) => candidate.result))\n results = results.filter((result) => !scope.blockedEntityTypes.includes(result.entityId) && (!persistedSources.has(result.source) || allowedResults.has(result)))\n const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // The kind lookup only matters to decryption, so skip the round trip entirely when no\n // encryption service will run \u2014 search is a hot path and the map would be dead weight.\n const decryptionActive = Boolean(encryptionService) && encryptionService?.isEnabled?.() !== false\n\n // Single batch query for all docs across all entity types\n const [rawDocs, kindResolversByEntityType] = await Promise.all([\n fetchDocsBatch(db, byEntityType, tenantId, organizationId),\n decryptionActive\n ? fetchCustomFieldKindResolversByEntityType(db, Array.from(byEntityType.keys()), tenantId)\n : Promise.resolve(new Map<string, CustomFieldKindMapResolver>()),\n ])\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n // Same scope for the kind as for the decryption itself \u2014 a result from another\n // organization must not be typed by this organization's definition.\n const resolveKinds = kindResolversByEntityType.get(row.entity_type)\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n resolveKinds ? resolveKinds(docOrgId, tenantId) : null,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n const enrichedResults = results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n\n return mergeLinkedDuplicateResults(enrichedResults)\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["scope"]
7
7
  }
package/dist/service.js CHANGED
@@ -26,14 +26,15 @@ function normalizeOrganizationFilter(options) {
26
26
  ));
27
27
  return values;
28
28
  }
29
- function filterResultsByOrganizationScope(results, options) {
29
+ function filterResultsByOrganizationScope(results, options, isTenantGlobalEntityType) {
30
30
  const organizationIds = normalizeOrganizationFilter(options);
31
31
  if (!organizationIds) return results;
32
32
  if (organizationIds.length === 0) return [];
33
33
  const allowed = new Set(organizationIds);
34
34
  return results.filter((result) => {
35
35
  const organizationId = typeof result.organizationId === "string" ? result.organizationId.trim() : "";
36
- return organizationId.length > 0 && allowed.has(organizationId);
36
+ if (organizationId.length > 0) return allowed.has(organizationId);
37
+ return isTenantGlobalEntityType(result.entityId);
37
38
  });
38
39
  }
39
40
  class SearchService {
@@ -82,6 +83,13 @@ class SearchService {
82
83
  if (activeStrategies.length === 0) {
83
84
  return [];
84
85
  }
86
+ const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
87
+ const scope = await resolveQueryIndexSearchScope();
88
+ if (options.entityTypes?.length) {
89
+ const entityTypes = options.entityTypes.filter((entityType) => !scope.blockedEntityTypes.includes(entityType));
90
+ if (!entityTypes.length) return [];
91
+ options = { ...options, entityTypes };
92
+ }
85
93
  const results = await Promise.allSettled(
86
94
  activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options))
87
95
  );
@@ -98,9 +106,23 @@ class SearchService {
98
106
  });
99
107
  }
100
108
  }
101
- const merged = mergeAndRankResults(allResults, this.mergeConfig);
102
- const scoped = filterResultsByOrganizationScope(merged, options);
103
- return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId);
109
+ const globalTypes = new Set(scope.tenantGlobalEntityTypes);
110
+ const isTenantGlobalEntityType = (entityType) => globalTypes.has(entityType);
111
+ const persistedSources = /* @__PURE__ */ new Set(["tokens", "vector", "fulltext"]);
112
+ const candidates = allResults.filter((result) => persistedSources.has(result.source)).map((result) => ({ entityId: result.entityId, recordId: result.recordId, tenantId: options.tenantId, organizationId: result.organizationId ?? null, result }));
113
+ const allowed = await filterQueryIndexSearchRecords(void 0, scope, candidates);
114
+ const allowedResults = new Set(allowed.map((candidate) => candidate.result));
115
+ const safeResults = allResults.filter((result) => !scope.blockedEntityTypes.includes(result.entityId) && (!persistedSources.has(result.source) || allowedResults.has(result)));
116
+ const seenCatalogueHits = /* @__PURE__ */ new Set();
117
+ const scoped = filterResultsByOrganizationScope(safeResults, options, isTenantGlobalEntityType).flatMap((result) => {
118
+ if (!isTenantGlobalEntityType(result.entityId)) return [result];
119
+ const key = JSON.stringify([result.source, result.entityId, result.recordId]);
120
+ if (seenCatalogueHits.has(key)) return [];
121
+ seenCatalogueHits.add(key);
122
+ return [{ ...result, organizationId: null }];
123
+ });
124
+ const merged = mergeAndRankResults(scoped, this.mergeConfig);
125
+ return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId);
104
126
  }
105
127
  /**
106
128
  * Recompute configured presenters at request time and fill missing presenter
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/service.ts"],
4
- "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\n/**\n * Maximum records indexed at once when bulkIndex falls back to per-record writes\n * for a strategy that has no bulkIndex implementation (currently the vector\n * strategy, whose index() performs an embedding-provider round trip per record).\n * A whole reindex page arrives in one bulkIndex call, so an unbounded fan-out\n * would burst hundreds of concurrent provider requests from a single job.\n */\nconst BULK_INDEX_FALLBACK_CONCURRENCY = 4\n\n/**\n * Map items through an async worker with a fixed number of in-flight calls.\n * Rejects with the first error, matching Promise.all semantics.\n */\nasync function mapWithConcurrency<T>(\n items: T[],\n limit: number,\n worker: (item: T) => Promise<void>,\n): Promise<void> {\n if (items.length === 0) return\n\n let nextIndex = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n for (;;) {\n const currentIndex = nextIndex++\n if (currentIndex >= items.length) return\n await worker(items[currentIndex])\n }\n })\n\n await Promise.all(runners)\n}\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const honorsFilter = (strategy: SearchStrategy) =>\n !options.indexDocFilter || strategy.supportsIndexDocFilter === true\n const activeStrategies = (await this.getAvailableStrategies(strategyIds)).filter(honorsFilter)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = (await this.getAvailableStrategies([this.fallbackStrategy])).filter(honorsFilter)\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Recompute configured presenters at request time and fill missing presenter\n * or navigation data for unconfigured results.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n this.throwOnStrategyFailures('index', strategies, results, {\n entityId: record.entityId,\n recordId: record.recordId,\n })\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing, bounded so a strategy without a batch\n // implementation cannot turn one batch job into hundreds of concurrent writes.\n return mapWithConcurrency(records, BULK_INDEX_FALLBACK_CONCURRENCY, (record) =>\n this.executeStrategyIndex(strategy, record),\n )\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n this.throwOnStrategyFailures('purge', strategies, results, { entityId })\n }\n\n /**\n * Inspect the settled results of a per-strategy write operation, log every\n * rejection, and re-throw an aggregated error when any strategy failed.\n *\n * Write operations (index/delete/purge) must surface failures to the caller\n * so the queue worker re-throws and the job is retried. Swallowing rejections\n * here causes silent, permanent index gaps on transient failures such as\n * Postgres connection-pool exhaustion (issue #3103). Successful strategies\n * still commit their work; only the aggregated failure propagates.\n */\n private throwOnStrategyFailures(\n operation: 'index' | 'delete' | 'purge',\n strategies: SearchStrategy[],\n results: PromiseSettledResult<unknown>[],\n context: { entityId: string; recordId?: string },\n ): void {\n const failures: Array<{ strategyId: string; reason: unknown }> = []\n\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })\n searchError('SearchService', `Strategy ${operation} failed`, {\n strategyId: strategy?.id,\n entityId: context.entityId,\n recordId: context.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n if (failures.length === 0) return\n\n const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures\n .map((failure) => {\n const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)\n return `${failure.strategyId} (${message})`\n })\n .join(', ')}`\n\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n summary,\n )\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\n/**\n * Maximum records indexed at once when bulkIndex falls back to per-record writes\n * for a strategy that has no bulkIndex implementation (currently the vector\n * strategy, whose index() performs an embedding-provider round trip per record).\n * A whole reindex page arrives in one bulkIndex call, so an unbounded fan-out\n * would burst hundreds of concurrent provider requests from a single job.\n */\nconst BULK_INDEX_FALLBACK_CONCURRENCY = 4\n\n/**\n * Map items through an async worker with a fixed number of in-flight calls.\n * Rejects with the first error, matching Promise.all semantics.\n */\nasync function mapWithConcurrency<T>(\n items: T[],\n limit: number,\n worker: (item: T) => Promise<void>,\n): Promise<void> {\n if (items.length === 0) return\n\n let nextIndex = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n for (;;) {\n const currentIndex = nextIndex++\n if (currentIndex >= items.length) return\n await worker(items[currentIndex])\n }\n })\n\n await Promise.all(runners)\n}\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\n/**\n * A result with no organization passes only for an entity type declared through\n * `registerTenantGlobalEntityTypes()`: a platform-wide catalogue is indexed once with no\n * organization, so an organization-scoped search would otherwise never return it. Every\n * other organization-less result is still dropped.\n */\nfunction filterResultsByOrganizationScope(\n results: SearchResult[],\n options: SearchOptions,\n isTenantGlobalEntityType: (entityType: string) => boolean,\n): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n if (organizationId.length > 0) return allowed.has(organizationId)\n return isTenantGlobalEntityType(result.entityId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const honorsFilter = (strategy: SearchStrategy) =>\n !options.indexDocFilter || strategy.supportsIndexDocFilter === true\n const activeStrategies = (await this.getAvailableStrategies(strategyIds)).filter(honorsFilter)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = (await this.getAvailableStrategies([this.fallbackStrategy])).filter(honorsFilter)\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import('@open-mercato/core/modules/query_index/lib/search-scope')\n const scope = await resolveQueryIndexSearchScope()\n if (options.entityTypes?.length) {\n const entityTypes = options.entityTypes.filter((entityType) => !scope.blockedEntityTypes.includes(entityType))\n if (!entityTypes.length) return []\n options = { ...options, entityTypes }\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const globalTypes = new Set(scope.tenantGlobalEntityTypes)\n const isTenantGlobalEntityType = (entityType: string) => globalTypes.has(entityType)\n const persistedSources = new Set(['tokens', 'vector', 'fulltext'])\n const candidates = allResults.filter((result) => persistedSources.has(result.source))\n .map((result) => ({ entityId: result.entityId, recordId: result.recordId, tenantId: options.tenantId, organizationId: result.organizationId ?? null, result }))\n const allowed = await filterQueryIndexSearchRecords(undefined, scope, candidates)\n const allowedResults = new Set(allowed.map((candidate) => candidate.result))\n const safeResults = allResults.filter((result) => !scope.blockedEntityTypes.includes(result.entityId) && (!persistedSources.has(result.source) || allowedResults.has(result)))\n const seenCatalogueHits = new Set<string>()\n const scoped = filterResultsByOrganizationScope(safeResults, options, isTenantGlobalEntityType)\n .flatMap((result) => {\n if (!isTenantGlobalEntityType(result.entityId)) return [result]\n const key = JSON.stringify([result.source, result.entityId, result.recordId])\n if (seenCatalogueHits.has(key)) return []\n seenCatalogueHits.add(key)\n return [{ ...result, organizationId: null }]\n })\n const merged = mergeAndRankResults(scoped, this.mergeConfig)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Recompute configured presenters at request time and fill missing presenter\n * or navigation data for unconfigured results.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n this.throwOnStrategyFailures('index', strategies, results, {\n entityId: record.entityId,\n recordId: record.recordId,\n })\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing, bounded so a strategy without a batch\n // implementation cannot turn one batch job into hundreds of concurrent writes.\n return mapWithConcurrency(records, BULK_INDEX_FALLBACK_CONCURRENCY, (record) =>\n this.executeStrategyIndex(strategy, record),\n )\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n this.throwOnStrategyFailures('purge', strategies, results, { entityId })\n }\n\n /**\n * Inspect the settled results of a per-strategy write operation, log every\n * rejection, and re-throw an aggregated error when any strategy failed.\n *\n * Write operations (index/delete/purge) must surface failures to the caller\n * so the queue worker re-throws and the job is retried. Swallowing rejections\n * here causes silent, permanent index gaps on transient failures such as\n * Postgres connection-pool exhaustion (issue #3103). Successful strategies\n * still commit their work; only the aggregated failure propagates.\n */\n private throwOnStrategyFailures(\n operation: 'index' | 'delete' | 'purge',\n strategies: SearchStrategy[],\n results: PromiseSettledResult<unknown>[],\n context: { entityId: string; recordId?: string },\n ): void {\n const failures: Array<{ strategyId: string; reason: unknown }> = []\n\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })\n searchError('SearchService', `Strategy ${operation} failed`, {\n strategyId: strategy?.id,\n entityId: context.entityId,\n recordId: context.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n if (failures.length === 0) return\n\n const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures\n .map((failure) => {\n const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)\n return `${failure.strategyId} (${message})`\n })\n .join(', ')}`\n\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n summary,\n )\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -46,6 +46,9 @@ class TokenSearchStrategy {
46
46
  const { tokenizeText } = await import("@open-mercato/shared/lib/search/tokenize");
47
47
  const { resolveSearchConfig } = await import("@open-mercato/shared/lib/search/config");
48
48
  const { listSearchTokenExcludedEntityTypes } = await import("@open-mercato/core/modules/query_index/lib/search-entity-policy");
49
+ const { resolveQueryIndexSearchScope, buildQueryIndexSearchScopePredicate } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
50
+ const scope = await resolveQueryIndexSearchScope();
51
+ const globalTypes = new Set(scope.tenantGlobalEntityTypes);
49
52
  const config = resolveSearchConfig();
50
53
  if (!config.enabled) return [];
51
54
  const excludedEntityTypes = listSearchTokenExcludedEntityTypes();
@@ -55,14 +58,31 @@ class TokenSearchStrategy {
55
58
  if (hashes.length === 0) return [];
56
59
  const minMatches = Math.max(1, Math.ceil(hashes.length * this.minMatchRatio));
57
60
  const limit = options.limit ?? this.defaultLimit;
61
+ const globalEntityTypes = requestedEntityTypes ? requestedEntityTypes.filter((entityType) => globalTypes.has(entityType)) : scope.tenantGlobalEntityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType));
58
62
  let queryBuilder = this.db.selectFrom("search_tokens").select([
59
63
  "entity_type",
60
64
  "entity_id",
61
65
  "organization_id",
62
66
  sql`count(*)`.as("match_count")
63
- ]).where("token_hash", "in", hashes).where("tenant_id", "=", options.tenantId).groupBy(["entity_type", "entity_id", "organization_id"]).having(sql`count(distinct token_hash) >= ${minMatches}`).orderBy(sql`count(distinct token_hash) desc`).limit(limit);
67
+ ]).where("token_hash", "in", hashes).where(buildQueryIndexSearchScopePredicate(scope, { entityTypeRef: "search_tokens.entity_type", recordIdRef: "search_tokens.entity_id", organizationIdRef: "search_tokens.organization_id" }, options.tenantId)).where((eb) => globalEntityTypes.length === 0 ? eb("tenant_id", "=", options.tenantId) : eb.or([
68
+ eb("tenant_id", "=", options.tenantId),
69
+ eb.and([
70
+ eb("tenant_id", "is", null),
71
+ eb("entity_type", "in", globalEntityTypes)
72
+ ])
73
+ ])).groupBy(["entity_type", "entity_id", "organization_id"]).having(sql`count(distinct token_hash) >= ${minMatches}`).orderBy(sql`count(distinct token_hash) desc`).limit(limit);
74
+ if (scope.blockedEntityTypes.length) {
75
+ queryBuilder = queryBuilder.where((expression) => expression("entity_type", "not in", [...scope.blockedEntityTypes]));
76
+ }
64
77
  if (organizationIds) {
65
- queryBuilder = queryBuilder.where("organization_id", "in", organizationIds);
78
+ queryBuilder = queryBuilder.where((eb) => globalEntityTypes.length === 0 ? eb("organization_id", "in", organizationIds) : eb.or([
79
+ eb("organization_id", "in", organizationIds),
80
+ eb.and([
81
+ eb("organization_id", "is", null),
82
+ eb("tenant_id", "is", null),
83
+ eb("entity_type", "in", globalEntityTypes)
84
+ ])
85
+ ]));
66
86
  }
67
87
  if (requestedEntityTypes?.length) {
68
88
  queryBuilder = queryBuilder.where("entity_type", "in", requestedEntityTypes);
@@ -74,7 +94,8 @@ class TokenSearchStrategy {
74
94
  buildIndexDocFilterExists(options.indexDocFilter, {
75
95
  entityTypeRef: "search_tokens.entity_type",
76
96
  recordIdRef: "search_tokens.entity_id",
77
- tenantIdRef: "search_tokens.tenant_id"
97
+ tenantIdRef: "search_tokens.tenant_id",
98
+ tenantGlobalEntityTypes: globalEntityTypes
78
99
  })
79
100
  );
80
101
  }
@@ -93,33 +114,42 @@ class TokenSearchStrategy {
93
114
  }
94
115
  async index(record) {
95
116
  const { replaceSearchTokensForRecord } = await import("@open-mercato/core/modules/query_index/lib/search-tokens");
117
+ const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
118
+ const scope = await resolveQueryIndexSearchScope();
119
+ if (!(await filterQueryIndexSearchRecords(this.db, scope, [record])).length) return;
120
+ const global = scope.tenantGlobalEntityTypes.includes(record.entityId);
96
121
  await replaceSearchTokensForRecord(this.db, {
97
122
  entityType: record.entityId,
98
123
  recordId: record.recordId,
99
- tenantId: record.tenantId,
100
- organizationId: record.organizationId,
124
+ tenantId: global ? null : record.tenantId,
125
+ organizationId: global ? null : record.organizationId,
101
126
  doc: normalizeCustomFieldKeys(record.fields)
102
127
  });
103
128
  }
104
129
  async delete(entityId, recordId, tenantId) {
105
130
  const { deleteSearchTokensForRecord } = await import("@open-mercato/core/modules/query_index/lib/search-tokens");
131
+ const { resolveQueryIndexSearchScope } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
132
+ const scope = await resolveQueryIndexSearchScope();
106
133
  await deleteSearchTokensForRecord(this.db, {
107
134
  entityType: entityId,
108
135
  recordId,
109
- tenantId
136
+ tenantId: scope.tenantGlobalEntityTypes.includes(entityId) ? null : tenantId
110
137
  });
111
138
  }
112
139
  async bulkIndex(records) {
113
140
  if (records.length === 0) return;
114
141
  const { replaceSearchTokensForBatch } = await import("@open-mercato/core/modules/query_index/lib/search-tokens");
115
- const payloads = records.map((record) => ({
142
+ const { resolveQueryIndexSearchScope, filterQueryIndexSearchRecords } = await import("@open-mercato/core/modules/query_index/lib/search-scope");
143
+ const scope = await resolveQueryIndexSearchScope();
144
+ const allowedRecords = await filterQueryIndexSearchRecords(this.db, scope, records);
145
+ const payloads = allowedRecords.map((record) => ({
116
146
  entityType: record.entityId,
117
147
  recordId: record.recordId,
118
- tenantId: record.tenantId,
119
- organizationId: record.organizationId,
148
+ tenantId: scope.tenantGlobalEntityTypes.includes(record.entityId) ? null : record.tenantId,
149
+ organizationId: scope.tenantGlobalEntityTypes.includes(record.entityId) ? null : record.organizationId,
120
150
  doc: normalizeCustomFieldKeys(record.fields)
121
151
  }));
122
- await replaceSearchTokensForBatch(this.db, payloads);
152
+ if (payloads.length) await replaceSearchTokensForBatch(this.db, payloads);
123
153
  }
124
154
  async purge(entityId, tenantId, organizationId) {
125
155
  const normalizedOrganizationId = typeof organizationId === "string" && organizationId.trim().length > 0 ? organizationId.trim() : null;