import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; import { join } from "node:path"; import { resolveWorkspaceRoot, parseSimpleYaml, duckdbQueryAllAsync, discoverDuckDBPaths, duckdbQueryOnFileAsync, isDatabaseFile, } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; /** Safely convert an unknown DB value to a display string. */ function dbStr(val: unknown): string { if (val == null) {return "";} if (typeof val === "object") {return JSON.stringify(val);} return String(val as string | number | boolean); } // --- Types --- export type SearchIndexItem = { /** Unique key: relative path for files, entryId for entries */ id: string; /** Primary display text (filename or display-field value) */ label: string; /** Secondary text (path for files, object name for entries) */ sublabel?: string; /** Item kind for grouping and icons */ kind: "file" | "object" | "entry"; /** Icon hint */ icon?: string; // Entry-specific objectName?: string; entryId?: string; /** First few field key-value pairs for search and preview */ fields?: Record; // File/object-specific path?: string; nodeType?: "document" | "folder" | "file" | "report" | "database"; }; // --- DB types --- type ObjectRow = { id: string; name: string; description?: string; icon?: string; default_view?: string; display_field?: string; }; type FieldRow = { id: string; name: string; type: string; sort_order?: number; }; type EavRow = { entry_id: string; created_at: string; updated_at: string; field_name: string; value: string | null; }; // --- Helpers --- function sqlEscape(s: string): string { return s.replace(/'/g, "''"); } /** Determine the display field (same heuristic as the objects route). */ function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string { if (obj.display_field) {return obj.display_field;} const nameField = fields.find( (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), ); if (nameField) {return nameField.name;} const textField = fields.find((f) => f.type === "text"); if (textField) {return textField.name;} return fields[0]?.name ?? "id"; } /** Flatten a tree recursively to produce file/object search items. */ function flattenTree( absDir: string, relBase: string, dbObjects: Map, items: SearchIndexItem[], ) { let entries: Dirent[]; try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith(".")) {continue;} const absPath = join(absDir, entry.name); const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; if (entry.isDirectory()) { const dbObj = dbObjects.get(entry.name); // Check for .object.yaml const yamlPath = join(absPath, ".object.yaml"); const hasYaml = existsSync(yamlPath); if (dbObj || hasYaml) { let icon: string | undefined; if (hasYaml) { try { const parsed = parseSimpleYaml( readFileSync(yamlPath, "utf-8"), ); icon = parsed.icon as string | undefined; } catch { // ignore } } items.push({ id: relPath, label: entry.name, sublabel: relPath, kind: "object", icon: icon ?? dbObj?.icon, path: relPath, nodeType: undefined, }); } else { // Regular folder -- don't add as item, but recurse } flattenTree(absPath, relPath, dbObjects, items); } else if (entry.isFile()) { const isReport = entry.name.endsWith(".report.json"); const ext = entry.name.split(".").pop()?.toLowerCase(); const isDocument = ext === "md" || ext === "mdx"; const isDatabase = isDatabaseFile(entry.name); items.push({ id: relPath, label: entry.name.replace(/\.md$/, ""), sublabel: relPath, kind: "file", path: relPath, nodeType: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", }); } } } /** * Fetch all entries from all objects across ALL discovered DuckDB files. * Deduplicates objects by name (shallower DBs win). */ async function buildEntryItems(): Promise { const items: SearchIndexItem[] = []; const dbPaths = discoverDuckDBPaths(); if (dbPaths.length === 0) {return [];} // Collect all objects across DBs, deduplicating by name (shallowest wins) const seenNames = new Set(); const objectsWithDb: Array<{ obj: ObjectRow; dbPath: string }> = []; for (const dbPath of dbPaths) { const objs = await duckdbQueryOnFileAsync(dbPath, "SELECT * FROM objects ORDER BY name", ); for (const obj of objs) { if (seenNames.has(obj.name)) {continue;} seenNames.add(obj.name); objectsWithDb.push({ obj, dbPath }); } } for (const { obj, dbPath } of objectsWithDb) { const fields = await duckdbQueryOnFileAsync(dbPath, `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, ); const displayField = resolveDisplayField(obj, fields); const previewFields = fields .filter((f) => !["relation", "richtext"].includes(f.type)) .slice(0, 4); // Try PIVOT view first, then raw EAV (on the same DB) let entries: Record[] = await duckdbQueryOnFileAsync(dbPath, `SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`, ); if (entries.length === 0) { const rawRows = await duckdbQueryOnFileAsync(dbPath, `SELECT e.id as entry_id, e.created_at, e.updated_at, f.name as field_name, ef.value FROM entries e JOIN entry_fields ef ON ef.entry_id = e.id JOIN fields f ON f.id = ef.field_id WHERE e.object_id = '${sqlEscape(obj.id)}' ORDER BY e.created_at DESC LIMIT 2500`, ); const grouped = new Map>(); for (const row of rawRows) { let entry = grouped.get(row.entry_id); if (!entry) { entry = { entry_id: row.entry_id }; grouped.set(row.entry_id, entry); } if (row.field_name) {entry[row.field_name] = row.value;} } entries = Array.from(grouped.values()); } for (const entry of entries) { const entryId = dbStr(entry.entry_id); if (!entryId) {continue;} const displayValue = dbStr(entry[displayField]); const fieldPreview: Record = {}; for (const f of previewFields) { const val = entry[f.name]; if (val != null && val !== "") { fieldPreview[f.name] = dbStr(val); } } items.push({ id: `entry:${obj.name}:${entryId}`, label: displayValue || `(${obj.name} entry)`, sublabel: obj.name, kind: "entry", icon: obj.icon, objectName: obj.name, entryId, fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined, }); } } return items; } // --- Route handler --- export async function GET() { const items: SearchIndexItem[] = []; // 1. Files + objects from tree const root = resolveWorkspaceRoot(); if (root) { // Aggregate objects from ALL discovered DuckDB files (shallower wins) const dbObjects = new Map(); const objs = await duckdbQueryAllAsync( "SELECT * FROM objects", "name", ); for (const o of objs) {dbObjects.set(o.name, o);} // Scan workspace root (the workspace folder IS the knowledge base) flattenTree(root, "", dbObjects, items); } // 2. Entries from all objects across all discovered DBs const dbPaths = discoverDuckDBPaths(); if (dbPaths.length > 0) { items.push(...await buildEntryItems()); } return Response.json({ items }); }