@open-mercato/search 0.8.1-develop.7297.1.67605e74f9 → 0.9.0

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.
@@ -1,2 +1,2 @@
1
- [build:search] found 98 entry points
1
+ [build:search] found 99 entry points
2
2
  [build:search] built successfully
package/dist/di.js CHANGED
@@ -6,6 +6,8 @@ import { FullTextSearchStrategy } from "./strategies/fulltext.strategy.js";
6
6
  import { createFulltextDriver } from "./fulltext/drivers/index.js";
7
7
  import { SearchIndexer } from "./indexer/search-indexer.js";
8
8
  import { createPresenterEnricher } from "./lib/presenter-enricher.js";
9
+ import { createEncryptionMapResolver } from "./lib/encryption-map-resolver.js";
10
+ import { createEncryptionMapResolver as createEncryptionMapResolver2 } from "./lib/encryption-map-resolver.js";
9
11
  const FULLTEXT_DRIVER_KEY = "__omSearchFulltextDriver__";
10
12
  function getSearchModuleGlobals() {
11
13
  return globalThis;
@@ -14,26 +16,9 @@ function shouldExcludeEncryptedFields() {
14
16
  const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? "").toLowerCase();
15
17
  return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
16
18
  }
17
- function createEncryptionMapResolver(db) {
18
- const cache = /* @__PURE__ */ new Map();
19
- const CACHE_TTL_MS = 5 * 60 * 1e3;
20
- return async (entityId) => {
21
- const cached = cache.get(entityId);
22
- if (cached && cached.expiresAt > Date.now()) {
23
- return cached.entries;
24
- }
25
- try {
26
- const row = await db.selectFrom("encryption_maps").select(["fields_json"]).where("entity_id", "=", entityId).where("is_active", "=", true).where("deleted_at", "is", null).executeTakeFirst();
27
- const fieldsJson = row?.fields_json;
28
- const entries = Array.isArray(fieldsJson) ? fieldsJson.map((f) => ({
29
- field: f.field,
30
- hashField: f.hashField ?? null
31
- })) : [];
32
- cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS });
33
- return entries;
34
- } catch {
35
- return [];
36
- }
19
+ function createUnavailableEncryptionMapResolver() {
20
+ return async () => {
21
+ throw new Error("[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields");
37
22
  };
38
23
  }
39
24
  function registerSearchModule(container, options) {
@@ -79,6 +64,7 @@ function registerSearchModule(container, options) {
79
64
  const db = em.getKysely();
80
65
  encryptionMapResolver = createEncryptionMapResolver(db);
81
66
  } catch {
67
+ encryptionMapResolver = createUnavailableEncryptionMapResolver();
82
68
  }
83
69
  }
84
70
  fulltextDriver = createFulltextDriver({
@@ -166,6 +152,7 @@ function addSearchStrategy(container, strategy) {
166
152
  }
167
153
  export {
168
154
  addSearchStrategy,
155
+ createEncryptionMapResolver2 as createEncryptionMapResolver,
169
156
  registerSearchModule
170
157
  };
171
158
  //# sourceMappingURL=di.js.map
package/dist/di.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/di.ts"],
4
- "sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport type { FullTextSearchDriver } from './fulltext/types'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\n\nconst FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'\n\ntype SearchModuleGlobals = {\n [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver\n}\n\nfunction getSearchModuleGlobals(): SearchModuleGlobals {\n return globalThis as unknown as SearchModuleGlobals\n}\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Create an encryption map resolver that queries the database.\n * Falls back to empty array if query fails.\n */\nfunction createEncryptionMapResolver(\n db: Kysely<any>,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n // Cache encryption maps per entity to avoid repeated queries\n const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()\n const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const cached = cache.get(entityId)\n if (cached && cached.expiresAt > Date.now()) {\n return cached.entries\n }\n\n try {\n const row = await db\n .selectFrom('encryption_maps' as any)\n .select(['fields_json' as any])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .executeTakeFirst() as { fields_json?: unknown } | undefined\n\n const fieldsJson = row?.fields_json\n const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)\n ? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({\n field: f.field,\n hashField: f.hashField ?? null,\n }))\n : []\n\n cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })\n return entries\n } catch {\n // Query failed, return empty array (don't exclude any fields)\n return []\n }\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n const excludeEncrypted = shouldExcludeEncryptedFields()\n const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n const g = getSearchModuleGlobals()\n\n // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is\n // OFF \u2014 in that case the driver holds no request-scoped Kysely reference. When the flag\n // is ON the encryptionMapResolver captures a per-request db handle and must stay\n // per-request to avoid cross-request data leaks.\n const canMemoize = singletonCacheEnabled && !excludeEncrypted\n\n let fulltextDriver: FullTextSearchDriver | null = canMemoize\n ? (g[FULLTEXT_DRIVER_KEY] ?? null)\n : null\n\n if (!fulltextDriver) {\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (excludeEncrypted) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n // Kysely not available, encrypted field filtering disabled\n }\n }\n\n fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (canMemoize && fulltextDriver) {\n g[FULLTEXT_DRIVER_KEY] = fulltextDriver\n }\n }\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport type { FullTextSearchDriver } from './fulltext/types'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\nimport { createEncryptionMapResolver } from './lib/encryption-map-resolver'\n\nexport { createEncryptionMapResolver } from './lib/encryption-map-resolver'\n\nconst FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'\n\ntype SearchModuleGlobals = {\n [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver\n}\n\nfunction getSearchModuleGlobals(): SearchModuleGlobals {\n return globalThis as unknown as SearchModuleGlobals\n}\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\nfunction createUnavailableEncryptionMapResolver(): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n return async () => {\n throw new Error('[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields')\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n const excludeEncrypted = shouldExcludeEncryptedFields()\n const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n const g = getSearchModuleGlobals()\n\n // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is\n // OFF \u2014 in that case the driver holds no request-scoped Kysely reference. When the flag\n // is ON the encryptionMapResolver captures a per-request db handle and must stay\n // per-request to avoid cross-request data leaks.\n const canMemoize = singletonCacheEnabled && !excludeEncrypted\n\n let fulltextDriver: FullTextSearchDriver | null = canMemoize\n ? (g[FULLTEXT_DRIVER_KEY] ?? null)\n : null\n\n if (!fulltextDriver) {\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (excludeEncrypted) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n encryptionMapResolver = createUnavailableEncryptionMapResolver()\n }\n }\n\n fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (canMemoize && fulltextDriver) {\n g[FULLTEXT_DRIVER_KEY] = fulltextDriver\n }\n }\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["createEncryptionMapResolver"]
7
7
  }
@@ -0,0 +1,64 @@
1
+ function compareNullableScope(left, right) {
2
+ if (left == null && right == null) return 0;
3
+ if (left == null) return -1;
4
+ if (right == null) return 1;
5
+ return String(left).localeCompare(String(right));
6
+ }
7
+ function compareCreatedAt(left, right) {
8
+ const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ""));
9
+ const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ""));
10
+ if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime;
11
+ return String(left ?? "").localeCompare(String(right ?? ""));
12
+ }
13
+ function compareEncryptionMapRows(left, right) {
14
+ return compareNullableScope(left.tenant_id, right.tenant_id) || compareNullableScope(left.organization_id, right.organization_id) || compareCreatedAt(left.created_at, right.created_at) || String(left.id ?? "").localeCompare(String(right.id ?? ""));
15
+ }
16
+ function parseEncryptionMapFields(value) {
17
+ if (Array.isArray(value)) return value;
18
+ if (typeof value !== "string") return [];
19
+ try {
20
+ const parsed = JSON.parse(value);
21
+ return Array.isArray(parsed) ? parsed : [];
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+ function mergeEncryptionMapEntries(rows) {
27
+ const merged = [];
28
+ const byField = /* @__PURE__ */ new Map();
29
+ for (const row of [...rows].sort(compareEncryptionMapRows)) {
30
+ for (const candidate of parseEncryptionMapFields(row.fields_json)) {
31
+ if (!candidate || typeof candidate !== "object") continue;
32
+ const rule = candidate;
33
+ const field = typeof rule.field === "string" ? rule.field.trim() : "";
34
+ if (!field) continue;
35
+ const hashField = typeof rule.hashField === "string" && rule.hashField.length > 0 ? rule.hashField : null;
36
+ const existing = byField.get(field);
37
+ if (!existing) {
38
+ const entry = { field, hashField };
39
+ byField.set(field, entry);
40
+ merged.push(entry);
41
+ } else if (!existing.hashField && hashField) {
42
+ existing.hashField = hashField;
43
+ }
44
+ }
45
+ }
46
+ return merged;
47
+ }
48
+ function createEncryptionMapResolver(db, legacyPolicyVersionCache) {
49
+ void legacyPolicyVersionCache;
50
+ return async (entityId) => {
51
+ const rows = await db.selectFrom("encryption_maps").select([
52
+ "id",
53
+ "tenant_id",
54
+ "organization_id",
55
+ "fields_json",
56
+ "created_at"
57
+ ]).where("entity_id", "=", entityId).where("is_active", "=", true).where("deleted_at", "is", null).orderBy("tenant_id", "asc").orderBy("organization_id", "asc").orderBy("created_at", "asc").orderBy("id", "asc").execute();
58
+ return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : []);
59
+ };
60
+ }
61
+ export {
62
+ createEncryptionMapResolver
63
+ };
64
+ //# sourceMappingURL=encryption-map-resolver.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/encryption-map-resolver.ts"],
4
+ "sourcesContent": ["import type { Kysely } from 'kysely'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { EncryptionMapEntry } from './field-policy'\n\ntype EncryptionMapRow = {\n id?: unknown\n tenant_id?: unknown\n organization_id?: unknown\n fields_json?: unknown\n created_at?: unknown\n}\n\ntype LegacyEncryptionMapPolicyVersionCache = {\n get(key: string): Promise<unknown | null>\n}\n\nfunction compareNullableScope(left: unknown, right: unknown): number {\n if (left == null && right == null) return 0\n if (left == null) return -1\n if (right == null) return 1\n return String(left).localeCompare(String(right))\n}\n\nfunction compareCreatedAt(left: unknown, right: unknown): number {\n const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ''))\n const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ''))\n if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime\n return String(left ?? '').localeCompare(String(right ?? ''))\n}\n\nfunction compareEncryptionMapRows(left: EncryptionMapRow, right: EncryptionMapRow): number {\n return compareNullableScope(left.tenant_id, right.tenant_id)\n || compareNullableScope(left.organization_id, right.organization_id)\n || compareCreatedAt(left.created_at, right.created_at)\n || String(left.id ?? '').localeCompare(String(right.id ?? ''))\n}\n\nfunction parseEncryptionMapFields(value: unknown): unknown[] {\n if (Array.isArray(value)) return value\n if (typeof value !== 'string') return []\n try {\n const parsed = JSON.parse(value) as unknown\n return Array.isArray(parsed) ? parsed : []\n } catch {\n return []\n }\n}\n\nfunction mergeEncryptionMapEntries(rows: readonly EncryptionMapRow[]): EncryptionMapEntry[] {\n const merged: EncryptionMapEntry[] = []\n const byField = new Map<string, EncryptionMapEntry>()\n for (const row of [...rows].sort(compareEncryptionMapRows)) {\n for (const candidate of parseEncryptionMapFields(row.fields_json)) {\n if (!candidate || typeof candidate !== 'object') continue\n const rule = candidate as { field?: unknown; hashField?: unknown }\n const field = typeof rule.field === 'string' ? rule.field.trim() : ''\n if (!field) continue\n const hashField = typeof rule.hashField === 'string' && rule.hashField.length > 0\n ? rule.hashField\n : null\n const existing = byField.get(field)\n if (!existing) {\n const entry = { field, hashField }\n byField.set(field, entry)\n merged.push(entry)\n } else if (!existing.hashField && hashField) {\n existing.hashField = hashField\n }\n }\n }\n return merged\n}\n\n/**\n * Resolve every active live declaration for an entity. Search indexing has no\n * tenant/scope argument at this boundary, so the safe policy is the union of\n * all scopes: over-excluding a field is preferable to publishing plaintext.\n *\n * The legacy version-cache argument remains optional for call-site compatibility but is\n * intentionally ignored. A version publication can fail after the database commit, so neither\n * that channel nor process-local positive memory can authorize reuse of an older policy.\n */\nexport function createEncryptionMapResolver(\n db: Kysely<any>,\n legacyPolicyVersionCache?: LegacyEncryptionMapPolicyVersionCache,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n void legacyPolicyVersionCache\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const rows = await db\n .selectFrom('encryption_maps' as any)\n .select([\n 'id' as any,\n 'tenant_id' as any,\n 'organization_id' as any,\n 'fields_json' as any,\n 'created_at' as any,\n ])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .orderBy('tenant_id' as any, 'asc')\n .orderBy('organization_id' as any, 'asc')\n .orderBy('created_at' as any, 'asc')\n .orderBy('id' as any, 'asc')\n .execute() as EncryptionMapRow[]\n\n return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : [])\n }\n}\n"],
5
+ "mappings": "AAgBA,SAAS,qBAAqB,MAAe,OAAwB;AACnE,MAAI,QAAQ,QAAQ,SAAS,KAAM,QAAO;AAC1C,MAAI,QAAQ,KAAM,QAAO;AACzB,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,OAAO,IAAI,EAAE,cAAc,OAAO,KAAK,CAAC;AACjD;AAEA,SAAS,iBAAiB,MAAe,OAAwB;AAC/D,QAAM,WAAW,gBAAgB,OAAO,KAAK,QAAQ,IAAI,KAAK,MAAM,OAAO,QAAQ,EAAE,CAAC;AACtF,QAAM,YAAY,iBAAiB,OAAO,MAAM,QAAQ,IAAI,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC;AAC1F,MAAI,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,SAAS,EAAG,QAAO,WAAW;AAC/E,SAAO,OAAO,QAAQ,EAAE,EAAE,cAAc,OAAO,SAAS,EAAE,CAAC;AAC7D;AAEA,SAAS,yBAAyB,MAAwB,OAAiC;AACzF,SAAO,qBAAqB,KAAK,WAAW,MAAM,SAAS,KACtD,qBAAqB,KAAK,iBAAiB,MAAM,eAAe,KAChE,iBAAiB,KAAK,YAAY,MAAM,UAAU,KAClD,OAAO,KAAK,MAAM,EAAE,EAAE,cAAc,OAAO,MAAM,MAAM,EAAE,CAAC;AACjE;AAEA,SAAS,yBAAyB,OAA2B;AAC3D,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,MAAI,OAAO,UAAU,SAAU,QAAO,CAAC;AACvC,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,WAAO,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC;AAAA,EAC3C,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,SAAS,0BAA0B,MAAyD;AAC1F,QAAM,SAA+B,CAAC;AACtC,QAAM,UAAU,oBAAI,IAAgC;AACpD,aAAW,OAAO,CAAC,GAAG,IAAI,EAAE,KAAK,wBAAwB,GAAG;AAC1D,eAAW,aAAa,yBAAyB,IAAI,WAAW,GAAG;AACjE,UAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,YAAM,OAAO;AACb,YAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACnE,UAAI,CAAC,MAAO;AACZ,YAAM,YAAY,OAAO,KAAK,cAAc,YAAY,KAAK,UAAU,SAAS,IAC5E,KAAK,YACL;AACJ,YAAM,WAAW,QAAQ,IAAI,KAAK;AAClC,UAAI,CAAC,UAAU;AACb,cAAM,QAAQ,EAAE,OAAO,UAAU;AACjC,gBAAQ,IAAI,OAAO,KAAK;AACxB,eAAO,KAAK,KAAK;AAAA,MACnB,WAAW,CAAC,SAAS,aAAa,WAAW;AAC3C,iBAAS,YAAY;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,4BACd,IACA,0BACuD;AACvD,OAAK;AAEL,SAAO,OAAO,aAAsD;AAClE,UAAM,OAAO,MAAM,GAChB,WAAW,iBAAwB,EACnC,OAAO;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC,EACA,MAAM,aAAoB,KAAK,QAAQ,EACvC,MAAM,aAAoB,KAAK,IAAI,EACnC,MAAM,cAAqB,MAAM,IAAI,EACrC,QAAQ,aAAoB,KAAK,EACjC,QAAQ,mBAA0B,KAAK,EACvC,QAAQ,cAAqB,KAAK,EAClC,QAAQ,MAAa,KAAK,EAC1B,QAAQ;AAEX,WAAO,0BAA0B,MAAM,QAAQ,IAAI,IAAI,OAAO,CAAC,CAAC;AAAA,EAClE;AACF;",
6
+ "names": []
7
+ }
@@ -18,7 +18,8 @@ async function handle(payload, ctx) {
18
18
  }
19
19
  if ((organizationId == null || tenantId == null) && em) {
20
20
  try {
21
- const db = em.getKysely();
21
+ const lookupEm = typeof em.fork === "function" ? em.fork() : em;
22
+ const db = lookupEm.getKysely();
22
23
  const table = resolveEntityTableName(em, entityType);
23
24
  const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
24
25
  if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/subscribers/fulltext_upsert.ts"],
4
- "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'search.index_record', persistent: false }\n\ntype Payload = {\n entityId?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityId ?? '')\n const recordId = String(payload?.recordId ?? '')\n\n if (!entityType || !recordId) {\n return\n }\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n // Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const db = em.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) {\n return\n }\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) {\n return\n }\n\n let queue: Queue<FulltextIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n searchDebugWarn('search.fulltext', 'fulltextIndexQueue not available, skipping fulltext indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.fulltext', 'Failed to enqueue fulltext index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,uBAAuB,YAAY,MAAM;AAW1E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,YAAY,EAAE;AACjD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAE/C,MAAI,CAAC,cAAc,CAAC,UAAU;AAC5B;AAAA,EACF;AAEA,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAGA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,KAAK,GAAG,UAAU;AACxB,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,UAAU;AACb;AAAA,EACF;AAEA,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,qBAAqB;AACxB;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAwC,oBAAoB;AAAA,EAC1E,QAAQ;AACN,oBAAgB,mBAAmB,8DAA8D;AACjG;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,mBAAmB,wCAAwC;AAAA,MACrE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
4
+ "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'search.index_record', persistent: false }\n\ntype Payload = {\n entityId?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityId ?? '')\n const recordId = String(payload?.recordId ?? '')\n\n if (!entityType || !recordId) {\n return\n }\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n // Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) {\n return\n }\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) {\n return\n }\n\n let queue: Queue<FulltextIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n searchDebugWarn('search.fulltext', 'fulltextIndexQueue not available, skipping fulltext indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.fulltext', 'Failed to enqueue fulltext index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,uBAAuB,YAAY,MAAM;AAW1E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,YAAY,EAAE;AACjD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAE/C,MAAI,CAAC,cAAc,CAAC,UAAU;AAC5B;AAAA,EACF;AAEA,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAGA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,UAAU;AACb;AAAA,EACF;AAEA,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,qBAAqB;AACxB;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAwC,oBAAoB;AAAA,EAC1E,QAAQ;AACN,oBAAgB,mBAAmB,8DAA8D;AACjG;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,mBAAmB,wCAAwC;AAAA,MACrE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
6
6
  "names": []
7
7
  }
@@ -16,7 +16,8 @@ async function handle(payload, ctx) {
16
16
  }
17
17
  if ((organizationId == null || tenantId == null) && em) {
18
18
  try {
19
- const db = em.getKysely();
19
+ const lookupEm = typeof em.fork === "function" ? em.fork() : em;
20
+ const db = lookupEm.getKysely();
20
21
  const table = resolveEntityTableName(em, entityType);
21
22
  const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
22
23
  if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/subscribers/vector_delete.ts"],
4
- "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.delete_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const db = em.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector delete')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'delete',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector delete job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,0BAA0B,YAAY,MAAM;AAW7E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,KAAK,GAAG,UAAU;AACxB,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,wDAAwD;AACzF;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,uCAAuC;AAAA,MAClE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
4
+ "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.delete_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector delete')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'delete',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector delete job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,0BAA0B,YAAY,MAAM;AAW7E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,wDAAwD;AACzF;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,uCAAuC;AAAA,MAClE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
6
6
  "names": []
7
7
  }
@@ -16,7 +16,8 @@ async function handle(payload, ctx) {
16
16
  }
17
17
  if ((organizationId == null || tenantId == null) && em) {
18
18
  try {
19
- const db = em.getKysely();
19
+ const lookupEm = typeof em.fork === "function" ? em.fork() : em;
20
+ const db = lookupEm.getKysely();
20
21
  const table = resolveEntityTableName(em, entityType);
21
22
  const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
22
23
  if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/subscribers/vector_upsert.ts"],
4
- "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.vectorize_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const db = em.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,6BAA6B,YAAY,MAAM;AAWhF,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,KAAK,GAAG,UAAU;AACxB,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,0DAA0D;AAC3F;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,sCAAsC;AAAA,MACjE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
4
+ "sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.vectorize_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,6BAA6B,YAAY,MAAM;AAWhF,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,0DAA0D;AAC3F;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,sCAAsC;AAAA,MACjE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.8.1-develop.7297.1.67605e74f9",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.8.1-develop.7297.1.67605e74f9",
131
- "@open-mercato/queue": "0.8.1-develop.7297.1.67605e74f9",
132
- "@open-mercato/shared": "0.8.1-develop.7297.1.67605e74f9"
130
+ "@open-mercato/core": "0.9.0",
131
+ "@open-mercato/queue": "0.9.0",
132
+ "@open-mercato/shared": "0.9.0"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -144,6 +144,5 @@
144
144
  "type": "git",
145
145
  "url": "https://github.com/open-mercato/open-mercato",
146
146
  "directory": "packages/search"
147
- },
148
- "stableVersion": "0.8.0"
147
+ }
149
148
  }
@@ -164,4 +164,19 @@ describe('registerSearchModule — fulltext driver canMemoize guard', () => {
164
164
 
165
165
  expect(jest.mocked(createFulltextDriver)).toHaveBeenCalledTimes(2)
166
166
  })
167
+
168
+ it('installs a fail-closed resolver when encrypted-field filtering cannot reach the database', async () => {
169
+ process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS = 'true'
170
+ const container = makeMockContainer()
171
+
172
+ registerSearchModule(container as never, { skipVector: true, skipTokens: true })
173
+
174
+ const options = jest.mocked(createFulltextDriver).mock.calls[0]?.[0] as {
175
+ encryptionMapResolver?: (entityId: string) => Promise<unknown>
176
+ }
177
+ expect(options.encryptionMapResolver).toEqual(expect.any(Function))
178
+ await expect(options.encryptionMapResolver?.('demo:item') as Promise<unknown>).rejects.toThrow(
179
+ 'refusing to index potentially encrypted fields',
180
+ )
181
+ })
167
182
  })
@@ -0,0 +1,200 @@
1
+ jest.mock('../fulltext/drivers', () => ({
2
+ createFulltextDriver: jest.fn(),
3
+ }))
4
+
5
+ import { createEncryptionMapResolver } from '../di'
6
+ import { encryptionMapPolicyVersionCacheKey } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
7
+
8
+ type QueryResult = Array<Record<string, unknown>> | Error
9
+
10
+ function createDatabase(results: QueryResult[]) {
11
+ const queue = [...results]
12
+ const query: Record<string, jest.Mock> = {}
13
+ for (const method of ['select', 'where', 'orderBy']) {
14
+ query[method] = jest.fn(() => query)
15
+ }
16
+ query.execute = jest.fn(async () => {
17
+ const result = queue.shift() ?? []
18
+ if (result instanceof Error) throw result
19
+ return result
20
+ })
21
+ return {
22
+ db: { selectFrom: jest.fn(() => query) },
23
+ query,
24
+ }
25
+ }
26
+
27
+ function createPolicyVersionCache(entityId: string) {
28
+ const storage = new Map<string, unknown>([
29
+ [encryptionMapPolicyVersionCacheKey(entityId), 'version-1'],
30
+ ])
31
+ const cache = {
32
+ get: jest.fn(async (key: string) => storage.get(key) ?? null),
33
+ set: jest.fn(async (key: string, value: unknown) => { storage.set(key, value) }),
34
+ delete: jest.fn(async (key: string) => storage.delete(key)),
35
+ }
36
+ return { cache, storage }
37
+ }
38
+
39
+ describe('search encryption-map resolver', () => {
40
+ it('deterministically unions legacy duplicate rows and completes a missing hash target', async () => {
41
+ const { db } = createDatabase([[
42
+ {
43
+ id: 'b',
44
+ tenant_id: 'tenant-1',
45
+ organization_id: 'org-1',
46
+ created_at: '2026-01-02T00:00:00.000Z',
47
+ fields_json: [{ field: 'email', hashField: 'email_hash' }, { field: 'phone' }],
48
+ },
49
+ {
50
+ id: 'a',
51
+ tenant_id: 'tenant-1',
52
+ organization_id: 'org-1',
53
+ created_at: '2026-01-01T00:00:00.000Z',
54
+ fields_json: [{ field: 'email' }, { field: 'display_name' }],
55
+ },
56
+ ]])
57
+
58
+ await expect(createEncryptionMapResolver(db as never)('customers:person')).resolves.toEqual([
59
+ { field: 'email', hashField: 'email_hash' },
60
+ { field: 'display_name', hashField: null },
61
+ { field: 'phone', hashField: null },
62
+ ])
63
+ })
64
+
65
+ it('unions global, tenant-global, and organization-scoped declarations fail-safe', async () => {
66
+ const { db, query } = createDatabase([[
67
+ {
68
+ id: 'org',
69
+ tenant_id: 'tenant-1',
70
+ organization_id: 'org-1',
71
+ created_at: '2026-01-03T00:00:00.000Z',
72
+ fields_json: [{ field: 'organization_secret' }],
73
+ },
74
+ {
75
+ id: 'global',
76
+ tenant_id: null,
77
+ organization_id: null,
78
+ created_at: '2026-01-01T00:00:00.000Z',
79
+ fields_json: '[{"field":"global_secret"}]',
80
+ },
81
+ {
82
+ id: 'tenant',
83
+ tenant_id: 'tenant-1',
84
+ organization_id: null,
85
+ created_at: '2026-01-02T00:00:00.000Z',
86
+ fields_json: [{ field: 'tenant_secret' }],
87
+ },
88
+ ]])
89
+
90
+ await expect(createEncryptionMapResolver(db as never)('demo:item')).resolves.toEqual([
91
+ { field: 'global_secret', hashField: null },
92
+ { field: 'tenant_secret', hashField: null },
93
+ { field: 'organization_secret', hashField: null },
94
+ ])
95
+ expect(query.where).toHaveBeenCalledWith('is_active', '=', true)
96
+ expect(query.where).toHaveBeenCalledWith('deleted_at', 'is', null)
97
+ expect(query.orderBy).toHaveBeenCalledTimes(4)
98
+ })
99
+
100
+ it('fails closed on lookup errors and retries instead of caching an empty policy', async () => {
101
+ const { db, query } = createDatabase([
102
+ new Error('database unavailable'),
103
+ [{
104
+ id: 'recovered',
105
+ tenant_id: null,
106
+ organization_id: null,
107
+ created_at: '2026-01-01T00:00:00.000Z',
108
+ fields_json: [{ field: 'secret' }],
109
+ }],
110
+ ])
111
+ const resolveMap = createEncryptionMapResolver(db as never)
112
+
113
+ await expect(resolveMap('demo:item')).rejects.toThrow('database unavailable')
114
+ await expect(resolveMap('demo:item')).resolves.toEqual([{ field: 'secret', hashField: null }])
115
+ expect(query.execute).toHaveBeenCalledTimes(2)
116
+ })
117
+
118
+ it('does not retain a successful empty lookup for five minutes', async () => {
119
+ const entityId = 'demo:item'
120
+ const { db, query } = createDatabase([
121
+ [],
122
+ [{
123
+ id: 'created-later',
124
+ tenant_id: 'tenant-1',
125
+ organization_id: null,
126
+ created_at: '2026-01-01T00:00:00.000Z',
127
+ fields_json: [{ field: 'new_secret' }],
128
+ }],
129
+ [{
130
+ id: 'created-later',
131
+ tenant_id: 'tenant-1',
132
+ organization_id: null,
133
+ created_at: '2026-01-01T00:00:00.000Z',
134
+ fields_json: [{ field: 'new_secret' }],
135
+ }],
136
+ ])
137
+ const { cache } = createPolicyVersionCache(entityId)
138
+ const resolveMap = createEncryptionMapResolver(db as never, cache)
139
+
140
+ await expect(resolveMap(entityId)).resolves.toEqual([])
141
+ await expect(resolveMap(entityId)).resolves.toEqual([{ field: 'new_secret', hashField: null }])
142
+ await expect(resolveMap(entityId)).resolves.toEqual([{ field: 'new_secret', hashField: null }])
143
+ expect(query.execute).toHaveBeenCalledTimes(3)
144
+ expect(cache.get).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('re-reads a positive policy when a protected field is added without a published version', async () => {
148
+ const entityId = 'demo:versioned_policy'
149
+ const { db, query } = createDatabase([
150
+ [{
151
+ id: 'existing',
152
+ tenant_id: 'tenant-1',
153
+ organization_id: 'org-1',
154
+ created_at: '2026-01-01T00:00:00.000Z',
155
+ fields_json: [{ field: 'old_secret' }],
156
+ }],
157
+ [{
158
+ id: 'existing',
159
+ tenant_id: 'tenant-1',
160
+ organization_id: 'org-1',
161
+ created_at: '2026-01-01T00:00:00.000Z',
162
+ fields_json: [{ field: 'old_secret' }, { field: 'new_secret' }],
163
+ }],
164
+ ])
165
+ const { cache } = createPolicyVersionCache(entityId)
166
+ const resolveMap = createEncryptionMapResolver(db as never, cache)
167
+
168
+ await expect(resolveMap(entityId)).resolves.toEqual([
169
+ { field: 'old_secret', hashField: null },
170
+ ])
171
+ await expect(resolveMap(entityId)).resolves.toEqual([
172
+ { field: 'old_secret', hashField: null },
173
+ { field: 'new_secret', hashField: null },
174
+ ])
175
+ expect(query.execute).toHaveBeenCalledTimes(2)
176
+ expect(cache.get).not.toHaveBeenCalled()
177
+ })
178
+
179
+ it('re-reads canonical policy while the legacy version cache is unavailable', async () => {
180
+ const { db, query } = createDatabase([[
181
+ {
182
+ id: 'current',
183
+ tenant_id: null,
184
+ organization_id: null,
185
+ created_at: '2026-01-01T00:00:00.000Z',
186
+ fields_json: [{ field: 'protected' }],
187
+ },
188
+ ]])
189
+ const cache = {
190
+ get: jest.fn(async () => { throw new Error('cache unavailable') }),
191
+ }
192
+ const resolveMap = createEncryptionMapResolver(db as never, cache)
193
+
194
+ await expect(resolveMap('demo:cache_outage')).resolves.toEqual([
195
+ { field: 'protected', hashField: null },
196
+ ])
197
+ expect(query.execute).toHaveBeenCalledTimes(1)
198
+ expect(cache.get).not.toHaveBeenCalled()
199
+ })
200
+ })
@@ -0,0 +1,108 @@
1
+ import { fork, type ChildProcess } from 'node:child_process'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ type WorkerResponse = {
7
+ id?: number
8
+ ready?: boolean
9
+ ok?: boolean
10
+ value?: unknown
11
+ }
12
+
13
+ function waitForMessage(worker: ChildProcess, predicate: (message: WorkerResponse) => boolean): Promise<WorkerResponse> {
14
+ return new Promise((resolve, reject) => {
15
+ const onMessage = (message: WorkerResponse) => {
16
+ if (!predicate(message)) return
17
+ cleanup()
18
+ resolve(message)
19
+ }
20
+ const onExit = (code: number | null) => {
21
+ cleanup()
22
+ reject(new Error(`[internal] Search policy worker exited with code ${String(code)}`))
23
+ }
24
+ const cleanup = () => {
25
+ worker.off('message', onMessage)
26
+ worker.off('exit', onExit)
27
+ }
28
+ worker.on('message', onMessage)
29
+ worker.on('exit', onExit)
30
+ })
31
+ }
32
+
33
+ async function startWorker(statePath: string, cacheStatePath: string): Promise<ChildProcess> {
34
+ const worker = fork(join(__dirname, 'fixtures/encryption-policy-process-worker.ts'), [], {
35
+ execArgv: ['--import', require.resolve('tsx')],
36
+ env: {
37
+ ...process.env,
38
+ SEARCH_POLICY_STATE_PATH: statePath,
39
+ SEARCH_POLICY_CACHE_STATE_PATH: cacheStatePath,
40
+ },
41
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
42
+ })
43
+ await waitForMessage(worker, (message) => message.ready === true)
44
+ return worker
45
+ }
46
+
47
+ let commandId = 0
48
+
49
+ async function resolvePolicy(worker: ChildProcess, entityId: string) {
50
+ const id = ++commandId
51
+ const response = waitForMessage(worker, (message) => message.id === id)
52
+ worker.send({ id, entityId })
53
+ return response
54
+ }
55
+
56
+ describe('Search encryption policy coherence across processes', () => {
57
+ it('re-reads positive policy after mutation, failed publication, and cache recovery', async () => {
58
+ const directory = await mkdtemp(join(tmpdir(), 'om-search-policy-'))
59
+ const statePath = join(directory, 'policy.json')
60
+ const cacheStatePath = join(directory, 'cache.json')
61
+ const entityId = 'demo:process_policy'
62
+ let worker: ChildProcess | undefined
63
+ try {
64
+ await writeFile(statePath, JSON.stringify([{
65
+ id: 'map',
66
+ tenant_id: 'tenant-1',
67
+ organization_id: 'org-1',
68
+ created_at: '2026-01-01T00:00:00.000Z',
69
+ fields_json: [{ field: 'old_secret' }],
70
+ }]))
71
+ await writeFile(cacheStatePath, JSON.stringify({ available: true, version: 'version-1' }))
72
+ worker = await startWorker(statePath, cacheStatePath)
73
+
74
+ await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
75
+ ok: true,
76
+ value: [{ field: 'old_secret', hashField: null }],
77
+ })
78
+
79
+ await writeFile(statePath, JSON.stringify([{
80
+ id: 'map',
81
+ tenant_id: 'tenant-1',
82
+ organization_id: 'org-1',
83
+ created_at: '2026-01-01T00:00:00.000Z',
84
+ fields_json: [{ field: 'old_secret' }, { field: 'new_secret' }],
85
+ }]))
86
+ await writeFile(cacheStatePath, JSON.stringify({ available: false, version: 'version-1' }))
87
+ await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
88
+ ok: true,
89
+ value: [
90
+ { field: 'old_secret', hashField: null },
91
+ { field: 'new_secret', hashField: null },
92
+ ],
93
+ })
94
+
95
+ await writeFile(cacheStatePath, JSON.stringify({ available: true, version: 'version-2' }))
96
+ await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
97
+ ok: true,
98
+ value: [
99
+ { field: 'old_secret', hashField: null },
100
+ { field: 'new_secret', hashField: null },
101
+ ],
102
+ })
103
+ } finally {
104
+ worker?.kill()
105
+ await rm(directory, { recursive: true, force: true })
106
+ }
107
+ })
108
+ })
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createEncryptionMapResolver } from '../../lib/encryption-map-resolver'
3
+
4
+ type WorkerCommand = {
5
+ id: number
6
+ entityId: string
7
+ }
8
+
9
+ const statePath = process.env.SEARCH_POLICY_STATE_PATH
10
+ const cacheStatePath = process.env.SEARCH_POLICY_CACHE_STATE_PATH
11
+
12
+ if (!statePath || !cacheStatePath) {
13
+ throw new Error('[internal] Search policy fixture paths are required')
14
+ }
15
+
16
+ const query: Record<string, unknown> = {}
17
+ for (const method of ['select', 'where', 'orderBy']) {
18
+ query[method] = () => query
19
+ }
20
+ query.execute = async () => JSON.parse(await readFile(statePath, 'utf8')) as unknown[]
21
+
22
+ const legacyPolicyVersionCache = {
23
+ get: async () => {
24
+ const state = JSON.parse(await readFile(cacheStatePath, 'utf8')) as {
25
+ available: boolean
26
+ version: string
27
+ }
28
+ if (!state.available) throw new Error('policy version cache unavailable')
29
+ return state.version
30
+ },
31
+ }
32
+
33
+ const resolveMap = createEncryptionMapResolver(
34
+ { selectFrom: () => query } as never,
35
+ legacyPolicyVersionCache,
36
+ )
37
+
38
+ process.on('message', async (command: WorkerCommand) => {
39
+ try {
40
+ const value = await resolveMap(command.entityId as never)
41
+ process.send?.({ id: command.id, ok: true, value })
42
+ } catch (error) {
43
+ process.send?.({
44
+ id: command.id,
45
+ ok: false,
46
+ error: error instanceof Error ? error.message : String(error),
47
+ })
48
+ }
49
+ })
50
+
51
+ process.send?.({ ready: true })
package/src/di.ts CHANGED
@@ -25,6 +25,9 @@ import type { VectorIndexJobPayload } from './queue/vector-indexing'
25
25
  import type { EncryptionMapEntry } from './lib/field-policy'
26
26
  import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
27
27
  import { createPresenterEnricher } from './lib/presenter-enricher'
28
+ import { createEncryptionMapResolver } from './lib/encryption-map-resolver'
29
+
30
+ export { createEncryptionMapResolver } from './lib/encryption-map-resolver'
28
31
 
29
32
  const FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'
30
33
 
@@ -46,46 +49,9 @@ function shouldExcludeEncryptedFields(): boolean {
46
49
  return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'
47
50
  }
48
51
 
49
- /**
50
- * Create an encryption map resolver that queries the database.
51
- * Falls back to empty array if query fails.
52
- */
53
- function createEncryptionMapResolver(
54
- db: Kysely<any>,
55
- ): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
56
- // Cache encryption maps per entity to avoid repeated queries
57
- const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()
58
- const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
59
-
60
- return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {
61
- const cached = cache.get(entityId)
62
- if (cached && cached.expiresAt > Date.now()) {
63
- return cached.entries
64
- }
65
-
66
- try {
67
- const row = await db
68
- .selectFrom('encryption_maps' as any)
69
- .select(['fields_json' as any])
70
- .where('entity_id' as any, '=', entityId)
71
- .where('is_active' as any, '=', true)
72
- .where('deleted_at' as any, 'is', null)
73
- .executeTakeFirst() as { fields_json?: unknown } | undefined
74
-
75
- const fieldsJson = row?.fields_json
76
- const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)
77
- ? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({
78
- field: f.field,
79
- hashField: f.hashField ?? null,
80
- }))
81
- : []
82
-
83
- cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })
84
- return entries
85
- } catch {
86
- // Query failed, return empty array (don't exclude any fields)
87
- return []
88
- }
52
+ function createUnavailableEncryptionMapResolver(): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
53
+ return async () => {
54
+ throw new Error('[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields')
89
55
  }
90
56
  }
91
57
 
@@ -193,7 +159,7 @@ export function registerSearchModule(
193
159
  const db = em.getKysely() as Kysely<any>
194
160
  encryptionMapResolver = createEncryptionMapResolver(db)
195
161
  } catch {
196
- // Kysely not available, encrypted field filtering disabled
162
+ encryptionMapResolver = createUnavailableEncryptionMapResolver()
197
163
  }
198
164
  }
199
165
 
@@ -0,0 +1,110 @@
1
+ import type { Kysely } from 'kysely'
2
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
3
+ import type { EncryptionMapEntry } from './field-policy'
4
+
5
+ type EncryptionMapRow = {
6
+ id?: unknown
7
+ tenant_id?: unknown
8
+ organization_id?: unknown
9
+ fields_json?: unknown
10
+ created_at?: unknown
11
+ }
12
+
13
+ type LegacyEncryptionMapPolicyVersionCache = {
14
+ get(key: string): Promise<unknown | null>
15
+ }
16
+
17
+ function compareNullableScope(left: unknown, right: unknown): number {
18
+ if (left == null && right == null) return 0
19
+ if (left == null) return -1
20
+ if (right == null) return 1
21
+ return String(left).localeCompare(String(right))
22
+ }
23
+
24
+ function compareCreatedAt(left: unknown, right: unknown): number {
25
+ const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ''))
26
+ const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ''))
27
+ if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime
28
+ return String(left ?? '').localeCompare(String(right ?? ''))
29
+ }
30
+
31
+ function compareEncryptionMapRows(left: EncryptionMapRow, right: EncryptionMapRow): number {
32
+ return compareNullableScope(left.tenant_id, right.tenant_id)
33
+ || compareNullableScope(left.organization_id, right.organization_id)
34
+ || compareCreatedAt(left.created_at, right.created_at)
35
+ || String(left.id ?? '').localeCompare(String(right.id ?? ''))
36
+ }
37
+
38
+ function parseEncryptionMapFields(value: unknown): unknown[] {
39
+ if (Array.isArray(value)) return value
40
+ if (typeof value !== 'string') return []
41
+ try {
42
+ const parsed = JSON.parse(value) as unknown
43
+ return Array.isArray(parsed) ? parsed : []
44
+ } catch {
45
+ return []
46
+ }
47
+ }
48
+
49
+ function mergeEncryptionMapEntries(rows: readonly EncryptionMapRow[]): EncryptionMapEntry[] {
50
+ const merged: EncryptionMapEntry[] = []
51
+ const byField = new Map<string, EncryptionMapEntry>()
52
+ for (const row of [...rows].sort(compareEncryptionMapRows)) {
53
+ for (const candidate of parseEncryptionMapFields(row.fields_json)) {
54
+ if (!candidate || typeof candidate !== 'object') continue
55
+ const rule = candidate as { field?: unknown; hashField?: unknown }
56
+ const field = typeof rule.field === 'string' ? rule.field.trim() : ''
57
+ if (!field) continue
58
+ const hashField = typeof rule.hashField === 'string' && rule.hashField.length > 0
59
+ ? rule.hashField
60
+ : null
61
+ const existing = byField.get(field)
62
+ if (!existing) {
63
+ const entry = { field, hashField }
64
+ byField.set(field, entry)
65
+ merged.push(entry)
66
+ } else if (!existing.hashField && hashField) {
67
+ existing.hashField = hashField
68
+ }
69
+ }
70
+ }
71
+ return merged
72
+ }
73
+
74
+ /**
75
+ * Resolve every active live declaration for an entity. Search indexing has no
76
+ * tenant/scope argument at this boundary, so the safe policy is the union of
77
+ * all scopes: over-excluding a field is preferable to publishing plaintext.
78
+ *
79
+ * The legacy version-cache argument remains optional for call-site compatibility but is
80
+ * intentionally ignored. A version publication can fail after the database commit, so neither
81
+ * that channel nor process-local positive memory can authorize reuse of an older policy.
82
+ */
83
+ export function createEncryptionMapResolver(
84
+ db: Kysely<any>,
85
+ legacyPolicyVersionCache?: LegacyEncryptionMapPolicyVersionCache,
86
+ ): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
87
+ void legacyPolicyVersionCache
88
+
89
+ return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {
90
+ const rows = await db
91
+ .selectFrom('encryption_maps' as any)
92
+ .select([
93
+ 'id' as any,
94
+ 'tenant_id' as any,
95
+ 'organization_id' as any,
96
+ 'fields_json' as any,
97
+ 'created_at' as any,
98
+ ])
99
+ .where('entity_id' as any, '=', entityId)
100
+ .where('is_active' as any, '=', true)
101
+ .where('deleted_at' as any, 'is', null)
102
+ .orderBy('tenant_id' as any, 'asc')
103
+ .orderBy('organization_id' as any, 'asc')
104
+ .orderBy('created_at' as any, 'asc')
105
+ .orderBy('id' as any, 'asc')
106
+ .execute() as EncryptionMapRow[]
107
+
108
+ return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : [])
109
+ }
110
+ }
@@ -0,0 +1,53 @@
1
+ jest.mock('@open-mercato/shared/lib/query/engine', () => ({
2
+ resolveEntityTableName: jest.fn(() => 'feature_toggles'),
3
+ }))
4
+ jest.mock('../../lib/auto-indexing', () => ({
5
+ resolveAutoIndexingEnabled: jest.fn().mockResolvedValue(false),
6
+ }))
7
+
8
+ import vectorUpsert from '../vector_upsert'
9
+ import vectorDelete from '../vector_delete'
10
+ import fulltextUpsert from '../fulltext_upsert'
11
+
12
+ /**
13
+ * The unscoped-record lookup reads `organization_id`/`tenant_id` from the
14
+ * entity table and swallows failures (global tables such as `feature_toggles`
15
+ * lack those columns). Running it on the shared request EntityManager let the
16
+ * failed statement land inside whatever write transaction that EM currently
17
+ * held (e.g. the audit redo finalization flush), aborting it. The lookup must
18
+ * run on a fork so a swallowed error can never poison a caller's transaction.
19
+ */
20
+ function buildContext() {
21
+ const executeTakeFirst = jest.fn().mockRejectedValue(new Error('column "organization_id" does not exist'))
22
+ const where = jest.fn(() => ({ executeTakeFirst }))
23
+ const select = jest.fn(() => ({ where }))
24
+ const forkKysely = { selectFrom: jest.fn(() => ({ select })) }
25
+ const forkEm = { getKysely: jest.fn(() => forkKysely) }
26
+ const requestEm = { getKysely: jest.fn(), fork: jest.fn(() => forkEm) }
27
+ const ctx = {
28
+ resolve: jest.fn((name: string) => {
29
+ if (name === 'em') return requestEm
30
+ throw new Error(`[internal] not registered: ${name}`)
31
+ }),
32
+ }
33
+ return { ctx, requestEm, forkEm, executeTakeFirst }
34
+ }
35
+
36
+ type SubscriberHandler = (payload: Record<string, unknown>, ctx: unknown) => Promise<void>
37
+
38
+ describe('search subscriber scope lookup isolation', () => {
39
+ it.each<[string, SubscriberHandler, Record<string, unknown>]>([
40
+ ['query_index.vectorize_one', vectorUpsert as SubscriberHandler, { entityType: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
41
+ ['query_index.delete_one', vectorDelete as SubscriberHandler, { entityType: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
42
+ ['search.index_record', fulltextUpsert as SubscriberHandler, { entityId: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
43
+ ])('%s never runs the unscoped lookup on the request EntityManager', async (_event, handler, payload) => {
44
+ const { ctx, requestEm, forkEm, executeTakeFirst } = buildContext()
45
+
46
+ await expect(handler(payload, ctx)).resolves.toBeUndefined()
47
+
48
+ expect(requestEm.getKysely).not.toHaveBeenCalled()
49
+ expect(requestEm.fork).toHaveBeenCalledTimes(1)
50
+ expect(forkEm.getKysely).toHaveBeenCalledTimes(1)
51
+ expect(executeTakeFirst).toHaveBeenCalledTimes(1)
52
+ })
53
+ })
@@ -37,7 +37,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
37
37
  // Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)
38
38
  if ((organizationId == null || tenantId == null) && em) {
39
39
  try {
40
- const db = em.getKysely()
40
+ const lookupEm = typeof em.fork === 'function' ? em.fork() : em
41
+ const db = lookupEm.getKysely()
41
42
  const table = resolveEntityTableName(em, entityType)
42
43
  const row = await db
43
44
  .selectFrom(table as any)
@@ -33,7 +33,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
33
33
 
34
34
  if ((organizationId == null || tenantId == null) && em) {
35
35
  try {
36
- const db = em.getKysely()
36
+ const lookupEm = typeof em.fork === 'function' ? em.fork() : em
37
+ const db = lookupEm.getKysely()
37
38
  const table = resolveEntityTableName(em, entityType)
38
39
  const row = await db
39
40
  .selectFrom(table as any)
@@ -33,7 +33,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
33
33
 
34
34
  if ((organizationId == null || tenantId == null) && em) {
35
35
  try {
36
- const db = em.getKysely()
36
+ const lookupEm = typeof em.fork === 'function' ? em.fork() : em
37
+ const db = lookupEm.getKysely()
37
38
  const table = resolveEntityTableName(em, entityType)
38
39
  const row = await db
39
40
  .selectFrom(table as any)