diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx index 8b0aa77b0..385e7f52b 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -10,114 +10,103 @@ import { } from "@databricks/appkit-ui/react"; import { type DatabaseApiError, - useDatabaseCreate, - useDatabaseList, - useDatabaseRecord, + type DatabaseOperationOutput, + useDatabaseMutation, + useDatabaseQuery, } from "@databricks/appkit-ui/react/beta"; -import { Loader2, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { + CheckIcon, + Loader2, + PencilIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { 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 a nested include on the generated board detail route. - */ +type Note = DatabaseOperationOutput<"notes.list">[number]; -/** Generated routes answer `{ error, details? }`; a field detail reads first. */ -function errorText(err: DatabaseApiError): string { - return err.details[0]?.message ?? err.message; +/** Operations answer `{ error, details? }`; a field detail reads first. */ +function errorText(error: DatabaseApiError): string { + return error.details[0]?.message ?? error.message; } +/** + * Everything on this panel calls a named operation from + * server/database/routes/. Only a note's author may edit or delete it; the + * buttons are on every note so a refusal from the server shows up here. + */ export function BoardExplorer() { - const authorFieldId = useId(); - const bodyFieldId = useId(); const boardFieldId = useId(); + const bodyFieldId = useId(); - const [selected, setSelected] = useState(null); - const [author, setAuthor] = useState("reviewer"); - const [body, setBody] = useState(""); + const [selected, setSelected] = useState(null); const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + // The latest write's outcome. Each mutation restarts the queries on success. + const [failure, setFailure] = useState(null); - // A write restarts every mounted read, so the lists and the board previews - // refresh on their own once it commits. - const createBoard = useDatabaseCreate("boards"); - const createNote = useDatabaseCreate("notes"); - const busy = createBoard.loading || createNote.loading; - - // Only a short note preview is needed for the board picker. - const boards = useDatabaseList("boards", { - include: { notes: { limit: 5 } }, - }); - const boardItems = boards.data?.items ?? []; + const boards = useDatabaseQuery("boards.list", {}); const board = - boardItems.find((entry) => entry.slug === selected) ?? boardItems[0]; - - // Null params hold the read until the boards answer. - const notes = useDatabaseList( - "notes", - board - ? { - where: { board_id: board.id }, - order: { created_at: "desc" }, - limit: 5, - } - : null, + boards.data?.find((entry) => entry.id === selected) ?? boards.data?.[0]; + const notes = useDatabaseQuery( + "notes.list", + { board_id: board?.id ?? 0 }, + { enabled: board !== undefined }, ); - const noteItems = notes.data?.items ?? []; - // The audit trail is a read-only include on the generated board detail route. - const timeline = useDatabaseRecord("boards", board?.id, { - include: { notes: { limit: 20, include: { note_events: { limit: 5 } } } }, - }); - const timelineNotes = timeline.data?.notes ?? []; + const createBoard = useDatabaseMutation("boards.create"); + const createNote = useDatabaseMutation("notes.create"); + const updateNote = useDatabaseMutation("notes.update"); + const deleteNote = useDatabaseMutation("notes.delete"); + const busy = + createBoard.isLoading || + createNote.isLoading || + updateNote.isLoading || + deleteNote.isLoading; - const failure = - createNote.error ?? - createBoard.error ?? - boards.error ?? - notes.error ?? - timeline.error; - const error = failure ? errorText(failure) : null; + const error = failure ?? boards.error ?? notes.error; - const refresh = () => { - boards.refetch(); - notes.refetch(); - timeline.refetch(); + const addBoard = async (event: React.FormEvent) => { + event.preventDefault(); + const outcome = await createBoard.mutate({ title: title.trim() }); + setFailure(outcome.error); + if (outcome.error !== null) return; + setTitle(""); + setSelected(outcome.data.id); }; const addNote = async (event: React.FormEvent) => { event.preventDefault(); - if (!board || !body.trim()) return; - createBoard.reset(); - // A failure resolves null and lands in createNote.error for the banner. - const created = await createNote.create({ + if (!board) return; + const outcome = await createNote.mutate({ board_id: board.id, - author, - body, + body: body.trim(), }); - if (created) setBody(""); + setFailure(outcome.error); + if (outcome.error === null) setBody(""); }; - const addBoard = async (event: React.FormEvent) => { - event.preventDefault(); - if (!title.trim()) return; - const slug = title - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-"); - createNote.reset(); - // A failure resolves null and lands in createBoard.error for the banner. - const created = await createBoard.create({ slug, title: title.trim() }); - if (!created) return; - setTitle(""); - setSelected(created.slug); + const saveNote = async (id: number, next: string) => { + const outcome = await updateNote.mutate({ id, body: next }); + setFailure(outcome.error); + return outcome.error === null; + }; + + const removeNote = async (id: number) => { + const outcome = await deleteNote.mutate({ id }); + setFailure(outcome.error); }; return (
{error && ( -
- {error} +
+ {errorText(error)}
)} @@ -125,23 +114,31 @@ export function BoardExplorer() { Board - {boardItems.map((entry) => ( + {boards.data?.map((entry) => ( ))} - @@ -162,141 +159,169 @@ export function BoardExplorer() { variant="outline" disabled={busy || !title.trim()} > - Create + Create board
-

- 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 + Notes and their events - GET /api/database/notes?where={"{"}"board_id":{board?.id ?? 0} - {"}"}&order={"{"}"created_at":"desc"{"}"} + {`useDatabaseQuery("notes.list", { board_id: ${board?.id ?? 0} })`} {" "} - — filters, ordering and pagination are decoded from the query - string against the schema, never interpolated into SQL. + reads the board's newest notes, each with the events written in + the same transaction. - {!notes.loading && noteItems.length === 0 && ( + {!notes.isLoading && notes.data?.length === 0 && (

- No notes yet. Add one below. + No notes yet. Add one.

)} - {noteItems.map((note) => ( -
-
- {note.author} - - {note.body.length} chars - -
-

- {note.body} -

-
+ {notes.data?.map((note) => ( + ))}
- Nested read + Add a note - - 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. + notes.create takes only the board + and the body. The server sets the author from the signed-in user + and writes the note and its{" "} + created event in one transaction. - - {timelineNotes.map((note) => ( -
-
- note #{note.id} by {note.author} -
- {note.note_events.length === 0 ? ( -

No events

+ +
+
+ + setBody(event.target.value)} + /> +
+
- ))} - {!timeline.loading && timelineNotes.length === 0 && ( -

- Nothing recorded yet. -

- )} + Add note + +
+
+ ); +} - - - Generated write - - POST /api/database/notes — the body - is checked against the generated insert schema before the row is - written. - - - -
-
- - setAuthor(event.target.value)} - className="w-40" - /> -
-
- - setBody(event.target.value)} - /> -
- -
-
-
+ + + )} + + {draft === null ? ( +

{note.body}

+ ) : ( +
+ setDraft(event.target.value)} + /> + + +
+ )} +
    + {note.note_events.map((event) => ( +
  • + {event.action} + + {new Date(event.created_at).toLocaleTimeString()} + +
  • + ))} +
); } diff --git a/apps/dev-playground/client/src/components/database/index.ts b/apps/dev-playground/client/src/components/database/index.ts index d1a4d20d5..b1c1d0468 100644 --- a/apps/dev-playground/client/src/components/database/index.ts +++ b/apps/dev-playground/client/src/components/database/index.ts @@ -1,2 +1 @@ export { BoardExplorer } from "./board-explorer"; -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 deleted file mode 100644 index dd9e9c028..000000000 --- a/apps/dev-playground/client/src/components/database/refusal-probe.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 00df84afe..d553f9c5f 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -64,7 +64,7 @@ export const NAV_GROUPS: ReadonlyArray = [ to: "/database", label: "Database", description: - "Declare a Postgres schema and get typed entities and generated CRUD routes.", + "Declare a Postgres schema and serve named operations, each with its own authorization.", icon: DatabaseZapIcon, }, { diff --git a/apps/dev-playground/client/src/routes/database.route.tsx b/apps/dev-playground/client/src/routes/database.route.tsx index c5861fcb4..d38ca9f65 100644 --- a/apps/dev-playground/client/src/routes/database.route.tsx +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -1,5 +1,4 @@ import { - Badge, Card, CardContent, CardDescription, @@ -10,7 +9,7 @@ import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { codeToHtml } from "shiki"; -import { BoardExplorer, RefusalProbe } from "@/components/database"; +import { BoardExplorer } from "@/components/database"; import { Header } from "@/components/layout/header"; export const Route = createFileRoute("/database")({ @@ -80,146 +79,106 @@ export const schema = defineSchema(({ table }) => { 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 OPERATION_EXAMPLE = `// server/database/routes/notes.ts +export const createNote = defineOperation({ + name: "notes.create", + entity: "notes", + method: "POST", + input: z.object({ board_id: z.number().int(), body }), + authorize: signedIn, + execute: ({ actor, db, input }) => + db.transaction(async (tx) => { + const note = await tx.notes.create({ + board_id: input.board_id, + body: input.body, + author: actor.id, + author_email: actor.email ?? null, + }); + await tx.note_events.create({ note_id: note.id, action: "created" }); + return note; + }), +});`; -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"] } }, -})`; +const POLICY_EXAMPLE = `// server/database/policies.ts +export const signedIn = definePolicy({ allow: () => true }); -const PRIVATE_COLUMN = `const notes = table("notes", { - // ... - author: text().notNull(), - author_email: text().private(), +export const noteAuthor = definePolicy({ + resource: ({ db, input }: PolicyContext<{ readonly id: number }>) => + db.notes.find(input.id), + allow: ({ actor, resource }) => + resource.author === actor.id ? true : { deny: "forbidden" }, }); -// 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 +// server/database/routes/notes.ts +export const updateNote = defineOperation({ + name: "notes.update", + entity: "notes", + method: "PATCH", + input: z.object({ id: z.number().int(), body }), + authorize: noteAuthor, + execute: ({ db, input }) => /* ... */, +});`; -// Over HTTP, the same declaration removes it from every surface: -// the select list, where, the create body, and the update body.`; +const REGISTRATION = `// server/database/routes.gen.ts, written by the AppKit Vite plugin +export const databaseRoutes = { + schema: () => import("../../config/database/schema"), + modules: [ + { file: "server/database/routes/boards.ts", load: () => import("./routes/boards") }, + { file: "server/database/routes/notes.ts", load: () => import("./routes/notes") }, + ], +} satisfies DatabaseRoutes; -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();`; +// server/index.ts, inside createApp({ plugins: [...] }) +database({ routes: databaseRoutes })`; -/** 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 one row against the generated insert schema", - }, - { - method: "PATCH", - suffix: "/:id", - purpose: "Partial update against the generated update schema", - }, - { - method: "DELETE", - suffix: "/:id", - purpose: "Delete one row by primary key", - }, +const HOOKS_EXAMPLE = `import { + useDatabaseMutation, + useDatabaseQuery, +} from "@databricks/appkit-ui/react/beta"; + +// Names, inputs, and outputs come from shared/appkit-types/database-operations.d.ts. +const notes = useDatabaseQuery("notes.list", { board_id: board.id }); +const createNote = useDatabaseMutation("notes.create"); + +// A write runs only when you call mutate. On success, mounted queries refetch. +const { error } = await createNote.mutate({ board_id: board.id, body });`; + +/** The operations server/database/routes/ declares, as the server publishes them. */ +const OPERATIONS = [ + { name: "boards.list", method: "GET", policy: "signedIn" }, + { name: "boards.create", method: "POST", policy: "signedIn" }, + { name: "notes.list", method: "GET", policy: "signedIn" }, + { name: "notes.create", method: "POST", policy: "signedIn" }, + { name: "notes.update", method: "PATCH", policy: "noteAuthor" }, + { name: "notes.delete", method: "DELETE", policy: "noteAuthor" }, ] as const; -const METHOD_TONE: Record = { +const METHOD_TONE: Record<(typeof OPERATIONS)[number]["method"], string> = { 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() { +function OperationTable() { 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} - -
- ))} -
+
+ {OPERATIONS.map((operation) => ( +
+ + {operation.method} + + + /api/database/operations/{operation.name} + + + {operation.policy} +
))}
@@ -232,8 +191,8 @@ function DatabaseRoute() {
@@ -241,8 +200,8 @@ function DatabaseRoute() { Live - Boards, their notes, and the read-only events nested under each - note. Every request below hits a route this app never wrote. + Boards, their notes, and the events each write records. Every + request below calls one named operation. @@ -255,7 +214,7 @@ function DatabaseRoute() { 1. Declare the schema Columns and foreign keys in TypeScript. The plugin expects these - tables to already exist — it owns no migrations. + tables to already exist; it owns no migrations. @@ -265,130 +224,83 @@ function DatabaseRoute() { - 2. Types follow + 2. Write an operation - appkit generate-types turns the - schema into a registry augmentation, so rows, filters, and - includes are checked at compile time. + execute receives the verified + actor, the database, and the input its Zod schema parsed. The + note and its event commit together or not at all. - + - 3. Restrict the generated API + 3. Authorize it - Declared tables get full CRUD routes by default. Use api to - restrict the exposed tables and write operations. + Every operation needs authorize + . Without a verified caller the plugin answers 401 before any + policy runs, and only a literal{" "} + true admits.{" "} + noteAuthor loads the note once, + refuses everyone but its author with 403, and hands the note to{" "} + execute. - -
-

- Full CRUD is enabled by default. -

-

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

-
- - -
-

- Try writing directly to the read-only note_events{" "} - table -

- - fetch("/api/database/note_events", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ note_id: 1, action: "created" }), - }) - } - /> -
+ +
- Private columns + 4. Serve the generated registry - private() marks a column the - server owns. It is the same declaration that keeps it out of - four separate request surfaces. + The AppKit Vite plugin lists every module in{" "} + server/database/routes/ and + writes the browser contract of each operation. Without{" "} + routes, the plugin serves no + HTTP endpoint at all. - -

- 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. Server code can still read - and write it. -

-
- 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", - }), - }) - } - /> -
+ +
- + - Nested reads through the generated API + 5. Call it by name - Load a board, its notes, and their{" "} - note_events in one bounded - read. A read-only table can participate in includes without - exposing write operations. + Queries take GET operations and mutations take the rest. A name + the server did not publish sends no request. - + + + + + + + Private columns + + private() marks a column only + server code reads and writes. + + + +

+ notes.create stores the caller's email in{" "} + author_email without ever accepting it as input. A + default output, like the row notes.create answers + with, drops private columns. An explicit output, like the one{" "} + notes.list declares, answers with exactly the + fields its schema names. +

diff --git a/apps/dev-playground/client/tsconfig.database.json b/apps/dev-playground/client/tsconfig.database.json index 6447ceb48..c732f4a51 100644 --- a/apps/dev-playground/client/tsconfig.database.json +++ b/apps/dev-playground/client/tsconfig.database.json @@ -1,8 +1,8 @@ { // Type-check the database components against the built appkit-ui - // declarations, where the Vite alias points, so the generated registry is - // proven to reach `@databricks/appkit-ui/js/beta` through `dist`. Run - // `pnpm build` first. + // declarations, where the Vite alias points, so the generated operation + // contracts are proven to reach `@databricks/appkit-ui/react/beta` through + // `dist`. Run `pnpm build` first. "extends": "./tsconfig.app.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.database.tsbuildinfo", @@ -11,5 +11,8 @@ "@databricks/appkit-ui/*": ["../../../packages/appkit-ui/dist/*"] } }, - "include": ["src/components/database", "../shared/appkit-types/database.d.ts"] + "include": [ + "src/components/database", + "../shared/appkit-types/database-operations.d.ts" + ] } diff --git a/apps/dev-playground/config/database/schema.ts b/apps/dev-playground/config/database/schema.ts index 627230778..b3674026d 100644 --- a/apps/dev-playground/config/database/schema.ts +++ b/apps/dev-playground/config/database/schema.ts @@ -1,6 +1,6 @@ // Annotations a reviewer leaves on a saved dashboard, plus an audit trail table -// 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. +// for them. The operations in server/database/routes/ are the only HTTP access. +// The DatabasePlugin does not create these tables, so the app expects them to exist. import { defineSchema, diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index c5a42fdaf..5a65348d8 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -13,7 +13,7 @@ "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", - "typecheck:database": "tsc -p client/tsconfig.database.json --noEmit", + "typecheck:database": "tsc -p tsconfig.database.json && tsc -p client/tsconfig.database.json --noEmit", "clean": "rm -rf build && cd client && rm -rf dist", "clean:full": "rm -rf build node_modules && cd client && rm -rf dist node_modules", "test:integration": "playwright test", diff --git a/apps/dev-playground/server/database.test.ts b/apps/dev-playground/server/database.test.ts index 0bbbd4599..0dde4c470 100644 --- a/apps/dev-playground/server/database.test.ts +++ b/apps/dev-playground/server/database.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; + import type { IDatabaseConfig } from "@databricks/appkit/beta"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -54,7 +57,7 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("inline playground database registration", () => { +describe("playground database registration", () => { test("declares three tables in the conventional schema file", () => { expect(Object.keys(schema.$tables)).toEqual([ "boards", @@ -63,18 +66,31 @@ describe("inline playground database registration", () => { ]); }); - test("leaves schema discovery to config/database/schema.ts", () => { - expect(databaseConfig.schema).toBeUndefined(); + test("serves the generated operations registry and configures nothing else", async () => { + // Imported after loadApp so it is the module instance index.ts received. + const { databaseRoutes } = await import("./database/routes.gen"); + expect(databaseConfig).toEqual({ routes: databaseRoutes }); + expect(databaseConfig.routes).toBe(databaseRoutes); }); - test("enables reads for all tables and HTTP writes only for boards and notes", () => { - expect(databaseConfig.api).toEqual({ - writes: { tables: ["boards", "notes"] }, - }); - }); + test("lists every routes module and loads the playground schema", async () => { + const { databaseRoutes } = await import("./database/routes.gen"); + const routesDir = path.join(import.meta.dirname, "database", "routes"); + // The generator's rule: direct *.ts children, not tests, declarations, or output. + const onDisk = fs + .readdirSync(routesDir) + .filter( + (file) => file.endsWith(".ts") && !/\.(?:test|d|gen)\.ts$/.test(file), + ) + .map((file) => `server/database/routes/${file}`) + .sort(); - test("does not register a removed lifecycle hooks config", () => { - expect(databaseConfig).not.toHaveProperty("hooks"); + // A stale registry would serve a different set than the files declare. + expect(databaseRoutes.modules.map((entry) => entry.file).sort()).toEqual( + onDisk, + ); + const { schema: served } = await databaseRoutes.schema(); + expect(Object.keys(served.$tables)).toEqual(Object.keys(schema.$tables)); }); test("does not register the database when Lakebase is not configured", async () => { @@ -84,7 +100,7 @@ describe("inline playground database registration", () => { ); }); - test("leaves all board and database HTTP routes to the generated API", async () => { + test("adds no Express route beside the operations for boards or the database", async () => { const get = vi.fn(); const post = vi.fn(); await appConfig.onPluginsReady({ diff --git a/apps/dev-playground/server/database/policies.ts b/apps/dev-playground/server/database/policies.ts new file mode 100644 index 000000000..c41e44a24 --- /dev/null +++ b/apps/dev-playground/server/database/policies.ts @@ -0,0 +1,18 @@ +import { definePolicy, type PolicyContext } from "@databricks/appkit/beta"; + +/** + * Any signed-in user. AppKit verifies the caller with the workspace before a + * policy runs and answers 401 without one, so reaching this rule is the check. + */ +export const signedIn = definePolicy({ allow: () => true }); + +/** + * Loads the note named by `input.id` and admits only its author. Notes are + * readable by every signed-in user, so a refusal is a 403 rather than a 404. + */ +export const noteAuthor = definePolicy({ + resource: ({ db, input }: PolicyContext<{ readonly id: number }>) => + db.notes.find(input.id), + allow: ({ actor, resource }) => + resource.author === actor.id ? true : { deny: "forbidden" }, +}); diff --git a/apps/dev-playground/server/database/routes.gen.ts b/apps/dev-playground/server/database/routes.gen.ts new file mode 100644 index 000000000..e56ab2939 --- /dev/null +++ b/apps/dev-playground/server/database/routes.gen.ts @@ -0,0 +1,16 @@ +// Auto-generated by AppKit - DO NOT EDIT +import type { DatabaseRoutes } from "@databricks/appkit/beta"; + +export const databaseRoutes = { + schema: () => import("../../config/database/schema"), + modules: [ + { + file: "server/database/routes/boards.ts", + load: () => import("./routes/boards"), + }, + { + file: "server/database/routes/notes.ts", + load: () => import("./routes/notes"), + }, + ], +} satisfies DatabaseRoutes; diff --git a/apps/dev-playground/server/database/routes/boards.ts b/apps/dev-playground/server/database/routes/boards.ts new file mode 100644 index 000000000..2da9590e9 --- /dev/null +++ b/apps/dev-playground/server/database/routes/boards.ts @@ -0,0 +1,49 @@ +import { defineOperation } from "@databricks/appkit/beta"; +import { z } from "zod"; + +import { signedIn } from "../policies"; + +/** Every board, oldest first, with how many notes it holds. */ +export const listBoards = defineOperation({ + name: "boards.list", + entity: "boards", + method: "GET", + input: z.object({}), + output: z.array( + z.object({ + id: z.number(), + slug: z.string(), + title: z.string(), + note_count: z.number(), + }), + ), + authorize: signedIn, + execute: async ({ db }) => { + const boards = await db.boards.order({ id: "asc" }).limit(50).toArray(); + return Promise.all( + boards.map(async (board) => ({ + ...board, + note_count: await db.notes.where({ board_id: board.id }).count(), + })), + ); + }, +}); + +/** A new board. The server derives its unique slug from the title. */ +export const createBoard = defineOperation({ + name: "boards.create", + entity: "boards", + method: "POST", + input: z.object({ title: z.string().trim().min(1).max(120) }), + authorize: signedIn, + execute: ({ db, input }) => + db.boards.create({ + slug: + input.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64) || "board", + title: input.title, + }), +}); diff --git a/apps/dev-playground/server/database/routes/notes.ts b/apps/dev-playground/server/database/routes/notes.ts new file mode 100644 index 000000000..0a02ed651 --- /dev/null +++ b/apps/dev-playground/server/database/routes/notes.ts @@ -0,0 +1,86 @@ +import { defineOperation } from "@databricks/appkit/beta"; +import { z } from "zod"; + +import { noteAuthor, signedIn } from "../policies"; + +const body = z.string().trim().min(1).max(2000); + +/** A board's 20 newest notes, each with its audit trail. */ +export const listNotes = defineOperation({ + name: "notes.list", + entity: "notes", + method: "GET", + input: z.object({ board_id: z.number().int() }), + output: z.array( + z.object({ + id: z.number(), + author: z.string(), + body: z.string(), + created_at: z.string(), + note_events: z.array( + z.object({ + id: z.number(), + action: z.string(), + created_at: z.string(), + }), + ), + }), + ), + authorize: signedIn, + execute: ({ db, input }) => + db.notes + .where({ board_id: input.board_id }) + .order({ id: "desc" }) + .limit(20) + .include({ note_events: { order: { id: "asc" }, limit: 10 } }) + .toArray(), +}); + +/** + * A note by the caller and its "created" event, in one transaction: if the + * event cannot be written, the note is not either. + */ +export const createNote = defineOperation({ + name: "notes.create", + entity: "notes", + method: "POST", + input: z.object({ board_id: z.number().int(), body }), + authorize: signedIn, + execute: ({ actor, db, input }) => + db.transaction(async (tx) => { + const note = await tx.notes.create({ + board_id: input.board_id, + body: input.body, + author: actor.id, + author_email: actor.email ?? null, + }); + await tx.note_events.create({ note_id: note.id, action: "created" }); + return note; + }), +}); + +/** A new body for the caller's own note, recorded as an "edited" event. */ +export const updateNote = defineOperation({ + name: "notes.update", + entity: "notes", + method: "PATCH", + input: z.object({ id: z.number().int(), body }), + authorize: noteAuthor, + execute: ({ db, input }) => + db.transaction(async (tx) => { + const note = await tx.notes.update(input.id, { body: input.body }); + if (note) { + await tx.note_events.create({ note_id: note.id, action: "edited" }); + } + return note; + }), +}); + +/** The caller's own note; its events go with it through the foreign key. */ +export const deleteNote = defineOperation({ + name: "notes.delete", + entity: "notes", + method: "DELETE", + authorize: noteAuthor, + execute: ({ db, input }) => db.notes.delete(input.id), +}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 2ec5a51b4..3a03d02b6 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -14,6 +14,7 @@ import { } from "@databricks/appkit"; import { agents, aiSearch, database } from "@databricks/appkit/beta"; +import { databaseRoutes } from "./database/routes.gen"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -70,16 +71,10 @@ 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. + // only joins the app once an instance is actually configured. It serves + // the named operations in server/database/routes/ over HTTP. ...(process.env.LAKEBASE_ENDPOINT - ? [ - // The schema is loaded from config/database/schema.ts. - database({ - // Reads are generated for all three tables. Only boards and notes - // accept HTTP writes; note_events is exposed read-only. - api: { writes: { tables: ["boards", "notes"] } }, - }), - ] + ? [database({ routes: databaseRoutes })] : []), files({ volumes: { diff --git a/apps/dev-playground/server/smoke.test.ts b/apps/dev-playground/server/smoke.test.ts index 54c8b69d2..734a7ead5 100644 --- a/apps/dev-playground/server/smoke.test.ts +++ b/apps/dev-playground/server/smoke.test.ts @@ -1,9 +1,43 @@ +import { database } from "@databricks/appkit/beta"; import { createTestApp } from "@databricks/appkit/testing"; -import { describe, expect, test, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { schema } from "../config/database/schema"; +import { databaseRoutes } from "./database/routes.gen"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; +import { + type FakeWorkspace, + fakeWorkspace, + type MemoryDatabase, + memoryDatabase, +} from "./testing/database-edges"; + +// The database plugin opens two external edges at setup: a Lakebase pool and +// the Drizzle data path over it. Both are replaced by the in-memory database +// the running test installs; nothing else in the plugin is faked. +const edges = vi.hoisted(() => ({ + database: undefined as MemoryDatabase | undefined, +})); +vi.mock( + "../../../packages/appkit/src/connectors/lakebase/index.ts", + async (importOriginal) => ({ + ...(await importOriginal>()), + initializeLakebasePool: async () => ({ end: async () => undefined }), + }), +); +vi.mock( + "../../../packages/appkit/src/database/runtime/engine/drizzle-data-path.ts", + async (importOriginal) => ({ + ...(await importOriginal>()), + createDrizzleDb: () => ({}), + createDrizzleDataPath: () => { + if (!edges.database) throw new Error("No in-memory database installed"); + return edges.database.dataPath; + }, + }), +); /** * Smoke tests for the playground's own server plugins. @@ -149,3 +183,178 @@ describe("dev-playground server plugins", () => { ); }); }); + +describe("dev-playground database operations", () => { + // Two workspace users, told apart only by the token the Apps proxy forwards. + const ada = { + token: "token-ada", + id: "ada-1001", + userName: "ada@example.com", + }; + const bo = { token: "token-bo", id: "bo-2002", userName: "bo@example.com" }; + let workspace: FakeWorkspace; + + beforeAll(async () => { + workspace = await fakeWorkspace({ + [ada.token]: { id: ada.id, userName: ada.userName }, + [bo.token]: { id: bo.id, userName: bo.userName }, + }); + }); + afterAll(() => workspace.close()); + + /** The `id` of a row an operation answered with. */ + async function idOf(response: Response): Promise { + const row: unknown = await response.json(); + if ( + typeof row !== "object" || + row === null || + !("id" in row) || + typeof row.id !== "number" + ) { + throw new Error(`Expected a row with an id, got ${JSON.stringify(row)}`); + } + return row.id; + } + + /** Boot the real plugin with the playground's registry over a fresh database. */ + async function bootDatabase() { + const memory = memoryDatabase(schema); + edges.database = memory; + const app = await createTestApp({ + plugins: [database({ routes: databaseRoutes })], + env: { + DATABRICKS_APP_NAME: "playground-smoke", + DATABRICKS_HOST: workspace.host, + DATABRICKS_CONFIG_FILE: workspace.configFile, + // The manifest's Lakebase resource; the faked pool never connects. + LAKEBASE_ENDPOINT: "projects/smoke/branches/main/endpoints/primary", + PGHOST: "lakebase.smoke.invalid", + PGDATABASE: "playground", + PGPORT: "5432", + PGSSLMODE: "require", + }, + }); + const path = (name: string) => `/api/database/operations/${name}`; + // The Apps proxy forwards the signed-in user's token in this header. + const as = (token?: string): { headers: Record } => ({ + headers: token ? { "x-forwarded-access-token": token } : {}, + }); + return { + app, + memory, + read: (token: string | undefined, name: string, input: unknown) => + app.get( + `${path(name)}?input=${encodeURIComponent(JSON.stringify(input))}`, + as(token), + ), + post: (token: string, name: string, body: unknown) => + app.post(path(name), { ...as(token), body }), + patch: (token: string, name: string, body: unknown) => + app.patch(path(name), { ...as(token), body }), + remove: (token: string, name: string, body: unknown) => + app.delete(path(name), { ...as(token), body }), + }; + } + + test("answers a signed-in caller and refuses an anonymous one", async () => { + const { app, read } = await bootDatabase(); + await using _app = app; + + expect((await read(undefined, "boards.list", {})).status).toBe(401); + const signedIn = await read(ada.token, "boards.list", {}); + expect(signedIn.status).toBe(200); + await expect(signedIn.json()).resolves.toEqual([]); + }); + + test("writes a note and its created event together, authored by the caller", async () => { + const { app, memory, read, post } = await bootDatabase(); + await using _app = app; + const boardId = await idOf( + await post(ada.token, "boards.create", { title: "Launch review" }), + ); + + const created = await post(ada.token, "notes.create", { + board_id: boardId, + body: "Ship it", + }); + + expect(created.status).toBe(201); + const note: unknown = await created.json(); + expect(note).toMatchObject({ author: ada.id, body: "Ship it" }); + expect(note).not.toHaveProperty("author_email"); + const [stored] = memory.rows("notes"); + expect(stored).toMatchObject({ + author: ada.id, + author_email: ada.userName, + }); + expect(memory.rows("note_events")).toEqual([ + expect.objectContaining({ note_id: stored?.id, action: "created" }), + ]); + const listed = await read(bo.token, "notes.list", { board_id: boardId }); + await expect(listed.json()).resolves.toEqual([ + expect.objectContaining({ + id: stored?.id, + note_events: [expect.objectContaining({ action: "created" })], + }), + ]); + }); + + test("keeps neither the note nor its event when the event write fails", async () => { + const { app, memory, post } = await bootDatabase(); + await using _app = app; + const boardId = await idOf( + await post(ada.token, "boards.create", { title: "Launch review" }), + ); + memory.failNextInsert("note_events"); + + const failed = await post(ada.token, "notes.create", { + board_id: boardId, + body: "Lost", + }); + + expect(failed.status).toBe(500); + expect(memory.rows("notes")).toEqual([]); + expect(memory.rows("note_events")).toEqual([]); + }); + + test("lets only a note's author edit or delete it", async () => { + const { app, memory, post, patch, remove } = await bootDatabase(); + await using _app = app; + const boardId = await idOf( + await post(ada.token, "boards.create", { title: "Launch review" }), + ); + const noteId = await idOf( + await post(ada.token, "notes.create", { + board_id: boardId, + body: "Mine", + }), + ); + + const edit = { id: noteId, body: "Taken over" }; + expect((await patch(bo.token, "notes.update", edit)).status).toBe(403); + expect( + (await remove(bo.token, "notes.delete", { id: noteId })).status, + ).toBe(403); + expect(memory.rows("notes")).toEqual([ + expect.objectContaining({ body: "Mine" }), + ]); + expect(memory.rows("note_events").map((event) => event.action)).toEqual([ + "created", + ]); + + const edited = await patch(ada.token, "notes.update", { + id: noteId, + body: "Still mine", + }); + expect(edited.status).toBe(200); + expect(memory.rows("note_events").map((event) => event.action)).toEqual([ + "created", + "edited", + ]); + expect( + (await remove(ada.token, "notes.delete", { id: noteId })).status, + ).toBe(204); + expect(memory.rows("notes")).toEqual([]); + expect(memory.rows("note_events")).toEqual([]); + }); +}); diff --git a/apps/dev-playground/server/testing/database-edges.ts b/apps/dev-playground/server/testing/database-edges.ts new file mode 100644 index 000000000..0d17ff664 --- /dev/null +++ b/apps/dev-playground/server/testing/database-edges.ts @@ -0,0 +1,385 @@ +/** + * Test support for the playground's database operations: stand-ins for the two + * external edges the database plugin talks to, so a test boots the real + * plugin, routes registry, operations, and policies without Lakebase or a + * workspace. Neither is a database or an identity provider. + */ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import http, { type Server } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { Schema } from "@databricks/appkit/beta"; + +type Table = Schema["$tables"][string]; +type Row = Record; +type Clause = Readonly>; + +interface Read { + readonly where?: Clause; + readonly order?: Readonly>; + readonly select?: readonly string[]; + readonly include?: Readonly>; + readonly limit?: number; + readonly offset?: number; +} + +/** The data path the plugin reads and writes through, over plain arrays. */ +export interface MemoryDataPath { + select(table: Table, spec: Read): Promise; + findOne(table: Table, id: unknown, spec?: Read): Promise; + count(table: Table, where?: Clause): Promise; + insert(table: Table, values: Row): Promise; + update( + table: Table, + id: unknown, + values: Row, + where?: Clause, + ): Promise; + upsert(): Promise; + delete(table: Table, id: unknown, where?: Clause): Promise; + raw(strings: TemplateStringsArray, ...values: unknown[]): Promise; + transaction(callback: (tx: MemoryDataPath) => Promise): Promise; +} + +export interface MemoryDatabase { + readonly dataPath: MemoryDataPath; + /** Committed rows of one table, in insertion order. */ + rows(table: string): readonly Row[]; + /** Make the next insert into `table` fail as a driver error would. */ + failNextInsert(table: string): void; +} + +type Store = Record; + +/** The page size the real data path applies when a read has no limit. */ +const DEFAULT_LIMIT = 50; + +const isList = (value: unknown): value is readonly unknown[] => + Array.isArray(value); +const isClause = (value: unknown): value is Clause => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * An in-memory data path over the declared tables. Inserts fill serial ids + * and `defaultNow()` timestamps and enforce unique columns and foreign keys; + * deletes cascade along `onDelete("cascade")` keys. A transaction writes to a + * copy that becomes the committed rows only if its callback resolves. Every + * failure is a plain error, which AppKit answers with 500. + */ +export function memoryDatabase(schema: Schema): MemoryDatabase { + const tables = Object.values(schema.$tables); + let committed: Store = Object.fromEntries( + tables.map((table) => [table.$name, []]), + ); + let nextId = 1; + const failing = new Set(); + + const tableNamed = (name: string): Table => { + const table = tables.find((candidate) => candidate.$name === name); + if (!table) throw new Error(`memory database: no table ${name}`); + return table; + }; + const keyOf = (table: Table): string => { + const entry = Object.entries(table.$columns).find( + ([, meta]) => meta.primaryKey, + ); + if (!entry) throw new Error(`memory database: ${table.$name} has no key`); + return entry[0]; + }; + + /** One data path over the rows `store()` returns when it runs. */ + const build = (store: () => Store): MemoryDataPath => { + const rowsOf = (table: Table): Row[] => store()[table.$name] ?? []; + const write = (table: Table, rows: Row[]) => { + store()[table.$name] = rows; + }; + + const project = (table: Table, row: Row, spec: Read): Row => { + const names = + spec.select ?? + Object.entries(table.$columns) + .filter(([, meta]) => !meta.isPrivate) + .map(([name]) => name); + const projected: Row = Object.fromEntries( + names.map((name) => [name, row[name]]), + ); + for (const [name, option] of Object.entries(spec.include ?? {})) { + if (option === false) continue; + const relation = table.$relations.find((edge) => edge.name === name); + if (!relation) throw new Error(`memory database: no relation ${name}`); + const target = tableNamed(relation.targetTable); + const related = rowsOf(target).filter( + (candidate) => + candidate[relation.targetColumn] === row[relation.localColumn], + ); + const nested = option === true ? {} : option; + projected[name] = + relation.cardinality === "toMany" + ? read(target, related, nested) + : related[0] + ? project(target, related[0], nested) + : null; + } + return projected; + }; + + const read = (table: Table, candidates: Row[], spec: Read): Row[] => { + const offset = spec.offset ?? 0; + return candidates + .filter((row) => matches(row, spec.where)) + .sort((left, right) => { + for (const [name, direction] of Object.entries(spec.order ?? {})) { + const order = compare(left[name], right[name]); + if (order !== 0) return direction === "desc" ? -order : order; + } + return 0; + }) + .slice(offset, offset + (spec.limit ?? DEFAULT_LIMIT)) + .map((row) => project(table, row, spec)); + }; + + const byKey = (table: Table, id: unknown, where?: Clause) => + rowsOf(table).find( + (row) => row[keyOf(table)] === id && matches(row, where), + ); + + const remove = (table: Table, row: Row) => { + write( + table, + rowsOf(table).filter((entry) => entry !== row), + ); + for (const child of tables) { + for (const [name, meta] of Object.entries(child.$columns)) { + const fk = meta.fk; + if (fk?.targetTable !== table.$name) continue; + for (const dependent of rowsOf(child)) { + if (dependent[name] !== row[fk.targetColumn]) continue; + if (fk.onDelete !== "cascade") { + throw new Error("memory database: foreign key violation"); + } + remove(child, dependent); + } + } + } + }; + + const check = (table: Table, row: Row, replacing?: Row) => { + for (const [name, meta] of Object.entries(table.$columns)) { + const value = row[name]; + if (value === null || value === undefined) continue; + const taken = rowsOf(table).some( + (other) => other !== replacing && other[name] === value, + ); + if ((meta.unique || meta.primaryKey) && taken) { + throw new Error("memory database: unique violation"); + } + const fk = meta.fk; + if ( + fk && + !rowsOf(tableNamed(fk.targetTable)).some( + (target) => target[fk.targetColumn] === value, + ) + ) { + throw new Error("memory database: foreign key violation"); + } + } + }; + + return { + select: async (table, spec) => read(table, rowsOf(table), spec), + findOne: async (table, id, spec = {}) => { + const row = byKey(table, id, spec.where); + return row ? project(table, row, spec) : null; + }, + count: async (table, where) => + rowsOf(table).filter((row) => matches(row, where)).length, + insert: async (table, values) => { + if (failing.delete(table.$name)) { + throw new Error(`memory database: forced ${table.$name} failure`); + } + const row: Row = {}; + for (const [name, meta] of Object.entries(table.$columns)) { + row[name] = + values[name] !== undefined + ? values[name] + : meta.storageKind === "id" || meta.storageKind === "bigid" + ? nextId++ + : meta.defaultNow + ? new Date().toISOString() + : (meta.defaultValue ?? null); + } + check(table, row); + write(table, [...rowsOf(table), row]); + return { ...row }; + }, + update: async (table, id, values, where) => { + const current = byKey(table, id, where); + if (!current) return null; + const next = { ...current, ...values }; + check(table, next, current); + write( + table, + rowsOf(table).map((row) => (row === current ? next : row)), + ); + return { ...next }; + }, + upsert: async () => { + throw new Error("memory database: upsert is not supported"); + }, + delete: async (table, id, where) => { + const row = byKey(table, id, where); + if (row) remove(table, row); + return row !== undefined; + }, + // Setup runs `select 1`, then reads pg_catalog to confirm that every + // declared column exists. This database holds exactly the declared ones. + raw: async (strings) => + strings.join("?").includes("pg_catalog") + ? tables.flatMap((table) => + Object.values(table.$columns).map((meta) => ({ + table_name: table.$name, + column_name: meta.columnName, + relation_kind: "r", + })), + ) + : [{ "?column?": 1 }], + // AppKit joins nested transactions itself; one never reaches the edge. + transaction: async () => { + throw new Error("memory database: nested transaction"); + }, + }; + }; + + return { + dataPath: { + ...build(() => committed), + transaction: async (callback) => { + const staged: Store = Object.fromEntries( + Object.entries(committed).map(([name, rows]) => [name, [...rows]]), + ); + const result = await callback(build(() => staged)); + committed = staged; + return result; + }, + }, + rows: (table) => committed[table] ?? [], + failNextInsert: (table) => { + failing.add(table); + }, + }; +} + +function compare(left: unknown, right: unknown): number { + if (left === right) return 0; + if (left === null || left === undefined) return 1; + if (right === null || right === undefined) return -1; + if (typeof left === "number" && typeof right === "number") { + return left - right; + } + return String(left) < String(right) ? -1 : 1; +} + +/** Equality, `in` lists, comparisons, and `and`/`or` groups. */ +function matches(row: Row, where?: Clause): boolean { + if (!where) return true; + return Object.entries(where).every(([name, condition]) => { + if ((name === "and" || name === "or") && isList(condition)) { + const results = condition.map( + (clause) => isClause(clause) && matches(row, clause), + ); + return name === "and" ? results.every(Boolean) : results.some(Boolean); + } + const value = row[name]; + if (isList(condition)) return condition.includes(value); + if (!isClause(condition)) return value === condition; + return Object.entries(condition).every(([operator, operand]) => { + switch (operator) { + case "eq": + return value === operand; + case "neq": + return value !== operand; + case "in": + return isList(operand) && operand.includes(value); + case "gt": + return compare(value, operand) > 0; + case "gte": + return compare(value, operand) >= 0; + case "lt": + return compare(value, operand) < 0; + case "lte": + return compare(value, operand) <= 0; + default: + throw new Error(`memory database: unsupported filter ${operator}`); + } + }); + }); +} + +/** A workspace user `currentUser.me` describes for one bearer token. */ +export interface WorkspaceUser { + readonly id: string; + readonly userName: string; +} + +export interface FakeWorkspace { + /** Use as `DATABRICKS_HOST`. */ + readonly host: string; + /** + * Use as `DATABRICKS_CONFIG_FILE`: an empty profile file, so the SDK never + * reads the developer's own `~/.databrickscfg`. + */ + readonly configFile: string; + /** Every path the workspace was asked for, in order. */ + readonly requests: readonly string[]; + close(): Promise; +} + +/** + * A loopback HTTP server that answers only SCIM `Me`, naming the user whose + * token the request bears and refusing any other token with 401. Every other + * path is 404, so nothing reaches a real workspace. + */ +export async function fakeWorkspace( + users: Readonly>, +): Promise { + const requests: string[] = []; + const server: Server = http.createServer((req, res) => { + requests.push(`${req.method} ${req.url}`); + const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]; + const user = token === undefined ? undefined : users[token]; + res.setHeader("content-type", "application/json"); + if (!req.url?.startsWith("/api/2.0/preview/scim/v2/Me")) { + res.writeHead(404).end(JSON.stringify({ error_code: "NOT_FOUND" })); + } else if (!user) { + res.writeHead(401).end(JSON.stringify({ error_code: "UNAUTHENTICATED" })); + } else { + res.writeHead(200).end( + JSON.stringify({ + ...user, + active: true, + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + }), + ); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("fake workspace: no TCP address"); + } + const profiles = await mkdtemp(path.join(tmpdir(), "fake-workspace-")); + const configFile = path.join(profiles, ".databrickscfg"); + await writeFile(configFile, ""); + return { + host: `http://127.0.0.1:${address.port}`, + configFile, + requests, + close: async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(profiles, { recursive: true, force: true }); + }, + }; +} diff --git a/apps/dev-playground/shared/appkit-types/database-operations.d.ts b/apps/dev-playground/shared/appkit-types/database-operations.d.ts new file mode 100644 index 000000000..0b9d366d1 --- /dev/null +++ b/apps/dev-playground/shared/appkit-types/database-operations.d.ts @@ -0,0 +1,82 @@ +// Auto-generated by AppKit - DO NOT EDIT + +declare global { + interface DatabricksAppKitDatabaseOperations { + "boards.create": { + method: "POST"; + input: { + "title": string; + }; + output: { + "id": number; + "slug": string; + "title": string; + "created_at": string; + }; + }; + "boards.list": { + method: "GET"; + input: Record; + output: { + "id": number; + "slug": string; + "title": string; + "note_count": number; + }[]; + }; + "notes.create": { + method: "POST"; + input: { + "board_id": number; + "body": string; + }; + output: { + "id": number; + "board_id": number; + "author": string; + "body": string; + "created_at": string; + }; + }; + "notes.delete": { + method: "DELETE"; + input: { + "id": number; + }; + output: void; + }; + "notes.list": { + method: "GET"; + input: { + "board_id": number; + }; + output: { + "id": number; + "author": string; + "body": string; + "created_at": string; + "note_events": { + "id": number; + "action": string; + "created_at": string; + }[]; + }[]; + }; + "notes.update": { + method: "PATCH"; + input: { + "id": number; + "body": string; + }; + output: { + "id": number; + "board_id": number; + "author": string; + "body": string; + "created_at": string; + }; + }; + } +} + +export {}; diff --git a/apps/dev-playground/shared/appkit-types/database.d.ts b/apps/dev-playground/shared/appkit-types/database.d.ts index a388108d0..ffeeb1a77 100644 --- a/apps/dev-playground/shared/appkit-types/database.d.ts +++ b/apps/dev-playground/shared/appkit-types/database.d.ts @@ -50,13 +50,6 @@ interface GeneratedDatabaseRegistry { "slug"?: string; "title"?: string; }; - filters: DatabaseLogicalFilter<{ - "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - "slug"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - "title"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; - }>; - orderable: "id" | "slug" | "title" | "created_at"; key: "id"; }; }; @@ -115,14 +108,6 @@ interface GeneratedDatabaseRegistry { "author"?: string; "body"?: string; }; - filters: DatabaseLogicalFilter<{ - "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - "board_id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - "author"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - "body"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; - }>; - orderable: "id" | "board_id" | "author" | "body" | "created_at"; key: "id"; }; }; @@ -169,13 +154,6 @@ interface GeneratedDatabaseRegistry { "note_id"?: number; "action"?: string; }; - filters: DatabaseLogicalFilter<{ - "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - "note_id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - "action"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; - }>; - orderable: "id" | "note_id" | "action" | "created_at"; key: "id"; }; }; @@ -184,7 +162,3 @@ interface GeneratedDatabaseRegistry { declare module "@databricks/appkit" { interface DatabaseRegistry extends GeneratedDatabaseRegistry {} } - -declare global { - interface DatabricksAppKitDatabaseRegistry extends GeneratedDatabaseRegistry {} -} diff --git a/apps/dev-playground/tsconfig.database.json b/apps/dev-playground/tsconfig.database.json new file mode 100644 index 000000000..ee54fe7a0 --- /dev/null +++ b/apps/dev-playground/tsconfig.database.json @@ -0,0 +1,15 @@ +{ + // Type-check the database schema, policies, operations, and their test + // edges against the built @databricks/appkit declarations and the generated + // registry. Run `pnpm build` first. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "config/database", + "server/database", + "server/testing", + "shared/appkit-types/database.d.ts" + ] +} diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 1d6b31fb6..6e1013a98 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -496,6 +496,18 @@ BasePlugin.getSkipBodyParsingPaths *** +### getSkipBodyParsingPrefixes() + +```ts +getSkipBodyParsingPrefixes(): ReadonlySet; +``` + +#### Returns + +`ReadonlySet`\<`string`\> + +*** + ### injectRoutes() ```ts @@ -608,3 +620,25 @@ setup(): Promise; ```ts BasePlugin.setup ``` + +*** + +### skipBodyParsingUnder() + +```ts +protected skipBodyParsingUnder(path: string): void; +``` + +Leave every request at or below `path` of this plugin's router unparsed, +for a handler mounted with `router.use(path, handler)` that reads its own +body, such as one that must authenticate the caller first. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `path` | `string` | + +#### Returns + +`void` diff --git a/docs/docs/api/appkit/Function.database.md b/docs/docs/api/appkit/Function.database.md index ec21cfd6c..aff1a5f36 100644 --- a/docs/docs/api/appkit/Function.database.md +++ b/docs/docs/api/appkit/Function.database.md @@ -41,9 +41,11 @@ Create the database plugin. Omit configuration to load function database(config?: IDatabaseConfig): DatabaseRegistration; ``` -Register the database plugin with opinionated defaults and full HTTP CRUD. -By default, setup loads the named `schema` export from the application's -`config/database/schema.ts`. Registration itself performs no file or database I/O. +Register the database plugin. Pass `routes: databaseRoutes` to serve the +generated named operations over HTTP; without it, no database endpoint is +registered. Without `routes` or `schema`, setup loads the named `schema` +export from the application's `config/database/schema.ts`. Registration +itself performs no file or database I/O. ### Type Parameters diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md b/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md deleted file mode 100644 index 8ec0c7be7..000000000 --- a/docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +++ /dev/null @@ -1,59 +0,0 @@ -# Type Alias: DatabaseApiConfig\ - -```ts -type DatabaseApiConfig = - | boolean - | { - tables?: readonly SchemaTableName[]; - writes?: DatabaseApiWritesConfig; -}; -``` - -Full generated CRUD for every declared table by default. Set false to disable -all generated routes, or use an object to restrict tables and writes. -Keyed routes require a public primary key; upsert stays programmatic. -Route names must start with a letter, contain only letters, digits, `_`, or -`-`, be at most 64 characters, and be unique ignoring case. Invalid names -fail setup; exclude internal tables with `api.tables` or use `api: false`. - -Routes run as the app's service principal and apply no per-user filter, so -anyone the app admits receives every enabled operation. An exposed table -also becomes includable from its neighbours; a relation whose target -stays off cannot be included. - -Text filters accept caller-supplied `like`/`ilike` patterns; a server-side -`statement_timeout` cancels a pattern that would otherwise hold its pooled -connection to completion. - -## Type Parameters - -| Type Parameter | -| ------ | -| `TSchema` *extends* [`Schema`](Interface.Schema.md) | - -## Type Declaration - -`boolean` - -```ts -{ - tables?: readonly SchemaTableName[]; - writes?: DatabaseApiWritesConfig; -} -``` - -### tables? - -```ts -readonly optional tables: readonly SchemaTableName[]; -``` - -Tables that remain exposed. Defaults to every declared table. - -### writes? - -```ts -readonly optional writes: DatabaseApiWritesConfig; -``` - -All writes by default. Set false for read-only routes. diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md b/docs/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md deleted file mode 100644 index 4b8f40ca8..000000000 --- a/docs/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md +++ /dev/null @@ -1,7 +0,0 @@ -# Type Alias: DatabaseApiWriteOperation - -```ts -type DatabaseApiWriteOperation = "create" | "update" | "delete"; -``` - -Generated HTTP write operations. diff --git a/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md b/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md deleted file mode 100644 index 1af91e127..000000000 --- a/docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +++ /dev/null @@ -1,45 +0,0 @@ -# Type Alias: DatabaseApiWritesConfig\ - -```ts -type DatabaseApiWritesConfig = - | boolean - | { - operations?: readonly DatabaseApiWriteOperation[]; - tables?: readonly SchemaTableName[]; -}; -``` - -All writes by default; false keeps reads only, and an object narrows writes. - -## Type Parameters - -| Type Parameter | -| ------ | -| `TSchema` *extends* [`Schema`](Interface.Schema.md) | - -## Type Declaration - -`boolean` - -```ts -{ - operations?: readonly DatabaseApiWriteOperation[]; - tables?: readonly SchemaTableName[]; -} -``` - -### operations? - -```ts -readonly optional operations: readonly DatabaseApiWriteOperation[]; -``` - -Operations that remain enabled. Defaults to create, update, and delete. - -### tables? - -```ts -readonly optional tables: readonly SchemaTableName[]; -``` - -Tables that remain writable. Defaults to every exposed table. diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index 3e8a88782..57eb9a58a 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -2,7 +2,7 @@ ```ts type IDatabaseConfig = { - api?: DatabaseApiConfig; + routes?: DatabaseRoutes | false; schema?: TSchema; }; ``` @@ -17,26 +17,21 @@ Configuration for one schema-bound DatabasePlugin instance. ## Properties -### api? +### routes? ```ts -readonly optional api: DatabaseApiConfig; +readonly optional routes: DatabaseRoutes | false; ``` -Generated HTTP CRUD is enabled for all tables by default, using the app's -service principal. Every admitted caller receives the enabled operations; -no per-user or per-row authorization is applied. This plugin does not -support OBO. Use custom routes for application-specific authorization. +The generated registry of named operations: `database({ routes: databaseRoutes })`. +Setup loads its schema and every module it lists, then serves each +authorized operation at `/api/database/operations/:name` with its +declared method. An operation without `authorize` is skipped with a +warning. A malformed or duplicate operation, or a module that fails to +load, fails setup. -Set `false` to disable routes, `{ writes: false }` for reads only, or -`{ tables: ["notes"] }` to expose only selected tables. To disable delete, -use `{ writes: { operations: ["create", "update"] } }`. - -#### Default Value - -```ts -true -``` +Absent or `false` serves no database endpoint over HTTP; `appkit.database` +stays available to server code either way. *** @@ -46,6 +41,8 @@ true readonly optional schema: TSchema; ``` -Explicit schema override. When omitted, setup loads the named `schema` -export from `config/database/schema.ts` under the application's working -directory. Missing or invalid declarations fail setup before pool creation. +Explicit schema override. Without `routes`, setup otherwise loads the +named `schema` export from `config/database/schema.ts` under the +application's working directory. With `routes`, setup uses the schema the +registry loads, and a different `schema` here fails setup. Missing or +invalid declarations fail setup before pool creation. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 4cbd14cfe..fceee78bc 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -152,9 +152,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [BaseSystemPromptOption](TypeAlias.BaseSystemPromptOption.md) | - | | [CallerPrincipal](TypeAlias.CallerPrincipal.md) | The caller identity whose permissions authorize execution, not its resources. | | [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | -| [DatabaseApiConfig](TypeAlias.DatabaseApiConfig.md) | Full generated CRUD for every declared table by default. Set false to disable all generated routes, or use an object to restrict tables and writes. Keyed routes require a public primary key; upsert stays programmatic. Route names must start with a letter, contain only letters, digits, `_`, or `-`, be at most 64 characters, and be unique ignoring case. Invalid names fail setup; exclude internal tables with `api.tables` or use `api: false`. | -| [DatabaseApiWriteOperation](TypeAlias.DatabaseApiWriteOperation.md) | Generated HTTP write operations. | -| [DatabaseApiWritesConfig](TypeAlias.DatabaseApiWritesConfig.md) | All writes by default; false keeps reads only, and an object narrows writes. | | [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | | [DatabricksAppKitDatabaseOperations](TypeAlias.DatabricksAppKitDatabaseOperations.md) | The same global registry, available as a module type for package exports. | | [EvalProgress](TypeAlias.EvalProgress.md) | - | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index f824e1864..125105214 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -675,21 +675,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ConfigSchema", label: "ConfigSchema" }, - { - type: "doc", - id: "api/appkit/TypeAlias.DatabaseApiConfig", - label: "DatabaseApiConfig" - }, - { - type: "doc", - id: "api/appkit/TypeAlias.DatabaseApiWriteOperation", - label: "DatabaseApiWriteOperation" - }, - { - type: "doc", - id: "api/appkit/TypeAlias.DatabaseApiWritesConfig", - label: "DatabaseApiWritesConfig" - }, { type: "doc", id: "api/appkit/TypeAlias.DatabaseExports", diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 30c9accd8..3338f26cb 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -11,20 +11,18 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor Declare your tables in `config/database/schema.ts` and register `database()` to -get generated HTTP CRUD and a server-side database client. CRUD is enabled for -every declared table by default. Use `api` to restrict the generated routes -without disabling server-side access. - -:::caution[Shared application access] -This plugin uses the app's service principal in deployed Databricks Apps. It does -not support OBO and does not apply per-user or per-row authorization. Every caller -who can reach the generated API can perform every enabled operation on every -exposed row, including delete. - -Restrict access to the app and grant its service principal only the database -permissions it needs. If users need different permissions or row ownership checks, -disable the relevant generated routes and implement authorization in custom -server routes. App admission alone does not provide row-level isolation. +get a typed database client for server code. To reach the database from the +browser, write named operations. Each operation declares its input, its output, +and who may call it, and the plugin serves it at +`/api/database/operations/`. Without operations, the plugin serves no HTTP +endpoint. + +:::caution[The service principal runs every query] +In a deployed Databricks App, the plugin connects to Lakebase as the app's service +principal for every caller. PostgreSQL sees one role, so it cannot tell users +apart and row-level security does not separate them. An operation's `authorize` +is the only check between a signed-in user and the rows its `execute` reads or +writes. Grant the service principal only the database permissions the app needs. ::: ## Basic usage @@ -32,16 +30,11 @@ server routes. App admission alone does not provide row-level isolation. Configure a Lakebase `postgres` resource and its connection environment variables as described in [Lakebase configuration](./lakebase.md#environment-variables). -When a query fails at runtime, the client receives only a stable message such as -`Database operation failed`. The server log records the SQLSTATE and only -identifier-only messages for known missing-column or missing-table errors -(for example `column notes.board_id does not exist`). Other driver messages, -details, and hints are omitted because they may contain row values. - Apps scaffolded with the Database plugin selected include an empty `config/database/schema.ts`, so `database()` can start without requiring sample -tables. Replace the empty declaration with your models when their PostgreSQL -tables are ready. +tables. Replace the empty declaration with your tables once they exist in +PostgreSQL. The plugin never creates or migrates tables. At startup, it checks +that every declared table and column exists and fails setup if one is missing. For local development, the PostgreSQL username is resolved from your Databricks credentials when `PGUSER` and `DATABRICKS_CLIENT_ID` are absent. An explicitly @@ -49,14 +42,22 @@ configured username takes precedence. ```ts // config/database/schema.ts -import { defineSchema, id, text } from "@databricks/appkit/beta"; +import { defineSchema, fk, id, text, timestamp } from "@databricks/appkit/beta"; -export const schema = defineSchema((builder) => ({ - notes: builder.table("notes", { +export const schema = defineSchema(({ table }) => { + const notes = table("notes", { id: id(), + author: text().notNull(), 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: text().notNull(), + }); + return { notes, note_events }; +}); ``` ```ts @@ -64,41 +65,42 @@ export const schema = defineSchema((builder) => ({ import { createApp, server } from "@databricks/appkit"; import { database } from "@databricks/appkit/beta"; -const AppKit = await createApp({ +const appkit = await createApp({ plugins: [server(), database()], }); -``` -With the server plugin enabled, this registers: +const latest = await appkit.database.notes + .order({ created_at: "desc" }) + .limit(20) + .toArray(); +``` -| Method | Path | Operation | -| --- | --- | --- | -| GET | `/api/database/notes` | List rows | -| GET | `/api/database/notes/:id` | Find one row | -| POST | `/api/database/notes` | Create a row | -| PATCH | `/api/database/notes/:id` | Update a row | -| DELETE | `/api/database/notes/:id` | Delete a row | +`appkit.database` has one client per declared table, plus `sql` and +`transaction`. Its reads and writes are typed from the generated +`shared/appkit-types/database.d.ts`. It runs as the service principal and checks +no authorization of its own. -A table without a public primary key supports create and, if it has a sortable -public column, list with an explicit non-empty `order`. A keyless table with no -sortable columns has no list route. `upsert` is available to server code but has -no generated HTTP route. +When a query fails at runtime, a caller receives only a stable message such as +`Database operation failed`. The server log records the SQLSTATE and only +identifier-only messages for known missing-column or missing-table errors (for +example, `column notes.board_id does not exist`). Other driver messages, details, +and hints are omitted because they may contain row values. ## Schema discovery and overrides -`database()` and `database({})` use the same defaults. During setup, the plugin -loads the named `schema` export from `config/database/schema.ts`, relative to the -application's working directory. The file must export a finalized `defineSchema()` -result. Missing files, import failures, and invalid exports fail setup before the -plugin creates a connection pool; they do not silently create an empty schema. +During setup, `database()` loads the named `schema` export from +`config/database/schema.ts`, relative to the application's working directory. +The file must export a finalized `defineSchema()` result. Missing files, import +failures, and invalid exports fail setup before the plugin creates a connection +pool; they never fall back to an empty schema. Keep `config/database/schema.ts` and its local imports in your deployment. The plugin loads TypeScript through Jiti, so a plain Node production process does not need a separate TypeScript loader. The schema module should only declare tables, not connect to the database or start the app. -For a different layout or a deployment that contains only a server bundle, import -the schema explicitly and pass it to the plugin: +For a different layout, or a deployment that contains only a server bundle, +import the schema and pass it to the plugin: ```ts import { schema } from "../config/database/schema"; @@ -106,91 +108,239 @@ import { schema } from "../config/database/schema"; database({ schema }); ``` -An explicit schema always takes precedence and skips file discovery. An invalid -explicit schema fails setup instead of falling back to another file. +An explicit schema skips file discovery. With [`routes`](#serve-the-operations), +the registry loads the schema itself; a `schema` passed beside it must be that +same object, or setup fails. -Run `appkit generate-types` to generate the database registry. With that registry, -configuration without an explicit schema still infers table names. -An explicitly supplied schema also checks configuration keys against its own table -names. +## Operations -## Restrict the generated API +An operation is one named database action that the browser may call. It +declares the entity it works on, its HTTP method, its input and output, an +`authorize` rule, and an `execute` function. -Omitting `api`, or setting it to `true` or `{}`, enables full CRUD. Restrictions -are optional. There is no separate write opt-in. +### Define an operation -```ts -// No generated HTTP routes. The server-side client still works. -database({ api: false }); - -// Read-only routes for every table. -database({ api: { writes: false } }); +Write operations in modules under `server/database/routes/`. Each exported +`defineOperation()` result is an operation, and other exports are ignored. -// Full CRUD for selected tables only. -database({ api: { tables: ["notes"] } }); +```ts +// server/database/routes/notes.ts +import { defineOperation } from "@databricks/appkit/beta"; +import { z } from "zod"; + +import { noteAuthor, signedIn } from "../policies"; + +export const listNotes = defineOperation({ + name: "notes.list", + entity: "notes", + method: "GET", + input: z.object({ limit: z.number().int().max(100) }), + output: z.array( + z.object({ id: z.number(), author: z.string(), body: z.string() }), + ), + authorize: signedIn, + execute: ({ db, input }) => + db.notes.order({ created_at: "desc" }).limit(input.limit).toArray(), +}); -// Allow reads, create, and update, but not delete. -database({ - api: { writes: { operations: ["create", "update"] } }, +export const createNote = defineOperation({ + name: "notes.create", + entity: "notes", + method: "POST", + input: z.object({ body: z.string().trim().min(1) }), + authorize: signedIn, + execute: ({ actor, db, input }) => + db.transaction(async (tx) => { + const note = await tx.notes.create({ author: actor.id, body: input.body }); + await tx.note_events.create({ note_id: note.id, action: "created" }); + return note; + }), }); -// Read every table, but allow writes only to notes. -database({ - api: { writes: { tables: ["notes"] } }, +export const updateNote = defineOperation({ + name: "notes.update", + entity: "notes", + method: "PATCH", + input: z.object({ id: z.number().int(), body: z.string().trim().min(1) }), + authorize: noteAuthor, + execute: ({ db, input }) => db.notes.update(input.id, { body: input.body }), }); ``` -| Option | Default | Effect | +| Field | Required | Meaning | | --- | --- | --- | -| `schema` | Named export in `config/database/schema.ts` | Overrides automatic schema loading | -| `api` | `true` | `false` disables all generated routes | -| `api.tables` | All declared tables | Limits which tables have routes | -| `api.writes` | `true` | `false` keeps only read routes | -| `api.writes.tables` | All exposed tables | Limits which exposed tables accept writes | -| `api.writes.operations` | `create`, `update`, `delete` | Limits which writes are enabled | +| `name` | Yes | The name the browser calls, one URL segment. Parts of letters, digits, `_`, and `-`, each starting with a letter, joined by dots. At most 64 characters per part and 255 in total. `constructor` and `prototype` are reserved | +| `entity` | Yes | The declared table the operation works on. It types the default input and output | +| `method` | Yes | `GET`, `POST`, `PATCH`, `PUT`, or `DELETE`. The operation answers only this method | +| `input` | No | A Zod schema for the input. `execute` receives the parsed value | +| `output` | No | A Zod schema for the answer. The plugin parses what `execute` returns and sends only that | +| `authorize` | Yes | A function or a [policy](#authorize-an-operation) that admits the caller | +| `execute` | Yes | Does the work and returns the answer | -`api.tables: []` disables all generated routes. An empty write-table or -write-operation list keeps reads and disables writes. Tables omitted from -`api.tables` also cannot be included through relations on exposed tables. -These restrictions apply to HTTP only, not to the server-side client. +Without `input` or `output`, the entity and method decide them: -An omitted restriction uses its default. A malformed restriction fails setup. -For example, `{ api: { write: false } }` is an error, not permission to generate -all writes. Unknown tables, duplicate names, and unsupported operations also -fail setup before the plugin creates a connection pool. +| Method | Default input | Default output | +| --- | --- | --- | +| `GET` | `{ id }` | The public row, or 404 when `execute` returns `null` | +| `POST` | The row's insertable public columns | The created public row, with status 201 | +| `PATCH` | `{ id, changes }`, with every updatable column optional and at least one present | The updated public row, or 404 for `null` | +| `PUT` | `{ id, values }`, with every updatable column required | The updated public row, or 404 for `null` | +| `DELETE` | `{ id }` | No body, status 204. `false` from `execute` answers 404 | + +A default output carries a `bigint` as a decimal string and a timestamp as +RFC 3339 text, such as `2024-01-01T10:00:00+00:00`, which a default input +accepts back. + +Keyed defaults need a public primary key. Server-controlled columns, such as an +author taken from the caller, need an explicit `input` that leaves them out; +otherwise the default `POST` and `PATCH` inputs accept them from the browser. + +`execute` and `authorize` receive one context: + +| Field | Meaning | +| --- | --- | +| `actor` | The verified caller, `{ id, email? }`. See [Caller identity](#caller-identity) | +| `input` | The parsed input | +| `db` | The same client as `appkit.database` | +| `appkit` | Every plugin's exports, as `createApp` returns them | +| `resource` | The value a resource policy loaded, otherwise `undefined` | + +The context carries no request, headers, or user token. Use an explicit nullable +`output`, such as `z.object({ ... }).nullable()`, for a `GET` that may answer +`null` with status 200. + +When a custom `output` declares literal values, such as `z.literal("ok")` or +`z.enum(["open", "closed"])`, a hand-written literal that `execute` returns +needs `as const`. Without it, TypeScript widens `"ok"` to `string`, and the +operation does not compile: -Replace the former `crudRoutes` option with `api`. The old name is rejected at -runtime so an old opt-out cannot silently enable the API. To preserve read-only -behavior, specify `api: { writes: false }`. +```ts +execute: async () => ({ status: "ok" }) as const, +``` -### Table names and setup errors +### Authorize an operation + +`authorize` runs after the plugin has verified the caller, and before `execute`. +A function receives the context without a resource: + +```ts +authorize: ({ actor, input }) => input.owner === actor.id, +``` + +To share a rule between operations, define it once with `definePolicy`. A +policy with a `resource` function loads one value, such as the row the input +names. `allow` then decides with that value, and `execute` receives it as +`resource`: + +```ts +// server/database/policies.ts +import { definePolicy, type PolicyContext } from "@databricks/appkit/beta"; + +/** Any signed-in user. Without one, the plugin has already answered 401. */ +export const signedIn = definePolicy({ allow: () => true }); + +/** The note `input.id` names, for its author only. */ +export const noteAuthor = definePolicy({ + resource: ({ db, input }: PolicyContext<{ readonly id: number }>) => + db.notes.find(input.id), + allow: ({ actor, resource }) => + resource.author === actor.id ? true : { deny: "forbidden" }, +}); +``` -Generated route names must: +Annotate the `resource` context with the input fields the policy reads. Any +operation whose input has those fields can use the policy, and one that lacks +them is a type error. -- Start with an ASCII letter. -- Contain only ASCII letters, digits, underscores, or hyphens. -- Be at most 64 characters long. -- Be unique without regard to case, because Express routes are case-insensitive. +| The rule | The plugin answers | +| --- | --- | +| Returns `true` | Runs `execute`. Only the literal `true` admits | +| Returns `false` | 403, or 404 for a resource policy | +| Returns `{ deny: "forbidden" }` | 403 | +| Returns `{ deny: "not-found" }` | 404 | +| `resource` returns `null` or `undefined` | 404, and `allow` does not run | +| Throws | 403, or 404 for a resource policy | -The default API validates every declared table. A table such as `_events` fails -setup instead of being silently omitted. The error names the table and suggests -renaming it, excluding it with `api.tables`, or disabling routes with `api: false`. -An excluded table remains available to server code. +A denial may carry a `reason` for your own tests. The plugin never sends it or +logs it. `execute` runs at most once, and only after `authorize` admitted. -Configuration errors include actionable details in the server-side error message. -Client-facing messages do not expose those details. +### Serve the operations -## Validation and private columns +The AppKit Vite plugin and `appkit generate-types` find the modules directly in +`server/database/routes/`, except `*.test.ts`, `*.d.ts`, and `*.gen.ts` files. +The Vite plugin runs when the dev server starts, and whenever a file in +`config/database/` or `server/database/routes/` changes. Both write two files: -The generated API validates request bodies and rejects unknown or read-only -fields. Columns marked `.private()` are not available through generated routes. -Database-generated primary keys, including `uuid().primaryKey().defaultRandom()`, -cannot be supplied by HTTP callers. A natural primary key may be supplied on -create, but primary keys cannot be updated through HTTP. +- `server/database/routes.gen.ts`, the registry: the schema and each routes + module, loaded lazily. +- `shared/appkit-types/database-operations.d.ts`, the browser contract: each + operation's method and the JSON types of its input and output. -Validation is not authorization. A valid request can still read or change any -exposed row. Use custom authorized routes when that is not the desired access -model. +Commit both. Then register the plugin with the registry: + +```ts +// server/index.ts +import { createApp, server } from "@databricks/appkit"; +import { database } from "@databricks/appkit/beta"; + +import { databaseRoutes } from "./database/routes.gen"; + +await createApp({ + plugins: [server(), database({ routes: databaseRoutes })], +}); +``` + +Setup loads every listed module and validates each operation before the plugin +opens a connection. A malformed declaration or a repeated name fails setup. An +operation without `authorize` is not served; setup logs a warning that names +its file and export, and a production build (`NODE_ENV=production`) fails +generation instead. With `routes` omitted or `false`, the plugin registers no +HTTP endpoint at all. + +An explicit schema must have an exact JSON type. Strings, numbers, booleans, +`null`, literals, enums, arrays, objects, records with string keys, unions, and +`z.unknown()` qualify, including their optional, nullable, and default forms. A +schema without one, such as `z.date()`, `z.tuple()`, `z.json()`, a transform, or +an input that coerces or preprocesses its value, fails generation with a message +that names the operation. + +### Endpoints + +Each operation answers one method at `/api/database/operations/`: + +- A `GET` carries its input as one URL-encoded JSON parameter: + `/api/database/operations/notes.list?input=%7B%22limit%22%3A20%7D`. The query + string is limited to 8 KiB. +- Other methods take a JSON body of at most 1 MiB, and no query parameters. +- A success answers 200, or 201 for `POST`, or 204 with no body. +- An unknown name, or a method the operation does not declare, answers 404. +- Without a verified caller, every request answers 401, before the plugin reads + its input. +- A browser request from another origin answers 403. +- Every response carries `Cache-Control: no-store`. + +A failure answers `{ "error": "" }`. An input that fails its +schema also carries `details`, a list of `{ path, message }` pairs that name the +input field without its value. + +### Caller identity + +The plugin verifies the caller on every request, and selects how once, from the +deployment: + +- In Databricks Apps (`DATABRICKS_APP_NAME` is set and `NODE_ENV` is not + `development`), the user token the Apps proxy forwards is checked with the + workspace. A rejected token, an inactive user, or a service principal answers + 401. The forwarded user and email headers are not trusted. +- In local development (`NODE_ENV=development`, with `DATABRICKS_TOKEN` or + `DATABRICKS_CONFIG_PROFILE`), the caller is the developer those credentials + sign in. Only same-origin requests to a loopback address are accepted. Set + `APPKIT_DEV_OBO=false` to turn this off. +- Anywhere else, every operation answers 401, and setup logs a warning. + +`actor.id` is the workspace user ID, and `actor.email` is the user name when the +workspace reports one. The user's token is never used for the database +connection. ## Transactions @@ -202,13 +352,9 @@ opening another one. A root write made while the callback is running also joins it, and keeps the predicate from the query that issued the write. A read or an `sql` call made on the root client does not join. -```ts -const note = await appkit.database.transaction(async (tx) => { - const created = await tx.notes.create({ body: "Ship it" }); - await tx.note_events.create({ note_id: created.id, action: "created" }); - return created; -}); -``` +The `notes.create` operation above writes a note and its event this way. If the +event cannot be written, the note is not written either, and the caller +receives the failure. The callback commits when it returns and rolls back when it throws. A thrown `DatabaseValidationError` keeps its identity. Any other thrown error becomes an @@ -229,159 +375,54 @@ The callback deadline does not cancel arbitrary JavaScript, HTTP requests, or other external side effects. Avoid starting external work that needs the database rollback to undo it. -## Browser client (beta) - -`@databricks/appkit-ui/js/beta` provides `databaseApi`, a typed client for the -generated routes. Entity names, parameters, values, and rows are typed from the -same generated registry as the server-side client, restricted to what the -generated routes accept. The client adds no authorization. Anyone who can load -the page can call the same routes. - -### Setup +## Private columns -Run `appkit generate-types` or use the AppKit Vite plugin to write -`shared/appkit-types/database.d.ts`, and include it in the client's TypeScript -project. It binds the server registry and a shared global interface used by -`@databricks/appkit-ui/js/beta`; server-only apps do not need the UI package or -`skipLibCheck`. Until the file exists, every entity name is a type error. +A column declared with `.private()` stays on the server. Server code, including +`execute`, can read and write it, but a default output never includes it. An +explicit `output` answers only the fields its schema names, so list the public +fields you mean to send. For example, `execute` can store the caller's email +from `actor.email` in a private column that no input accepts and no output +returns. -The client finds routes in the endpoint map the server embeds in the page. When -the `api` configuration does not expose an operation, a call to it fails with -`NOT_EXPOSED` and sends no request. The page also carries the relations between -exposed tables, as relation and table names only, so the -[React hooks](#refresh-reads-after-a-write) can tell which reads a write -affects. These are the names `include` already accepts. +## Browser client (beta) -### Calls +`@databricks/appkit-ui/js/beta` provides `databaseApi`, which calls an +operation by name: ```ts import { databaseApi } from "@databricks/appkit-ui/js/beta"; -const page = await databaseApi.list("notes", { - where: { board_id: 7 }, - order: { created_at: "desc" }, - limit: 20, -}); -const board = await databaseApi.get("boards", 7, { select: ["id", "title"] }); -const note = await databaseApi.create("notes", { - board_id: board.id, - author: "ada", - body: "Ship it", -}); -await databaseApi.update("notes", note.id, { body: "Shipped" }); -await databaseApi.remove("notes", note.id); -``` - -| Call | Route | Resolves with | -| --- | --- | --- | -| `list(entity, params?)` | `GET /api/database/` | The list envelope `{ items, limit, offset }` | -| `get(entity, id, params?)` | `GET /api/database//:id` | The row | -| `create(entity, values)` | `POST /api/database/` | The created row | -| `update(entity, id, values)` | `PATCH /api/database//:id` | The updated row | -| `remove(entity, id)` | `DELETE /api/database//:id` | Nothing | - -A failed call rejects with a [`DatabaseApiError`](#errors). An optional last -argument, `{ signal }`, cancels the request, and the promise then rejects with -the abort reason. Cancelling a write does not undo it if the server already -committed it. - -Only tables with a public primary key that the URL path can represent have -`get`, `update`, and `remove`. Boolean and JSONB primary keys, like private or -missing keys, are treated as keyless over HTTP: no keyed routes are published, -although creates and explicitly ordered lists remain available when supported. -Calling a keyed operation on these tables is a type error. A missing row on a -supported keyed route rejects with `NOT_FOUND`. - -### Parameters - -| Parameter | `list` | `get` | Accepts | -| --- | --- | --- | --- | -| `where` | Yes | No | Public, queryable columns. A value, or an operator object (`eq`, `neq`, `in`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte` by column kind, `is: null` for nullable columns), combined with `and` and `or` | -| `order` | Yes | No | Public, queryable columns mapped to `"asc"` or `"desc"`; required and non-empty for keyless lists | -| `select` | Yes | Yes | Public columns. The row type narrows to them | -| `include` | Yes | Yes | Exposed relations, `true` or options, at most two edges deep. Only a to-many relation takes a `limit` | -| `limit`, `offset` | Yes | No | Integers, 0 to 500 and 0 to 10,000 | - -Private columns, JSON columns in `where` or `order`, and unknown parameters are -compile errors. An explicit `where` must not be empty, and an undefined nested -filter value fails rather than broadening the read; omit `where` entirely to -list all rows. A top-level `where: {}` fails locally with guidance to omit the -parameter, without sending a request. For optional search controls, for example: - -```typescript -const where = search ? { body: { ilike: `%${search}%` } } : undefined; -await databaseApi.list("notes", { where }); +const notes = await databaseApi.execute("notes.list", { limit: 20 }); +await databaseApi.execute("notes.update", { id: 7, body: "Shipped" }); ``` -A to-many include adds an array to each row and a to-one include adds a row or -`null`. JSON carries bigint columns as decimal strings, so rows type them as -`string`, and filters accept a string or a safe integer. Dynamic `select` arrays -return optional properties for their possible columns; a literal tuple returns -those properties as required. This also applies to detail and included rows. -Params typed broadly with an optional `select` keep row fields optional. - -### Values - -`create` and `update` accept only the fields the generated route accepts. -Private columns, server-generated columns such as `id()`, and unknown fields are -compile errors, including fields that a spread carries in. Every update field is -optional, and primary keys and `defaultNow()` or `defaultRandom()` columns -cannot be updated. Bigint columns accept a decimal string or a safe integer. -NaN and positive or negative Infinity fail locally before a write is sent, -including inside JSON values. Use an explicit `null` to clear a nullable column; -a non-finite number never implicitly becomes null. Query numbers must also be -finite. - -Skip an update when a form has no changes. An empty update returns -`INVALID_REQUEST` with a `body` detail explaining that at least one field is -required, and the stored row does not change. - -The answered row is the public row the database holds after the write. - -### Errors - -A `DatabaseApiError` has a stable `code`, the HTTP `status`, a `message`, and -`details`. Each detail is a `{ path, message }` pair that names a public request -field. Branch on `code` rather than `message`. - -| `code` | `status` | Meaning | -| --- | --- | --- | -| `NOT_EXPOSED` | `null` | No published route for the operation. Nothing was sent | -| `INVALID_REQUEST` | 400 or `null` | Malformed or unsupported parameters. `null` when the client refused them before sending: params that cannot be encoded, or an id of `""`, `"."`, or `".."`, which would resolve to another route | -| `FORBIDDEN` | 403 | The database refused the operation | -| `NOT_FOUND` | 404 | No row has this id | -| `CONFLICT` | 409 | A constraint rejected the change | -| `PAYLOAD_TOO_LARGE` | 413 | The response exceeded the size limit | -| `UNSUPPORTED_MEDIA_TYPE` | 415 | The request body was not JSON | -| `VALIDATION_FAILED` | 422 | A value failed validation | -| `TRANSIENT` | 503 or `null` | Temporarily unavailable, or a read received no response | -| `OUTCOME_UNKNOWN` | `null` or a successful HTTP status | A write received no usable response; it may have committed. Check before retrying | -| `INTERNAL` | 500 or other | Any other failure | +Names, inputs, and outputs are typed from +`shared/appkit-types/database-operations.d.ts`, so include it in the client's +TypeScript project. The URL and method come from what the server published in +the page, so a name the server does not serve fails with `NOT_EXPOSED` and +sends no request. A failed call rejects with a [`DatabaseApiError`](#errors). +An optional third argument, `{ signal }`, cancels the request, and the promise +then rejects with the abort reason. Cancelling a write does not undo it if the +server already committed it. ## React hooks (beta) -`@databricks/appkit-ui/react/beta` provides React hooks over the -[browser client](#browser-client-beta). They take the same entity, id, and -[parameters](#parameters) as `databaseApi`, need the same -[setup](#setup), and report a failure as the same -[`DatabaseApiError`](#errors) in `error`. +`@databricks/appkit-ui/react/beta` provides one hook for `GET` operations and +one for every other method. Both take an operation name and are typed from the +same generated file as `databaseApi`. -### Read a list +### Read ```tsx -import { useDatabaseList } from "@databricks/appkit-ui/react/beta"; +import { useDatabaseQuery } from "@databricks/appkit-ui/react/beta"; -function Notes({ boardId }: { boardId: number }) { - const notes = useDatabaseList("notes", { - where: { board_id: boardId }, - order: { created_at: "desc" }, - limit: 20, - }); +function Notes() { + const notes = useDatabaseQuery("notes.list", { limit: 20 }); if (notes.error) return

{notes.error.message}

; return (
    - {notes.data?.items.map((note) => ( + {notes.data?.map((note) => (
  • {note.body}
  • ))}
@@ -389,204 +430,88 @@ function Notes({ boardId }: { boardId: number }) { } ``` -`data` is the list envelope `{ items, limit, offset }`, or `null` until the -first response arrives. To wait for a value the params depend on, pass `null` -as the params; the hook stays idle and sends nothing: - -```tsx -const notes = useDatabaseList( - "notes", - board ? { where: { board_id: board.id }, limit: 20 } : null, -); -``` - -`{ enabled: false }` in the third argument also holds the request. - -### Paginate +`useDatabaseQuery(name, input, options?)` returns `data`, `isLoading`, `error`, +and `refetch()`. `data` is `undefined` until the first answer arrives. Pass +`{ enabled: false }` to hold the request, for example until a value the input +needs has loaded. -New params start a new request, and `data` is `null` until it answers. For a -paginated list, pass `keepPreviousData: true` to keep showing the previous page -while the next one loads. `loading` stays `true` until it arrives, and a failure -for the new page shows no stale rows. - -```tsx -const [offset, setOffset] = useState(0); -const notes = useDatabaseList( - "notes", - { order: { id: "desc" }, limit: 20, offset }, - { keepPreviousData: true }, -); - -; -``` - -### Read one record - -```tsx -import { useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; - -const board = useDatabaseRecord("boards", boardId, { - include: { notes: { limit: 20, include: { note_events: { limit: 5 } } } }, -}); -board.data?.notes[0]?.note_events; -``` +- An equal input on a later render keeps the request in flight. A new input + aborts it and clears `data` and `error`. +- `refetch()` sends the request again and keeps `data` visible while it loads. +- Each hook sends its own request. Nothing is shared or cached between hooks, + and unmounting aborts the request. -Only tables with a public primary key have a detail route, so a keyless table or -a table with a private key is a type error here. A `null` or `undefined` id -holds the hook without a request. A missing row reports `NOT_FOUND`, and a -refetch that finds the row deleted clears `data` rather than showing it beside -the error. An id of `""`, `"."`, or `".."` reports `INVALID_REQUEST` without a -request. To pass options without params, use `{}` for the params: -`useDatabaseRecord("boards", boardId, {}, { keepPreviousData: true })`. - -### Request lifecycle - -- Hooks that request the same entity with parameters that encode to the same - query share one request while any of them is mounted. An inline parameter - object does not refetch on every render. -- `refetch()` aborts the in-flight request and sends it again. The last `data` - stays visible while it loads and if it fails, except for `NOT_FOUND`. -- The request is aborted once the last hook using it unmounts. Nothing is cached - after that. A React Strict Mode remount reuses the in-flight request. -- `error` keeps its identity across renders until it changes, so an effect that - depends on it, such as a toast, runs once per failure. -- A successful write through a write hook restarts mounted reads. See - [Refresh reads after a write](#refresh-reads-after-a-write). - -### Write rows +### Write ```tsx import { useState } from "react"; -import { useDatabaseCreate } from "@databricks/appkit-ui/react/beta"; +import { useDatabaseMutation } from "@databricks/appkit-ui/react/beta"; -function AddNote({ boardId }: { boardId: number }) { - const notes = useDatabaseCreate("notes"); +function AddNote() { + const createNote = useDatabaseMutation("notes.create"); const [body, setBody] = useState(""); async function submit(event: React.FormEvent) { event.preventDefault(); - const note = await notes.create({ board_id: boardId, author: "ada", body }); - if (note) setBody(""); + const { error } = await createNote.mutate({ body }); + if (error === null) setBody(""); } return (
setBody(event.target.value)} /> - - {notes.error && ( -

{notes.error.details[0]?.message ?? notes.error.message}

- )} + + {createNote.error &&

{createNote.error.message}

}
); } ``` -| Hook | Call | Route | Resolves with | -| --- | --- | --- | --- | -| `useDatabaseCreate(entity)` | `create(values)` | `POST /api/database/` | The created row, or `null` | -| `useDatabaseUpdate(entity)` | `update(id, values)` | `PATCH /api/database//:id` | The updated row, or `null` | -| `useDatabaseDelete(entity)` | `remove(id)` | `DELETE /api/database//:id` | `true`, or `false` | +`useDatabaseMutation(name)` sends nothing until you call `mutate(input)`. It +returns `mutate`, `data`, `isLoading`, `error`, and `reset()`. -Each hook also returns `loading`, `error`, and `reset()`. The create and update -hooks return `data`, the row the latest call answered with. Like -`useServingInvoke`, a call never rejects: a failure resolves `null` (or `false` -for `remove`) and the `DatabaseApiError` is in `error`, so a handler needs no -`try/catch`. To handle failures as exceptions, call `databaseApi` instead. +- `mutate` resolves `{ data, error }`, where `error` is `null` exactly when the + write succeeded. It never rejects, so a handler needs no `try/catch`. +- A write is never retried and never aborted, even when its component + unmounts, because the server may have committed it. +- A successful write restarts every mounted `useDatabaseQuery`, which keeps its + `data` visible while it reloads. An operation can change any row, so every + query restarts. +- Only the latest call updates `data`, `isLoading`, and `error`. `reset()` + returns the hook to idle. +- Writes are not queued or deduplicated. Disable the submit control while + `isLoading`. -A successful call resolves once the reads it restarts have reloaded, and -`loading` stays `true` until then. A handler that clears its form after -`await create(...)` therefore sees the new row already in the lists. A read -that fails to reload does not fail the write. If another write or a manual -refetch supersedes a reload, the waiting call follows the current run of each -read rather than resolving on the cancelled run. Reads with no subscribers -stop holding the write open. After teardown, remounting the same URL creates -a new entry, not part of that write's refresh. +### Session changes -Values follow the same [rules](#values) as `databaseApi.create` and `update`. -Update and delete need a public primary key, like record reads. +Every answer names its caller. When an answer shows that a different user is +signed in, or a 401 shows that the session ended, every query drops its data +and asks again, and every mutation returns to idle. An answer that arrives +afterwards for the earlier caller fails with `SESSION_CHANGED` instead of +showing that caller's data. -### Observe every write +## Errors -`error` only shows the latest call, and only while a component renders it. To -report every outcome, for example to a toast or an error tracker, pass -`onSuccess` and `onError`: - -```tsx -const notes = useDatabaseCreate("notes", { - onSuccess: (note) => toast(`Added note #${note.id}`), - onError: (error, values) => report(error, { board: values.board_id }), -}); -``` +A `DatabaseApiError` has a stable `code`, the HTTP `status`, a `message`, and +`details`. Branch on `code` rather than `message`. -| Hook | `onSuccess` | `onError` | +| `code` | `status` | Meaning | | --- | --- | --- | -| `useDatabaseCreate` | `(row, values)` | `(error, values)` | -| `useDatabaseUpdate` | `(row, id, values)` | `(error, id, values)` | -| `useDatabaseDelete` | `(id)` | `(error, id)` | - -The callbacks run for every call, including one a later call superseded and one -that finishes after the component unmounted, once the hook's state has settled. -`onSuccess` runs after the restarted reads reload. A callback that throws does -not change what the call resolves with; its error is reported as uncaught. -Inline callbacks do not change the identity of `create`, `update`, or `remove`. - -### Refresh reads after a write - -When a write succeeds, every mounted database read restarts, so lists and -includes that show the changed row refresh without a manual `refetch()`. Each -read keeps its last `data` while it reloads. A failed write restarts nothing. - -The default restarts every read because a foreign key declared with -`onDelete("cascade")` can change other tables, and the browser cannot see that. -When a page mounts many reads, pass `invalidate` to restart only the reads that -show the tables you name, or none: - -```ts -// A board's title changes only boards rows. -useDatabaseUpdate("boards", { invalidate: ["boards"] }); - -// Restart nothing. Call refetch() on the reads that need it. -useDatabaseCreate("notes", { invalidate: false }); -``` - -A read shows a table when it is rooted at it or when its `include` reaches it. -The server publishes the relations between exposed tables, so -`invalidate: ["notes"]` also restarts a `boards` read that includes `notes`, -and a `boards` read that includes `notes` and then `note_events`. A read whose -include the server did not describe is restarted by any table list. - -A write the hooks did not make, such as a `databaseApi` call or one of your own -routes that changes rows, does not restart reads by itself. Call -`invalidateDatabaseReads` from `@databricks/appkit-ui/react/beta` afterwards. It -takes the same scope as `invalidate`, defaults to every mounted read, and -resolves once the restarted reads have reloaded: - -```ts -import { invalidateDatabaseReads } from "@databricks/appkit-ui/react/beta"; - -await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); -await invalidateDatabaseReads(["boards", "notes"]); -``` - -### Write lifecycle - -- A write is never aborted, even when its component unmounts. Aborting the - request would not undo a transaction the server already committed. -- A write that succeeds after its component unmounts still restarts reads and - runs `onSuccess`, because the rows did change. -- Only the latest call updates `data`, `loading`, and `error`. An earlier call - still resolves for the code that awaits it, with `null` if it failed. -- `invalidate`, `onSuccess`, and `onError` are read when a call settles, so the - options of the latest render apply. -- `reset()` returns the hook to idle. A call in flight keeps running, but no - longer updates the hook. -- Writes are not queued or deduplicated. Each call sends its own request, so - disable the submit control while `loading`. +| `NOT_EXPOSED` | `null` | The server does not serve this operation, or the hook takes another kind. Nothing was sent | +| `UNAUTHENTICATED` | 401 | No verified caller | +| `INVALID_REQUEST` | 400 or `null` | The input failed its schema. `null` when the client could not encode it and sent nothing | +| `FORBIDDEN` | 403 | `authorize` refused, the request came from another origin, or the database refused | +| `NOT_FOUND` | 404 | No such operation for this method, a resource policy found nothing, or `execute` answered `null` | +| `CONFLICT` | 409 | A constraint rejected the change | +| `PAYLOAD_TOO_LARGE` | 413 | The request body exceeded its limit | +| `UNSUPPORTED_MEDIA_TYPE` | 415 | The request body was not JSON | +| `VALIDATION_FAILED` | 422 | A value failed the schema's validation | +| `TRANSIENT` | 503 or `null` | Temporarily unavailable, or a read received no response | +| `SESSION_CHANGED` | `null` | The answer belongs to an earlier session. A write may have completed | +| `OUTCOME_UNKNOWN` | 500, `null`, or a successful status | A write may have committed without a usable answer, for example when it failed after `execute` ran. Check before retrying | +| `INTERNAL` | 500 or other | Any other failure | ## API reference - [`database`](../api/appkit/Function.database.md) - [`IDatabaseConfig`](../api/appkit/TypeAlias.IDatabaseConfig.md) -- [`DatabaseApiConfig`](../api/appkit/TypeAlias.DatabaseApiConfig.md) diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index 8b5709d35..7d1ce351f 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -1,34 +1,19 @@ // Beta JS utilities -- APIs may change between minor releases. // Import from '@databricks/appkit-ui/js' once graduated to stable. -// Database client + types. Tracks the `database` plugin, which ships at beta -// from '@databricks/appkit/beta'. -export type { - DatabaseErrorCategory, - DatabaseErrorDetail, - DatabaseListPage, -} from "shared"; +// Database operation client + types. Tracks the `database` plugin, which ships +// at beta from '@databricks/appkit/beta'. +export type { DatabaseErrorCategory, DatabaseErrorDetail } from "shared"; export { type DatabaseApi, type DatabaseRequestOptions, databaseApi, } from "./database/client"; export { DatabaseApiError, type DatabaseApiErrorCode } from "./database/errors"; -export type { DatabaseRegistry } from "./database/registry"; export type { - DatabaseEntity, - DatabaseId, - DatabaseInsert, - DatabaseKeyedEntity, - DatabaseListParams, - DatabaseListRow, DatabaseMutationName, DatabaseOperationInput, DatabaseOperationName, DatabaseOperationOutput, DatabaseQueryName, - DatabaseRecordParams, - DatabaseRecordRow, - DatabaseRow, - DatabaseUpdate, } from "./database/types"; diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index ddd847e7b..c3d2eea0e 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -5,10 +5,6 @@ import { } from "shared"; import { getClientConfig, getPluginClientConfig } from "../config"; -import { - type DatabaseApi as DatabaseEntityApi, - databaseApi as databaseEntityApi, -} from "./entity-client"; import { DatabaseApiError, invalidDatabaseInput } from "./errors"; import { acceptDatabaseReply, @@ -31,11 +27,8 @@ export interface DatabaseRequestOptions { readonly signal?: AbortSignal; } -/** - * Typed calls to the operations `DatabasePlugin` publishes, by name. The entity - * calls remain until the plugin serves operations instead of entity routes. - */ -export interface DatabaseApi extends DatabaseEntityApi { +/** Typed calls to the operations `DatabasePlugin` publishes, by name. */ +export interface DatabaseApi { /** * Call one operation with its declared method and resolve with its JSON * answer, or `undefined` when it answers without a body. The name, input, @@ -399,7 +392,6 @@ async function send( * methods come from the server's boot payload. */ export const databaseApi: DatabaseApi = { - ...databaseEntityApi, async execute( name: N, input: DatabaseOperationInput, diff --git a/packages/appkit-ui/src/js/database/entity-client.test.ts b/packages/appkit-ui/src/js/database/entity-client.test.ts deleted file mode 100644 index e504298b2..000000000 --- a/packages/appkit-ui/src/js/database/entity-client.test.ts +++ /dev/null @@ -1,682 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { _resetConfigCache } from "../config"; -import { - databaseApi as typedApi, - type DatabaseRequestOptions, -} from "./entity-client"; -import { DatabaseApiError } from "./errors"; - -// This package has no generated registry, so every entity name is `never` -// here. The typed surface is compiled against a real schema in the appkit -// type-generator tests; these cover the runtime transport. -const databaseApi = typedApi as unknown as { - list( - entity: string, - params?: object, - init?: DatabaseRequestOptions, - ): Promise<{ items: unknown[]; limit: number; offset: number }>; - get( - entity: string, - id: string | number | bigint, - params?: object, - init?: DatabaseRequestOptions, - ): Promise>; - create( - entity: string, - values: object, - init?: DatabaseRequestOptions, - ): Promise>; - update( - entity: string, - id: string | number | bigint, - values: object, - init?: DatabaseRequestOptions, - ): Promise>; - remove( - entity: string, - id: string | number | bigint, - init?: DatabaseRequestOptions, - ): Promise; -}; - -const PAGE = { items: [{ id: 1, body: "hi" }], limit: 5, offset: 0 }; - -function publish(database: Record | undefined): void { - window.__appkit__ = { - appName: "test", - queries: {}, - endpoints: database ? { database } : {}, - plugins: {}, - }; -} - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -async function rejection(promise: Promise): Promise { - return promise.then( - () => { - throw new Error("expected the request to fail"); - }, - (error: unknown) => error, - ); -} - -describe("databaseApi.list", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(async () => json(PAGE)); - vi.stubGlobal("fetch", fetchMock); - publish({ - "notes.list": "/api/database/notes", - "notes.detail": "/api/database/notes/:id", - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); - - test("refuses an unpublished operation locally without sending a request", async () => { - const error = await rejection(databaseApi.list("boards")); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - name: "DatabaseApiError", - code: "NOT_EXPOSED", - status: null, - details: [], - message: 'Database operation "boards.list" is not exposed', - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("refuses every operation when the plugin published no routes", async () => { - _resetConfigCache(); - publish(undefined); - - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "NOT_EXPOSED", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("calls the published route with the encoded query", async () => { - const page = await databaseApi.list("notes", { - where: { board_id: 7 }, - order: { created_at: "desc" }, - limit: 5, - }); - - expect(page).toEqual(PAGE); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const [path, query] = url.split("?"); - expect(path).toBe("/api/database/notes"); - expect(Object.fromEntries(new URLSearchParams(query))).toEqual({ - where: '{"board_id":7}', - order: '{"created_at":"desc"}', - limit: "5", - }); - expect(init.method).toBe("GET"); - expect(new Headers(init.headers).get("Accept")).toBe("application/json"); - }); - - test("sends no query string when there are no params", async () => { - await databaseApi.list("notes"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes"); - }); - - test("rejects an incomplete filter locally without widening the request", async () => { - await expect( - databaseApi.list("notes", { where: { board_id: undefined } }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database query contains an unsupported value", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("explains an empty filter locally without broadening the read", async () => { - await expect( - databaseApi.list("notes", { where: {} }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Filter cannot be empty; omit where to list all rows", - details: [ - { - path: ["where"], - message: "Filter cannot be empty; omit where to list all rows", - }, - ], - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test.each([NaN, Infinity, -Infinity])( - "rejects non-finite filter %s before JSON can turn it into null", - async (value) => { - await expect( - databaseApi.list("notes", { where: { rank: { is: value } } }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database query numbers must be finite", - details: [ - { path: ["where"], message: "Database query numbers must be finite" }, - ], - }); - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); - - test("uses the route path the server published, not a local convention", async () => { - _resetConfigCache(); - publish({ "notes.list": "/custom/base/notes" }); - - await databaseApi.list("notes", { limit: 1 }); - expect(fetchMock.mock.calls[0]?.[0]).toBe("/custom/base/notes?limit=1"); - }); - - test("decodes a failure envelope into its stable category and details", async () => { - fetchMock.mockResolvedValueOnce( - json( - { - error: "Invalid database request", - details: [ - { path: ["where"], message: "Names an unknown column" }, - { path: "where", message: "not a detail" }, - { message: "no path" }, - ], - }, - 400, - ), - ); - - const error = await rejection(databaseApi.list("notes")); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "INVALID_REQUEST", - status: 400, - message: "Invalid database request", - details: [{ path: ["where"], message: "Names an unknown column" }], - }); - }); - - test.each([ - [403, "FORBIDDEN"], - [404, "NOT_FOUND"], - [409, "CONFLICT"], - [413, "PAYLOAD_TOO_LARGE"], - [422, "VALIDATION_FAILED"], - [503, "TRANSIENT"], - [500, "INTERNAL"], - [502, "INTERNAL"], - ])("maps status %i to %s", async (status, code) => { - fetchMock.mockResolvedValueOnce(json({ error: "stable" }, status)); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code, - status, - }); - }); - - test("keeps the category when a failure body is not JSON", async () => { - fetchMock.mockResolvedValueOnce( - new Response("Bad gateway", { status: 502 }), - ); - - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "INTERNAL", - status: 502, - message: "Database request failed with status 502", - details: [], - }); - }); - - test("rejects a success body that is not the list envelope", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 1 }])); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "INTERNAL", - status: 200, - }); - - fetchMock.mockResolvedValueOnce( - new Response("", { status: 200 }), - ); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "INTERNAL", - status: 200, - message: "Database response is not JSON", - }); - }); - - test("reports an unanswered read as TRANSIENT without claiming the server never saw it", async () => { - const cause = new TypeError("Failed to fetch"); - fetchMock.mockRejectedValueOnce(cause); - - const error = await rejection(databaseApi.list("notes")); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ code: "TRANSIENT", status: null, cause }); - expect((error as Error).message).not.toContain("did not reach the server"); - }); - - test("passes the caller's signal and rejects with its abort, not a database error", async () => { - const controller = new AbortController(); - fetchMock.mockImplementationOnce( - (_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - init.signal?.addEventListener("abort", () => - reject(init.signal?.reason), - ); - }), - ); - - const pending = databaseApi.list( - "notes", - {}, - { signal: controller.signal }, - ); - controller.abort(); - const error = await rejection(pending); - - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - signal: controller.signal, - }); - expect(error).not.toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ name: "AbortError" }); - }); -}); - -describe("databaseApi.get", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(async () => json({ id: 7, title: "Roadmap" })); - vi.stubGlobal("fetch", fetchMock); - publish({ - "boards.list": "/api/database/boards", - "boards.detail": "/api/database/boards/:id", - "events.list": "/api/database/events", - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); - - test("calls the published detail route with the id and the encoded query", async () => { - const row = await databaseApi.get("boards", 7, { - include: { notes: { limit: 20 } }, - select: ["id", "title"], - }); - - expect(row).toEqual({ id: 7, title: "Roadmap" }); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const [path, query] = url.split("?"); - expect(path).toBe("/api/database/boards/7"); - expect([...new URLSearchParams(query)]).toEqual([ - ["select", '["id","title"]'], - ["include", '{"notes":{"limit":20}}'], - ]); - expect(init.method).toBe("GET"); - }); - - test("encodes the id as one path segment", async () => { - await databaseApi.get("boards", "a/b c?d"); - await databaseApi.get("boards", 9007199254740993n); - - expect(fetchMock.mock.calls[0]?.[0]).toBe( - "/api/database/boards/a%2Fb%20c%3Fd", - ); - expect(fetchMock.mock.calls[1]?.[0]).toBe( - "/api/database/boards/9007199254740993", - ); - }); - - test.each(["", ".", ".."])( - "refuses the id %j, which URL resolution would move off the detail route", - async (id) => { - // "" and "." resolve to the list route, ".." to the plugin root. - const error = await rejection(databaseApi.get("boards", id)); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database id must be a non-empty path segment", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); - - test("keeps an id that only contains dots among other characters", async () => { - await databaseApi.get("boards", "..."); - await databaseApi.get("boards", "v1.2"); - - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - "/api/database/boards/...", - "/api/database/boards/v1.2", - ]); - }); - - test("maps a missing row to NOT_FOUND", async () => { - fetchMock.mockResolvedValueOnce( - json({ error: "Database record not found" }, 404), - ); - - await expect(databaseApi.get("boards", 404)).rejects.toMatchObject({ - name: "DatabaseApiError", - code: "NOT_FOUND", - status: 404, - message: "Database record not found", - }); - }); - - test("refuses a keyless table locally, since it has no detail route", async () => { - const error = await rejection(databaseApi.get("events", 1)); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "NOT_EXPOSED", - status: null, - message: 'Database operation "events.detail" is not exposed', - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("rejects a success body that is not one row", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 7 }])); - - await expect(databaseApi.get("boards", 7)).rejects.toMatchObject({ - code: "INTERNAL", - status: 200, - message: "Database response has an unexpected shape", - }); - }); -}); - -describe("databaseApi writes", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(async () => json({ id: 7, body: "hi" }, 201)); - vi.stubGlobal("fetch", fetchMock); - publish({ - "notes.list": "/api/database/notes", - "notes.detail": "/api/database/notes/:id", - "notes.create": "/api/database/notes", - "notes.update": "/api/database/notes/:id", - "notes.delete": "/api/database/notes/:id", - "ledger.create": "/api/database/ledger", - // Read-only: the plugin published reads but no writes. - "note_events.list": "/api/database/note_events", - "note_events.detail": "/api/database/note_events/:id", - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); - - function sent(index = 0): { url: string; init: RequestInit } { - const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; - return { url, init }; - } - - test("creates with a JSON body on the published route and returns the row", async () => { - const row = await databaseApi.create("notes", { - board_id: 7, - author: "ada", - body: "hi", - }); - - expect(row).toEqual({ id: 7, body: "hi" }); - const { url, init } = sent(); - expect(url).toBe("/api/database/notes"); - expect(init.method).toBe("POST"); - const headers = new Headers(init.headers); - expect(headers.get("Content-Type")).toBe("application/json"); - expect(headers.get("Accept")).toBe("application/json"); - expect(JSON.parse(init.body as string)).toEqual({ - board_id: 7, - author: "ada", - body: "hi", - }); - }); - - test("sends a bigint value as its decimal string, which the server reads back exactly", async () => { - await databaseApi.create("ledger", { - seq: 9007199254740993n, - note: "x", - }); - - expect(sent().init.body).toBe('{"seq":"9007199254740993","note":"x"}'); - }); - - test.each([NaN, Infinity, -Infinity])( - "rejects non-finite write %s rather than clearing a nullable value", - async (value) => { - for (const write of [ - () => databaseApi.create("notes", { rank: value }), - () => databaseApi.update("notes", 7, { rank: value }), - () => databaseApi.update("notes", 7, { payload: { scores: [value] } }), - ]) { - await expect(write()).rejects.toMatchObject({ - name: "DatabaseApiError", - code: "INVALID_REQUEST", - status: null, - message: - "Database write numbers must be finite; use null explicitly to clear a value", - details: [ - { - path: ["body"], - message: - "Database write numbers must be finite; use null explicitly to clear a value", - }, - ], - }); - } - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); - - test("keeps an explicit null and finite numbers unchanged", async () => { - await databaseApi.update("notes", 7, { rank: null, payload: { score: 0 } }); - expect(sent().init.body).toBe('{"rank":null,"payload":{"score":0}}'); - }); - - test("sanitizes serialization failures before any write is sent", async () => { - const values = { - toJSON: () => { - throw new Error("private serialization detail"); - }, - }; - await expect(databaseApi.create("notes", values)).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database write contains an unsupported value", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("reports an unanswered write as unknown because it may have committed", async () => { - const cause = new TypeError("Connection lost after sending the request"); - fetchMock.mockRejectedValue(cause); - - for (const request of [ - () => - databaseApi.create("notes", { board_id: 7, author: "ada", body: "hi" }), - () => databaseApi.update("notes", 7, { body: "edited" }), - () => databaseApi.remove("notes", 7), - ]) { - await expect(request()).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: null, - cause, - message: expect.stringContaining("may have completed"), - }); - } - }); - - test("updates one row with PATCH on its encoded id", async () => { - fetchMock.mockResolvedValueOnce(json({ id: 7, body: "edited" })); - - const row = await databaseApi.update("notes", "a/b", { body: "edited" }); - - expect(row).toEqual({ id: 7, body: "edited" }); - const { url, init } = sent(); - expect(url).toBe("/api/database/notes/a%2Fb"); - expect(init.method).toBe("PATCH"); - expect(new Headers(init.headers).get("Content-Type")).toBe( - "application/json", - ); - expect(init.body).toBe('{"body":"edited"}'); - }); - - test("deletes one row and resolves without reading the empty 204 body", async () => { - const response = new Response(null, { status: 204 }); - const readBody = vi.spyOn(response, "json"); - fetchMock.mockResolvedValueOnce(response); - - await expect(databaseApi.remove("notes", 7)).resolves.toBeUndefined(); - - const { url, init } = sent(); - expect(url).toBe("/api/database/notes/7"); - expect(init.method).toBe("DELETE"); - expect(init.body).toBeUndefined(); - expect(readBody).not.toHaveBeenCalled(); - }); - - test("keeps the server's validation details on a 422", async () => { - fetchMock.mockResolvedValueOnce( - json( - { - error: "Database request failed validation", - details: [ - { path: ["body"], message: "Must be at most 5000 characters" }, - ], - }, - 422, - ), - ); - - const error = await rejection( - databaseApi.create("notes", { board_id: 7, author: "ada", body: "x" }), - ); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "VALIDATION_FAILED", - status: 422, - message: "Database request failed validation", - details: [{ path: ["body"], message: "Must be at most 5000 characters" }], - }); - }); - - test("maps a 415 to UNSUPPORTED_MEDIA_TYPE", async () => { - fetchMock.mockResolvedValueOnce( - json({ error: "Database request body must be JSON" }, 415), - ); - - await expect( - databaseApi.update("notes", 7, { body: "x" }), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_MEDIA_TYPE", - status: 415, - message: "Database request body must be JSON", - }); - }); - - test("maps a missing row on update or delete to NOT_FOUND", async () => { - fetchMock.mockResolvedValue( - json({ error: "Database record not found" }, 404), - ); - - await expect( - databaseApi.update("notes", 404, { body: "x" }), - ).rejects.toMatchObject({ code: "NOT_FOUND", status: 404 }); - await expect(databaseApi.remove("notes", 404)).rejects.toMatchObject({ - code: "NOT_FOUND", - status: 404, - }); - }); - - test("refuses a write addressed by an id that is not one path segment", async () => { - const results = await Promise.all([ - rejection(databaseApi.update("notes", "..", { body: "x" })), - rejection(databaseApi.remove("notes", ".")), - rejection(databaseApi.remove("notes", "")), - ]); - - expect(results).toMatchObject([ - { code: "INVALID_REQUEST", status: null }, - { code: "INVALID_REQUEST", status: null }, - { code: "INVALID_REQUEST", status: null }, - ]); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("refuses writes the plugin did not publish without sending a request", async () => { - const results = await Promise.all([ - rejection(databaseApi.create("note_events", { note_id: 1 })), - rejection(databaseApi.update("note_events", 1, { action: "x" })), - rejection(databaseApi.remove("note_events", 1)), - ]); - - expect(results.map((error) => error instanceof DatabaseApiError)).toEqual([ - true, - true, - true, - ]); - expect(results).toMatchObject([ - { - code: "NOT_EXPOSED", - status: null, - message: 'Database operation "note_events.create" is not exposed', - }, - { code: "NOT_EXPOSED", message: expect.stringContaining(".update") }, - { code: "NOT_EXPOSED", message: expect.stringContaining(".delete") }, - ]); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("reports a successful write with an invalid response as outcome unknown", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 7 }], 201)); - await expect( - databaseApi.create("notes", { board_id: 7, author: "a", body: "b" }), - ).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: 201, - message: expect.stringContaining("may have completed"), - }); - - fetchMock.mockResolvedValueOnce(json({ id: 7 }, 200)); - await expect(databaseApi.remove("notes", 7)).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: 200, - }); - - fetchMock.mockResolvedValueOnce( - new Response("broken JSON", { status: 200 }), - ); - await expect( - databaseApi.update("notes", 7, { body: "edited" }), - ).rejects.toMatchObject({ code: "OUTCOME_UNKNOWN", status: 200 }); - }); -}); diff --git a/packages/appkit-ui/src/js/database/entity-client.ts b/packages/appkit-ui/src/js/database/entity-client.ts deleted file mode 100644 index 32137f78a..000000000 --- a/packages/appkit-ui/src/js/database/entity-client.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { - type DatabaseErrorDetail, - type DatabaseListPage, - type DatabaseListQuery, - databaseErrorCategoryForStatus, - encodeDatabaseListQuery, - encodeDatabaseRecordQuery, - type ExactDatabaseParams, -} from "shared"; - -import { getClientConfig } from "../config"; -import { - DatabaseApiError, - invalidDatabaseQuery, - invalidDatabaseWrite, -} from "./errors"; -import type { - DatabaseEntity, - DatabaseId, - DatabaseInsert, - DatabaseKeyedEntity, - DatabaseListParams, - DatabaseListRow, - DatabaseRecordParams, - DatabaseRecordRow, - DatabaseRow, - DatabaseUpdate, -} from "./types"; - -/** - * Suffix of the endpoint names `DatabasePlugin` publishes for each table. - * @internal Shared with the React hooks; not part of the public surface. - */ -export type DatabaseOperation = - | "list" - | "detail" - | "create" - | "update" - | "delete"; - -/** - * An id as a keyed route addresses it in its path. - * @internal Shared with the React hooks; not part of the public surface. - */ -export type IdLike = string | number | bigint; - -/** Per-call options for a database request. */ -export interface DatabaseRequestOptions { - /** - * Cancels the request; the promise then rejects with the abort reason. - * Cancelling a write does not undo it once the server has committed. - */ - readonly signal?: AbortSignal; -} - -/** Typed calls to the routes `DatabasePlugin` generates under `/api/database`. */ -export interface DatabaseApi { - /** - * Read one page from `GET /api/database/`. Rows are public rows as - * JSON carries them, narrowed by `select` and widened by `include`. - * - * @example - * ```typescript - * const page = await databaseApi.list("notes", { - * where: { board_id: 7 }, - * order: { created_at: "desc" }, - * limit: 5, - * }); - * page.items[0]?.body; - * ``` - */ - list( - entity: K, - params?: undefined, - init?: DatabaseRequestOptions, - ): Promise>>; - list>( - entity: K, - params: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>>; - - /** - * Read one row from `GET /api/database//:id`. Only entities with a - * public primary key have this route; a missing row rejects with - * `NOT_FOUND`. - * - * @example - * ```typescript - * const board = await databaseApi.get("boards", 7, { - * include: { notes: { limit: 20 } }, - * }); - * board.notes.length; - * ``` - */ - get< - K extends DatabaseKeyedEntity, - const P extends DatabaseRecordParams = Record, - >( - entity: K, - id: DatabaseId, - params?: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; - - /** - * Create one row with `POST /api/database/` and return it as the - * database holds it. Private, generated, and undeclared fields are compile - * errors, as the server refuses them. - * - * @example - * ```typescript - * const note = await databaseApi.create("notes", { - * board_id: 7, - * author: "ada", - * body: "Ship it", - * }); - * note.id; - * ``` - */ - create>( - entity: K, - values: V & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; - - /** - * Change some fields of one row with `PATCH /api/database//:id` and - * return the updated row. A missing row rejects with `NOT_FOUND`. - * - * @example - * ```typescript - * await databaseApi.update("notes", 7, { body: "Shipped" }); - * ``` - */ - update>( - entity: K, - id: DatabaseId, - values: V & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; - - /** - * Delete one row with `DELETE /api/database//:id`. A missing row - * rejects with `NOT_FOUND`. - * - * @example - * ```typescript - * await databaseApi.remove("notes", 7); - * ``` - */ - remove( - entity: K, - id: DatabaseId, - init?: DatabaseRequestOptions, - ): Promise; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * An id that URL resolution would not keep as one path segment: `""` and - * `"."` collapse onto the list route and `".."` climbs above the table, so a - * keyed request would silently reach another route. - */ -function isUnaddressableId(segment: string): boolean { - return segment === "" || segment === "." || segment === ".."; -} - -/** - * Find the route the server published for one operation. The plugin publishes - * only what its `api` configuration exposes, so a missing entry is refused - * here with `NOT_EXPOSED` and no request is sent. An id that is not one path - * segment is refused with `INVALID_REQUEST`, also without a request. - * - * @internal Shared with the React hooks; not part of the public surface. - */ -export function resolveDatabaseUrl( - entity: string, - operation: DatabaseOperation, - id?: IdLike, - query?: string, -): string { - const name = `${entity}.${operation}`; - const endpoints = getClientConfig().endpoints.database; - const path = - isRecord(endpoints) && Object.hasOwn(endpoints, name) - ? endpoints[name] - : undefined; - if (typeof path !== "string") { - throw new DatabaseApiError( - "NOT_EXPOSED", - null, - `Database operation "${name}" is not exposed`, - ); - } - let url = path; - if (id !== undefined) { - const segment = String(id); - if (isUnaddressableId(segment)) { - // The id may be user input from a route param; do not echo it. - throw new DatabaseApiError( - "INVALID_REQUEST", - null, - "Database id must be a non-empty path segment", - ); - } - url = path.replace(":id", encodeURIComponent(segment)); - } - return query ? `${url}?${query}` : url; -} - -/** Keep only details shaped like the server's; they name public fields only. */ -function publicDetails(value: unknown): DatabaseErrorDetail[] { - if (!Array.isArray(value)) return []; - return value.flatMap((detail): DatabaseErrorDetail[] => - isRecord(detail) && - typeof detail.message === "string" && - Array.isArray(detail.path) && - detail.path.every((segment) => typeof segment === "string") - ? [{ path: [...detail.path], message: detail.message }] - : [], - ); -} - -/** Decode a failure envelope; a non-JSON body still keeps its category. */ -async function failure(response: Response): Promise { - const body: unknown = await response.json().catch(() => undefined); - const envelope = isRecord(body) ? body : {}; - return new DatabaseApiError( - databaseErrorCategoryForStatus(response.status), - response.status, - typeof envelope.error === "string" - ? envelope.error - : `Database request failed with status ${response.status}`, - publicDetails(envelope.details), - ); -} - -/** - * Send one request and decode its JSON body, throwing `DatabaseApiError` for - * anything but the shape `accept` expects. A `204` has no body, so `accept` - * sees `undefined`. An abort rejects with the signal's own reason, so a - * caller can tell cancellation from failure. - * - * @internal Shared with the React hooks; not part of the public surface. - */ -export async function requestDatabase( - url: string, - init: RequestInit, - accept: (body: unknown) => body is T, -): Promise { - const headers = new Headers(init.headers); - headers.set("Accept", "application/json"); - const write = init.method !== "GET"; - let response: Response; - try { - response = await fetch(url, { ...init, headers }); - } catch (error) { - if (init.signal?.aborted) throw error; - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "TRANSIENT", - null, - write - ? "Database write may have completed; check its result before retrying" - : "Database read failed before receiving a response", - [], - { cause: error }, - ); - } - if (!response.ok) throw await failure(response); - - let body: unknown; - try { - body = response.status === 204 ? undefined : await response.json(); - } catch (error) { - if (init.signal?.aborted) throw error; - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "INTERNAL", - response.status, - write - ? "Database write may have completed; response is not JSON" - : "Database response is not JSON", - [], - { cause: error }, - ); - } - if (!accept(body)) { - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "INTERNAL", - response.status, - write - ? "Database write may have completed; response has an unexpected shape" - : "Database response has an unexpected shape", - ); - } - return body; -} - -/** - * The `{ items, limit, offset }` envelope a list route answers with. - * @internal Shared with the React hooks; not part of the public surface. - */ -export function isDatabaseListPage( - body: unknown, -): body is DatabaseListPage { - return ( - isRecord(body) && - Array.isArray(body.items) && - typeof body.limit === "number" && - typeof body.offset === "number" - ); -} - -/** - * A detail route answers one bare row. - * @internal Shared with the React hooks; not part of the public surface. - */ -export function isDatabaseRow(body: unknown): body is Record { - return isRecord(body); -} - -/** A delete answers `204` with nothing to decode. */ -function isNoContent(body: unknown): body is undefined { - return body === undefined; -} - -/** A write must not lose a non-finite number to JSON's implicit null coercion. */ -function jsonWriteValue(_key: string, value: unknown): unknown { - if (typeof value === "number" && !Number.isFinite(value)) { - throw invalidDatabaseWrite( - "Database write numbers must be finite; use null explicitly to clear a value", - ); - } - return typeof value === "bigint" ? value.toString() : value; -} - -/** A write sends its values as a JSON body, the only type its route parses. */ -function jsonWrite( - method: "POST" | "PATCH", - values: object, - signal: AbortSignal | undefined, -): RequestInit { - try { - return { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values, jsonWriteValue), - signal, - }; - } catch (error) { - if (error instanceof DatabaseApiError) throw error; - throw invalidDatabaseWrite(); - } -} - -/** - * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their - * signatures carry the checks, while the entity is still a literal. - * - * @internal Shared with the React hooks; not part of the public surface. - */ -export async function createDatabaseRow( - entity: string, - values: object, - init: DatabaseRequestOptions = {}, -): Promise> { - const url = resolveDatabaseUrl(entity, "create"); - return requestDatabase( - url, - jsonWrite("POST", values, init.signal), - isDatabaseRow, - ); -} - -/** - * Untyped update, shared by the typed client and the write hooks. - * @internal Not part of the public surface. - */ -export async function updateDatabaseRow( - entity: string, - id: IdLike, - values: object, - init: DatabaseRequestOptions = {}, -): Promise> { - const url = resolveDatabaseUrl(entity, "update", id); - return requestDatabase( - url, - jsonWrite("PATCH", values, init.signal), - isDatabaseRow, - ); -} - -/** - * Untyped delete, shared by the typed client and the write hooks. - * @internal Not part of the public surface. - */ -export async function deleteDatabaseRow( - entity: string, - id: IdLike, - init: DatabaseRequestOptions = {}, -): Promise { - const url = resolveDatabaseUrl(entity, "delete", id); - await requestDatabase( - url, - { method: "DELETE", signal: init.signal }, - isNoContent, - ); -} - -function list( - entity: K, - params?: undefined, - init?: DatabaseRequestOptions, -): Promise>>; -function list>( - entity: K, - params: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, -): Promise>>; -async function list( - entity: string, - params?: DatabaseListQuery, - init: DatabaseRequestOptions = {}, -): Promise> { - let query: string; - try { - query = encodeDatabaseListQuery(params ?? {}); - } catch (error) { - throw invalidDatabaseQuery(error); - } - const url = resolveDatabaseUrl(entity, "list", undefined, query); - const page = await requestDatabase( - url, - { method: "GET", signal: init.signal }, - isDatabaseListPage, - ); - return page; -} - -async function get< - K extends DatabaseKeyedEntity, - const P extends DatabaseRecordParams = Record, ->( - entity: K, - id: DatabaseId, - params?: P & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - let query: string; - try { - query = encodeDatabaseRecordQuery(params ?? {}); - } catch (error) { - throw invalidDatabaseQuery(error); - } - const url = resolveDatabaseUrl(entity, "detail", id, query); - const row = await requestDatabase( - url, - { method: "GET", signal: init.signal }, - isDatabaseRow, - ); - return row as DatabaseRecordRow; -} - -async function create< - K extends DatabaseEntity, - const V extends DatabaseInsert, ->( - entity: K, - values: V & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - // `values` is an insert object; a still-generic `K` only widens its type. - const row = await createDatabaseRow(entity, values as object, init); - // The server projected the row it holds; the types describe that wire. - return row as DatabaseRow; -} - -async function update< - K extends DatabaseKeyedEntity, - const V extends DatabaseUpdate, ->( - entity: K, - id: DatabaseId, - values: V & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - const row = await updateDatabaseRow(entity, id, values as object, init); - return row as DatabaseRow; -} - -function remove( - entity: K, - id: DatabaseId, - init: DatabaseRequestOptions = {}, -): Promise { - return deleteDatabaseRow(entity, id, init); -} - -/** - * Typed browser client for the routes `DatabasePlugin` generates. Entity names, - * params, and rows come from the generated `database.d.ts`; routes come from - * the endpoints the server published in the boot payload. - */ -export const databaseApi: DatabaseApi = { list, get, create, update, remove }; diff --git a/packages/appkit-ui/src/js/database/errors.ts b/packages/appkit-ui/src/js/database/errors.ts index ec3ced716..b50b7baa3 100644 --- a/packages/appkit-ui/src/js/database/errors.ts +++ b/packages/appkit-ui/src/js/database/errors.ts @@ -1,8 +1,4 @@ -import { - type DatabaseErrorCategory, - type DatabaseErrorDetail, - DatabaseQueryEncodingError, -} from "shared"; +import type { DatabaseErrorCategory, DatabaseErrorDetail } from "shared"; /** * A server category, or a client-side outcome: `NOT_EXPOSED` for an operation @@ -57,26 +53,3 @@ export function invalidDatabaseInput( { path: ["input"], message }, ]); } - -/** Do not echo a caller's invalid query values into the client-facing error. */ -export function invalidDatabaseQuery(error?: unknown): DatabaseApiError { - if (error instanceof DatabaseQueryEncodingError) { - return new DatabaseApiError("INVALID_REQUEST", null, error.message, [ - { path: [error.parameter], message: error.message }, - ]); - } - return new DatabaseApiError( - "INVALID_REQUEST", - null, - "Database query contains an unsupported value", - ); -} - -/** Local write failures name the body, never the values it would have sent. */ -export function invalidDatabaseWrite( - message = "Database write contains an unsupported value", -): DatabaseApiError { - return new DatabaseApiError("INVALID_REQUEST", null, message, [ - { path: ["body"], message }, - ]); -} diff --git a/packages/appkit-ui/src/js/database/registry.ts b/packages/appkit-ui/src/js/database/registry.ts deleted file mode 100644 index c787e4148..000000000 --- a/packages/appkit-ui/src/js/database/registry.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Browser binding target for the application's generated database registry. - * The generated database.d.ts extends the global bridge without importing UI, - * so apps using only the server package can typecheck independently. - */ -declare global { - // oxlint-disable-next-line typescript/no-empty-object-type -- populated by typegen. - interface DatabricksAppKitDatabaseRegistry {} -} - -// oxlint-disable-next-line typescript/no-empty-object-type -- application registry. -export interface DatabaseRegistry extends DatabricksAppKitDatabaseRegistry {} diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index bd31cc546..98f7460dc 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -1,90 +1,8 @@ import type { - DatabaseApiEntityFor, DatabaseOperationMethod, DatabricksAppKitDatabaseOperations, - IdFor, - InsertFor, - KeyedEntityFor, - ListParamsFor, - ListRowFor, - PublicRowFor, - RecordParamsFor, - RecordRowFor, - UpdateFor, } from "shared"; -import type { DatabaseRegistry } from "./registry"; - -/** Entity names the generated registry binds, or `never` before typegen runs. */ -export type DatabaseEntity = DatabaseApiEntityFor; - -/** - * Entities with a path-decodable public key, the only ones with a detail route. - * Private, missing, boolean, and JSON keys do not address generated HTTP routes. - */ -export type DatabaseKeyedEntity = KeyedEntityFor; - -/** The public primary key value that addresses one row of `K`. */ -export type DatabaseId = IdFor< - DatabaseRegistry, - K ->; - -/** - * The public query `GET /api/database/` accepts: filters and ordering - * over queryable columns, projection over public columns, and includes over - * relations, each checked against the target's own public facets. - */ -export type DatabaseListParams = ListParamsFor< - DatabaseRegistry, - K ->; - -/** - * One list row as JSON carries it: the public row, narrowed by `select` and - * widened by `include`, with bigint columns as decimal strings. - */ -export type DatabaseListRow< - K extends DatabaseEntity, - P = Record, -> = ListRowFor; - -/** The public query `GET /api/database//:id` accepts: `select` and `include`. */ -export type DatabaseRecordParams = RecordParamsFor< - DatabaseRegistry, - K ->; - -/** One detail row as JSON carries it; projection follows the list rules. */ -export type DatabaseRecordRow< - K extends DatabaseEntity, - P = Record, -> = RecordRowFor; - -/** - * The body `POST /api/database/` accepts: public columns the server - * does not generate, with bigint columns as a decimal string or safe integer. - */ -export type DatabaseInsert = InsertFor< - DatabaseRegistry, - K ->; - -/** - * The body `PATCH /api/database//:id` accepts: every field optional, - * and no key, generated, or default-stamped column. - */ -export type DatabaseUpdate = UpdateFor< - DatabaseRegistry, - K ->; - -/** The public row a create or update answers with, as JSON carries it. */ -export type DatabaseRow = PublicRowFor< - DatabaseRegistry, - K ->; - type Operations = DatabricksAppKitDatabaseOperations; /** Names of the generated database operations, or `never` before generation. */ diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 2289599b1..88dfc8d1d 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -17,56 +17,19 @@ export { useAiSearchQuery, } from "./hooks/use-ai-search-query"; -// Database hooks: the entity read and write hooks, and the operation query and -// mutation hooks. Track the `database` plugin, which ships at beta from -// '@databricks/appkit/beta'. The client and the registry binding live in +// Database operation query and mutation hooks. Track the `database` plugin, +// which ships at beta from '@databricks/appkit/beta'. The client lives in // '@databricks/appkit-ui/js/beta'; the types the hooks mention are re-exported. export { DatabaseApiError, type DatabaseApiErrorCode, - type DatabaseEntity, type DatabaseErrorDetail, - type DatabaseId, - type DatabaseInsert, - type DatabaseKeyedEntity, - type DatabaseListPage, - type DatabaseListParams, - type DatabaseListRow, type DatabaseMutationName, type DatabaseOperationInput, type DatabaseOperationName, type DatabaseOperationOutput, type DatabaseQueryName, - type DatabaseRecordParams, - type DatabaseRecordRow, - type DatabaseRow, - type DatabaseUpdate, } from "@/js/beta"; -export { invalidateDatabaseReads } from "./hooks/database-request-store"; -export { - type UseDatabaseCreateOptions, - type UseDatabaseCreateResult, - useDatabaseCreate, -} from "./hooks/use-database-create"; -export { - type UseDatabaseDeleteOptions, - type UseDatabaseDeleteResult, - useDatabaseDelete, -} from "./hooks/use-database-delete"; -export { - type UseDatabaseListOptions, - type UseDatabaseListResult, - useDatabaseList, -} from "./hooks/use-database-list"; -export type { - UseDatabaseReadOptions, - UseDatabaseReadResult, -} from "./hooks/use-database-read"; -export { - type UseDatabaseRecordOptions, - type UseDatabaseRecordResult, - useDatabaseRecord, -} from "./hooks/use-database-record"; export { type DatabaseMutationOutcome, type UseDatabaseMutationResult, @@ -77,13 +40,3 @@ export { type UseDatabaseQueryResult, useDatabaseQuery, } from "./hooks/use-database-query"; -export { - type UseDatabaseUpdateOptions, - type UseDatabaseUpdateResult, - useDatabaseUpdate, -} from "./hooks/use-database-update"; -export type { - DatabaseInvalidation, - UseDatabaseWriteOptions, - UseDatabaseWriteState, -} from "./hooks/use-database-write"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts deleted file mode 100644 index fc55ae3e2..000000000 --- a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { vi } from "vitest"; - -import { _resetConfigCache } from "@/js/config"; -import type { DatabaseApiError } from "@/js/database/errors"; - -import { - invalidateDatabaseReads as typedInvalidateDatabaseReads, - resetDatabaseRequestStore, -} from "../database-request-store"; -import { useDatabaseCreate as typedUseDatabaseCreate } from "../use-database-create"; -import { useDatabaseDelete as typedUseDatabaseDelete } from "../use-database-delete"; -import { useDatabaseList as typedUseDatabaseList } from "../use-database-list"; -import type { UseDatabaseReadResult } from "../use-database-read"; -import { useDatabaseRecord as typedUseDatabaseRecord } from "../use-database-record"; -import { useDatabaseUpdate as typedUseDatabaseUpdate } from "../use-database-update"; -import type { UseDatabaseWriteState } from "../use-database-write"; - -// This package has no generated registry, so every entity name is `never` -// here. The typed surface is compiled in use-database.types.test.ts; the -// runtime tests drive the hooks through these string-typed views. - -export type Row = Record; -type Page = { items: unknown[]; limit: number; offset: number }; - -interface ReadOptions { - enabled?: boolean; - keepPreviousData?: boolean; -} - -interface WriteOptions { - invalidate?: boolean | readonly string[]; - onSuccess?: (result: T, ...args: Args) => void; - onError?: (error: DatabaseApiError, ...args: Args) => void; -} - -type Id = string | number | bigint; - -export const useDatabaseList = typedUseDatabaseList as unknown as ( - entity: string, - params?: object | null, - options?: ReadOptions, -) => UseDatabaseReadResult; - -export const useDatabaseRecord = typedUseDatabaseRecord as unknown as ( - entity: string, - id: Id | null | undefined, - params?: object, - options?: ReadOptions, -) => UseDatabaseReadResult; - -export const useDatabaseCreate = typedUseDatabaseCreate as unknown as ( - entity: string, - options?: WriteOptions<[values: object], Row>, -) => UseDatabaseWriteState & { - create(values: object): Promise; - reset(): void; -}; - -export const useDatabaseUpdate = typedUseDatabaseUpdate as unknown as ( - entity: string, - options?: WriteOptions<[id: Id, values: object], Row>, -) => UseDatabaseWriteState & { - update(id: Id, values: object): Promise; - reset(): void; -}; - -export const useDatabaseDelete = typedUseDatabaseDelete as unknown as ( - entity: string, - options?: { - invalidate?: boolean | readonly string[]; - onSuccess?: (id: Id) => void; - onError?: (error: DatabaseApiError, id: Id) => void; - }, -) => { - remove(id: Id): Promise; - loading: boolean; - error: DatabaseApiError | null; - reset(): void; -}; - -export const invalidateDatabaseReads = typedInvalidateDatabaseReads as ( - scope?: boolean | readonly string[], -) => Promise; - -/** One request the mocked `fetch` holds open until the test answers it. */ -export interface PendingRequest { - url: string; - method: string; - body: unknown; - signal: AbortSignal | undefined; - respond(body: unknown, status?: number): void; -} - -export function reply(body: unknown, status = 200): Response { - if (status === 204) return new Response(null, { status }); - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -export function page(...items: unknown[]): Page { - return { items, limit: 50, offset: 0 }; -} - -/** - * Stub `fetch` so every request stays open until the test answers it, which - * lets a test control the order completions arrive in. Aborts are ignored, so - * a test can deliver a completion after its request was superseded. - */ -export function mockDatabaseFetch() { - const requests: PendingRequest[] = []; - const fetchMock = vi.fn( - (url: string, init: RequestInit = {}) => - new Promise((resolve) => { - requests.push({ - url, - method: init.method ?? "GET", - body: typeof init.body === "string" ? JSON.parse(init.body) : null, - signal: init.signal ?? undefined, - respond: (body, status) => resolve(reply(body, status)), - }); - }), - ); - vi.stubGlobal("fetch", fetchMock); - /** Requests sent with `method`, in order. */ - const sent = (method: string) => - requests.filter((request) => request.method === method); - return { requests, fetchMock, sent }; -} - -/** Publish database routes, and optionally relations, as the server would. */ -export function publishDatabase( - endpoints: Record, - relations?: Record>, -): void { - window.__appkit__ = { - appName: "test", - queries: {}, - endpoints: { database: endpoints }, - plugins: relations ? { database: { relations } } : {}, - }; - _resetConfigCache(); - resetDatabaseRequestStore(); -} - -export function resetDatabaseTestEnvironment(): void { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - resetDatabaseRequestStore(); -} - -/** Let the deferred teardown of a released entry run. */ -export const nextTick = () => - new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts deleted file mode 100644 index a005ab01b..000000000 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import path from "node:path"; - -import ts from "typescript"; -import { expect, test, vi } from "vitest"; - -const packageRoot = - path.basename(process.cwd()) === "appkit-ui" - ? process.cwd() - : path.join(process.cwd(), "packages", "appkit-ui"); - -// Each probe type-checks the package with the TypeScript compiler, which can -// exceed Vitest's 5s default on CI's 2-core runners. -vi.setConfig({ testTimeout: 30_000 }); - -function compileTypeProbe(source: string): string[] { - const configPath = path.join(packageRoot, "tsconfig.json"); - const config = ts.readConfigFile(configPath, ts.sys.readFile); - const parsed = ts.parseJsonConfigFileContent( - config.config, - ts.sys, - packageRoot, - ); - const filename = path.join(packageRoot, "__type-tests__", "database.ts"); - const host = ts.createCompilerHost(parsed.options); - const getSourceFile = host.getSourceFile.bind(host); - - host.fileExists = (candidate) => - candidate === filename || ts.sys.fileExists(candidate); - host.readFile = (candidate) => - candidate === filename ? source : ts.sys.readFile(candidate); - host.getSourceFile = (candidate, languageVersion, onError, shouldCreate) => - candidate === filename - ? ts.createSourceFile( - candidate, - source, - languageVersion, - true, - ts.ScriptKind.TS, - ) - : getSourceFile(candidate, languageVersion, onError, shouldCreate); - - const program = ts.createProgram([filename], parsed.options, host); - return ts - .getPreEmitDiagnostics(program) - .map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ); -} - -// Entries as `appkit generate-types` renders them (trusted facets omitted: the -// browser reads only `publicRow`, `includes`, and `api`), bound the same way. -const REGISTRY = ` - type Filter = T & { and?: readonly Filter[]; or?: readonly Filter[] }; - type Text = string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; - type Int = number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; - type Big = bigint | { eq?: bigint; neq?: bigint; in?: readonly (bigint)[]; gt?: bigint; gte?: bigint; lt?: bigint; lte?: bigint; }; - - interface Fixture { - "users": { - publicRow: { "slug": string; "name": string }; - includes: { "posts": { to: "posts"; many: true } }; - api: { - insert: { "slug": string; "name": string }; - update: { "name"?: string }; - filters: Filter<{ "slug"?: Text; "name"?: Text }>; - orderable: "slug" | "name"; - key: "slug"; - }; - }; - "posts": { - publicRow: { "id": number; "user_slug": string; "title": string; "total": bigint; "payload": unknown | null }; - includes: { - "users": { to: "users"; many: false }; - "comments": { to: "comments"; many: true }; - }; - api: { - insert: { "user_slug": string; "title": string; "total": bigint; "payload"?: unknown | null }; - update: { "title"?: string; "total"?: bigint; "payload"?: unknown | null }; - filters: Filter<{ "id"?: Int; "user_slug"?: Text; "title"?: Text; "total"?: Big }>; - orderable: "id" | "user_slug" | "title" | "total"; - key: "id"; - }; - }; - "comments": { - publicRow: { "id": number; "post_id": number; "body": string }; - includes: { "posts": { to: "posts"; many: false } }; - api: { - insert: { "post_id": number; "body": string }; - update: { "body"?: string }; - filters: Filter<{ "id"?: Int; "post_id"?: Int; "body"?: Text }>; - orderable: "id" | "post_id" | "body"; - key: "id"; - }; - }; - "ledger": { - publicRow: { "seq": bigint; "note": string }; - includes: {}; - api: { - insert: { "seq": bigint; "note": string }; - update: { "note"?: string }; - filters: Filter<{ "seq"?: Big; "note"?: Text }>; - orderable: "seq" | "note"; - key: "seq"; - }; - }; - "sessions": { - publicRow: { "user_slug": string }; - includes: { "users": { to: "users"; many: false } }; - api: { - insert: { "user_slug": string }; - update: { "user_slug"?: string }; - filters: Filter<{ "user_slug"?: Text }>; - orderable: "user_slug"; - key: never; - }; - }; - "events": { - publicRow: { "message": string }; - includes: {}; - api: { - insert: { "message": string }; - update: { "message"?: string }; - filters: Filter<{ "message"?: Text }>; - orderable: "message"; - key: never; - }; - }; - } - - declare module "@databricks/appkit-ui/js/beta" { - interface DatabaseRegistry extends Fixture {} - } -`; - -test("read hooks type entities, params, and rows from the generated registry", () => { - const diagnostics = compileTypeProbe(` - import { databaseApi } from "@databricks/appkit-ui/js/beta"; - import { - type DatabaseEntity, - type DatabaseKeyedEntity, - useDatabaseList, - useDatabaseRecord, - } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - const keyed: DatabaseKeyedEntity[] = ["users", "posts", "comments", "ledger"]; - const listable: DatabaseEntity[] = ["sessions", "events"]; - void [keyed, listable]; - - export function Lists() { - const users = useDatabaseList("users", { - where: { name: { ilike: "%ada%" } }, - include: { posts: { limit: 2, select: ["title", "total"] } }, - }); - const title: string | undefined = users.data?.items[0]?.posts[0]?.title; - // A bigint travels as its decimal string. - const total: string | undefined = users.data?.items[0]?.posts[0]?.total; - // @ts-expect-error unselected relation columns are absent - users.data?.items[0]?.posts[0]?.user_slug; - const failed: "NOT_EXPOSED" | "NOT_FOUND" | undefined = - users.error?.code === "NOT_EXPOSED" || users.error?.code === "NOT_FOUND" - ? users.error.code - : undefined; - users.refetch(); - - const posts = useDatabaseList("posts", { include: { users: true, comments: { limit: 3 } } }); - const owner: { slug: string; name: string } | null | undefined = posts.data?.items[0]?.users; - const bodies: string[] | undefined = posts.data?.items[0]?.comments.map((c) => c.body); - const nested = useDatabaseList("users", { include: { posts: { include: { comments: { limit: 1 } } } } }); - const deep: number | undefined = nested.data?.items[0]?.posts[0]?.comments[0]?.id; - const gated = useDatabaseList("posts", { where: { id: 1 } }, { enabled: false }); - const gatedTitle: string | undefined = gated.data?.items[0]?.title; - useDatabaseList("sessions", { order: { user_slug: "asc" } }); - // @ts-expect-error a keyless list needs an explicit order - useDatabaseList("sessions"); - // @ts-expect-error keyless lists cannot sort by zero columns - useDatabaseList("sessions", { order: {} }); - useDatabaseList("posts", { where: { total: { gt: "9007199254740993" } } }); - void [title, total, failed, owner, bodies, deep, gatedTitle]; - } - - export function DynamicSelections() { - const columns: ("id" | "title")[] = ["id"]; - const list = useDatabaseList("posts", { select: columns }); - const record = useDatabaseRecord("posts", 7, { select: columns }); - if (list.data && record.data) { - const maybeTitle: string | undefined = list.data.items[0].title; - // @ts-expect-error a dynamic array does not guarantee every possible column - const listTitle: string = list.data.items[0].title; - // @ts-expect-error the record follows the same dynamic projection rule - const recordId: number = record.data.id; - void [maybeTitle, listTitle, recordId]; - } - } - - export function RejectedLists() { - // @ts-expect-error entities are generated table names - useDatabaseList("missing"); - // @ts-expect-error private columns are absent from public rows - useDatabaseList("users").data?.items[0]?.secret; - // @ts-expect-error private columns are not HTTP filters, even beside a valid one - useDatabaseList("users", { where: { name: "Ada", secret: "token" } }); - // @ts-expect-error private columns are not selectable - useDatabaseList("users", { select: ["slug", "secret"] }); - // @ts-expect-error JSON columns are not queryable - useDatabaseList("posts", { where: { payload: { eq: 1 } } }); - // @ts-expect-error a to-one relation is one row, not an array - useDatabaseList("posts", { include: { users: true } }).data?.items[0]?.users?.length; - // @ts-expect-error a to-one include takes no limit - useDatabaseList("posts", { include: { users: { limit: 1 } } }); - // @ts-expect-error includes stop at two relation edges - useDatabaseList("users", { include: { posts: { include: { comments: { include: { posts: true } } } } } }); - // @ts-expect-error list params are the generated route's parameters only - useDatabaseList("users", { limit: 1, includeTotal: true }); - } - - export function Records(boardId: number | undefined) { - const post = useDatabaseRecord("posts", 7, { include: { comments: { limit: 20 } } }); - const body: string | undefined = post.data?.comments[0]?.body; - const title: string | undefined = post.data?.title; - const user = useDatabaseRecord("users", "ada", { select: ["name"] }); - const name: string | undefined = user.data?.name; - // A null or undefined id waits without a request. - useDatabaseRecord("posts", null); - useDatabaseRecord("posts", boardId); - // A bigint key is addressed by its decimal string, a safe integer, or a bigint. - const seq: string | undefined = useDatabaseRecord("ledger", "9007199254740993").data?.seq; - useDatabaseRecord("ledger", 10); - useDatabaseRecord("ledger", 10n); - void [body, title, name, seq]; - } - - export function RejectedRecords() { - // @ts-expect-error keyless entities have no detail route - useDatabaseRecord("events", "x"); - // @ts-expect-error a private key is not addressable over HTTP - useDatabaseRecord("sessions", "token"); - // @ts-expect-error the id has the public key's type - useDatabaseRecord("posts", "7"); - // @ts-expect-error record params are select and include only - useDatabaseRecord("posts", 7, { where: { id: 7 } }); - // @ts-expect-error private columns are not selectable on a record - useDatabaseRecord("users", "ada", { select: ["secret"] }); - // @ts-expect-error unselected columns are absent - useDatabaseRecord("users", "ada", { select: ["name"] }).data?.slug; - } - - export async function client() { - const post = await databaseApi.get("posts", 7, { select: ["id", "title"] }); - const title: string = post.title; - // @ts-expect-error unselected columns are absent - post.total; - // @ts-expect-error keyless entities have no detail route - await databaseApi.get("events", "x"); - // @ts-expect-error record params are the generated route's parameters only - await databaseApi.get("posts", 7, { select: ["id"], limit: 1 }); - void title; - } - `); - - expect(diagnostics).toEqual([]); -}); - -test("read rows come only from the registry: no shape, serialized(), or row type argument", () => { - const diagnostics = compileTypeProbe(` - import { useDatabaseList, useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - interface PostCard { id: number; headline: string } - - export function Controls() { - // The probes below differ from these calls only in what was removed. - useDatabaseList<"posts", {}>("posts", {}); - useDatabaseRecord<"posts", {}>("posts", 7); - useDatabaseList("posts", {}, { enabled: false }); - useDatabaseRecord("posts", 7, {}, { enabled: false }); - } - - export function Removed() { - // @ts-expect-error a list takes no caller-chosen row type - useDatabaseList<"posts", {}, PostCard>("posts", {}); - // @ts-expect-error a record takes no caller-chosen row type - useDatabaseRecord<"posts", {}, PostCard>("posts", 7); - // @ts-expect-error a list takes no shape option - useDatabaseList("posts", {}, { shape: (row: unknown) => row as PostCard }); - // @ts-expect-error a record takes no shape option - useDatabaseRecord("posts", 7, {}, { shape: (row: unknown) => row as PostCard }); - } - - // @ts-expect-error serialized() is not exported - export type Serialized = typeof import("@databricks/appkit-ui/react/beta").serialized; - // @ts-expect-error DatabaseShape is not exported - export type Shape = import("@databricks/appkit-ui/react/beta").DatabaseShape; - // @ts-expect-error DatabaseRowShape is not exported - export type RowShape = import("@databricks/appkit-ui/react/beta").DatabaseRowShape; - `); - - expect(diagnostics).toEqual([]); -}); - -test("read hooks take null params and keepPreviousData", () => { - const diagnostics = compileTypeProbe(` - import { - type UseDatabaseListResult, - type UseDatabaseRecordResult, - useDatabaseList, - useDatabaseRecord, - } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - export function Dependent(userSlug: string | undefined, offset: number) { - // Null params hold the read; the non-null branch keeps its exact type. - const posts = useDatabaseList( - "posts", - userSlug === undefined ? null : { where: { user_slug: userSlug }, limit: 20, offset }, - { keepPreviousData: true }, - ); - const title: string | undefined = posts.data?.items[0]?.title; - const typed: UseDatabaseListResult<{ id: number; user_slug: string; title: string; total: string; payload: unknown }> = posts; - // @ts-expect-error the non-null branch is still checked exactly - useDatabaseList("posts", userSlug ? { where: { secret: userSlug } } : null); - const post: UseDatabaseRecordResult<{ title: string }> = useDatabaseRecord( - "posts", - 7, - { select: ["title"] }, - { keepPreviousData: true }, - ); - void [title, typed, post]; - } - `); - - expect(diagnostics).toEqual([]); -}); - -test("write hooks type values, ids, and rows from the generated registry", () => { - const diagnostics = compileTypeProbe(` - import { databaseApi } from "@databricks/appkit-ui/js/beta"; - import { - type DatabaseInsert, - type DatabaseRow, - useDatabaseCreate, - useDatabaseDelete, - useDatabaseUpdate, - } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - export function Writes(existing: DatabaseRow<"posts">) { - const posts = useDatabaseCreate("posts"); - // A bigint input travels as a decimal string or a safe integer. - posts.create({ user_slug: "ada", title: "Hi", total: "9007199254740993" }); - posts.create({ user_slug: "ada", title: "Hi", total: 1, payload: { tags: ["a"], n: 1 } }); - const created: Promise | null> = posts.create({ user_slug: "ada", title: "Hi", total: 1 }); - // The answered row carries a bigint as its decimal string. - const total: string | undefined = posts.data?.total; - const values: DatabaseInsert<"posts"> = { user_slug: "ada", title: "Hi", total: "1" }; - posts.create(values); - useDatabaseCreate("events").create({ message: "keyless tables accept creates" }); - useDatabaseCreate("sessions").create({ user_slug: "ada" }); - useDatabaseCreate("posts", { invalidate: ["posts", "users"] }); - useDatabaseCreate("posts", { invalidate: false }); - useDatabaseCreate("posts", { - onSuccess: (row, values) => { - const id: number = row.id; - const title: string = values.title; - void [id, title]; - }, - onError: (error, values) => { - const code: string = error.code; - void [code, values.user_slug]; - }, - }); - useDatabaseUpdate("posts", { - onSuccess: (row, id, values) => { - const title: string = row.title; - const key: number = id; - const next: string | undefined = values.title; - void [title, key, next]; - }, - }); - useDatabaseDelete("ledger", { - onSuccess: (id) => { - const seq: string | number | bigint = id; - void seq; - }, - onError: (error, id) => void [error.status, id], - }); - - const edit = useDatabaseUpdate("posts"); - const updated: Promise | null> = edit.update(7, { title: "New", total: "2" }); - edit.update(7, { payload: null }); - useDatabaseUpdate("ledger").update("9007199254740993", { note: "x" }); - useDatabaseUpdate("ledger").update(10n, { note: "x" }); - useDatabaseUpdate("users").update("ada", { name: "Ada" }); - - const removal = useDatabaseDelete("posts"); - const removed: Promise = removal.remove(7); - const loading: boolean = removal.loading; - void [created, total, updated, removed, loading, existing]; - } - - export function RejectedWrites(existing: DatabaseRow<"posts">) { - const posts = useDatabaseCreate("posts"); - // @ts-expect-error entities are generated table names - useDatabaseCreate("missing"); - // @ts-expect-error a field the public insert lacks is refused, even beside valid ones - posts.create({ user_slug: "ada", title: "Hi", total: 1, secret: "token" }); - // @ts-expect-error a generated key is not an HTTP input - posts.create({ id: 1, user_slug: "ada", title: "Hi", total: 1 }); - // @ts-expect-error a spread cannot carry a read-only field along - posts.create({ ...existing, title: "Copy" }); - // @ts-expect-error required insert fields stay required - posts.create({ title: "Hi" }); - // @ts-expect-error values keep their column types - posts.create({ user_slug: "ada", title: 1, total: 1 }); - // @ts-expect-error invalidation names generated tables - useDatabaseCreate("posts", { invalidate: ["missing"] }); - // @ts-expect-error a create answers the public row, which has no private column - useDatabaseCreate("users", { onSuccess: (row) => row.secret }); - // @ts-expect-error a delete reports the id, not a row - useDatabaseDelete("posts", { onSuccess: (id) => id.title }); - - // @ts-expect-error keyless entities have no update route - useDatabaseUpdate("events"); - // @ts-expect-error a private key is not addressable over HTTP - useDatabaseUpdate("sessions"); - // @ts-expect-error the id has the public key's type - useDatabaseUpdate("posts").update("7", { title: "x" }); - // @ts-expect-error keys are not updatable - useDatabaseUpdate("posts").update(7, { id: 8 }); - // @ts-expect-error insert-only fields are not updatable - useDatabaseUpdate("users").update("ada", { slug: "grace" }); - - // @ts-expect-error keyless entities have no delete route - useDatabaseDelete("events"); - // @ts-expect-error the id has the public key's type - useDatabaseDelete("posts").remove("7"); - // @ts-expect-error a delete answers no row - useDatabaseDelete("posts").data; - } - - export async function client() { - const post = await databaseApi.create("posts", { user_slug: "ada", title: "Hi", total: "1" }); - const id: number = post.id; - const total: string = post.total; - await databaseApi.update("posts", 7, { title: "New" }); - const removed: void = await databaseApi.remove("posts", 7); - // @ts-expect-error a field the public insert lacks is refused - await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); - // @ts-expect-error keyless entities have no update route - await databaseApi.update("events", "x", { message: "y" }); - // @ts-expect-error keyless entities have no delete route - await databaseApi.remove("events", "x"); - // @ts-expect-error update values are the public update facet only - await databaseApi.update("posts", 7, { title: "New", user_slug: "grace" }); - void [id, total, removed]; - } - `); - - expect(diagnostics).toEqual([]); -}); - -test("no entity exists before typegen binds the registry", () => { - const diagnostics = compileTypeProbe(` - import { - useDatabaseCreate, - useDatabaseList, - useDatabaseRecord, - } from "@databricks/appkit-ui/react/beta"; - - export function Unbound() { - // @ts-expect-error the empty registry binds no entity - useDatabaseList("notes"); - // @ts-expect-error the empty registry binds no keyed entity - useDatabaseRecord("notes", 1); - // @ts-expect-error the empty registry binds no entity to write - useDatabaseCreate("notes"); - } - `); - - expect(diagnostics).toEqual([]); -}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx deleted file mode 100644 index ef5350973..000000000 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { StrictMode, useEffect } from "react"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import { DatabaseApiError } from "@/js/database/errors"; - -import { - mockDatabaseFetch, - nextTick, - type PendingRequest, - page, - publishDatabase, - resetDatabaseTestEnvironment, - useDatabaseList, -} from "./database-test-utils"; - -describe("useDatabaseList", () => { - let requests: PendingRequest[]; - let fetchMock: ReturnType["fetchMock"]; - - beforeEach(() => { - ({ requests, fetchMock } = mockDatabaseFetch()); - publishDatabase({ - "notes.list": "/api/database/notes", - "boards.list": "/api/database/boards", - }); - }); - - afterEach(resetDatabaseTestEnvironment); - - test("reads the published route with the encoded query", async () => { - const { result } = renderHook(() => - useDatabaseList("notes", { where: { board_id: 7 }, limit: 5 }), - ); - - expect(result.current).toMatchObject({ - data: null, - loading: true, - error: null, - }); - expect(requests.map((request) => request.url)).toEqual([ - "/api/database/notes?where=%7B%22board_id%22%3A7%7D&limit=5", - ]); - - await act(async () => requests[0]?.respond(page({ id: 1 }))); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toEqual(page({ id: 1 })); - expect(result.current.error).toBeNull(); - }); - - test("is loading from its first render, before the request starts", () => { - const loading: boolean[] = []; - renderHook(() => { - const read = useDatabaseList("notes"); - loading.push(read.loading); - return read; - }); - - expect(loading[0]).toBe(true); - }); - - test("shares one request between hooks with equal params", async () => { - const first = renderHook(() => - useDatabaseList("notes", { order: { id: "desc" }, limit: 5 }), - ); - // A different key order encodes to the same query. - const second = renderHook(() => - useDatabaseList("notes", { limit: 5, order: { id: "desc" } }), - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - - await act(async () => requests[0]?.respond(page({ id: 2 }))); - - await waitFor(() => - expect(first.result.current.data).toEqual(page({ id: 2 })), - ); - expect(second.result.current.data).toEqual(page({ id: 2 })); - }); - - test("does not refetch when an inline params literal re-renders", () => { - const { rerender } = renderHook( - ({ limit }: { limit: number }) => useDatabaseList("notes", { limit }), - { initialProps: { limit: 5 } }, - ); - - rerender({ limit: 5 }); - rerender({ limit: 5 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - - rerender({ limit: 10 }); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(requests[1]?.url).toBe("/api/database/notes?limit=10"); - }); - - test("sends nothing while disabled, and reads once enabled", () => { - const { result, rerender } = renderHook( - ({ enabled }: { enabled: boolean }) => - useDatabaseList("notes", {}, { enabled }), - { initialProps: { enabled: false } }, - ); - - expect(result.current).toMatchObject({ - data: null, - loading: false, - error: null, - }); - act(() => result.current.refetch()); - expect(fetchMock).not.toHaveBeenCalled(); - - rerender({ enabled: true }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.loading).toBe(true); - }); - - test("null params hold the read idle until the params they depend on exist", () => { - const { result, rerender } = renderHook( - ({ boardId }: { boardId: number | undefined }) => - useDatabaseList( - "notes", - boardId === undefined ? null : { where: { board_id: boardId } }, - ), - { initialProps: { boardId: undefined as number | undefined } }, - ); - - expect(result.current).toMatchObject({ - data: null, - loading: false, - error: null, - }); - expect(fetchMock).not.toHaveBeenCalled(); - - rerender({ boardId: 7 }); - expect(requests[0]?.url).toContain("board_id%22%3A7"); - expect(result.current.loading).toBe(true); - }); - - test("does not encode an incomplete filter while disabled", () => { - const { rerender } = renderHook( - ({ boardId }: { boardId: number | undefined }) => - useDatabaseList( - "notes", - { where: { board_id: boardId } }, - { enabled: boardId !== undefined }, - ), - { initialProps: { boardId: undefined as number | undefined } }, - ); - - expect(fetchMock).not.toHaveBeenCalled(); - rerender({ boardId: 7 }); - expect(requests[0]?.url).toContain("board_id%22%3A7"); - }); - - test("reports an incomplete enabled filter without sending a request", () => { - const { result } = renderHook(() => - useDatabaseList("notes", { where: { board_id: undefined } }), - ); - - expect(result.current.error).toMatchObject({ - code: "INVALID_REQUEST", - status: null, - }); - expect(result.current.loading).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("preserves local validation guidance and changes it only when the failure changes", () => { - const { result, rerender } = renderHook( - ({ where }: { where: object | undefined }) => - useDatabaseList("notes", { where }), - { initialProps: { where: {} as object | undefined } }, - ); - expect(result.current.error).toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Filter cannot be empty; omit where to list all rows", - details: [ - { - path: ["where"], - message: "Filter cannot be empty; omit where to list all rows", - }, - ], - }); - const empty = result.current.error; - rerender({ where: {} }); - expect(result.current.error).toBe(empty); - - rerender({ where: { rank: NaN } }); - expect(result.current.error).toMatchObject({ - message: "Database query numbers must be finite", - details: [ - { path: ["where"], message: "Database query numbers must be finite" }, - ], - }); - const nonFinite = result.current.error; - expect(nonFinite).not.toBe(empty); - rerender({ where: { rank: NaN } }); - expect(result.current.error).toBe(nonFinite); - expect(fetchMock).not.toHaveBeenCalled(); - - rerender({ where: undefined }); - expect(result.current.error).toBeNull(); - expect(fetchMock).toHaveBeenCalledOnce(); - }); - - test("keeps an INVALID_REQUEST error stable, so an effect keyed on it runs once", () => { - const seen: unknown[] = []; - const { result, rerender } = renderHook(() => { - const read = useDatabaseList("notes", { where: { board_id: undefined } }); - useEffect(() => { - seen.push(read.error); - }, [read.error]); - return read; - }); - const first = result.current.error; - - rerender(); - rerender(); - - expect(result.current.error).toBe(first); - expect(seen).toEqual([first]); - }); - - test("refetch aborts the in-flight request, sends it again, and keeps the last page visible", async () => { - const { result } = renderHook(() => useDatabaseList("notes")); - await act(async () => requests[0]?.respond(page({ id: 1 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - act(() => result.current.refetch()); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(result.current).toMatchObject({ - data: page({ id: 1 }), - loading: true, - }); - - act(() => result.current.refetch()); - expect(requests[1]?.signal?.aborted).toBe(true); - expect(requests[2]?.signal?.aborted).toBe(false); - - await act(async () => requests[2]?.respond(page({ id: 1 }, { id: 2 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toEqual(page({ id: 1 }, { id: 2 })); - }); - - test("ignores completions that arrive after their request was superseded", async () => { - const { result } = renderHook(() => useDatabaseList("notes")); - act(() => result.current.refetch()); - act(() => result.current.refetch()); - - await act(async () => requests[2]?.respond(page({ id: "fresh" }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - await act(async () => requests[0]?.respond(page({ id: "stale" }))); - await act(async () => requests[1]?.respond({ error: "late" }, 500)); - - expect(result.current.data).toEqual(page({ id: "fresh" })); - expect(result.current.error).toBeNull(); - expect(result.current.loading).toBe(false); - }); - - test("new params show null while they load, unless keepPreviousData holds the last page", async () => { - const { result, rerender } = renderHook( - ({ offset, keep }: { offset: number; keep: boolean }) => - useDatabaseList( - "notes", - { limit: 1, offset }, - { keepPreviousData: keep }, - ), - { initialProps: { offset: 0, keep: false } }, - ); - await act(async () => requests[0]?.respond(page({ id: 0 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - rerender({ offset: 1, keep: false }); - expect(result.current).toMatchObject({ data: null, loading: true }); - await act(async () => requests[1]?.respond(page({ id: 1 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - rerender({ offset: 2, keep: true }); - expect(result.current).toMatchObject({ - data: page({ id: 1 }), - loading: true, - error: null, - }); - await act(async () => requests[2]?.respond(page({ id: 2 }))); - await waitFor(() => expect(result.current.data).toEqual(page({ id: 2 }))); - }); - - test("keepPreviousData does not show the last page beside a failure for the new params", async () => { - const { result, rerender } = renderHook( - ({ offset }: { offset: number }) => - useDatabaseList( - "notes", - { limit: 1, offset }, - { keepPreviousData: true }, - ), - { initialProps: { offset: 0 } }, - ); - await act(async () => requests[0]?.respond(page({ id: 0 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - rerender({ offset: 1 }); - await act(async () => requests[1]?.respond({ error: "boom" }, 500)); - await waitFor(() => expect(result.current.loading).toBe(false)); - - expect(result.current.data).toBeNull(); - expect(result.current.error).toMatchObject({ code: "INTERNAL" }); - }); - - test("aborts the request once the last subscriber unmounts", async () => { - const first = renderHook(() => useDatabaseList("notes")); - const second = renderHook(() => useDatabaseList("notes")); - - first.unmount(); - await nextTick(); - expect(requests[0]?.signal?.aborted).toBe(false); - - second.unmount(); - expect(requests[0]?.signal?.aborted).toBe(false); - await nextTick(); - expect(requests[0]?.signal?.aborted).toBe(true); - }); - - test("reuses the in-flight request across a StrictMode remount", async () => { - const { result } = renderHook(() => useDatabaseList("notes"), { - wrapper: StrictMode, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - await nextTick(); - expect(requests[0]?.signal?.aborted).toBe(false); - - await act(async () => requests[0]?.respond(page({ id: 3 }))); - await waitFor(() => expect(result.current.data).toEqual(page({ id: 3 }))); - }); - - test("reports a failure as a DatabaseApiError and keeps the last page after a failed refetch", async () => { - const { result } = renderHook(() => useDatabaseList("notes")); - await act(async () => requests[0]?.respond(page({ id: 1 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - act(() => result.current.refetch()); - await act(async () => - requests[1]?.respond( - { - error: "Invalid database request", - details: [{ path: ["where"], message: "Unknown column" }], - }, - 400, - ), - ); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.error).toBeInstanceOf(DatabaseApiError); - expect(result.current.error).toMatchObject({ - code: "INVALID_REQUEST", - status: 400, - details: [{ path: ["where"], message: "Unknown column" }], - }); - expect(result.current.data).toEqual(page({ id: 1 })); - }); - - test("reports NOT_EXPOSED for an unpublished route without sending a request", () => { - const { result, rerender } = renderHook(() => useDatabaseList("secrets")); - const first = result.current.error; - - expect(first).toBeInstanceOf(DatabaseApiError); - expect(first).toMatchObject({ code: "NOT_EXPOSED", status: null }); - expect(result.current.loading).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - - // The error is stable across renders, so effects keyed on it do not loop. - rerender(); - expect(result.current.error).toBe(first); - }); -}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx deleted file mode 100644 index 3e9a4ea5b..000000000 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx +++ /dev/null @@ -1,675 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { StrictMode } from "react"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { DatabaseApiError } from "@/js/database/errors"; - -import { - invalidateDatabaseReads, - mockDatabaseFetch, - nextTick, - page, - publishDatabase, - resetDatabaseTestEnvironment, - type Row, - useDatabaseCreate, - useDatabaseDelete, - useDatabaseList, - useDatabaseUpdate, -} from "./database-test-utils"; - -const ENDPOINTS = { - "notes.list": "/api/database/notes", - "notes.create": "/api/database/notes", - "notes.update": "/api/database/notes/:id", - "notes.delete": "/api/database/notes/:id", - "boards.list": "/api/database/boards", - "note_events.list": "/api/database/note_events", -}; - -// As `DatabasePlugin` publishes them: board → notes → note_events. -const RELATIONS = { - boards: { notes: "notes" }, - notes: { boards: "boards", note_events: "note_events" }, - note_events: { notes: "notes" }, -}; - -describe("database write hooks", () => { - let fetchMock: ReturnType["fetchMock"]; - let sent: ReturnType["sent"]; - - /** URLs of the reads sent after the first `after`. */ - const readsAfter = (after: number) => - sent("GET") - .slice(after) - .map((request) => request.url); - - /** Answer every read still open, as the server would. */ - const answerReads = (from: number, body: unknown = page({ id: 1 })) => { - for (const request of sent("GET").slice(from)) request.respond(body); - }; - - beforeEach(() => { - ({ fetchMock, sent } = mockDatabaseFetch()); - publishDatabase(ENDPOINTS, RELATIONS); - }); - - afterEach(resetDatabaseTestEnvironment); - - /** Mount a notes read and a boards read, and answer both. */ - async function mountReads() { - const reads = renderHook(() => ({ - notes: useDatabaseList("notes"), - boards: useDatabaseList("boards"), - })); - await act(async () => answerReads(0)); - await waitFor(() => - expect(reads.result.current.boards.loading).toBe(false), - ); - return reads; - } - - test("create posts the values and moves from loading to the created row", async () => { - const { result } = renderHook(() => useDatabaseCreate("notes")); - expect(result.current).toMatchObject({ - data: null, - loading: false, - error: null, - }); - - let created!: Promise; - act(() => { - created = result.current.create({ board_id: 7, body: "hi" }); - }); - - expect(result.current.loading).toBe(true); - expect(sent("POST")).toMatchObject([ - { url: "/api/database/notes", body: { board_id: 7, body: "hi" } }, - ]); - - await act(async () => sent("POST")[0]?.respond({ id: 1, body: "hi" }, 201)); - - await expect(created).resolves.toEqual({ id: 1, body: "hi" }); - expect(result.current).toMatchObject({ - data: { id: 1, body: "hi" }, - loading: false, - error: null, - }); - }); - - test("a failed write resolves null and reports the DatabaseApiError, without rejecting", async () => { - const { result } = renderHook(() => useDatabaseCreate("notes")); - - // No handler is attached: a rejection here would fail the run as unhandled. - let failed!: Promise; - act(() => { - failed = result.current.create({ body: "" }); - }); - await act(async () => - sent("POST")[0]?.respond( - { - error: "Database request failed validation", - details: [{ path: ["body"], message: "Must not be empty" }], - }, - 422, - ), - ); - - await expect(failed).resolves.toBeNull(); - const { error } = result.current; - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "VALIDATION_FAILED", - status: 422, - details: [{ path: ["body"], message: "Must not be empty" }], - }); - expect(result.current).toMatchObject({ data: null, loading: false }); - - // The next call starts clean. - act(() => { - void result.current.create({ body: "again" }); - }); - expect(result.current).toMatchObject({ loading: true, error: null }); - }); - - test("rejects a non-finite update before sending it or invalidating reads", async () => { - const { result } = renderHook(() => useDatabaseUpdate("notes")); - let updated!: Row | null; - await act(async () => { - updated = await result.current.update(7, { rank: NaN }); - }); - expect(updated).toBeNull(); - expect(result.current.error).toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: - "Database write numbers must be finite; use null explicitly to clear a value", - }); - expect(result.current.loading).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("update patches one id and delete removes one without a response body", async () => { - const { result } = renderHook(() => ({ - update: useDatabaseUpdate("notes"), - remove: useDatabaseDelete("notes"), - })); - - let updated!: Promise; - let removed!: Promise; - act(() => { - updated = result.current.update.update(7, { body: "edited" }); - removed = result.current.remove.remove("a/b"); - }); - expect(result.current.update.loading).toBe(true); - expect(result.current.remove.loading).toBe(true); - expect(sent("PATCH")).toMatchObject([ - { url: "/api/database/notes/7", body: { body: "edited" } }, - ]); - expect(sent("DELETE")).toMatchObject([ - { url: "/api/database/notes/a%2Fb", body: null }, - ]); - - await act(async () => { - sent("PATCH")[0]?.respond({ id: 7, body: "edited" }); - sent("DELETE")[0]?.respond(null, 204); - }); - - await expect(updated).resolves.toEqual({ id: 7, body: "edited" }); - await expect(removed).resolves.toBe(true); - expect(result.current.update).toMatchObject({ - data: { id: 7, body: "edited" }, - loading: false, - }); - expect(result.current.remove).toMatchObject({ - loading: false, - error: null, - }); - expect(result.current.remove).not.toHaveProperty("data"); - }); - - test("reports NOT_EXPOSED for a write the plugin did not publish, without a request", async () => { - const { result } = renderHook(() => ({ - create: useDatabaseCreate("note_events"), - remove: useDatabaseDelete("note_events"), - })); - - let created!: Promise; - let removed!: Promise; - await act(async () => { - created = result.current.create.create({ action: "x" }); - removed = result.current.remove.remove(1); - await Promise.all([created, removed]); - }); - - await expect(created).resolves.toBeNull(); - await expect(removed).resolves.toBe(false); - expect(result.current.create.error).toMatchObject({ - code: "NOT_EXPOSED", - status: null, - }); - expect(result.current.remove.error).toMatchObject({ - code: "NOT_EXPOSED", - status: null, - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("refuses a write addressed by an unaddressable id, without a request", async () => { - const { result } = renderHook(() => useDatabaseDelete("notes")); - - let removed!: Promise; - await act(async () => { - removed = result.current.remove(".."); - await removed; - }); - - await expect(removed).resolves.toBe(false); - expect(result.current.error).toMatchObject({ code: "INVALID_REQUEST" }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("a successful write restarts every mounted read by default and resolves once they reload", async () => { - const reads = await mountReads(); - const writer = renderHook(() => useDatabaseCreate("notes")); - expect(sent("GET")).toHaveLength(2); - - let created!: Promise; - let settled = false; - act(() => { - created = writer.result.current.create({ body: "new" }); - void created.then(() => { - settled = true; - }); - }); - await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); - - expect(readsAfter(2)).toEqual([ - "/api/database/notes", - "/api/database/boards", - ]); - // Reads keep their data while they reload; the write waits for them. - expect(reads.result.current.notes).toMatchObject({ - data: page({ id: 1 }), - loading: true, - }); - expect(writer.result.current).toMatchObject({ loading: true, data: null }); - expect(settled).toBe(false); - - await act(async () => { - sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 })); - sent("GET")[3]?.respond(page({ id: 1 })); - }); - await expect(created).resolves.toEqual({ id: 2 }); - expect(reads.result.current.notes.data).toEqual(page({ id: 1 }, { id: 2 })); - expect(writer.result.current).toMatchObject({ - loading: false, - data: { id: 2 }, - }); - }); - - test("overlapping writes wait for the latest reload of every read before resolving or onSuccess", async () => { - const reads = await mountReads(); - const createdSuccess = vi.fn(); - const updatedSuccess = vi.fn(); - const creator = renderHook(() => - useDatabaseCreate("notes", { onSuccess: createdSuccess }), - ); - const updater = renderHook(() => - useDatabaseUpdate("notes", { onSuccess: updatedSuccess }), - ); - const settled: string[] = []; - let created!: Promise; - let updated!: Promise; - act(() => { - created = creator.result.current.create({ body: "new" }); - void created.then(() => { - settled.push("create"); - }); - }); - await act(async () => - sent("POST")[0]?.respond({ id: 2, body: "new" }, 201), - ); - await act(async () => sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 }))); - expect(settled).toEqual([]); - - act(() => { - updated = updater.result.current.update(1, { body: "edited" }); - void updated.then(() => { - settled.push("update"); - }); - }); - await act(async () => { - sent("PATCH")[0]?.respond({ id: 1, body: "edited" }); - await nextTick(); - }); - expect(sent("GET")[3]?.signal?.aborted).toBe(true); - expect(sent("GET")).toHaveLength(6); - expect(settled).toEqual([]); - expect(createdSuccess).not.toHaveBeenCalled(); - expect(creator.result.current.loading).toBe(true); - expect(updater.result.current.loading).toBe(true); - - await act(async () => { - sent("GET")[5]?.respond(page({ id: "current board" })); - sent("GET")[3]?.respond(page({ id: "stale board" })); - await nextTick(); - }); - expect(settled).toEqual([]); - expect(createdSuccess).not.toHaveBeenCalled(); - expect(updatedSuccess).not.toHaveBeenCalled(); - - const freshNotes = page({ id: 1, body: "edited" }, { id: 2, body: "new" }); - await act(async () => sent("GET")[4]?.respond(freshNotes)); - await expect(Promise.all([created, updated])).resolves.toEqual([ - { id: 2, body: "new" }, - { id: 1, body: "edited" }, - ]); - expect(reads.result.current.notes.data).toEqual(freshNotes); - expect(reads.result.current.boards.data).toEqual( - page({ id: "current board" }), - ); - expect(createdSuccess).toHaveBeenCalledOnce(); - expect(updatedSuccess).toHaveBeenCalledOnce(); - expect(creator.result.current.loading).toBe(false); - expect(updater.result.current.loading).toBe(false); - }); - - test("a restarted read that fails still lets the write resolve", async () => { - await mountReads(); - const writer = renderHook(() => useDatabaseCreate("notes")); - - let created!: Promise; - act(() => { - created = writer.result.current.create({ body: "new" }); - }); - await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); - await act(async () => answerReads(2, { error: "down" })); - - await expect(created).resolves.toEqual({ id: 2 }); - expect(writer.result.current.error).toBeNull(); - }); - - test("invalidate narrows the restart to reads of the named tables, or turns it off", async () => { - await mountReads(); - const writers = renderHook(() => ({ - narrow: useDatabaseCreate("notes", { invalidate: ["notes"] }), - none: useDatabaseCreate("notes", { invalidate: false }), - })); - - let created!: Promise; - act(() => { - created = writers.result.current.narrow.create({ body: "a" }); - }); - await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); - expect(readsAfter(2)).toEqual(["/api/database/notes"]); - await act(async () => answerReads(2)); - await created; - - act(() => { - created = writers.result.current.none.create({ body: "b" }); - }); - await act(async () => sent("POST")[1]?.respond({ id: 3 }, 201)); - await created; - expect(sent("GET")).toHaveLength(3); - }); - - test("a narrowed invalidate reaches the reads whose includes show the written table", async () => { - renderHook(() => ({ - // boards → notes, and boards → notes → note_events. - boards: useDatabaseList("boards", { include: { notes: { limit: 5 } } }), - timeline: useDatabaseList("boards", { - include: { notes: { include: { note_events: { limit: 5 } } } }, - }), - plain: useDatabaseList("boards"), - events: useDatabaseList("note_events"), - })); - await act(async () => answerReads(0)); - - act(() => { - void invalidateDatabaseReads(["notes"]); - }); - expect(readsAfter(4)).toEqual([ - "/api/database/boards?include=%7B%22notes%22%3A%7B%22limit%22%3A5%7D%7D", - expect.stringContaining("note_events"), - ]); - - act(() => { - void invalidateDatabaseReads(["note_events"]); - }); - expect(readsAfter(6)).toEqual([ - expect.stringContaining("note_events"), - "/api/database/note_events", - ]); - }); - - test("a read with an include the server did not describe restarts on any scoped invalidate", async () => { - renderHook(() => ({ - unknown: useDatabaseList("boards", { include: { archived: true } }), - plain: useDatabaseList("boards"), - })); - await act(async () => answerReads(0)); - - act(() => { - void invalidateDatabaseReads(["note_events"]); - }); - expect(readsAfter(2)).toEqual([ - "/api/database/boards?include=%7B%22archived%22%3Atrue%7D", - ]); - }); - - test("invalidateDatabaseReads refreshes reads after a write the hooks did not make", async () => { - await mountReads(); - - // A custom route or a databaseApi call changed boards rows. - let refreshed!: Promise; - let done = false; - act(() => { - refreshed = invalidateDatabaseReads(["boards"]).then(() => { - done = true; - }); - }); - expect(readsAfter(2)).toEqual(["/api/database/boards"]); - await Promise.resolve(); - expect(done).toBe(false); - await act(async () => answerReads(2)); - await refreshed; - expect(done).toBe(true); - - // With no scope, every mounted read restarts. - act(() => { - void invalidateDatabaseReads(); - }); - expect(readsAfter(3)).toEqual([ - "/api/database/notes", - "/api/database/boards", - ]); - - await expect(invalidateDatabaseReads(false)).resolves.toBeUndefined(); - await expect(invalidateDatabaseReads([])).resolves.toBeUndefined(); - expect(sent("GET")).toHaveLength(5); - }); - - test("a failed write restarts no reads", async () => { - await mountReads(); - const writer = renderHook(() => useDatabaseCreate("notes")); - - let failed!: Promise; - act(() => { - failed = writer.result.current.create({ body: "" }); - }); - await act(async () => - sent("POST")[0]?.respond({ error: "Database conflict" }, 409), - ); - - await expect(failed).resolves.toBeNull(); - expect(writer.result.current.error).toMatchObject({ code: "CONFLICT" }); - expect(sent("GET")).toHaveLength(2); - }); - - test("unmounting keeps the write in flight, and its success still restarts reads", async () => { - await mountReads(); - const onSuccess = vi.fn(); - const writer = renderHook(() => useDatabaseCreate("notes", { onSuccess })); - - let created!: Promise; - act(() => { - created = writer.result.current.create({ body: "late" }); - }); - writer.unmount(); - - // The write carries no abort signal: cancelling it would not undo it. - expect(sent("POST")[0]?.signal).toBeUndefined(); - await act(async () => sent("POST")[0]?.respond({ id: 9 }, 201)); - expect(sent("GET")).toHaveLength(4); - await act(async () => answerReads(2)); - - await expect(created).resolves.toEqual({ id: 9 }); - // The rows changed, so the callback runs even though the hook is gone. - expect(onSuccess).toHaveBeenCalledWith({ id: 9 }, { body: "late" }); - }); - - test("onSuccess and onError run for every call with its arguments, after the state settles", async () => { - const events: string[] = []; - const { result } = renderHook(() => { - const hook = useDatabaseUpdate("notes", { - onSuccess: (row, id, values) => - events.push(`ok ${JSON.stringify([row, id, values])}`), - onError: (error, id) => - events.push(`fail ${error.code} ${String(id)} ${hook.loading}`), - }); - return hook; - }); - - let first!: Promise; - let second!: Promise; - act(() => { - first = result.current.update(1, { body: "a" }); - second = result.current.update(2, { body: "b" }); - }); - await act(async () => { - sent("PATCH")[0]?.respond({ id: 1 }); - sent("PATCH")[1]?.respond({ error: "Database conflict" }, 409); - }); - await Promise.all([first, second]); - - // The stale first call does not update the state, but is still reported. - expect(events).toHaveLength(2); - expect(events).toEqual( - expect.arrayContaining([ - expect.stringMatching(/^fail CONFLICT 2 /), - `ok ${JSON.stringify([{ id: 1 }, 1, { body: "a" }])}`, - ]), - ); - expect(result.current.error).toMatchObject({ code: "CONFLICT" }); - }); - - test("a delete reports the removed id to onSuccess", async () => { - const onSuccess = vi.fn(); - const { result } = renderHook(() => - useDatabaseDelete("notes", { onSuccess }), - ); - - let removed!: Promise; - act(() => { - removed = result.current.remove(7); - }); - await act(async () => sent("DELETE")[0]?.respond(null, 204)); - - await expect(removed).resolves.toBe(true); - expect(onSuccess).toHaveBeenCalledWith(7); - }); - - test("a throwing callback is reported as uncaught without breaking the call", async () => { - const reported = vi.fn(); - vi.stubGlobal("reportError", reported); - const { result } = renderHook(() => - useDatabaseCreate("notes", { - onSuccess: () => { - throw new Error("callback bug"); - }, - }), - ); - - let created!: Promise; - act(() => { - created = result.current.create({ body: "x" }); - }); - await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); - - await expect(created).resolves.toEqual({ id: 1 }); - expect(reported).toHaveBeenCalledWith(new Error("callback bug")); - expect(result.current).toMatchObject({ data: { id: 1 }, error: null }); - }); - - test("only the latest call reports its state; an earlier call still resolves", async () => { - const { result } = renderHook(() => useDatabaseCreate("notes")); - - let first!: Promise; - let second!: Promise; - act(() => { - first = result.current.create({ body: "first" }); - second = result.current.create({ body: "second" }); - }); - - await act(async () => sent("POST")[1]?.respond({ id: 2 }, 201)); - await second; - expect(result.current).toMatchObject({ data: { id: 2 }, loading: false }); - - await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); - await expect(first).resolves.toEqual({ id: 1 }); - expect(result.current).toMatchObject({ data: { id: 2 }, loading: false }); - - // A stale failure does not replace the latest result either. - let third!: Promise; - let fourth!: Promise; - act(() => { - third = result.current.create({ body: "third" }); - fourth = result.current.create({ body: "fourth" }); - }); - await act(async () => sent("POST")[3]?.respond({ id: 4 }, 201)); - await fourth; - await act(async () => - sent("POST")[2]?.respond({ error: "Database conflict" }, 409), - ); - await expect(third).resolves.toBeNull(); - expect(result.current).toMatchObject({ - data: { id: 4 }, - loading: false, - error: null, - }); - }); - - test("reset returns to idle and ignores the call in flight", async () => { - const { result } = renderHook(() => useDatabaseCreate("notes")); - - let created!: Promise; - act(() => { - created = result.current.create({ body: "x" }); - }); - act(() => result.current.reset()); - expect(result.current).toMatchObject({ - data: null, - loading: false, - error: null, - }); - - await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); - await expect(created).resolves.toEqual({ id: 1 }); - expect(result.current).toMatchObject({ data: null, loading: false }); - }); - - test("keeps its write functions stable across renders with inline options", () => { - const { result, rerender } = renderHook(() => ({ - create: useDatabaseCreate("notes", { - invalidate: ["notes", "boards"], - onSuccess: () => {}, - }), - remove: useDatabaseDelete("notes", { onError: () => {} }), - })); - const { create } = result.current.create; - const { remove, reset } = result.current.remove; - - rerender(); - - expect(result.current.create.create).toBe(create); - expect(result.current.remove.remove).toBe(remove); - expect(result.current.remove.reset).toBe(reset); - }); - - test("uses the options of the latest render when a call settles", async () => { - await mountReads(); - const { result, rerender } = renderHook( - ({ scope }: { scope: readonly string[] }) => - useDatabaseCreate("notes", { invalidate: scope }), - { initialProps: { scope: ["notes"] as readonly string[] } }, - ); - - let created!: Promise; - act(() => { - created = result.current.create({ body: "x" }); - }); - rerender({ scope: ["boards"] }); - await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); - - expect(readsAfter(2)).toEqual(["/api/database/boards"]); - await act(async () => answerReads(2)); - await created; - }); - - test("reports its state under StrictMode", async () => { - const { result } = renderHook(() => useDatabaseCreate("notes"), { - wrapper: StrictMode, - }); - - let created!: Promise; - act(() => { - created = result.current.create({ body: "strict" }); - }); - expect(result.current.loading).toBe(true); - - await act(async () => sent("POST")[0]?.respond({ id: 5 }, 201)); - await created; - expect(result.current).toMatchObject({ data: { id: 5 }, loading: false }); - }); -}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx deleted file mode 100644 index fb7fe39ea..000000000 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { StrictMode } from "react"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { DatabaseApiError } from "@/js/database/errors"; - -import { - invalidateDatabaseReads, - nextTick, - publishDatabase, - reply, - resetDatabaseTestEnvironment, - useDatabaseRecord, -} from "./database-test-utils"; - -describe("useDatabaseRecord", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - // Answers every detail read at once with a row named after its id. - fetchMock = vi.fn(async (url: string) => { - const id = decodeURIComponent(url.split("?")[0]?.split("/").pop() ?? ""); - return reply({ id, body: `note ${id}` }); - }); - vi.stubGlobal("fetch", fetchMock); - publishDatabase({ - "notes.list": "/api/database/notes", - "notes.detail": "/api/database/notes/:id", - "events.list": "/api/database/events", - }); - }); - - afterEach(resetDatabaseTestEnvironment); - - test("reads the detail route with the id as one path segment and the encoded query", async () => { - const { result } = renderHook(() => - useDatabaseRecord("notes", "a/b c", { - include: { note_events: { limit: 5 } }, - }), - ); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(fetchMock.mock.calls[0]?.[0]).toBe( - "/api/database/notes/a%2Fb%20c?include=%7B%22note_events%22%3A%7B%22limit%22%3A5%7D%7D", - ); - expect(result.current.data).toEqual({ id: "a/b c", body: "note a/b c" }); - }); - - test("waits without a request while the id is null or undefined", async () => { - const { result, rerender } = renderHook( - ({ id }: { id: number | null | undefined }) => - useDatabaseRecord("notes", id), - { initialProps: { id: null as number | null | undefined } }, - ); - - expect(result.current).toMatchObject({ - data: null, - loading: false, - error: null, - }); - rerender({ id: undefined }); - act(() => result.current.refetch()); - expect(fetchMock).not.toHaveBeenCalled(); - - rerender({ id: 7 }); - await waitFor(() => - expect(result.current.data).toEqual({ id: "7", body: "note 7" }), - ); - expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes/7"); - }); - - test.each(["", ".", ".."])( - "refuses the id %j, which would resolve to another route, without a request", - (id) => { - const { result, rerender } = renderHook(() => - useDatabaseRecord("notes", id), - ); - const first = result.current.error; - - expect(first).toMatchObject({ code: "INVALID_REQUEST", status: null }); - expect(result.current).toMatchObject({ data: null, loading: false }); - expect(fetchMock).not.toHaveBeenCalled(); - rerender(); - expect(result.current.error).toBe(first); - }, - ); - - test("does not encode incomplete includes while the record is disabled", () => { - const { result, rerender } = renderHook( - ({ id, author }: { id: number | null; author: string | undefined }) => - useDatabaseRecord("notes", id, { - include: { note_events: { where: { author } } }, - }), - { - initialProps: { - id: null as number | null, - author: undefined as string | undefined, - }, - }, - ); - - expect(result.current.error).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - rerender({ id: 7, author: "ada" }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test("reports a non-finite include operand locally with stable field details", () => { - const { result, rerender } = renderHook(() => - useDatabaseRecord("notes", 7, { - include: { note_events: { where: { rank: NaN } } }, - }), - ); - expect(result.current.error).toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database query numbers must be finite", - details: [ - { path: ["include"], message: "Database query numbers must be finite" }, - ], - }); - const first = result.current.error; - rerender(); - expect(result.current.error).toBe(first); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("reads the new record when the id changes", async () => { - const { result, rerender } = renderHook( - ({ id }: { id: number }) => useDatabaseRecord("notes", id), - { initialProps: { id: 1 } }, - ); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); - - rerender({ id: 2 }); - expect(result.current).toMatchObject({ data: null, loading: true }); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "2" })); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("keepPreviousData shows the previous record while the next id loads", async () => { - const { result, rerender } = renderHook( - ({ id }: { id: number }) => - useDatabaseRecord("notes", id, {}, { keepPreviousData: true }), - { initialProps: { id: 1 } }, - ); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); - - rerender({ id: 2 }); - expect(result.current).toMatchObject({ - data: { id: "1" }, - loading: true, - }); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "2" })); - }); - - test("reports a missing row as NOT_FOUND", async () => { - fetchMock.mockResolvedValueOnce( - reply({ error: "Database record not found" }, 404), - ); - - const { result } = renderHook(() => useDatabaseRecord("notes", 404)); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toBeNull(); - expect(result.current.error).toBeInstanceOf(DatabaseApiError); - expect(result.current.error).toMatchObject({ - code: "NOT_FOUND", - status: 404, - message: "Database record not found", - }); - }); - - test("clears the row when a refetch finds it deleted, but keeps it through other failures", async () => { - const { result } = renderHook(() => useDatabaseRecord("notes", 1)); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); - - fetchMock.mockResolvedValueOnce(reply({ error: "Unavailable" }, 503)); - act(() => result.current.refetch()); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toMatchObject({ id: "1" }); - expect(result.current.error).toMatchObject({ status: 503 }); - - // The row was deleted elsewhere; the restart after that write finds it gone. - fetchMock.mockResolvedValueOnce( - reply({ error: "Database record not found" }, 404), - ); - await act(() => invalidateDatabaseReads()); - expect(result.current.data).toBeNull(); - expect(result.current.error).toMatchObject({ code: "NOT_FOUND" }); - }); - - test("reads once under StrictMode and keeps the request across the remount", async () => { - const { result } = renderHook(() => useDatabaseRecord("notes", 3), { - wrapper: StrictMode, - }); - - await nextTick(); - await waitFor(() => expect(result.current.data).toMatchObject({ id: "3" })); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test("reports NOT_EXPOSED for a table with no detail route, without a request", () => { - const { result } = renderHook(() => useDatabaseRecord("events", 1)); - - expect(result.current.error).toMatchObject({ - code: "NOT_EXPOSED", - message: 'Database operation "events.detail" is not exposed', - }); - expect(result.current.loading).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts index 2ecb1d99e..280959037 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -239,10 +239,51 @@ export async function Rejected() { (await databaseApi.execute("notes.delete", { id: 1 })).id; // @ts-expect-error TS2345 the client takes generated names only await databaseApi.execute("notes.missing", {}); + // @ts-expect-error TS2339 the entity CRUD client is gone + await databaseApi.list("notes"); } `); }); +test("removed entity CRUD hooks, types, and serializer shapes are not exported", () => { + const removedHooks = [ + "useDatabaseList", + "useDatabaseRecord", + "useDatabaseCreate", + "useDatabaseUpdate", + "useDatabaseDelete", + "invalidateDatabaseReads", + "serialized", + "DatabaseShape", + "DatabaseRowShape", + "UseDatabaseReadResult", + "UseDatabaseWriteState", + "DatabaseInvalidation", + "DatabaseEntity", + "DatabaseKeyedEntity", + "DatabaseListPage", + "DatabaseRow", + ]; + const removedClient = [ + "DatabaseRegistry", + "DatabaseListParams", + "DatabaseInsert", + "DatabaseUpdate", + ]; + expectEachProbeRejected( + [ + ...removedHooks.map( + (name) => + `// @ts-expect-error TS2694 ${name} is removed\nexport type Removed_${name} = import("@databricks/appkit-ui/react/beta").${name};`, + ), + ...removedClient.map( + (name) => + `// @ts-expect-error TS2694 ${name} is removed\nexport type Client_${name} = import("@databricks/appkit-ui/js/beta").${name};`, + ), + ].join("\n"), + ); +}); + test("no operation exists before generation fills the registry", () => { expectEachProbeRejected(`${IMPORTS} export async function Unbound() { diff --git a/packages/appkit-ui/src/react/hooks/database-request-store.ts b/packages/appkit-ui/src/react/hooks/database-request-store.ts deleted file mode 100644 index 766a05808..000000000 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { getPluginClientConfig } from "@/js/config"; -import { requestDatabase } from "@/js/database/entity-client"; -import { DatabaseApiError } from "@/js/database/errors"; -import type { DatabaseEntity } from "@/js/database/types"; - -import { createRequestStore, type RequestRunner } from "./request-store"; - -/** - * Which mounted reads to restart after a write: `true` for every database - * read, a table list for the reads that touch those tables, `false` for none. - */ -export type DatabaseInvalidation = boolean | readonly DatabaseEntity[]; - -/** - * Shared in-flight read store for the database hooks: an instance of the - * generic {@link createRequestStore} lifecycle wired to the database client's - * transport. Hook instances that resolve to the same URL share one request and - * one snapshot while any of them is mounted. - * - * Nothing outlives its last subscriber, so this deduplicates reads; it is not - * a cache. - */ - -/** Immutable per-key read state; mirrors the hooks' public result shape. */ -interface DatabaseReadSnapshot { - data: unknown; - loading: boolean; - error: DatabaseApiError | null; -} - -/** Snapshot for keys with no live entry. Referentially stable. */ -export const IDLE_DATABASE_READ: DatabaseReadSnapshot = Object.freeze({ - data: null, - loading: false, - error: null, -}); - -/** Checks a decoded body is the envelope the read's route answers with. */ -type ResponseGuard = (body: unknown) => body is object; - -/** - * The tables one read shows rows of: the one it is rooted at and every table - * its includes reach. `open` marks a read with an include the server did not - * describe, which any scoped invalidation restarts rather than risk missing. - */ -interface DatabaseReadScope { - readonly tables: ReadonlySet; - readonly open: boolean; -} - -/** `{ table: { relation: targetTable } }`, as `DatabasePlugin` publishes it. */ -type PublishedRelations = Record>; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function publishedRelations(): PublishedRelations { - const { relations } = getPluginClientConfig<{ relations?: unknown }>( - "database", - ); - return isRecord(relations) ? (relations as PublishedRelations) : {}; -} - -/** - * Walk a read's include tree through the relations the server published, so - * a write to `notes` can find a `boards` read that includes its notes. - */ -export function databaseReadScope( - entity: string, - include: unknown, -): DatabaseReadScope { - const relations = publishedRelations(); - const tables = new Set([entity]); - let open = false; - - const walk = (table: string, tree: unknown): void => { - if (!isRecord(tree)) return; - const edges = relations[table]; - for (const [name, options] of Object.entries(tree)) { - const target = - isRecord(edges) && Object.hasOwn(edges, name) ? edges[name] : undefined; - if (typeof target !== "string") { - open = true; - continue; - } - tables.add(target); - if (isRecord(options)) walk(target, options.include); - } - }; - walk(entity, include); - - return { tables, open }; -} - -/** Anything a request rejects with other than an abort, as the hooks report it. */ -export function asDatabaseApiError(error: unknown): DatabaseApiError { - if (error instanceof DatabaseApiError) return error; - return new DatabaseApiError( - "INTERNAL", - null, - error instanceof Error ? error.message : "Database request failed", - [], - { cause: error }, - ); -} - -/** Resolves once `signal` aborts, even if the transport ignores it. */ -function whenAborted(signal: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); -} - -/** - * Build the runner for one read. A restart keeps the last result visible - * while it loads, and a run superseded by a restart or torn down after its - * last release never patches the entry. A `NOT_FOUND` drops the last result: - * the row is gone, so showing it beside the error would mislead. The returned - * promise settles once the run has patched its outcome or was aborted. - */ -function runDatabaseRead( - url: string, - accept: ResponseGuard, -): RequestRunner { - return ({ signal, patch }) => { - patch({ loading: true, error: null }); - const outcome = requestDatabase( - url, - { method: "GET", signal }, - accept, - ).then( - (data) => { - if (!signal.aborted) patch({ data, loading: false, error: null }); - }, - (cause: unknown) => { - if (signal.aborted) return; - const error = asDatabaseApiError(cause); - patch( - error.code === "NOT_FOUND" - ? { data: null, loading: false, error } - : { loading: false, error }, - ); - }, - ); - return Promise.race([outcome, whenAborted(signal)]); - }; -} - -const store = createRequestStore( - IDLE_DATABASE_READ, -); - -/** - * Register a subscriber for the read of `url`, starting it on first use. - * `scope` is kept from the first subscriber; it is derived from the same - * params as `url`, so every subscriber of one URL has the same scope. - * Returns a `release` function that must be called on unmount. - */ -export function retainDatabaseRead( - url: string, - accept: ResponseGuard, - scope: DatabaseReadScope, -): () => void { - return store.retain(url, runDatabaseRead(url, accept), { meta: scope }); -} - -/** - * Restart mounted database reads after a write the hooks did not make, such - * as a `databaseApi` call or a custom route that changes rows. The write hooks - * call this on success with their `invalidate` option. - * - * `true` (the default) restarts every read. A table list restarts the reads - * that show rows of those tables: reads rooted at them, and reads whose - * includes reach them through the relations the server published. `false` - * restarts none. - * - * Resolves once the current runs of those reads have answered, failed, or - * been torn down. A superseding refresh is followed rather than counted as - * complete; it never rejects. - * - * @example - * ```ts - * await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); - * await invalidateDatabaseReads(["boards", "notes"]); - * ``` - */ -export function invalidateDatabaseReads( - scope: DatabaseInvalidation = true, -): Promise { - if (scope === false) return Promise.resolve(); - if (scope === true) return store.restartStarted(); - const written = new Set(scope); - if (written.size === 0) return Promise.resolve(); - return store.restartStarted((_url, read) => { - if (read === undefined || read.open) return true; - for (const table of read.tables) if (written.has(table)) return true; - return false; - }); -} - -export const startDatabaseRead = store.start; -export const subscribeDatabaseRead = store.subscribe; -export const getDatabaseReadSnapshot = store.getSnapshot; - -/** Test-only: abort every in-flight read and clear the store. */ -export const resetDatabaseRequestStore = store.reset; diff --git a/packages/appkit-ui/src/react/hooks/use-database-create.ts b/packages/appkit-ui/src/react/hooks/use-database-create.ts deleted file mode 100644 index 2fd7f7d76..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-create.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback } from "react"; -import type { ExactDatabaseParams } from "shared"; - -import { createDatabaseRow } from "@/js/database/entity-client"; -import type { DatabaseApiError } from "@/js/database/errors"; -import type { - DatabaseEntity, - DatabaseInsert, - DatabaseRow, -} from "@/js/database/types"; - -import { - type UseDatabaseWriteOptions, - type UseDatabaseWriteState, - useDatabaseWrite, -} from "./use-database-write"; - -/** Options for {@link useDatabaseCreate}. */ -export interface UseDatabaseCreateOptions< - K extends DatabaseEntity, -> extends UseDatabaseWriteOptions { - /** - * Called for every successful create, once the restarted reads reloaded, - * even if the component unmounted meanwhile. - */ - onSuccess?: (row: DatabaseRow, values: DatabaseInsert) => void; - /** Called for every failed create, with the error `error` also reports. */ - onError?: (error: DatabaseApiError, values: DatabaseInsert) => void; -} - -/** What {@link useDatabaseCreate} returns. */ -export interface UseDatabaseCreateResult< - K extends DatabaseEntity, -> extends UseDatabaseWriteState> { - /** - * Send `POST /api/database/` and resolve with the created row, or - * with `null` when the write failed; the reason is in `error`. It never - * rejects, so a handler needs no `try/catch`. - */ - create>( - values: V & ExactDatabaseParams>, - ): Promise | null>; - /** Return to the idle state; a call in flight no longer reports here. */ - reset(): void; -} - -/** - * Create rows through `POST /api/database/`. Values are typed from the - * generated `database.d.ts`, so private, generated, and undeclared fields are - * compile errors. Once a create succeeds, mounted database reads restart, so - * lists and includes that show the new row refresh without a manual refetch. - * - * @param entity - A table the generated registry exposes - * @param options - `invalidate` to narrow the read restart; `onSuccess` and - * `onError` to observe every call - * @returns `create`, the latest call's row, loading and error state, and `reset` - * - * @example - * ```tsx - * const notes = useDatabaseCreate("notes", { - * onError: (error) => toast(error.message), - * }); - * - * async function add(body: string) { - * const note = await notes.create({ board_id: boardId, author: "ada", body }); - * if (note) setDraft(""); - * } - * ``` - */ -export function useDatabaseCreate( - entity: K, - options: UseDatabaseCreateOptions = {}, -): UseDatabaseCreateResult { - const send = useCallback( - (values: DatabaseInsert) => createDatabaseRow(entity, values as object), - [entity], - ); - const { onSuccess, onError } = options; - const { mutate, ...write } = useDatabaseWrite(send, { - invalidate: options.invalidate, - // The server projected the row it holds; the types describe that wire. - onSuccess: - onSuccess && - ((row, [values]) => onSuccess(row as DatabaseRow, values)), - onError: onError && ((error, [values]) => onError(error, values)), - }); - return { ...write, create: mutate } as UseDatabaseCreateResult; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-delete.ts b/packages/appkit-ui/src/react/hooks/use-database-delete.ts deleted file mode 100644 index e8fe5d040..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-delete.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useCallback } from "react"; - -import { deleteDatabaseRow } from "@/js/database/entity-client"; -import type { DatabaseApiError } from "@/js/database/errors"; -import type { DatabaseId, DatabaseKeyedEntity } from "@/js/database/types"; - -import { - type UseDatabaseWriteOptions, - type UseDatabaseWriteState, - useDatabaseWrite, -} from "./use-database-write"; - -/** Options for {@link useDatabaseDelete}. */ -export interface UseDatabaseDeleteOptions< - K extends DatabaseKeyedEntity, -> extends UseDatabaseWriteOptions { - /** - * Called for every successful delete, once the restarted reads reloaded, - * even if the component unmounted meanwhile. - */ - onSuccess?: (id: DatabaseId) => void; - /** Called for every failed delete, with the error `error` also reports. */ - onError?: (error: DatabaseApiError, id: DatabaseId) => void; -} - -/** What {@link useDatabaseDelete} returns. A delete answers no row. */ -export interface UseDatabaseDeleteResult< - K extends DatabaseKeyedEntity, -> extends Omit, "data"> { - /** - * Send `DELETE /api/database//:id` and resolve `true` once the row - * is deleted, or `false` when the write failed; the reason is in `error`, - * and a missing row is `NOT_FOUND`. It never rejects. - */ - remove(id: DatabaseId): Promise; - /** Return to the idle state; a call in flight no longer reports here. */ - reset(): void; -} - -/** - * Delete rows through `DELETE /api/database//:id`. Only entities with - * a public primary key have this route. Once a delete succeeds, mounted - * database reads restart, so lists that showed the row drop it and a record - * read of it reports `NOT_FOUND` with no `data`. - * - * @param entity - A table the generated registry exposes with a public key - * @param options - `invalidate` to narrow the read restart; `onSuccess` and - * `onError` to observe every call - * @returns `remove`, loading and error state, and `reset` - * - * @example - * ```tsx - * const notes = useDatabaseDelete("notes"); - * - * ; - * ``` - */ -export function useDatabaseDelete( - entity: K, - options: UseDatabaseDeleteOptions = {}, -): UseDatabaseDeleteResult { - // A delete answers no row, so success is the only result worth reporting. - const send = useCallback( - async (id: DatabaseId) => { - await deleteDatabaseRow(entity, id); - return true as const; - }, - [entity], - ); - const { onSuccess, onError } = options; - const { mutate, reset, loading, error } = useDatabaseWrite(send, { - invalidate: options.invalidate, - onSuccess: onSuccess && ((_deleted, [id]) => onSuccess(id)), - onError: onError && ((failure, [id]) => onError(failure, id)), - }); - const remove = useCallback( - async (id: DatabaseId) => (await mutate(id)) === true, - [mutate], - ); - return { remove, reset, loading, error }; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts deleted file mode 100644 index e97fd3fd9..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - type DatabaseListPage, - type DatabaseListQuery, - DatabaseQueryEncodingError, - encodeDatabaseListQuery, - type ExactDatabaseParams, -} from "shared"; - -import { isDatabaseListPage } from "@/js/database/entity-client"; -import type { - DatabaseEntity, - DatabaseKeyedEntity, - DatabaseListParams, - DatabaseListRow, -} from "@/js/database/types"; - -import { - type DatabaseReadRequest, - type UseDatabaseReadOptions, - type UseDatabaseReadResult, - useDatabaseRead, -} from "./use-database-read"; - -/** Options for {@link useDatabaseList}. */ -export type UseDatabaseListOptions = UseDatabaseReadOptions; - -/** What {@link useDatabaseList} returns: one page of rows and its state. */ -export type UseDatabaseListResult = UseDatabaseReadResult< - DatabaseListPage ->; - -/** - * Subscribe to one page of `GET /api/database/`. Entity, params, and - * rows are typed from the generated `database.d.ts`; the route comes from the - * endpoints the server published. - * - * Mounted hooks whose params encode to the same query share one request, so - * an inline params literal does not refetch on every render. The request is - * aborted once its last subscriber unmounts. - * - * @param entity - A table the generated registry exposes - * @param params - `where`, `order`, `select`, `include`, `limit`, `offset`; - * `null` holds the hook idle, for params that depend on another read - * @param options - `enabled` and `keepPreviousData` - * @returns The page, loading and error state, and `refetch` - * - * @example - * ```tsx - * const notes = useDatabaseList( - * "notes", - * board ? { where: { board_id: board.id }, limit: 20, offset } : null, - * { keepPreviousData: true }, - * ); - * notes.data?.items.map((note) => note.body); - * ``` - */ -export function useDatabaseList( - entity: K, - params?: undefined, - options?: UseDatabaseListOptions, -): UseDatabaseListResult>; -export function useDatabaseList< - K extends DatabaseEntity, - const P extends DatabaseListParams, ->( - entity: K, - params: (P & ExactDatabaseParams>) | null, - options?: UseDatabaseListOptions, -): UseDatabaseListResult>; -export function useDatabaseList( - entity: string, - params?: DatabaseListQuery | null, - options: UseDatabaseListOptions = {}, -): UseDatabaseListResult { - const enabled = (options.enabled ?? true) && params !== null; - // Encode only an enabled read: a held read's params may still be incomplete. - let query: DatabaseReadRequest["query"] = ""; - if (enabled) { - try { - query = encodeDatabaseListQuery(params ?? {}); - } catch (error) { - query = error instanceof DatabaseQueryEncodingError ? error : null; - } - } - const read = useDatabaseRead({ - entity, - operation: "list", - id: undefined, - query, - include: params?.include, - enabled, - accept: isDatabaseListPage, - keepPreviousData: options.keepPreviousData ?? false, - }); - return read as UseDatabaseListResult; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts deleted file mode 100644 index db89f9dac..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useSyncExternalStore, -} from "react"; -import { DatabaseQueryEncodingError } from "shared"; - -import { - type DatabaseOperation, - type IdLike, - resolveDatabaseUrl, -} from "@/js/database/entity-client"; -import { DatabaseApiError, invalidDatabaseQuery } from "@/js/database/errors"; - -import { - databaseReadScope, - getDatabaseReadSnapshot, - IDLE_DATABASE_READ, - retainDatabaseRead, - startDatabaseRead, - subscribeDatabaseRead, -} from "./database-request-store"; - -/** Options shared by `useDatabaseList` and `useDatabaseRecord`. */ -export interface UseDatabaseReadOptions { - /** Send the request. `false` keeps the hook idle and sends nothing. Default true. */ - enabled?: boolean; - /** - * While new params load, keep showing the previous params' `data` instead - * of `null`, so a paginated list does not blank between pages. `loading` - * stays true until the new response arrives. Default false. - */ - keepPreviousData?: boolean; -} - -/** Latest state of one database read. */ -export interface UseDatabaseReadResult { - /** - * The last successful response, or `null` before one arrives. A refetch - * keeps it visible while it loads and after it fails, except a `NOT_FOUND`, - * which clears it. - */ - data: T | null; - /** Whether a request for the current params is in flight. */ - loading: boolean; - /** - * Why the latest request failed: `NOT_EXPOSED` when no route exists, - * `INVALID_REQUEST` for params or an id that cannot be sent. Stable across - * renders. - */ - error: DatabaseApiError | null; - /** Abort any in-flight request and send it again. No-op while disabled. */ - refetch: () => void; -} - -/** What one read hook asks `useDatabaseRead` to subscribe to. */ -export interface DatabaseReadRequest { - entity: string; - operation: Extract; - id: IdLike | undefined; - /** Encoded params, a known validation failure, or `null` for an unknown failure. */ - query: string | DatabaseQueryEncodingError | null; - /** The params' include tree, to find the tables the read shows. */ - include: unknown; - enabled: boolean; - accept: (body: unknown) => body is object; - keepPreviousData: boolean; -} - -type Route = { url: string } | { error: DatabaseApiError } | null; - -const noop = () => {}; - -/** - * One subscribed read, keyed by the resolved URL so params with equal encoded - * values share a request whatever their object identity. A route the server - * did not publish resolves to a stable `NOT_EXPOSED` error without a request, - * and params that cannot be encoded to a stable `INVALID_REQUEST`. - */ -export function useDatabaseRead({ - entity, - operation, - id, - query, - include, - enabled, - accept, - keepPreviousData, -}: DatabaseReadRequest): UseDatabaseReadResult { - const encodedQuery = typeof query === "string" ? query : null; - // Encoding runs every render; only the safe failure fields define its identity. - const parameter = - query instanceof DatabaseQueryEncodingError ? query.parameter : undefined; - const message = - query instanceof DatabaseQueryEncodingError ? query.message : undefined; - const route = useMemo((): Route => { - if (!enabled) return null; - if (encodedQuery === null) { - const cause = - parameter !== undefined && message !== undefined - ? new DatabaseQueryEncodingError(parameter, message) - : undefined; - return { error: invalidDatabaseQuery(cause) }; - } - try { - return { url: resolveDatabaseUrl(entity, operation, id, encodedQuery) }; - } catch (error) { - if (error instanceof DatabaseApiError) return { error }; - throw error; - } - }, [enabled, entity, operation, id, encodedQuery, parameter, message]); - - const url = route !== null && "url" in route ? route.url : null; - const routeError = route !== null && "error" in route ? route.error : null; - - // Every subscriber of one URL encodes the same include, so the URL is the - // include tree's identity; the object passed in may be a fresh literal. - const scope = useMemo( - () => (url === null ? null : databaseReadScope(entity, include)), - [url], - ); - - const subscribe = useCallback( - (listener: () => void) => - url === null ? noop : subscribeDatabaseRead(url, listener), - [url], - ); - const getSnapshot = useCallback( - () => (url === null ? IDLE_DATABASE_READ : getDatabaseReadSnapshot(url)), - [url], - ); - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - - // The first subscriber of a URL starts the request; later ones share it. - useEffect(() => { - if (url === null || scope === null) return; - return retainDatabaseRead(url, accept, scope); - }, [url, accept, scope]); - - const refetch = useCallback(() => { - if (url !== null) startDatabaseRead(url); - }, [url]); - - // Until the effect retains a new URL, its request is about to start. - const loading = - snapshot.loading || (url !== null && snapshot === IDLE_DATABASE_READ); - const error = routeError ?? snapshot.error; - - // The last data this hook showed for a URL, to hold across a params change. - const previous = useRef<{ url: string; data: unknown } | null>(null); - useEffect(() => { - if (url !== null && snapshot.data !== null) { - previous.current = { url, data: snapshot.data }; - } - }, [url, snapshot.data]); - - let data = snapshot.data; - if ( - keepPreviousData && - data === null && - loading && - error === null && - previous.current !== null && - previous.current.url !== url - ) { - data = previous.current.data; - } - - return { data, loading, error, refetch }; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts deleted file mode 100644 index a5283b5ff..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - DatabaseQueryEncodingError, - encodeDatabaseRecordQuery, - type ExactDatabaseParams, -} from "shared"; - -import { isDatabaseRow } from "@/js/database/entity-client"; -import type { - DatabaseId, - DatabaseKeyedEntity, - DatabaseRecordParams, - DatabaseRecordRow, -} from "@/js/database/types"; - -import { - type DatabaseReadRequest, - type UseDatabaseReadOptions, - type UseDatabaseReadResult, - useDatabaseRead, -} from "./use-database-read"; - -/** Options for {@link useDatabaseRecord}. */ -export type UseDatabaseRecordOptions = UseDatabaseReadOptions; - -/** What {@link useDatabaseRecord} returns: one row and its state. */ -export type UseDatabaseRecordResult = UseDatabaseReadResult; - -/** - * Subscribe to one row from `GET /api/database//:id`. Only entities - * with a public primary key have this route; a missing row surfaces as a - * `NOT_FOUND` error, and a refetch that finds the row gone clears `data`. - * - * A `null` or `undefined` id holds the hook idle without a request, so a - * record that depends on another read needs no separate `enabled` flag. An - * empty, `"."`, or `".."` id reports `INVALID_REQUEST` without a request. - * - * @param entity - A table the generated registry exposes with a public key - * @param id - The row's public key, or `null`/`undefined` to wait - * @param params - `select` and `include`; pass `{}` to reach `options` alone - * @param options - `enabled` and `keepPreviousData` - * @returns The row, loading and error state, and `refetch` - * - * @example - * ```tsx - * const board = useDatabaseRecord("boards", selectedId, { - * include: { notes: { limit: 20 } }, - * }); - * board.data?.notes.length; - * ``` - */ -export function useDatabaseRecord< - K extends DatabaseKeyedEntity, - const P extends DatabaseRecordParams = Record, ->( - entity: K, - id: DatabaseId | null | undefined, - params?: P & ExactDatabaseParams>, - options: UseDatabaseRecordOptions = {}, -): UseDatabaseRecordResult> { - const enabled = (options.enabled ?? true) && id !== null && id !== undefined; - const recordParams = (params ?? {}) as { include?: unknown }; - // Encode only an enabled read: a held read's params may still be incomplete. - let query: DatabaseReadRequest["query"] = ""; - if (enabled) { - try { - query = encodeDatabaseRecordQuery(recordParams); - } catch (error) { - query = error instanceof DatabaseQueryEncodingError ? error : null; - } - } - const read = useDatabaseRead({ - entity, - operation: "detail", - id: id ?? undefined, - query, - include: recordParams.include, - enabled, - accept: isDatabaseRow, - keepPreviousData: options.keepPreviousData ?? false, - }); - // The server projected and encoded the row; the types describe that wire. - return read as UseDatabaseRecordResult>; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-update.ts b/packages/appkit-ui/src/react/hooks/use-database-update.ts deleted file mode 100644 index 18863098a..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-update.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useCallback } from "react"; -import type { ExactDatabaseParams } from "shared"; - -import { updateDatabaseRow } from "@/js/database/entity-client"; -import type { DatabaseApiError } from "@/js/database/errors"; -import type { - DatabaseId, - DatabaseKeyedEntity, - DatabaseRow, - DatabaseUpdate, -} from "@/js/database/types"; - -import { - type UseDatabaseWriteOptions, - type UseDatabaseWriteState, - useDatabaseWrite, -} from "./use-database-write"; - -/** Options for {@link useDatabaseUpdate}. */ -export interface UseDatabaseUpdateOptions< - K extends DatabaseKeyedEntity, -> extends UseDatabaseWriteOptions { - /** - * Called for every successful update, once the restarted reads reloaded, - * even if the component unmounted meanwhile. - */ - onSuccess?: ( - row: DatabaseRow, - id: DatabaseId, - values: DatabaseUpdate, - ) => void; - /** Called for every failed update, with the error `error` also reports. */ - onError?: ( - error: DatabaseApiError, - id: DatabaseId, - values: DatabaseUpdate, - ) => void; -} - -/** What {@link useDatabaseUpdate} returns. */ -export interface UseDatabaseUpdateResult< - K extends DatabaseKeyedEntity, -> extends UseDatabaseWriteState> { - /** - * Send `PATCH /api/database//:id` and resolve with the updated row, - * or with `null` when the write failed; the reason is in `error`, and a - * missing row is `NOT_FOUND`. It never rejects. - */ - update>( - id: DatabaseId, - values: V & ExactDatabaseParams>, - ): Promise | null>; - /** Return to the idle state; a call in flight no longer reports here. */ - reset(): void; -} - -/** - * Update rows through `PATCH /api/database//:id`. Only entities with a - * public primary key have this route, and keys, generated, and - * default-stamped columns are not updatable. Once an update succeeds, mounted - * database reads restart. - * - * @param entity - A table the generated registry exposes with a public key - * @param options - `invalidate` to narrow the read restart; `onSuccess` and - * `onError` to observe every call - * @returns `update`, the latest call's row, loading and error state, and `reset` - * - * @example - * ```tsx - * const notes = useDatabaseUpdate("notes"); - * - * ; - * ``` - */ -export function useDatabaseUpdate( - entity: K, - options: UseDatabaseUpdateOptions = {}, -): UseDatabaseUpdateResult { - const send = useCallback( - (id: DatabaseId, values: DatabaseUpdate) => - updateDatabaseRow(entity, id, values as object), - [entity], - ); - const { onSuccess, onError } = options; - const { mutate, ...write } = useDatabaseWrite(send, { - invalidate: options.invalidate, - // The server projected the row it holds; the types describe that wire. - onSuccess: - onSuccess && - ((row, [id, values]) => onSuccess(row as DatabaseRow, id, values)), - onError: onError && ((error, [id, values]) => onError(error, id, values)), - }); - return { ...write, update: mutate } as UseDatabaseUpdateResult; -} diff --git a/packages/appkit-ui/src/react/hooks/use-database-write.ts b/packages/appkit-ui/src/react/hooks/use-database-write.ts deleted file mode 100644 index 8e977d46a..000000000 --- a/packages/appkit-ui/src/react/hooks/use-database-write.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { DatabaseApiError } from "@/js/database/errors"; - -import { - asDatabaseApiError, - type DatabaseInvalidation, - invalidateDatabaseReads, -} from "./database-request-store"; - -export type { DatabaseInvalidation } from "./database-request-store"; - -/** Latest state of a database write hook. */ -export interface UseDatabaseWriteState { - /** The latest call's result, or `null` before it answers. */ - data: T | null; - /** - * Whether the latest call is in flight, including the reload of the reads - * it restarts. - */ - loading: boolean; - /** - * Why the latest call failed: `NOT_EXPOSED` when no route exists, - * `OUTCOME_UNKNOWN` when it may have committed without an answer. - */ - error: DatabaseApiError | null; -} - -/** Options every database write hook takes. */ -export interface UseDatabaseWriteOptions { - /** - * Reads to restart once a write succeeds. Default `true`: every mounted - * database read, since a foreign key declared with `onDelete("cascade")` can - * change other tables too. A table list restarts only the reads that show - * those tables, directly or through an include; `false` restarts none. - */ - invalidate?: DatabaseInvalidation; -} - -/** The generic write lifecycle's callbacks, before each hook names its args. */ -interface WriteCallbacks { - invalidate?: DatabaseInvalidation; - onSuccess?: (data: T, args: Args) => void; - onError?: (error: DatabaseApiError, args: Args) => void; -} - -interface DatabaseWrite< - Args extends unknown[], - T, -> extends UseDatabaseWriteState { - mutate(...args: Args): Promise; - reset(): void; -} - -const IDLE: UseDatabaseWriteState = Object.freeze({ - data: null, - loading: false, - error: null, -}); - -/** - * Run a caller's callback without letting it break the write's promise: the - * call still resolves, and the exception still reaches the page's error - * handling (and any error tracker) as an uncaught error. - */ -function runCallback(callback: () => void): void { - try { - callback(); - } catch (error) { - if (typeof globalThis.reportError === "function") { - globalThis.reportError(error); - } else { - setTimeout(() => { - throw error; - }); - } - } -} - -/** - * One write hook's state around `send`, which must be stable per entity. - * - * A call never rejects, like `useServingInvoke`: it resolves with the result, - * or with `null` once the failure is in `error`, so a handler needs no - * `try/catch`. Callers that want an exception use `databaseApi` directly. - * - * A write is never aborted: cancelling the request would not undo a committed - * transaction. A successful call restarts the reads `invalidate` names and - * resolves once they have reloaded, so a handler that clears its form sees the - * new rows already on screen. Only the latest call of a hook updates its - * state; callbacks run for every call, even after the hook unmounts, since the - * rows did change. - */ -export function useDatabaseWrite( - send: (...args: Args) => Promise, - options: WriteCallbacks, -): DatabaseWrite { - const [state, setState] = useState>(IDLE); - const latest = useRef(null); - - // Read at call time, so inline callbacks and an inline `invalidate` list do - // not change `mutate`'s identity on every render. - const optionsRef = useRef(options); - useEffect(() => { - optionsRef.current = options; - }); - - const mutate = useCallback( - async (...args: Args): Promise => { - const call = Symbol("database write"); - latest.current = call; - // React ignores a state update after unmount, so only staleness matters. - const settle = (next: UseDatabaseWriteState) => { - if (latest.current === call) setState(next); - }; - - settle({ data: null, loading: true, error: null }); - let data: T; - try { - data = await send(...args); - } catch (cause) { - const error = asDatabaseApiError(cause); - settle({ data: null, loading: false, error }); - const { onError } = optionsRef.current; - if (onError) runCallback(() => onError(error, args)); - return null; - } - // Hold `loading` until the restarted reads answer, so the hook does not - // report success beside rows that still show the old state. - await invalidateDatabaseReads(optionsRef.current.invalidate ?? true); - settle({ data, loading: false, error: null }); - const { onSuccess } = optionsRef.current; - if (onSuccess) runCallback(() => onSuccess(data, args)); - return data; - }, - [send], - ); - - // A call still in flight keeps running, but no longer reports here. - const reset = useCallback(() => { - latest.current = null; - setState(IDLE); - }, []); - - return { ...state, mutate, reset }; -} diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 2486e9917..71ac6a01a 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -123,9 +123,6 @@ export type { export * from "./plugins/beta-exports.generated"; export { database } from "./plugins/database"; export type { - DatabaseApiConfig, - DatabaseApiWriteOperation, - DatabaseApiWritesConfig, DatabaseExports, IDatabaseConfig, TransactionClient, diff --git a/packages/appkit/src/database/contract/registry.ts b/packages/appkit/src/database/contract/registry.ts index 6635eff91..221201572 100644 --- a/packages/appkit/src/database/contract/registry.ts +++ b/packages/appkit/src/database/contract/registry.ts @@ -16,7 +16,7 @@ export interface DatabaseRegistryEntry { includes: Record; /** Literal capability used to omit keyed methods from keyless entities. */ hasPrimaryKey: boolean; - /** What the generated HTTP routes accept: public inputs, filters, order, and key. */ + /** What default operation contracts accept over HTTP: public inputs and key. */ api: DatabaseApiEntry["api"]; } diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index 1fa48fa59..db8ff08a6 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -9,7 +9,7 @@ import { createLogger } from "../logging/logger"; // The category vocabulary is shared with the browser client, which reads the // same statuses back into the same categories. -export type { DatabaseErrorCategory, DatabaseErrorDetail } from "shared"; +export type { DatabaseErrorCategory } from "shared"; const logger = createLogger("database"); diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index b00baed66..70799c4eb 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -154,6 +154,9 @@ export abstract class Plugin< /** Paths that opt out of JSON body parsing (e.g. file upload routes) */ private skipBodyParsingPaths: Set = new Set(); + /** Mount paths whose whole namespace opts out of JSON body parsing. */ + private skipBodyParsingPrefixes: Set = new Set(); + /** * Plugin initialization phase. * - 'core': Initialized first (e.g., config plugins) @@ -241,6 +244,10 @@ export abstract class Plugin< return this.skipBodyParsingPaths; } + getSkipBodyParsingPrefixes(): ReadonlySet { + return this.skipBodyParsingPrefixes; + } + abortActiveOperations(): void { this.streamManager.abortAll(); } @@ -539,6 +546,15 @@ export abstract class Plugin< } } + /** + * Leave every request at or below `path` of this plugin's router unparsed, + * for a handler mounted with `router.use(path, handler)` that reads its own + * body, such as one that must authenticate the caller first. + */ + protected skipBodyParsingUnder(path: string): void { + this.skipBodyParsingPrefixes.add(`/api/${camelToKebab(this.name)}${path}`); + } + // build execution options by merging defaults, plugin config, and user overrides private _buildExecutionConfig( options: PluginExecutionSettings, diff --git a/packages/appkit/src/plugins/database/config.ts b/packages/appkit/src/plugins/database/config.ts index 40e2a51e2..1c5a9ae95 100644 --- a/packages/appkit/src/plugins/database/config.ts +++ b/packages/appkit/src/plugins/database/config.ts @@ -1,5 +1,26 @@ import { databaseSetupFailed } from "../../database/errors"; +function isCallable(value: unknown): boolean { + return typeof value === "function"; +} + +/** The shape of the generated `databaseRoutes` registry. */ +function isDatabaseRoutes(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const modules: unknown = Reflect.get(value, "modules"); + return ( + isCallable(Reflect.get(value, "schema")) && + Array.isArray(modules) && + modules.every( + (entry: unknown) => + typeof entry === "object" && + entry !== null && + typeof Reflect.get(entry, "file") === "string" && + isCallable(Reflect.get(entry, "load")), + ) + ); +} + /** Reject invalid arguments instead of turning them into the default API. */ export function assertDatabaseConfig(config: unknown): void { if (typeof config !== "object" || config === null || Array.isArray(config)) { @@ -13,10 +34,10 @@ export function assertDatabaseConfig(config: unknown): void { "Expected a plain database configuration object.", ); } - // Do not silently turn a previous opt-out into the default full API. - if ("crudRoutes" in config) { + const routes: unknown = Reflect.get(config, "routes"); + if (routes !== undefined && routes !== false && !isDatabaseRoutes(routes)) { throw databaseSetupFailed( - '"crudRoutes" was renamed to "api". Use api: false to disable generated routes or api: { writes: false } for reads only.', + "Expected routes to be the generated databaseRoutes registry, or false.", ); } } diff --git a/packages/appkit/src/plugins/database/crud/capabilities.ts b/packages/appkit/src/plugins/database/crud/capabilities.ts index 427fb074e..7a381acf1 100644 --- a/packages/appkit/src/plugins/database/crud/capabilities.ts +++ b/packages/appkit/src/plugins/database/crud/capabilities.ts @@ -1,21 +1,18 @@ import type { ColumnMeta } from "../../../database/schema-builder"; -import { filterOperatorsForKind } from "../../../database/schema-builder/types"; /** - * What one column may do over the generated HTTP routes. The route compiler - * and the type generator both read these, so the typed browser surface cannot - * drift from what the server actually accepts. + * What one column may do in a named operation's default HTTP contract. The + * operation runtime and the type generator both read these, so the typed + * browser surface cannot drift from what the server actually accepts. */ export interface ColumnHttpCapabilities { - /** A request may project it; every non-private column. */ + /** A reply may carry it; every non-private column. */ readonly selectable: boolean; - /** A request may filter or order by it; its kind has an operator matrix. */ - readonly queryable: boolean; /** A create body may set it, including a caller-chosen key. */ readonly creatable: boolean; /** An update body may set it; a key or a stamp is never one. */ readonly updatable: boolean; - /** It can address one row in `/:table/:id`. */ + /** It can address one row as a default contract's `id`. */ readonly publicKey: boolean; } @@ -40,7 +37,6 @@ export function columnHttpCapabilities( !(meta.primaryKey && meta.defaultRandom); return { selectable, - queryable: selectable && filterOperatorsForKind(meta.kind).length > 0, creatable, // Rewriting a key would move a row out from under every existing reference, // and rewriting a database-materialized stamp would rewrite history. diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts index b2e5f0a24..231d2a96f 100644 --- a/packages/appkit/src/plugins/database/crud/contract.ts +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -1,38 +1,22 @@ import { DatabasePluginError } from "../../../database/errors"; -import type { Row } from "../../../database/runtime"; import type { AppKitTable } from "../../../database/schema-builder"; import { isPlainObject as hasPlainObjectPrototype } from "../../../utils/is-plain-object"; import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults"; import { columnHttpCapabilities } from "./capabilities"; import { type CompiledColumn, compileColumn, type JsonValue } from "./codecs"; -/** One relation edge wired to the contract of its target table. */ -export interface CrudRelation { - readonly cardinality: "toOne" | "toMany"; - readonly target: CrudTable; -} - -/** Private HTTP contract compiled once for one exposed table. */ +/** Private HTTP contract compiled once for one table. */ export interface CrudTable { - readonly name: string; readonly primaryKey?: CompiledColumn; readonly columns: ReadonlyMap; - /** Public columns a request may project. */ + /** Public columns: every column that is not private. */ readonly selectable: ReadonlySet; - /** Public columns a request may filter or order by. */ - readonly queryable: ReadonlySet; /** Public columns a create body may set, including a caller-chosen key. */ readonly creatable: ReadonlySet; /** Public columns an update body may set; a key or a stamp is never one. */ readonly updatable: ReadonlySet; - readonly relations: ReadonlyMap; - projectPublicRow(row: Row): JsonValue; } -type MutableCrudTable = Omit & { - readonly relations: Map; -}; - interface SanitizeState { nodes: number; readonly ancestors: Set; @@ -114,35 +98,10 @@ export function boundedJson(value: unknown): JsonValue { return sanitizeJson(value, 0, { nodes: 0, ancestors: new Set() }); } -/** Project an included row through its own table; absent to-one reads null. */ -function projectRelation(target: CrudTable, value: unknown): JsonValue { - if (value === null || value === undefined) return null; - return Array.isArray(value) - ? value.map((row) => target.projectPublicRow(row as Row)) - : target.projectPublicRow(value as Row); -} - -/** Build the public JSON for one driver row, dropping anything uncontracted. */ -function projectRow(table: CrudTable, row: Row): JsonValue { - const out: Record = {}; - for (const [key, value] of Object.entries(row)) { - const column = table.columns.get(key); - if (column) { - // Whatever the driver returned, only public contracted columns ship. - if (!column.meta.isPrivate) out[key] = column.encode(value); - continue; - } - const relation = table.relations.get(key); - if (relation) out[key] = projectRelation(relation.target, value); - } - return out; -} - /** Compile one table's allowlists and codecs from its finalized metadata. */ -function compileTable(table: AppKitTable): MutableCrudTable { +function compileTable(table: AppKitTable): CrudTable { const columns = new Map(); const selectable = new Set(); - const queryable = new Set(); const creatable = new Set(); const updatable = new Set(); let primaryKey: CompiledColumn | undefined; @@ -154,47 +113,20 @@ function compileTable(table: AppKitTable): MutableCrudTable { const can = columnHttpCapabilities(meta); if (can.publicKey) primaryKey = column; if (can.selectable) selectable.add(meta.columnName); - if (can.queryable) queryable.add(meta.columnName); if (can.creatable) creatable.add(meta.columnName); if (can.updatable) updatable.add(meta.columnName); } - const compiled: MutableCrudTable = { - name: table.$name, - primaryKey, - columns, - selectable, - queryable, - creatable, - updatable, - relations: new Map(), - projectPublicRow: (row) => projectRow(compiled, row), - }; - return compiled; + return { primaryKey, columns, selectable, creatable, updatable }; } -/** - * Compile the HTTP contract for every exposed table and wire the relations - * they share. A relation whose target is not exposed stays unreachable, so - * enabling one table never widens another table's public surface. - */ +/** Compile the HTTP contract of every table, keyed by table name. */ export function compileCrudTables( tables: Record, ): Map { - const compiled = new Map(); - for (const table of Object.values(tables)) { - compiled.set(table.$name, compileTable(table)); - } - for (const table of Object.values(tables)) { - const entry = compiled.get(table.$name); - for (const relation of table.$relations) { - const target = compiled.get(relation.targetTable); - if (!entry || !target) continue; - entry.relations.set(relation.name, { - cardinality: relation.cardinality, - target, - }); - } - } - return compiled as Map; + return new Map( + Object.values(tables).map( + (table) => [table.$name, compileTable(table)] as const, + ), + ); } diff --git a/packages/appkit/src/plugins/database/crud/exposure.ts b/packages/appkit/src/plugins/database/crud/exposure.ts deleted file mode 100644 index d67183fbb..000000000 --- a/packages/appkit/src/plugins/database/crud/exposure.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { databaseSetupFailed } from "../../../database/errors"; -import type { DatabaseApiWriteOperation } from "../types"; - -/** A table name also becomes a URL path segment, so keep it unambiguous. */ -const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; -const WRITE_OPERATIONS: readonly DatabaseApiWriteOperation[] = [ - "create", - "update", - "delete", -]; - -/** Resolved generated routes for one plugin instance. */ -export interface CrudExposure { - readonly tables: readonly string[]; - readonly writes: ReadonlyMap>; -} - -/** Refuse names that cannot address exactly one table over HTTP. */ -function assertRoutable(names: readonly string[]): void { - const lowercased = new Map(); - for (const name of names) { - if (!ROUTABLE_TABLE.test(name)) { - throw databaseSetupFailed( - `Table ${JSON.stringify(name)} cannot be exposed through api. Route names must start with a letter, contain only letters, digits, "_", or "-", and be at most 64 characters. Rename the table, exclude it with api.tables, or set api: false.`, - ); - } - // Express matches paths case-insensitively, so near-duplicates would alias. - const previous = lowercased.get(name.toLowerCase()); - if (previous !== undefined) { - throw databaseSetupFailed( - `Tables ${JSON.stringify(previous)} and ${JSON.stringify(name)} conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false.`, - ); - } - lowercased.set(name.toLowerCase(), name); - } -} - -/** Validate a unique list drawn from an allowlist. */ -function requestedNames( - value: unknown, - allowed: readonly string[], - path: string, -): string[] { - if (!Array.isArray(value)) { - throw databaseSetupFailed(`${path} must be an array of names.`); - } - const names: string[] = []; - for (const name of value) { - if (typeof name !== "string") { - throw databaseSetupFailed(`${path} must contain only string names.`); - } - if (!allowed.includes(name)) { - throw databaseSetupFailed( - `${path} contains unsupported name ${JSON.stringify(name)}. Allowed names: ${allowed.map((entry) => JSON.stringify(entry)).join(", ") || "none"}.`, - ); - } - if (names.includes(name)) { - throw databaseSetupFailed( - `${path} contains duplicate name ${JSON.stringify(name)}. List each name once.`, - ); - } - names.push(name); - } - return names; -} - -/** Reject misspelled restrictions instead of silently enabling all routes. */ -function configuration( - value: unknown, - allowedKeys: readonly string[], - path: string, -): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw databaseSetupFailed( - `${path} must be true, false, or a configuration object.`, - ); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw databaseSetupFailed(`${path} must be a plain configuration object.`); - } - for (const key of Object.keys(value)) { - if (!allowedKeys.includes(key)) { - throw databaseSetupFailed( - `Unknown option ${JSON.stringify(`${path}.${key}`)}. Allowed options: ${allowedKeys.join(", ")}.`, - ); - } - } - return value as Record; -} - -/** Enable CRUD by default, applying only the restrictions the caller supplies. */ -export function resolveCrudExposure( - exposure: unknown, - declared: readonly string[], -): CrudExposure { - if (exposure === false) { - return { tables: [], writes: new Map() }; - } - - let tables: string[]; - let writeConfig: unknown; - if (exposure === undefined || exposure === true) { - tables = [...declared]; - } else { - const configured = configuration(exposure, ["tables", "writes"], "api"); - tables = - configured.tables === undefined - ? [...declared] - : requestedNames(configured.tables, declared, "api.tables"); - writeConfig = configured.writes; - } - assertRoutable(tables); - - const writes = new Map>(); - if (writeConfig === false) return { tables, writes }; - - let writeTables: string[]; - let operations: DatabaseApiWriteOperation[]; - if (writeConfig === undefined || writeConfig === true) { - writeTables = tables; - operations = [...WRITE_OPERATIONS]; - } else { - const configured = configuration( - writeConfig, - ["tables", "operations"], - "api.writes", - ); - writeTables = - configured.tables === undefined - ? tables - : requestedNames(configured.tables, tables, "api.writes.tables"); - operations = - configured.operations === undefined - ? [...WRITE_OPERATIONS] - : (requestedNames( - configured.operations, - WRITE_OPERATIONS, - "api.writes.operations", - ) as DatabaseApiWriteOperation[]); - } - - const enabled = new Set(operations); - for (const table of writeTables) writes.set(table, enabled); - return { tables, writes }; -} diff --git a/packages/appkit/src/plugins/database/crud/query.ts b/packages/appkit/src/plugins/database/crud/query.ts deleted file mode 100644 index db485245c..000000000 --- a/packages/appkit/src/plugins/database/crud/query.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { - DEFAULT_LIMIT, - IN_CAP, - isFilterOperator, - MAX_INCLUDE_DEPTH, - MAX_INCLUDE_NODES, - MAX_INCLUDES, - MAX_LIMIT, -} from "../../../database/contract"; -import { invalidDatabaseInput } from "../../../database/errors"; -import type { - IncludeOptions, - IncludeSpec, - OrderDirection, - OrderSpec, - ScalarValue, - WhereClause, - WhereValue, -} from "../../../database/runtime"; -import { filterOperatorsForKind } from "../../../database/schema-builder/types"; -import { - MAX_GROUP_ITEMS, - MAX_MATERIALIZED_NODES, - MAX_OFFSET, - MAX_ORDER_FIELDS, - MAX_QUERY_BYTES, - MAX_WHERE_CONDITIONS, - MAX_WHERE_DEPTH, -} from "../defaults"; -import type { CompiledColumn } from "./codecs"; -import type { CrudTable } from "./contract"; - -/** One decoded, budget-checked page request. */ -interface DecodedListQuery { - readonly where?: WhereClause; - readonly order?: OrderSpec; - readonly select?: string[]; - readonly include?: IncludeSpec; - readonly limit: number; - readonly offset: number; -} - -/** One decoded, budget-checked single-row request. */ -interface DecodedDetailQuery { - readonly select?: string[]; - readonly include?: IncludeSpec; -} - -const LIST_PARAMS = new Set([ - "where", - "order", - "select", - "include", - "limit", - "offset", -]); -const DETAIL_PARAMS = new Set(["select", "include"]); -const CANONICAL_INT = /^-?(0|[1-9]\d*)$/; - -/** - * Reject one query parameter. `parameter` is always a fixed name and `reason` - * a fixed sentence, so a rejection can never echo caller-supplied text back. - */ -function reject(parameter: string, reason: string): never { - throw invalidDatabaseInput([parameter], reason); -} - -/** A decoded JSON object; arrays and null are separate wire shapes here. */ -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Read known, single-occurrence parameters from the raw query string. */ -function parseParams( - rawQuery: string, - allowed: ReadonlySet, -): Map { - if (Buffer.byteLength(rawQuery, "utf8") > MAX_QUERY_BYTES) { - reject("query", "Query string exceeds the maximum size"); - } - const params = new Map(); - for (const [key, value] of new URLSearchParams(rawQuery)) { - if (!allowed.has(key) || params.has(key)) { - reject("query", "Unknown or repeated query parameter"); - } - params.set(key, value); - } - return params; -} - -/** Parse one structured parameter without leaking the parser's diagnostics. */ -function parseJson(raw: string, parameter: string): unknown { - try { - return JSON.parse(raw); - } catch { - reject(parameter, "Expected JSON"); - } -} - -/** Accept one bounded integer, rejecting the forms `Number` would coerce. */ -function decodeIntParam( - raw: string | undefined, - parameter: string, - fallback: number, - max: number, -): number { - if (raw === undefined) return fallback; - if (!CANONICAL_INT.test(raw)) { - reject(parameter, "Expected a canonical decimal integer"); - } - const value = Number(raw); - if (!Number.isSafeInteger(value) || value < 0 || value > max) { - reject(parameter, `Must be between 0 and ${max}`); - } - return value; -} - -/** Narrow a projection to the table's public columns. */ -function decodeSelect( - table: CrudTable, - value: unknown, - parameter: string, -): string[] { - if (!Array.isArray(value) || value.length === 0) { - reject(parameter, "Expected a non-empty array of public column names"); - } - for (const name of value) { - if (typeof name !== "string" || !table.selectable.has(name)) { - reject(parameter, "Names an unknown or private column"); - } - } - return value as string[]; -} - -/** Order only by columns the table can sort on, in a declared direction. */ -function decodeOrder( - table: CrudTable, - value: unknown, - parameter: string, -): OrderSpec { - if (!isPlainObject(value)) { - reject(parameter, "Expected an object of column directions"); - } - const entries = Object.entries(value); - if (entries.length === 0 || entries.length > MAX_ORDER_FIELDS) { - reject(parameter, `Expected 1 to ${MAX_ORDER_FIELDS} columns`); - } - const order: Record = {}; - for (const [column, direction] of entries) { - if (!table.queryable.has(column)) { - reject(parameter, "Names an unknown or unorderable column"); - } - if (direction !== "asc" && direction !== "desc") { - reject(parameter, "Expected a direction of asc or desc"); - } - order[column] = direction; - } - return order; -} - -/** Move one filter operand onto its column's canonical runtime value. */ -function decodeOperand( - column: CompiledColumn, - raw: unknown, - parameter: string, -): ScalarValue { - const value = column.decode(raw); - if (value === undefined) - reject(parameter, "Operand does not match its column type"); - return value; -} - -/** - * Decode the predicate on one column against the operator matrix its kind - * allows, counting conditions so a filter cannot grow without bound. - */ -function decodeCondition( - column: CompiledColumn, - value: unknown, - parameter: string, - state: { conditions: number }, -): WhereValue { - const countCondition = () => { - state.conditions += 1; - if (state.conditions > MAX_WHERE_CONDITIONS) { - reject(parameter, `Expected at most ${MAX_WHERE_CONDITIONS} conditions`); - } - }; - - if (value === null) { - // SQL three-valued matching stays explicit; a bare null is ambiguous. - reject(parameter, "Match a null column with is: null"); - } - if (!isPlainObject(value)) { - if (Array.isArray(value)) { - reject(parameter, "Expected a scalar or an operator object"); - } - countCondition(); - return decodeOperand(column, value, parameter); - } - - const nullable = !column.meta.notNull; - const supported = filterOperatorsForKind(column.meta.kind); - const operators: Record = {}; - const entries = Object.entries(value); - if (entries.length === 0) reject(parameter, "Filter cannot be empty"); - - for (const [operator, operand] of entries) { - countCondition(); - if (operator === "is") { - if (operand !== null || !nullable) { - reject( - parameter, - "The is operator accepts only null on a nullable column", - ); - } - operators.is = null; - continue; - } - if (!isFilterOperator(operator) || !supported.includes(operator)) { - reject(parameter, "Operator is not supported for this column"); - } - if (operator !== "in") { - operators[operator] = decodeOperand(column, operand, parameter); - continue; - } - if (!Array.isArray(operand) || operand.length > IN_CAP) { - reject(parameter, `Expected an array of at most ${IN_CAP} values`); - } - operators.in = operand.map((item) => { - if (item === null) - reject(parameter, "The in operator does not accept null"); - return decodeOperand(column, item, parameter); - }); - } - return operators as WhereValue; -} - -/** Decode one filter level, recursing through bounded `and`/`or` groups. */ -function decodeWhere( - table: CrudTable, - node: unknown, - depth: number, - parameter: string, - state: { conditions: number }, -): WhereClause { - if (depth > MAX_WHERE_DEPTH) { - reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`); - } - if (!isPlainObject(node)) reject(parameter, "Expected an object"); - if (Object.keys(node).length === 0) { - reject(parameter, "Filter cannot be empty"); - } - - const clause: Record = {}; - for (const [key, value] of Object.entries(node)) { - if (key === "and" || key === "or") { - if ( - !Array.isArray(value) || - value.length === 0 || - value.length > MAX_GROUP_ITEMS - ) { - reject(parameter, `Expected 1 to ${MAX_GROUP_ITEMS} group members`); - } - clause[key] = value.map((member) => - decodeWhere(table, member, depth + 1, parameter, state), - ); - continue; - } - // Relations are absent from `queryable`, so relation predicates fail here. - const column = table.queryable.has(key) - ? table.columns.get(key) - : undefined; - if (!column) reject(parameter, "Names an unknown or unfilterable column"); - clause[key] = decodeCondition(column, value, parameter, state); - } - return clause; -} - -/** - * Decode one relation's options against the target table, not the parent, and - * give every to-many edge a limit so an unqualified include stays bounded. - */ -function decodeIncludeOptions( - target: CrudTable, - value: unknown, - depth: number, - toMany: boolean, - parameter: string, -): boolean | IncludeOptions { - if (value === true) return toMany ? { limit: DEFAULT_LIMIT } : true; - if (!isPlainObject(value)) { - reject(parameter, "Expected true or an options object"); - } - - const options: { - select?: string[]; - where?: WhereClause; - order?: OrderSpec; - limit?: number; - include?: IncludeSpec; - } = {}; - for (const [key, inner] of Object.entries(value)) { - switch (key) { - case "select": - options.select = decodeSelect(target, inner, parameter); - break; - case "where": - options.where = decodeWhere(target, inner, 1, parameter, { - conditions: 0, - }); - break; - case "order": - options.order = decodeOrder(target, inner, parameter); - break; - case "limit": - if (!toMany) reject(parameter, "Only to-many relations accept a limit"); - if ( - typeof inner !== "number" || - !Number.isSafeInteger(inner) || - inner < 0 || - inner > MAX_LIMIT - ) { - reject(parameter, `Expected a limit between 0 and ${MAX_LIMIT}`); - } - options.limit = inner; - break; - case "include": - options.include = decodeInclude(target, inner, depth + 1, parameter); - break; - default: - reject(parameter, "Unsupported relation option"); - } - } - if (toMany && options.limit === undefined) options.limit = DEFAULT_LIMIT; - return options; -} - -/** Decode one include level; a relation to an unexposed table has no edge. */ -function decodeInclude( - table: CrudTable, - value: unknown, - depth: number, - parameter: string, -): IncludeSpec { - if (depth > MAX_INCLUDE_DEPTH) { - reject(parameter, `Expected at most ${MAX_INCLUDE_DEPTH} relation edges`); - } - if (!isPlainObject(value)) { - reject(parameter, "Expected an object of relation names"); - } - const entries = Object.entries(value); - if (entries.length > MAX_INCLUDES) { - reject(parameter, `Expected at most ${MAX_INCLUDES} relations per level`); - } - - const include: Record = {}; - for (const [name, options] of entries) { - const relation = table.relations.get(name); - if (!relation) reject(parameter, "Names an unknown or unexposed relation"); - include[name] = decodeIncludeOptions( - relation.target, - options, - depth, - relation.cardinality === "toMany", - parameter, - ); - } - return include; -} - -/** - * Rows one include tree materializes per parent row, counting relation nodes - * on the way. Every term is a non-negative integer bounded by `MAX_LIMIT` and - * both running totals are checked after each addition, so neither can overflow. - */ -function measureInclude( - table: CrudTable, - include: IncludeSpec | undefined, - budget: { nodes: number }, -): number { - let rows = 1; - if (!include) return rows; - for (const [name, value] of Object.entries(include)) { - const relation = table.relations.get(name); - if (!relation) continue; - budget.nodes += 1; - if (budget.nodes > MAX_INCLUDE_NODES) { - reject("include", `Expected at most ${MAX_INCLUDE_NODES} relations`); - } - const options = value === true ? undefined : (value as IncludeOptions); - const child = measureInclude(relation.target, options?.include, budget); - rows += - relation.cardinality === "toMany" - ? (options?.limit ?? DEFAULT_LIMIT) * child - : child; - if (rows > MAX_MATERIALIZED_NODES) { - reject("include", "Relation fan-out exceeds the read budget"); - } - } - return rows; -} - -/** Reject an include tree whose cost is only visible once it is assembled. */ -function assertReadBudget( - table: CrudTable, - include: IncludeSpec | undefined, - rootRows: number, -): void { - const rows = measureInclude(table, include, { nodes: 0 }); - if (rootRows * rows > MAX_MATERIALIZED_NODES) { - reject("include", "Relation fan-out exceeds the read budget"); - } -} - -/** Decode the projection parameters both reads share. */ -function decodeProjection( - table: CrudTable, - params: ReadonlyMap, -): DecodedDetailQuery { - const rawSelect = params.get("select"); - const rawInclude = params.get("include"); - return { - select: - rawSelect === undefined - ? undefined - : decodeSelect(table, parseJson(rawSelect, "select"), "select"), - include: - rawInclude === undefined - ? undefined - : decodeInclude(table, parseJson(rawInclude, "include"), 1, "include"), - }; -} - -/** Decode `GET /:table` and reject it before any database work is scheduled. */ -export function decodeListQuery( - table: CrudTable, - rawQuery: string, -): DecodedListQuery { - const params = parseParams(rawQuery, LIST_PARAMS); - const rawWhere = params.get("where"); - const rawOrder = params.get("order"); - - const where = - rawWhere === undefined - ? undefined - : decodeWhere(table, parseJson(rawWhere, "where"), 1, "where", { - conditions: 0, - }); - const order = - rawOrder === undefined - ? undefined - : decodeOrder(table, parseJson(rawOrder, "order"), "order"); - const { select, include } = decodeProjection(table, params); - const limit = decodeIntParam( - params.get("limit"), - "limit", - DEFAULT_LIMIT, - MAX_LIMIT, - ); - const offset = decodeIntParam(params.get("offset"), "offset", 0, MAX_OFFSET); - - assertReadBudget(table, include, limit); - return { where, order, select, include, limit, offset }; -} - -/** Decode `GET /:table/:id`, which supports projection and includes only. */ -export function decodeDetailQuery( - table: CrudTable, - rawQuery: string, -): DecodedDetailQuery { - const decoded = decodeProjection(table, parseParams(rawQuery, DETAIL_PARAMS)); - assertReadBudget(table, decoded.include, 1); - return decoded; -} diff --git a/packages/appkit/src/plugins/database/crud/request.ts b/packages/appkit/src/plugins/database/crud/request.ts index 39336de6c..619755594 100644 --- a/packages/appkit/src/plugins/database/crud/request.ts +++ b/packages/appkit/src/plugins/database/crud/request.ts @@ -1,29 +1,8 @@ -import { - DatabasePluginError, - invalidDatabaseInput, -} from "../../../database/errors"; -import type { IdValue, Row, ScalarValue } from "../../../database/runtime"; +import { invalidDatabaseInput } from "../../../database/errors"; +import type { Row, ScalarValue } from "../../../database/runtime"; import type { CompiledColumn, JsonValue } from "./codecs"; import { boundedJson, type CrudTable, isPlainObject } from "./contract"; -/** - * Decode a path identifier against the declared key type. A keyless table gets - * no `/:id` route, so arriving here without a key is a wiring fault, not input. - */ -export function decodeId(table: CrudTable, raw: string): IdValue { - const { primaryKey } = table; - if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read"); - const value = primaryKey.decode(raw); - if ( - typeof value !== "string" && - typeof value !== "number" && - typeof value !== "bigint" - ) { - throw invalidDatabaseInput(["id"], "Not a valid identifier"); - } - return value; -} - /** Map one body value onto its column; `undefined` when it does not fit. */ function decodeWriteValue( column: CompiledColumn, @@ -67,12 +46,12 @@ function decodeBody( return values; } -/** Decode the body of `POST /:table`, which may carry a caller-chosen key. */ +/** Decode a create body, which may carry a caller-chosen key. */ export function decodeCreateBody(table: CrudTable, raw: unknown): Row { return decodeBody(table, table.creatable, raw); } -/** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */ +/** Decode an update body, which may not carry a key or stamp. */ export function decodeUpdateBody(table: CrudTable, raw: unknown): Row { return decodeBody(table, table.updatable, raw); } diff --git a/packages/appkit/src/plugins/database/crud/response.ts b/packages/appkit/src/plugins/database/crud/response.ts index 943616d17..e6d77f118 100644 --- a/packages/appkit/src/plugins/database/crud/response.ts +++ b/packages/appkit/src/plugins/database/crud/response.ts @@ -1,27 +1,8 @@ import type { Response } from "express"; -import { - classifyDatabaseError, - type DatabaseErrorDetail, - DatabasePluginError, -} from "../../../database/errors"; -import type { Row } from "../../../database/runtime"; -import { DatabaseValidationError } from "../../../errors"; +import { DatabasePluginError } from "../../../database/errors"; import { MAX_RESPONSE_BYTES } from "../defaults"; import type { JsonValue } from "./codecs"; -import type { CrudTable } from "./contract"; - -/** Low-cardinality span outcome for one failed generated route. */ -export function routeOutcome( - error: unknown, -): "not_found" | "rejected" | "failed" { - const statusCode = - error instanceof DatabaseValidationError - ? error.statusCode - : classifyDatabaseError(error, "read").statusCode; - if (statusCode === 404) return "not_found"; - return statusCode < 500 ? "rejected" : "failed"; -} /** * Row data is never cacheable by a shared proxy or a browser: the same URL can @@ -43,87 +24,9 @@ export function sendJson(res: Response, status: number, body: JsonValue): void { writeJson(res, status, payload); } -/** - * Encode the list envelope one row at a time, charging each encoded row - * against the byte budget before the next row is shaped, so one response - * never costs more than the budget in memory. - */ -export function sendListPage( - res: Response, - rows: readonly Row[], - encodeRow: (row: Row) => JsonValue, - limit: number, - offset: number, -): void { - const prefix = '{"items":['; - const suffix = `],"limit":${limit},"offset":${offset}}`; - let bytes = - Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8"); - const items: string[] = []; - for (const row of rows) { - const encoded = JSON.stringify(encodeRow(row)); - bytes += Buffer.byteLength(encoded, "utf8") + (items.length > 0 ? 1 : 0); - if (bytes > MAX_RESPONSE_BYTES) { - throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read"); - } - items.push(encoded); - } - writeJson(res, 200, prefix + items.join(",") + suffix); -} - /** A `204` carries no body but owes the same cache promise as one that does. */ export function sendEmpty(res: Response, status: number): void { res.status(status); res.setHeader("Cache-Control", "no-store"); res.send(); } - -/** - * Convert a failure into its safe category. A deliberate validation error is - * the one signal that reaches the caller, and only through the issues naming a - * public column of this table. - */ -function safeError( - table: CrudTable, - phase: "read" | "write", - error: unknown, -): DatabasePluginError { - if (!(error instanceof DatabaseValidationError)) { - return classifyDatabaseError(error, phase); - } - const details = error.issues - .filter( - (issue) => issue.path.length > 0 && table.selectable.has(issue.path[0]), - ) - .map((issue) => ({ path: [...issue.path], message: issue.message })); - return new DatabasePluginError( - "VALIDATION_FAILED", - phase, - undefined, - details, - ); -} - -/** Answer with the failure's safe category and the field it concerns. */ -export function sendError( - res: Response, - table: CrudTable, - phase: "read" | "write", - error: unknown, -): void { - if (res.headersSent) return; - const safe = safeError(table, phase, error); - const body: { error: string; details?: readonly DatabaseErrorDetail[] } = { - error: safe.clientMessage, - }; - if (safe.details && safe.details.length > 0) body.details = safe.details; - const payload = JSON.stringify(body); - // A failure owes the same byte budget, and its details are what can grow. - writeJson( - res, - safe.statusCode, - Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES - ? JSON.stringify({ error: safe.clientMessage }) - : payload, - ); -} diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts deleted file mode 100644 index cc164d4de..000000000 --- a/packages/appkit/src/plugins/database/crud/routes.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { Request, Response } from "express"; - -import { - DatabasePluginError, - invalidDatabaseInput, -} from "../../../database/errors"; -import type { - IdValue, - IncludeSpec, - OrderSpec, - Row, - WhereClause, -} from "../../../database/runtime"; -import type { CrudTable } from "./contract"; -import { decodeDetailQuery, decodeListQuery } from "./query"; -import { decodeCreateBody, decodeId, decodeUpdateBody } from "./request"; -import { sendEmpty, sendError, sendJson, sendListPage } from "./response"; - -/** The `EntityClient` subset the generated routes drive. */ -export interface CrudEntity { - where(where: WhereClause): CrudEntity; - order(order: OrderSpec): CrudEntity; - select(columns: string[]): CrudEntity; - include(include: IncludeSpec): CrudEntity; - limit(limit: number): CrudEntity; - offset(offset: number): CrudEntity; - toArray(): Promise; - find(id: IdValue): Promise; - create(values: Row): Promise; - update(id: IdValue, values: Row): Promise; - delete(id: IdValue): Promise; -} - -/** Which generated operation a span and its route belong to. */ -export type CrudOperation = "list" | "detail" | "create" | "update" | "delete"; - -/** Everything one table's generated routes need from the plugin instance. */ -export interface CrudRouteDeps { - readonly table: CrudTable; - /** Resolved per request so a draining plugin cannot serve a stale client. */ - entity(): CrudEntity; - runRouteSpan( - operation: CrudOperation, - route: string, - run: () => Promise, - ): Promise; -} - -type RouteHandler = (req: Request, res: Response) => Promise; - -/** Express normalizes `req.query`; the decoders need the untouched string. */ -function rawQuery(req: Request): string { - const url = req.originalUrl ?? req.url; - const start = url.indexOf("?"); - return start === -1 ? "" : url.slice(start + 1); -} - -/** - * Append the primary key so equal sort keys cannot reshuffle between pages. - * A keyless table has no unique tie-breaker to append, so it must order itself - * and its pages stay stable only while no concurrent write reorders them. - */ -function stableOrder( - primaryKey: string | undefined, - order: OrderSpec | undefined, -): OrderSpec { - if (primaryKey) - return { ...order, [primaryKey]: order?.[primaryKey] ?? "asc" }; - if (!order) { - throw invalidDatabaseInput( - ["order"], - "A table without a primary key requires an explicit order", - ); - } - return order; -} - -/** - * A write takes its whole input from the path and the body. A query string - * would silently look like a filter, so it is refused rather than ignored. - */ -function assertNoQuery(req: Request): void { - if (rawQuery(req) !== "") { - throw invalidDatabaseInput(["query"], "Writes accept no query parameters"); - } -} - -/** The body parser only ran if the caller declared JSON. */ -function assertJsonBody(req: Request): void { - if (!req.is("application/json")) { - throw new DatabasePluginError("UNSUPPORTED_MEDIA_TYPE", "write"); - } -} - -/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */ -export function createListHandler(deps: CrudRouteDeps): RouteHandler { - const primaryKey = deps.table.primaryKey?.meta.columnName; - const route = `/${deps.table.name}`; - - return async (req, res) => { - try { - await deps.runRouteSpan("list", route, async () => { - const decoded = decodeListQuery(deps.table, rawQuery(req)); - let query = deps - .entity() - .order(stableOrder(primaryKey, decoded.order)) - .limit(decoded.limit) - .offset(decoded.offset); - if (decoded.where) query = query.where(decoded.where); - if (decoded.select) query = query.select(decoded.select); - if (decoded.include) query = query.include(decoded.include); - - const rows = await query.toArray(); - sendListPage( - res, - rows, - (row) => deps.table.projectPublicRow(row), - decoded.limit, - decoded.offset, - ); - }); - } catch (error) { - sendError(res, deps.table, "read", error); - } - }; -} - -/** `GET /:table/:id` — one public row, or 404 when nothing matches. */ -export function createDetailHandler(deps: CrudRouteDeps): RouteHandler { - const route = `/${deps.table.name}/:id`; - - return async (req, res) => { - try { - await deps.runRouteSpan("detail", route, async () => { - const id = decodeId(deps.table, req.params.id); - const decoded = decodeDetailQuery(deps.table, rawQuery(req)); - let query = deps.entity(); - if (decoded.select) query = query.select(decoded.select); - if (decoded.include) query = query.include(decoded.include); - - const row = await query.find(id); - if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); - sendJson(res, 200, deps.table.projectPublicRow(row)); - }); - } catch (error) { - sendError(res, deps.table, "read", error); - } - }; -} - -/** `POST /:table` — the created row at `201`. */ -export function createCreateHandler(deps: CrudRouteDeps): RouteHandler { - const route = `/${deps.table.name}`; - - return async (req, res) => { - try { - await deps.runRouteSpan("create", route, async () => { - assertNoQuery(req); - assertJsonBody(req); - const values = decodeCreateBody(deps.table, req.body); - const row = await deps.entity().create(values); - sendJson(res, 201, deps.table.projectPublicRow(row)); - }); - } catch (error) { - sendError(res, deps.table, "write", error); - } - }; -} - -/** `PATCH /:table/:id` — the updated row at `200`, or 404 when it is gone. */ -export function createUpdateHandler(deps: CrudRouteDeps): RouteHandler { - const route = `/${deps.table.name}/:id`; - - return async (req, res) => { - try { - await deps.runRouteSpan("update", route, async () => { - assertNoQuery(req); - assertJsonBody(req); - const id = decodeId(deps.table, req.params.id); - const values = decodeUpdateBody(deps.table, req.body); - const row = await deps.entity().update(id, values); - if (row === null) throw new DatabasePluginError("NOT_FOUND", "write"); - sendJson(res, 200, deps.table.projectPublicRow(row)); - }); - } catch (error) { - sendError(res, deps.table, "write", error); - } - }; -} - -/** `DELETE /:table/:id` — `204` with no body, or 404 when nothing matched. */ -export function createDeleteHandler(deps: CrudRouteDeps): RouteHandler { - const route = `/${deps.table.name}/:id`; - - return async (req, res) => { - try { - await deps.runRouteSpan("delete", route, async () => { - assertNoQuery(req); - const id = decodeId(deps.table, req.params.id); - const deleted = await deps.entity().delete(id); - if (!deleted) throw new DatabasePluginError("NOT_FOUND", "write"); - sendEmpty(res, 204); - }); - } catch (error) { - sendError(res, deps.table, "write", error); - } - }; -} diff --git a/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts index 52b27c562..c2b773083 100644 --- a/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts @@ -62,7 +62,6 @@ describe("columnHttpCapabilities", () => { expect(columnHttpCapabilities(schema.$tables.users.$columns.token)).toEqual( { selectable: false, - queryable: false, creatable: false, updatable: false, publicKey: false, @@ -96,7 +95,6 @@ describe("columnHttpCapabilities", () => { it("separates generated identities, caller keys, and stamps", () => { const { users, invites, sessions } = schema.$tables; expect(columnHttpCapabilities(users.$columns.id)).toMatchObject({ - queryable: true, creatable: false, publicKey: true, }); @@ -119,7 +117,6 @@ describe("columnHttpCapabilities", () => { }); expect(columnHttpCapabilities(users.$columns.profile)).toMatchObject({ selectable: true, - queryable: false, updatable: true, }); }); @@ -138,8 +135,6 @@ describe("HTTP capability parity", () => { expect(facetKeys(api.insert)).toEqual([...table.creatable]); expect(facetKeys(api.update)).toEqual([...table.updatable]); - expect(facetKeys(api.filters)).toEqual([...table.queryable]); - expect(unionMembers(api.orderable)).toEqual([...table.queryable]); expect(unionMembers(api.key)).toEqual( table.primaryKey ? [table.primaryKey.meta.columnName] : [], ); @@ -155,6 +150,5 @@ describe("HTTP capability parity", () => { Object.values(entries.get("audits")?.api ?? {}).join("\n"), ).not.toContain('"id"'); expect(entries.get("audits")?.api.key).toBe("never"); - expect(entries.get("blobs")?.api.orderable).toBe("never"); }); }); diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts index dfce58109..fb92bd635 100644 --- a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -49,24 +49,11 @@ const sessions = tables.get("sessions") as NonNullable< >; describe("compileCrudTables", () => { - it("allowlists public columns and excludes unfilterable kinds", () => { + it("allowlists public columns and keeps private ones server-side", () => { expect([...users.selectable]).toEqual(["id", "name", "profile"]); - expect([...users.queryable]).toEqual(["id", "name"]); expect(users.columns.has("token")).toBe(true); expect(users.primaryKey?.meta.columnName).toBe("id"); - }); - - it("wires relations only between exposed tables", () => { - expect(users.relations.get("notes")).toMatchObject({ - cardinality: "toMany", - target: notes, - }); - expect(notes.relations.get("users")).toMatchObject({ - cardinality: "toOne", - target: users, - }); - const isolated = compileCrudTables({ notes: schema.$tables.notes }); - expect(isolated.get("notes")?.relations.size).toBe(0); + expect([...notes.selectable]).toEqual(["id", "authorId", "body"]); }); it("treats a private primary key as no key over HTTP", () => { @@ -102,32 +89,6 @@ describe("write allowlists", () => { }); }); -describe("projectPublicRow", () => { - it("drops private columns at every level and encodes relations", () => { - const projected = users.projectPublicRow({ - id: 1, - name: "Ada", - token: "secret", - profile: { theme: "dark" }, - notes: [{ id: 2, body: "hello", draft: "hidden" }], - unknown: "ignored", - }); - expect(projected).toEqual({ - id: 1, - name: "Ada", - profile: { theme: "dark" }, - notes: [{ id: 2, body: "hello" }], - }); - }); - - it("represents an absent to-one relation as null", () => { - expect(notes.projectPublicRow({ id: 1, users: null })).toEqual({ - id: 1, - users: null, - }); - }); -}); - describe("boundedJson", () => { it("keeps finite JSON and drops undefined object entries", () => { expect( diff --git a/packages/appkit/src/plugins/database/crud/tests/exposure.test.ts b/packages/appkit/src/plugins/database/crud/tests/exposure.test.ts deleted file mode 100644 index 80646eede..000000000 --- a/packages/appkit/src/plugins/database/crud/tests/exposure.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { DatabasePluginError } from "../../../../database/errors"; -import { resolveCrudExposure } from "../exposure"; - -const tables = ["notes", "audits"]; - -describe("generated API configuration diagnostics", () => { - test.each([ - [null, "api must be true, false, or a configuration object"], - [[], "api must be true, false, or a configuration object"], - ["false", "api must be true, false, or a configuration object"], - [new Date(), "api must be a plain configuration object"], - [ - { write: false }, - 'Unknown option "api.write". Allowed options: tables, writes', - ], - [{ tables: "notes" }, "api.tables must be an array of names"], - [{ tables: [1] }, "api.tables must contain only string names"], - [ - { tables: ["missing"] }, - 'api.tables contains unsupported name "missing". Allowed names: "notes", "audits"', - ], - [ - { tables: ["notes", "notes"] }, - 'api.tables contains duplicate name "notes"', - ], - [ - { writes: null }, - "api.writes must be true, false, or a configuration object", - ], - [{ writes: new Date() }, "api.writes must be a plain configuration object"], - [ - { writes: { operation: [] } }, - 'Unknown option "api.writes.operation". Allowed options: tables, operations', - ], - [ - { writes: { tables: false } }, - "api.writes.tables must be an array of names", - ], - [ - { tables: ["notes"], writes: { tables: ["audits"] } }, - 'api.writes.tables contains unsupported name "audits". Allowed names: "notes"', - ], - [ - { writes: { operations: "create" } }, - "api.writes.operations must be an array of names", - ], - [ - { writes: { operations: ["upsert"] } }, - 'api.writes.operations contains unsupported name "upsert". Allowed names: "create", "update", "delete"', - ], - [ - { writes: { operations: ["create", "create"] } }, - 'api.writes.operations contains duplicate name "create"', - ], - ])( - "explains invalid api=%j without changing the client error", - (api, message) => { - let caught: unknown; - try { - resolveCrudExposure(api, tables); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(DatabasePluginError); - expect(caught).toMatchObject({ - category: "SETUP_FAILED", - phase: "setup", - message: expect.stringContaining(message), - clientMessage: "Database setup failed", - details: undefined, - }); - }, - ); - - test.each([undefined, true, {}])( - "explains invalid table names with api=%j", - (api) => { - expect(() => resolveCrudExposure(api, ["_events"])).toThrow( - 'Table "_events" cannot be exposed through api', - ); - expect(() => resolveCrudExposure(api, ["_events"])).toThrow( - "Rename the table, exclude it with api.tables, or set api: false", - ); - }, - ); - - test("names both conflicting tables and how to fix them", () => { - expect(() => resolveCrudExposure(undefined, ["notes", "Notes"])).toThrow( - 'Tables "notes" and "Notes" conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false', - ); - }); - - test("accepts route names at the documented boundary", () => { - const declared = ["a", "Notes_2026-archive", "n".repeat(64)]; - expect(resolveCrudExposure(undefined, declared).tables).toEqual(declared); - expect(() => resolveCrudExposure(undefined, ["n".repeat(65)])).toThrow( - "at most 64 characters", - ); - }); - - test("accepts null-prototype configuration objects", () => { - const api = Object.assign(Object.create(null), { - writes: Object.assign(Object.create(null), { operations: ["create"] }), - }); - const exposure = resolveCrudExposure(api, tables); - expect(exposure.tables).toEqual(tables); - expect([...exposure.writes.entries()]).toEqual([ - ["notes", new Set(["create"])], - ["audits", new Set(["create"])], - ]); - }); -}); diff --git a/packages/appkit/src/plugins/database/crud/tests/query.test.ts b/packages/appkit/src/plugins/database/crud/tests/query.test.ts deleted file mode 100644 index 82e7c8875..000000000 --- a/packages/appkit/src/plugins/database/crud/tests/query.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - DEFAULT_LIMIT, - IN_CAP, - MAX_LIMIT, -} from "../../../../database/contract"; -import { DatabasePluginError } from "../../../../database/errors"; -import { - boolean, - type ColumnRef, - defineSchema, - fk, - id, - integer, - jsonb, - type SchemaBuilderContext, - text, - timestamp, -} from "../../../../database/schema-builder"; -import { MAX_OFFSET, MAX_QUERY_BYTES } from "../../defaults"; -import { type CrudTable, compileCrudTables } from "../contract"; -import { decodeDetailQuery, decodeListQuery } from "../query"; - -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - name: text(), - rank: integer(), - active: boolean().notNull(), - createdAt: timestamp(), - profile: jsonb(), - token: text().private(), - }); - const notes = builder.table("notes", { - id: id(), - authorId: fk(() => users.id), - body: text(), - }); - return { users, notes }; -}); - -const tables = compileCrudTables(schema.$tables); -const users = tables.get("users") as CrudTable; - -function query(params: Record): string { - const encoded = Object.entries(params).map( - ([key, value]): [string, string] => [ - key, - typeof value === "string" ? value : JSON.stringify(value), - ], - ); - return new URLSearchParams(encoded).toString(); -} - -function expectRejected(raw: string): void { - expect(() => decodeListQuery(users, raw)).toThrow(DatabasePluginError); - expect(() => decodeListQuery(users, raw)).toThrow( - expect.objectContaining({ category: "INVALID_REQUEST", statusCode: 400 }), - ); -} - -describe("query parameters", () => { - it("defaults pagination and accepts canonical integers", () => { - expect(decodeListQuery(users, "")).toEqual({ - where: undefined, - order: undefined, - select: undefined, - include: undefined, - limit: DEFAULT_LIMIT, - offset: 0, - }); - expect(decodeListQuery(users, "limit=10&offset=20")).toMatchObject({ - limit: 10, - offset: 20, - }); - }); - - it("rejects unknown, repeated, oversized, and non-canonical parameters", () => { - expectRejected("unknown=1"); - // A generated list is one page query, so there is no total to request. - expectRejected("includeTotal=true"); - expectRejected("limit=1&limit=2"); - expectRejected(`select=${"x".repeat(MAX_QUERY_BYTES)}`); - expectRejected("limit=01"); - expectRejected("limit=-1"); - expectRejected("limit=1.0"); - expectRejected(`limit=${MAX_LIMIT + 1}`); - expectRejected(`offset=${MAX_OFFSET + 1}`); - expectRejected("where=notjson"); - }); - - it("keeps detail requests to projection and includes", () => { - expect(decodeDetailQuery(users, query({ select: ["id"] }))).toEqual({ - select: ["id"], - include: undefined, - }); - expect(() => decodeDetailQuery(users, query({ where: { id: 1 } }))).toThrow( - DatabasePluginError, - ); - expect(() => decodeDetailQuery(users, "limit=1")).toThrow( - DatabasePluginError, - ); - }); -}); - -describe("select and order", () => { - it("accepts public columns only", () => { - expect( - decodeListQuery(users, query({ select: ["id", "name"] })).select, - ).toEqual(["id", "name"]); - expect( - decodeListQuery(users, query({ order: { name: "desc" } })).order, - ).toEqual({ name: "desc" }); - }); - - it("rejects private, unknown, empty, and unorderable selections", () => { - expectRejected(query({ select: ["token"] })); - expectRejected(query({ select: ["missing"] })); - expectRejected(query({ select: [] })); - expectRejected(query({ select: "id" })); - expectRejected(query({ order: { token: "asc" } })); - expectRejected(query({ order: { profile: "asc" } })); - expectRejected(query({ order: { name: "sideways" } })); - expectRejected(query({ order: {} })); - }); -}); - -describe("where", () => { - it("decodes operands through their column codec", () => { - expect( - decodeListQuery(users, query({ where: { rank: "3" } })).where, - ).toEqual({ rank: 3 }); - expect( - decodeListQuery(users, query({ where: { name: { ilike: "a%" } } })).where, - ).toEqual({ name: { ilike: "a%" } }); - expect( - decodeListQuery(users, query({ where: { name: { is: null } } })).where, - ).toEqual({ name: { is: null } }); - expect( - decodeListQuery( - users, - query({ - where: { or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }, - }), - ).where, - ).toEqual({ or: [{ rank: { gte: 1 } }, { id: { in: [1, 2] } }] }); - }); - - it("applies one operator matrix per column kind", () => { - expectRejected(query({ where: { name: { gt: "a" } } })); - expectRejected(query({ where: { rank: { like: "1" } } })); - expectRejected(query({ where: { profile: { eq: {} } } })); - expectRejected(query({ where: { rank: "abc" } })); - expectRejected(query({ where: { createdAt: { gt: "yesterday" } } })); - }); - - it("keeps null matching explicit and bounds in lists", () => { - // An empty set is a legal filter that the engine renders as no match. - expect( - decodeListQuery(users, query({ where: { id: { in: [] } } })).where, - ).toEqual({ id: { in: [] } }); - expectRejected(query({ where: { name: null } })); - expectRejected(query({ where: { name: { eq: null } } })); - expectRejected(query({ where: { active: { is: null } } })); - expectRejected(query({ where: { name: { in: ["a", null] } } })); - expectRejected( - query({ - where: { id: { in: Array.from({ length: IN_CAP + 1 }, (_, i) => i) } }, - }), - ); - }); - - it("rejects unknown columns, relations, and empty filters", () => { - expectRejected(query({ where: { missing: 1 } })); - expectRejected(query({ where: { token: "secret" } })); - expectRejected(query({ where: { notes: { some: { body: "a" } } } })); - expectRejected(query({ where: { name: {} } })); - expectRejected(query({ where: { and: [] } })); - expectRejected(query({ where: [] })); - }); - - it("bounds nesting, group size, and total conditions", () => { - let deep: Record = { rank: 1 }; - for (let level = 0; level < 5; level += 1) deep = { and: [deep] }; - expectRejected(query({ where: deep })); - expectRejected( - query({ where: { or: Array.from({ length: 21 }, () => ({ rank: 1 })) } }), - ); - expectRejected( - query({ - where: { - and: Array.from({ length: 20 }, () => ({ - rank: { gt: 1, lt: 5, gte: 1 }, - })), - }, - }), - ); - }); -}); - -describe("include", () => { - it("bounds to-many relations and carries a second edge", () => { - expect( - decodeListQuery(users, query({ include: { notes: true } })).include, - ).toEqual({ notes: { limit: DEFAULT_LIMIT } }); - expect( - decodeListQuery( - users, - query({ - include: { - notes: { select: ["body"], limit: 5, include: { users: true } }, - }, - }), - ).include, - ).toEqual({ - notes: { select: ["body"], limit: 5, include: { users: true } }, - }); - }); - - it("rejects a third edge, unknown relations, and unsupported options", () => { - expectRejected( - query({ include: { notes: { include: { users: { include: {} } } } } }), - ); - expectRejected(query({ include: { missing: true } })); - expectRejected(query({ include: { notes: false } })); - expectRejected(query({ include: { notes: { offset: 1 } } })); - expectRejected(query({ include: { notes: { limit: MAX_LIMIT + 1 } } })); - expectRejected(query({ include: { notes: { select: ["missing"] } } })); - }); - - it("limits only to-many relations and scopes options to the target", () => { - const notes = tables.get("notes") as CrudTable; - expect(() => - decodeListQuery(notes, query({ include: { users: { limit: 1 } } })), - ).toThrow(DatabasePluginError); - expect( - decodeListQuery( - notes, - query({ include: { users: { where: { rank: 1 } } } }), - ).include, - ).toEqual({ users: { where: { rank: 1 } } }); - expect(() => - decodeListQuery( - notes, - query({ include: { users: { where: { body: "a" } } } }), - ), - ).toThrow(DatabasePluginError); - }); -}); - -describe("read budget", () => { - it("rejects fan-out before the entity terminal runs", () => { - expectRejected( - query({ limit: "500", include: { notes: { limit: MAX_LIMIT } } }), - ); - expectRejected( - query({ - limit: "50", - include: { notes: { limit: 100, include: { users: true } } }, - }), - ); - expect( - decodeListQuery( - users, - query({ - limit: "49", - include: { notes: { limit: 100, include: { users: true } } }, - }), - ).limit, - ).toBe(49); - }); - - it("bounds the total number of relation nodes in one tree", () => { - const wide = defineSchema((builder: SchemaBuilderContext) => { - const shared = builder.table("shared", { id: id() }); - const leaves: Record = {}; - for (let index = 0; index < 9; index += 1) { - leaves[`t${index}`] = builder.table(`t${index}`, { - id: id(), - sharedId: fk(() => shared.id), - }); - } - const hub = builder.table("hub", { - id: id(), - ...Object.fromEntries( - Object.entries(leaves).map(([name, leaf]) => [ - `${name}Id`, - fk(() => leaf.id), - ]), - ), - }); - return { shared, ...leaves, hub }; - }); - const hub = compileCrudTables(wide.$tables).get("hub") as CrudTable; - const branch = { include: { shared: true, hub: { limit: 0 } } }; - const tree = (count: number) => - query({ - limit: "1", - include: Object.fromEntries( - Array.from({ length: count }, (_, index) => [`t${index}`, branch]), - ), - }); - - // 8 branches materialize 24 relation nodes; a ninth crosses the 25 cap. - expect( - Object.keys(decodeListQuery(hub, tree(8)).include ?? {}), - ).toHaveLength(8); - expect(() => decodeListQuery(hub, tree(9))).toThrow(DatabasePluginError); - }); -}); diff --git a/packages/appkit/src/plugins/database/crud/tests/request.test.ts b/packages/appkit/src/plugins/database/crud/tests/request.test.ts index 8a06739cb..3cbd05de5 100644 --- a/packages/appkit/src/plugins/database/crud/tests/request.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/request.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { DatabasePluginError } from "../../../../database/errors"; import { - bigid, defineSchema, fk, id, @@ -12,7 +11,7 @@ import { } from "../../../../database/schema-builder"; import { MAX_SERIALIZED_DEPTH } from "../../defaults"; import { compileCrudTables } from "../contract"; -import { decodeCreateBody, decodeId, decodeUpdateBody } from "../request"; +import { decodeCreateBody, decodeUpdateBody } from "../request"; const schema = defineSchema((builder) => { const users = builder.table("users", { @@ -26,40 +25,22 @@ const schema = defineSchema((builder) => { authorId: fk(() => users.id), body: text(), }); - const ledger = builder.table("ledger", { id: bigid(), memo: text() }); const invites = builder.table("invites", { code: text().primaryKey(), email: text().notNull(), label: text().default("guest"), createdAt: timestamp().defaultNow(), }); - return { users, notes, ledger, invites }; + return { users, notes, invites }; }); const tables = compileCrudTables(schema.$tables); const users = tables.get("users") as NonNullable>; const notes = tables.get("notes") as NonNullable>; -const ledger = tables.get("ledger") as NonNullable< - ReturnType ->; const invites = tables.get("invites") as NonNullable< ReturnType >; -describe("decodeId", () => { - it("decodes identifiers against the declared key type", () => { - expect(decodeId(users, "42")).toBe(42); - expect(decodeId(ledger, "9007199254740993")).toBe(9007199254740993n); - expect(() => decodeId(users, "abc")).toThrow(DatabasePluginError); - expect(() => decodeId(users, "abc")).toThrow( - expect.objectContaining({ - category: "INVALID_REQUEST", - details: [{ path: ["id"], message: expect.any(String) }], - }), - ); - }); -}); - describe("decodeCreateBody / decodeUpdateBody", () => { it("accepts the public columns each operation may set", () => { expect( diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts deleted file mode 100644 index 161cf00e5..000000000 --- a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import type { Request, Response } from "express"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { DEFAULT_LIMIT } from "../../../../database/contract"; -import { DatabasePluginError } from "../../../../database/errors"; -import type { Row } from "../../../../database/runtime"; -import { - defineSchema, - fk, - id, - text, -} from "../../../../database/schema-builder"; -import { DatabaseValidationError } from "../../../../errors"; -import { MAX_RESPONSE_BYTES } from "../../defaults"; -import type { EntityClient } from "../../entity-client"; -import { type CrudTable, compileCrudTables } from "../contract"; -import { - type CrudEntity, - type CrudRouteDeps, - createCreateHandler, - createDeleteHandler, - createDetailHandler, - createListHandler, - createUpdateHandler, -} from "../routes"; - -const schema = defineSchema((builder) => { - const users = builder.table("users", { - id: id(), - name: text(), - token: text().private(), - }); - const notes = builder.table("notes", { - id: id(), - authorId: fk(() => users.id), - body: text(), - }); - const events = builder.table("events", { message: text() }); - return { users, notes, events }; -}); - -const tables = compileCrudTables(schema.$tables); - -// The routes reach their entity through an untyped export lookup, so the -// surface they drive has to stay a subset of the real client. -const _entityClientSatisfiesRoutes: CrudEntity = {} as EntityClient; - -interface FakeEntity extends CrudEntity { - readonly calls: Record; -} - -function fakeEntity(rows: Row[], found: Row | null = null): FakeEntity { - const calls: Record = {}; - const record = (name: string, value: unknown) => { - calls[name] ??= []; - calls[name].push(value); - }; - const chain = (name: string) => (value: unknown) => { - record(name, value); - return entity; - }; - const entity: FakeEntity = { - calls, - where: chain("where"), - order: chain("order"), - select: chain("select"), - include: chain("include"), - limit: chain("limit"), - offset: chain("offset"), - toArray: async () => { - record("toArray", true); - return rows; - }, - find: async (value) => { - record("find", value); - return found; - }, - create: async (values) => { - record("create", values); - return { id: 1, ...values, token: "secret" }; - }, - update: async (value, values) => { - record("update", [value, values]); - return found && { ...found, ...values }; - }, - delete: async (value) => { - record("delete", value); - return found !== null; - }, - }; - return entity; -} - -function fakeResponse() { - const sent: { - status?: number; - body?: string; - type?: string; - headers: Record; - } = { headers: {} }; - const res = { - headersSent: false, - status: vi.fn((code: number) => { - sent.status = code; - return res; - }), - type: vi.fn((value: string) => { - sent.type = value; - return res; - }), - setHeader: vi.fn((name: string, value: string) => { - sent.headers[name] = value; - return res; - }), - send: vi.fn((body: string) => { - sent.body = body; - return res; - }), - }; - return { - res: res as unknown as Response, - sent, - json: () => JSON.parse(sent.body ?? "null"), - }; -} - -function request(url: string, params: Record = {}): Request { - return { originalUrl: url, url, params } as unknown as Request; -} - -function writeRequest( - url: string, - body: unknown, - params: Record = {}, - contentType: string | null = "application/json", -): Request { - return { - originalUrl: url, - url, - params, - body, - is: (type: string) => contentType?.includes(type.split("/")[1]) ?? false, - } as unknown as Request; -} - -function deps(table: string, entity: CrudEntity): CrudRouteDeps { - return { - table: tables.get(table) as CrudTable, - entity: () => entity, - runRouteSpan: (_operation, _route, run) => run(), - }; -} - -let response = fakeResponse(); -beforeEach(() => { - response = fakeResponse(); -}); - -describe("list route", () => { - it("returns the bounded envelope from one query", async () => { - const entity = fakeEntity([ - { id: 1, name: "Ada", token: "secret" }, - { id: 2, name: "Grace", token: "secret" }, - ]); - await createListHandler(deps("users", entity))( - request("/users"), - response.res, - ); - - expect(response.sent.status).toBe(200); - expect(response.json()).toEqual({ - items: [ - { id: 1, name: "Ada" }, - { id: 2, name: "Grace" }, - ], - limit: DEFAULT_LIMIT, - offset: 0, - }); - expect(entity.calls.toArray).toHaveLength(1); - expect(entity.calls.where).toBeUndefined(); - expect(entity.calls.order).toEqual([{ id: "asc" }]); - }); - - it("keeps row data out of shared and browser caches", async () => { - await createListHandler(deps("users", fakeEntity([])))( - request("/users"), - response.res, - ); - expect(response.sent.headers["Cache-Control"]).toBe("no-store"); - - const rejected = fakeResponse(); - await createListHandler(deps("users", fakeEntity([])))( - request("/users?limit=abc"), - rejected.res, - ); - expect(rejected.sent.headers["Cache-Control"]).toBe("no-store"); - }); - - it("appends the primary key so equal sort keys stay stable", async () => { - const entity = fakeEntity([]); - await createListHandler(deps("users", entity))( - request(`/users?order=${encodeURIComponent('{"name":"desc"}')}`), - response.res, - ); - expect(entity.calls.order).toEqual([{ name: "desc", id: "asc" }]); - }); - - it("keeps a caller-supplied key direction", async () => { - const entity = fakeEntity([]); - await createListHandler(deps("users", entity))( - request(`/users?order=${encodeURIComponent('{"id":"desc"}')}`), - response.res, - ); - expect(entity.calls.order).toEqual([{ id: "desc" }]); - }); - - it("requires explicit ordering when a table has no key", async () => { - const entity = fakeEntity([]); - await createListHandler(deps("events", entity))( - request("/events"), - response.res, - ); - expect(response.sent.status).toBe(400); - expect(entity.calls.toArray).toBeUndefined(); - - const ordered = fakeEntity([]); - await createListHandler(deps("events", ordered))( - request(`/events?order=${encodeURIComponent('{"message":"asc"}')}`), - fakeResponse().res, - ); - expect(ordered.calls.order).toEqual([{ message: "asc" }]); - }); - - it("forwards only the parameters the caller supplied", async () => { - const entity = fakeEntity([]); - const url = `/notes?where=${encodeURIComponent('{"body":"a"}')}&select=${encodeURIComponent('["body"]')}&include=${encodeURIComponent('{"users":true}')}&limit=5&offset=3`; - await createListHandler(deps("notes", entity))(request(url), response.res); - expect(entity.calls).toMatchObject({ - where: [{ body: "a" }], - select: [["body"]], - include: [{ users: true }], - limit: [5], - offset: [3], - }); - }); - - it("rejects an invalid query before touching the entity", async () => { - const entity = fakeEntity([]); - await createListHandler(deps("users", entity))( - request("/users?limit=abc"), - response.res, - ); - expect(response.sent.status).toBe(400); - expect(response.json()).toEqual({ - error: "Invalid database request", - details: [{ path: ["limit"], message: expect.any(String) }], - }); - expect(entity.calls.toArray).toBeUndefined(); - }); -}); - -describe("detail route", () => { - it("returns one public row for a decoded key", async () => { - const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); - await createDetailHandler(deps("users", entity))( - request("/users/7", { id: "7" }), - response.res, - ); - expect(entity.calls.find).toEqual([7]); - expect(response.json()).toEqual({ id: 7, name: "Ada" }); - }); - - it("answers a missing row with 404 and no internal category", async () => { - const entity = fakeEntity([], null); - await createDetailHandler(deps("users", entity))( - request("/users/7", { id: "7" }), - response.res, - ); - expect(response.sent.status).toBe(404); - expect(response.json()).toEqual({ error: "Database record not found" }); - }); - - it("rejects an unrepresentable key without a lookup", async () => { - const entity = fakeEntity([], null); - await createDetailHandler(deps("users", entity))( - request("/users/abc", { id: "abc" }), - response.res, - ); - expect(response.sent.status).toBe(400); - expect(entity.calls.find).toBeUndefined(); - }); -}); - -describe("projection and response limits", () => { - it("projects public columns and included rows", async () => { - const entity = fakeEntity([ - { - id: 1, - name: "Ada", - token: "secret", - notes: [{ id: 2, body: "hello", draft: "hidden" }], - }, - ]); - await createListHandler(deps("users", entity))( - request("/users?include=%7B%22notes%22%3Atrue%7D"), - response.res, - ); - expect(response.json().items).toEqual([ - { id: 1, name: "Ada", notes: [{ id: 2, body: "hello" }] }, - ]); - }); - - it("projects one detail row without its private columns", async () => { - const entity = fakeEntity([], { - id: 1, - name: "Ada", - token: "private", - notes: [{ id: 2, body: "hello", draft: "hidden" }], - }); - await createDetailHandler(deps("users", entity))( - request("/users/1", { id: "1" }), - response.res, - ); - expect(response.sent.status).toBe(200); - expect(response.json()).toEqual({ - id: 1, - name: "Ada", - notes: [{ id: 2, body: "hello" }], - }); - }); - - it("rejects a response that exceeds the byte budget", async () => { - const wide = "x".repeat(1024 * 1024); - const entity = fakeEntity( - Array.from({ length: 6 }, (_, index) => ({ id: index, name: wide })), - ); - await createListHandler(deps("users", entity))( - request("/users"), - response.res, - ); - expect(response.sent.status).toBe(413); - expect(response.json()).toEqual({ - error: "Database payload is too large", - }); - expect(MAX_RESPONSE_BYTES).toBeLessThan(6 * wide.length); - }); - - it("stops shaping rows once the byte budget is exceeded", async () => { - const wide = "x".repeat(2 * 1024 * 1024); - let touchedSentinel = false; - const rows = [ - ...Array.from({ length: 4 }, (_, index) => ({ id: index, name: wide })), - { - id: 99, - get name(): string { - touchedSentinel = true; - return "sentinel"; - }, - }, - ]; - await createListHandler(deps("users", fakeEntity(rows)))( - request("/users"), - response.res, - ); - expect(response.sent.status).toBe(413); - expect(response.json()).toEqual({ - error: "Database payload is too large", - }); - expect(touchedSentinel).toBe(false); - }); - - it("maps an entity failure to its safe category", async () => { - const entity = fakeEntity([]); - entity.toArray = async () => { - throw new DatabasePluginError("FORBIDDEN", "read"); - }; - await createListHandler(deps("users", entity))( - request("/users"), - response.res, - ); - expect(response.sent.status).toBe(403); - expect(response.json()).toEqual({ - error: "Database operation forbidden", - }); - }); -}); - -describe("write routes", () => { - it("creates a row at 201 and returns only its public columns", async () => { - const entity = fakeEntity([]); - await createCreateHandler(deps("users", entity))( - writeRequest("/users", { name: "Ada" }), - response.res, - ); - expect(entity.calls.create).toEqual([{ name: "Ada" }]); - expect(response.sent.status).toBe(201); - expect(response.json()).toEqual({ id: 1, name: "Ada" }); - expect(response.sent.headers["Cache-Control"]).toBe("no-store"); - }); - - it("updates a row at 200 and deletes one at 204", async () => { - const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); - await createUpdateHandler(deps("users", entity))( - writeRequest("/users/7", { name: "Grace" }, { id: "7" }), - response.res, - ); - expect(entity.calls.update).toEqual([[7, { name: "Grace" }]]); - expect(response.sent.status).toBe(200); - expect(response.json()).toEqual({ id: 7, name: "Grace" }); - - const removed = fakeResponse(); - await createDeleteHandler(deps("users", entity))( - request("/users/7", { id: "7" }), - removed.res, - ); - expect(entity.calls.delete).toEqual([7]); - expect(removed.sent.status).toBe(204); - expect(removed.sent.body).toBeUndefined(); - }); - - it("answers 404 when an update or delete matches nothing", async () => { - const entity = fakeEntity([], null); - await createUpdateHandler(deps("users", entity))( - writeRequest("/users/7", { name: "Grace" }, { id: "7" }), - response.res, - ); - expect(response.sent.status).toBe(404); - expect(response.json()).toEqual({ error: "Database record not found" }); - - const removed = fakeResponse(); - await createDeleteHandler(deps("users", entity))( - request("/users/7", { id: "7" }), - removed.res, - ); - expect(removed.sent.status).toBe(404); - }); - - it("requires a JSON body and refuses query parameters", async () => { - const entity = fakeEntity([]); - await createCreateHandler(deps("users", entity))( - writeRequest("/users", "name=Ada", {}, "text/plain"), - response.res, - ); - expect(response.sent.status).toBe(415); - expect(response.json()).toEqual({ - error: "Database request body must be JSON", - }); - - const filtered = fakeResponse(); - await createCreateHandler(deps("users", entity))( - writeRequest("/users?where=x", { name: "Ada" }), - filtered.res, - ); - expect(filtered.sent.status).toBe(400); - expect(filtered.json().details).toEqual([ - { path: ["query"], message: expect.any(String) }, - ]); - - const patched = fakeResponse(); - await createUpdateHandler(deps("users", entity))( - writeRequest("/users/7?where=x", { name: "Ada" }, { id: "7" }), - patched.res, - ); - expect(patched.sent.status).toBe(400); - - const onDelete = fakeResponse(); - await createDeleteHandler(deps("users", entity))( - request("/users/7?cascade=true", { id: "7" }), - onDelete.res, - ); - expect(onDelete.sent.status).toBe(400); - expect(entity.calls.create).toBeUndefined(); - expect(entity.calls.update).toBeUndefined(); - expect(entity.calls.delete).toBeUndefined(); - }); - - it("names a public column the caller may not write", async () => { - const entity = fakeEntity([]); - await createCreateHandler(deps("users", entity))( - writeRequest("/users", { name: "Ada", id: 7 }), - response.res, - ); - expect(response.sent.status).toBe(400); - expect(response.json()).toEqual({ - error: "Invalid database request", - details: [{ path: ["id"], message: expect.any(String) }], - }); - expect(entity.calls.create).toBeUndefined(); - }); - - it.each([ - ["a private column", { token: "stolen" }, "stolen"], - ["caller markup", { "": 1 }, "