Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/ottabase-template-app-tanstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ TanStack Router + Query template with automated OttaORM migrations and Cloudflar
- **Auth.js** - OAuth, Magic Link, and Credentials authentication
- **Vite** - Fast development server and optimized builds
- **Cloudflare Workers** - D1, KV, R2, Queues, Rate Limiting, Durable Objects
- **In-house Search** - `@ottabase/ottasearch` with D1 FTS + optional Vectorize semantic ranking
- **Mantine + shadcn/ui** - Flexible UI component libraries
- **Jotai** - Global state management

Expand All @@ -31,6 +32,13 @@ curl -X POST http://localhost:3004/api/ottaorm/init
# Done! Visit http://localhost:3003 (frontend) or http://localhost:3004 (when using dev:worker only)
```

## Search setup (admin)

- Open **`/admin/search`** to configure searchable models and index fields.
- Run **Reindex** once after migrations to initialize FTS and build search documents.
- Global spotlight (`/` shortcut) queries `/api/ottasearch/spotlight`.
- If Vectorize binding is missing, admin shows pending setup and search falls back to D1 FTS only.

## Configuration

### Config Files
Expand Down
3 changes: 3 additions & 0 deletions apps/ottabase-template-app-tanstack/ottabase/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
postsTable,
seriesTable,
} from '@ottabase/ottablog';
import { searchDocumentsTable, searchableModelsTable } from '@ottabase/ottasearch';
import { referralTrackingTable } from '@ottabase/referrals';
import { shortlinksTable } from '@ottabase/shortlinks';

Expand All @@ -54,6 +55,8 @@ export {
postTagsTable,
postVersionsTable,
postsTable,
searchDocumentsTable,
searchableModelsTable,
seriesTable,
referralTrackingTable,
shortlinksTable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { searchDocumentsTable } from '@ottabase/ottasearch';
import { BaseModel } from '@ottabase/ottaorm';

export class SearchDocument extends BaseModel {
static entity = 'search_documents';
static table = searchDocumentsTable;
static primaryKey = 'id';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { searchableModelsTable } from '@ottabase/ottasearch';
import { BaseModel } from '@ottabase/ottaorm';

export class SearchableModel extends BaseModel {
static entity = 'searchable_models';
static table = searchableModelsTable;
static primaryKey = 'entityName';

static casts = {
enabled: 'boolean' as const,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// ============================================================

import { defineOttabaseConfig } from '@ottabase/config';
import { searchDocumentsTable, searchableModelsTable } from '@ottabase/ottasearch';

export default defineOttabaseConfig({
// ── App Identity ──────────────────────────────────────────
Expand Down Expand Up @@ -52,7 +53,14 @@ export default defineOttabaseConfig({
// customPackages: {
// myPremiumFeature: { tables: { premiumTable } },
// },
customPackages: {},
customPackages: {
ottasearch: {
tables: {
searchableModelsTable,
searchDocumentsTable,
},
},
},

// ── Feature Configuration ─────────────────────────────────
features: {
Expand Down
1 change: 1 addition & 0 deletions apps/ottabase-template-app-tanstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@ottabase/ottamenu": "workspace:*",
"@ottabase/ottaorm": "workspace:*",
"@ottabase/ottarenderer": "workspace:*",
"@ottabase/ottasearch": "workspace:*",
"@ottabase/ottaselect": "workspace:*",
"@ottabase/ottaupload": "workspace:*",
"@ottabase/queue": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Palette,
Shield,
ShieldCheck,
Search,
UserPlus,
Users,
Power,
Expand Down Expand Up @@ -148,6 +149,13 @@ export function AdminIndexPage() {
icon: Power,
disabled: false,
},
{
title: 'Search Admin',
description: 'Manage searchable models, run D1 FTS indexing, and monitor semantic setup.',
href: '/admin/search',
icon: Search,
disabled: false,
},
{
title: 'System Health',
description: 'View system health metrics and API status.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { api } from '@/lib/api';
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Switch,
} from '@ottabase/ui-shadcn';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';

type SearchModelConfig = {
entityName: string;
modelName: string;
tableName: string;
enabled: boolean;
fields: string[];
lastIndexedAt: number | null;
};

type SearchStatus = {
ftsReady: boolean;
indexedDocuments: number;
enabledModels: number;
hasVectorize: boolean;
pending: string[];
};

async function loadSearchConfig() {
return api<{ models: SearchModelConfig[] }>('/api/ottasearch/config');
}

async function loadSearchStatus() {
return api<SearchStatus>('/api/ottasearch/status');
}

export function AdminSearchPage() {
const [models, setModels] = useState<SearchModelConfig[]>([]);
const [status, setStatus] = useState<SearchStatus | null>(null);
const [busyEntity, setBusyEntity] = useState<string | null>(null);
const [reindexing, setReindexing] = useState(false);

const refresh = async () => {
const [configData, statusData] = await Promise.all([loadSearchConfig(), loadSearchStatus()]);
setModels(configData.models);
setStatus(statusData);
};

useEffect(() => {
void refresh();
}, []);

const pending = useMemo(() => status?.pending ?? [], [status]);

const toggleModel = async (model: SearchModelConfig, enabled: boolean) => {
setBusyEntity(model.entityName);
try {
await api('/api/ottasearch/config', {
method: 'PUT',
body: {
entityName: model.entityName,
enabled,
fields: model.fields,
},
});
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update searchable model');
} finally {
setBusyEntity(null);
}
};

const updateFields = async (model: SearchModelConfig, rawFields: string) => {
const fields = rawFields
.split(',')
.map((item) => item.trim())
.filter(Boolean);

await api('/api/ottasearch/config', {
method: 'PUT',
body: {
entityName: model.entityName,
enabled: model.enabled,
fields,
},
});
await refresh();
};

const runReindex = async (entityName?: string) => {
setReindexing(true);
try {
await api('/api/ottasearch/reindex', {
method: 'POST',
body: {
entityName,
},
});
await refresh();
toast.success(entityName ? `Reindexed ${entityName}` : 'Search index refreshed');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Reindex failed');
} finally {
setReindexing(false);
}
};

return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Search Admin</h1>
<p className="text-muted-foreground mt-2">
Configure searchable models and run in-house indexing (D1 FTS + optional Vectorize semantic
ranking).
</p>
</div>

<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">FTS Index</CardTitle>
<CardDescription>{status?.ftsReady ? 'Ready' : 'Pending setup'}</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Indexed Documents</CardTitle>
<CardDescription>{status?.indexedDocuments ?? 0}</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Enabled Models</CardTitle>
<CardDescription>{status?.enabledModels ?? 0}</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Semantic (Vectorize)</CardTitle>
<CardDescription>{status?.hasVectorize ? 'Enabled' : 'Not configured'}</CardDescription>
</CardHeader>
</Card>
</div>

{pending.length > 0 && (
<Card className="border-amber-500/50">
<CardHeader>
<CardTitle>Pending setup</CardTitle>
<CardDescription>Complete these from admin to fully activate in-house search.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{pending.map((item) => (
<div key={item} className="text-sm text-amber-700 dark:text-amber-300">
• {item}
</div>
))}
</CardContent>
</Card>
)}

<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Searchable models</CardTitle>
<CardDescription>Choose models and fields to index into D1 FTS.</CardDescription>
</div>
<Button onClick={() => runReindex()} disabled={reindexing}>
{reindexing ? 'Reindexing…' : 'Reindex all'}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{models.map((model) => (
<div key={model.entityName} className="rounded border p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium flex items-center gap-2">
{model.entityName}
<Badge variant="outline">{model.tableName}</Badge>
</div>
<div className="text-sm text-muted-foreground">{model.modelName}</div>
</div>
<Switch
checked={model.enabled}
disabled={busyEntity === model.entityName}
onCheckedChange={(checked) => toggleModel(model, checked)}
/>
</div>
<Input
defaultValue={model.fields.join(', ')}
placeholder="title, description, content"
onBlur={(event) => updateFields(model, event.target.value)}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Last indexed:{' '}
{model.lastIndexedAt ? new Date(model.lastIndexedAt).toLocaleString() : 'Never'}
</span>
<Button
size="sm"
variant="outline"
disabled={reindexing}
onClick={() => runReindex(model.entityName)}
>
Reindex model
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { ApiError } from '@ottabase/api';
import { BrandProvider } from '@ottabase/brand-engine-react';
import { I18nProvider } from '@ottabase/i18n/react';
import { OttaQueryProvider } from '@ottabase/ottaorm/client';
import { SpotlightProvider } from '@ottabase/spotlight';
import { createApiSearchHandler, SpotlightProvider } from '@ottabase/spotlight';
import { ProviderState } from '@ottabase/state';
import { ProviderUIBase } from '@ottabase/ui-base';
import { ShadcnProviders } from '@ottabase/ui-shadcn/providers';
Expand Down Expand Up @@ -132,6 +132,32 @@ function ProvidersCore({
fontFamilies: { primary: string; heading: string; monospace: string };
queryConfig: { defaultOptions: { queries: { retry: (n: number, err: unknown) => boolean } } };
}) {
const spotlightSearch = React.useMemo(
() =>
createApiSearchHandler<{
id: string;
label: string;
description?: string;
href?: string;
keywords?: string[];
}>({
api,
endpoint: '/api/ottasearch/spotlight',
transform: (item) => ({
id: item.id,
label: item.label,
description: item.description,
keywords: item.keywords,
onSelect: () => {
if (item.href && typeof window !== 'undefined') {
window.location.href = item.href;
}
},
}),
}),
[api],
);

return (
<OttaQueryProvider apiClient={api} config={queryConfig}>
<ProviderUIBase
Expand All @@ -152,6 +178,9 @@ function ProvidersCore({
<SpotlightProvider
enabled={appConfig.features.spotlight.enabled}
shortcuts={appConfig.features.spotlight.shortcuts}
onSearch={spotlightSearch}
minQueryLength={2}
placeholder="Search app, models, and records..."
>
{children}
</SpotlightProvider>
Expand Down
Loading