Search System
show.fm implements a two-layer search strategy: client-side fuzzy search for instant results and server-side full-text search for comprehensive queries.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ SEARCH ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ CLIENT LAYER │ │ SERVER LAYER │ │
│ │ (Instant) │ │ (Comprehensive) │ │
│ │ │ │ │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ Fuse.js │ │ │ │ PostgreSQL │ │ │
│ │ │ Fuzzy │ │ ────▶ │ │ Full-Text │ │ │
│ │ │ Search │ │ large │ │ Search │ │ │
│ │ └───────────────┘ │ data │ └───────────────┘ │ │
│ │ │ │ │ │ │
│ │ • <10K items │ │ • GIN indexes │ │
│ │ • In-memory │ │ • Weighted ranks │ │
│ │ • Typo-tolerant │ │ • Trigram fuzzy │ │
│ │ • 0ms latency │ │ • <50ms latency │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘When to Use Each Layer
| Scenario | Layer | Reason |
|---|---|---|
| Command palette | Client (Fuse.js) | Instant feedback on keystroke |
| Episode list filtering | Client (Fuse.js) | Already loaded in memory |
| Global search (>10K items) | Server (FTS) | Too large for client |
| Cross-table search | Server (FTS) | Data not loaded |
| Typo-tolerant search | Both | Trigram on server, Fuse.js on client |
Client-Side Search (Fuse.js)
Location
src/lib/search/
├── index.ts # Barrel exports
├── fuzzy-search.ts # FuzzySearch class wrapper
├── search-store.svelte.ts # Reactive search state
└── command-palette-store.svelte.ts # Global Cmd+K singletonFuzzySearch Class
Type-safe wrapper around Fuse.js:
// src/lib/search/fuzzy-search.ts
import Fuse from 'fuse.js';
export class FuzzySearch<T> {
private fuse: Fuse<T>;
constructor(items: T[], options: Fuse.IFuseOptions<T>) {
this.fuse = new Fuse(items, {
threshold: 0.3, // Typo tolerance (0=exact, 1=match anything)
ignoreLocation: true, // Match anywhere in string
includeScore: true,
includeMatches: true,
minMatchCharLength: 2,
...options
});
}
search(query: string): Fuse.FuseResult<T>[] {
if (!query.trim()) return [];
return this.fuse.search(query);
}
update(items: T[]): void {
this.fuse.setCollection(items);
}
}Factory Functions
Pre-configured search instances for common use cases:
// Episode search
export function createEpisodeSearch(episodes: Episode[]) {
return new FuzzySearch(episodes, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'description', weight: 1 },
{ name: 'show_notes', weight: 0.5 }
]
});
}
// Automation rules search
export function createAutomationSearch(rules: AutomationRule[]) {
return new FuzzySearch(rules, {
keys: [
{ name: 'name', weight: 2 },
{ name: 'description', weight: 1 }
]
});
}Usage in Components
<script lang="ts">
import { createEpisodeSearch } from '$lib/search';
let { episodes } = $props();
const search = createEpisodeSearch(episodes);
let query = $state('');
let results = $derived(query ? search.search(query) : []);
</script>
<input bind:value={query} placeholder="Search episodes..." />
{#each results as result}
<EpisodeCard episode={result.item} score={result.score} />
{/each}Search Store (Reactive State)
createSearchStore
Factory function for reactive search state with URL synchronization:
// src/lib/search/search-store.svelte.ts
interface SearchStoreConfig {
filters: FilterConfig[];
syncToUrl?: boolean;
urlParamPrefix?: string;
}
export function createSearchStore(config: SearchStoreConfig) {
let query = $state('');
let filters = $state<Record<string, FilterValue>>({});
let isOpen = $state(false);
// Derived state
const hasActiveFilters = $derived(
Object.values(filters).some((v) => v !== null && v !== undefined)
);
const urlParams = $derived.by(() => {
const params = new URLSearchParams();
if (query) params.set('q', query);
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, JSON.stringify(value));
});
return params;
});
return {
// Getters
get query() {
return query;
},
get filters() {
return filters;
},
get isOpen() {
return isOpen;
},
get hasActiveFilters() {
return hasActiveFilters;
},
get urlParams() {
return urlParams;
},
// Actions
setQuery(q: string) {
query = q;
},
setFilter(key: string, value: FilterValue) {
filters[key] = value;
},
clearFilters() {
filters = {};
query = '';
},
open() {
isOpen = true;
},
close() {
isOpen = false;
},
// URL sync
loadFromUrl(url: URL) {
query = url.searchParams.get('q') ?? '';
config.filters.forEach((f) => {
const param = url.searchParams.get(f.key);
if (param) filters[f.key] = JSON.parse(param);
});
},
syncToUrl(url: URL) {
const newUrl = new URL(url);
newUrl.search = urlParams.toString();
history.replaceState({}, '', newUrl);
}
};
}Usage Example
<script lang="ts">
import { createSearchStore } from '$lib/search';
import { page } from '$app/stores';
const search = createSearchStore({
filters: [
{ key: 'status', type: 'multi-select', options: ['draft', 'published', 'archived'] },
{ key: 'dateRange', type: 'date-range' }
],
syncToUrl: true
});
// Load from URL on mount
$effect(() => {
search.loadFromUrl($page.url);
});
// Sync to URL on changes
$effect(() => {
if (search.hasActiveFilters || search.query) {
search.syncToUrl($page.url);
}
});
</script>Command Palette Store
Global singleton for Cmd+K search across the application:
// src/lib/search/command-palette-store.svelte.ts
interface CommandPaletteItem {
id: string;
type: 'episode' | 'automation' | 'action' | 'navigation';
title: string;
subtitle?: string;
icon?: string;
action: () => void;
}
function createCommandPaletteStore() {
let isOpen = $state(false);
let query = $state('');
let items = $state<CommandPaletteItem[]>([]);
// Pre-configured fuzzy search
const search = new FuzzySearch<CommandPaletteItem>([], {
keys: [
{ name: 'title', weight: 2 },
{ name: 'subtitle', weight: 1 }
]
});
const searchResults = $derived(query ? search.search(query) : items.slice(0, 10));
const groupedResults = $derived.by(() => {
const groups: Record<string, CommandPaletteItem[]> = {};
searchResults.forEach((r) => {
const item = r.item ?? r;
if (!groups[item.type]) groups[item.type] = [];
groups[item.type].push(item);
});
return groups;
});
return {
get isOpen() {
return isOpen;
},
get query() {
return query;
},
get searchResults() {
return searchResults;
},
get groupedResults() {
return groupedResults;
},
open() {
isOpen = true;
},
close() {
isOpen = false;
query = '';
},
toggle() {
isOpen = !isOpen;
},
setQuery(q: string) {
query = q;
},
// Register items from different sources
setEpisodes(episodes: Episode[]) {
const episodeItems = episodes.map((e) => ({
id: e.id,
type: 'episode' as const,
title: e.title,
subtitle: `Episode ${e.episode_number}`,
action: () => goto(`/p/${e.podcast_slug}/e/${e.slug}`)
}));
items = [...items.filter((i) => i.type !== 'episode'), ...episodeItems];
search.update(items);
},
setNavigationItems(navItems: CommandPaletteItem[]) {
items = [...items.filter((i) => i.type !== 'navigation'), ...navItems];
search.update(items);
}
};
}
export const commandPaletteStore = createCommandPaletteStore();Server-Side Search (PostgreSQL FTS)
Location
src/lib/search/
├── fts-search.ts # FTS query builder and helpers
├── types.ts # FTS type definitions
└── __tests__/
└── fts-search.test.ts # Comprehensive FTS tests (~750 lines)Database Schema
FTS is implemented using generated tsvector columns with GIN indexes:
-- Episode full-text search (migration: 20260112100000)
ALTER TABLE episodes ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX idx_episodes_fts ON episodes USING GIN (fts);
-- Automation rules FTS
ALTER TABLE automation_rules ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX idx_automation_rules_fts ON automation_rules USING GIN (fts);
-- Notification templates FTS
ALTER TABLE notification_templates ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '') || ' ' || coalesce(subject, '')), 'B')
) STORED;
CREATE INDEX idx_notification_templates_fts ON notification_templates USING GIN (fts);FTS-Enabled Tables
| Table | Column | Weights |
|---|---|---|
episodes | fts | title (A), description (B) |
automation_rules | fts | name (A), description (B) |
notification_templates | fts | name (A), description+subject (B) |
Generic FTS Query Builder
Location: src/lib/search/fts-search.ts
The ftsQuery function is a type-safe, chainable query builder for PostgreSQL FTS:
import { ftsQuery, type FtsQueryFilters, type FtsSearchOptions } from '$lib/search';
// Full-featured query
const result = await ftsQuery(
supabase,
'episodes',
{
// Text search (uses websearch syntax by default)
query: 'podcast interview',
// Exact match filters
eq: { podcast_id: 'pod-123', status: 'published' },
// Array IN filters
in: { status: ['draft', 'published', 'scheduled'] },
// Date range filters
dateRange: {
published_at: {
from: new Date('2024-01-01'),
to: new Date('2024-12-31')
}
},
// Ordering (descending by default)
orderBy: { column: 'created_at', ascending: false },
// Pagination (default: page 1, 20 per page)
pagination: { page: 2, perPage: 25 }
},
{
// FTS options
type: 'websearch', // 'websearch' | 'phrase' | 'plain'
config: 'english' // PostgreSQL text search config
}
);
// Result shape
result.data; // T[] - matched rows
result.count; // number - total count
result.page; // number - current page
result.perPage; // number - items per page
result.totalPages; // number - calculated total pagesFilter Types Reference
| Filter | Type | Description |
|---|---|---|
query | string | FTS query (trimmed, empty = skip) |
eq | Record<string, unknown> | Exact match filters |
in | Record<string, unknown[]> | Array IN filters (empty array = skip) |
dateRange | Record<string, { from?, to? }> | Date range with ISO conversion |
orderBy | { column, ascending? } | Column ordering (default: desc) |
pagination | { page, perPage } | Page-based pagination |
Specialized Search Functions
Pre-configured search functions for common use cases:
import { searchEpisodes, searchAutomationRules, searchNotificationTemplates } from '$lib/search';
// Episode search (filters by podcast_id automatically)
const episodes = await searchEpisodes(supabase, 'pod-123', {
query: 'interview',
status: ['draft', 'published'], // Multi-status filter
season: 2, // Season number filter
dateRange: { from, to }, // Published date range
page: 1,
perPage: 20
});
// Automation rules search
const rules = await searchAutomationRules(supabase, 'pod-123', {
query: 'welcome email',
triggerTypes: ['booking.confirmed', 'episode.published'],
isEnabled: true // or false for disabled rules
});
// Notification templates search
const templates = await searchNotificationTemplates(supabase, 'pod-123', {
query: 'reminder'
});Query Utility Functions
Helper functions for building complex FTS queries:
import { escapeSearchQuery, phraseSearch, orSearch, excludeSearch } from '$lib/search';
// Escape special characters (quotes, backslashes)
escapeSearchQuery('search "term"');
// → 'search \\"term\\"'
// Wrap in quotes for exact phrase matching
phraseSearch('exact match');
// → '"exact match"'
// Build OR query (multi-word terms auto-quoted)
orSearch(['podcast', 'radio show']);
// → 'podcast OR "radio show"'
// Build NOT query
excludeSearch('unwanted content');
// → '-"unwanted content"'
// Combine for complex queries
const query = `${orSearch(['interview', 'conversation'])} ${excludeSearch('preview')}`;
// → 'interview OR conversation -preview'Usage in Page Loads
// +page.server.ts
import { searchEpisodes } from '$lib/search';
export const load: PageServerLoad = async ({ locals, url }) => {
const query = url.searchParams.get('q');
const statusFilter = url.searchParams.getAll('status');
const page = parseInt(url.searchParams.get('page') ?? '1');
if (query) {
const result = await searchEpisodes(locals.supabase, podcastId, {
query,
status: statusFilter as EpisodeStatus[],
page,
perPage: 20
});
return {
episodes: result.data,
pagination: {
page: result.page,
perPage: result.perPage,
count: result.count,
totalPages: result.totalPages
},
query
};
}
// Default: no search, return all
const { data: episodes } = await locals.supabase
.from('episodes')
.select('*')
.eq('podcast_id', podcastId)
.order('created_at', { ascending: false });
return { episodes };
};Trigram Fuzzy Search
For typo-tolerant search, trigram indexes are enabled:
-- Enable extension (migration: 20260112110000)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Trigram indexes for fuzzy matching
CREATE INDEX idx_episodes_title_trgm ON episodes USING GIN (title gin_trgm_ops);
CREATE INDEX idx_episodes_description_trgm ON episodes USING GIN (description gin_trgm_ops);
CREATE INDEX idx_automation_rules_name_trgm ON automation_rules USING GIN (name gin_trgm_ops);
## Search UI Components
### SearchInput
```svelte
<!-- src/lib/components/search/SearchInput.svelte -->
<script lang="ts">
let { value = $bindable(), placeholder = 'Search...', onSearch } = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
onSearch?.(value);
}
}
</script>
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
bind:value
{placeholder}
onkeydown={handleKeydown}
class="w-full pl-10 pr-4 py-2 border rounded-md"
/>
</div>FilterBar
<!-- src/lib/components/search/FilterBar.svelte -->
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
let { filters, onFilterChange, onClear } = $props();
const activeFilters = $derived(Object.entries(filters).filter(([_, v]) => v != null));
</script>
<div class="flex flex-wrap gap-2">
{#each activeFilters as [key, value]}
<Badge variant="secondary" class="gap-1">
{key}: {value}
<button onclick={() => onFilterChange(key, null)} class="ml-1">
<X class="h-3 w-3" />
</button>
</Badge>
{/each}
{#if activeFilters.length > 0}
<button onclick={onClear} class="text-sm text-muted-foreground"> Clear all </button>
{/if}
</div>Performance Considerations
Client-Side Limits
| Metric | Threshold | Action |
|---|---|---|
| Item count | < 10,000 | Use Fuse.js |
| Item count | > 10,000 | Use server FTS |
| Query complexity | Simple | Use Fuse.js |
| Cross-table | Yes | Use server FTS |
Server-Side Optimization
- GIN indexes: O(log n) search instead of O(n)
- Weighted ranking: Title matches rank higher
- Trigram threshold: 0.3 default balances recall/precision
- Pagination: Always limit results
Caching Strategy
// Cache FTS results for repeated queries
const searchCache = new Map<string, { data: unknown[]; timestamp: number }>();
const CACHE_TTL = 60_000; // 1 minute
export async function cachedFtsQuery<T>(
supabase: SupabaseClient,
cacheKey: string,
queryFn: () => Promise<T[]>
): Promise<T[]> {
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data as T[];
}
const data = await queryFn();
searchCache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}FTS Test Coverage
Location: src/lib/search/__tests__/fts-search.test.ts (~752 lines)
Test Categories
| Category | Tests | Coverage |
|---|---|---|
| Basic Query Building | 5 | Query construction, text search, whitespace handling |
| Filter Application | 6 | eq, IN, date range (full and partial) |
| Ordering | 2 | Default descending, explicit ascending |
| Pagination | 4 | Default, custom, metadata, totalPages calculation |
| FTS Search Options | 4 | websearch/phrase types, config languages |
| Error Handling | 3 | Database failures, null count/data handling |
| Specialized Functions | 12 | searchEpisodes, searchAutomationRules, searchNotificationTemplates |
| Utility Functions | 14 | escapeSearchQuery, phraseSearch, orSearch, excludeSearch |
| Integration Patterns | 3 | Combined filters, utility composition |
Mock Query Builder Pattern
// Create chainable mock for Supabase query builder
function createMockQueryBuilder(resolvedData: unknown[] = [], count = 0) {
const builder = {
select: vi.fn().mockReturnThis(),
textSearch: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue({
data: resolvedData,
count,
error: null
})
};
return builder;
}
// Usage in tests
const mockQueryBuilder = createMockQueryBuilder([{ id: '1' }], 10);
const mockSupabase = {
from: vi.fn().mockReturnValue(mockQueryBuilder)
} as unknown as SupabaseClient<Database>;
// Assert FTS was applied correctly
await ftsQuery(mockSupabase, 'episodes', { query: 'test' });
expect(mockQueryBuilder.textSearch).toHaveBeenCalledWith('fts', 'test', {
type: 'websearch',
config: 'english'
});Key Test Examples
// Test filter combination
it('should combine multiple filters correctly', async () => {
const from = new Date('2024-01-01');
await ftsQuery(mockSupabase, 'episodes', {
query: 'interview',
eq: { podcast_id: 'pod-123' },
in: { status: ['draft', 'published'] },
dateRange: { published_at: { from } },
orderBy: { column: 'created_at', ascending: false },
pagination: { page: 2, perPage: 25 }
});
expect(mockQueryBuilder.textSearch).toHaveBeenCalled();
expect(mockQueryBuilder.eq).toHaveBeenCalledWith('podcast_id', 'pod-123');
expect(mockQueryBuilder.in).toHaveBeenCalledWith('status', ['draft', 'published']);
expect(mockQueryBuilder.gte).toHaveBeenCalledWith('published_at', from.toISOString());
expect(mockQueryBuilder.range).toHaveBeenCalledWith(25, 49);
});
// Test utility function composition
it('should work with combined utility functions', () => {
const orTerms = orSearch(['podcast', 'radio show']);
const excludeTerm = excludeSearch('test');
const combined = `${orTerms} ${excludeTerm}`;
expect(combined).toBe('podcast OR "radio show" -test');
});Related Documentation
- Supabase Integration - Database and FTS setup
- Architecture Overview - System context
- SvelteKit Routing - Page loads
- Testing Patterns - General testing patterns
- Mock Factories - Supabase mock utilities