search
See also: SQO (the query DTO contract) · RQO (the request that wraps an SQO) · Sections · common
The server query engine compiles a Search Query Object (SQO) into a single prepared PostgreSQL statement over the JSONB matrix_* tables, runs it, and returns the matched rows.
This page is the subsystem reference for the search engine. For what an
SQO is — its fields, the Mango-style filter grammar and the two-phase parsed
lifecycle — read SQO first; this document is about the engine that
consumes an SQO and emits SQL, and does not repeat the SQO field contract at
length.
Role
The search engine lives in src/core/search/. It is the TypeScript runtime
that turns a Sqo into prepared SQL and executes it via Bun.sql
(src/core/db/postgres.ts). It is the single query builder in Dédalo: every
read that filters, counts or paginates records — list views, portals,
autocompletes, the thesaurus tree, diffusion exports, server-internal lookups —
funnels through it.
The engine is a set of pure functions across a handful of modules — no instance, no shared caches, nothing to reset. The compile step is idempotent and the process holds no cross-request search state:
| module | responsibility |
|---|---|
conform.ts |
Phase A — walk the SQO filter tree, gate every identifier, resolve each leaf's model/column, dispatch to the per-model fragment builder. |
builders/ |
the per-component SQL fragment builders (builder_string, builder_number, builder_iri, builder_date, builder_section_id, builder_relation). |
identifier_gate.ts |
the injection chokepoint — assertValidTipo / assertValidLang / assertValidDataColumn / assertValidTipoOrColumn. |
sql_assembler.ts |
Phase B — assemble SELECT / FROM / JOIN / WHERE / ORDER / LIMIT into the final {sql, params}; multi-section UNION; per-record projects ACL. |
params.ts |
ParamsCollector — the positional $1..$n prepared-param list. |
search_related.ts |
the inverse-reference / relation-breakdown engine — one btree query over matrix_relation_index, the only relation engine (see The relation index). |
search_store.ts |
coverage gates for the two derived stores: matrix_string_search (string contains pre-filter) and matrix_relation_index (inverse/related engine + WC-012 translation). Coverage = sync triggers present AND the store backfilled. |
count.ts |
countSectionRecords() — the full_count variant for one section. |
It sits at the boundary between the request layer and the database:
| layer | responsibility |
|---|---|
Client JS (client/dedalo/core/search/js/search.js, copied as-is) |
builds the SQO (filter groups, q_operator, limit/offset) for the search UI. |
API gate (dispatch.ts) |
sanitizeClientSqo() (src/core/concepts/sqo.ts) — the only place an untrusted, client-authored SQO is sanitized before it reaches the engine. |
the engine (src/core/search/) |
compiles the SQO → one prepared SQL string + a positional $params list. |
Bun.sql / postgres.ts |
sql.unsafe(sqlString, params) binds $1..$n and returns the rows; JSONB columns arrive decoded by the one JSON codec. |
flowchart TB
JS["client search.js"] -->|"rqo.sqo"| GATE["sanitizeClientSqo()<br/>(concepts/sqo.ts, at the dispatch gate)"]
GATE --> BUILD["buildSearchSql(sqo, {principal})"]
SRV["server-internal callers"] -->|"build a Sqo"| BUILD
BUILD -->|"Phase A"| CONF["conformFilter()<br/>→ identifier_gate assert*<br/>→ per-model builders/*"]
CONF -->|"Phase B"| ASM["assemble SELECT/FROM/JOIN/WHERE/ORDER<br/>+ projects filter + UNION"]
ASM -->|"{sql, params}"| EXEC["sql.unsafe(sql, params) (Bun.sql)"]
EXEC --> ROWS["decoded matrix rows"]
JS -. "mode 'related'" .-> REL["search_related.ts<br/>(inverse refs / breakdown / count)"]
Two entry doors, one gate
Client SQOs are sanitized by sanitizeClientSqo() at the API edge (inside
the dd_core_api handlers in dispatch.ts). Server-internal callers
build a Sqo object and call buildSearchSql() directly, bypassing that
gate — so they keep full access to server-only fields
(skip_projects_filter, limit:'all', …). The in-engine identifier gate
(identifier_gate.ts, below) is what protects both paths against SQL
injection through un-parameterizable identifiers.
Responsibilities
- Mode dispatch — there is no single
get_instance()factory. The default (edit/list) path compiles throughbuildSearchSql(); Time Machine (mode:'tm') and relation breakdown (mode:'related') are selected one layer up, by the read strategypickReadSource()(src/core/section/read_source.ts) and bysearch_related.tsrespectively. - SQO conform —
conformFilter()walks the filter tree and asks each component model's builder to translate its filter leaf into a SQL fragment. - Injection chokepoint —
identifier_gate.tsvalidates everysection_tipo/component_tipopath step and thelangselector before any component dispatch, because those identifiers are string-interpolated into JSONB keys / jsonpath and cannot be parameterized. - SQL assembly —
buildSearchSql()builds SELECT / FROM / JOIN / WHERE / ORDER / LIMIT in a load-bearing order, using the window-subquery pattern. - Access-control filter —
buildProjectsFilter()(per-user project scoping) reaches the WHERE clause for non-global-admins with aprincipal. - Prepared params —
ParamsCollector.getPlaceholder()maintains the positional$1..$nlist thatsql.unsafe()binds; every literal value becomes a$nplaceholder. - Multi-section UNION —
buildSearchSql()emits oneUNION ALLbranch per matrix table when an SQO spans more than one section. - Counting —
full_countbuilds thecount(DISTINCT …)variant;countSectionRecords()andcountInverseReferences()wrap it for their callers.
The compile functions must stay stateless
One long-lived process serves every user. The compile functions hold no per-user cache and no shared state, so there is nothing to reset between requests and nothing that can bleed from one user's search into another's.
Add a module-level cache here keyed on anything user-specific and you
reintroduce that hazard. Request-scoped context belongs in
AsyncLocalStorage, not in a module variable.
Key concepts
The SQO → SQL flow (one pass, end to end)
buildSearchSql(sqo, { principal }) entry (sql_assembler.ts)
→ conformFilter(sqo.filter, alias, table) Phase A (conform.ts)
→ identifier gate: assertValidTipo / assertValidTipoOrColumn / assertValidLang
→ ontology resolve: getModelByTipo, getColumnNameByModel, getTranslatableByTipo
→ per-leaf builder: builders/builder_string|number|iri|date|section_id
relation family → relations/registry getRelationSearchFragmentBuilder
→ returns a ConformedFilter tree whose leaves carry BuilderResults
→ assemble (Phase B):
FROM → SELECT → ORDER (may add sort-select aliases) → WHERE
+ buildProjectsFilter() for non-admins
+ filter_by_locators / multi-section UNION ALL
window-subquery wrapper when a custom ORDER (or join) is present
→ { sql, params }
→ sql.unsafe(sql, params) Bun.sql, $1..$n bound
→ decoded matrix rows
Execution order is load-bearing
buildSearchSql() builds FROM → SELECT → ORDER → WHERE, documented in
its own header comment. ORDER runs before WHERE because component-based
ordering may add join fragments (buildJoinChain()) and sort-select aliases,
and those need the base FROM established and their aliases available. Do not
reorder.
The working arrays
buildSearchSql() keeps function-local arrays — no shared state — that are
joined into the final query:
const mainWhere: string[] // section_tipo = / IN (...)
const whereParts: string[] // filter, projects filter, filter_by_locators
const select: string[] // SELECT columns (DISTINCT ON unless removeDistinct)
const selectExtra: string[] // component sort-select aliases
const orderClauses: string[] // custom (sqo.order) ordering
const orderDefault = [`${alias}.section_id ASC`] // deterministic tie-break
const joinFragments: Map<string,string> // LEFT JOIN LATERAL chains, dedup by alias
Identity / table resolution
getSectionTipos(sqo) normalizes sqo.section_tipo to an array. The first
entry is the main section tipo; its alias is trimTipo() (e.g. rsc197 →
rs197) for a single section, or the literal mix when more than one
section is queried. The matrix table is resolved from the first reliable section
tipo via getMatrixTableFromTipo() (skipping tipos with no resolvable table).
When more than one section is queried, removeDistinct is forced true
(cross-section search wants duplicate section_ids across sections).
Time Machine reads override the source entirely (pickReadSource('tm') →
read_tm.ts, which queries the flat matrix_time_machine table); they do not go
through buildSearchSql().
Ranking by locator position ({mode:'locator_position'})
Semantic search returns records best-first, and that rank has to survive the
ordinary search machinery (pagination, full_count, export). The client
achieves it with a resolve-once-then-pin step (WC-047): it pins the hit
locators into sqo.filter_by_locators and appends a single
{mode:'locator_position'} entry to sqo.order. buildOrderClauses()
(src/core/search/sql_assembler.ts) renders that entry as a selectExtra
alias —
array_position(ARRAY[…]::int[], mix.section_id) AS locator_position_order —
so the rank rides the normal component-sort path and is preserved across the
windowed/count/export wrappers.
Load-bearing details, all enforced in the assembler:
- It is emitted as a select alias, never an alias-qualified raw
ORDERexpression: the windowed wrapper strips only a leading alias prefix (stripAliasPrefix), so a rawarray_position(…, mix.section_id)clause would reference an out-of-scope table there ("missing FROM-clause entry"). - The ids are inlined as integer literals validated with
Number.isSafeInteger, never a bound$nparameter — the count path reuses the same SQO but emits noORDER BY, so an order-time bind would leave a$nwith no placeholder in the count SQL and fail every paginator count. - Single-tipo pin lists only in v1 — a mixed-tipo pin list throws; an empty pin list is a silent no-op (a session-merged leftover order without pins).
sanitizeClientSqoclampsfilter_by_locatorstoCLIENT_MAX_LOCATOR_PINS(1000) loudly (DEC-07) — a longer pin list truncates the result set with a logged warning rather than silently.
This ordering mode is TS-native, additive (WC-047) — there is no counterpart in the previous engine.
The identifier gate — the injection chokepoint
identifier_gate.ts is the central security gate; it covers all component
search builders at one point. Builders string-interpolate component_tipo (as a
JSONB key / jsonpath member step) and lang (into jsonpath / string literals) —
neither can be parameterized (jsonpath vars can't parameterize a member
accessor). So before any builder dispatch, conformLeaf() (in conform.ts)
calls the gate on every path step:
assertValidTipo()—isValidTipo(^[a-z]+[0-9]+$,src/core/concepts/ontology.ts) for eachsection_tipo,assertValidTipoOrColumn()— a valid tipo or a legitimate data column (section_id,id,tipo,lang,type,section_tipo, the matrix jsonb columns) for eachcomponent_tipo,assertValidLang()—isValidLang(^(lg-[a-z0-9_]+|all)$) for the optionallang,
and every assert throws on a malformed value (allowlist only — invalid input
is rejected, never repaired). A new per-component builder that interpolates a new
SQO field into SQL must gate it here, not in the leaf. The multi-hop join builder
(buildJoinChain()) re-asserts each hop's tipos for the same reason.
Prepared params model
ParamsCollector (params.ts) returns $1..$n from getPlaceholder() and
stores the values as a 0-indexed positional list (dedup by strict
equality, so 1/'1'/true never collapse onto the same slot). Component
builders build sentences with _Q1_, _Q2_… named token placeholders plus a
token→value map; ParamsCollector.substitute() swaps each token for a real $n.
buildSearchSql() returns params.toArray(), which sql.unsafe() binds
directly. Everything that can be parameterized is — the only verbatim
interpolations are the gate-validated identifiers above.
Access-control filter (reaches WHERE)
buildProjectsFilter() (in sql_assembler.ts) — for a non-global-admin
principal, restricts results to records whose component_filter relation
references one of the user's projects (getUserProjects()). A section that is
not project-gated (getComponentFilterTipo() returns null) contributes nothing;
a gated section queried by a user with no projects yields an impossible
clause (empty result — fail closed). Project ids ride as bound params.
Passing no principal skips the record ACL
Global admins and internal searches (no principal) skip the projects filter.
Trusted server code must therefore pass a principal whenever it intends
user-scoped results — omitting it is not a shortcut, it is a full-visibility
search.
Exempt tables
Sections living in the shared vocabulary/infrastructure tables —
matrix_hierarchy, matrix_hierarchy_main, matrix_list, matrix_dd,
matrix_langs, matrix_tools, matrix_stats, matrix_notes — are
never project-gated (PROJECTS_FILTER_EXEMPT_TABLES in
sql_assembler.ts), applied per section in a multi-section UNION. Their
records carry no project locators, so gating them would blank the whole
thesaurus for every non-admin — exactly what happened for one day when
the virtual→real filter fallback landed without this rule (autocomplete
regression caught 2026-07-20).
The string-search store (matrix_string_search)
Accent/case-insensitive contains searches on string components compile to a
per-row predicate (EXISTS(jsonb_path_query(string, …) WHERE f_unaccent(value)
~* f_unaccent(q))) that no JSONB index can serve — on a 21k-record section that
is ~1.4 s per search, for admins and regular users alike. The engine therefore
maintains a derived per-value search store:
matrix_string_search (
section_tipo varchar(64), -- the record's section
section_id integer, -- the record
component_tipo varchar(64), -- WHICH component the value belongs to
string text -- lower(f_unaccent(value)) — one row per value
)
The string column is named after its source: only components stored in the
matrix string column are included (dates, numbers, relations etc. have their
own typed columns and their own search shapes). One composite
btree_gin index —
gin (component_tipo, string gin_trgm_ops) — resolves the component scoping
and the trigram containment in a single index scan, so a component-scoped
contains is served in ~1 ms with no reliance on the planner combining separate
indexes.
builder_string prepends the store lookup as a pre-filter in front of the
exact predicate (which always remains and decides membership — results are
byte-identical with or without the store):
mix.section_id = ANY (ARRAY(
SELECT sv.section_id FROM matrix_string_search sv
WHERE sv.component_tipo = $t
AND sv.string LIKE '%' || lower(f_unaccent($q)) || '%'))
AND ( …the exact jsonb predicate, unchanged… )
The shape is deliberate, and each choice is load-bearing:
| choice | why |
|---|---|
uncorrelated ANY(ARRAY(…)), not a correlated EXISTS |
plans as a one-shot InitPlan so the main table is entered by section_id; the correlated form let jsonb selectivity misestimates invert the plan back to the per-row scan. |
no section_tipo condition inside the subquery |
the multi-section UNION replicates the WHERE verbatim into every branch; a section pin would fail-close other branches — cross-section ids only widen the superset, the outer section_tipo pin and the exact predicate still decide. |
emitted for positive shapes only (contains / begins / ends / == / = / quoted literal) with a regex-plain q |
the exact predicate is regex-semantic, the store LIKE is literal-substring — they only agree on plain text; %/_ are escaped. Negations (!*, !=, -, !!) and bare * never carry it. |
| emitted for non-joined leaves only (path length 1) | on a hop-joined alias the join already bounds the per-row work, and the pre-filter's tiny cardinality estimate flips the join order into an unindexed filter join (measured 4× slower). conform.ts gates on the leaf's join chain. |
gated on the table's sync trigger presence (search_store.ts, cached; the maintenance rebuild actions clear the cache) |
against an unmaintained table the empty store would wrongly exclude rows — the gate is correctness, not just performance. Uncovered tables (e.g. matrix_time_machine) keep the classic SQL byte-identically. |
The store is derived data kept in sync by AFTER INSERT OR DELETE OR UPDATE
OF string row triggers ({table}_string_search_sync → the plpgsql
matrix_string_search_sync(), delete-then-reinsert per record) on every
string-searchable matrix table — engine writes, scripts and manual SQL all stay
consistent, in the same transaction as the write. Fresh installs are born with
the store: the seed dump (install/db/dedalo_install.pgsql.gz) creates the
table, function, triggers and indexes and backfills the seed rows at restore
time. On a pre-existing instance (a database created by a previous beta) the
server self-provisions at boot: ensureSearchStores() (db_assets.ts,
called by startServer after the boot migrations, before serving) detects a
missing store table / sync trigger, or a store that is empty while its
sources would produce rows, and runs the targeted DDL + one-time backfill —
so updating the code and restarting is the whole upgrade. The database_info
maintenance widget keeps the same operations as MANUAL repair actions:
Recreate database assets (full DDL incl. legacy cleanups) and Backfill
search stores (backfill_search_stores → backfillSearchStores():
TRUNCATE + INSERT … SELECT per covered table, mirroring the trigger row
filters). Until provisioning ran, the presence gate keeps string searches on
the classic scan.
Why a side table and not an in-record column
Trigram (pg_trgm) indexes plain text only, so an in-record column (the
relation_search pattern) forces one concatenated text per record — and
lossy GIN-trigram rechecks then re-read that whole (often TOASTed) text per
candidate row, which measured slower than the un-indexed scan. One row
per value is what makes the recheck a short string and the component scoping
index-resolvable; relation_search works as a column because its @>
semantics are exact-match, which jsonb GIN serves per-element.
The relation index (matrix_relation_index)
The relation twin of the string store: one typed row per locator stored in
any relation column —
matrix_relation_index (
section_tipo varchar(64), -- the OWNING record
section_id integer,
from_component_tipo varchar(64), -- the component holding the locator
type varchar(64), -- locator type (dd151…)
target_section_tipo varchar(64), -- the record it points AT
target_section_id integer -- signed: system refs (user -1) included
)
— with three btree indexes (target-side for inverse lookups, from-side for the
sync trigger, type-side for delete-propagation shapes), kept in sync by
{table}_relation_index_sync row triggers on the same content-table list as
the string store. Derived and never authoritative: consumers either treat
it as an exact-but-rebuildable index behind a coverage gate, or read it for
integrity reporting.
The index is the only relation engine — every consumer runs on it:
| consumer | shape | measured |
|---|---|---|
search_related.ts finds/counts (relation_list panels, children, observers, delete propagation, diffusion) |
one btree query, GROUP BY owner, op AND via HAVING bool_or |
st_si 14→0.3 ms · fct_st_si 6→0.2 ms · ty_st 1,014→54 ms (vs the retired flat GIN) |
breakdown (findInverseReferenceLocators — exact locator payload recovery) |
tuple-IN row-narrowing + a jsonb cross-join (only the payload side touches the jsonb, so it stays exact) | — |
relation filter leaves (autocomplete catalogue pre-filter): canonical format:'relation' (q = partial locator object(s)) + the DEPRECATED format:'function' reader (WC-012) |
exact tuple-IN over typed bound params — see sqo.md → Relation filter leaves | — |
database_info.relation_integrity_report (maintenance) |
dangling-target anti-joins + non-integer locator census | first run on MIB: 5,148 dangling refs found |
Coverage (relationIndexCovers = triggers present AND the index backfilled —
or legitimately empty because the source tables hold no locators at all) is a
requirement, not an optimization gate: an uncovered instance fails loudly
(requireRelationIndex) with the remediation — Area Maintenance → Database
info → Recreate database assets, then Backfill search stores — instead
of silently degrading. In practice an uncovered instance should not survive a
restart: the boot self-provisioning (ensureSearchStores(), see the string
store section above) heals a previous-beta database automatically; the widget
actions are the manual repair for a damaged store or a boot-provisioning
failure. Fresh installs and v6→v7 closures arrive with everything in place.
The v6-era flat functions are gone (2026-07-20)
Earlier engines answered these queries with four flattening functions
(data_relations_flat_{st_si,fct_st_si,ty_st_si,ty_st}) that projected
the nested relation JSONB into flat "a_b_c" strings, plus a functional
GIN index per function per table (~411 MB, five GIN updates on every
relation write). v7 removed the functions, their indexes and every SQL
path that called them — the definitions survive only as drop-only
cleanup entries in db_pg_definitions.json, the v6→v7 update drops
them (both name families) on upgraded installs, and the fresh-install
dump ships without them. The client-side vocabulary was cleaned the next
day (WC-012 amendment 3): the shipped client emits format:'relation'
with a partial-locator q; the old format:'function' /
use_function:'relations_flat_*' spelling survives only as a deprecated
reader — see sqo.md → Relation filter leaves.
This is NOT the v6 relations table
v6's table was application-maintained (drift → a dedicated regenerate
tool), the join spine of every search, redundant with the JSONB path, and
indexed the activity logs (~50M rows) while dropping the locator type.
This index inverts each decision: DB triggers in the write transaction,
never a hot-path join spine (consumers enter by id set or tuple-IN),
replaces the flat-GIN redundancy instead of adding to it, content tables
only (~9.7M rows at MIB), type kept.
Multi-section UNION
buildSearchSql() builds one UNION ALL branch per distinct matrix table. Each
branch keeps the same main alias (mix); only the main FROM <table> AS mix is
swapped via an exact-substring String.replace (never a regex — a regex over
the generated SQL would corrupt correlated subqueries). The outer ORDER BY
strips the mix. qualifier because UNION result columns aren't alias-qualified
(stripAliasPrefix()).
Pipeline entry & lifecycle
There is no search class and no get_instance(). The single compile entry
is a pure async function:
export async function buildSearchSql(
sqo: Sqo,
options: SearchOptions = {} // { principal? } — non-admin ⇒ projects filter applies
): Promise<{ sql: string; params: unknown[] }>
Mode selection happens above the engine:
// mode → path:
// 'tm' → pickReadSource('tm') → read_tm.ts (matrix_time_machine)
// 'related' → search_related.ts (findInverseReferences / countInverseReferences)
// 'edit' | 'list' | default → buildSearchSql() → matrix_* tables
buildSearchSql() throws when the SQO carries no resolvable section tipo (no
matrix table), and when it hits uncovered scope (multi-hop ORDER on unported
shapes, non-admin multi-section search over a project-gated section, group_by,
children_recursive) — never a silent narrowing.
Typical usage
import { sanitizeClientSqo, type Sqo } from '../concepts/sqo.ts';
import { buildSearchSql } from '../search/sql_assembler.ts';
import { sql } from '../db/postgres.ts';
// 1. build a Sqo (server-internal — trusted; a client Sqo would be
// sanitized with sanitizeClientSqo() at the API gate instead)
const sqo: Sqo = sanitizeClientSqo({
section_tipo: ['rsc197'], // People
mode: 'list',
limit: 50,
offset: 0,
// filter: { ...Mango-style filter... }
});
// 2. compile to prepared SQL + params (projects filter applies when principal is a non-admin)
const { sql: query, params } = await buildSearchSql(sqo, { principal });
// 3a. fetch records
const rows = await sql.unsafe(query, params as (string | number | null)[]);
for (const row of rows) {
// row.section_id, row.section_tipo, decoded JSON columns ...
}
// 3b. or count (full_count variant)
const total = await countSectionRecords(principal, 'rsc197'); // number | null
Untrusted vs trusted SQOs
A client SQO arriving over the API is run through sanitizeClientSqo() first
(strips server-only fields, forces parsed=false, clamps limit to the
client ceiling CLIENT_MAX_LIMIT, coerces offset/total to ints).
Server code that builds its own Sqo is trusted and skips that step. Both
are protected by identifier_gate.ts.
Public API
Grouped by concern. All are plain exported functions (no class methods) unless noted.
Compile & execute (sql_assembler.ts)
| symbol | purpose |
|---|---|
buildSearchSql(sqo, options) |
The compile entry: conform the SQO, assemble FROM→SELECT→ORDER→WHERE, add the projects filter / filter_by_locators / multi-section UNION, return {sql, params}. |
trimTipo(tipo) |
Contract a tipo for compact SQL aliases (rsc453 → rs453); null on malformed input. |
renderConformedFilter(node, params) |
Render one conformed filter tree to a WHERE fragment against a caller-owned ParamsCollector (reused by the TM read, which owns its own query shell). |
SearchOptions |
{ principal? } — scopes the projects ACL. |
Conform / dispatch to builders (conform.ts)
| symbol | purpose |
|---|---|
conformFilter(filter, alias, table) |
Recursively conform a filter node: $and/$or/$not/$nand/$nor groups + leaves; returns a ConformedFilter tree. |
buildJoinChain(path, mainAlias) |
Build the LEFT JOIN LATERAL jsonb_array_elements(...) + matrix-table join chain for a multi-hop path; gates every hop's tipos. Used by filter leaves and ORDER paths. |
ConformedFilter / JoinFragment |
The conform-tree types the assembler consumes. |
Identifier allowlists (identifier_gate.ts)
| symbol | purpose |
|---|---|
assertValidTipo(v, where) |
Throw unless ^[a-z]+[0-9]+$ — gate for tipos interpolated into JSONB keys. |
assertValidTipoOrColumn(v, where) |
Throw unless a valid tipo or a bare data column — path.component_tipo accepts both, e.g. ordering by section_id. |
assertValidLang(v, where) |
Throw unless ^(lg-[a-z0-9_]+|all)$. |
assertValidDataColumn(v, where) / isValidDataColumn(v) |
The allowlist of real matrix columns, plus the structural and Time Machine columns. |
VALID_DATA_COLUMNS |
The exported column allowlist. |
Per-component builders (builders/)
| symbol | purpose |
|---|---|
buildStringFragment |
component_input_text / component_text_area / component_email — LIKE/=/accent-insensitive, q_split fan-out, the !! duplicated-operator self-join. |
buildNumberFragment |
component_number — numeric comparisons. |
buildIriFragment |
component_iri. |
buildDateFragment |
component_date — jsonpath time-range comparisons. |
buildSectionIdFragment |
component_section_id — structural section_id predicates (incl. between). |
getRelationSearchFragmentBuilder(model) |
The relation family's search face (src/core/relations/registry.ts): the shared JSONB-containment builder; unported relation pipelines throw loudly. |
BuilderContext / BuilderResult / fragment() / compound() |
The builder contract (builders/types.ts): a leaf resolves to false, a Fragment (sentence + _Qn_ tokens), or a CompoundFragment ($and/$or). |
Params (params.ts)
| symbol | purpose |
|---|---|
ParamsCollector |
getPlaceholder(value) → $n (strict dedup); substitute(sentence, tokenValues) swaps _Qn_ tokens; toArray() returns the bound values in $1-first order. |
search_related (relation breakdown, search_related.ts)
| symbol | purpose |
|---|---|
findInverseReferences(...) |
Which records point at a locator — one btree query over matrix_relation_index (coverage required, requireRelationIndex). |
findInverseReferenceLocators(...) |
Exact inverse-locator recovery (the breakdown case). |
countInverseReferences(locators, options) |
The relation_list paginator total, with per-group_by breakdowns. |
getRelationTables() / clearRelatedTablesCache() |
The ontology-enumerated relation-table set (module-level memo). |
Count (count.ts)
| symbol | purpose |
|---|---|
countSectionRecords(principal, sectionTipo) |
Read-gated full_count for one section; null when not countable/accessible so callers can tell "zero" from "no access" — unreadable, untabled, or declaring a table that is not a matrix record store (dd15 → matrix_time_machine). |
How it fits with the rest of Dédalo
- SQO — the query DTO this engine consumes.
search.mddocuments the engine;sqo.mddocuments the contract (filter grammar, field cheat-sheet, theparsedtwo-phase lifecycle). They are companions. - RQO — the request envelope that carries the SQO from the
client;
sanitizeClientSqo()runs while thedd_core_apihandlers indispatch.tsunpack the RQO. - Sections / section —
list views and the per-section navigation build SQOs and call this engine; the
read strategy (
section/read_source.ts) chooses the matrix vs Time Machine source. - Components — each component model's filter leaf is
turned into
{sentence, tokenValues}by a builder undersrc/core/search/builders/, or, for the relation family, by the relations registry. - The engine layer — the search engine depends on the ontology
resolver (
getMatrixTableFromTipo,getModelByTipo, …) for table and model resolution. It holds no cache of its own. - Time Machine — the
matrix_time_machinetable (singledatacolumn instead of tipo-keyed JSONB; defaulttimestamp DESC) is served byread_tm.ts, selected throughpickReadSource('tm'), not bybuildSearchSql().
Examples
A simple filtered, paginated list
const sqo = sanitizeClientSqo({
section_tipo: ['oh1'], // Oral History
mode: 'list',
limit: 20,
offset: 0,
});
const { sql: query, params } = await buildSearchSql(sqo, { principal });
const rows = await sql.unsafe(query, params as (string | number | null)[]);
for (const row of rows) {
// process each matched record
}
Inverse relations (which records point at me)
// resolve the count of every record that references this locator, per group
const related = await countInverseReferences(
[{ section_tipo: 'rsc197', section_id: 3 }], // reference locators
{ sectionTipos: 'all' }
);
// related.total, related.totals_group ...
Reading the generated SQL (debug)
const { sql: query, params } = await buildSearchSql(sqo, { principal });
console.log(query); // 'SELECT ... WHERE ... = $1'
console.log(params); // the bound values, $1 first
The convert_search_object_to_sql_query action of dd_utils_api
(dispatch.ts, the SQO-test-environment maintenance widget, global-admin only)
exposes exactly this: it sanitizes the client SQO, calls buildSearchSql(),
substitutes the $N placeholders back into a human-readable string for display,
then executes the real bound query.
How the search engine is tested
Two flavours, both bun:test:
- unit / SQL-string —
test/unit/search_gates.test.ts,test/unit/relation_search_builders.test.ts,test/unit/search_related.test.ts: build aSqo, callbuildSearchSql()(or one builder), and assert on the generated SQL andparams— or expect a throw for a rejected injection payload or an uncovered scope. - fixture replay —
test/parity/: replay the frozen fixture store and assert the returned ids and rows still match.
Run with bun test test/unit/search_gates.test.ts, or a test/parity/… path.
Related
- SQO — the Search Query Object contract (filter grammar, fields,
parsedlifecycle). - RQO — the request format that wraps an SQO.
- Sections · section — list views and the read-source strategy.
- Components — the per-component fragment builders.
- common — the ontology resolver the engine leans on.
- Locator — the typed pointers
search_relatedresolves. - Architecture overview — where search sits in the request lifecycle.