Changelog
September 3, 2026 · View on GitHub
All notable changes to this project are documented here.
This file covers high-level release notes for the Arranger project as a whole. When Changesets is adopted (see roadmap Phase 3.1), individual packages will also gain their own CHANGELOG.md files generated automatically at publish time. This root file is maintained by hand and covers operator- and integrator-facing changes.
[3.1.0] - Unreleased
Breaking changes
-
Environment variable
PORTrenamed toSERVER_PORT: Update.envfiles, container configs, and Helm values. -
Environment variable
SEARCH_CLIENT_TYPErenamed toSEARCH_ENGINE: Acceptsopensearchorelasticsearch. Leave unset to auto-detect from the cluster. -
Docker image
arranger-serverrenamed toarranger-search-server: Updatedocker-compose.yml, Helm values, and any deployment manifests. -
getServerSideFiltermust return a filter;nullandundefinedare no longer accepted: the callback's contract is now total. A callback returning nothing, or returning a filter with no leaf clause at any depth (a negated empty combination, for example), is rejected with an error instead of being treated as "no restriction". Deployments that do not configuregetServerSideFilterat all are unaffected and need no change. To state "no additional restriction" explicitly, return the newly exportedgetDefaultServerSideFilter(). To state "deny", return a leaf that matches nothing, such asSqonBuilder.matchNothing(fieldName). -
execute_query'ssqonargument is now enforced as required: omitting it fails schema validation instead of reaching the handler, since Zod 4 treats anunknown()key as required. The tool already documented it as required, so this closes a gap rather than changing intent, and the error text is unchanged: it still names the fix and the empty-root-SQON form. -
MCP tool input schemas no longer advertise
additionalProperties: false: Zod 4 omits it where Zod 3 emitted it. Unrecognized properties are still stripped at runtime, so this loosens whattools/listadvertises, not what the server accepts. Clients doing strict-mode function calling against the advertised schema should re-check. -
SQON nesting is now capped:
SqonSchemarejects anything deeper thanSQON_MAX_DEPTH(128 in raw JSON nesting, 62 nested combinations) instead of throwing an uncaughtRangeErrorout ofsafeParse. Real queries nest 2 to 4 levels, so no realistic query comes close.SQON_MAX_DEPTHandcheckSqonDepth()are exported so callers can apply a stricter limit of their own. -
The published SQON JSON Schema at
GET /introspection/sqonchanged shape: it is now generated by Zod 4'szod.toJSONSchemarather thanzod-to-json-schema. It accepts exactly the same SQONs; only how it is written changed.additionalProperties: truebecame the equivalent{}, shared value subschemas are inlined instead of referenced through another definition's path, while a group's children now reference the SQON union by name rather than inlining it (so every$reftargets a$defsentry root), and operators pairing a canonical name with aliases (in/=,gt/>,wildcard/filter) emit as a two-branchoneOfinstead of one flatenum. Strict$refresolvers benefit; anything pattern-matching the exact JSON should re-check. -
@overture-stack/sqonand@overture-stack/arranger-typesnow require Zod 4:zodis a peer dependency at^4.2.0on both. Consumers on Zod 3 must upgrade, since these packages export Zod schema instances and two Zod majors in one tree do not interoperate. -
MAX_RESULTS_WINDOWis now enforced: Previously present in the env schema but not applied; now caps query results at10000by default. Deployments that return more than 10,000 documents must set this explicitly (via env var or per-cataloguetable.json).
See docs/reference/08-Migration/v3.1.md for upgrade instructions.
Architecture
- Server abstracted into its own application (
apps/search-server): The Arranger search server is now a separate app rather than part of the main module, making the core routing logic inmodules/graphql-routereasier to compose in custom deployments. - New
modules/sqonpackage (@overture-stack/sqon): Centralizes SQON schema definitions, operator metadata, and validation. Shared by server and client code.
Types (@overture-stack/arranger-types)
- A field's
isArrayis nowboolean | null:nullmeans nothing declared the field's cardinality, which is distinct fromfalse, meaning a configuration declared it single-valued. Consumers that treated the value as a plain boolean will readnullas falsy and so as "single-valued", which is the one conflation the third state exists to prevent; check fornullexplicitly. table.columns,facets.aggs,sets.index/type, andcharts.queryare now optional: none of these are validated byarrangerRouter's own config validation, and all are given complete defaults when omitted, so requiring them from callers didn't match how they're actually used (matching howdownloads's fields were already optional). Non-breaking: any config that already provided these continues to work unchanged.
Server
- Multicatalogue support: A single Arranger server can now serve multiple catalogues simultaneously. Organize configs in subdirectories under
CONFIGS_PATH(one subdirectory per catalogue). Existing flat layouts continue to work as single-catalogue deployments: no migration required. - Catalogue-scoped paths accept a
documentType, not just acatalogueId:/{catalogueId}/graphqlandGET /introspection/:catalogueIdnow also resolve adocumentType, provided it names exactly one catalogue on the server. A realcatalogueIdis always checked first and always wins, so this can't be shadowed by a same-nameddocumentType. AdocumentTypeshared by more than one catalogue returns409(ambiguous_document_type) listing every matchingcatalogueId, rather than silently picking one. See docs/reference/05-introspection.md. - Partial catalogue availability: A catalogue whose search index is missing or unreachable no longer crashes the whole server. It's reported as
failed(with anerrorobject: a machine-readablecodeand a human-readablemessage) inGET /introspectionand its ownGET /introspection/:catalogueId, alongside a server-widestatus(healthy/degraded/unhealthy). Its GraphQL endpoint returns404instead of taking the process down. NewGET /readyreadiness endpoint reflects this aggregate for orchestration probes.GET /ping(liveness) is unaffected and stays blind to catalogue state on purpose, so a search-engine outage doesn't trigger a restart loop. NewREADY_PATHenv var (default/ready), mirrorsPING_PATH. A failing catalogue's full stack trace and cause chain is only printed to the console whenenableDebugis set: by default, only the curatedcode/messagesummary is logged, keeping routine startup output readable when several catalogues load concurrently. Newpermission_deniederror code, for a search engine user lacking the permissions needed to read its index or mapping. A catalogue whose GraphQL schema or endpoint fails to build is now correctly reported asfailedwith the newschema_build_errorcode, instead of being silently mounted asavailablewhile every request under it returned a generic500. - New
nestingPrefixcatalogue config: for a catalogue whose real index documents wrap all their content under one top-level envelope property (for example a Lyric-sourced catalogue, which nests everything underdata), settingnestingPrefix(a dotted path, e.g."data"or"envelope.payload"for deeper envelopes) unwraps the mapping at that path during schema generation, soextended.json/facets.json/table.jsoncan keep referencing clean, unprefixed field names (mapping ingestion), and every filter, aggregation, sort, and response read re-applies the prefix against the real ES paths transparently (per-request query/response translation), so a catalogue withnestingPrefixset behaves identically, end to end, to one whose documents were never wrapped in the first place. A configured value that doesn't match the real mapping fails the catalogue at startup (newnesting_prefix_not_founderror code, reported viaGET /introspectionlike any other catalogue failure) rather than silently falling back and reproducing the exact "everything is null" symptom this feature exists to fix. See docs/reference/01-arranger-configs.md for full detail, including a documented bandwidth tradeoff and a field-level-access-control caveat for future work. - Field names with characters GraphQL can't use as identifiers now work (e.g. a hyphen or leading digit, common in some biomarker/clinical naming conventions): previously any such field crashed schema generation outright. These are now sanitized into valid GraphQL identifiers automatically;
extended.json/table.json/facets.jsoncontinue to reference fields by their natural raw path, no config changes required.facets.jsonspecifically can now reference nested fields by their raw dotted path too (e.g."donor.age"); the previous__-escaped form ("donor__age") still works but is deprecated, see migration guide. The one case sanitization can't resolve automatically, two distinct raw field names colliding on the same sanitized identifier, still fails withschema_build_error, naming the specific colliding fields. - Introspection API: New REST endpoints for tooling and LLM integration:
GET /introspection: Lists all registered catalogues with their document types, GraphQL paths, introspection paths, and availabilitystatus.GET /introspection/:catalogueId: Returns all fields for a catalogue, their ES types, and valid SQON operators grouped by field type.GET /introspection/sqon: Returns the SQON JSON Schema.- See docs/reference/05-introspection.md for full API reference.
- Introspection now reports
isArrayfor every field: whether one document can hold more than one value for that field, which no part of the response previously carried. A consumer cannot infer this fromtype, because Elasticsearch never enforces cardinality against its mapping: any field can hold an array unless a configuration says otherwise. Three states are distinguishable.truemeans a configuration declared it multi-valued,falsemeans one declared it single-valued, andnullmeans nothing declared it either way.nullrather than an omitted key, so a consumer pinned to a published version can tell "nothing declared this field" apart from "this server predates the field", which two absences could not express. PreviouslyextendFieldsdefaulted the value tofalsefor every field whether configured or not, collapsing the last two states into one before the value ever left the server. Which state is the cautious one depends on the operator: anallclause needstrueto be satisfiable at all, whereas combining twoinclauses on one field is only safe when it isfalse. See docs/reference/05-introspection.md. - Network search federation: A catalogue can federate queries across multiple remote Arranger nodes via
network.jsonconfig. Supports passthrough headers for forwarding auth tokens to remote nodes. - GraphQL query complexity limits: Configurable alias count and query depth limits protect against abusive queries. Set via
GRAPHQL_MAX_ALIASESandGRAPHQL_MAX_DEPTHenv vars or per-catalogue config. Unset by default. - CORS configuration:
ALLOWED_CORS_ORIGINSenv var controls which origins are permitted. Omit to allow all. - Catalogue descriptions: Add an optional
"description"field tobase.jsonto surface a human-readable label in introspection responses. ROW_ID_FIELD_NAMEconfigurable: The ES field used as the row identifier (defaultid). Previously hardcoded.DOWNLOAD_STREAM_BUFFER_SIZEdefault corrected: Fixed incorrect default of100; now2000as documented.- Fixed:
downloads.maxRows/downloads.allowCustomMaxRowswere never enforced:getAllDatareferenced config property names that didn't exist, so every download ran completely uncapped regardless of configuration. Downloads are now correctly capped at the configuredmaxRows(default100), or a caller-supplied value whenallowCustomMaxRowsis set. - Fixed: a
notnested directly inside anothernotlost a level of negation: query building flattens a combination into a same-operator parent, which is sound forandandorbecause both are associative, and unsound fornot, which is not.not[not[X]]therefore compiled to the same query asnot[X], so a doubly negated filter matched the complement of the document set it named. Confirmed against a live cluster rather than by inspection.and[not[X]]nested correctly, so the defect was specific tonotappearing directly insidenot. Access-control filters compose as siblings underandrather than as nested negations, so no access-control path could reach it. Flattening now excludesnotfor every operator, and the regression is covered by tests that fail without the fix.
Access control
-
The server-side filter is now applied on every read path: record queries, aggregations, the export route, and federated queries. It was previously composed per call site, which meant a read path could be added without it.
buildAggregationsre-applies the filter after the field-removal step that facets require, the export route composes it through the samecompileFilteras GraphQL reads, and federated queries forward it to remote nodes in the outgoing query variables. -
Federation forwards the filter rather than enforcing it. A remote node applies the SQON it receives; a node that ignores it applies nothing, and the querying node cannot detect that. Treat federated results as trusted only to the extent the remote nodes are.
-
New
getDefaultServerSideFilterexport from@overture-stack/arranger-graphql-router, the value used when no callback is configured. Return it from a custom callback to mean "no additional restriction", rather than returning nothing.
MCP server
-
New
apps/mcp-server: A Model Context Protocol server that exposes Arranger catalogues as LLM-queryable resources and tools. Separate Docker image:ghcr.io/overture-stack/arranger-mcp-server. Implements the MCP Streamable HTTP transport.- Resources: server introspection, SQON schema, per-catalogue fields.
- Tools:
list_catalogues,get_sqon_schema,get_catalogue_fields,build_sqon,execute_query.
-
build_sqontool: builds a validated SQON from plainfieldName/operator/valueclauses, so a model selects conditions instead of writing query JSON. Every clause is checked against the catalogue's own field types and valid operators before anything is built, and one error is reported per invalid clause rather than stopping at the first, so a whole batch can be corrected in one resubmission. Returns the SQON alongside a plain-Englishsummarybuilt from the catalogue's display names (for reading back to the user before the query runs), and reports when equivalent clauses merged, either during the build or because a suppliedexistingSqonwas already redundant on arrival, so a lower filter count than was submitted is explained rather than silent. Optionally extends the SQON from an earlier call viaexistingSqon, for narrowing a query that already ran. Covers every operatormodules/sqonimplements: the single-field operators (in,not-in,some-not-in,all,gt,gte,lt,lte,between) viafieldName, andwildcardtext search across several fields at once viafieldNames. Oneand/orapplies per call; mixed AND/OR nesting still requires a hand-writtensqonpassed toexecute_query, as does the plannedfuzzyoperator. An asterisk inside anin-like value is rejected and redirected towildcard, since Arranger would otherwise run it as a regular expression rather than matching it literally. The server instructions,execute_query's description, and thequery_arrangerprompt now all route SQON construction through this tool. See docs/mcp-server.md for the full tool surface. -
build_sqon's same-fieldinmerge is now conditional on the field's declared cardinality (isArray), andallis gated the same way in the other direction: an ambiguous same-field collision is refused rather than silently resolved, reported as an error naming both readings; a successful merge still reports itself vianotesso a lower filter count than submitted is explained rather than silent. See docs/mcp-server.md for the full behaviour. -
execute_queryaddresses fields whose raw names GraphQL can't use as identifiers, matching the server-side support noted under Server above.fields,sort, andaggregationFieldstake names exactly asget_catalogue_fieldsreports them, including hyphens and leading digits. Results are keyed by the names the generated GraphQL schema uses, which are not always the same string:donor-info.age-at-diagnosiscomes back asdonor_info { age_at_diagnosis }underhitsand asdonor_info__age_at_diagnosisunderaggregations. Field names inside asqonare never rewritten in either direction, since a SQON travels as a query variable rather than as part of the query document. See docs/mcp-server.md.
Charts (@overture-stack/arranger-charts)
The charts module was introduced in this release cycle as a new package.
- Bar chart: Responsive bar chart with configurable colours, tooltips, and sorting. New in 3.1:
- Zero-value suppression: bars with a data value of exactly
0render a small visible stub rather than being invisible. disableIncludeMissingoption to exclude the "missing values" bucket.- Configurable bottom-axis tick values.
- "Top X of Y" display showing how many bars are visible vs. the total bucket count.
- Max bars configurable.
- Sortable by label (in addition to by value).
- Tooltip text wraps on long labels.
- Y axis offset corrected.
- Zero-value suppression: bars with a data value of exactly
- Sunburst chart: Hierarchical proportional chart using nivo, with mapper and max-segments support.
- Numeric aggregations: Range query support and improved range handling.
- Theming: Theme prop for operator customization of chart appearance.
- Colour persistence: Selected colours are saved to
sessionStorageand restored across page loads. - Configurable loading delay: Control the loading state transition duration.
- Composable architecture: Charts refactored to use hooks and single-responsibility context providers rather than a monolithic do-everything component.
- Consistent tooltips: Shared tooltip component and CSS classes used across all chart types, enabling consumer styling via standard class selectors.
- Fixed:
useNetworkQuery/ChartsProviderignoredcataloguescoping entirely: same root cause as theAggregations/QuickSearchbugs inarranger-components(see Components section):ChartsProviderpulledapiFetcherfromuseArrangerData()context but never forwardedapiUrl, so every chart's network/aggregation query silently went to the unscoped default in multicatalogue mode. Fixed by threadingapiUrlthroughChartsProvider→useNetworkQuery→apiFetcher'surl; the fetch-args construction was extracted asbuildNetworkQueryFetchArgsfor direct unit testing.
Components (@overture-stack/arranger-components)
- Select all on facet panel: Facet term aggregations now include a "select all" button to select every visible bucket at once.
- Column width themability: Table header column widths are now configurable via the theme prop.
- Quoted string search in QuickSearch: Quoted phrases are preserved as a single search token rather than split on whitespace.
- Large TSV download: Streaming download for large result sets restored; handles files that exceed the default row limit.
- Accessibility improvements: Table headers, row count selector, and pagination controls updated for keyboard navigation and screen reader compatibility.
- Non-SSR config compatibility: Fixed a type error in config resolution that surfaced in non-server-rendered environments.
SQONViewermulti-value bubble regression fixed: A filter with multiple values (e.g. aninfilter matching several values) was collapsing into one joined bubble instead of one bubble per value, with the operator label incorrectly showing "is" instead of "in". Regressed silently for over a year; now covered by a unit test on the underlying value-normalization logic.- New
catalogueprop onDataProvider: Scopes a provider to one catalogue on a multicatalogue Arranger server. Omit for existing single-catalogue deployments (unchanged behaviour); setcatalogue="my-catalogue-id"to route that provider's requests to{apiUrl}/my-catalogue-id/graphqlinstead of{apiUrl}/graphql. If you pass acustomFetcher, it must honour theurlfield it receives rather than hardcoding a fixed base URL:DataProviderresolvesapiUrlandcatalogueinto that field before every request, and a fetcher that ignores it will silently keep hitting the unscoped base URL. documentTypeonDataProvideris now optional, resolved automatically fromcatalogue: omit it andDataProvidercalls the newuseArrangerConfighook internally (GET /{catalogue}/introspection) to discover it, replacing the deprecatedhasValidConfigGraphQL query's role as a startup validity check in the process; a resolution failure (catalogue not found, or an ambiguousdocumentType) surfaces on context ascatalogueErrorinstead of a confusing downstream query failure. Adds one request before real queries can start when used this way; passingdocumentTypeexplicitly (every existing consumer) skips the lookup entirely, unchanged.useArrangerConfigis also exported for standalone use, to validate one or more catalogues before rendering anything.APIFetcherFn'sbodyis now optional, needed for the hook's body-lessGETrequest, and it gained asignalfield (anAbortSignal) so a stale request can be cancelled outright whencataloguechanges mid-flight, rather than just having its result ignored; both are additive, and any existingcustomFetcheris unaffected either way.- Fixed: aggregation/facet panels (
Aggregations) ignoredcataloguescoping entirely: unlike the main table (which resolves its base URL throughDataProvider's ownfetchData),Aggregationspulled the raw, unscopedapiFetcherfrom context and never forwarded the resolvedapiUrlinto its own query path (AggsQuery), so facet queries silently went to the wrong catalogue in multicatalogue mode, or the wrong server for any single-catalogue deployment whereapiUrldiffers from theARRANGER_APIenv default. Fixed by threadingapiUrlthroughAggregations→AggsQuery→ the underlyingQuery'surl. No effect on a deployment whereapiUrlalready matchedARRANGER_API(the common case, including every deployment not usingcatalogueat all). - Fixed:
QuickSearchhad the identical bug: same root cause and fix asAggregationsabove, found in the same audit.QuickSearchpulledapiFetcherfrom context but never forwardedapiUrl;QuickSearchQuery's options builder (now extracted and exported asgetQuickSearchQueryOptions) now includesurl: apiUrl. - Fixed:
useArrangerTheme/withArrangerThemecouldn't unset a previously-set theme value: theme aggregation merged every caller's contribution into one ever-growing accumulator, additively, forever; removing a key or shrinking an array from a later render had nothing to overlay onto the stale value, so it silently persisted (only a full remount, not a re-render, ever cleared it). Each caller's contribution is now tracked and replaced wholesale on every call instead, then the effective theme is re-derived fresh from all callers' current contributions. Two related gaps fixed in the same pass: array-valued theme properties (e.g.Table.defaultSorting) were merged element-by-element by index rather than replaced wholesale, so shrinking one left stale trailing entries; and the old change-detection (JSON.stringifyequality) silently ignored function-valued properties entirely, so a change confined to a callback body never propagated. The equality check now compares real values (correctly catching key removals, array-length changes, and reordering) while still deliberately treating any two functions as equal, to avoid a re-render on every render of a theme carrying an inline callback.
SQON operators
-
wildcardis now the canonical op for text-pattern search: The operator that performs case-insensitive substring matching across multiple fields was previously namedfilter. That name was misleading in two ways: it collides with the generic meaning of "filter" (every SQON op is a filter), and it falsely implies fuzzy/approximate matching, which is a distinct ES/OS feature (Levenshtein edit-distance) that does not exist yet. The operation is implemented with an ES/OSwildcardquery, sowildcardis the name it carries going forward.filteris accepted as an alias and normalizes towildcardat query-build time; existing serialized SQONs continue to work without any migration. New SQONs should useop: "wildcard". -
VersionedSqonJsonSchema,SqonJsonSchema, andJsonSchemaObjectnow exported from the package root: previously only reachable through an internal path. Needed by any consumer that wants to reference the real shape ofgetVersionedSqonJsonSchema()'s return value instead of a hand-duplicated, looser type. -
All operator aliases now normalize on parse, not just
filter/wildcard:SqonBuilder.from()rewrites every leaf'sopto its canonical form (=->in,>=->gte,filter->wildcard, etc.) before returning, recursively through nested combinations. Previously the schema validated aliases but never normalized them, so code that switched on.opafter parsing (rather than going through the builder's own methods) could accept a query using an alias and then fail to match any canonical branch. CallingSqonSchema.parse()directly still returns the alias unchanged; use the newly-exportednormalizeSqonNode()if you have a reason to validate without the builder. Also newly exported:isGroupNode/isFieldFiltertype guards for discriminating aSqonNodeby shape. -
New
asCombination()export, for consumers that need a stable, always-a-combination shape:SqonBuilderalways collapses a single-itemand/ordown to its sole child (SqonBuilder.and([oneFilter]).toValue()returnsoneFilter, not{ op: 'and', content: [oneFilter] }), the same behavioursqon-builderhad. That's the right default for the common case, but it surprised a consumer whose SQON-rendering code assumed the top level was always a combination withcontentas an array, and crashed the moment a lone filter collapsed to a bare leaf.asCombination(node, op = 'and')wraps a node in a combination if it isn't already one, and never unwraps a single-item result the way builder methods do, use it instead of a hand-written{ op: 'and', content: [node] }literal wherever that stability matters more than the minimal form. -
New
SqonBuilder.matchNothing(fieldName)export, for a filter guaranteed to match nothing: aninfilter with an empty value list. It's a leaf, not a combination, so it stays stable underreduceSqon's pruning and under later composition, unlike a hand-constructed negated empty combination.fieldNamehas no effect on the result, an emptyinmatches nothing regardless of which field it names; a field name is still required since every leaf operator needs one. Use this to express "match nothing" (for example, a denied principal in an access-control filter) rather than constructing it by hand. -
Fixed:
matchNothing()(or any empty-valueinfilter) could be erased by a same-field filter underand: same-fieldinfilters were always merged by unioning their value lists, soAND(matchNothing('study'), in('study', ['x']))reduced toin('study', ['x']), silently turning a deny into an allow. An emptyinnow wins outright when merged underandinstead of being unioned away;oris unchanged, where union was already the correct result. -
Fixed:
reduceSqonwas not idempotent for a same-field filter nested inside a combination: two defects, found in sequence. First, a child combination was folded into its parent before being fully reduced itself, so a same-field filter it only produced through its own internal merging never got offered to the parent's merge check. Second, a merged value array was never deduplicated unless the merge result happened to also get promoted to a bare leaf; a merge that stayed part of a combination (for example, kept alive by a sibling clause on another field) could carry a duplicate value indefinitely. Every child is now fully reduced before being folded into its parent, and every merge result is deduplicated at the point it's produced, so a secondreduceSqonpass never finds more to do than the first. -
Fixed: merging two same-field filters under
notcould invert the query's meaning:not's children are each individually negated (not[A, B]means¬A ∧ ¬B), so combining two same-field clauses correctly undernotrequires flipping the operator itself; none ofreduceSqon's merge rules did that; they merged as ifnotbehaved likeand. Concretely,not[not-in a:['2','3'], not-in a:['1']](matches nothing: the two clauses requireato be both in{2,3}and equal to1, which cannot hold) reduced tonot[not-in a:['1','2','3']], which matchesabeing1,2, or3. Found by differential testing against real query results, not by inspection.reduceSqonno longer merges same-field filters undernotat all, for any operator; two clauses on the same field undernotare now always kept separate, which costs a missed normalization but never an incorrect one. -
Fixed: merging two same-field
infilters underandwidened the match set instead of narrowing it:AND(in a:['1','2'], in a:['1'])requires a document to satisfy both clauses, their intersection, but was merged by unioning the value lists toin a:['1','2'], matching more documents than either clause alone.reduceSqondoes not compute intersections, soinclauses underandare no longer merged at all; two separateinclauses underandalready compile to twotermsclauses under Elasticsearch'sbool.must, which correctly evaluates as their intersection without any pre-computation needed.oris unaffected, union is the correct merge there. Confirmed this doesn't affectarranger-components' facet selection UI, which has its own independent same-field merge logic and never callsreduceSqonfor it. -
Fixed: same-field filter matching ignored
pivotin three places: apivotscopes a nested-field condition to one matched sub-document, so treating two differently-scoped leaves (or one pivoted and one not) as the same filter silently reassigns a condition to the wrong nested scope, or drops its scoping entirely.reduceSqon's merge check now also comparespivot; two same-field leaves with different pivots are left as separate clauses instead of merged.- The exported
checkMatchingFilter, used byremoveExactFilter, now also comparespivot. SqonBuilderHandle.setFilteralways constructs an unpivoted leaf (it has nopivotparameter), but its "replace an existing same-field filter" check didn't require the existing filter to also be unpivoted, so it could silently replace a pivoted filter, discarding its nested scope. It now only replaces an existing unpivoted match, adding the new filter as a separate clause otherwise.removeFilteris unaffected by design: it's a bulk, field-scoped removal regardless of pivot, documented as such; useremoveExactFilterto target one pivot specifically.
-
Fixed:
isFieldFilterthrew on a node with nocontentobject instead of returningfalse. Guarded againstcontentbeingundefined,null, or non-object. -
Fixed:
setFiltersilently inverted the requested condition when the current SQON's top level was anotcombination: anot's children are each independently negated (not[A, B]means¬A ∧ ¬B), so replacing or adding a child there asserts the opposite of what was requested;setFilter('a', 'in', ['z'])againstnot[in a:['x'], in b:['y']](a≠x ∧ b≠y) producednot[in a:['z'], in b:['y']](a≠z ∧ b≠y), the caller having asked to asserta is in ['z']and silently gottena is not in ['z']instead. This affected both the replace path and the append path (no existing match on the target field also landed inside thenot, negated the same way). No silently-correct rewrite exists for every operator: onlyin/not-inandgt/lte/gte/lthave a clean single-leaf inverse, and treating thenotas opaque and composing alongside it underandcan produce an always-false query when the new value collides with one thenotalready excludes.setFilternow throws when the current SQON's top level is anot, rather than silently producing an incorrect result; reconstruct the SQON without a top-levelnotfirst.removeFilterneeded no equivalent fix: dropping a child (or a value) fromnot's content only ever widens the match, which is already correct. -
getSqonFieldOperatorDetails()entries now include adescription: previously onlyfieldRef/applicableTo/valueType, which left operators with the same value shape indistinguishable, most notablyin("matches any of these values") andall("contains all of these values"). Every field operator now carries a one-clause statement of what it means, matching the operator-selection table indocs/reference/03-building-sqon-queries.md. Additive: existing consumers of the function are unaffected, and get the new field automatically. -
Fixed: a
betweenfilter with equal bounds silently lost one of them:reduceSqon's value deduplication ran unconditionally on every array-valued leaf, includingbetween's[min, max]pair, which is a fixed-position tuple, not a set of interchangeable options.SqonBuilder.between('age', [30, 30])deduplicated tovalue: [30], one element short of what the schema itself requires (length(2)), so the result failed re-validation on the nextSqonBuilder.from()orSqonSchema.parse()call, and would have failed to compile correctly whereverbetween's bounds happened to coincide. Found by extending the property-based test to coverbetween(previously excluded, along withall,some-not-in, andpivot, now all covered). Deduplication now excludesbetweenexplicitly.
Infrastructure
- Turborepo: Build and test pipeline uses Turborepo for change detection: only affected packages and their dependents rebuild on each commit.
npm run release:check: New script (scripts/verify-pack.mjs) verifies that no publishable package containsfile:dependency references before release.@overture-stack/sqonno longer reads the filesystem at runtime: Its version constant was previously computed byreadFileSync-ing the package's ownpackage.jsonat module-init time, a Node-only API with no browser equivalent, breaking any bundler building for a browser target (e.g. Vite) that imports the package, directly or transitively througharranger-types/arranger-components/arranger-charts. The version is now stamped into a generated file at build/test time instead (scripts/generateVersion.mjs, wired viapretest/prebuild); the shipped bundle contains nonode:fs/node:path/node:urlreferences. Also added"sideEffects": falseto the package now that its module graph has no remaining top-level side effects.
[3.0.0] and earlier
See git history.