Skip to content

[Domain] Orders search: id prefix and buyer-email substring - #243

Merged
vedanshujain merged 5 commits into
mainfrom
feat/orders-search-prefix-and-substring
Aug 9, 2026
Merged

[Domain] Orders search: id prefix and buyer-email substring#243
vedanshujain merged 5 commits into
mainfrom
feat/orders-search-prefix-and-substring

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

Summary

Admin orders search moves from exact-match-only to an order-id PREFIX combined, via OR, with a
case-folded buyer-reference SUBSTRING match. %, _, and the escape character itself are
treated as literal characters — the LIKE pattern is escaped and the escaping is pinned by
contract cases, so a search string is never accidentally read as a wildcard. The exact-equality
customer-key lookup paths are deliberately left unchanged and keep their existing indices — a
substring there would fold distinct customers into one history.

The sequential scan this predicate now runs is the documented design, not an oversight: measured
as a floor on a synthetic harness, a searched page costs a list-plus-count pair. A b-tree cannot
serve an unanchored substring, and a trigram index at this scale buys operational surface, not
latency. The port doc, the migration rationale that used to describe the old exact-match index,
and the sibling changesets that referenced the old semantics were all re-pointed so no comment
anywhere still describes exact-only search.

Test plan

Contract-first: the new prefix/substring cases run against the in-memory fake, SQLite, and
Postgres by delegation through the shared store contract — 684 domain tests and 1078
store-postgres PG-tier tests green, including the wildcard, backslash-escaping, and
empty-string pins. The admin HTTP suite for orders is green with the cursor/filter gate
agreement unchanged. All Postgres-tier runs were against a local Postgres test instance.

Changeset included (@otta-sh/domain + @otta-sh/store-postgres minor, @otta-sh/service
patch).

`OrderListFilter.search` matched an exact order id or an exact case-folded
`buyer_ref`. The console never renders a full uuid — it renders the shortest
unique prefix — so the one identifier on screen was the one identifier the list
could not find, and an operator arriving with a fragment of a customer's email
got nothing.

Search now matches an order-id PREFIX or a `buyer_ref` SUBSTRING, ORed, with
`lower()` applied to BOTH sides of both halves. Exact lookups are unchanged: a
whole id is its own prefix, a whole address its own substring. The id half stays
anchored — an unanchored match on hex would surface arbitrary rows.

The fold is explicit because a bare LIKE is case-sensitive on Postgres and
ASCII-case-insensitive on SQLite; only `lower(col) LIKE lower(:pattern)` makes
the fake, SQLite and Postgres agree case for case. `%` and `_` stay literal
(escaped pattern plus `ESCAPE '\'` in SQL; the fake builds no pattern at all).

The sequential scan is the design, and the port now says so where it used to
document the exact-match divergence from products. Measured over 5k orders: the
predicate drops the old BitmapOr over `orders_pkey` + `idx_orders_buyer_ref_lower`
for a seq scan, costing ~3 ms per page in the worst case. A trigram or full-text
index at this scale buys operational surface, not latency.

`OrderCustomerKey.buyerRef` deliberately did NOT follow: it answers whose orders
these are, a substring would fold two customers into one history, and equality is
what keeps the functional index on the plan. A contract case pins the difference.

Every new case runs on the fake, SQLite and Postgres. The cursor gate compares
the search STRING rather than what it selects, so the canonical form on the wire
is unchanged — the admin Orders HTTP suite is untouched apart from added cases.
…emantics

Three port/adapter comments described `OrderListFilter.search` as exact-only to
explain their own choices. That is now false, and each of the three still has a
real reason to differ, so the reasoning is restated rather than deleted:

- Products: title and buyer_ref have CONVERGED on the substring; what remains
  different is `sku`, which stays exact because it is short and renders in full,
  where an order uuid renders only as a short prefix.
- Coupons: the strictest search in the product, and now explicitly neither
  neighbour — a code is chosen, short, and quoted whole.
- The products adapter's builder comment, matching its port.

Comment text only; no behavior, no test change.
…he notes

Review follow-up. Two behaviours the first pass left unpinned, and a set of
comments that overstated what is true.

Pins:
- `\` is a LIKE metacharacter too, and the one the `%`/`_` cases cannot catch:
  unescaped, a search for `a\b` compiles to `%a\b%`, where `\b` means "a literal
  b" — it matches `ab@…` and misses the address that actually contains the
  backslash. Exactly inverted. Verified by removing the rule and watching the
  case fail with the inverted row. The same gap existed in the products suite
  and is closed there too.
- The empty string matches EVERYTHING: every string starts with and contains
  `""`. That is the widest this axis goes, not the narrowest, and the naive
  reading is the opposite. The wire cannot send it (`min(1)`), so it is the
  port's own boundary.

Corrections:
- Migration `0022` no longer names order search among the functional index's
  consumers, and no longer promises that its test would catch a `LIKE` rewrite.
  What that test pins is three EXPLAINed statements; a predicate it does not
  EXPLAIN can drop off the index with nothing turning red, which is what just
  happened.
- The port claimed dialect agreement "byte for byte". It inherits the repo's
  established accepted-divergence caveat instead: SQLite's `lower()` folds ASCII
  only where JS is Unicode-aware. The pattern-side fold moving from JS into SQL
  is stated rather than implied.
- The measured timings are qualified as a floor — a synthetic four-column table,
  no `order_totals` join, and a searched page pays the predicate twice (list
  plus count). The products ~27 ms figure is marked as precedent for accepting a
  scan, not a bound on this one, and 3.359 ms is not rounded down.
- A "newest-first" comment sat over a `.toSorted()` assertion; the assertion now
  makes the claim it was making.
- The products contract's sku comment and one test title still described orders
  search as exact.
- The changeset gains `@otta-sh/service` (its endpoint answers differently for
  the same query) and says why the plugin is not bumped; its opener now
  separates results preserved from plans changed. The three pending sibling
  changesets no longer describe orders search as exact-only, so the released
  changelog will not contradict itself.
The changeset said the explicit `lower()` makes the fake, SQLite and Postgres
agree "case for case". They agree for ASCII. SQLite's built-in `lower()` folds
ASCII only where JS `toLowerCase()` is Unicode-aware, so a non-ASCII buyer_ref
folds differently on sqlite than on pg and the fake — the repo's existing
accepted position, carried by `couponFilterConditions` and `linkGuestOrders`
already and now by the port doc, inherited here rather than introduced. Emails
and hex ids are ASCII, which is why it is accepted rather than solved.

Also re-flows the two paragraphs left ragged by the earlier one-line corrections
to the sibling changesets.
The port's cost note cited an internal tracking code for the exact filtered-set
count. Replaced with the fact it stood for: a searched page issues the list and
the count together, so a real page pays the search predicate twice under the
same filter.
@vedanshujain
vedanshujain merged commit 899bc4c into main Aug 9, 2026
3 checks passed
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