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 PORT renamed to SERVER_PORT: Update .env files, container configs, and Helm values.

  • Environment variable SEARCH_CLIENT_TYPE renamed to SEARCH_ENGINE: Accepts opensearch or elasticsearch. Leave unset to auto-detect from the cluster.

  • Docker image arranger-server renamed to arranger-search-server: Update docker-compose.yml, Helm values, and any deployment manifests.

  • getServerSideFilter must return a filter; null and undefined are 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 configure getServerSideFilter at all are unaffected and need no change. To state "no additional restriction" explicitly, return the newly exported getDefaultServerSideFilter(). To state "deny", return a leaf that matches nothing, such as SqonBuilder.matchNothing(fieldName).

  • execute_query's sqon argument is now enforced as required: omitting it fails schema validation instead of reaching the handler, since Zod 4 treats an unknown() 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 what tools/list advertises, not what the server accepts. Clients doing strict-mode function calling against the advertised schema should re-check.

  • SQON nesting is now capped: SqonSchema rejects anything deeper than SQON_MAX_DEPTH (128 in raw JSON nesting, 62 nested combinations) instead of throwing an uncaught RangeError out of safeParse. Real queries nest 2 to 4 levels, so no realistic query comes close. SQON_MAX_DEPTH and checkSqonDepth() are exported so callers can apply a stricter limit of their own.

  • The published SQON JSON Schema at GET /introspection/sqon changed shape: it is now generated by Zod 4's zod.toJSONSchema rather than zod-to-json-schema. It accepts exactly the same SQONs; only how it is written changed. additionalProperties: true became 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 $ref targets a $defs entry root), and operators pairing a canonical name with aliases (in/=, gt/>, wildcard/filter) emit as a two-branch oneOf instead of one flat enum. Strict $ref resolvers benefit; anything pattern-matching the exact JSON should re-check.

  • @overture-stack/sqon and @overture-stack/arranger-types now require Zod 4: zod is a peer dependency at ^4.2.0 on 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_WINDOW is now enforced: Previously present in the env schema but not applied; now caps query results at 10000 by default. Deployments that return more than 10,000 documents must set this explicitly (via env var or per-catalogue table.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 in modules/graphql-router easier to compose in custom deployments.
  • New modules/sqon package (@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 isArray is now boolean | null: null means nothing declared the field's cardinality, which is distinct from false, meaning a configuration declared it single-valued. Consumers that treated the value as a plain boolean will read null as falsy and so as "single-valued", which is the one conflation the third state exists to prevent; check for null explicitly.
  • table.columns, facets.aggs, sets.index/type, and charts.query are now optional: none of these are validated by arrangerRouter'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 how downloads'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 a catalogueId: /{catalogueId}/graphql and GET /introspection/:catalogueId now also resolve a documentType, provided it names exactly one catalogue on the server. A real catalogueId is always checked first and always wins, so this can't be shadowed by a same-named documentType. A documentType shared by more than one catalogue returns 409 (ambiguous_document_type) listing every matching catalogueId, 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 an error object: a machine-readable code and a human-readable message) in GET /introspection and its own GET /introspection/:catalogueId, alongside a server-wide status (healthy/degraded/unhealthy). Its GraphQL endpoint returns 404 instead of taking the process down. New GET /ready readiness 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. New READY_PATH env var (default /ready), mirrors PING_PATH. A failing catalogue's full stack trace and cause chain is only printed to the console when enableDebug is set: by default, only the curated code/message summary is logged, keeping routine startup output readable when several catalogues load concurrently. New permission_denied error 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 as failed with the new schema_build_error code, instead of being silently mounted as available while every request under it returned a generic 500.
  • New nestingPrefix catalogue 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 under data), setting nestingPrefix (a dotted path, e.g. "data" or "envelope.payload" for deeper envelopes) unwraps the mapping at that path during schema generation, so extended.json/facets.json/table.json can 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 with nestingPrefix set 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 (new nesting_prefix_not_found error code, reported via GET /introspection like 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.json continue to reference fields by their natural raw path, no config changes required. facets.json specifically 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 with schema_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 availability status.
    • 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 isArray for 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 from type, because Elasticsearch never enforces cardinality against its mapping: any field can hold an array unless a configuration says otherwise. Three states are distinguishable. true means a configuration declared it multi-valued, false means one declared it single-valued, and null means nothing declared it either way. null rather 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. Previously extendFields defaulted the value to false for 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: an all clause needs true to be satisfiable at all, whereas combining two in clauses on one field is only safe when it is false. See docs/reference/05-introspection.md.
  • Network search federation: A catalogue can federate queries across multiple remote Arranger nodes via network.json config. 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_ALIASES and GRAPHQL_MAX_DEPTH env vars or per-catalogue config. Unset by default.
  • CORS configuration: ALLOWED_CORS_ORIGINS env var controls which origins are permitted. Omit to allow all.
  • Catalogue descriptions: Add an optional "description" field to base.json to surface a human-readable label in introspection responses.
  • ROW_ID_FIELD_NAME configurable: The ES field used as the row identifier (default id). Previously hardcoded.
  • DOWNLOAD_STREAM_BUFFER_SIZE default corrected: Fixed incorrect default of 100; now 2000 as documented.
  • Fixed: downloads.maxRows/downloads.allowCustomMaxRows were never enforced: getAllData referenced config property names that didn't exist, so every download ran completely uncapped regardless of configuration. Downloads are now correctly capped at the configured maxRows (default 100), or a caller-supplied value when allowCustomMaxRows is set.
  • Fixed: a not nested directly inside another not lost a level of negation: query building flattens a combination into a same-operator parent, which is sound for and and or because both are associative, and unsound for not, which is not. not[not[X]] therefore compiled to the same query as not[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 to not appearing directly inside not. Access-control filters compose as siblings under and rather than as nested negations, so no access-control path could reach it. Flattening now excludes not for 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. buildAggregations re-applies the filter after the field-removal step that facets require, the export route composes it through the same compileFilter as 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 getDefaultServerSideFilter export 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_sqon tool: builds a validated SQON from plain fieldName/operator/value clauses, 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-English summary built 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 supplied existingSqon was 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 via existingSqon, for narrowing a query that already ran. Covers every operator modules/sqon implements: the single-field operators (in, not-in, some-not-in, all, gt, gte, lt, lte, between) via fieldName, and wildcard text search across several fields at once via fieldNames. One and/or applies per call; mixed AND/OR nesting still requires a hand-written sqon passed to execute_query, as does the planned fuzzy operator. An asterisk inside an in-like value is rejected and redirected to wildcard, since Arranger would otherwise run it as a regular expression rather than matching it literally. The server instructions, execute_query's description, and the query_arranger prompt now all route SQON construction through this tool. See docs/mcp-server.md for the full tool surface.

  • build_sqon's same-field in merge is now conditional on the field's declared cardinality (isArray), and all is 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 via notes so a lower filter count than submitted is explained rather than silent. See docs/mcp-server.md for the full behaviour.

  • execute_query addresses fields whose raw names GraphQL can't use as identifiers, matching the server-side support noted under Server above. fields, sort, and aggregationFields take names exactly as get_catalogue_fields reports 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-diagnosis comes back as donor_info { age_at_diagnosis } under hits and as donor_info__age_at_diagnosis under aggregations. Field names inside a sqon are 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 0 render a small visible stub rather than being invisible.
    • disableIncludeMissing option 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.
  • 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 sessionStorage and 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/ChartsProvider ignored catalogue scoping entirely: same root cause as the Aggregations/QuickSearch bugs in arranger-components (see Components section): ChartsProvider pulled apiFetcher from useArrangerData() context but never forwarded apiUrl, so every chart's network/aggregation query silently went to the unscoped default in multicatalogue mode. Fixed by threading apiUrl through ChartsProvideruseNetworkQueryapiFetcher's url; the fetch-args construction was extracted as buildNetworkQueryFetchArgs for 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.
  • SQONViewer multi-value bubble regression fixed: A filter with multiple values (e.g. an in filter 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 catalogue prop on DataProvider: Scopes a provider to one catalogue on a multicatalogue Arranger server. Omit for existing single-catalogue deployments (unchanged behaviour); set catalogue="my-catalogue-id" to route that provider's requests to {apiUrl}/my-catalogue-id/graphql instead of {apiUrl}/graphql. If you pass a customFetcher, it must honour the url field it receives rather than hardcoding a fixed base URL: DataProvider resolves apiUrl and catalogue into that field before every request, and a fetcher that ignores it will silently keep hitting the unscoped base URL.
  • documentType on DataProvider is now optional, resolved automatically from catalogue: omit it and DataProvider calls the new useArrangerConfig hook internally (GET /{catalogue}/introspection) to discover it, replacing the deprecated hasValidConfig GraphQL query's role as a startup validity check in the process; a resolution failure (catalogue not found, or an ambiguous documentType) surfaces on context as catalogueError instead of a confusing downstream query failure. Adds one request before real queries can start when used this way; passing documentType explicitly (every existing consumer) skips the lookup entirely, unchanged. useArrangerConfig is also exported for standalone use, to validate one or more catalogues before rendering anything. APIFetcherFn's body is now optional, needed for the hook's body-less GET request, and it gained a signal field (an AbortSignal) so a stale request can be cancelled outright when catalogue changes mid-flight, rather than just having its result ignored; both are additive, and any existing customFetcher is unaffected either way.
  • Fixed: aggregation/facet panels (Aggregations) ignored catalogue scoping entirely: unlike the main table (which resolves its base URL through DataProvider's own fetchData), Aggregations pulled the raw, unscoped apiFetcher from context and never forwarded the resolved apiUrl into 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 where apiUrl differs from the ARRANGER_API env default. Fixed by threading apiUrl through AggregationsAggsQuery → the underlying Query's url. No effect on a deployment where apiUrl already matched ARRANGER_API (the common case, including every deployment not using catalogue at all).
  • Fixed: QuickSearch had the identical bug: same root cause and fix as Aggregations above, found in the same audit. QuickSearch pulled apiFetcher from context but never forwarded apiUrl; QuickSearchQuery's options builder (now extracted and exported as getQuickSearchQueryOptions) now includes url: apiUrl.
  • Fixed: useArrangerTheme/withArrangerTheme couldn'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.stringify equality) 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

  • wildcard is now the canonical op for text-pattern search: The operator that performs case-insensitive substring matching across multiple fields was previously named filter. 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/OS wildcard query, so wildcard is the name it carries going forward.

    filter is accepted as an alias and normalizes to wildcard at query-build time; existing serialized SQONs continue to work without any migration. New SQONs should use op: "wildcard".

  • VersionedSqonJsonSchema, SqonJsonSchema, and JsonSchemaObject now exported from the package root: previously only reachable through an internal path. Needed by any consumer that wants to reference the real shape of getVersionedSqonJsonSchema()'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's op to 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 .op after 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. Calling SqonSchema.parse() directly still returns the alias unchanged; use the newly-exported normalizeSqonNode() if you have a reason to validate without the builder. Also newly exported: isGroupNode/isFieldFilter type guards for discriminating a SqonNode by shape.

  • New asCombination() export, for consumers that need a stable, always-a-combination shape: SqonBuilder always collapses a single-item and/or down to its sole child (SqonBuilder.and([oneFilter]).toValue() returns oneFilter, not { op: 'and', content: [oneFilter] }), the same behaviour sqon-builder had. 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 with content as 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: an in filter with an empty value list. It's a leaf, not a combination, so it stays stable under reduceSqon's pruning and under later composition, unlike a hand-constructed negated empty combination. fieldName has no effect on the result, an empty in matches 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-value in filter) could be erased by a same-field filter under and: same-field in filters were always merged by unioning their value lists, so AND(matchNothing('study'), in('study', ['x'])) reduced to in('study', ['x']), silently turning a deny into an allow. An empty in now wins outright when merged under and instead of being unioned away; or is unchanged, where union was already the correct result.

  • Fixed: reduceSqon was 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 second reduceSqon pass never finds more to do than the first.

  • Fixed: merging two same-field filters under not could 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 under not requires flipping the operator itself; none of reduceSqon's merge rules did that; they merged as if not behaved like and. Concretely, not[not-in a:['2','3'], not-in a:['1']] (matches nothing: the two clauses require a to be both in {2,3} and equal to 1, which cannot hold) reduced to not[not-in a:['1','2','3']], which matches a being 1, 2, or 3. Found by differential testing against real query results, not by inspection. reduceSqon no longer merges same-field filters under not at all, for any operator; two clauses on the same field under not are now always kept separate, which costs a missed normalization but never an incorrect one.

  • Fixed: merging two same-field in filters under and widened 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 to in a:['1','2'], matching more documents than either clause alone. reduceSqon does not compute intersections, so in clauses under and are no longer merged at all; two separate in clauses under and already compile to two terms clauses under Elasticsearch's bool.must, which correctly evaluates as their intersection without any pre-computation needed. or is unaffected, union is the correct merge there. Confirmed this doesn't affect arranger-components' facet selection UI, which has its own independent same-field merge logic and never calls reduceSqon for it.

  • Fixed: same-field filter matching ignored pivot in three places: a pivot scopes 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 compares pivot; two same-field leaves with different pivots are left as separate clauses instead of merged.
    • The exported checkMatchingFilter, used by removeExactFilter, now also compares pivot.
    • SqonBuilderHandle.setFilter always constructs an unpivoted leaf (it has no pivot parameter), 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. removeFilter is unaffected by design: it's a bulk, field-scoped removal regardless of pivot, documented as such; use removeExactFilter to target one pivot specifically.
  • Fixed: isFieldFilter threw on a node with no content object instead of returning false. Guarded against content being undefined, null, or non-object.

  • Fixed: setFilter silently inverted the requested condition when the current SQON's top level was a not combination: a not'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']) against not[in a:['x'], in b:['y']] (a≠x ∧ b≠y) produced not[in a:['z'], in b:['y']] (a≠z ∧ b≠y), the caller having asked to assert a is in ['z'] and silently gotten a 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 the not, negated the same way). No silently-correct rewrite exists for every operator: only in/not-in and gt/lte/gte/lt have a clean single-leaf inverse, and treating the not as opaque and composing alongside it under and can produce an always-false query when the new value collides with one the not already excludes. setFilter now throws when the current SQON's top level is a not, rather than silently producing an incorrect result; reconstruct the SQON without a top-level not first. removeFilter needed no equivalent fix: dropping a child (or a value) from not's content only ever widens the match, which is already correct.

  • getSqonFieldOperatorDetails() entries now include a description: previously only fieldRef/applicableTo/valueType, which left operators with the same value shape indistinguishable, most notably in ("matches any of these values") and all ("contains all of these values"). Every field operator now carries a one-clause statement of what it means, matching the operator-selection table in docs/reference/03-building-sqon-queries.md. Additive: existing consumers of the function are unaffected, and get the new field automatically.

  • Fixed: a between filter with equal bounds silently lost one of them: reduceSqon's value deduplication ran unconditionally on every array-valued leaf, including between's [min, max] pair, which is a fixed-position tuple, not a set of interchangeable options. SqonBuilder.between('age', [30, 30]) deduplicated to value: [30], one element short of what the schema itself requires (length(2)), so the result failed re-validation on the next SqonBuilder.from() or SqonSchema.parse() call, and would have failed to compile correctly wherever between's bounds happened to coincide. Found by extending the property-based test to cover between (previously excluded, along with all, some-not-in, and pivot, now all covered). Deduplication now excludes between explicitly.

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 contains file: dependency references before release.
  • @overture-stack/sqon no longer reads the filesystem at runtime: Its version constant was previously computed by readFileSync-ing the package's own package.json at 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 through arranger-types/arranger-components/arranger-charts. The version is now stamped into a generated file at build/test time instead (scripts/generateVersion.mjs, wired via pretest/prebuild); the shipped bundle contains no node:fs/node:path/node:url references. Also added "sideEffects": false to the package now that its module graph has no remaining top-level side effects.

[3.0.0] and earlier

See git history.