From 984f0fa9e568f48aeb0460ef051b3003044f4037 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:08:50 +0530 Subject: [PATCH 1/9] feat: enable Nitro SQLite database for search Configure experimental database with SQLite connector for FTS. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/vite-config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 5ce7df6..ded4272 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -136,6 +136,15 @@ export async function createViteConfig( output: { dir: resolveOutputDir(projectRoot, preset), }, + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'chronicle-search' }, + }, + }, }, }; } From 7e9f2307178832992fbe9ba89f664f3d3af5b9cb Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:10:11 +0530 Subject: [PATCH 2/9] feat: replace MiniSearch with SQLite FTS5 for full-text search - FTS5 virtual table for ranked full-text search - Indexes page titles, descriptions, and API operations - Fresh index on every deploy (drops and recreates tables) - FTS MATCH with prefix queries for partial matching - Results ranked by relevance via FTS5 rank function Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/api/search.ts | 176 +++++++++++--------- 1 file changed, 93 insertions(+), 83 deletions(-) diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index ac12532..2919abf 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -1,7 +1,7 @@ -import MiniSearch from 'minisearch'; import { defineHandler, HTTPError } from 'nitro'; +import { useDatabase } from 'nitro/database'; import type { OpenAPIV3 } from 'openapi-types'; -import { getSpecSlug } from '@/lib/api-routes'; +import { getSpecSlug, getFirstApiUrl } from '@/lib/api-routes'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; @@ -15,65 +15,86 @@ interface SearchDocument { type: 'page' | 'api'; } -const indexCache = new Map>(); -const docsCache = new Map(); +const indexedVersions = new Set(); -function keyFor(ctx: VersionContext): string { +function versionKey(ctx: VersionContext): string { return ctx.dir ?? '__latest__'; } -function createIndex(docs: SearchDocument[]): MiniSearch { - const index = new MiniSearch({ - fields: ['title', 'content'], - storeFields: ['url', 'title', 'type'], - searchOptions: { - boost: { title: 2 }, - fuzzy: 0.2, - prefix: true - } - }); - index.addAll(docs); - return index; +async function ensureIndex(ctx: VersionContext) { + const key = versionKey(ctx); + if (indexedVersions.has(key)) return; + + const db = useDatabase(); + + await db.sql`DROP TABLE IF EXISTS search_docs`; + await db.sql`DROP TABLE IF EXISTS search_fts`; + + await db.sql`CREATE TABLE IF NOT EXISTS search_docs ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL, + version TEXT NOT NULL + )`; + + await db.sql`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( + title, + content, + content=search_docs, + content_rowid=rowid + )`; + + const docs = await buildDocs(ctx); + for (const doc of docs) { + await db.sql`INSERT INTO search_docs (id, url, title, content, type, version) + VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.content}, ${doc.type}, ${key})`; + } + + await db.sql`INSERT INTO search_fts (rowid, title, content) + SELECT rowid, title, content FROM search_docs WHERE version = ${key}`; + + indexedVersions.add(key); } -async function scanContent(ctx: VersionContext): Promise { +async function buildDocs(ctx: VersionContext): Promise { + const docs: SearchDocument[] = []; + const pages = await getPagesForVersion(ctx); - return pages.map(p => { + for (const p of pages) { const fm = extractFrontmatter(p); - return { + docs.push({ id: p.url, url: p.url, title: fm.title, - content: fm.description ?? '', - type: 'page' as const - }; - }); -} + content: [fm.title, fm.description ?? ''].join(' '), + type: 'page', + }); + } -async function buildApiDocs(ctx: VersionContext): Promise { const config = loadConfig(); const apiConfigs = getApiConfigsForVersion(config, ctx.dir); - if (!apiConfigs.length) return []; - - const docs: SearchDocument[] = []; - const specs = await loadApiSpecs(apiConfigs); - - for (const spec of specs) { - const specSlug = getSpecSlug(spec); - const paths = spec.document.paths ?? {}; - for (const [, pathItem] of Object.entries(paths)) { - if (!pathItem) continue; - for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { - const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; - if (!op?.operationId) continue; - const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`; - docs.push({ - id: url, - url, - title: `${method.toUpperCase()} ${op.summary ?? op.operationId}`, - content: op.description ?? '', - type: 'api' - }); + if (apiConfigs.length) { + const specs = await loadApiSpecs(apiConfigs); + for (const spec of specs) { + const specSlug = getSpecSlug(spec); + const paths = spec.document.paths ?? {}; + for (const [pathStr, pathItem] of Object.entries(paths)) { + if (!pathItem) continue; + for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { + const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; + if (!op) continue; + const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`; + const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(opId)}`; + docs.push({ + id: url, + url, + title: `${method.toUpperCase()} ${op.summary ?? opId}`, + content: [op.summary ?? '', op.description ?? '', pathStr, method.toUpperCase()].join(' '), + type: 'api', + }); + } } } } @@ -81,29 +102,6 @@ async function buildApiDocs(ctx: VersionContext): Promise { return docs; } -async function getDocs(ctx: VersionContext): Promise { - const key = keyFor(ctx); - const cached = docsCache.get(key); - if (cached) return cached; - const [contentDocs, apiDocs] = await Promise.all([ - scanContent(ctx), - buildApiDocs(ctx) - ]); - const docs = [...contentDocs, ...apiDocs]; - docsCache.set(key, docs); - return docs; -} - -async function getIndex(ctx: VersionContext): Promise> { - const key = keyFor(ctx); - const cached = indexCache.get(key); - if (cached) return cached; - const docs = await getDocs(ctx); - const index = createIndex(docs); - indexCache.set(key, index); - return index; -} - function resolveCtx(tag: string | null): VersionContext { if (!tag) return LATEST_CONTEXT; const config = loadConfig(); @@ -121,25 +119,37 @@ export default defineHandler(async event => { const query = event.url.searchParams.get('query') ?? ''; const tag = event.url.searchParams.get('tag'); const ctx = resolveCtx(tag); - const index = await getIndex(ctx); + + await ensureIndex(ctx); + const db = useDatabase(); + const key = versionKey(ctx); if (!query) { - const docs = await getDocs(ctx); - return Response.json(docs - .filter(d => d.type === 'page') - .slice(0, 8) - .map(d => ({ - id: d.id, - url: d.url, - type: d.type, - content: d.title - }))); + const result = await db.sql`SELECT id, url, title, type FROM search_docs + WHERE version = ${key} AND type = 'page' + LIMIT 8`; + return Response.json((result.rows ?? []).map(r => ({ + id: r.id, + url: r.url, + type: r.type, + content: r.title, + }))); } - return Response.json(index.search(query).map(r => ({ + const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' '); + const result = await db.sql`SELECT s.id, s.url, s.title, s.type, + rank + FROM search_fts f + JOIN search_docs s ON s.rowid = f.rowid + WHERE search_fts MATCH ${searchTerm} + AND s.version = ${key} + ORDER BY rank + LIMIT 20`; + + return Response.json((result.rows ?? []).map(r => ({ id: r.id, url: r.url, type: r.type, - content: r.title + content: r.title, }))); }); From c0ccc56650fd7f17d05809bcde8b0f82df8f7472 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:10:38 +0530 Subject: [PATCH 3/9] chore: remove minisearch dependency Replaced by SQLite FTS5 via Nitro database. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index e13ac3b..a9194f6 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -61,7 +61,6 @@ "h3": "^2.0.1-rc.16", "lodash": "^4.17.23", "mermaid": "^11.13.0", - "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", From bd3044ee98dbb81615bc2545fae6decb4725de81 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:14:53 +0530 Subject: [PATCH 4/9] feat: index page body content and headings for FTS - Read raw MDX files and extract headings + body text - Separate FTS columns: title (10x boost), headings (5x), body (1x) - bm25 ranking for relevance-based results - Use fs/promises for async file reading Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/source.ts | 31 +++++++++++++++++++++ packages/chronicle/src/server/api/search.ts | 30 ++++++++++++-------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 581e3e4..a8cac0e 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; import { loader } from 'fumadocs-core/source'; import { flattenTree } from 'fumadocs-core/page-tree'; import type { Root, Node, Folder } from 'fumadocs-core/page-tree'; @@ -240,6 +242,35 @@ export function getOriginalPath(page: { data: unknown }): string { return ((page.data as Record)._originalPath as string) ?? ''; } +export async function getPageSearchContent(page: { data: unknown }): Promise<{ headings: string; body: string }> { + const originalPath = getOriginalPath(page); + if (!originalPath) return { headings: '', body: '' }; + try { + const contentDir = typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : process.cwd(); + const filePath = path.resolve(contentDir, originalPath); + const raw = await fs.readFile(filePath, 'utf-8'); + const withoutFrontmatter = raw.replace(/^---[\s\S]*?---/m, ''); + const headings: string[] = []; + const lines: string[] = []; + for (const line of withoutFrontmatter.split('\n')) { + const headingMatch = line.match(/^#{1,6}\s+(.+)/); + if (headingMatch) { + headings.push(headingMatch[1]); + } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) { + const cleaned = line + .replace(/<[^>]+>/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_~`]+/g, '') + .trim(); + if (cleaned) lines.push(cleaned); + } + } + return { headings: headings.join(' '), body: lines.join(' ') }; + } catch { + return { headings: '', body: '' }; + } +} + interface ReadingTime { text: string; minutes: number; diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index 2919abf..a13e9be 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -4,14 +4,15 @@ import type { OpenAPIV3 } from 'openapi-types'; import { getSpecSlug, getFirstApiUrl } from '@/lib/api-routes'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; -import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; +import { extractFrontmatter, getPageSearchContent, getPagesForVersion } from '@/lib/source'; import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source'; interface SearchDocument { id: string; url: string; title: string; - content: string; + headings: string; + body: string; type: 'page' | 'api'; } @@ -34,26 +35,28 @@ async function ensureIndex(ctx: VersionContext) { id TEXT PRIMARY KEY, url TEXT NOT NULL, title TEXT NOT NULL, - content TEXT NOT NULL, + headings TEXT NOT NULL, + body TEXT NOT NULL, type TEXT NOT NULL, version TEXT NOT NULL )`; await db.sql`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( title, - content, + headings, + body, content=search_docs, content_rowid=rowid )`; const docs = await buildDocs(ctx); for (const doc of docs) { - await db.sql`INSERT INTO search_docs (id, url, title, content, type, version) - VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.content}, ${doc.type}, ${key})`; + await db.sql`INSERT INTO search_docs (id, url, title, headings, body, type, version) + VALUES (${doc.id}, ${doc.url}, ${doc.title}, ${doc.headings}, ${doc.body}, ${doc.type}, ${key})`; } - await db.sql`INSERT INTO search_fts (rowid, title, content) - SELECT rowid, title, content FROM search_docs WHERE version = ${key}`; + await db.sql`INSERT INTO search_fts (rowid, title, headings, body) + SELECT rowid, title, headings, body FROM search_docs WHERE version = ${key}`; indexedVersions.add(key); } @@ -64,11 +67,13 @@ async function buildDocs(ctx: VersionContext): Promise { const pages = await getPagesForVersion(ctx); for (const p of pages) { const fm = extractFrontmatter(p); + const { headings, body } = await getPageSearchContent(p); docs.push({ id: p.url, url: p.url, title: fm.title, - content: [fm.title, fm.description ?? ''].join(' '), + headings, + body: [fm.description ?? '', body].join(' '), type: 'page', }); } @@ -91,7 +96,8 @@ async function buildDocs(ctx: VersionContext): Promise { id: url, url, title: `${method.toUpperCase()} ${op.summary ?? opId}`, - content: [op.summary ?? '', op.description ?? '', pathStr, method.toUpperCase()].join(' '), + headings: op.summary ?? opId, + body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '), type: 'api', }); } @@ -138,12 +144,12 @@ export default defineHandler(async event => { const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' '); const result = await db.sql`SELECT s.id, s.url, s.title, s.type, - rank + bm25(search_fts, 10.0, 5.0, 1.0) AS score FROM search_fts f JOIN search_docs s ON s.rowid = f.rowid WHERE search_fts MATCH ${searchTerm} AND s.version = ${key} - ORDER BY rank + ORDER BY score LIMIT 20`; return Response.json((result.rows ?? []).map(r => ({ From f579ea29bb3e286688986717a629cdc6937eae9b Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:18:55 +0530 Subject: [PATCH 5/9] feat: add match type and snippet to search results - Response includes match field (title/heading/body) - Snippet shows the matched text with surrounding context - Headings stored newline-separated for individual matching - Body snippets show ~120 chars around the match Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/source.ts | 2 +- packages/chronicle/src/server/api/search.ts | 48 ++++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index a8cac0e..dbc8417 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -265,7 +265,7 @@ export async function getPageSearchContent(page: { data: unknown }): Promise<{ h if (cleaned) lines.push(cleaned); } } - return { headings: headings.join(' '), body: lines.join(' ') }; + return { headings: headings.join('\n'), body: lines.join(' ') }; } catch { return { headings: '', body: '' }; } diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index a13e9be..a5bf417 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -108,6 +108,34 @@ async function buildDocs(ctx: VersionContext): Promise { return docs; } +function findMatch( + query: string, + title: string, + headings: string, + body: string, +): { match: 'title' | 'heading' | 'body'; snippet: string } { + if (title.toLowerCase().includes(query)) { + return { match: 'title', snippet: title }; + } + + const headingList = headings.split('\n').filter(Boolean); + for (const h of headingList) { + if (h.toLowerCase().includes(query)) { + return { match: 'heading', snippet: h }; + } + } + + const idx = body.toLowerCase().indexOf(query); + if (idx >= 0) { + const start = Math.max(0, idx - 40); + const end = Math.min(body.length, idx + query.length + 80); + const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : ''); + return { match: 'body', snippet }; + } + + return { match: 'title', snippet: title }; +} + function resolveCtx(tag: string | null): VersionContext { if (!tag) return LATEST_CONTEXT; const config = loadConfig(); @@ -143,7 +171,7 @@ export default defineHandler(async event => { } const searchTerm = query.split(/\s+/).map(t => `"${t}"*`).join(' '); - const result = await db.sql`SELECT s.id, s.url, s.title, s.type, + const result = await db.sql`SELECT s.id, s.url, s.title, s.headings, s.body, s.type, bm25(search_fts, 10.0, 5.0, 1.0) AS score FROM search_fts f JOIN search_docs s ON s.rowid = f.rowid @@ -152,10 +180,16 @@ export default defineHandler(async event => { ORDER BY score LIMIT 20`; - return Response.json((result.rows ?? []).map(r => ({ - id: r.id, - url: r.url, - type: r.type, - content: r.title, - }))); + const queryLower = query.toLowerCase(); + return Response.json((result.rows ?? []).map(r => { + const { match, snippet } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string); + return { + id: r.id, + url: r.url, + type: r.type, + title: r.title, + match, + snippet, + }; + })); }); From b14cb324ebb02ddab64962cbe8663536c5a881a2 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:45:53 +0530 Subject: [PATCH 6/9] feat: readiness endpoint, search UI snippets, title highlights - /api/ready returns 503 until search index is built, 200 after - Lock file in /tmp deleted on startup for pod restart detection - Search results show match type (title/heading/body) with snippets - Matched text highlighted in accent color in both title and snippet - # prefix for heading matches - Fixed race condition with index mutex - Fixed table creation with IF NOT EXISTS - Pass items to Command to disable client-side filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ui/search.module.css | 32 ++++++++++--- .../chronicle/src/components/ui/search.tsx | 45 +++++++++++-------- packages/chronicle/src/server/api/ready.ts | 15 +++++++ packages/chronicle/src/server/api/search.ts | 39 ++++++++++++---- 4 files changed, 99 insertions(+), 32 deletions(-) create mode 100644 packages/chronicle/src/server/api/ready.ts diff --git a/packages/chronicle/src/components/ui/search.module.css b/packages/chronicle/src/components/ui/search.module.css index d36228a..c82985d 100644 --- a/packages/chronicle/src/components/ui/search.module.css +++ b/packages/chronicle/src/components/ui/search.module.css @@ -13,6 +13,7 @@ .list { max-height: 400px; + gap: var(--rs-space-3); } .list :global([cmdk-group-heading]) { @@ -24,13 +25,14 @@ } .item { - height: 32px; + min-height: 40px; padding: var(--rs-space-3); gap: var(--rs-space-3); border-radius: var(--rs-radius-2); cursor: pointer; } + .item[data-selected="true"] { background: var(--rs-color-background-base-primary-hover); } @@ -43,8 +45,9 @@ .resultText { display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + gap: 2px; + min-width: 0; } .headingText { @@ -68,16 +71,35 @@ } .icon { - width: 18px; - height: 18px; + width: 48px; + height: 24px; color: var(--rs-color-foreground-base-secondary); flex-shrink: 0; } +.itemContent :global([class*="badge-module"]) { + min-width: 48px; + justify-content: center; +} + .item[data-selected="true"] .icon { color: var(--rs-color-foreground-accent-primary-hover); } +.snippetText { + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + color: var(--rs-color-foreground-base-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.matchHighlight { + color: var(--rs-color-foreground-accent-primary); + font-weight: var(--rs-font-weight-medium); +} + .pageText :global(mark), .headingText :global(mark) { background: transparent; diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index f5c8bd6..107def1 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -16,6 +16,8 @@ interface SearchResult { url: string; type: string; content: string; + match?: 'title' | 'heading' | 'body'; + snippet?: string; } interface SearchProps { @@ -121,7 +123,7 @@ export function Search({ classNames }: SearchProps) { - + } @@ -171,23 +173,17 @@ export function Search({ classNames }: SearchProps) {
{getResultIcon(result)}
- {result.type === 'heading' ? ( - <> - - - - - - - {getPageTitle(result.url)} - - - ) : ( - - + + + + {result.snippet && result.match === 'heading' && ( + + # + + )} + {result.snippet && result.match === 'body' && ( + + )}
@@ -236,6 +232,19 @@ function HighlightedText({ ); } +function HighlightQuery({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx < 0) return <>{text}; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} + function getResultIcon(result: SearchResult): React.ReactNode { if (!result.url.startsWith('/apis/')) { return result.type === 'page' ? ( diff --git a/packages/chronicle/src/server/api/ready.ts b/packages/chronicle/src/server/api/ready.ts new file mode 100644 index 0000000..b42b56c --- /dev/null +++ b/packages/chronicle/src/server/api/ready.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'nitro'; +import { isSearchReady } from './search'; + +export default defineHandler(() => { + const searchReady = isSearchReady(); + + if (!searchReady) { + return Response.json( + { status: 'not_ready', search: false }, + { status: 503 }, + ); + } + + return Response.json({ status: 'ready', search: true }); +}); diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index a5bf417..bdf6a07 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -16,22 +16,43 @@ interface SearchDocument { type: 'page' | 'api'; } -const indexedVersions = new Set(); +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const LOCK_FILE = path.join(os.tmpdir(), 'chronicle-search-ready'); + +export const indexedVersions = new Set(); +let indexPromise: Promise | null = null; function versionKey(ctx: VersionContext): string { return ctx.dir ?? '__latest__'; } -async function ensureIndex(ctx: VersionContext) { +fs.unlink(LOCK_FILE).catch(() => {}); + +export function isSearchReady(): boolean { + return existsSync(LOCK_FILE); +} + +export async function ensureIndex(ctx: VersionContext) { const key = versionKey(ctx); if (indexedVersions.has(key)) return; + if (indexPromise) return indexPromise; + indexPromise = buildIndex(ctx, key); + await indexPromise; + indexPromise = null; + await fs.writeFile(LOCK_FILE, new Date().toISOString()); +} +async function buildIndex(ctx: VersionContext, key: string) { const db = useDatabase(); - await db.sql`DROP TABLE IF EXISTS search_docs`; - await db.sql`DROP TABLE IF EXISTS search_fts`; + await db.exec('DROP TABLE IF EXISTS search_fts'); + await db.exec('DROP TABLE IF EXISTS search_docs'); - await db.sql`CREATE TABLE IF NOT EXISTS search_docs ( + await db.exec(`CREATE TABLE IF NOT EXISTS search_docs ( id TEXT PRIMARY KEY, url TEXT NOT NULL, title TEXT NOT NULL, @@ -39,15 +60,15 @@ async function ensureIndex(ctx: VersionContext) { body TEXT NOT NULL, type TEXT NOT NULL, version TEXT NOT NULL - )`; + )`); - await db.sql`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( + await db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( title, headings, body, content=search_docs, content_rowid=rowid - )`; + )`); const docs = await buildDocs(ctx); for (const doc of docs) { @@ -187,7 +208,7 @@ export default defineHandler(async event => { id: r.id, url: r.url, type: r.type, - title: r.title, + content: r.title, match, snippet, }; From c4b1e9dfaaa3be4132024b9f780bf08f106ca6c0 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:48:11 +0530 Subject: [PATCH 7/9] docs: add deployment guide with Docker, k8s, Vercel, Netlify Covers build, Docker multi-stage, Docker Compose, Kubernetes deployment with health/readiness probes, service, ingress, Vercel and Netlify configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../basic/content/docs/guides/deployment.mdx | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 examples/basic/content/docs/guides/deployment.mdx diff --git a/examples/basic/content/docs/guides/deployment.mdx b/examples/basic/content/docs/guides/deployment.mdx new file mode 100644 index 0000000..89f54f1 --- /dev/null +++ b/examples/basic/content/docs/guides/deployment.mdx @@ -0,0 +1,187 @@ +--- +title: Deployment +description: Deploy Chronicle to production +order: 3 +--- + +# Deployment + +Chronicle builds to a standalone Node.js server that can be deployed anywhere. + +## Build + +```bash +bunx chronicle build +``` + +This outputs a production build to `.output/`. + +## Start + +```bash +bunx chronicle start +``` + +Starts the production server on port 3000. + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Server port | `3000` | +| `HOST` | Server host | `0.0.0.0` | + +## Docker + +```dockerfile +FROM oven/bun:latest AS builder +WORKDIR /app +COPY . . +RUN bun install +RUN bunx chronicle build + +FROM node:20-slim +WORKDIR /app +COPY --from=builder /app/.output .output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] +``` + +Build and run: + +```bash +docker build -t my-docs . +docker run -p 3000:3000 my-docs +``` + +## Docker Compose + +```yaml +version: '3.8' +services: + docs: + build: . + ports: + - '3000:3000' + restart: unless-stopped +``` + +## Kubernetes + +### Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: chronicle-docs +spec: + replicas: 2 + selector: + matchLabels: + app: chronicle-docs + template: + metadata: + labels: + app: chronicle-docs + spec: + containers: + - name: docs + image: my-docs:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /api/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +``` + +### Service + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: chronicle-docs +spec: + selector: + app: chronicle-docs + ports: + - port: 80 + targetPort: 3000 + type: ClusterIP +``` + +### Ingress + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: chronicle-docs +spec: + rules: + - host: docs.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: chronicle-docs + port: + number: 80 +``` + +### Health Endpoints + +| Endpoint | Purpose | Response | +|---|---|---| +| `GET /api/health` | Liveness probe | `200 {"status":"ok"}` always | +| `GET /api/ready` | Readiness probe | `200 {"status":"ready"}` when search index built, `503` otherwise | + +The readiness probe returns `503` until the search FTS index is built. On pod restart or redeploy, the index rebuilds automatically on first request. Kubernetes will not route traffic to the pod until it reports ready. + +## Vercel + +Add `vercel.json`: + +```json +{ + "buildCommand": "bunx chronicle build", + "outputDirectory": ".output/public", + "rewrites": [ + { "source": "/(.*)", "destination": "/api/server" } + ] +} +``` + +## Netlify + +Add `netlify.toml`: + +```toml +[build] + command = "bunx chronicle build" + publish = ".output/public" + +[[redirects]] + from = "/*" + to = "/.netlify/functions/server" + status = 200 +``` From 24892fb849257ccc4864f72ad1ebb3d2d6e58c2e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:51:24 +0530 Subject: [PATCH 8/9] fix: update lockfile after minisearch removal Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 3 --- 1 file changed, 3 deletions(-) diff --git a/bun.lock b/bun.lock index b743f63..5cf718b 100644 --- a/bun.lock +++ b/bun.lock @@ -44,7 +44,6 @@ "h3": "^2.0.1-rc.16", "lodash": "^4.17.23", "mermaid": "^11.13.0", - "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", @@ -869,8 +868,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], - "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], From 1df546fa48b6227177b4357a27db1818b359e205 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 13 May 2026 14:59:54 +0530 Subject: [PATCH 9/9] fix: lint errors in search handler - Remove unused getFirstApiUrl import - Suppress useDatabase false positive (Nitro DI, not React hook) - Suppress empty catch block on lock file cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/api/search.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index bdf6a07..d790bec 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -1,7 +1,7 @@ import { defineHandler, HTTPError } from 'nitro'; import { useDatabase } from 'nitro/database'; import type { OpenAPIV3 } from 'openapi-types'; -import { getSpecSlug, getFirstApiUrl } from '@/lib/api-routes'; +import { getSpecSlug } from '@/lib/api-routes'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { extractFrontmatter, getPageSearchContent, getPagesForVersion } from '@/lib/source'; @@ -30,6 +30,7 @@ function versionKey(ctx: VersionContext): string { return ctx.dir ?? '__latest__'; } +// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op catch fs.unlink(LOCK_FILE).catch(() => {}); export function isSearchReady(): boolean { @@ -47,6 +48,7 @@ export async function ensureIndex(ctx: VersionContext) { } async function buildIndex(ctx: VersionContext, key: string) { + // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook const db = useDatabase(); await db.exec('DROP TABLE IF EXISTS search_fts'); @@ -176,6 +178,7 @@ export default defineHandler(async event => { const ctx = resolveCtx(tag); await ensureIndex(ctx); + // biome-ignore lint/correctness/useHookAtTopLevel: useDatabase is a Nitro DI accessor, not a React hook const db = useDatabase(); const key = versionKey(ctx);