Skip to content

feat(events): restore header search as its own opt-in facet (#24)#674

Merged
choraria merged 3 commits into
mainfrom
feat-24-header-search-facet
Jul 18, 2026
Merged

feat(events): restore header search as its own opt-in facet (#24)#674
choraria merged 3 commits into
mainfrom
feat-24-header-search-facet

Conversation

@choraria

Copy link
Copy Markdown
Contributor

What & why

Internal to-do #24: restore header search as its own opt-in facet — NOT OR'd into the main search.

Slice 2 deliberately removed the headers::text branch from the main event search: a trigram GIN on headers measured ~88x ingest p99 write-amp and was refused (packages/db/test/ingest-gin-writeamp.pg.test.ts). The fast search now covers only provider_event_id + dedup_key (trigram-indexed, BitmapOr).

This adds a separate, opt-in headerSearch facet — an unindexed case-insensitive substring over headers::text that AND-composes with search and is never OR'd into it, so the fast trigram search stays un-poisoned and a caller who explicitly searches headers accepts a slower (date-bounded, timeout-backstopped) scan. No new index — the 88x refusal stands.

Contract field

// events.list filter
headerSearch: z.string().trim().min(1).max(SEARCH_MAX_LENGTH).optional()

min(1), not the search min(3) trigram floor — that floor exists only because pg_trgm extracts no trigrams below 3 chars, and this scan is unindexed, so it doesn't apply. Reuses SEARCH_MAX_LENGTH.

Surfaces touched (mirrors the slice-3b/slice-4 method/eventType facet pattern)

  • contract (packages/contract) — filter.headerSearch, documented as a separate unindexed residual
  • db (packages/db/src/reads.ts) — its OWN and headers::text ilike … residual in browseEvents, AND-composing; eventSearchFilter untouched. Threaded through the shared api/mcp read-handler
  • api (packages/openapi) — headerSearch query param on both events routes (the facet-parity guard)
  • cli (packages/cli) — wbhk events list --header-search <term> (honest "slower, unindexed" help)
  • mcp (apps/mcp) — honest tool description: separate, unindexed, slower than search, best date-bounded
  • web (apps/web) — a header-search text input that commits on Enter/blur (NOT debounced — each scan is slow), wired through the same applyPatch/URL state as the other facets, on both the per-endpoint and org-wide events pages, with an honest slower-scan hint
  • plan guard (packages/db/test/index-usage.test.ts) — a date-bounded headerSearch case rides events_org_ordered_idx with no blocking Sort (documents the unindexed-by-design residual; adds NO index)
  • goldens — regenerated packages/openapi/src/openapi.json + packages/sdk-ts/src/generated/schema.d.ts

Load-bearing invariants held

  1. headerSearch is its OWN residual that AND-composes — never OR'd into eventSearchFilter/search (a DB test proves an event matching search but not headerSearch is excluded).
  2. No new index on headers::text — the 88x write-amp refusal stands; the plan guard asserts the residual rides the date-ordered path rather than forcing an index.
  3. Honest copy everywhere (contract comment, MCP description, CLI --help, web hint): a slower unindexed scan, best date-bounded.

Tests (strict TDD, red→green)

  • contract: headerSearch validates (min 1 — not the min-3 floor, trims, over-max rejected); AND-composes with search
  • db (real-PG): matches a header substring, excludes a non-match, case-insensitive; AND-composes with search (separate AND'd filters, not OR'd); threads through the shared handler
  • plan guard: date-bounded headerSearch rides the ordered index (no blocking Sort)
  • parity: openapi facet-parity + CLI facet-completeness both extended (they auto-fail on a new contract facet); api router filter test
  • web: parseEventFilters / effectiveHeaderSearch unit tests; filter-bar component tests (commit-on-blur, own ?headerSearch= param, 1-2 char accepted, hint gating, Clear reset)

Full affected suites green; turbo run typecheck clean across all changed packages; prettier + eslint clean.

⚠️ HUMAN-UI VERIFICATION REQUIRED

The web filter-bar addition needs founder visual verification — it was NOT visually verified by the agent:

  • the new "Search request headers" text input renders correctly in the filter bar layout (both the per-endpoint and org-wide events pages)
  • the honest slower-scan hint ("Header search scans the raw request headers… pair it with a date range") appears only while a header search is set
  • it commits on Enter/blur (not as-you-type), and Clear resets it

🤖 Generated with Claude Code

Slice 2 removed the `headers::text` branch from the main event search: a
trigram GIN on headers measured ~88x ingest p99 write-amp and was refused
(packages/db/test/ingest-gin-writeamp.pg.test.ts). The fast `search` now
covers only provider_event_id + dedup_key (trigram-indexed, BitmapOr).

This restores header search as a SEPARATE, opt-in `headerSearch` facet — an
UNINDEXED case-insensitive substring over `headers::text` that AND-composes
with `search` and is NEVER OR'd into it, so the fast trigram search stays
un-poisoned and a caller who explicitly searches headers accepts a slower
(date-bounded, timeout-backstopped) scan. No new index — the 88x refusal
stands.

Mirrors the existing slice-3b/slice-4 facet pattern (method/eventType) across
every surface:
- contract: filter.headerSearch = z.string().trim().min(1).max(SEARCH_MAX_LENGTH)
  (min(1), not the trigram min(3) floor — header search is unindexed)
- db: its own `and headers::text ilike ...` residual in browseEvents; threaded
  through the shared api/mcp read-handler
- api: headerSearch query param on both events routes (facet-parity guard)
- cli: `wbhk events list --header-search <term>` (honest "slower, unindexed" help)
- mcp: honest tool description (separate, unindexed, slower — best date-bounded)
- web: a header-search text input that commits on Enter/blur (NOT debounced —
  each scan is slow), wired through the same applyPatch/URL state, on both the
  per-endpoint and org-wide pages, with an honest slower-scan hint
- plan guard: a date-bounded headerSearch case rides events_org_ordered_idx
  with no blocking Sort (documents the unindexed-by-design residual)

Regenerated the openapi + sdk-ts goldens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RybDP88CyucJpg7hCbJVc9
Adversarial review of PR #674 surfaced four issues, all fixed on this branch.

1. Copy honesty (correctness). Headers are stored as a jsonb ARRAY of
   [name, value] pairs, so `headers::text` serializes to
   `[["x-shopify-topic", "orders/create"]]` — NOT wire form. A user pasting a
   `Name: Value` line matches nothing (the `: ` separator isn't in the text);
   the residual matches a header NAME substring OR a VALUE substring. Reworded
   the contract comment, MCP description, CLI --help, OpenAPI param, and the web
   hint to say "names and values (not a full name: value line)". Regenerated the
   openapi + sdk goldens. Added a DB assertion pinning that a wire-form line
   does not match (name/value substrings still do).

2. Per-endpoint timeout advice (correctness). The per-endpoint events page
   browses ALL-TIME (no 7d default), so the unindexed headerSearch residual can
   deterministically blow the 5s browse timeout (57014). loadEvents discarded
   the reason and the page said "Refresh" (which re-runs the identical timeout).
   Mirrored loadOrgEvents: loadEvents now preserves the 57014→timeout signal and
   the page renders the actionable "narrow the date range" banner. Test added.

3. Extract the commit-on-blur machinery (cleanup). The headerSearch box was a
   near-verbatim copy of the eventType box (state + adopt-effect + committedRef
   + commit + clear reset) — two copies of a hard-won URL-sync discipline that
   would drift. Extracted ONE shared hook (useCommitOnBlurFilter) used by both.
   Existing filter-bar tests are the safety net (all green).

4. e2e coverage (jsdom "cannot see the cascade"). Added a Playwright spec that
   drives the header-search input on the real org events page: type a term,
   Enter, assert ?headerSearch= in the URL and the row filters, then Clear
   resets it. Passes in a real browser. The founder must still eyeball the
   rendered layout + hint copy — the e2e is a floor, not a substitute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RybDP88CyucJpg7hCbJVc9
@choraria
choraria marked this pull request as ready for review July 18, 2026 17:16
@choraria
choraria merged commit e91a7d8 into main Jul 18, 2026
31 of 32 checks passed
@choraria
choraria deleted the feat-24-header-search-facet branch July 18, 2026 17:22
choraria added a commit to webhook-co/webhook-go that referenced this pull request Jul 18, 2026
#14)

Mirrors webhook-co/webhook#674, which added the `headerSearch` opt-in filter to
GET /v1/events and GET /v1/endpoints/{id}/events. The main repo's CI check
"webhook-go vendored spec is current" compares packages/openapi/src/openapi.json
against this testdata/openapi.json; regenerating this copy keeps them in sync so
the route-coverage ratchet sees the new param.


Claude-Session: https://claude.ai/code/session_01RybDP88CyucJpg7hCbJVc9

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant