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..09cf41785 --- /dev/null +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -0,0 +1,401 @@ +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[]; +} + +/** Only a short note preview is needed for the board picker. */ +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`; + +/** The audit trail is a read-only include on the generated board detail route. */ +const timelineUrl = (boardId: number) => + `/api/database/boards/${boardId}?include=${encodeURIComponent( + JSON.stringify({ + notes: { limit: 20, include: { note_events: { 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(timelineUrl(active.id)), + ]); + 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/database/boards/{board?.id ?? ":id"}?include=… + {" "} + reads notes and their events through nested includes. The + generated API exposes note_events{" "} + for reading only; the hook writes it inside the note's + transaction. + + + + {(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..ce2b44465 --- /dev/null +++ b/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx @@ -0,0 +1,102 @@ +import { Badge } from "@databricks/appkit-ui/react"; + +/** + * The create transaction and the separate read serializer. Only database + * writes through the transaction-bound client share the mutation's rollback. + */ + +interface Step { + name: string; + kind: "async" | "sql" | "sync"; + detail: string; +} + +const IN_TRANSACTION: Step[] = [ + { + name: "beforeCreate(values, ctx)", + kind: "async", + detail: + "Calls the redaction agent with a 10-second cancellation signal, rejects failed or blank output, and 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) => ( + + ))} +
+
+ +
+
+ + on a subsequent read + +
+ +
+ +

+ ctx.app.database provides the transaction-bound database + client. Import other APIs separately and give network calls their own + cancellation signal: the database deadline does not cancel them, and + rollback does not undo their external effects. +

+
+ ); +} 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 dacb87b44..5e6eda2a9 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, @@ -59,6 +60,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 5d9e2009f..8658c1952 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -23,6 +23,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' @@ -101,6 +102,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', @@ -145,6 +151,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 @@ -168,6 +175,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 @@ -192,6 +200,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 @@ -217,6 +226,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -240,6 +250,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -263,6 +274,7 @@ export interface FileRouteTypes { | '/arrow-analytics' | '/chart-inference' | '/data-visualization' + | '/database' | '/files' | '/genie' | '/jobs' @@ -287,6 +299,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 @@ -403,6 +416,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' @@ -463,6 +483,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..9b041cef6 --- /dev/null +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -0,0 +1,506 @@ +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, inside createApp({ plugins: [...] }) +database({ + schema, + // All tables are readable; only boards and notes accept HTTP writes. + api: { writes: { tables: ["boards", "notes"] } }, + hooks: { /* inline hooks shown below */ }, +})`; + +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 INLINE_HOOKS = `// server/index.ts +// Model-based redaction is best-effort, not a privacy guarantee. +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.", +}); + +database({ + schema, + api: { writes: { tables: ["boards", "notes"] } }, + hooks: { + notes: { + async beforeCreate(values) { + // Cancel the model request before the 30-second transaction deadline. + const signal = AbortSignal.timeout(10_000); + const answer = await runAgent(redactor, { + messages: String(values.body), + signal, + }); + // A cancelled stream can return partial text instead of throwing. + signal.throwIfAborted(); + if (answer.events.some(event => + event.type === "status" && event.status === "error" + )) { + throw new Error("Note redaction failed"); + } + const body = answer.text.trim(); + if (!body) throw new Error("Note redaction returned no text"); + + return { + ...values, + body, + // Demo only; production apps should use the authenticated session. + author_email: \`\${values.author}@example.com\`, + }; + }, + + async afterCreate(row, ctx) { + // HTTP writes are disabled for this table, but this client is trusted + // and bound to the note's transaction. + await ctx.app.database.note_events.create({ + note_id: row.id, + action: "created", + }); + }, + + serialize: (row, { operation }) => + operation === "list" + ? { ...row, body: String(row.body).slice(0, 120) } + : row, + }, + }, +});`; + +const TIMELINE_QUERY = `// Browser: use the generated board detail route, not a custom controller. +const include = encodeURIComponent(JSON.stringify({ + notes: { + limit: 20, + include: { note_events: { limit: 5 } }, + }, +})); +const response = await fetch( + \`/api/database/boards/\${boardId}?include=\${include}\`, +); +if (!response.ok) throw new Error("Failed to load board timeline"); +const board = await response.json();`; + +/** The five default CRUD routes for each exposed table with a public primary key. */ +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", "note_events"].map((table) => ( +
+
+ {table} + + {table === "note_events" ? "read-only" : "full CRUD"} + +
+
+ {GENERATED_ROUTES.filter( + (route) => table !== "note_events" || route.method === "GET", + ).map((route) => ( +
+ + {route.method} + + + /api/database/{table} + {route.suffix} + + + {route.purpose} + +
+ ))} +
+
+ ))} +
+ ); +} + +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. Restrict the generated API + + Declared tables get full CRUD routes by default. Use api to + restrict the exposed tables and write operations. + + + +
+

+ Full CRUD is enabled by default. +

+

+ Set api: false to disable generated endpoints + while keeping the typed database client available to your own + routes and hooks. Use api: {"{ writes: false }"}{" "} + for read-only routes on every table. Here,{" "} + api.writes.tables allows writes to boards and + notes while keeping the audit trail read-only. +

+
+ + +
+

+ Try writing directly to the read-only audit trail +

+ + fetch("/api/database/note_events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note_id: 1, action: "created" }), + }) + } + /> +
+
+
+ + + + 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. + + + + + + + + + + Inline hooks + + Redaction, audit writes, and read previews are defined directly + in the plugin registration, without a wrapper or custom routes. + + + + +

+ A hook needs nothing from the plugin system to do this.{" "} + createAgent returns a definition, and the inline + hook passes it to runAgent. The capability from{" "} + ctx is ctx.app.database, which is + bound to the current transaction. Calls to a model or another + service do not join that transaction, and rollback cannot undo + their external effects. +

+

+ The model request receives a 10-second cancellation signal, + shorter than the database transaction's 30-second deadline. + Cancellation, an agent error status, or blank output rejects the + mutation instead of saving the original or partial text. The + transaction occupies a pooled connection while it waits, so keep + this work short. +

+

+ This demonstrates best-effort redaction, not guaranteed removal + of personal data. A non-empty model response can still miss + names or email addresses; do not use it as the sole privacy + control for sensitive data. +

+
+
+ + + + Nested reads through the generated API + + Load a board, its notes, and their audit events in one bounded + read. A read-only table can participate in includes without + exposing write operations. + + + + + + +
+
+
+ ); +} 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/database.test.ts b/apps/dev-playground/server/database.test.ts new file mode 100644 index 000000000..4ddcba737 --- /dev/null +++ b/apps/dev-playground/server/database.test.ts @@ -0,0 +1,244 @@ +import type { HookContext, IDatabaseConfig } from "@databricks/appkit/beta"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { schema } from "../config/database/schema"; + +const mocks = vi.hoisted(() => ({ + createApp: vi.fn(async (_config: unknown) => undefined), + runAgent: vi.fn(), +})); +vi.mock("@databricks/appkit", async (importOriginal) => ({ + ...(await importOriginal()), + createApp: mocks.createApp, +})); +vi.mock("@databricks/appkit/beta", async (importOriginal) => ({ + ...(await importOriginal()), + runAgent: mocks.runAgent, +})); +// Capture the real bootstrap configuration without starting services or loading ORMs. +vi.mock("./lakebase-examples-plugin", () => ({ + lakebaseExamples: () => ({ name: "lakebaseExamples" }), +})); +vi.mock("./reconnect-plugin", () => ({ + reconnect: () => ({ name: "reconnect" }), +})); +vi.mock("./telemetry-example-plugin", () => ({ + telemetryExamples: () => ({ name: "telemetryExamples" }), +})); + +type DatabaseConfig = IDatabaseConfig; +type Hooks = NonNullable["notes"]; +interface CapturedAppConfig { + plugins: Array<{ name: string; config: unknown }>; + onPluginsReady(appkit: unknown): Promise; +} + +async function loadApp(endpoint = "test-endpoint"): Promise { + vi.resetModules(); + vi.stubEnv("LAKEBASE_ENDPOINT", endpoint); + vi.stubEnv("APPKIT_E2E_TEST", ""); + mocks.createApp.mockClear(); + await import("./index"); + const call = mocks.createApp.mock.calls[0]; + if (!call) throw new Error("The playground did not call createApp"); + return call[0] as CapturedAppConfig; +} + +function requiredHooks(hooks: Hooks) { + if (!hooks?.beforeCreate || !hooks.afterCreate || !hooks.serialize) { + throw new Error("The inline database hooks are missing"); + } + return { + beforeCreate: hooks.beforeCreate, + afterCreate: hooks.afterCreate, + serialize: hooks.serialize, + }; +} + +const values = { + board_id: 7, + author: "reviewer", + body: "Original note text", +}; +const context = { + entity: "notes", + app: { database: {} }, +} as HookContext; +let appConfig: CapturedAppConfig; +let databaseConfig: DatabaseConfig; +let hooks: ReturnType; + +beforeEach(async () => { + mocks.runAgent.mockReset(); + appConfig = await loadApp(); + const registration = appConfig.plugins.find( + (plugin) => plugin.name === "database", + ); + if (!registration) throw new Error("The database plugin was not registered"); + databaseConfig = registration.config as DatabaseConfig; + hooks = requiredHooks(databaseConfig.hooks?.notes); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +const beforeCreate = () => hooks.beforeCreate(values, context); + +describe("inline playground database registration", () => { + test("enables reads for all tables and HTTP writes only for boards and notes", () => { + expect(Object.keys(databaseConfig.schema.$tables)).toEqual([ + "boards", + "notes", + "note_events", + ]); + expect(databaseConfig.api).toEqual({ + writes: { tables: ["boards", "notes"] }, + }); + }); + + test("does not register the database when Lakebase is not configured", async () => { + const app = await loadApp(""); + expect(app.plugins.some((plugin) => plugin.name === "database")).toBe( + false, + ); + }); + + test("leaves all board and database HTTP routes to the generated API", async () => { + const get = vi.fn(); + const post = vi.fn(); + await appConfig.onPluginsReady({ + database: {}, + server: { + extend: (register: (router: unknown) => void) => + register({ get, post }), + }, + }); + const paths = [...get.mock.calls, ...post.mock.calls].map( + ([path]) => path as string, + ); + expect(paths.length).toBeGreaterThan(0); + expect( + paths.some( + (path) => + path.startsWith("/api/boards") || path.startsWith("/api/database"), + ), + ).toBe(false); + }); +}); + +describe("inline database hooks", () => { + test("uses non-empty model output without changing the original payload", async () => { + mocks.runAgent.mockResolvedValue({ + text: " [redacted] note text ", + events: [], + }); + const result = await beforeCreate(); + expect(result).toEqual({ + ...values, + body: "[redacted] note text", + author_email: "reviewer@example.com", + }); + expect(values.body).toBe("Original note text"); + expect(result).not.toBe(values); + }); + + test.each(["", " ", "\n\t "])( + "rejects blank model output %j rather than returning the original body", + async (text) => { + mocks.runAgent.mockResolvedValue({ text, events: [] }); + await expect(beforeCreate()).rejects.toThrow( + "Note redaction returned no text", + ); + expect(values.body).toBe("Original note text"); + }, + ); + + test("propagates model failures so the mutation can roll back", async () => { + const error = new Error("Model unavailable"); + mocks.runAgent.mockRejectedValue(error); + await expect(beforeCreate()).rejects.toBe(error); + }); + + test("rejects partial output accompanied by an agent error status", async () => { + mocks.runAgent.mockResolvedValue({ + text: "Partial output", + events: [{ type: "status", status: "error", error: "upstream detail" }], + }); + await expect(beforeCreate()).rejects.toThrow("Note redaction failed"); + }); + + test("forwards a 10-second abort signal to the model call", async () => { + const controller = new AbortController(); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(controller.signal); + mocks.runAgent.mockResolvedValue({ text: "Processed note", events: [] }); + await beforeCreate(); + expect(timeout).toHaveBeenCalledExactlyOnceWith(10_000); + expect(mocks.runAgent).toHaveBeenCalledWith(expect.anything(), { + messages: values.body, + signal: controller.signal, + }); + }); + + test("propagates a model request cancelled by the timeout signal", async () => { + const controller = new AbortController(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + mocks.runAgent.mockImplementation( + (_agent, { signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }), + ); + const error = new DOMException("Redaction timed out", "TimeoutError"); + const result = beforeCreate(); + const rejected = expect(result).rejects.toBe(error); + controller.abort(error); + await rejected; + }); + + test("rejects accumulated text even if the model resolves after cancellation", async () => { + const controller = new AbortController(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const error = new DOMException("Redaction timed out", "TimeoutError"); + mocks.runAgent.mockImplementation(async () => { + controller.abort(error); + return { + text: "Partial text returned by a cancelled stream", + events: [], + }; + }); + await expect(beforeCreate()).rejects.toBe(error); + }); + + test("keeps the audit write on the transaction-bound client", async () => { + const create = vi.fn().mockResolvedValue({ id: 1 }); + const row = { + ...values, + id: 2, + author_email: null, + created_at: "2026-01-01T00:00:00Z", + }; + await hooks.afterCreate(row, { + entity: "notes", + app: { database: { note_events: { create } } }, + } as unknown as HookContext); + expect(create).toHaveBeenCalledExactlyOnceWith({ + note_id: 2, + action: "created", + }); + }); + + test("preserves list previews and full detail responses", () => { + const row = { body: "x".repeat(200) }; + expect( + hooks.serialize(row, { entity: "notes", operation: "list" }), + ).toEqual({ body: "x".repeat(120) }); + expect(hooks.serialize(row, { entity: "notes", operation: "detail" })).toBe( + row, + ); + }); +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index eb88c2a97..92493895a 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -12,8 +12,15 @@ import { serving, WRITE_ACTIONS, } from "@databricks/appkit"; -import { agents, aiSearch } from "@databricks/appkit/beta"; +import { + agents, + aiSearch, + createAgent, + database, + runAgent, +} from "@databricks/appkit/beta"; +import { schema } from "../config/database/schema"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -58,6 +65,14 @@ const usersOnly: FilePolicy = (_action, _resource, user) => { return user.isServicePrincipal !== true; }; +// Best-effort redaction demonstrates calling an agent from a hook; it is not +// a guarantee that all personal data will be removed. +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.", +}); + createApp({ plugins: [ server(), @@ -69,6 +84,60 @@ 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, + // Reads are generated for all three tables. Only boards and notes + // accept HTTP writes; the audit trail is written by the hook. + api: { writes: { tables: ["boards", "notes"] } }, + hooks: { + notes: { + async beforeCreate(values) { + // Cancel the model call before the 30-second transaction deadline. + const signal = AbortSignal.timeout(10_000); + const answer = await runAgent(redactor, { + messages: String(values.body), + signal, + }); + // A cancelled stream can return accumulated text instead of throwing. + signal.throwIfAborted(); + if ( + answer.events.some( + (event) => + event.type === "status" && event.status === "error", + ) + ) { + throw new Error("Note redaction failed"); + } + const body = answer.text.trim(); + if (!body) throw new Error("Note redaction returned no text"); + + return { + ...values, + body, + // Demo only; production apps should use the authenticated session. + author_email: `${values.author}@example.com`, + }; + }, + async afterCreate(row, ctx) { + // The audit write joins the note's transaction, not the HTTP API. + await ctx.app.database.note_events.create({ + note_id: row.id, + action: "created", + }); + }, + serialize: (row, { operation }) => + operation === "list" + ? { ...row, body: String(row.body).slice(0, 120) } + : row, + }, + }, + }), + ] + : []), files({ volumes: { // Smart Dashboard saved views land here. Backed by 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..41e7107d0 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts @@ -0,0 +1,292 @@ +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(), + }); + const note_events = builder.table("note_events", { + id: id(), + note_id: fk(() => notes.id).notNull(), + action: text().notNull(), + }); + return { boards, notes, note_events }; +}); + +/** 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, + api: { writes: { 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, + handlers, + list: (query = "") => get("/boards", `/boards${query}`), + detail: (id: string, query = "") => + get("/boards/:id", `/boards/${id}${query}`, { 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("generates read-only audit routes while keeping board and note CRUD", async () => { + const { handlers } = await mount(); + expect(handlers.has("get /note_events")).toBe(true); + expect(handlers.has("get /note_events/:id")).toBe(true); + expect(handlers.has("post /note_events")).toBe(false); + expect(handlers.has("patch /note_events/:id")).toBe(false); + expect(handlers.has("delete /note_events/:id")).toBe(false); + for (const table of ["boards", "notes"]) { + expect(handlers.has(`post /${table}`)).toBe(true); + expect(handlers.has(`patch /${table}/:id`)).toBe(true); + expect(handlers.has(`delete /${table}/:id`)).toBe(true); + } + }); + + test("serves the timeline through a generated detail route with bounded audit includes", async () => { + const { database, detail } = await mount(); + const notes = [ + { + id: 1, + board_id: 7, + body: "looks off", + note_events: [{ id: 2, note_id: 1, action: "created" }], + }, + ]; + database.path.findOne = async (_table, _id, spec) => { + database.reads.push(spec ?? {}); + return { ...storedBoard, notes }; + }; + const include = { + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }; + const response = await detail( + "7", + `?include=${encodeURIComponent(JSON.stringify(include))}`, + ); + expect(response.sent.status).toBe(200); + expect(database.reads).toEqual([{ include }]); + expect(response.json()).toEqual({ id: 7, title: "Q3 review", notes }); + expect(response.sent.body).not.toContain("retention_note"); + }); + + 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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d466c3a51..f11eb42e8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,16 @@ export default defineConfig({ ], }, projects: [ + { + plugins: [tsconfigPaths()], + test: { + name: "playground", + root: "./apps/dev-playground", + environment: "node", + // Playwright owns tests/*.spec.ts; these are isolated server unit tests. + include: ["server/**/*.test.ts"], + }, + }, { plugins: [react()], resolve: {