2026-02-19 14:59:34 -08:00

492 lines
14 KiB
TypeScript

import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths, getObjectViews } from "@/lib/workspace";
import { deserializeFilters, buildWhereClause, buildOrderByClause, type FieldMeta } from "@/lib/object-filters";
import { execSync } from "node:child_process";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type ObjectRow = {
id: string;
name: string;
description?: string;
icon?: string;
default_view?: string;
display_field?: string;
immutable?: boolean;
created_at?: string;
updated_at?: string;
};
type FieldRow = {
id: string;
name: string;
type: string;
description?: string;
required?: boolean;
enum_values?: string;
enum_colors?: string;
enum_multiple?: boolean;
related_object_id?: string;
relationship_type?: string;
sort_order?: number;
};
type StatusRow = {
id: string;
name: string;
color?: string;
sort_order?: number;
is_default?: boolean;
};
type EavRow = {
entry_id: string;
created_at: string;
updated_at: string;
field_name: string;
value: string | null;
};
// --- Schema migration (idempotent, runs once per process) ---
const migratedDbs = new Set<string>();
/** Ensure the display_field column exists on a specific DB file. */
function ensureDisplayFieldColumn(dbFile: string) {
if (migratedDbs.has(dbFile)) {return;}
const bin = resolveDuckdbBin();
if (!bin) {return;}
try {
execSync(
`'${bin}' '${dbFile}' 'ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR'`,
{ encoding: "utf-8", timeout: 5_000, shell: "/bin/sh" },
);
} catch {
// migration might fail on DBs that don't have the objects table — skip
}
migratedDbs.add(dbFile);
}
// --- Helpers ---
/** Scoped query helper: queries a specific DB file. */
function q<T = Record<string, unknown>>(dbFile: string, sql: string): T[] {
return duckdbQueryOnFile<T>(dbFile, sql);
}
/**
* Pivot raw EAV rows into one object per entry with field names as keys.
*/
function pivotEavRows(rows: EavRow[]): Record<string, unknown>[] {
const grouped = new Map<string, Record<string, unknown>>();
for (const row of rows) {
let entry = grouped.get(row.entry_id);
if (!entry) {
entry = {
entry_id: row.entry_id,
created_at: row.created_at,
updated_at: row.updated_at,
};
grouped.set(row.entry_id, entry);
}
if (row.field_name) {
entry[row.field_name] = row.value;
}
}
return Array.from(grouped.values());
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") {return value;}
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** SQL-escape a string (double single-quotes). */
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* Determine the display field for an object.
* Priority: explicit display_field > heuristic (name/title) > first text field > first field.
*/
function resolveDisplayField(
obj: ObjectRow,
objFields: FieldRow[],
): string {
if (obj.display_field) {return obj.display_field;}
// Heuristic: look for name/title fields
const nameField = objFields.find(
(f) =>
/\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
);
if (nameField) {return nameField.name;}
// Fallback: first text field
const textField = objFields.find((f) => f.type === "text");
if (textField) {return textField.name;}
// Ultimate fallback: first field
return objFields[0]?.name ?? "id";
}
/**
* Resolve relation field values to human-readable display labels.
* All queries target the same DB file where the object lives.
*/
function resolveRelationLabels(
dbFile: string,
fields: FieldRow[],
entries: Record<string, unknown>[],
): {
labels: Record<string, Record<string, string>>;
relatedObjectNames: Record<string, string>;
} {
const labels: Record<string, Record<string, string>> = {};
const relatedObjectNames: Record<string, string> = {};
const relationFields = fields.filter(
(f) => f.type === "relation" && f.related_object_id,
);
for (const rf of relationFields) {
const relatedObjs = q<ObjectRow>(dbFile,
`SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
);
if (relatedObjs.length === 0) {continue;}
const relObj = relatedObjs[0];
relatedObjectNames[rf.name] = relObj.name;
const relFields = q<FieldRow>(dbFile,
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(relObj, relFields);
const entryIds = new Set<string>();
for (const entry of entries) {
const val = entry[rf.name];
if (val == null || val === "") {
continue;
}
const valStr =
typeof val === "object" && val !== null
? JSON.stringify(val)
: typeof val === "string"
? val
: typeof val === "number" || typeof val === "boolean"
? String(val)
: "";
for (const id of parseRelationValue(valStr)) {
entryIds.add(id);
}
}
if (entryIds.size === 0) {
labels[rf.name] = {};
continue;
}
const idList = Array.from(entryIds)
.map((id) => `'${sqlEscape(id)}'`)
.join(",");
const displayRows = q<{ entry_id: string; value: string }>(dbFile,
`SELECT e.id as entry_id, 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.id IN (${idList})
AND f.object_id = '${sqlEscape(relObj.id)}'
AND f.name = '${sqlEscape(displayFieldName)}'`,
);
const labelMap: Record<string, string> = {};
for (const row of displayRows) {
labelMap[row.entry_id] = row.value || row.entry_id;
}
for (const id of entryIds) {
if (!labelMap[id]) {labelMap[id] = id;}
}
labels[rf.name] = labelMap;
}
return { labels, relatedObjectNames };
}
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
entries: Record<string, Array<{ id: string; label: string }>>;
};
/**
* Find reverse relations: other objects with relation fields pointing TO this object.
* Searches across ALL discovered databases to catch cross-DB relations.
*/
function findReverseRelations(objectId: string): ReverseRelation[] {
const dbPaths = discoverDuckDBPaths();
const result: ReverseRelation[] = [];
for (const db of dbPaths) {
const reverseFields = q<
FieldRow & { source_object_id: string; source_object_name: string }
>(db,
`SELECT f.*, f.object_id as source_object_id, o.name as source_object_name
FROM fields f
JOIN objects o ON o.id = f.object_id
WHERE f.type = 'relation'
AND f.related_object_id = '${sqlEscape(objectId)}'`,
);
for (const rrf of reverseFields) {
const sourceObjs = q<ObjectRow>(db,
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.source_object_id)}' LIMIT 1`,
);
if (sourceObjs.length === 0) {continue;}
const sourceFields = q<FieldRow>(db,
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.source_object_id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(sourceObjs[0], sourceFields);
const refRows = q<{ source_entry_id: string; target_value: string }>(db,
`SELECT ef.entry_id as source_entry_id, ef.value as target_value
FROM entry_fields ef
WHERE ef.field_id = '${sqlEscape(rrf.id)}'
AND ef.value IS NOT NULL
AND ef.value != ''`,
);
if (refRows.length === 0) {continue;}
const sourceEntryIds = [...new Set(refRows.map((r) => r.source_entry_id))];
const idList = sourceEntryIds.map((id) => `'${sqlEscape(id)}'`).join(",");
const displayRows = q<{ entry_id: string; value: string }>(db,
`SELECT ef.entry_id, ef.value
FROM entry_fields ef
JOIN fields f ON f.id = ef.field_id
WHERE ef.entry_id IN (${idList})
AND f.name = '${sqlEscape(displayFieldName)}'
AND f.object_id = '${sqlEscape(rrf.source_object_id)}'`,
);
const displayMap: Record<string, string> = {};
for (const row of displayRows) {
displayMap[row.entry_id] = row.value || row.entry_id;
}
const entriesMap: Record<string, Array<{ id: string; label: string }>> = {};
for (const row of refRows) {
const targetIds = parseRelationValue(row.target_value);
for (const targetId of targetIds) {
if (!entriesMap[targetId]) {entriesMap[targetId] = [];}
entriesMap[targetId].push({
id: row.source_entry_id,
label: displayMap[row.source_entry_id] || row.source_entry_id,
});
}
}
result.push({
fieldName: rrf.name,
sourceObjectName: rrf.source_object_name,
sourceObjectId: rrf.source_object_id,
displayField: displayFieldName,
entries: entriesMap,
});
}
}
return result;
}
// --- Route handler ---
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!resolveDuckdbBin()) {
return Response.json(
{ error: "DuckDB CLI is not installed", code: "DUCKDB_NOT_INSTALLED" },
{ status: 503 },
);
}
// Sanitize name to prevent injection (only allow alphanumeric + underscore + hyphen)
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
// Find which DuckDB file contains this object (searches all discovered DBs)
const dbFile = findDuckDBForObject(name);
if (!dbFile) {
// Fall back to primary DB check for a friendlier error message
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB database not found" },
{ status: 404 },
);
}
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
// Ensure display_field column exists on this specific DB
ensureDisplayFieldColumn(dbFile);
// All queries below target the specific DB that owns this object
const objects = q<ObjectRow>(dbFile,
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const obj = objects[0];
const fields = q<FieldRow>(dbFile,
`SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
);
const statuses = q<StatusRow>(dbFile,
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
);
// --- Parse filter/sort/pagination query params ---
const url = new URL(_req.url);
const filtersParam = url.searchParams.get("filters");
const sortParam = url.searchParams.get("sort");
const searchParam = url.searchParams.get("search");
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const filterGroup = filtersParam ? deserializeFilters(filtersParam) : undefined;
const fieldsMeta: FieldMeta[] = fields.map((f) => ({ name: f.name, type: f.type }));
// Build WHERE clause from filters
let whereClause = "";
if (filterGroup) {
const where = buildWhereClause(filterGroup, fieldsMeta);
if (where) {whereClause = ` WHERE ${where}`;}
}
// Build ORDER BY clause
let orderByClause = " ORDER BY created_at DESC";
if (sortParam) {
try {
const sortRules = JSON.parse(sortParam);
const orderBy = buildOrderByClause(sortRules);
if (orderBy) {orderByClause = ` ORDER BY ${orderBy}`;}
} catch {
// keep default sort
}
}
// Pagination
const page = Math.max(1, Number(pageParam) || 1);
const pageSize = Math.min(5000, Math.max(1, Number(pageSizeParam) || 100));
const offset = (page - 1) * pageSize;
const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`;
// Full-text search across text fields
if (searchParam && searchParam.trim()) {
const textFields = fields.filter((f) => ["text", "richtext", "email"].includes(f.type));
if (textFields.length > 0) {
const searchConditions = textFields
.map((f) => `LOWER(CAST("${f.name.replace(/"/g, '""')}" AS VARCHAR)) LIKE '%${sqlEscape(searchParam.toLowerCase())}%'`)
.join(" OR ");
whereClause = whereClause
? `${whereClause} AND (${searchConditions})`
: ` WHERE (${searchConditions})`;
}
}
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
let entries: Record<string, unknown>[] = [];
let totalCount = 0;
try {
// Get total count with same WHERE clause but no LIMIT/OFFSET
const countResult = q<{ cnt: number }>(dbFile,
`SELECT COUNT(*) as cnt FROM v_${name}${whereClause}`,
);
totalCount = countResult[0]?.cnt ?? 0;
const pivotEntries = q(dbFile,
`SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`,
);
entries = pivotEntries;
} catch {
// Pivot view might not exist or filter SQL may not apply; fall back
const rawRows = q<EavRow>(dbFile,
`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 = '${obj.id}'
ORDER BY e.created_at DESC
LIMIT 5000`,
);
entries = pivotEavRows(rawRows);
}
const parsedFields = fields.map((f) => ({
...f,
enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
}));
const { labels: relationLabels, relatedObjectNames } =
resolveRelationLabels(dbFile, fields, entries);
const enrichedFields = parsedFields.map((f) => ({
...f,
related_object_name:
f.type === "relation" ? relatedObjectNames[f.name] : undefined,
}));
const reverseRelations = findReverseRelations(obj.id);
const effectiveDisplayField = resolveDisplayField(obj, fields);
// Include saved views from .object.yaml
const { views: savedViews, activeView } = getObjectViews(name);
return Response.json({
object: obj,
fields: enrichedFields,
statuses,
entries,
relationLabels,
reverseRelations,
effectiveDisplayField,
savedViews,
activeView,
totalCount,
page,
pageSize,
});
}