Skip to content

Stack.query() must fail loud, not silently widen: hoist capability checks into the invariant layer; a search that sanitizes to empty matches nothing #113

Description

@cuibonobo

Two facets of the same #56 principle — never present a superset as the filtered result — that the query path violates locally. Both fixes live at/around Stack.query().

C1 — a search that sanitizes to nothing returns everything

buildWhereClause adds the FTS condition only if (sanitized) (packages/sqlite-shared/src/query.ts:131-137):

if (f.search) {
  const sanitized = sanitizeSearch(f.search);
  if (sanitized) { conditions.push(...MATCH...); }
  // else: the search clause silently vanishes → full table returned
}

A search that the sanitizer strips entirely ("*", punctuation-only, a bare stripped operator) drops the filter and returns every record, presented as the search result — the exact silent-superset failure #56 rules out.

Decision: "a query for nothing matches nothing." When f.search is present but sanitizes to empty, force an empty result — push an always-false condition (e.g. a literal 0) rather than omitting the clause. A search for an unsearchable term legitimately has no matches; that's honest, a full-table dump is not.

C2 — hoist the capability pre-check into Stack.query()

APIAdapter.queryRecords already fails loud (APIAdapterCapabilityError, before dispatch) when filter.content/filter.search is used against a server that hasn't declared contentFieldQuery/fullTextSearch (adapter-api/src/index.ts:426-439). But Stack.query() itself performs no such check, so a local adapter that doesn't support a capability silently returns the unfiltered superset — e.g. MemoryAdapter (fullTextSearch: false) ignores f.search entirely and returns everything.

Decision: hoist the check into Stack.query() (the invariant layer), against this.adapter.capabilities, so local and wire behave identically — the "local and remote behave identically" phrasing the spec already uses for ifVersion. Throw before dispatching when filter.search is used without fullTextSearch, or filter.content without contentFieldQuery.

With both: a search against a non-FTS adapter throws (C2) before reaching an adapter; a search against an FTS adapter that sanitizes to empty matches nothing (C1). They compose.

Work items

  • buildWhereClause: sanitized-empty search → always-false condition (matches nothing), not an omitted clause
  • Stack.query(): pre-check filter.search/filter.content against adapter.capabilities; throw StackQueryError when unsupported
  • Reconcile with APIAdapter's existing APIAdapterCapabilityError (defense-in-depth vs. fold-in) and with Require contentFieldQuery for local adapters; keep it negotiable only over the wire #90's contract
  • Tests: search: "*" returns empty, not all; search against MemoryAdapter throws rather than returning the full set; content filter against a contentFieldQuery:false adapter throws

Refs

#56 (never-silently-widen), #50 (query honesty), #90 (contentFieldQuery contract — overlapping). From docs/design-assessment-2026-07.md §C1/§C2 (PR #105).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions