From f72c9a77088ec558cf8726ef30a72c4eb6b3201c Mon Sep 17 00:00:00 2001 From: ditadi Date: Fri, 23 Oct 2026 14:21:54 +0100 Subject: [PATCH] feat(appkit): serve named database operations instead of AutoCRUD database({ routes }) mounts the generated registry under /api/database/operations/; without routes the plugin serves no HTTP endpoint. The table routes, the api and crudRoutes options, the entity client and hooks, and their HTTP facets are removed. The playground, template, and docs move to named operations. The playground writes a note and its event in one transaction, and only the author may change or delete the note. Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 401 ++++---- .../client/src/components/database/index.ts | 1 - .../src/components/database/refusal-probe.tsx | 70 -- apps/dev-playground/client/src/lib/nav.ts | 2 +- .../client/src/routes/database.route.tsx | 364 +++---- .../client/tsconfig.database.json | 11 +- apps/dev-playground/config/database/schema.ts | 4 +- apps/dev-playground/package.json | 2 +- apps/dev-playground/server/database.test.ts | 38 +- .../server/database/policies.ts | 18 + .../server/database/routes.gen.ts | 16 + .../server/database/routes/boards.ts | 49 + .../server/database/routes/notes.ts | 86 ++ apps/dev-playground/server/index.ts | 13 +- apps/dev-playground/server/smoke.test.ts | 211 +++- .../server/testing/database-edges.ts | 385 +++++++ .../appkit-types/database-operations.d.ts | 82 ++ .../shared/appkit-types/database.d.ts | 26 - apps/dev-playground/tsconfig.database.json | 15 + docs/docs/api/appkit/Class.Plugin.md | 34 + docs/docs/api/appkit/Function.database.md | 8 +- .../api/appkit/TypeAlias.DatabaseApiConfig.md | 59 -- .../TypeAlias.DatabaseApiWriteOperation.md | 7 - .../TypeAlias.DatabaseApiWritesConfig.md | 45 - .../api/appkit/TypeAlias.IDatabaseConfig.md | 35 +- docs/docs/api/appkit/index.md | 3 - docs/docs/api/appkit/typedoc-sidebar.ts | 15 - docs/docs/plugins/database.md | 763 +++++++------- packages/appkit-ui/src/js/beta.ts | 21 +- packages/appkit-ui/src/js/database/client.ts | 12 +- .../src/js/database/entity-client.test.ts | 682 ------------- .../src/js/database/entity-client.ts | 507 ---------- packages/appkit-ui/src/js/database/errors.ts | 29 +- .../appkit-ui/src/js/database/registry.ts | 12 - packages/appkit-ui/src/js/database/types.ts | 82 -- packages/appkit-ui/src/react/beta.ts | 51 +- .../hooks/__tests__/database-test-utils.ts | 156 --- .../use-database-entity.types.test.ts | 485 --------- .../__tests__/use-database-list.test.tsx | 376 ------- .../__tests__/use-database-mutations.test.tsx | 675 ------------- .../__tests__/use-database-record.test.tsx | 213 ---- .../__tests__/use-database.types.test.ts | 41 + .../src/react/hooks/database-request-store.ts | 208 ---- .../src/react/hooks/use-database-create.ts | 88 -- .../src/react/hooks/use-database-delete.ts | 83 -- .../src/react/hooks/use-database-list.ts | 96 -- .../src/react/hooks/use-database-read.ts | 172 ---- .../src/react/hooks/use-database-record.ts | 83 -- .../src/react/hooks/use-database-update.ts | 96 -- .../src/react/hooks/use-database-write.ts | 146 --- packages/appkit/src/beta.ts | 3 - .../appkit/src/database/contract/registry.ts | 2 +- packages/appkit/src/database/errors.ts | 2 +- packages/appkit/src/plugin/plugin.ts | 16 + .../appkit/src/plugins/database/config.ts | 27 +- .../src/plugins/database/crud/capabilities.ts | 14 +- .../src/plugins/database/crud/contract.ts | 88 +- .../src/plugins/database/crud/exposure.ts | 146 --- .../appkit/src/plugins/database/crud/query.ts | 475 --------- .../src/plugins/database/crud/request.ts | 29 +- .../src/plugins/database/crud/response.ts | 99 +- .../src/plugins/database/crud/routes.ts | 208 ---- .../database/crud/tests/capabilities.test.ts | 6 - .../database/crud/tests/contract.test.ts | 43 +- .../database/crud/tests/exposure.test.ts | 114 --- .../plugins/database/crud/tests/query.test.ts | 311 ------ .../database/crud/tests/request.test.ts | 23 +- .../database/crud/tests/routes.test.ts | 563 ----------- .../crud/tests/wire-roundtrip.test.ts | 187 ---- .../appkit/src/plugins/database/database.ts | 355 ++++--- .../appkit/src/plugins/database/defaults.ts | 21 +- packages/appkit/src/plugins/database/index.ts | 7 +- .../src/plugins/database/operation-runtime.ts | 5 + .../plugins/database/tests/crud-span.test.ts | 217 ---- .../database/tests/crud.integration.test.ts | 315 ------ .../database/tests/mvp.integration.test.ts | 293 ------ .../plugins/database/tests/operation-edges.ts | 51 +- .../tests/operations.integration.test.ts | 955 ++++++++++++++++++ .../src/plugins/database/tests/plugin.test.ts | 500 ++------- packages/appkit/src/plugins/database/types.ts | 73 +- .../plugins/server/client-config-sanitizer.ts | 8 +- packages/appkit/src/plugins/server/index.ts | 36 +- .../tests/client-config-sanitizer.test.ts | 28 + .../src/type-generator/database/generate.ts | 10 +- .../database/tests/generate.test.ts | 234 +---- .../type-generator/database/walk-schema.ts | 23 +- packages/shared/src/database/api-types.ts | 188 +--- packages/shared/src/database/index.ts | 1 - .../shared/src/database/query-codec.test.ts | 180 ---- packages/shared/src/database/query-codec.ts | 100 -- template/README.md.tmpl | 14 +- template/config/database/schema.ts | 4 +- 92 files changed, 3212 insertions(+), 9839 deletions(-) delete mode 100644 apps/dev-playground/client/src/components/database/refusal-probe.tsx create mode 100644 apps/dev-playground/server/database/policies.ts create mode 100644 apps/dev-playground/server/database/routes.gen.ts create mode 100644 apps/dev-playground/server/database/routes/boards.ts create mode 100644 apps/dev-playground/server/database/routes/notes.ts create mode 100644 apps/dev-playground/server/testing/database-edges.ts create mode 100644 apps/dev-playground/shared/appkit-types/database-operations.d.ts create mode 100644 apps/dev-playground/tsconfig.database.json delete mode 100644 docs/docs/api/appkit/TypeAlias.DatabaseApiConfig.md delete mode 100644 docs/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md delete mode 100644 docs/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md delete mode 100644 packages/appkit-ui/src/js/database/entity-client.test.ts delete mode 100644 packages/appkit-ui/src/js/database/entity-client.ts delete mode 100644 packages/appkit-ui/src/js/database/registry.ts delete mode 100644 packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts delete mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts delete mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx delete mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx delete mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx delete mode 100644 packages/appkit-ui/src/react/hooks/database-request-store.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-create.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-delete.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-list.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-read.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-record.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-update.ts delete mode 100644 packages/appkit-ui/src/react/hooks/use-database-write.ts delete mode 100644 packages/appkit/src/plugins/database/crud/exposure.ts delete mode 100644 packages/appkit/src/plugins/database/crud/query.ts delete mode 100644 packages/appkit/src/plugins/database/crud/routes.ts delete mode 100644 packages/appkit/src/plugins/database/crud/tests/exposure.test.ts delete mode 100644 packages/appkit/src/plugins/database/crud/tests/query.test.ts delete mode 100644 packages/appkit/src/plugins/database/crud/tests/routes.test.ts delete mode 100644 packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts delete mode 100644 packages/appkit/src/plugins/database/tests/crud-span.test.ts delete mode 100644 packages/appkit/src/plugins/database/tests/crud.integration.test.ts delete mode 100644 packages/appkit/src/plugins/database/tests/mvp.integration.test.ts create mode 100644 packages/appkit/src/plugins/database/tests/operations.integration.test.ts delete mode 100644 packages/shared/src/database/query-codec.test.ts delete mode 100644 packages/shared/src/database/query-codec.ts diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx 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 }, "