@open-mercato/shared 0.9.1-develop.7473.1.de5b22888f → 0.9.1-develop.7475.1.28ca93ed97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +4 -0
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/localization/index.js +9 -1
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +26 -0
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/query/engine.js +29 -2
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/overlap.js +58 -0
- package/dist/lib/query/overlap.js.map +7 -0
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/search.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
- package/src/lib/catalog-visibility/index.ts +2 -0
- package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
- package/src/lib/localization/index.ts +8 -1
- package/src/lib/localization/translatable-fields.ts +46 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +33 -2
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/overlap.ts +106 -0
- package/src/lib/query/types.ts +13 -1
- package/src/modules/search.ts +39 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "kysely";
|
|
2
|
-
const VALID_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "exists"];
|
|
2
|
+
const VALID_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike", "exists", "overlap", "noverlap"];
|
|
3
3
|
const normalizeField = (key) => key.startsWith("cf_") ? `cf:${key.slice(3)}` : key;
|
|
4
4
|
const MAX_DNF_DISJUNCTS = 1e3;
|
|
5
5
|
class AdvancedFilterComplexityError extends Error {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/query/join-utils.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport { sql } from 'kysely'\nimport type { QueryOptions, QueryJoinEdge } from './types'\nimport type { FilterOp } from './types'\n\ntype AnyBuilder = any\n\nexport type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }\n\nconst VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const\n\nconst normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)\n\ntype LeafClause = { field: string; op: FilterOp; value?: unknown }\n\n/**\n * Cap on DNF disjunct count to prevent adversarial / pathological filters from\n * exploding the in-memory normalization. `validateTreeLimits` already caps\n * editor input at 50 rules / 15 children per group / 3 levels of nesting, but\n * a hand-crafted URL or buggy preset could still bypass that. The cap mirrors\n * the editor's posture: fail loud, refuse to compile.\n */\nexport const MAX_DNF_DISJUNCTS = 1000\n\nexport class AdvancedFilterComplexityError extends Error {\n readonly code = 'ADVANCED_FILTER_TOO_COMPLEX'\n readonly disjunctCount: number\n constructor(disjunctCount: number) {\n super(\n `Advanced filter expanded to ${disjunctCount} disjuncts, exceeding the limit of ${MAX_DNF_DISJUNCTS}. ` +\n `Reduce the number of OR branches or simplify nested groups.`,\n )\n this.name = 'AdvancedFilterComplexityError'\n this.disjunctCount = disjunctCount\n }\n}\n\n/**\n * Expand a Mongo-style filter object (with arbitrary nesting of `$and`/`$or` and\n * implicit AND between sibling keys) into Disjunctive Normal Form: `Clause[][]`,\n * where the outer array is OR'd alternatives and each inner array is the AND'd\n * leaves of one alternative.\n *\n * Empty filter -> `[[]]` (one empty disjunct, treated as \"no constraint\").\n * Throws `AdvancedFilterComplexityError` if any intermediate cartesian product\n * exceeds `MAX_DNF_DISJUNCTS`.\n */\nfunction compileToDnf(filter: unknown): LeafClause[][] {\n if (filter === null || filter === undefined) return [[]]\n if (Array.isArray(filter) || typeof filter !== 'object') return [[]]\n const obj = filter as Record<string, unknown>\n const partialDnfs: LeafClause[][][] = []\n\n for (const [rawKey, rawVal] of Object.entries(obj)) {\n if (rawKey === '$and' && Array.isArray(rawVal)) {\n partialDnfs.push(cartesianAnd((rawVal as unknown[]).map(compileToDnf)))\n continue\n }\n if (rawKey === '$or' && Array.isArray(rawVal)) {\n const expanded = (rawVal as unknown[]).flatMap(compileToDnf)\n if (expanded.length > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(expanded.length)\n }\n partialDnfs.push(expanded)\n continue\n }\n const field = normalizeField(rawKey)\n if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {\n const clauses: LeafClause[] = []\n for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {\n const op = (opKey.startsWith('$') ? opKey.slice(1) : opKey) as FilterOp\n if (VALID_OPS.includes(op as (typeof VALID_OPS)[number])) {\n clauses.push({ field, op, value: opVal })\n }\n }\n if (clauses.length > 0) partialDnfs.push([clauses])\n } else {\n partialDnfs.push([[{ field, op: 'eq' as FilterOp, value: rawVal }]])\n }\n }\n\n return cartesianAnd(partialDnfs)\n}\n\nfunction cartesianAnd(dnfs: LeafClause[][][]): LeafClause[][] {\n if (dnfs.length === 0) return [[]]\n let result: LeafClause[][] = [[]]\n for (const dnf of dnfs) {\n if (dnf.length === 0) {\n // An empty DNF means an unsatisfiable subexpression; the AND becomes unsatisfiable\n // too. Represent that as an empty outer array (no disjuncts).\n return []\n }\n // Predict the next size to fail loud before allocating millions of arrays.\n const projected = result.length * dnf.length\n if (projected > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(projected)\n }\n const next: LeafClause[][] = []\n for (const left of result) {\n for (const right of dnf) {\n next.push([...left, ...right])\n }\n }\n result = next\n }\n return result\n}\n\nfunction clauseKey(c: LeafClause): string {\n return JSON.stringify([c.field, c.op, c.value])\n}\n\n/**\n * Lift clauses that appear in every disjunct into a \"common\" set so the engine\n * can treat them as regular ANDed base filters (preserving the search-tokens\n * optimization for like/ilike clauses) instead of duplicating them across OR\n * groups.\n */\nfunction liftCommonClauses(dnf: LeafClause[][]): { common: LeafClause[]; remaining: LeafClause[][] } {\n if (dnf.length <= 1) return { common: [], remaining: dnf }\n const counts = new Map<string, { clause: LeafClause; count: number }>()\n for (const disjunct of dnf) {\n const seen = new Set<string>()\n for (const c of disjunct) {\n const key = clauseKey(c)\n if (seen.has(key)) continue // count once per disjunct even if duplicated\n seen.add(key)\n const entry = counts.get(key)\n if (entry) entry.count += 1\n else counts.set(key, { clause: c, count: 1 })\n }\n }\n const total = dnf.length\n const commonKeys = new Set<string>()\n const common: LeafClause[] = []\n for (const [key, { clause, count }] of counts) {\n if (count === total) {\n commonKeys.add(key)\n common.push(clause)\n }\n }\n if (common.length === 0) return { common: [], remaining: dnf }\n const remaining = dnf.map((d) => d.filter((c) => !commonKeys.has(clauseKey(c))))\n return { common, remaining }\n}\n\nexport function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] {\n if (!filters) return []\n if (Array.isArray(filters)) {\n return (filters as any[]).map((f) => ({\n ...f,\n field: normalizeField(String((f as any).field)),\n }))\n }\n const dnf = compileToDnf(filters)\n if (dnf.length === 0) return []\n const nonEmpty = dnf.filter((disjunct) => disjunct.length > 0)\n if (nonEmpty.length === 0) return []\n if (nonEmpty.length === 1) {\n return nonEmpty[0].map((c) => ({ field: c.field, op: c.op, value: c.value }))\n }\n const { common, remaining } = liftCommonClauses(nonEmpty)\n const out: NormalizedFilter[] = []\n for (const c of common) out.push({ field: c.field, op: c.op, value: c.value })\n // Drop disjuncts that became empty after lifting (they're already represented in `common`).\n const remainingNonEmpty = remaining.filter((d) => d.length > 0)\n if (remainingNonEmpty.length === 0) return out\n if (remainingNonEmpty.length === 1) {\n for (const c of remainingNonEmpty[0]) out.push({ field: c.field, op: c.op, value: c.value })\n return out\n }\n for (let i = 0; i < remainingNonEmpty.length; i++) {\n for (const c of remainingNonEmpty[i]) {\n out.push({ field: c.field, op: c.op, value: c.value, orGroup: `or_${i}` })\n }\n }\n return out\n}\n\nexport type ResolvedJoin = {\n alias: string\n table: string\n entityId?: string | null\n fromAlias: string\n fromField: string\n toField: string\n type: 'left' | 'inner'\n}\n\nexport type BaseFilter = NormalizedFilter & { qualified?: string | null }\nexport type JoinFilter = { alias: string; column: string; op: FilterOp; value?: unknown }\n\nexport function resolveJoins(\n baseTable: string,\n joins: QueryJoinEdge[] | null | undefined,\n resolveTable: (entityId: string) => string | null,\n): ResolvedJoin[] {\n if (!joins || joins.length === 0) return []\n const resolved: ResolvedJoin[] = []\n const seen = new Set<string>()\n for (const entry of joins) {\n if (!entry || typeof entry !== 'object') continue\n const alias = typeof entry.alias === 'string' ? entry.alias.trim() : ''\n if (!alias) continue\n if (seen.has(alias)) continue\n const table =\n entry.table ??\n (entry.entityId ? resolveTable(String(entry.entityId)) : null)\n if (!table) continue\n const fromField = entry.from?.field?.trim()\n const toField = entry.to?.field?.trim()\n if (!fromField || !toField) continue\n const fromAliasRaw = entry.from?.alias?.trim()\n const fromAlias = fromAliasRaw && fromAliasRaw.length > 0 ? fromAliasRaw : 'base'\n const type: 'left' | 'inner' = entry.type === 'inner' ? 'inner' : 'left'\n resolved.push({\n alias,\n table,\n entityId: entry.entityId ? String(entry.entityId) : null,\n fromAlias,\n fromField,\n toField,\n type,\n })\n seen.add(alias)\n }\n return resolved\n}\n\nexport function buildJoinChain(\n alias: string,\n joinMap: Map<string, ResolvedJoin>,\n baseTable: string,\n visited: Set<string> = new Set(),\n): ResolvedJoin[] {\n if (visited.has(alias)) {\n throw new Error(`QueryEngine: circular join reference detected for alias ${alias}`)\n }\n const cfg = joinMap.get(alias)\n if (!cfg) return []\n visited.add(alias)\n if (!cfg.fromAlias || cfg.fromAlias === 'base' || cfg.fromAlias === baseTable) {\n return [cfg]\n }\n const parentChain = buildJoinChain(cfg.fromAlias, joinMap, baseTable, visited)\n if (parentChain.length === 0) return []\n return [...parentChain, cfg]\n}\n\nexport function partitionFilters(\n baseTable: string,\n filters: NormalizedFilter[],\n joinMap: Map<string, ResolvedJoin>,\n): { baseFilters: BaseFilter[]; joinFilters: Map<string, JoinFilter[]> } {\n const baseFilters: BaseFilter[] = []\n const joinFilters = new Map<string, JoinFilter[]>()\n for (const filter of filters) {\n const field = String(filter.field)\n if (field.startsWith('cf:')) continue\n const parts = field.split('.')\n if (parts.length === 2) {\n const [aliasNameRaw, column] = parts\n const aliasName = aliasNameRaw || ''\n if (joinMap.has(aliasName)) {\n const list = joinFilters.get(aliasName) ?? []\n list.push({ alias: aliasName, column, op: filter.op, value: filter.value })\n joinFilters.set(aliasName, list)\n continue\n }\n if (aliasName === baseTable || aliasName === 'base') {\n baseFilters.push({\n field: column,\n op: filter.op,\n value: filter.value,\n qualified: `${baseTable}.${column}`,\n })\n continue\n }\n }\n baseFilters.push({ ...filter })\n }\n return { baseFilters, joinFilters }\n}\n\ntype ApplyJoinFiltersOptions = {\n db: Kysely<any>\n baseTable: string\n builder: AnyBuilder\n joinMap: Map<string, ResolvedJoin>\n joinFilters: Map<string, JoinFilter[]>\n aliasTables: Map<string, string>\n qualifyBase: (column: string) => string\n applyAliasScope: (builder: AnyBuilder, alias: string, table: string) => Promise<AnyBuilder> | AnyBuilder\n applyFilterOp: (builder: AnyBuilder, column: string, op: FilterOp, value?: unknown) => AnyBuilder\n applyJoinFilterOp?: (builder: AnyBuilder, filter: JoinFilter, qualified: string, join: ResolvedJoin, table: string) => Promise<{ applied: boolean; builder: AnyBuilder }> | { applied: boolean; builder: AnyBuilder }\n columnExists?: (table: string, column: string) => Promise<boolean> | boolean\n}\n\nexport async function applyJoinFilters({\n db,\n baseTable,\n builder,\n joinMap,\n joinFilters,\n aliasTables,\n qualifyBase,\n applyAliasScope,\n applyFilterOp,\n applyJoinFilterOp,\n columnExists,\n}: ApplyJoinFiltersOptions): Promise<AnyBuilder> {\n const resolveAliasName = (aliasName?: string | null) => {\n if (!aliasName || aliasName === 'base') return baseTable\n return aliasName\n }\n\n let nextBuilder = builder\n for (const [alias, filtersForAlias] of joinFilters.entries()) {\n const chain = buildJoinChain(alias, joinMap, baseTable)\n if (!chain.length) continue\n const first = chain[0]\n let sub: AnyBuilder = db.selectFrom(`${first.table} as ${first.alias}` as any).select(sql`1`.as('one'))\n sub = await applyAliasScope(sub, first.alias, first.table)\n const parentAlias = resolveAliasName(first.fromAlias)\n const parentRef = parentAlias === baseTable ? qualifyBase(first.fromField) : `${parentAlias}.${first.fromField}`\n sub = sub.whereRef(`${first.alias}.${first.toField}`, '=', parentRef)\n for (const cfg of chain.slice(1)) {\n const parent = resolveAliasName(cfg.fromAlias)\n const rightRef = parent === baseTable ? qualifyBase(cfg.fromField) : `${parent}.${cfg.fromField}`\n const joinFn = cfg.type === 'inner' ? 'innerJoin' : 'leftJoin'\n sub = (sub as any)[joinFn](`${cfg.table} as ${cfg.alias}`, (jb: any) =>\n jb.onRef(`${cfg.alias}.${cfg.toField}`, '=', rightRef))\n sub = await applyAliasScope(sub, cfg.alias, cfg.table)\n }\n let existsDirective: boolean | null = null\n for (const filter of filtersForAlias) {\n if (filter.op === 'exists') {\n if (filter.value === false) existsDirective = false\n else if (existsDirective === null) existsDirective = true\n continue\n }\n const join = joinMap.get(filter.alias)\n if (!join) continue\n const targetTable = aliasTables.get(filter.alias)\n if (!targetTable) continue\n if (columnExists) {\n const exists = await columnExists(targetTable, filter.column)\n if (!exists) continue\n }\n const qualified = `${filter.alias}.${filter.column}`\n if (applyJoinFilterOp) {\n const result = await applyJoinFilterOp(sub, filter, qualified, join, targetTable)\n if (result && result.applied) {\n sub = result.builder\n continue\n }\n if (result && result.builder) {\n sub = result.builder\n }\n }\n sub = applyFilterOp(sub, qualified, filter.op, filter.value)\n }\n if (existsDirective === false) {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.not(eb.exists(capturedSub)))\n } else {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.exists(capturedSub))\n }\n }\n return nextBuilder\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,WAAW;AAQpB,MAAM,YAAY,CAAC,MAAM,MAAM,MAAM,OAAO,MAAM,OAAO,MAAM,OAAO,QAAQ,SAAS,
|
|
4
|
+
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport { sql } from 'kysely'\nimport type { QueryOptions, QueryJoinEdge } from './types'\nimport type { FilterOp } from './types'\n\ntype AnyBuilder = any\n\nexport type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }\n\nconst VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists', 'overlap', 'noverlap'] as const\n\nconst normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)\n\ntype LeafClause = { field: string; op: FilterOp; value?: unknown }\n\n/**\n * Cap on DNF disjunct count to prevent adversarial / pathological filters from\n * exploding the in-memory normalization. `validateTreeLimits` already caps\n * editor input at 50 rules / 15 children per group / 3 levels of nesting, but\n * a hand-crafted URL or buggy preset could still bypass that. The cap mirrors\n * the editor's posture: fail loud, refuse to compile.\n */\nexport const MAX_DNF_DISJUNCTS = 1000\n\nexport class AdvancedFilterComplexityError extends Error {\n readonly code = 'ADVANCED_FILTER_TOO_COMPLEX'\n readonly disjunctCount: number\n constructor(disjunctCount: number) {\n super(\n `Advanced filter expanded to ${disjunctCount} disjuncts, exceeding the limit of ${MAX_DNF_DISJUNCTS}. ` +\n `Reduce the number of OR branches or simplify nested groups.`,\n )\n this.name = 'AdvancedFilterComplexityError'\n this.disjunctCount = disjunctCount\n }\n}\n\n/**\n * Expand a Mongo-style filter object (with arbitrary nesting of `$and`/`$or` and\n * implicit AND between sibling keys) into Disjunctive Normal Form: `Clause[][]`,\n * where the outer array is OR'd alternatives and each inner array is the AND'd\n * leaves of one alternative.\n *\n * Empty filter -> `[[]]` (one empty disjunct, treated as \"no constraint\").\n * Throws `AdvancedFilterComplexityError` if any intermediate cartesian product\n * exceeds `MAX_DNF_DISJUNCTS`.\n */\nfunction compileToDnf(filter: unknown): LeafClause[][] {\n if (filter === null || filter === undefined) return [[]]\n if (Array.isArray(filter) || typeof filter !== 'object') return [[]]\n const obj = filter as Record<string, unknown>\n const partialDnfs: LeafClause[][][] = []\n\n for (const [rawKey, rawVal] of Object.entries(obj)) {\n if (rawKey === '$and' && Array.isArray(rawVal)) {\n partialDnfs.push(cartesianAnd((rawVal as unknown[]).map(compileToDnf)))\n continue\n }\n if (rawKey === '$or' && Array.isArray(rawVal)) {\n const expanded = (rawVal as unknown[]).flatMap(compileToDnf)\n if (expanded.length > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(expanded.length)\n }\n partialDnfs.push(expanded)\n continue\n }\n const field = normalizeField(rawKey)\n if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) {\n const clauses: LeafClause[] = []\n for (const [opKey, opVal] of Object.entries(rawVal as Record<string, unknown>)) {\n const op = (opKey.startsWith('$') ? opKey.slice(1) : opKey) as FilterOp\n if (VALID_OPS.includes(op as (typeof VALID_OPS)[number])) {\n clauses.push({ field, op, value: opVal })\n }\n }\n if (clauses.length > 0) partialDnfs.push([clauses])\n } else {\n partialDnfs.push([[{ field, op: 'eq' as FilterOp, value: rawVal }]])\n }\n }\n\n return cartesianAnd(partialDnfs)\n}\n\nfunction cartesianAnd(dnfs: LeafClause[][][]): LeafClause[][] {\n if (dnfs.length === 0) return [[]]\n let result: LeafClause[][] = [[]]\n for (const dnf of dnfs) {\n if (dnf.length === 0) {\n // An empty DNF means an unsatisfiable subexpression; the AND becomes unsatisfiable\n // too. Represent that as an empty outer array (no disjuncts).\n return []\n }\n // Predict the next size to fail loud before allocating millions of arrays.\n const projected = result.length * dnf.length\n if (projected > MAX_DNF_DISJUNCTS) {\n throw new AdvancedFilterComplexityError(projected)\n }\n const next: LeafClause[][] = []\n for (const left of result) {\n for (const right of dnf) {\n next.push([...left, ...right])\n }\n }\n result = next\n }\n return result\n}\n\nfunction clauseKey(c: LeafClause): string {\n return JSON.stringify([c.field, c.op, c.value])\n}\n\n/**\n * Lift clauses that appear in every disjunct into a \"common\" set so the engine\n * can treat them as regular ANDed base filters (preserving the search-tokens\n * optimization for like/ilike clauses) instead of duplicating them across OR\n * groups.\n */\nfunction liftCommonClauses(dnf: LeafClause[][]): { common: LeafClause[]; remaining: LeafClause[][] } {\n if (dnf.length <= 1) return { common: [], remaining: dnf }\n const counts = new Map<string, { clause: LeafClause; count: number }>()\n for (const disjunct of dnf) {\n const seen = new Set<string>()\n for (const c of disjunct) {\n const key = clauseKey(c)\n if (seen.has(key)) continue // count once per disjunct even if duplicated\n seen.add(key)\n const entry = counts.get(key)\n if (entry) entry.count += 1\n else counts.set(key, { clause: c, count: 1 })\n }\n }\n const total = dnf.length\n const commonKeys = new Set<string>()\n const common: LeafClause[] = []\n for (const [key, { clause, count }] of counts) {\n if (count === total) {\n commonKeys.add(key)\n common.push(clause)\n }\n }\n if (common.length === 0) return { common: [], remaining: dnf }\n const remaining = dnf.map((d) => d.filter((c) => !commonKeys.has(clauseKey(c))))\n return { common, remaining }\n}\n\nexport function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] {\n if (!filters) return []\n if (Array.isArray(filters)) {\n return (filters as any[]).map((f) => ({\n ...f,\n field: normalizeField(String((f as any).field)),\n }))\n }\n const dnf = compileToDnf(filters)\n if (dnf.length === 0) return []\n const nonEmpty = dnf.filter((disjunct) => disjunct.length > 0)\n if (nonEmpty.length === 0) return []\n if (nonEmpty.length === 1) {\n return nonEmpty[0].map((c) => ({ field: c.field, op: c.op, value: c.value }))\n }\n const { common, remaining } = liftCommonClauses(nonEmpty)\n const out: NormalizedFilter[] = []\n for (const c of common) out.push({ field: c.field, op: c.op, value: c.value })\n // Drop disjuncts that became empty after lifting (they're already represented in `common`).\n const remainingNonEmpty = remaining.filter((d) => d.length > 0)\n if (remainingNonEmpty.length === 0) return out\n if (remainingNonEmpty.length === 1) {\n for (const c of remainingNonEmpty[0]) out.push({ field: c.field, op: c.op, value: c.value })\n return out\n }\n for (let i = 0; i < remainingNonEmpty.length; i++) {\n for (const c of remainingNonEmpty[i]) {\n out.push({ field: c.field, op: c.op, value: c.value, orGroup: `or_${i}` })\n }\n }\n return out\n}\n\nexport type ResolvedJoin = {\n alias: string\n table: string\n entityId?: string | null\n fromAlias: string\n fromField: string\n toField: string\n type: 'left' | 'inner'\n}\n\nexport type BaseFilter = NormalizedFilter & { qualified?: string | null }\nexport type JoinFilter = { alias: string; column: string; op: FilterOp; value?: unknown }\n\nexport function resolveJoins(\n baseTable: string,\n joins: QueryJoinEdge[] | null | undefined,\n resolveTable: (entityId: string) => string | null,\n): ResolvedJoin[] {\n if (!joins || joins.length === 0) return []\n const resolved: ResolvedJoin[] = []\n const seen = new Set<string>()\n for (const entry of joins) {\n if (!entry || typeof entry !== 'object') continue\n const alias = typeof entry.alias === 'string' ? entry.alias.trim() : ''\n if (!alias) continue\n if (seen.has(alias)) continue\n const table =\n entry.table ??\n (entry.entityId ? resolveTable(String(entry.entityId)) : null)\n if (!table) continue\n const fromField = entry.from?.field?.trim()\n const toField = entry.to?.field?.trim()\n if (!fromField || !toField) continue\n const fromAliasRaw = entry.from?.alias?.trim()\n const fromAlias = fromAliasRaw && fromAliasRaw.length > 0 ? fromAliasRaw : 'base'\n const type: 'left' | 'inner' = entry.type === 'inner' ? 'inner' : 'left'\n resolved.push({\n alias,\n table,\n entityId: entry.entityId ? String(entry.entityId) : null,\n fromAlias,\n fromField,\n toField,\n type,\n })\n seen.add(alias)\n }\n return resolved\n}\n\nexport function buildJoinChain(\n alias: string,\n joinMap: Map<string, ResolvedJoin>,\n baseTable: string,\n visited: Set<string> = new Set(),\n): ResolvedJoin[] {\n if (visited.has(alias)) {\n throw new Error(`QueryEngine: circular join reference detected for alias ${alias}`)\n }\n const cfg = joinMap.get(alias)\n if (!cfg) return []\n visited.add(alias)\n if (!cfg.fromAlias || cfg.fromAlias === 'base' || cfg.fromAlias === baseTable) {\n return [cfg]\n }\n const parentChain = buildJoinChain(cfg.fromAlias, joinMap, baseTable, visited)\n if (parentChain.length === 0) return []\n return [...parentChain, cfg]\n}\n\nexport function partitionFilters(\n baseTable: string,\n filters: NormalizedFilter[],\n joinMap: Map<string, ResolvedJoin>,\n): { baseFilters: BaseFilter[]; joinFilters: Map<string, JoinFilter[]> } {\n const baseFilters: BaseFilter[] = []\n const joinFilters = new Map<string, JoinFilter[]>()\n for (const filter of filters) {\n const field = String(filter.field)\n if (field.startsWith('cf:')) continue\n const parts = field.split('.')\n if (parts.length === 2) {\n const [aliasNameRaw, column] = parts\n const aliasName = aliasNameRaw || ''\n if (joinMap.has(aliasName)) {\n const list = joinFilters.get(aliasName) ?? []\n list.push({ alias: aliasName, column, op: filter.op, value: filter.value })\n joinFilters.set(aliasName, list)\n continue\n }\n if (aliasName === baseTable || aliasName === 'base') {\n baseFilters.push({\n field: column,\n op: filter.op,\n value: filter.value,\n qualified: `${baseTable}.${column}`,\n })\n continue\n }\n }\n baseFilters.push({ ...filter })\n }\n return { baseFilters, joinFilters }\n}\n\ntype ApplyJoinFiltersOptions = {\n db: Kysely<any>\n baseTable: string\n builder: AnyBuilder\n joinMap: Map<string, ResolvedJoin>\n joinFilters: Map<string, JoinFilter[]>\n aliasTables: Map<string, string>\n qualifyBase: (column: string) => string\n applyAliasScope: (builder: AnyBuilder, alias: string, table: string) => Promise<AnyBuilder> | AnyBuilder\n applyFilterOp: (builder: AnyBuilder, column: string, op: FilterOp, value?: unknown) => AnyBuilder\n applyJoinFilterOp?: (builder: AnyBuilder, filter: JoinFilter, qualified: string, join: ResolvedJoin, table: string) => Promise<{ applied: boolean; builder: AnyBuilder }> | { applied: boolean; builder: AnyBuilder }\n columnExists?: (table: string, column: string) => Promise<boolean> | boolean\n}\n\nexport async function applyJoinFilters({\n db,\n baseTable,\n builder,\n joinMap,\n joinFilters,\n aliasTables,\n qualifyBase,\n applyAliasScope,\n applyFilterOp,\n applyJoinFilterOp,\n columnExists,\n}: ApplyJoinFiltersOptions): Promise<AnyBuilder> {\n const resolveAliasName = (aliasName?: string | null) => {\n if (!aliasName || aliasName === 'base') return baseTable\n return aliasName\n }\n\n let nextBuilder = builder\n for (const [alias, filtersForAlias] of joinFilters.entries()) {\n const chain = buildJoinChain(alias, joinMap, baseTable)\n if (!chain.length) continue\n const first = chain[0]\n let sub: AnyBuilder = db.selectFrom(`${first.table} as ${first.alias}` as any).select(sql`1`.as('one'))\n sub = await applyAliasScope(sub, first.alias, first.table)\n const parentAlias = resolveAliasName(first.fromAlias)\n const parentRef = parentAlias === baseTable ? qualifyBase(first.fromField) : `${parentAlias}.${first.fromField}`\n sub = sub.whereRef(`${first.alias}.${first.toField}`, '=', parentRef)\n for (const cfg of chain.slice(1)) {\n const parent = resolveAliasName(cfg.fromAlias)\n const rightRef = parent === baseTable ? qualifyBase(cfg.fromField) : `${parent}.${cfg.fromField}`\n const joinFn = cfg.type === 'inner' ? 'innerJoin' : 'leftJoin'\n sub = (sub as any)[joinFn](`${cfg.table} as ${cfg.alias}`, (jb: any) =>\n jb.onRef(`${cfg.alias}.${cfg.toField}`, '=', rightRef))\n sub = await applyAliasScope(sub, cfg.alias, cfg.table)\n }\n let existsDirective: boolean | null = null\n for (const filter of filtersForAlias) {\n if (filter.op === 'exists') {\n if (filter.value === false) existsDirective = false\n else if (existsDirective === null) existsDirective = true\n continue\n }\n const join = joinMap.get(filter.alias)\n if (!join) continue\n const targetTable = aliasTables.get(filter.alias)\n if (!targetTable) continue\n if (columnExists) {\n const exists = await columnExists(targetTable, filter.column)\n if (!exists) continue\n }\n const qualified = `${filter.alias}.${filter.column}`\n if (applyJoinFilterOp) {\n const result = await applyJoinFilterOp(sub, filter, qualified, join, targetTable)\n if (result && result.applied) {\n sub = result.builder\n continue\n }\n if (result && result.builder) {\n sub = result.builder\n }\n }\n sub = applyFilterOp(sub, qualified, filter.op, filter.value)\n }\n if (existsDirective === false) {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.not(eb.exists(capturedSub)))\n } else {\n const capturedSub = sub\n nextBuilder = nextBuilder.where((eb: any) => eb.exists(capturedSub))\n }\n }\n return nextBuilder\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
const LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
3
|
+
function normalizeOverlapValues(value) {
|
|
4
|
+
const list = Array.isArray(value) ? value : value === void 0 || value === null ? [] : [value];
|
|
5
|
+
const out = [];
|
|
6
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7
|
+
for (const entry of list) {
|
|
8
|
+
if (entry === void 0 || entry === null) continue;
|
|
9
|
+
const normalized = String(entry);
|
|
10
|
+
if (seen.has(normalized)) continue;
|
|
11
|
+
seen.add(normalized);
|
|
12
|
+
out.push(normalized);
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function jsonbDocKeyExpression(docColumnRef, key) {
|
|
17
|
+
const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`;
|
|
18
|
+
return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`;
|
|
19
|
+
}
|
|
20
|
+
function buildJsonbOverlapPredicate(docColumnRef, key, value) {
|
|
21
|
+
const values = normalizeOverlapValues(value);
|
|
22
|
+
if (!values.length) return sql`false`;
|
|
23
|
+
return sql`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`;
|
|
24
|
+
}
|
|
25
|
+
function buildJsonbNoOverlapPredicate(docColumnRef, key, value) {
|
|
26
|
+
const values = normalizeOverlapValues(value);
|
|
27
|
+
const keyExpr = jsonbDocKeyExpression(docColumnRef, key);
|
|
28
|
+
const isArray = sql`jsonb_typeof(${keyExpr}) = 'array'`;
|
|
29
|
+
if (!values.length) return isArray;
|
|
30
|
+
return sql`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`;
|
|
31
|
+
}
|
|
32
|
+
function buildArrayColumnOverlapPredicate(column, value) {
|
|
33
|
+
const values = normalizeOverlapValues(value);
|
|
34
|
+
if (!values.length) return sql`false`;
|
|
35
|
+
const columnExpr = typeof column === "string" ? sql.ref(column) : column;
|
|
36
|
+
return sql`${columnExpr} && ${values}`;
|
|
37
|
+
}
|
|
38
|
+
function buildArrayColumnNoOverlapPredicate(column, value) {
|
|
39
|
+
const values = normalizeOverlapValues(value);
|
|
40
|
+
const columnExpr = typeof column === "string" ? sql.ref(column) : column;
|
|
41
|
+
if (!values.length) return sql`${columnExpr} is not null`;
|
|
42
|
+
return sql`not (${columnExpr} && ${values})`;
|
|
43
|
+
}
|
|
44
|
+
function buildScalarOverlapPredicate(expression, value) {
|
|
45
|
+
const values = normalizeOverlapValues(value);
|
|
46
|
+
if (!values.length) return sql`false`;
|
|
47
|
+
return sql`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`;
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
buildArrayColumnNoOverlapPredicate,
|
|
51
|
+
buildArrayColumnOverlapPredicate,
|
|
52
|
+
buildJsonbNoOverlapPredicate,
|
|
53
|
+
buildJsonbOverlapPredicate,
|
|
54
|
+
buildScalarOverlapPredicate,
|
|
55
|
+
jsonbDocKeyExpression,
|
|
56
|
+
normalizeOverlapValues
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=overlap.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/query/overlap.ts"],
|
|
4
|
+
"sourcesContent": ["import { sql, type RawBuilder } from 'kysely'\n\nconst LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/\n\n/**\n * Normalizes the right-hand side of an `overlap` filter into a de-duplicated list of strings.\n *\n * `null`/`undefined` entries are dropped; a scalar is treated as a one-element list. An empty\n * result means the filter matches nothing \u2014 every `overlap` builder compiles it to `false`\n * rather than dropping the predicate, so a caller that computes an empty inclusion set can\n * never widen a query by accident.\n */\nexport function normalizeOverlapValues(value: unknown): string[] {\n const list: readonly unknown[] = Array.isArray(value)\n ? value\n : value === undefined || value === null\n ? []\n : [value]\n const out: string[] = []\n const seen = new Set<string>()\n for (const entry of list) {\n if (entry === undefined || entry === null) continue\n const normalized = String(entry)\n if (seen.has(normalized)) continue\n seen.add(normalized)\n out.push(normalized)\n }\n return out\n}\n\n/**\n * `(<docColumn> -> '<key>')` \u2014 the jsonb sub-document of an index document.\n *\n * The key is emitted as an escaped SQL literal when it is a plain identifier so the expression is\n * textually identical to a GIN expression index such as `((doc -> 'scope_keys'))` and the planner\n * can match it. Any other key falls back to a bound parameter (still correct, just not indexable).\n */\nexport function jsonbDocKeyExpression(docColumnRef: string, key: string): RawBuilder<unknown> {\n const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`\n return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`\n}\n\n/**\n * jsonb \"has any of\" over a string-array key of an index document:\n * `(<docColumn> -> '<key>') ?| $n::text[]`. The values are bound as one array parameter.\n * A missing key or a JSON `null` evaluates to SQL NULL, i.e. the row does not match.\n */\nexport function buildJsonbOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n return sql<boolean>`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`\n}\n\n/**\n * jsonb \"has none of\" over a string-array key of an index document (`noverlap`):\n * `(jsonb_typeof(<expr>) = 'array' and not (<expr> ?| $n::text[]))`.\n *\n * Only a document whose key holds a JSON array can match: a missing key, a JSON `null` or a\n * scalar never satisfies it, so an unindexed document can never slip through an exclusion.\n * An empty value list excludes nothing (only the array check remains).\n */\nexport function buildJsonbNoOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n const keyExpr = jsonbDocKeyExpression(docColumnRef, key)\n const isArray = sql<boolean>`jsonb_typeof(${keyExpr}) = 'array'`\n if (!values.length) return isArray\n return sql<boolean>`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`\n}\n\n/**\n * Postgres array overlap for array-typed columns: `<column> && $n`. The bound array takes its\n * element type from the column, so `text[]`, `varchar[]` and `uuid[]` columns all work.\n */\nexport function buildArrayColumnOverlapPredicate(\n column: string | RawBuilder<unknown>,\n value: unknown,\n): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n const columnExpr = typeof column === 'string' ? sql.ref(column) : column\n return sql<boolean>`${columnExpr} && ${values}`\n}\n\n/**\n * Postgres array \"has none of\" for array-typed columns (`noverlap`): `not (<column> && $n)`.\n * A NULL column never matches; an empty value list excludes nothing (`<column> is not null`).\n */\nexport function buildArrayColumnNoOverlapPredicate(\n column: string | RawBuilder<unknown>,\n value: unknown,\n): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n const columnExpr = typeof column === 'string' ? sql.ref(column) : column\n if (!values.length) return sql<boolean>`${columnExpr} is not null`\n return sql<boolean>`not (${columnExpr} && ${values})`\n}\n\n/**\n * `overlap` over a scalar expression evaluated once per stored value (custom-field value rows):\n * the record matches when any of its values is one of the requested ones \u2014 `<expr> in (...)`.\n */\nexport function buildScalarOverlapPredicate(expression: RawBuilder<unknown>, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n return sql<boolean>`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,WAA4B;AAErC,MAAM,uBAAuB;AAUtB,SAAS,uBAAuB,OAA0B;AAC/D,QAAM,OAA2B,MAAM,QAAQ,KAAK,IAChD,QACA,UAAU,UAAa,UAAU,OAC/B,CAAC,IACD,CAAC,KAAK;AACZ,QAAM,MAAgB,CAAC;AACvB,QAAM,OAAO,oBAAI,IAAY;AAC7B,aAAW,SAAS,MAAM;AACxB,QAAI,UAAU,UAAa,UAAU,KAAM;AAC3C,UAAM,aAAa,OAAO,KAAK;AAC/B,QAAI,KAAK,IAAI,UAAU,EAAG;AAC1B,SAAK,IAAI,UAAU;AACnB,QAAI,KAAK,UAAU;AAAA,EACrB;AACA,SAAO;AACT;AASO,SAAS,sBAAsB,cAAsB,KAAkC;AAC5F,QAAM,UAAU,qBAAqB,KAAK,GAAG,IAAI,IAAI,IAAI,GAAG,IAAI,MAAM,GAAG;AACzE,SAAO,OAAO,IAAI,IAAI,YAAY,CAAC,OAAO,OAAO;AACnD;AAOO,SAAS,2BAA2B,cAAsB,KAAa,OAAqC;AACjH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,MAAe,sBAAsB,cAAc,GAAG,CAAC,OAAO,MAAM;AAC7E;AAUO,SAAS,6BAA6B,cAAsB,KAAa,OAAqC;AACnH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,QAAM,UAAU,sBAAsB,cAAc,GAAG;AACvD,QAAM,UAAU,mBAA4B,OAAO;AACnD,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,OAAgB,OAAO,aAAa,OAAO,OAAO,MAAM;AACjE;AAMO,SAAS,iCACd,QACA,OACqB;AACrB,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,QAAM,aAAa,OAAO,WAAW,WAAW,IAAI,IAAI,MAAM,IAAI;AAClE,SAAO,MAAe,UAAU,OAAO,MAAM;AAC/C;AAMO,SAAS,mCACd,QACA,OACqB;AACrB,QAAM,SAAS,uBAAuB,KAAK;AAC3C,QAAM,aAAa,OAAO,WAAW,WAAW,IAAI,IAAI,MAAM,IAAI;AAClE,MAAI,CAAC,OAAO,OAAQ,QAAO,MAAe,UAAU;AACpD,SAAO,WAAoB,UAAU,OAAO,MAAM;AACpD;AAMO,SAAS,4BAA4B,YAAiC,OAAqC;AAChH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,MAAe,UAAU,QAAQ,IAAI,KAAK,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK,EAAE,GAAG,OAAO,CAAC;AACjG;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/query/types.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n /**\n * Exact when `meta.listCountCapWarning` is absent; a floor when it is\n * present (the list total itself was bounded at the cap).\n */\n totalMatched: number\n}\n\n/**\n * Present when the list COUNT was bounded at `cap` matching rows\n * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on\n * CRUD list payloads as `totalIsCapped: true`.\n */\nexport type ListCountCapWarning = {\n entity: EntityId\n cap: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n listCountCapWarning?: ListCountCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\n/**\n * `overlap` (additive, 2026-10-05): \"has any of\" over a multi-valued field. An index-document\n * string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base\n * column to `&&`. An empty value list matches nothing.\n *\n * `noverlap` (additive, 2026-10-05): \"has none of\" \u2014 the negation of `overlap` on an index-document\n * string array or an array-typed base column. Only a present array can match (a missing/null key or\n * NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where\n * it compiles to `false`.\n */\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n $overlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n $noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n /**\n * Exact when `meta.listCountCapWarning` is absent; a floor when it is\n * present (the list total itself was bounded at the cap).\n */\n totalMatched: number\n}\n\n/**\n * Present when the list COUNT was bounded at `cap` matching rows\n * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on\n * CRUD list payloads as `totalIsCapped: true`.\n */\nexport type ListCountCapWarning = {\n entity: EntityId\n cap: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n listCountCapWarning?: ListCountCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
|
|
5
|
+
"mappings": "AAgBO,IAAK,UAAL,kBAAKA,aAAL;AACL,EAAAA,SAAA,SAAM;AACN,EAAAA,SAAA,UAAO;AAFG,SAAAA;AAAA,GAAA;",
|
|
6
6
|
"names": ["SortDir"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.9.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.9.1-develop.7475.1.28ca93ed97';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/modules/search.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityId } from './entities'\nimport { createLogger } from '../lib/logger'\n\nconst logger = createLogger('shared').child({ component: 'search-registry' })\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n /** Organization scope of the result, when known by the strategy. */\n organizationId?: string | null\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /**\n * Optional organization allowlist.\n * - Non-empty array restricts results to one of those organizations.\n * - Empty array means no organizations are visible and should return no results.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n *\n * `organizationId` takes precedence when both are provided.\n */\n organizationIds?: string[] | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string, organizationId?: string | null): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { fulltext: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n /**\n * Per-entity view feature(s) required to read this entity's records through\n * data-returning surfaces (e.g. the `search_get` / `search_aggregate` AI tools).\n * These tools must NOT rely on the search-administration `search.view` feature\n * to gate record reads \u2014 callers must additionally hold the owning module's\n * `<entity>.view` feature(s) declared here. When omitted, those tools fail\n * closed (deny) so an entity is never exposed without an explicit grant.\n */\n aclFeatures?: string[]\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;
|
|
4
|
+
"sourcesContent": ["import type { EntityId } from './entities'\nimport { createLogger } from '../lib/logger'\n\nconst logger = createLogger('shared').child({ component: 'search-registry' })\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n /** Organization scope of the result, when known by the strategy. */\n organizationId?: string | null\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /**\n * Optional organization allowlist.\n * - Non-empty array restricts results to one of those organizations.\n * - Empty array means no organizations are visible and should return no results.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n *\n * `organizationId` takes precedence when both are provided.\n */\n organizationIds?: string[] | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n /**\n * Restricts results to records whose query-index row (`entity_indexes`) matches this predicate,\n * evaluated inside the strategy's ranking query so a restrictive filter never starves the result\n * set (additive, 2026-10-06). Only strategies with `supportsIndexDocFilter: true` receive a\n * filtered search; any other strategy returns no results for it rather than unfiltered ones.\n */\n indexDocFilter?: SearchIndexDocFilter\n}\n\n/**\n * One condition over a record's query-index document (`entity_indexes.doc`), with the query\n * engine's index-document semantics:\n * - `exists`: the key is present and not JSON `null`;\n * - `eq`: the key's text value equals `value` (booleans and numbers compare by their JSON text);\n * - `overlap`: the key holds an array sharing at least one entry with `values`; empty `values`\n * matches nothing;\n * - `noverlap`: the key holds an array sharing no entry with `values`; a missing key never matches;\n * - `recordIdNotIn`: the record id is none of `values`.\n */\nexport type SearchIndexDocCondition =\n | { op: 'exists'; key: string }\n | { op: 'eq'; key: string; value: string | number | boolean }\n | { op: 'overlap'; key: string; values: string[] }\n | { op: 'noverlap'; key: string; values: string[] }\n | { op: 'recordIdNotIn'; values: string[] }\n\n/**\n * A predicate over a record's `entity_indexes` row in disjunctive normal form: the record matches\n * when its index row is not deleted and every condition of at least one `anyOf` branch holds.\n * An empty `anyOf` matches nothing; an empty branch matches every indexed record.\n */\nexport type SearchIndexDocFilter = {\n anyOf: SearchIndexDocCondition[][]\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /**\n * Whether `search()` applies `SearchOptions.indexDocFilter` inside its ranking query. Absent or\n * `false` means the strategy is skipped for filtered searches (fail closed).\n */\n readonly supportsIndexDocFilter?: boolean\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string, organizationId?: string | null): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { fulltext: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n /**\n * Per-entity view feature(s) required to read this entity's records through\n * data-returning surfaces (e.g. the `search_get` / `search_aggregate` AI tools).\n * These tools must NOT rely on the search-administration `search.view` feature\n * to gate record reads \u2014 callers must additionally hold the owning module's\n * `<entity>.view` feature(s) declared here. When omitted, those tools fail\n * closed (deny) so an entity is never exposed without an explicit grant.\n */\n aclFeatures?: string[]\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAkX5E,IAAI,uBAAoD;AAMjD,SAAS,4BAA4B,SAAqC;AAC/E,MAAI,yBAAyB,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC3E,WAAO,MAAM,iEAAiE;AAAA,EAChF;AACA,yBAAuB;AACzB;AAMO,SAAS,yBAA+C;AAC7D,SAAO,wBAAwB,CAAC;AAClC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7475.1.28ca93ed97",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@mikro-orm/core": "^7.1.14",
|
|
122
122
|
"@mikro-orm/decorators": "^7.1.14",
|
|
123
123
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
124
|
-
"@open-mercato/cache": "0.9.1-develop.
|
|
124
|
+
"@open-mercato/cache": "0.9.1-develop.7475.1.28ca93ed97",
|
|
125
125
|
"@types/html-to-text": "^9.0.4",
|
|
126
126
|
"@types/sanitize-html": "^2.16.1",
|
|
127
127
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { canonicalizeEffectiveScope } from '../canonicalizeEffectiveScope'
|
|
2
|
+
import { hashEffectiveScope } from '../hashEffectiveScope'
|
|
3
|
+
import type { AssortmentScope, EffectiveAssortmentScope } from '../types'
|
|
4
|
+
import { mulberry32, randomAssortmentScope } from './testFixtures'
|
|
5
|
+
|
|
6
|
+
function shuffle<T>(rng: () => number, items: T[]): T[] {
|
|
7
|
+
const copy = [...items]
|
|
8
|
+
for (let index = copy.length - 1; index > 0; index -= 1) {
|
|
9
|
+
const swap = Math.floor(rng() * (index + 1))
|
|
10
|
+
const held = copy[index]
|
|
11
|
+
copy[index] = copy[swap]
|
|
12
|
+
copy[swap] = held
|
|
13
|
+
}
|
|
14
|
+
return copy
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function shuffleScope(rng: () => number, scope: AssortmentScope): AssortmentScope {
|
|
18
|
+
const entries = shuffle(rng, Object.entries(scope)).map(([key, value]) => {
|
|
19
|
+
if (key === 'allOf') {
|
|
20
|
+
return [key, shuffle(rng, (value as AssortmentScope[]).map((nested) => shuffleScope(rng, nested)))]
|
|
21
|
+
}
|
|
22
|
+
return [key, shuffle(rng, value as string[])]
|
|
23
|
+
})
|
|
24
|
+
return Object.fromEntries(entries) as AssortmentScope
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function randomEffective(rng: () => number): EffectiveAssortmentScope {
|
|
28
|
+
if (rng() < 0.1) return null
|
|
29
|
+
const branches: AssortmentScope[] = []
|
|
30
|
+
const count = Math.floor(rng() * 4)
|
|
31
|
+
for (let index = 0; index < count; index += 1) {
|
|
32
|
+
const branch = randomAssortmentScope(rng)
|
|
33
|
+
if (rng() < 0.3) {
|
|
34
|
+
branch.allOf = [randomAssortmentScope(rng), randomAssortmentScope(rng)]
|
|
35
|
+
}
|
|
36
|
+
branches.push(branch)
|
|
37
|
+
}
|
|
38
|
+
return branches
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('canonicalizeEffectiveScope', () => {
|
|
42
|
+
it('distinguishes unrestricted (null) from deny-all ([])', () => {
|
|
43
|
+
expect(canonicalizeEffectiveScope(null)).toBe('null')
|
|
44
|
+
expect(canonicalizeEffectiveScope([])).toBe('[]')
|
|
45
|
+
expect(hashEffectiveScope(null)).not.toBe(hashEffectiveScope([]))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('treats empty arrays as absent keys', () => {
|
|
49
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: [], tagIds: ['t1'], excludeTagIds: [] }])).toBe(
|
|
50
|
+
canonicalizeEffectiveScope([{ tagIds: ['t1'] }]),
|
|
51
|
+
)
|
|
52
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: [], allOf: [] }])).toBe(canonicalizeEffectiveScope([{}]))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps an empty branch (matches everything) distinct from no branches', () => {
|
|
56
|
+
expect(canonicalizeEffectiveScope([{}])).toBe('[{}]')
|
|
57
|
+
expect(canonicalizeEffectiveScope([{}])).not.toBe(canonicalizeEffectiveScope([]))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('is invariant to key order, id order and duplicate ids', () => {
|
|
61
|
+
const left: EffectiveAssortmentScope = [{ tagIds: ['b', 'a', 'a'], categoryIds: ['y', 'x'] }]
|
|
62
|
+
const right: EffectiveAssortmentScope = [{ categoryIds: ['x', 'y'], tagIds: ['a', 'b'] }]
|
|
63
|
+
expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
|
|
64
|
+
expect(hashEffectiveScope(left)).toBe(hashEffectiveScope(right))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is invariant to branch order and collapses identical branches', () => {
|
|
68
|
+
const forward: EffectiveAssortmentScope = [{ categoryIds: ['a'] }, { tagIds: ['b'] }]
|
|
69
|
+
const reversed: EffectiveAssortmentScope = [{ tagIds: ['b'] }, { categoryIds: ['a'] }, { categoryIds: ['a'] }]
|
|
70
|
+
expect(canonicalizeEffectiveScope(forward)).toBe(canonicalizeEffectiveScope(reversed))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('canonicalizes nested allOf entries and their order', () => {
|
|
74
|
+
const left: EffectiveAssortmentScope = [
|
|
75
|
+
{ categoryIds: ['a'], allOf: [{ tagIds: ['t2', 't1'] }, { excludeProductIds: ['p1'], allOf: [{ categoryIds: ['c2', 'c1'] }] }] },
|
|
76
|
+
]
|
|
77
|
+
const right: EffectiveAssortmentScope = [
|
|
78
|
+
{ allOf: [{ allOf: [{ categoryIds: ['c1', 'c2'] }], excludeProductIds: ['p1'] }, { tagIds: ['t1', 't2'] }], categoryIds: ['a'] },
|
|
79
|
+
]
|
|
80
|
+
expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('distinguishes scopes that match differently', () => {
|
|
84
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(canonicalizeEffectiveScope([{ tagIds: ['a'] }]))
|
|
85
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'], tagIds: ['b'] }])).not.toBe(
|
|
86
|
+
canonicalizeEffectiveScope([{ categoryIds: ['a'] }, { tagIds: ['b'] }]),
|
|
87
|
+
)
|
|
88
|
+
expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(
|
|
89
|
+
canonicalizeEffectiveScope([{ excludeCategoryIds: ['a'] }]),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('hashEffectiveScope', () => {
|
|
95
|
+
it('returns 16 lowercase hex characters', () => {
|
|
96
|
+
expect(hashEffectiveScope([{ categoryIds: ['a'] }])).toMatch(/^[0-9a-f]{16}$/)
|
|
97
|
+
expect(hashEffectiveScope(null)).toMatch(/^[0-9a-f]{16}$/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('property: shuffling branch, key and id order preserves the hash', () => {
|
|
101
|
+
for (let seed = 1; seed <= 200; seed += 1) {
|
|
102
|
+
const rng = mulberry32(seed)
|
|
103
|
+
const scope = randomEffective(rng)
|
|
104
|
+
const shuffled = scope === null ? null : shuffle(rng, scope.map((branch) => shuffleScope(rng, branch)))
|
|
105
|
+
expect(hashEffectiveScope(shuffled)).toBe(hashEffectiveScope(scope))
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('property: equal canonical form if and only if equal hash across random pairs', () => {
|
|
110
|
+
const rng = mulberry32(4242)
|
|
111
|
+
const scopes: EffectiveAssortmentScope[] = []
|
|
112
|
+
for (let index = 0; index < 60; index += 1) scopes.push(randomEffective(rng))
|
|
113
|
+
for (const left of scopes) {
|
|
114
|
+
for (const right of scopes) {
|
|
115
|
+
const sameCanonical = canonicalizeEffectiveScope(left) === canonicalizeEffectiveScope(right)
|
|
116
|
+
const sameHash = hashEffectiveScope(left) === hashEffectiveScope(right)
|
|
117
|
+
expect(sameHash).toBe(sameCanonical)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AssortmentScope, EffectiveAssortmentScope } from './types'
|
|
2
|
+
|
|
3
|
+
const ID_LIST_KEYS = [
|
|
4
|
+
'categoryIds',
|
|
5
|
+
'excludeCategoryIds',
|
|
6
|
+
'excludeProductIds',
|
|
7
|
+
'excludeTagIds',
|
|
8
|
+
'tagIds',
|
|
9
|
+
] as const
|
|
10
|
+
|
|
11
|
+
function compareStrings(left: string, right: string): number {
|
|
12
|
+
if (left < right) return -1
|
|
13
|
+
if (left > right) return 1
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sortedUnique(values: string[]): string[] {
|
|
18
|
+
return Array.from(new Set(values)).sort(compareStrings)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function canonicalizeBranch(scope: AssortmentScope): string {
|
|
22
|
+
const members: string[] = []
|
|
23
|
+
const nested = (scope.allOf ?? []).map(canonicalizeBranch)
|
|
24
|
+
if (nested.length > 0) members.push(`"allOf":[${sortedUnique(nested).join(',')}]`)
|
|
25
|
+
for (const key of ID_LIST_KEYS) {
|
|
26
|
+
const ids = scope[key] ?? []
|
|
27
|
+
if (ids.length > 0) members.push(`${JSON.stringify(key)}:${JSON.stringify(sortedUnique(ids))}`)
|
|
28
|
+
}
|
|
29
|
+
return `{${members.join(',')}}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic serialization of a resolved scope. `null` (unrestricted) serializes as `null`
|
|
34
|
+
* and `[]` (deny-all) as `[]`, so the two never collide. Empty and absent arrays are
|
|
35
|
+
* equivalent, id lists are sorted and deduplicated, and OR-branches and `allOf` entries are
|
|
36
|
+
* ordered by their own canonical form with identical ones collapsed.
|
|
37
|
+
*/
|
|
38
|
+
export function canonicalizeEffectiveScope(scope: EffectiveAssortmentScope): string {
|
|
39
|
+
if (scope === null) return 'null'
|
|
40
|
+
return `[${sortedUnique(scope.map(canonicalizeBranch)).join(',')}]`
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
|
|
3
|
+
import type { EffectiveAssortmentScope } from './types'
|
|
4
|
+
|
|
5
|
+
/** 16-hex-character sha256 digest of the canonical scope (SPEC-029 §6.1 truncation). Server-only: uses node:crypto. */
|
|
6
|
+
export function hashEffectiveScope(scope: EffectiveAssortmentScope): string {
|
|
7
|
+
return createHash('sha256').update(canonicalizeEffectiveScope(scope)).digest('hex').slice(0, 16)
|
|
8
|
+
}
|
|
@@ -3,3 +3,5 @@ export { matchesOne } from './matchesOne'
|
|
|
3
3
|
export { matchesScope } from './matchesScope'
|
|
4
4
|
export { unionScopes } from './unionScopes'
|
|
5
5
|
export { intersectScopes } from './intersectScopes'
|
|
6
|
+
export { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
|
|
7
|
+
export { hashEffectiveScope } from './hashEffectiveScope'
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
registerTranslatableFields,
|
|
3
3
|
getTranslatableFields,
|
|
4
4
|
getTranslatableFieldsRegistry,
|
|
5
|
+
registerTranslatableFieldExpander,
|
|
6
|
+
getTranslatableFieldExpander,
|
|
5
7
|
} from '../translatable-fields'
|
|
6
8
|
|
|
7
9
|
// The registry is global mutable state — we snapshot before all tests
|
|
@@ -67,3 +69,18 @@ describe('translatable fields registry', () => {
|
|
|
67
69
|
expect(getTranslatableFields('b:second')).toEqual(['f2'])
|
|
68
70
|
})
|
|
69
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('translatable field expander registry', () => {
|
|
74
|
+
it('returns undefined for an entity type without an expander', () => {
|
|
75
|
+
expect(getTranslatableFieldExpander('expander_test:none')).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns the registered expander and keeps earlier registrations', () => {
|
|
79
|
+
const first = jest.fn(() => [{ key: 'a.b' }])
|
|
80
|
+
const second = jest.fn(() => [])
|
|
81
|
+
registerTranslatableFieldExpander('expander_test:first', first)
|
|
82
|
+
registerTranslatableFieldExpander('expander_test:second', second)
|
|
83
|
+
expect(getTranslatableFieldExpander('expander_test:first')).toBe(first)
|
|
84
|
+
expect(getTranslatableFieldExpander('expander_test:second')).toBe(second)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export type { TranslationRecord, LocaleCode } from './types'
|
|
2
2
|
export { applyLocalizedContent } from './resolver'
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
registerTranslatableFields,
|
|
5
|
+
getTranslatableFields,
|
|
6
|
+
getTranslatableFieldsRegistry,
|
|
7
|
+
registerTranslatableFieldExpander,
|
|
8
|
+
getTranslatableFieldExpander,
|
|
9
|
+
} from './translatable-fields'
|
|
10
|
+
export type { TranslatableFieldExpansion, TranslatableFieldExpander } from './translatable-fields'
|
|
4
11
|
export { registerTranslationOverlayPlugin, getTranslationOverlayPlugin } from './overlay-plugin'
|
|
5
12
|
export type { TranslationOverlayFn, ResolveLocaleFromRequestFn } from './overlay-plugin'
|