Item History & Live Search-as-You-Type with Prisma's contains Filter
Food Tracker (React + Express + Prisma)
Chapter 7 · Item History & Live Search-as-You-Type with Prisma's contains Filter
Every item ever added stays in the database forever — Chapter 2's own design decision, active or used. This chapter surfaces that whole history, searchable in real time — and it's the one chapter in this course where a naive Prisma port genuinely regresses a feature that already worked correctly in the sibling course, verified directly against Prisma's own current documentation rather than assumed.
The Search Route — First Pass
Deliberately no status filter, matching the sibling course exactly — this route searches the entire history, active and used items both, since re-adding something bought before is exactly the case this search exists for.
A Real, Verified Regression
The sibling course's own LIKE '%' || ? || '%' is case-insensitive for ASCII automatically — a genuine, well-known SQLite behavior. It's tempting to assume Prisma's contains just carries that same behavior forward on top of the identical SQLite database. Checked directly against Prisma's own current documentation, it doesn't:
mode: "insensitive" option that fixes this cleanly on PostgreSQL or MongoDB is not supported on SQLite at all. So the route above, exactly as written, is case-sensitive — searching "milk" would not find an item named "Milk". This is a genuine functional regression against the sibling course's own working search, not a hypothetical edge case.
The Real Fix: COLLATE NOCASE
Prisma's own documented fix is to add SQLite's COLLATE NOCASE to the column itself, restoring ASCII case-insensitive comparison for every filter type — contains, startsWith, endsWith, and equals alike. Because name already exists as a plain column since Chapter 2, and SQLite doesn't support altering a column's collation in place, this means generating a migration without applying it, editing the SQL by hand, then applying the edited version:
Then editing the generated migration.sql to recreate the table with name declared COLLATE NOCASE — SQLite's own standard pattern for changing an existing column, since columns can't be altered directly (simplified here to the essential steps; a production migration would also need to reapply any indexes):
With that migration applied, the exact same route code from the first pass — nothing about the query itself changes — now matches case-insensitively, because the comparison behavior lives in the column's own collation, not in the query.
--create-only generates the SQL file and stops there, deliberately leaving room to hand-edit it before anything touches the real database — exactly the workflow this fix needs, and a real, documented part of the Prisma CLI, not a workaround.
A Reusable Debounce Hook
Unchanged from the sibling course — firing a search request on every keystroke would flood the server, so this delays the actual fetch until typing pauses:
return () => clearTimeout(timeoutId), every keystroke would still schedule its own timer, and every one of those timers would eventually fire — the delay would only push the flood of requests later, not prevent it. Because useEffect re-runs this whole function on every query change, each run's cleanup cancels the previous run's still-pending timer before scheduling a new one — the same "always clean up what the last effect run started" discipline Chapter 4's camera-stream cleanup already taught, applied here to a timer instead of a media stream.
The Search Component
contains compiles down to — can never use a standard database index efficiently, even with one declared on name, since a match could start anywhere in the string. The same real limitation the sibling course names for its own raw LIKE '%...%' applies here identically; it's a fact about this query shape on SQLite, not something Prisma changes either way. At this app's own realistic scale, that cost is genuinely invisible.
Where This Course Is Headed
Marking items used next — a React action and a Prisma-backed PATCH endpoint, tying directly back into both this chapter's own search results and Chapter 6's own alerts dashboard.
Hands-On Exercises
Explain the real regression this chapter found: why does the naive contains-based search route fail to match "Milk" when a user searches "milk," and why wouldn't simply adding mode: "insensitive" to the query fix it?
📄 View solutionExplain why fixing this requires hand-editing a generated migration file with COLLATE NOCASE, rather than a change to schema.prisma or to the search route's own query code.
📄 View solutionExplain what would happen if useDebouncedSearch's useEffect omitted its cleanup function, and why the fix is described as "the same lesson" as Chapter 4's camera-stream cleanup.
📄 View solutionChapter 7 Quick Reference
- Route: GET /api/items/search?q=... — prisma.item.findMany with contains, no status filter, the full history
- Real, verified regression: Prisma's contains defaults to case-sensitive on SQLite; mode: "insensitive" isn't supported on SQLite at all — unlike the sibling's automatically case-insensitive raw LIKE
- The real fix: COLLATE NOCASE added to the column via a hand-edited migration (prisma migrate dev --create-only, edit the SQL, then apply it)
- Honest limit, unchanged from the sibling: a leading wildcard can't use an index — a SQLite fact, not a Prisma one
- useDebouncedSearch: unchanged from the sibling course — cleanup cancels the previous pending timer on every keystroke
- Next chapter: Marking Items Used