From 5d88bf248b966f3cbde19ea00e0a5269d4dac392 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 10 Aug 2026 17:35:32 +0100 Subject: [PATCH] feat(playground): add a DatabasePlugin example Exercise the typed client, the generated routes, and the three hook points against a live Postgres, and cover the assembled stack with one integration test. Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 391 +++++++++++++ .../components/database/hook-lifecycle.tsx | 102 ++++ .../client/src/components/database/index.ts | 3 + .../src/components/database/refusal-probe.tsx | 70 +++ apps/dev-playground/client/src/lib/nav.ts | 8 + .../client/src/routeTree.gen.ts | 21 + .../client/src/routes/database.route.tsx | 513 ++++++++++++++++++ apps/dev-playground/config/database/schema.ts | 44 ++ apps/dev-playground/server/index.ts | 99 ++++ .../shared/appkit-types/database.d.ts | 124 +++++ .../database/tests/mvp.integration.test.ts | 243 +++++++++ 11 files changed, 1618 insertions(+) create mode 100644 apps/dev-playground/client/src/components/database/board-explorer.tsx create mode 100644 apps/dev-playground/client/src/components/database/hook-lifecycle.tsx create mode 100644 apps/dev-playground/client/src/components/database/index.ts create mode 100644 apps/dev-playground/client/src/components/database/refusal-probe.tsx create mode 100644 apps/dev-playground/client/src/routes/database.route.tsx create mode 100644 apps/dev-playground/config/database/schema.ts create mode 100644 apps/dev-playground/shared/appkit-types/database.d.ts create mode 100644 packages/appkit/src/plugins/database/tests/mvp.integration.test.ts diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx new file mode 100644 index 000000000..1c972aedb --- /dev/null +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -0,0 +1,391 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@databricks/appkit-ui/react"; +import { Loader2, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useEffect, useId, useState } from "react"; + +/** + * Everything on this panel comes from routes the app never wrote: the note list + * is a generated read, the two forms are generated writes, and the audit trail + * is the row an `afterCreate` hook commits alongside each note. + */ + +interface Note { + id: number; + board_id: number; + author: string; + body: string; + created_at: string; +} + +interface Board { + id: number; + slug: string; + title: string; + created_at: string; + notes?: Note[]; +} + +interface NoteEvent { + id: number; + note_id: number; + action: string; + created_at: string; +} + +interface TimelineNote extends Note { + note_events?: NoteEvent[]; +} + +interface Timeline extends Board { + notes?: TimelineNote[]; +} + +/** The include is bounded to `notes` because that is the only exposed relation. */ +const BOARDS_URL = `/api/database/boards?include=${encodeURIComponent( + JSON.stringify({ notes: { limit: 5 } }), +)}`; + +/** Listing notes directly is what puts them through the entity's serializer. */ +const notesUrl = (boardId: number) => + `/api/database/notes?where=${encodeURIComponent( + JSON.stringify({ board_id: boardId }), + )}&order=${encodeURIComponent( + JSON.stringify({ created_at: "desc" }), + )}&limit=5`; + +/** Generated routes answer failures as `{ error, details? }`. */ +function failureMessage(body: unknown, fallback: string): string { + const payload = body as { + error?: unknown; + details?: Array<{ message?: string }>; + } | null; + const detail = payload?.details?.[0]?.message; + if (typeof detail === "string") return detail; + return typeof payload?.error === "string" ? payload.error : fallback; +} + +async function getJson(url: string): Promise { + const response = await fetch(url); + const body: unknown = await response.json(); + if (!response.ok) { + throw new Error(failureMessage(body, `HTTP ${response.status}`)); + } + return body as T; +} + +export function BoardExplorer() { + const authorFieldId = useId(); + const bodyFieldId = useId(); + const boardFieldId = useId(); + + const [boards, setBoards] = useState([]); + const [notes, setNotes] = useState([]); + const [timeline, setTimeline] = useState(null); + const [selected, setSelected] = useState(null); + const [fullBody, setFullBody] = useState>({}); + const [author, setAuthor] = useState("reviewer"); + const [body, setBody] = useState(""); + const [title, setTitle] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (slug?: string | null) => { + setError(null); + try { + const page = await getJson<{ items: Board[] }>(BOARDS_URL); + setBoards(page.items); + const active = + page.items.find((entry) => entry.slug === slug) ?? page.items[0]; + setSelected(active?.slug ?? null); + setFullBody({}); + if (!active) { + setNotes([]); + setTimeline(null); + return; + } + const [listed, board] = await Promise.all([ + getJson<{ items: Note[] }>(notesUrl(active.id)), + getJson(`/api/boards/${active.slug}/timeline`), + ]); + setNotes(listed.items); + setTimeline(board); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const board = boards.find((entry) => entry.slug === selected) ?? null; + + const post = async (url: string, payload: unknown) => { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const created: unknown = await response.json(); + if (!response.ok) throw new Error(failureMessage(created, "Create failed")); + return created; + }; + + const submit = async (run: () => Promise) => { + setBusy(true); + setError(null); + try { + const slug = await run(); + await load(slug ?? selected); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const addNote = (event: React.FormEvent) => { + event.preventDefault(); + if (!board || !body.trim()) return; + return submit(async () => { + await post("/api/database/notes", { + board_id: board.id, + author, + body, + }); + setBody(""); + return board.slug; + }); + }; + + const addBoard = (event: React.FormEvent) => { + event.preventDefault(); + if (!title.trim()) return; + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-"); + return submit(async () => { + await post("/api/database/boards", { slug, title: title.trim() }); + setTitle(""); + return slug; + }); + }; + + /** The list route truncates; the detail route does not. Same serializer. */ + const revealFullBody = async (id: number) => { + const note = await getJson(`/api/database/notes/${id}`); + setFullBody((current) => ({ ...current, [id]: note.body })); + }; + + const eventsByNote = new Map( + (timeline?.notes ?? []).map((note) => [note.id, note.note_events ?? []]), + ); + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + Board + + {boards.map((entry) => ( + + ))} + + +
+ + setTitle(event.target.value)} + className="h-8 w-48" + /> + +
+
+

+ The note counts above ride along on the board list as{" "} + ?include={'{"notes":{"limit":5}}'}. Creating a board is the + second exposed table answering POST /api/database/boards. +

+ +
+ + + Generated read + + + GET /api/database/notes?where={"{"}"board_id":{board?.id ?? 0} + {"}"}&order={"{"}"created_at":"desc"{"}"} + {" "} + — filters, ordering and pagination are decoded from the query + string against the schema, never interpolated into SQL. + + + + {notes.length === 0 && ( +

+ No notes yet. Add one and watch the audit trail fill in. +

+ )} + {notes.map((note) => ( +
+
+ {note.author} + + {(fullBody[note.id] ?? note.body).length} chars + +
+

+ {fullBody[note.id] ?? note.body} +

+ {!fullBody[note.id] && note.body.length === 120 && ( + + )} +
+ ))} +
+
+ + + + + Audit trail written by a hook + + + + GET /api/boards/{selected ?? ":slug"}/timeline + {" "} + — note_events has no route of its + own, so only the server-side client can reach it. + + + + {(timeline?.notes ?? []).map((note) => ( +
+
+ note #{note.id} by {note.author} +
+
    + {(eventsByNote.get(note.id) ?? []).map((event) => ( +
  • + {event.action} + + {new Date(event.created_at).toLocaleTimeString()} + +
  • + ))} +
+
+ ))} + {(timeline?.notes ?? []).length === 0 && ( +

+ Nothing recorded yet. +

+ )} +
+
+
+ + + + Generated write + + POST /api/database/notes — the note + and its created event commit + together or not at all. + + + +
+
+ + setAuthor(event.target.value)} + className="w-40" + /> +
+
+ + setBody(event.target.value)} + /> +
+ +
+
+
+
+ ); +} diff --git a/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx b/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx new file mode 100644 index 000000000..be6636a29 --- /dev/null +++ b/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx @@ -0,0 +1,102 @@ +import { Badge } from "@databricks/appkit-ui/react"; + +/** + * One create, from request to response. The transaction boundary is the whole + * point of the picture: it decides what belongs in each step, and it is why + * the last one is synchronous. + */ + +interface Step { + name: string; + kind: "async" | "sql" | "sync"; + detail: string; +} + +const IN_TRANSACTION: Step[] = [ + { + name: "beforeCreate(values, ctx)", + kind: "async", + detail: + "May return a replacement payload, revalidated before it is persisted. Where this app stamps the private author_email.", + }, + { + name: "INSERT", + kind: "sql", + detail: "The row the caller asked for, plus whatever the hook added.", + }, + { + name: "afterCreate(row, ctx)", + kind: "async", + detail: + "Sees the persisted row. Writes through ctx.app.database join this transaction — here, the note_events entry.", + }, +]; + +const KIND_LABEL: Record = { + async: "async", + sql: "sql", + sync: "sync", +}; + +function StepRow({ step }: { step: Step }) { + return ( +
+ + {KIND_LABEL[step.kind]} + +
+ {step.name} +

{step.detail}

+
+
+ ); +} + +export function HookLifecycle() { + return ( +
+ POST /api/database/notes + +
+
+ + one transaction + + + a throw anywhere rolls back everything below + +
+
+ {IN_TRANSACTION.map((step) => ( + + ))} +
+
+ +
+
+ + after commit + +
+ +
+ +

+ The two async steps are where slow work is possible but expensive:{" "} + ctx.app reaches every other plugin from here, and they hold + the transaction open, so bound anything that leaves the process. +

+
+ ); +} diff --git a/apps/dev-playground/client/src/components/database/index.ts b/apps/dev-playground/client/src/components/database/index.ts new file mode 100644 index 000000000..bd948b805 --- /dev/null +++ b/apps/dev-playground/client/src/components/database/index.ts @@ -0,0 +1,3 @@ +export { BoardExplorer } from "./board-explorer"; +export { HookLifecycle } from "./hook-lifecycle"; +export { RefusalProbe } from "./refusal-probe"; diff --git a/apps/dev-playground/client/src/components/database/refusal-probe.tsx b/apps/dev-playground/client/src/components/database/refusal-probe.tsx new file mode 100644 index 000000000..dd9e9c028 --- /dev/null +++ b/apps/dev-playground/client/src/components/database/refusal-probe.tsx @@ -0,0 +1,70 @@ +import { Badge, Button } from "@databricks/appkit-ui/react"; +import { PlayIcon } from "lucide-react"; +import { useState } from "react"; + +/** + * Fires one request the plugin is expected to refuse and prints what came + * back. The guarantees on this page are only worth as much as the response, + * so the page asks the running server instead of asserting. + */ + +interface Attempt { + status: number; + body: string; +} + +export function RefusalProbe({ + label, + request, + send, +}: { + /** What the caller is trying to get away with. */ + label: string; + /** The request as a reader would write it, shown before running. */ + request: string; + send: () => Promise; +}) { + const [attempt, setAttempt] = useState(null); + const [running, setRunning] = useState(false); + + const run = async () => { + setRunning(true); + try { + const response = await send(); + const text = await response.text(); + setAttempt({ status: response.status, body: text.slice(0, 400) }); + } catch (error) { + setAttempt({ status: 0, body: String(error) }); + } finally { + setRunning(false); + } + }; + + return ( +
+
+
+

{label}

+ + {request} + +
+ +
+ {attempt && ( +
+ = 400 ? "destructive" : "secondary"} + className="tabular-nums shrink-0" + > + {attempt.status} + + {attempt.body} +
+ )} +
+ ); +} diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 86c086805..bf9c0f5bf 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -2,6 +2,7 @@ import { BarChart3Icon, BotIcon, DatabaseIcon, + DatabaseZapIcon, FileCode2Icon, FolderIcon, GaugeIcon, @@ -57,6 +58,13 @@ export const NAV_GROUPS: ReadonlyArray = [ "Query execution, charts, and interactive components against live SQL.", icon: BarChart3Icon, }, + { + to: "/database", + label: "Database", + description: + "Declare a Postgres schema and get typed entities, generated CRUD routes, and transactional hooks.", + icon: DatabaseZapIcon, + }, { to: "/arrow-analytics", label: "Arrow Analytics", diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 94034f5f7..609852958 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as JobsRouteRouteImport } from './routes/jobs.route' import { Route as GenieRouteRouteImport } from './routes/genie.route' import { Route as FilesRouteRouteImport } from './routes/files.route' +import { Route as DatabaseRouteRouteImport } from './routes/database.route' import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route' import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' @@ -89,6 +90,11 @@ const FilesRouteRoute = FilesRouteRouteImport.update({ path: '/files', getParentRoute: () => rootRouteImport, } as any) +const DatabaseRouteRoute = DatabaseRouteRouteImport.update({ + id: '/database', + path: '/database', + getParentRoute: () => rootRouteImport, +} as any) const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({ id: '/data-visualization', path: '/data-visualization', @@ -133,6 +139,7 @@ export interface FileRoutesByFullPath { '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/database': typeof DatabaseRouteRoute '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute @@ -154,6 +161,7 @@ export interface FileRoutesByTo { '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/database': typeof DatabaseRouteRoute '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute @@ -176,6 +184,7 @@ export interface FileRoutesById { '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/database': typeof DatabaseRouteRoute '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute @@ -199,6 +208,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -220,6 +230,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -241,6 +252,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -263,6 +275,7 @@ export interface RootRouteChildren { ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute + DatabaseRouteRoute: typeof DatabaseRouteRoute FilesRouteRoute: typeof FilesRouteRoute GenieRouteRoute: typeof GenieRouteRoute JobsRouteRoute: typeof JobsRouteRoute @@ -363,6 +376,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FilesRouteRouteImport parentRoute: typeof rootRouteImport } + '/database': { + id: '/database' + path: '/database' + fullPath: '/database' + preLoaderRoute: typeof DatabaseRouteRouteImport + parentRoute: typeof rootRouteImport + } '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -423,6 +443,7 @@ const rootRouteChildren: RootRouteChildren = { ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, ChartInferenceRouteRoute: ChartInferenceRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, + DatabaseRouteRoute: DatabaseRouteRoute, FilesRouteRoute: FilesRouteRoute, GenieRouteRoute: GenieRouteRoute, JobsRouteRoute: JobsRouteRoute, diff --git a/apps/dev-playground/client/src/routes/database.route.tsx b/apps/dev-playground/client/src/routes/database.route.tsx new file mode 100644 index 000000000..f0f2def74 --- /dev/null +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -0,0 +1,513 @@ +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@databricks/appkit-ui/react"; +import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { codeToHtml } from "shiki"; +import { + BoardExplorer, + HookLifecycle, + RefusalProbe, +} from "@/components/database"; +import { Header } from "@/components/layout/header"; + +export const Route = createFileRoute("/database")({ + component: DatabaseRoute, + search: { + middlewares: [retainSearchParams(true)], + }, +}); + +function CodeBlock({ + code, + lang = "typescript", +}: { + code: string; + lang?: string; +}) { + const [html, setHtml] = useState(""); + + useEffect(() => { + codeToHtml(code, { lang, theme: "dark-plus" }).then(setHtml); + }, [code, lang]); + + return ( +
+ ); +} + +const SCHEMA_EXAMPLE = `// config/database/schema.ts +export const schema = defineSchema(({ table }) => { + const boards = table("boards", { + id: id(), + slug: varchar(64).notNull().unique(), + title: text().notNull(), + created_at: timestamp({ withTimezone: true }) + .defaultNow() + .notNull(), + }); + + const notes = table("notes", { + id: id(), + board_id: fk(() => boards.id) + .notNull() + .onDelete("cascade"), + author: text().notNull(), + // The server needs it; no client should see it. + author_email: text().private(), + body: text().notNull(), + created_at: timestamp({ withTimezone: true }) + .defaultNow() + .notNull(), + }); + + const note_events = table("note_events", { + id: id(), + note_id: fk(() => notes.id) + .notNull() + .onDelete("cascade"), + action: varchar(32).notNull(), + created_at: timestamp({ withTimezone: true }) + .defaultNow() + .notNull(), + }); + + return { boards, notes, note_events }; +});`; + +const GENERATED_TYPES = `// shared/appkit-types/database.d.ts +// Auto-generated by AppKit - DO NOT EDIT +declare module "@databricks/appkit" { + interface DatabaseRegistry { + "notes": { + // What server code sees. + row: { + id: number; + board_id: number; + author: string; + author_email: string | null; + body: string; + created_at: string; + }; + // What a response can hold. Not a convention: + // a handler returning the private column + // does not typecheck. + publicRow: { + id: number; + board_id: number; + author: string; + body: string; + created_at: string; + }; + insert: { board_id: number; /* ... */ }; + update: { board_id?: number; /* ... */ }; + filters: DatabaseLogicalFilter<{ /* ... */ }>; + // A foreign key is a relation on both sides. + includes: { + "boards": { to: "boards"; many: false }; + "note_events": { + to: "note_events"; + many: true; + }; + }; + hasPrimaryKey: true; + }; + // ... boards, note_events + } +}`; + +const REGISTRATION = `// server/index.ts +database({ + schema, + // Only these two get HTTP routes. note_events stays server-only. + crudRoutes: { tables: ["boards", "notes"] }, + hooks: { /* see below */ }, +})`; + +const HOOKS_EXAMPLE = `hooks: { + notes: { + // Refused on the wire, writable here. + beforeCreate: (values) => ({ + ...values, + author_email: \`\${values.author}@example.com\`, + }), + + // Its write joins the same transaction, so a note + // and its event are never out of step. + afterCreate: async (row, ctx) => { + await ctx.app.database.note_events.create({ + note_id: row.id, + action: "created", + }); + }, + + // After commit, on the already-public row. + serialize: (row, { operation }) => + operation === "list" + ? { ...row, body: String(row.body).slice(0, 120) } + : row, + }, +}`; + +const PRIVATE_COLUMN = `const notes = table("notes", { + // ... + author: text().notNull(), + author_email: text().private(), +}); + +// Server-side, it is an ordinary column. +const note = await db.notes.find(id); // note.author_email — typed +await db.notes.create({ author_email }); // accepted + +// Over HTTP, the same declaration removes it from every surface: +// the select list, where, the create body, and the update body.`; + +const AGENT_SEAM = `// An agent is a definition, not a service to look up, so a +// hook runs one by importing it. This is the agent that ran on +// the note you just wrote. +const redactor = createAgent({ + instructions: "Replace every personal name and email with [redacted].", +}); + +hooks: { + notes: { + // Atomic with the row: the unredacted body must never reach the + // table, so this has to happen before the insert, not after it. + // It holds a transaction open — give it a timeout. + beforeCreate: async (values) => { + const answer = await runAgent(redactor, { messages: values.body }); + return { ...values, body: answer.text }; + }, + + // The row exists, and this write still shares its transaction, so a + // failure here takes the note with it. Good for derived rows; wrong + // for best-effort work like sending mail. + afterCreate: async (row, ctx) => { + await ctx.app.database.note_events.create({ + note_id: row.id, + action: "created", + }); + }, + + // Not a candidate: no await is possible here. Anything a model + // produced has to already be a column by the time a read runs. + serialize: (row) => row, + }, +}`; + +const CUSTOM_ROUTES = `// The generated routes shape rows; they do not group them, +// and they stop at the exposure list. Everything else is yours. +app.get("/api/boards/stats", async (_req, res) => { + const rows = await db.sql<{ slug: string; notes: string }>\` + select b.slug, count(n.id)::text as notes + from boards b left join notes n on n.board_id = b.id + group by b.slug order by b.slug + \`; + res.json(rows); +}); + +// Two bounded relation edges, including a table with no route of its own. +const board = await db.boards + .where({ slug: req.params.slug }) + .include({ notes: { limit: 20, include: { note_events: { limit: 5 } } } }) + .first();`; + +/** The five routes the plugin writes for every table named in `crudRoutes`. */ +const GENERATED_ROUTES = [ + { + method: "GET", + suffix: "", + purpose: "List with filters, order, pagination and includes", + }, + { method: "GET", suffix: "/:id", purpose: "One row by primary key" }, + { + method: "POST", + suffix: "", + purpose: "Create, with before/after hooks in one transaction", + }, + { + method: "PATCH", + suffix: "/:id", + purpose: "Partial update against the generated update schema", + }, + { + method: "DELETE", + suffix: "/:id", + purpose: "Delete one row, with the same hooks available", + }, +] as const; + +const METHOD_TONE: Record = { + GET: "text-emerald-600 dark:text-emerald-400", + POST: "text-blue-600 dark:text-blue-400", + PATCH: "text-amber-600 dark:text-amber-400", + DELETE: "text-red-600 dark:text-red-400", +}; + +function RouteTable() { + return ( +
+ {["boards", "notes"].map((table) => ( +
+
+ {table} + exposed +
+
+ {GENERATED_ROUTES.map((route) => ( +
+ + {route.method} + + + /api/database/{table} + {route.suffix} + + + {route.purpose} + +
+ ))} +
+
+ ))} + +
+
+ note_events + not exposed +
+
+ No routes at all. The table is still fully typed for server code, and + a hook writes to it on every note — it is simply unreachable from a + browser, including as an include on a table that is + exposed. +
+
+
+ ); +} + +function DatabaseRoute() { + return ( +
+
+
+ +
+ + + Live + + Boards, their notes, and the audit trail a hook keeps for them. + Every request below hits a route this app never wrote. + + + + + + + + + + 1. Declare the schema + + Columns and foreign keys in TypeScript. The plugin expects these + tables to already exist — it owns no migrations. + + + + + + + + + + 2. Types follow + + appkit generate-types turns the + schema into a registry augmentation, so rows, filters, and + includes are checked at compile time. + + + + + + + + + + + 3. Routes are generated from the exposure list + + + A declared table is a server-side capability. It becomes a + network surface only where the registration says so. + + + +
+

+ Nothing is exposed by default. +

+

+ Drop crudRoutes and this app has a fully typed + database and zero database endpoints. Every table stays + reachable from your own routes and hooks, and from nowhere + else. Exposure is a list you write, one table at a time — not + something you remember to switch off. +

+
+ + +
+

+ Ask the running server for a table it does not publish +

+ + fetch( + `/api/database/boards?include=${encodeURIComponent( + JSON.stringify({ note_events: true }), + )}`, + ) + } + /> +
+
+
+ + + + Private columns + + private() marks a column the + server owns. It is the same declaration that keeps it out of + four separate request surfaces. + + + + +

+ A private column is dropped from the select list before the + query is built, so it never leaves Postgres; it is refused in{" "} + where, so nobody can guess it a character at a + time; it is refused in write bodies rather than quietly ignored; + and it is stripped on the way out even if a serializer puts it + back. Server code, meanwhile, reads and writes it normally — + which is how author_email gets set at all. +

+
+ fetch("/api/database/notes?limit=1")} + /> + + fetch( + `/api/database/notes?where=${encodeURIComponent( + JSON.stringify({ author_email: "victor@example.com" }), + )}`, + ) + } + /> + + fetch("/api/database/notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + board_id: 1, + author: "intruder", + body: "trying to set a private column", + author_email: "intruder@example.com", + }), + }) + } + /> +
+
+
+ + + + The lifecycle of one write + + Where your code runs, and what it is allowed to do there. This + is the flow the panel at the top of the page executes. + + + + + + + + + + + Running an agent from a write + + The lifecycle above decides where a model call fits: two steps + can await one, and one cannot. + + + + +

+ A hook needs nothing from the plugin system to do this.{" "} + createAgent returns a definition, so the hook + imports it and calls runAgent — the note you added + above went through exactly this. What the hook does need from{" "} + ctx is ctx.app.database, because that + one is bound to this transaction and cannot be imported. The + model call is not: it holds a Postgres connection and the row's + locks for as long as it runs, and nothing it did is undone if + the transaction later rolls back. Redaction that must be atomic + with the row belongs in beforeCreate under a + timeout; a summary that can arrive a second later belongs after + the response, keyed by the row you just wrote. +

+
+
+ + + + Where the generator stops + + Aggregates and deeper traversals stay ordinary application code, + written against the same typed client. + + + + + + +
+
+
+ ); +} diff --git a/apps/dev-playground/config/database/schema.ts b/apps/dev-playground/config/database/schema.ts new file mode 100644 index 000000000..2bc9a0126 --- /dev/null +++ b/apps/dev-playground/config/database/schema.ts @@ -0,0 +1,44 @@ +// Annotations a reviewer leaves on a saved dashboard, plus the audit trail the +// plugin keeps for them. Three tables is enough to exercise a two-edge include; +// the DatabasePlugin does not create them, so the app expects them to exist. + +import { + defineSchema, + fk, + id, + text, + timestamp, + varchar, +} from "@databricks/appkit/beta"; + +export const schema = defineSchema(({ table }) => { + const boards = table("boards", { + id: id(), + slug: varchar(64).notNull().unique(), + title: text().notNull(), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }); + + const notes = table("notes", { + id: id(), + board_id: fk(() => boards.id) + .notNull() + .onDelete("cascade"), + author: text().notNull(), + // Server code needs it to notify the reviewer; no client should see it. + author_email: text().private(), + body: text().notNull(), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }); + + const note_events = table("note_events", { + id: id(), + note_id: fk(() => notes.id) + .notNull() + .onDelete("cascade"), + action: varchar(32).notNull(), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }); + + return { boards, notes, note_events }; +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index b30c51684..af708ab0c 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -17,10 +17,13 @@ import { aiSearch, createAgent, DatabricksAdapter, + database, + runAgent, supervisorTools, tool, } from "@databricks/appkit/beta"; import { z } from "zod"; +import { schema } from "../config/database/schema"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -326,6 +329,14 @@ const sql_analyst = createAgent({ }, }); +// Run by the notes beforeCreate hook, not by the agents plugin: an agent is a +// definition, so anything holding it can call runAgent. +const redactor = createAgent({ + instructions: + "Replace every personal name and email address in the user's text with [redacted]. " + + "Return only the rewritten text, nothing else.", +}); + const dashboard_pilot = createAgent({ instructions: [ "You are the Smart Dashboard pilot. You do not query data — you manipulate the UI.", @@ -384,6 +395,54 @@ createApp({ }), ...(process.env.LAKEBASE_ENDPOINT ? [lakebase()] : []), lakebaseExamples(), + // Setup queries the database before publishing anything, so the plugin + // only joins the app once an instance is actually configured. + ...(process.env.LAKEBASE_ENDPOINT + ? [ + database({ + schema, + // Boards and their annotations are client-facing; the audit trail + // is written by the hook below and stays server-only. + crudRoutes: { tables: ["boards", "notes"] }, + hooks: { + notes: { + // An agent is a plain definition, so a hook runs one by + // importing it. This has to happen before the insert: the + // unredacted body must never reach the table. It also holds + // the transaction open while the model answers, which is the + // trade being made here. + // + // author_email is a private column: refused on the wire but + // writable here. A real app takes it from the session. + beforeCreate: async (values) => { + const answer = await runAgent(redactor, { + messages: String(values.body), + }); + return { + ...values, + body: answer.text || values.body, + author_email: `${values.author}@example.com`, + }; + }, + // A board page lists many notes, so the list route ships a + // preview and the detail route ships the whole body. + serialize: (row, { operation }) => + operation === "list" + ? { ...row, body: String(row.body).slice(0, 120) } + : row, + // Runs inside the insert's own transaction: a note and the + // event describing it commit together or not at all. + afterCreate: async (row, ctx) => { + await ctx.app.database.note_events.create({ + note_id: row.id, + action: "created", + }); + }, + }, + }, + }), + ] + : []), files({ volumes: { // Smart Dashboard saved views land here. Backed by @@ -485,6 +544,46 @@ createApp({ }); } + // ── Database routes (what a generated route cannot do) ────────── + + if ("database" in appkit) { + const db = appkit.database; + + // Aggregates: a generated read shapes rows, it does not group them. + app.get("/api/boards/stats", async (_req, res) => { + try { + const rows = await db.sql<{ slug: string; notes: string }>` + select b.slug, count(n.id)::text as notes + from boards b left join notes n on n.board_id = b.id + group by b.slug order by b.slug + `; + res.json(rows); + } catch (error: unknown) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // Two bounded relation edges in one read: a board, its notes, and + // the audit trail of each note. + app.get("/api/boards/:slug/timeline", async (req, res) => { + try { + const board = await db.boards + .where({ slug: req.params.slug }) + .include({ + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }) + .first(); + if (!board) { + res.status(404).json({ error: "No such board" }); + return; + } + res.json(board); + } catch (error: unknown) { + res.status(500).json({ error: (error as Error).message }); + } + }); + } + // ── Analytics examples ────────── app.get("/sp", (_req, res) => { diff --git a/apps/dev-playground/shared/appkit-types/database.d.ts b/apps/dev-playground/shared/appkit-types/database.d.ts new file mode 100644 index 000000000..f6e599c75 --- /dev/null +++ b/apps/dev-playground/shared/appkit-types/database.d.ts @@ -0,0 +1,124 @@ +// Auto-generated by AppKit - DO NOT EDIT +import "@databricks/appkit"; + +declare module "@databricks/appkit" { + type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; + }; + + interface DatabaseRegistry { + "boards": { + row: { + "id": number; + "slug": string; + "title": string; + "created_at": string; + }; + publicRow: { + "id": number; + "slug": string; + "title": string; + "created_at": string; + }; + insert: { + "slug": string; + "title": string; + "created_at"?: string; + }; + update: { + "slug"?: string; + "title"?: string; + "created_at"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "slug"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "title"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + includes: { + "notes": { to: "notes"; many: true }; + }; + hasPrimaryKey: true; + }; + "notes": { + row: { + "id": number; + "board_id": number; + "author": string; + "author_email": string | null; + "body": string; + "created_at": string; + }; + publicRow: { + "id": number; + "board_id": number; + "author": string; + "body": string; + "created_at": string; + }; + insert: { + "board_id": number; + "author": string; + "author_email"?: string | null; + "body": string; + "created_at"?: string; + }; + update: { + "board_id"?: number; + "author"?: string; + "author_email"?: string | null; + "body"?: string; + "created_at"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "board_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "author"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "author_email"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; is?: null; }; + "body"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + includes: { + "boards": { to: "boards"; many: false }; + "note_events": { to: "note_events"; many: true }; + }; + hasPrimaryKey: true; + }; + "note_events": { + row: { + "id": number; + "note_id": number; + "action": string; + "created_at": string; + }; + publicRow: { + "id": number; + "note_id": number; + "action": string; + "created_at": string; + }; + insert: { + "note_id": number; + "action": string; + "created_at"?: string; + }; + update: { + "note_id"?: number; + "action"?: string; + "created_at"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "note_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "action"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + includes: { + "notes": { to: "notes"; many: false }; + }; + hasPrimaryKey: true; + }; + } +} diff --git a/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts new file mode 100644 index 000000000..aa3423dbe --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts @@ -0,0 +1,243 @@ +import { createMockTelemetry, mockServiceContext } from "@tools/test-helpers"; +import type { Request, RequestHandler, Response } from "express"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { DEFAULT_LIMIT } from "../../../database/contract"; +import type { DataPath, QuerySpec, Row } from "../../../database/runtime"; +import { defineSchema, fk, id, text } from "../../../database/schema-builder"; +import type { ITelemetry } from "../../../telemetry"; +import type { DatabaseExports } from "../entity-types"; +import type { EntityHooks } from "../types"; + +const mocks = vi.hoisted(() => ({ + createLakebasePool: vi.fn(), + createDrizzleDb: vi.fn(), + createDrizzleDataPath: vi.fn(), +})); + +vi.mock("../../../connectors/lakebase", () => ({ + createLakebasePool: mocks.createLakebasePool, +})); +vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ + createDrizzleDb: mocks.createDrizzleDb, + createDrizzleDataPath: mocks.createDrizzleDataPath, +})); + +import { DatabasePlugin } from "../database"; + +const schema = defineSchema((builder) => { + const boards = builder.table("boards", { + id: id(), + title: text().notNull(), + retention_note: text().private(), + }); + const notes = builder.table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + body: text().notNull(), + }); + return { boards, notes }; +}); + +/** What an included read returns: relation rows nested under their parent. */ +const storedBoard: Row = { + id: 7, + title: "Q3 review", + retention_note: "delete after the audit", + notes: [{ id: 1, board_id: 7, body: "looks off" }], +}; + +/** + * Answers every read with the same row, so the assertions can look at the spec + * the plugin composed and at what survived on the way back to the wire. + */ +function recordingDataPath() { + const reads: QuerySpec[] = []; + const statements: Array<{ text: string; values: unknown[] }> = []; + const path: DataPath = { + select: async (_table, spec) => { + reads.push(spec); + return [storedBoard]; + }, + findOne: async (_table, _value, spec) => { + reads.push(spec ?? {}); + return storedBoard; + }, + count: async () => 1, + insert: async (_table, values) => values, + update: async (_table, _value, values) => values, + upsert: async (_table, values) => values, + delete: async () => true, + // The driver infers the row shape from the statement; a stub cannot. + raw: (async (strings: TemplateStringsArray, ...values: unknown[]) => { + statements.push({ text: strings.join("?"), values }); + return [{ notes: "3" }]; + }) as unknown as DataPath["raw"], + transaction: async (callback) => callback(path), + }; + return { path, reads, statements }; +} + +function fakeResponse() { + const sent: { status?: number; body?: string } = {}; + const res = { + headersSent: false, + status: (code: number) => { + sent.status = code; + return res; + }, + type: () => res, + setHeader: () => res, + send: (body?: string) => { + sent.body = body; + return res; + }, + }; + return { + res: res as unknown as Response, + sent, + json: () => JSON.parse(sent.body ?? "null"), + }; +} + +async function mount(hooks?: Record) { + const database = recordingDataPath(); + const end = vi.fn(async () => undefined); + mocks.createLakebasePool.mockReturnValue({ end }); + mocks.createDrizzleDb.mockReturnValue({}); + mocks.createDrizzleDataPath.mockReturnValue(database.path); + + const plugin = new DatabasePlugin({ + schema, + crudRoutes: { tables: ["boards", "notes"] }, + hooks, + }); + (plugin as unknown as { telemetry: ITelemetry }).telemetry = + createMockTelemetry(); + await plugin.setup(); + + const handlers = new Map(); + const record = + (method: string) => (path: string, handler: RequestHandler) => { + handlers.set(`${method} ${path}`, handler); + }; + plugin.injectRoutes({ + get: record("get"), + post: record("post"), + patch: record("patch"), + delete: record("delete"), + } as unknown as Parameters[0]); + + const get = async ( + route: string, + url: string, + params: Record = {}, + ) => { + const response = fakeResponse(); + const handler = handlers.get(`get ${route}`) as unknown as ( + req: Request, + res: Response, + ) => Promise; + await handler( + { originalUrl: url, url, params } as unknown as Request, + response.res, + ); + return response; + }; + + return { + plugin, + database, + end, + list: (query = "") => get("/boards", `/boards${query}`), + detail: (id: string) => get("/boards/:id", `/boards/${id}`, { id }), + }; +} + +const exportsOf = (plugin: DatabasePlugin) => + plugin.exports() as unknown as DatabaseExports; + +let context: Awaited>; + +beforeEach(async () => { + mocks.createLakebasePool.mockReset(); + mocks.createDrizzleDb.mockReset(); + mocks.createDrizzleDataPath.mockReset(); + // A read runs through Plugin.execute(), which keys on the current identity. + context = await mockServiceContext(); +}); + +afterEach(() => { + context.restore(); +}); + +describe("the assembled MVP", () => { + test("carries a generated read from the query string to the DataPath", async () => { + const { database, list } = await mount(); + const include = encodeURIComponent('{"notes":true}'); + + const response = await list(`?limit=2&include=${include}`); + + expect(response.sent.status).toBe(200); + // The unqualified include arrives bounded, and the key breaks order ties. + expect(database.reads).toEqual([ + { + order: { id: "asc" }, + limit: 2, + offset: 0, + include: { notes: { limit: DEFAULT_LIMIT } }, + }, + ]); + }); + + test("shapes the response without the private column", async () => { + const { list, detail } = await mount({ + boards: { + serialize: (row, { operation }) => ({ ...row, read_as: operation }), + }, + }); + + const page = await list(); + const one = await detail("7"); + + expect(page.json()).toEqual({ + items: [ + { + id: 7, + title: "Q3 review", + notes: [{ id: 1, board_id: 7, body: "looks off" }], + read_as: "list", + }, + ], + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect(one.json().read_as).toBe("detail"); + expect(page.sent.body).not.toContain("retention_note"); + expect(one.sent.body).not.toContain("retention_note"); + }); + + test("sends tagged SQL interpolations as bound values", async () => { + const { plugin, database } = await mount(); + + const rows = await exportsOf(plugin).sql<{ + notes: string; + }>`select count(*)::text as notes from notes where board_id = ${7}`; + + expect(rows).toEqual([{ notes: "3" }]); + // Setup ran the readiness probe first; this is the caller's statement. + expect(database.statements.at(-1)).toEqual({ + text: "select count(*)::text as notes from notes where board_id = ?", + values: [7], + }); + }); + + test("closes the pool and stops answering once it has shut down", async () => { + const { plugin, end, list } = await mount(); + + await plugin.shutdown(); + + expect(end).toHaveBeenCalledOnce(); + expect(() => plugin.exports()).toThrow(); + expect((await list()).sent.status).toBe(500); + }); +});