@uiresponse/resolver
v0.2.0
Published
Executes one declared UIR dataset's query against a connector. The single host tool behind every UIR page's data.
Maintainers
Readme
UIR resolver
resolve(pageDoc, datasetName, params) executes one declared dataset's query (per
spec/foundations/data.schema.json) against whatever connector its source.connector names, and
returns:
{
rows: object[],
columns: object[], // the dataset's declared columns[]
errors: object[], // structured errors — see index.mjs's header comment for the full list
meta: {
complete: boolean, // see "meta.complete" below
row_count: number, // rows.length, for convenience
limit: number | null, // the EFFECTIVE limit applied (query.limit, or the resolver's own
// DEFAULT_LIMIT when the query names none) — null only when the
// dataset itself couldn't be found at all
},
}meta is additive — everything else about resolve()'s signature and the shape of rows/
columns/errors is unchanged from Stage 3c. See index.mjs's header comment for the full
pipeline (derive → filter → select/group_by → sort → limit → D5 verification → projection).
Wiring docs for specific deployments live with the deployment, not here.
meta.complete
true iff the returned rows are the WHOLE matching set — nothing was cut short by the query's
limit, and the connector itself didn't cap its own fetch short of the true count either. Both have
to hold:
- Resolver-side: the row count before the
limitslice was strictly less than the effective limit. Landing on EXACTLY the limit is treated as "maybe there's more, can't tell" — conservatively not complete, rather than assuming the count hit the limit by coincidence. (Confirmed against live data:artist-pipeline.page.json'spipelinedataset,limit: 500, against a resource holding more than that, returns exactly 500 rows andcomplete: false— correctly, since there genuinely are more.) - Connector-side: a connector reports its own completeness by returning either a plain
rows[]array (the implicit claim: "I never cap, this is everything" — true forconnectors/seed.mjs, which always returns its whole fixture) or{rows, complete}when it CAN cap and wants to say so honestly (connectors/postgrest.mjs, hitting PostgREST's row limits — this caught a real bug: an unlimited query silently truncated at PostgREST's default 1000-row cap until the connector was taught to paginate and reportcompletehonestly). An object return with nocomplete: trueis treated as NOT complete — an unstated claim is not a claim, and assuming completeness a connector never asserted is exactly the confident-lie failure this field exists to prevent.
Host optimization rule
A host holding a prior, meta.complete: true result for a dataset MAY satisfy a
param-driven re-resolution client-side, by re-evaluating the changed predicate over the rows
it already has — via evaluatePredicateOverRows (below) — instead of calling resolve() again.
Same semantics, zero latency, zero network round-trip.
This is valid when and only when both hold:
- The prior result was complete (
meta.complete === true). Filtering a truncated window silently drops rows that exist beyond the cut — the confident-lie failure R9/D5 already exist to prevent elsewhere in this resolver, showing up again one layer up. A host that filters an incomplete result client-side will show fewer matches than actually exist, with nothing to indicate the difference. - The changed predicate is post-derive, row-level, with no
group_by/aggregate reshaping downstream of it in the dataset's query.derivehas already run and its columns are already real properties on the rows the host is holding, so re-filtering needs no re-derivation — but if the query alsoselects withgroup_by/aggregate, the rows the host holds are already RESHAPED (one row per group, values already collapsed). You cannot correctly "further filter" an already-aggregated result and get what a real re-query with the new filter would produce — the correct rows to aggregate are different from the ones that made it into the old groups. A dataset with noselect/group_byat all (a plain filter → sort → limit, e.g.unsigned-contracts.page.json'sunsigneddataset, orartist-pipeline.page.json'spipelinedataset) is always eligible;stage_counts/monthly_earnings-shaped datasets never are.
Outside those two conditions, a host MUST re-query (call resolve() again). This is a policy
for the HOST to apply, not something this resolver can enforce from the inside — resolve() has
no way to know what a caller intends to do with the rows it hands back.
evaluatePredicateOverRows(rows, filter, params, opts)
import { evaluatePredicateOverRows } from "uir/resolver/index.mjs";
const kept = evaluatePredicateOverRows(priorResult.rows, dataset.query.filter, newParams, { now });Reuses the EXACT SAME predicate grammar resolve() itself runs internally (all/any/not,
every comparison op, $param substitution, $now resolution, optional: true dropping) — a host
never reimplements or drifts from the grammar. It is a mechanical re-filter only: it does not
re-run derive, and it has no opinion on whether using it here is actually eligible per the rule
above — that check is the caller's responsibility.
rows— rows from a priorresolve()call (already carrying any derived columns the filter references).filter— a UIR predicate tree; in practice, usually the dataset's ownquery.filtertaken as-is, since the eligible case is "the same filter, a different parameter value."params— already-resolved parameter values, same shaperesolve()'s third argument takes.opts.now— defaults tonew Date(), exactly likeresolve()'s ownopts.now.
Proven, not just asserted: resolver/tests.mjs resolves unsigned-contracts.page.json's
unsigned dataset once with no artist filter (a complete result), then shows that re-evaluating
that SAME dataset's own filter client-side with evaluatePredicateOverRows for a specific artist
produces bit-for-bit the same rows as a real second resolve() call for that artist.
Writing a connector
A connector is async ({ source, query, params }) => rows[] | { rows: object[], complete: boolean }.
- Return a plain array if your connector never caps short of the true count (the common case —
see
connectors/seed.mjs). - Return
{ rows, complete }if your connector CAN cap (a page-size limit, a server-side default, a hard ceiling) and wantsmeta.completeto reflect reality instead of silently defaulting to "not complete" — seeconnectors/postgrest.mjs'spgrestSelectAll, which pages through PostgREST'sRangeheader until a short page proves there's nothing left, and reportscomplete: false(not an error) if it ever exhausts its page ceiling without finding one. - The resolver always re-applies the FULL query (
derive/filter/select/group_by/sort/limit) regardless of what a connector returns — pushing filter/sort/limit down (asconnectors/postgrest.mjsdoes, conservatively, for a real efficiency win) is optional and never a correctness dependency.
MCP resources
The built-in mcp connector is read-only by construction. A page declares only a logical name:
{ "connector": "mcp", "resource": "release_notes" }The host maps that name to one trusted MCP read operation through UIR_MCP_SERVERS. Each entry
uses either a stdio command or a Streamable HTTP URL and configures exactly one tool or uri:
{
"release_notes": {
"transport": "stdio",
"command": "node",
"args": ["./release-notes-server.mjs"],
"tool": "list_release_notes",
"arguments": { "limit": { "$query": "limit" }, "artist": { "$param": "artist" } },
"description": "Approved release notes.",
"columns": [
{ "name": "release_id", "type": "string", "role": "id" },
{ "name": "published_at", "type": "date", "nullable": true }
],
"timeout_ms": 10000
},
"campaign_briefs": {
"url": "https://mcp.internal.example/mcp",
"uri": "briefs://campaigns/current",
"columns": [{ "name": "campaign", "type": "string" }]
}
}Tool argument templates may contain {$param:"name"}, {$query:"limit"} (including dotted
query paths), {$source:"resource"}, and {$now:true}. The connector performs the MCP
initialize handshake using protocol version 2025-11-25, then only tools/call or
resources/read; it exposes no write path. Returned values must be JSON rows: a tool may return
an array or { "rows": [...] } in structuredContent or JSON text content, and a resource may
return the same shapes as JSON text/blob contents.
MCP calls are optimization-neutral: the shared resolver always reapplies the whole UIR query,
verifies declared column types, and drops undeclared fields. A transport error, MCP error,
malformed result, or timeout becomes connector_error, never a successful empty dataset. Since
MCP has no universal pagination signal, results default to meta.complete: false; set
"complete": true only when that configured operation is guaranteed to return the whole set.
