diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 379d83951..f9d2f06d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,9 @@ jobs: run: pnpm --filter=dev-playground exec playwright install --with-deps chromium - name: Build packages run: pnpm build + # The generated database registry must reach appkit-ui's built declarations. + - name: Typecheck database components + run: pnpm --filter=dev-playground typecheck:database - name: Run Integration Tests run: pnpm --filter=dev-playground test:integration env: 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 09cf41785..d1d93b651 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -8,8 +8,14 @@ import { CardTitle, Input, } from "@databricks/appkit-ui/react"; +import { + type DatabaseApiError, + useDatabaseCreate, + useDatabaseList, + useDatabaseRecord, +} from "@databricks/appkit-ui/react/beta"; import { Loader2, PlusIcon, RefreshCwIcon } from "lucide-react"; -import { useCallback, useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; /** * Everything on this panel comes from routes the app never wrote: the note list @@ -17,76 +23,9 @@ import { useCallback, useEffect, useId, useState } from "react"; * is the row an `afterCreate` hook commits alongside each note. */ -interface Note { - id: number; - board_id: number; - author: string; - body: string; - created_at: string; -} - -interface Board { - id: number; - slug: string; - title: string; - created_at: string; - notes?: Note[]; -} - -interface NoteEvent { - id: number; - note_id: number; - action: string; - created_at: string; -} - -interface TimelineNote extends Note { - note_events?: NoteEvent[]; -} - -interface Timeline extends Board { - notes?: TimelineNote[]; -} - -/** Only a short note preview is needed for the board picker. */ -const BOARDS_URL = `/api/database/boards?include=${encodeURIComponent( - JSON.stringify({ notes: { limit: 5 } }), -)}`; - -/** Listing notes directly is what puts them through the entity's serializer. */ -const notesUrl = (boardId: number) => - `/api/database/notes?where=${encodeURIComponent( - JSON.stringify({ board_id: boardId }), - )}&order=${encodeURIComponent( - JSON.stringify({ created_at: "desc" }), - )}&limit=5`; - -/** The audit trail is a read-only include on the generated board detail route. */ -const timelineUrl = (boardId: number) => - `/api/database/boards/${boardId}?include=${encodeURIComponent( - JSON.stringify({ - notes: { limit: 20, include: { note_events: { limit: 5 } } }, - }), - )}`; - -/** Generated routes answer failures as `{ error, details? }`. */ -function failureMessage(body: unknown, fallback: string): string { - const payload = body as { - error?: unknown; - details?: Array<{ message?: string }>; - } | null; - const detail = payload?.details?.[0]?.message; - if (typeof detail === "string") return detail; - return typeof payload?.error === "string" ? payload.error : fallback; -} - -async function getJson(url: string): Promise { - const response = await fetch(url); - const body: unknown = await response.json(); - if (!response.ok) { - throw new Error(failureMessage(body, `HTTP ${response.status}`)); - } - return body as T; +/** Generated routes answer `{ error, details? }`; a field detail reads first. */ +function errorText(err: DatabaseApiError): string { + return err.details[0]?.message ?? err.message; } export function BoardExplorer() { @@ -94,110 +33,96 @@ export function BoardExplorer() { const bodyFieldId = useId(); const boardFieldId = useId(); - const [boards, setBoards] = useState([]); - const [notes, setNotes] = useState([]); - const [timeline, setTimeline] = useState(null); const [selected, setSelected] = useState(null); - const [fullBody, setFullBody] = useState>({}); + const [revealedId, setRevealedId] = useState(null); const [author, setAuthor] = useState("reviewer"); const [body, setBody] = useState(""); const [title, setTitle] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const load = useCallback(async (slug?: string | null) => { - setError(null); - try { - const page = await getJson<{ items: Board[] }>(BOARDS_URL); - setBoards(page.items); - const active = - page.items.find((entry) => entry.slug === slug) ?? page.items[0]; - setSelected(active?.slug ?? null); - setFullBody({}); - if (!active) { - setNotes([]); - setTimeline(null); - return; - } - const [listed, board] = await Promise.all([ - getJson<{ items: Note[] }>(notesUrl(active.id)), - getJson(timelineUrl(active.id)), - ]); - setNotes(listed.items); - setTimeline(board); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, []); + // A write restarts every mounted read, so the lists, the board previews, and + // the audit trail `afterCreate` writes 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 board = + boardItems.find((entry) => entry.slug === selected) ?? boardItems[0]; - useEffect(() => { - load(); - }, [load]); + // Listing notes directly is what puts them through the entity's serializer. + const notes = useDatabaseList( + "notes", + { + where: { board_id: board?.id ?? 0 }, + order: { created_at: "desc" }, + limit: 5, + }, + { enabled: board !== undefined }, + ); + const noteItems = notes.data?.items ?? []; - const board = boards.find((entry) => entry.slug === selected) ?? null; + // 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 post = async (url: string, payload: unknown) => { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const created: unknown = await response.json(); - if (!response.ok) throw new Error(failureMessage(created, "Create failed")); - return created; + // The list route truncates; the detail route does not. Same serializer. + const fullNote = useDatabaseRecord("notes", revealedId); + + const failure = + createNote.error ?? + createBoard.error ?? + boards.error ?? + notes.error ?? + timeline.error ?? + fullNote.error; + const error = failure ? errorText(failure) : null; + + const refresh = () => { + boards.refetch(); + notes.refetch(); + timeline.refetch(); + fullNote.refetch(); }; - const submit = async (run: () => Promise) => { - setBusy(true); - setError(null); - try { - const slug = await run(); - await load(slug ?? selected); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } + const selectBoard = (slug: string) => { + setSelected(slug); + setRevealedId(null); }; - const addNote = (event: React.FormEvent) => { + const addNote = async (event: React.FormEvent) => { event.preventDefault(); if (!board || !body.trim()) return; - return submit(async () => { - await post("/api/database/notes", { - board_id: board.id, - author, - body, - }); - setBody(""); - return board.slug; + createBoard.reset(); + // A failure resolves null and lands in createNote.error for the banner. + const created = await createNote.create({ + board_id: board.id, + author, + body, }); + if (created) setBody(""); }; - const addBoard = (event: React.FormEvent) => { + const addBoard = async (event: React.FormEvent) => { event.preventDefault(); if (!title.trim()) return; const slug = title .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-"); - return submit(async () => { - await post("/api/database/boards", { slug, title: title.trim() }); - setTitle(""); - return slug; - }); + 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(""); + selectBoard(created.slug); }; - /** The list route truncates; the detail route does not. Same serializer. */ - const revealFullBody = async (id: number) => { - const note = await getJson(`/api/database/notes/${id}`); - setFullBody((current) => ({ ...current, [id]: note.body })); - }; - - const eventsByNote = new Map( - (timeline?.notes ?? []).map((note) => [note.id, note.note_events ?? []]), - ); - return (
{error && ( @@ -210,28 +135,23 @@ export function BoardExplorer() { Board - {boards.map((entry) => ( + {boardItems.map((entry) => ( ))} - @@ -276,34 +196,39 @@ export function BoardExplorer() { - {notes.length === 0 && ( + {!notes.loading && noteItems.length === 0 && (

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

)} - {notes.map((note) => ( -
-
- {note.author} - - {(fullBody[note.id] ?? note.body).length} chars - + {noteItems.map((note) => { + const full = + note.id === revealedId ? fullNote.data?.body : undefined; + return ( +
+
+ {note.author} + + {(full ?? note.body).length} chars + +
+

+ {full ?? note.body} +

+ {full === undefined && note.body.length === 120 && ( + + )}
-

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

- {!fullBody[note.id] && note.body.length === 120 && ( - - )} -
- ))} + ); + })} @@ -323,13 +248,13 @@ export function BoardExplorer() { - {(timeline?.notes ?? []).map((note) => ( + {timelineNotes.map((note) => (
note #{note.id} by {note.author}
    - {(eventsByNote.get(note.id) ?? []).map((event) => ( + {note.note_events.map((event) => (
))} - {(timeline?.notes ?? []).length === 0 && ( + {!timeline.loading && timelineNotes.length === 0 && (

Nothing recorded yet.

diff --git a/apps/dev-playground/client/src/routes/database.route.tsx b/apps/dev-playground/client/src/routes/database.route.tsx index 9b041cef6..daa3e1251 100644 --- a/apps/dev-playground/client/src/routes/database.route.tsx +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -293,7 +293,7 @@ function DatabaseRoute() {
diff --git a/apps/dev-playground/client/tsconfig.database.json b/apps/dev-playground/client/tsconfig.database.json new file mode 100644 index 000000000..6447ceb48 --- /dev/null +++ b/apps/dev-playground/client/tsconfig.database.json @@ -0,0 +1,15 @@ +{ + // 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. + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.database.tsbuildinfo", + "paths": { + "@/*": ["./src/*"], + "@databricks/appkit-ui/*": ["../../../packages/appkit-ui/dist/*"] + } + }, + "include": ["src/components/database", "../shared/appkit-types/database.d.ts"] +} diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index d07afce6d..7c057c4ef 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -13,6 +13,7 @@ "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", + "typecheck:database": "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/shared/appkit-types/database.d.ts b/apps/dev-playground/shared/appkit-types/database.d.ts index f6e599c75..96e1c211f 100644 --- a/apps/dev-playground/shared/appkit-types/database.d.ts +++ b/apps/dev-playground/shared/appkit-types/database.d.ts @@ -1,49 +1,68 @@ // Auto-generated by AppKit - DO NOT EDIT import "@databricks/appkit"; +import "@databricks/appkit-ui/js/beta"; -declare module "@databricks/appkit" { - type DatabaseLogicalFilter = T & { - and?: readonly DatabaseLogicalFilter[]; - or?: readonly DatabaseLogicalFilter[]; - }; +type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; +}; - interface DatabaseRegistry { - "boards": { - row: { +interface GeneratedDatabaseRegistry { + "boards": { + row: { "id": number; "slug": string; "title": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "slug": string; "title": string; "created_at": string; }; - insert: { + insert: { "slug": string; "title": string; "created_at"?: string; }; - update: { + update: { "slug"?: string; "title"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "slug"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "title"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "notes": { to: "notes"; many: true }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "slug": string; + "title": string; + "created_at"?: string; + }; + update: { + "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"; }; - "notes": { - row: { + }; + "notes": { + row: { "id": number; "board_id": number; "author": string; @@ -51,28 +70,28 @@ declare module "@databricks/appkit" { "body": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "board_id": number; "author": string; "body": string; "created_at": string; }; - insert: { + insert: { "board_id": number; "author": string; "author_email"?: string | null; "body": string; "created_at"?: string; }; - update: { + update: { "board_id"?: number; "author"?: string; "author_email"?: string | null; "body"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "board_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "author"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; @@ -80,45 +99,93 @@ declare module "@databricks/appkit" { "body"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "boards": { to: "boards"; many: false }; "note_events": { to: "note_events"; many: true }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "board_id": number; + "author": string; + "body": string; + "created_at"?: string; + }; + update: { + "board_id"?: number; + "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"; }; - "note_events": { - row: { + }; + "note_events": { + row: { "id": number; "note_id": number; "action": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "note_id": number; "action": string; "created_at": string; }; - insert: { + insert: { "note_id": number; "action": string; "created_at"?: string; }; - update: { + update: { "note_id"?: number; "action"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "note_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "action"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "notes": { to: "notes"; many: false }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "note_id": number; + "action": string; + "created_at"?: string; + }; + update: { + "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"; }; - } + }; +} + +declare module "@databricks/appkit" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} +} + +declare module "@databricks/appkit-ui/js/beta" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} } diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index dd0b9db74..efa57f48d 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -32,7 +32,16 @@ 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). The database tables must already exist and match the declared schema. This plugin -checks connectivity during setup; it does not create or migrate tables. +does not create or migrate tables. During setup it checks connectivity and that +every declared table and column exists, and it fails with the missing names +(for example `table public.notes is missing columns board_id, body`) instead of +publishing routes that would fail on every request. + +When a query fails at runtime, the client receives only a stable message such as +`Database operation failed`. The server log adds the Postgres text for errors +that name connections, credentials, or schema objects (for example +`column notes.board_id does not exist`), and never for errors that can echo row +values. Apps scaffolded with the Database plugin selected include an empty `config/database/schema.ts`, so `database()` can start without requiring sample @@ -226,6 +235,264 @@ The callback deadline does not cancel arbitrary JavaScript, HTTP requests, or other external side effects. Avoid putting external side effects in hooks that need database rollback semantics. +## Frontend hooks (beta) + +`@databricks/appkit-ui/react/beta` provides React hooks that call the generated +routes, and `@databricks/appkit-ui/js/beta` provides the client they use. Entity +names, parameters, and rows are typed from the same generated registry as the +server-side client, restricted to what the generated routes accept. The hooks +add no authorization. Anyone who can load the page can call the same routes. + +### Setup + +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. The file binds one set of table entries to both `@databricks/appkit` +and `@databricks/appkit-ui/js/beta`. Until it exists, every entity name is a +type error. + +The hooks find 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. + +### Read a list + +```tsx +import { useDatabaseList } 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, + }); + + if (notes.error) return

{notes.error.message}

; + return ( +
    + {notes.data?.items.map((note) => ( +
  • {note.body}
  • + ))} +
+ ); +} +``` + +`data` is the list envelope `{ items, limit, offset }`, or `null` until the +first response arrives. Pass `{ enabled: false }` as the third argument to hold +the request, for example until a value it depends on is known. + +### 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; +``` + +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`. + +### Parameters + +| Parameter | List | Record | 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"` | +| `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. 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. + +### 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. +- New parameters start a new request, and `data` is `null` until it answers. +- `refetch()` aborts the in-flight request and sends it again. The last `data` + stays visible while it loads and if it fails. +- 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. +- A successful write through a write hook restarts mounted reads. See + [Refresh reads after a write](#refresh-reads-after-a-write). + +### Serializer-shaped reads + +A read serializer can change the rows a list or detail route returns. Pass +`shape: serialized()` to type the result as `T`. The entity, id, and +parameters are still checked against the generated registry. + +```tsx +import { serialized, useDatabaseList } from "@databricks/appkit-ui/react/beta"; + +// server: serialize: (row) => ({ ...row, excerpt: String(row.body).slice(0, 80) }) +interface NoteView { + id: number; + author: string; + excerpt: string; +} + +const notes = useDatabaseList( + "notes", + { limit: 20 }, + { shape: serialized() }, +); +``` + +`serialized()` is not checked at runtime. Keep `T` in step with the +serializer. + +### Write rows + +```tsx +import { useState } from "react"; +import { useDatabaseCreate } from "@databricks/appkit-ui/react/beta"; + +function AddNote({ boardId }: { boardId: number }) { + const notes = useDatabaseCreate("notes"); + 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(""); + } + + return ( +
+ setBody(event.target.value)} /> + + {notes.error && ( +

{notes.error.details[0]?.message ?? notes.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` | + +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. + +Values 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. +Update and delete need a public primary key, like record reads. Bigint columns +accept a decimal string or a safe integer. + +The answered row is the public row the database holds after any `before*` hook +ran. A read serializer never reshapes a write's response. + +### 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. + +Relations exist only in the generated types, so at runtime the hooks cannot +tell that a `boards` read includes `notes`. That is why the default restarts +every mounted read, not only reads of the written table. To restart only reads +of named tables, or none, pass `invalidate`: + +```ts +// Restart only the reads of notes and boards. +useDatabaseCreate("notes", { invalidate: ["notes", "boards"] }); + +// Restart nothing. Call refetch() on the reads that need it. +useDatabaseCreate("notes", { invalidate: false }); +``` + +A read of `boards` that includes `notes` belongs to `boards`, so +`invalidate: ["notes"]` does not restart it. + +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` and defaults to every mounted read: + +```ts +import { invalidateDatabaseReads } from "@databricks/appkit-ui/react/beta"; + +await fetch(`/api/cases/${caseId}/sar`, { method: "POST" }); +invalidateDatabaseReads(["str_reports", "activity_log"]); +``` + +### 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, + 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. +- `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. + +### Errors + +`error` is a `DatabaseApiError` with 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 | Malformed or unsupported parameters | +| `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 the request did not reach the server | +| `INTERNAL` | 500 or other | Any other failure | + +### Without React + +`databaseApi.list`, `get`, `create`, `update`, and `remove` in +`@databricks/appkit-ui/js/beta` take the same entity, id, parameters, and +values as the hooks and return a promise. An optional last argument, +`{ signal }`, cancels the request. Cancelling a write does not undo it if the +server already committed it. Unlike the hooks, they reject with +`DatabaseApiError`, or with the abort reason after a cancel. A write through +`databaseApi` does not restart hook reads; call `invalidateDatabaseReads` when +the screen should refresh. + +```ts +import { databaseApi } from "@databricks/appkit-ui/js/beta"; + +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); +``` + +A write through `databaseApi` does not restart hook reads. Call `refetch()` on +the reads that show the changed rows. + ## API reference - [`database`](../api/appkit/Function.database.md) diff --git a/docs/docs/plugins/lakebase.md b/docs/docs/plugins/lakebase.md index d3f77e78d..c7ee5b367 100644 --- a/docs/docs/plugins/lakebase.md +++ b/docs/docs/plugins/lakebase.md @@ -93,6 +93,8 @@ env: For local development, the `.env` file is automatically generated by `databricks apps init` with the correct values for your Lakebase project. +`PGHOST` must be a host of `LAKEBASE_ENDPOINT`. Credentials are issued for the endpoint, but the pool connects to `PGHOST`, so a host left over from another branch silently serves that branch's tables. AppKit looks the endpoint up at startup and logs a warning that names both hosts when they differ, or that the endpoint was not found. When you switch branches, update both variables; `databricks postgres list-endpoints projects/{project}/branches/{branch}` shows the endpoint name and its host. + For the full configuration reference (SSL, pool size, timeouts, logging, ORM examples), see the [`@databricks/lakebase` README](https://github.com/databricks/appkit/blob/main/packages/lakebase/README.md). ### Pool configuration diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index 088ff80b1..dce640f45 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -1,2 +1,29 @@ // 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"; +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, + DatabaseRecordParams, + DatabaseRecordRow, + DatabaseRow, + DatabaseUpdate, +} from "./database/types"; diff --git a/packages/appkit-ui/src/js/database/client.test.ts b/packages/appkit-ui/src/js/database/client.test.ts new file mode 100644 index 000000000..ee0b8bc88 --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -0,0 +1,520 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "../config"; +import { databaseApi as typedApi, type DatabaseRequestOptions } from "./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("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 a request that never reached the server as TRANSIENT", 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 }); + }); + + 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("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("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 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("rejects a write answered with something other than one row", async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 7 }], 201)); + await expect( + databaseApi.create("notes", { board_id: 7, author: "a", body: "b" }), + ).rejects.toMatchObject({ + code: "INTERNAL", + status: 201, + message: "Database response has an unexpected shape", + }); + + fetchMock.mockResolvedValueOnce(json({ id: 7 }, 200)); + await expect(databaseApi.remove("notes", 7)).rejects.toMatchObject({ + code: "INTERNAL", + status: 200, + }); + }); +}); diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts new file mode 100644 index 000000000..4f5e5ad72 --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.ts @@ -0,0 +1,432 @@ +import { + type DatabaseErrorDetail, + type DatabaseListPage, + databaseErrorCategoryForStatus, + encodeDatabaseListQuery, + encodeDatabaseRecordQuery, + type ExactDatabaseParams, +} from "shared"; + +import { getClientConfig } from "../config"; +import { DatabaseApiError } 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. */ +export type DatabaseOperation = + | "list" + | "detail" + | "create" + | "update" + | "delete"; + +/** An id as a keyed route addresses it in its path. */ +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< + K extends DatabaseEntity, + const P extends DatabaseListParams = Record, + >( + 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 after any `beforeCreate` hook. 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); +} + +/** + * 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. + */ +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`, + ); + } + const url = + id === undefined + ? path + : path.replace(":id", encodeURIComponent(String(id))); + 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. + */ +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"); + let response: Response; + try { + response = await fetch(url, { ...init, headers }); + } catch (error) { + if (init.signal?.aborted) throw error; + throw new DatabaseApiError( + "TRANSIENT", + null, + "Database request did not reach the server", + [], + { 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( + "INTERNAL", + response.status, + "Database response is not JSON", + [], + { cause: error }, + ); + } + if (!accept(body)) { + throw new DatabaseApiError( + "INTERNAL", + response.status, + "Database response has an unexpected shape", + ); + } + return body; +} + +/** The `{ items, limit, offset }` envelope a list route answers with. */ +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; a serializer returns an object too. */ +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; +} + +/** JSON has no bigint; the server reads a bigint value from its decimal string. */ +function bigintAsDecimal(_key: string, value: unknown): unknown { + 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 { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values, bigintAsDecimal), + signal, + }; +} + +/** + * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their + * signatures carry the checks, while the entity is still a literal. + */ +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. */ +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. */ +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, + ); +} + +async function list< + K extends DatabaseEntity, + const P extends DatabaseListParams = Record, +>( + entity: K, + params?: P & ExactDatabaseParams>, + init: DatabaseRequestOptions = {}, +): Promise>> { + const url = resolveDatabaseUrl( + entity, + "list", + undefined, + encodeDatabaseListQuery(params ?? {}), + ); + const page = await requestDatabase( + url, + { method: "GET", signal: init.signal }, + isDatabaseListPage, + ); + // The server projected and encoded every row; the types describe that wire. + return page as DatabaseListPage>; +} + +async function get< + K extends DatabaseKeyedEntity, + const P extends DatabaseRecordParams = Record, +>( + entity: K, + id: DatabaseId, + params?: P & ExactDatabaseParams>, + init: DatabaseRequestOptions = {}, +): Promise> { + const url = resolveDatabaseUrl( + entity, + "detail", + id, + encodeDatabaseRecordQuery(params ?? {}), + ); + 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 new file mode 100644 index 000000000..ab4f38c3e --- /dev/null +++ b/packages/appkit-ui/src/js/database/errors.ts @@ -0,0 +1,31 @@ +import type { DatabaseErrorCategory, DatabaseErrorDetail } from "shared"; + +/** + * A server category, or `NOT_EXPOSED` when the operation has no published + * route and nothing was sent. + */ +export type DatabaseApiErrorCode = DatabaseErrorCategory | "NOT_EXPOSED"; + +/** A failed database request, decoded from the generated `{ error, details }`. */ +export class DatabaseApiError extends Error { + /** Stable category; branch on this rather than on `message`. */ + readonly code: DatabaseApiErrorCode; + /** HTTP status, or `null` when no response was received. */ + readonly status: number | null; + /** Validation details naming public request fields, when the server sent any. */ + readonly details: readonly DatabaseErrorDetail[]; + + constructor( + code: DatabaseApiErrorCode, + status: number | null, + message: string, + details: readonly DatabaseErrorDetail[] = [], + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "DatabaseApiError"; + this.code = code; + this.status = status; + this.details = details; + } +} diff --git a/packages/appkit-ui/src/js/database/registry.ts b/packages/appkit-ui/src/js/database/registry.ts new file mode 100644 index 000000000..1d61c74dc --- /dev/null +++ b/packages/appkit-ui/src/js/database/registry.ts @@ -0,0 +1,14 @@ +/** + * Browser binding target for the application's generated database registry. + * Empty by default; the generated `shared/appkit-types/database.d.ts` binds the + * same entries it binds into `@databricks/appkit`: + * + * @example + * ```typescript + * declare module "@databricks/appkit-ui/js/beta" { + * interface DatabaseRegistry extends GeneratedDatabaseRegistry {} + * } + * ``` + */ +// oxlint-disable-next-line typescript/no-empty-object-type -- augmentation target, populated by typegen. +export interface DatabaseRegistry {} diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts new file mode 100644 index 000000000..aab02368d --- /dev/null +++ b/packages/appkit-ui/src/js/database/types.ts @@ -0,0 +1,84 @@ +import type { + DatabaseApiEntityFor, + 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 public primary key, the only ones with a detail route. + * A table whose key is private or absent is listable but not addressable. + */ +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 +>; diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 992a79635..286243600 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -16,3 +16,50 @@ export { type UseAiSearchQueryResult, useAiSearchQuery, } from "./hooks/use-ai-search-query"; + +// Database read and write hooks. Track the `database` plugin, which ships at +// beta from '@databricks/appkit/beta'. The client and the registry binding live +// 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 DatabaseRecordParams, + type DatabaseRecordRow, + type DatabaseRow, + type DatabaseUpdate, +} from "@/js/beta"; +export { invalidateDatabaseReads } from "./hooks/database-request-store"; +export { + type DatabaseCreateResult, + useDatabaseCreate, +} from "./hooks/use-database-create"; +export { + type DatabaseDeleteResult, + useDatabaseDelete, +} from "./hooks/use-database-delete"; +export { useDatabaseList } from "./hooks/use-database-list"; +export { + type DatabaseReadOptions, + type DatabaseReadResult, + type DatabaseShape, + serialized, +} from "./hooks/use-database-read"; +export { useDatabaseRecord } from "./hooks/use-database-record"; +export { + type DatabaseUpdateResult, + useDatabaseUpdate, +} from "./hooks/use-database-update"; +export type { + DatabaseInvalidation, + DatabaseWriteOptions, + DatabaseWriteState, +} from "./hooks/use-database-write"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts index 966e07836..769ea6d01 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts @@ -74,6 +74,70 @@ describe("createRequestStore", () => { expect(run).toHaveBeenCalledTimes(1); }); + test("restartStarted re-runs started entries and leaves never-started ones idle", () => { + const deferred = vi.fn((_c: RequestControls) => {}); + store.retain("started", run); + store.retain("deferred", deferred, false); + + store.restartStarted(); + + expect(run).toHaveBeenCalledTimes(2); + expect(deferred).not.toHaveBeenCalled(); + + // Once started by hand, the deferred entry is restarted too. + store.start("deferred"); + store.restartStarted(); + expect(deferred).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenCalledTimes(3); + }); + + test("restartStarted restarts only the keys the predicate accepts", () => { + const other = vi.fn((_c: RequestControls) => {}); + store.retain("notes /a", run); + store.retain("boards /b", other); + + const seen: string[] = []; + store.restartStarted((key) => { + seen.push(key); + return key.startsWith("notes "); + }); + + expect(seen.sort()).toEqual(["boards /b", "notes /a"]); + expect(run).toHaveBeenCalledTimes(2); + expect(other).toHaveBeenCalledTimes(1); + }); + + test("restartStarted aborts the prior run before re-running with a fresh signal", () => { + const signals: AbortSignal[] = []; + store.retain("k", (c) => { + signals.push(c.signal); + }); + + store.restartStarted(); + + expect(signals).toHaveLength(2); + expect(signals[0]?.aborted).toBe(true); + expect(signals[1]?.aborted).toBe(false); + }); + + test("restartStarted keeps the snapshot and notifies through the restarted run", () => { + const listener = vi.fn(); + let runs = 0; + store.subscribe("k", listener); + store.retain("k", (c) => { + runs += 1; + if (runs === 1) c.patch({ value: 1 }); + }); + listener.mockClear(); + + store.restartStarted(); + + // The store keeps the last result; only the run decides what to patch. + expect(store.getSnapshot("k").value).toBe(1); + expect(listener).not.toHaveBeenCalled(); + expect(runs).toBe(2); + }); + test("reset aborts in-flight runs and clears entries", () => { let captured: AbortSignal | undefined; store.retain("k", (c) => { 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 new file mode 100644 index 000000000..c2a59423d --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx @@ -0,0 +1,268 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "@/js/config"; +import { DatabaseApiError } from "@/js/database/errors"; + +import { resetDatabaseRequestStore } from "../database-request-store"; +import { useDatabaseList as typedUseDatabaseList } from "../use-database-list"; +import type { DatabaseReadResult } from "../use-database-read"; + +// This package has no generated registry, so every entity name is `never` +// here. The typed surface is compiled in use-database.types.test.ts; these +// cover the request lifecycle. +const useDatabaseList = typedUseDatabaseList as unknown as ( + entity: string, + params?: object, + options?: { enabled?: boolean }, +) => DatabaseReadResult<{ items: unknown[]; limit: number; offset: number }>; + +interface PendingRequest { + url: string; + signal: AbortSignal | undefined; + respond(body: unknown, status?: number): void; +} + +function page(...items: unknown[]) { + return { items, limit: 50, offset: 0 }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Let the deferred teardown of a released entry run. */ +const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("useDatabaseList", () => { + let requests: PendingRequest[]; + let fetchMock: ReturnType; + + beforeEach(() => { + requests = []; + // Each request stays open until the test answers it, and ignores aborts, + // so a test can deliver a completion after it was superseded. + fetchMock = vi.fn( + (url: string, init: RequestInit) => + new Promise((resolve) => { + requests.push({ + url, + signal: init.signal ?? undefined, + respond: (body, status) => resolve(json(body, status)), + }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: { + database: { + "notes.list": "/api/database/notes", + "boards.list": "/api/database/boards", + }, + }, + plugins: {}, + }; + resetDatabaseRequestStore(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + 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("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("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 new file mode 100644 index 000000000..9a82233c2 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx @@ -0,0 +1,478 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "@/js/config"; +import { 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 { DatabaseReadResult } from "../use-database-read"; +import { useDatabaseUpdate as typedUseDatabaseUpdate } from "../use-database-update"; +import type { DatabaseWriteState } 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; these +// cover the write lifecycle. +type Row = Record; +type Options = { invalidate?: boolean | readonly string[] }; +const useDatabaseCreate = typedUseDatabaseCreate as unknown as ( + entity: string, + options?: Options, +) => DatabaseWriteState & { + create(values: object): Promise; + reset(): void; +}; +const useDatabaseUpdate = typedUseDatabaseUpdate as unknown as ( + entity: string, + options?: Options, +) => DatabaseWriteState & { + update(id: string | number, values: object): Promise; + reset(): void; +}; +const useDatabaseDelete = typedUseDatabaseDelete as unknown as ( + entity: string, + options?: Options, +) => { + remove(id: string | number): Promise; + loading: boolean; + error: DatabaseApiError | null; + reset(): void; +}; +const invalidateDatabaseReads = typedInvalidateDatabaseReads as ( + scope?: boolean | readonly string[], +) => void; +const useDatabaseList = typedUseDatabaseList as unknown as ( + entity: string, + params?: object, + options?: { enabled?: boolean }, +) => DatabaseReadResult<{ items: unknown[]; limit: number; offset: number }>; + +interface PendingRequest { + url: string; + method: string; + body: unknown; + signal: AbortSignal | undefined; + respond(body: unknown, status?: number): void; +} + +function page(...items: unknown[]) { + return { items, limit: 50, offset: 0 }; +} + +function reply(body: unknown, status: number): Response { + if (status === 204) return new Response(null, { status }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("database write hooks", () => { + let requests: PendingRequest[]; + let fetchMock: ReturnType; + + /** Requests to `method`, in the order they were sent. */ + const sent = (method: string) => + requests.filter((request) => request.method === method); + + beforeEach(() => { + requests = []; + // Each request stays open until the test answers it, so a test controls + // the order completions arrive in. + 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 = 200) => resolve(reply(body, status)), + }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: { + database: { + "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", + }, + }, + plugins: {}, + }; + resetDatabaseRequestStore(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + /** 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 () => { + for (const request of sent("GET")) request.respond(page({ id: 1 })); + }); + 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("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("a successful write restarts every mounted read by default, keeping their data while they load", async () => { + const reads = await mountReads(); + const writer = renderHook(() => useDatabaseCreate("notes")); + expect(sent("GET")).toHaveLength(2); + + let created!: Promise; + act(() => { + created = writer.result.current.create({ body: "new" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + await created; + + expect(sent("GET").map((request) => request.url)).toEqual([ + "/api/database/notes", + "/api/database/boards", + "/api/database/notes", + "/api/database/boards", + ]); + expect(reads.result.current.notes).toMatchObject({ + data: page({ id: 1 }), + loading: true, + }); + + await act(async () => { + sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 })); + sent("GET")[3]?.respond(page({ id: 1 })); + }); + await waitFor(() => + expect(reads.result.current.notes.data).toEqual( + page({ id: 1 }, { id: 2 }), + ), + ); + }); + + test("invalidate narrows the restart to reads of the named entities, 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)); + await created; + expect( + sent("GET") + .slice(2) + .map((request) => request.url), + ).toEqual(["/api/database/notes"]); + + 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("invalidateDatabaseReads refreshes reads after a write the hooks did not make", async () => { + await mountReads(); + + // A custom route or a databaseApi call changed boards rows. + act(() => invalidateDatabaseReads(["boards"])); + expect( + sent("GET") + .slice(2) + .map((request) => request.url), + ).toEqual(["/api/database/boards"]); + + // With no scope, every mounted read restarts. + act(() => invalidateDatabaseReads()); + expect( + sent("GET") + .slice(3) + .map((request) => request.url), + ).toEqual(["/api/database/notes", "/api/database/boards"]); + + act(() => invalidateDatabaseReads(false)); + 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 writer = renderHook(() => useDatabaseCreate("notes")); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + + 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)); + + await expect(created).resolves.toEqual({ id: 9 }); + expect(sent("GET")).toHaveLength(4); + expect(errors).not.toHaveBeenCalled(); + errors.mockRestore(); + }); + + 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 an inline entity list", () => { + const { result, rerender } = renderHook(() => ({ + create: useDatabaseCreate("notes", { invalidate: ["notes", "boards"] }), + remove: useDatabaseDelete("notes"), + })); + 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("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 new file mode 100644 index 000000000..dc6f7f264 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx @@ -0,0 +1,134 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "@/js/config"; +import { DatabaseApiError } from "@/js/database/errors"; + +import { resetDatabaseRequestStore } from "../database-request-store"; +import type { DatabaseReadResult } from "../use-database-read"; +import { useDatabaseRecord as typedUseDatabaseRecord } from "../use-database-record"; + +// This package has no generated registry, so every entity name is `never` +// here. The typed surface is compiled in use-database.types.test.ts. +const useDatabaseRecord = typedUseDatabaseRecord as unknown as ( + entity: string, + id: string | number | bigint | null | undefined, + params?: object, + options?: { enabled?: boolean }, +) => DatabaseReadResult>; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("useDatabaseRecord", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async (url: string) => { + const id = decodeURIComponent(url.split("?")[0]?.split("/").pop() ?? ""); + return json({ id, body: `note ${id}` }); + }); + vi.stubGlobal("fetch", fetchMock); + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: { + database: { + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + "events.list": "/api/database/events", + }, + }, + plugins: {}, + }; + resetDatabaseRequestStore(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + 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("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("reports a missing row as NOT_FOUND", async () => { + fetchMock.mockResolvedValueOnce( + json({ 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("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 new file mode 100644 index 000000000..988475772 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -0,0 +1,416 @@ +import path from "node:path"; + +import ts from "typescript"; +import { expect, test } from "vitest"; + +const packageRoot = + path.basename(process.cwd()) === "appkit-ui" + ? process.cwd() + : path.join(process.cwd(), "packages", "appkit-ui"); + +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" } }); + useDatabaseList("posts", { where: { total: { gt: "9007199254740993" } } }); + void [title, total, failed, owner, bodies, deep, gatedTitle]; + } + + 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("serialized() replaces the row type and keeps entity, id, and params checked", () => { + const diagnostics = compileTypeProbe(` + import { + type DatabaseListPage, + type DatabaseReadResult, + serialized, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + interface PostCard { id: number; headline: string; comment_count: number } + + export function Shaped() { + const cards = useDatabaseList( + "posts", + { order: { id: "desc" }, limit: 5 }, + { shape: serialized() }, + ); + const typed: DatabaseReadResult> = cards; + const headline: string | undefined = cards.data?.items[0]?.headline; + // @ts-expect-error the shaped row replaces the inferred one + cards.data?.items[0]?.title; + + const card = useDatabaseRecord("posts", 7, undefined, { + shape: serialized(), + enabled: false, + }); + const count: number | undefined = card.data?.comment_count; + void [typed, headline, count]; + } + + export function StillChecked() { + // @ts-expect-error the entity stays checked with a shape + useDatabaseList("missing", {}, { shape: serialized() }); + // @ts-expect-error private filters stay rejected with a shape + useDatabaseList("users", { where: { secret: "token" } }, { shape: serialized() }); + // @ts-expect-error unknown params stay rejected with a shape + useDatabaseList("users", { includeTotal: true }, { shape: serialized() }); + // @ts-expect-error includes stay bounded with a shape + useDatabaseList("posts", { include: { users: { limit: 1 } } }, { shape: serialized() }); + // @ts-expect-error keyless entities stay rejected with a shape + useDatabaseRecord("events", "x", {}, { shape: serialized() }); + // @ts-expect-error the id stays checked with a shape + useDatabaseRecord("posts", "7", {}, { shape: serialized() }); + // @ts-expect-error record params stay checked with a shape + useDatabaseRecord("users", "ada", { select: ["secret"] }, { shape: serialized() }); + // @ts-expect-error a shape is only built by serialized() + useDatabaseList("posts", {}, { shape: { headline: "x" } }); + } + `); + + 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 }); + + 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 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/database-request-store.ts b/packages/appkit-ui/src/react/hooks/database-request-store.ts new file mode 100644 index 000000000..d6bf2049f --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -0,0 +1,137 @@ +import { requestDatabase } from "@/js/database/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, an entity list for reads of those entities only, `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 route and encoded query + * 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 = { + 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; + +/** + * Identity of one read. The resolved URL already carries the route and the + * encoded query; the entity prefix lets a write find the reads rooted at it. + * Entity names never contain a space, so the prefix always splits cleanly. + */ +export function databaseReadKey(entity: string, url: string): string { + return `${entity} ${url}`; +} + +/** The entity a read key is rooted at. */ +function readKeyEntity(key: string): string { + return key.slice(0, key.indexOf(" ")); +} + +/** 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 }, + ); +} + +/** + * 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. + */ +function runDatabaseRead( + url: string, + accept: ResponseGuard, +): RequestRunner { + return ({ signal, patch }) => { + patch({ loading: true, error: null }); + requestDatabase(url, { method: "GET", signal }, accept).then( + (data) => { + if (!signal.aborted) patch({ data, loading: false, error: null }); + }, + (error: unknown) => { + if (!signal.aborted) { + patch({ loading: false, error: asDatabaseApiError(error) }); + } + }, + ); + }; +} + +const store = createRequestStore(IDLE_DATABASE_READ); + +/** + * Register a subscriber for `key`, starting the shared read of `url` on first + * use. Returns a `release` function that must be called on unmount. + */ +export function retainDatabaseRead( + key: string, + url: string, + accept: ResponseGuard, +): () => void { + return store.retain(key, runDatabaseRead(url, accept)); +} + +/** + * 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, an entity list restarts the reads + * rooted at those entities, and `false` restarts none. An include is invisible + * at runtime, so a `boards` read that includes `notes` is rooted at `boards`; + * only `true` is sure to reach it. + * + * @example + * ```ts + * await fetch(`/api/cases/${id}/sar`, { method: "POST" }); + * invalidateDatabaseReads(["str_reports", "activity_log"]); + * ``` + */ +export function invalidateDatabaseReads( + scope: DatabaseInvalidation = true, +): void { + if (scope === false) return; + if (scope === true) { + store.restartStarted(); + return; + } + const entities = new Set(scope); + store.restartStarted((key) => entities.has(readKeyEntity(key))); +} + +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/request-store.ts b/packages/appkit-ui/src/react/hooks/request-store.ts index 485a0f7fe..b13eae1b1 100644 --- a/packages/appkit-ui/src/react/hooks/request-store.ts +++ b/packages/appkit-ui/src/react/hooks/request-store.ts @@ -39,6 +39,12 @@ interface RequestStore { retain(key: string, run: RequestRunner, autoStart?: boolean): () => void; /** (Re)start the request for `key`: abort any in-flight run, then re-run. */ start(key: string): void; + /** + * `start` every entry that has run at least once and that `match` accepts + * (every such entry without one). An entry retained with `autoStart: false` + * that never ran stays idle. + */ + restartStarted(match?: (key: string) => boolean): void; subscribe(key: string, listener: () => void): () => void; getSnapshot(key: string): S; /** Test-only: abort every in-flight request and clear the store. */ @@ -135,6 +141,14 @@ export function createRequestStore(idle: S): RequestStore { start, + restartStarted(match) { + // Collect first: a restarted run patches, and a patch notifies. + const keys = [...entries] + .filter(([key, entry]) => entry.started && (!match || match(key))) + .map(([key]) => key); + for (const key of keys) start(key); + }, + subscribe(key, listener) { let listeners = listenersByKey.get(key); if (!listeners) { diff --git a/packages/appkit-ui/src/react/hooks/use-database-create.ts b/packages/appkit-ui/src/react/hooks/use-database-create.ts new file mode 100644 index 000000000..d3ced53fb --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-create.ts @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import type { ExactDatabaseParams } from "shared"; + +import { createDatabaseRow } from "@/js/database/client"; +import type { + DatabaseEntity, + DatabaseInsert, + DatabaseRow, +} from "@/js/database/types"; + +import { + type DatabaseWriteOptions, + type DatabaseWriteState, + useDatabaseWrite, +} from "./use-database-write"; + +/** What {@link useDatabaseCreate} returns. */ +export interface DatabaseCreateResult< + K extends DatabaseEntity, +> extends DatabaseWriteState> { + /** + * 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 or turn off the read restart + * @returns `create`, the latest call's row, loading and error state, and `reset` + * + * @example + * ```tsx + * const notes = useDatabaseCreate("notes"); + * + * async function add(body: string) { + * const note = await notes.create({ board_id: boardId, author: "ada", body }); + * if (note) setDraft(""); + * } + * notes.error?.details[0]?.message; + * ``` + */ +export function useDatabaseCreate( + entity: K, + options: DatabaseWriteOptions = {}, +): DatabaseCreateResult { + const send = useCallback( + (values: object) => createDatabaseRow(entity, values), + [entity], + ); + const { mutate, ...write } = useDatabaseWrite( + send, + options.invalidate ?? true, + ); + // The server projected the row it holds; the types describe that wire. + return { ...write, create: mutate } as DatabaseCreateResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-delete.ts b/packages/appkit-ui/src/react/hooks/use-database-delete.ts new file mode 100644 index 000000000..b8dcd5484 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-delete.ts @@ -0,0 +1,67 @@ +import { useCallback } from "react"; + +import { deleteDatabaseRow, type IdLike } from "@/js/database/client"; +import type { DatabaseApiError } from "@/js/database/errors"; +import type { DatabaseId, DatabaseKeyedEntity } from "@/js/database/types"; + +import { + type DatabaseWriteOptions, + useDatabaseWrite, +} from "./use-database-write"; + +/** What {@link useDatabaseDelete} returns. */ +export interface DatabaseDeleteResult { + /** + * 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; + /** Whether the latest call is in flight. */ + loading: boolean; + /** Why the latest call failed; `NOT_EXPOSED` when no route exists. */ + error: DatabaseApiError | null; + /** 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. + * + * @param entity - A table the generated registry exposes with a public key + * @param options - `invalidate` to narrow or turn off the read restart + * @returns `remove`, loading and error state, and `reset` + * + * @example + * ```tsx + * const notes = useDatabaseDelete("notes"); + * + * ; + * ``` + */ +export function useDatabaseDelete( + entity: K, + options: DatabaseWriteOptions = {}, +): DatabaseDeleteResult { + // A delete answers no row, so success is the only result worth reporting. + const send = useCallback( + async (id: IdLike) => { + await deleteDatabaseRow(entity, id); + return true as const; + }, + [entity], + ); + const { mutate, reset, loading, error } = useDatabaseWrite( + send, + options.invalidate ?? true, + ); + 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 new file mode 100644 index 000000000..8702264f0 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -0,0 +1,63 @@ +import { + type DatabaseListPage, + encodeDatabaseListQuery, + type ExactDatabaseParams, +} from "shared"; + +import { isDatabaseListPage } from "@/js/database/client"; +import type { + DatabaseEntity, + DatabaseListParams, + DatabaseListRow, +} from "@/js/database/types"; + +import { + type DatabaseReadOptions, + type DatabaseReadResult, + useDatabaseRead, +} from "./use-database-read"; + +/** + * 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` + * @param options - `enabled` to hold the request; `shape` for a serializer's row + * @returns The page, loading and error state, and `refetch` + * + * @example + * ```tsx + * const notes = useDatabaseList( + * "notes", + * { where: { board_id: boardId }, order: { created_at: "desc" }, limit: 5 }, + * { enabled: boardId !== undefined }, + * ); + * notes.data?.items.map((note) => note.body); + * ``` + */ +export function useDatabaseList< + K extends DatabaseEntity, + const P extends DatabaseListParams = Record, + Row = DatabaseListRow, +>( + entity: K, + params?: P & ExactDatabaseParams>, + options: DatabaseReadOptions = {}, +): DatabaseReadResult> { + const read = useDatabaseRead( + entity, + "list", + undefined, + encodeDatabaseListQuery(params ?? {}), + options.enabled ?? true, + isDatabaseListPage, + ); + // The server projected and encoded every row; the types describe that wire. + return read as DatabaseReadResult>; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts new file mode 100644 index 000000000..97c3dcfb6 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; + +import { + type DatabaseOperation, + type IdLike, + resolveDatabaseUrl, +} from "@/js/database/client"; +import { DatabaseApiError } from "@/js/database/errors"; + +import { + databaseReadKey, + getDatabaseReadSnapshot, + IDLE_DATABASE_READ, + retainDatabaseRead, + startDatabaseRead, + subscribeDatabaseRead, +} from "./database-request-store"; + +declare const DATABASE_SHAPE: unique symbol; + +/** + * Phantom marker for the row a read serializer returns. It only carries a + * type; build one with {@link serialized}. + */ +export interface DatabaseShape { + readonly [DATABASE_SHAPE]: T; +} + +const SERIALIZED = Object.freeze({}); + +/** + * Declare the row a read serializer returns for a hook's result. The entity, + * id, and params stay checked against the generated registry; only the row + * type is replaced. It is a promise about the server's serializer, not a + * runtime check. + * + * @example + * ```typescript + * interface CaseListView { id: number; alert_count: number } + * + * const cases = useDatabaseList("cases", { limit: 20 }, { + * shape: serialized(), + * }); + * cases.data?.items[0]?.alert_count; + * ``` + */ +export function serialized(): DatabaseShape { + return SERIALIZED as DatabaseShape; +} + +/** Options shared by the database read hooks. */ +export interface DatabaseReadOptions { + /** Send the request. `false` keeps the hook idle and sends nothing. Default true. */ + enabled?: boolean; + /** The row a read serializer returns, from `serialized()`. */ + shape?: DatabaseShape; +} + +/** Latest state of one database read. */ +export interface DatabaseReadResult { + /** + * The last successful response, or `null` before one arrives. A refetch + * keeps it visible while it loads and after it fails. + */ + 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. */ + error: DatabaseApiError | null; + /** Abort any in-flight request and send it again. No-op while disabled. */ + refetch: () => void; +} + +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. + */ +export function useDatabaseRead( + entity: string, + operation: Extract, + id: IdLike | undefined, + query: string, + enabled: boolean, + accept: (body: unknown) => body is object, +): DatabaseReadResult { + const route = useMemo((): Route => { + if (!enabled) return null; + try { + return { url: resolveDatabaseUrl(entity, operation, id, query) }; + } catch (error) { + if (error instanceof DatabaseApiError) return { error }; + throw error; + } + }, [enabled, entity, operation, id, query]); + + const url = route !== null && "url" in route ? route.url : null; + const key = url === null ? null : databaseReadKey(entity, url); + + const subscribe = useCallback( + (listener: () => void) => + key === null ? noop : subscribeDatabaseRead(key, listener), + [key], + ); + const getSnapshot = useCallback( + () => (key === null ? IDLE_DATABASE_READ : getDatabaseReadSnapshot(key)), + [key], + ); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + // The first subscriber of a key starts the request; later ones share it. + useEffect(() => { + if (key === null || url === null) return; + return retainDatabaseRead(key, url, accept); + }, [key, url, accept]); + + const refetch = useCallback(() => { + if (key !== null) startDatabaseRead(key); + }, [key]); + + return { + data: snapshot.data, + // Until the effect retains a new key, its request is about to start. + loading: + snapshot.loading || (key !== null && snapshot === IDLE_DATABASE_READ), + error: route !== null && "error" in route ? route.error : snapshot.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 new file mode 100644 index 000000000..106f7b264 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -0,0 +1,59 @@ +import { encodeDatabaseRecordQuery, type ExactDatabaseParams } from "shared"; + +import { isDatabaseRow } from "@/js/database/client"; +import type { + DatabaseId, + DatabaseKeyedEntity, + DatabaseRecordParams, + DatabaseRecordRow, +} from "@/js/database/types"; + +import { + type DatabaseReadOptions, + type DatabaseReadResult, + useDatabaseRead, +} from "./use-database-read"; + +/** + * 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. + * + * 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. + * + * @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` + * @param options - `enabled` to hold the request; `shape` for a serializer's row + * @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, + Row = DatabaseRecordRow, +>( + entity: K, + id: DatabaseId | null | undefined, + params?: P & ExactDatabaseParams>, + options: DatabaseReadOptions = {}, +): DatabaseReadResult { + const read = useDatabaseRead( + entity, + "detail", + id ?? undefined, + encodeDatabaseRecordQuery(params ?? {}), + (options.enabled ?? true) && id !== null && id !== undefined, + isDatabaseRow, + ); + // The server projected and encoded the row; the types describe that wire. + return read as DatabaseReadResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-update.ts b/packages/appkit-ui/src/react/hooks/use-database-update.ts new file mode 100644 index 000000000..6118a5afd --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-update.ts @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import type { ExactDatabaseParams } from "shared"; + +import { type IdLike, updateDatabaseRow } from "@/js/database/client"; +import type { + DatabaseId, + DatabaseKeyedEntity, + DatabaseRow, + DatabaseUpdate, +} from "@/js/database/types"; + +import { + type DatabaseWriteOptions, + type DatabaseWriteState, + useDatabaseWrite, +} from "./use-database-write"; + +/** What {@link useDatabaseUpdate} returns. */ +export interface DatabaseUpdateResult< + K extends DatabaseKeyedEntity, +> extends DatabaseWriteState> { + /** + * 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 or turn off the read restart + * @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: DatabaseWriteOptions = {}, +): DatabaseUpdateResult { + const send = useCallback( + (id: IdLike, values: object) => updateDatabaseRow(entity, id, values), + [entity], + ); + const { mutate, ...write } = useDatabaseWrite( + send, + options.invalidate ?? true, + ); + // The server projected the row it holds; the types describe that wire. + return { ...write, update: mutate } as DatabaseUpdateResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-write.ts b/packages/appkit-ui/src/react/hooks/use-database-write.ts new file mode 100644 index 000000000..424ae7a90 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-write.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useMemo, 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"; + +/** Options shared by the database write hooks. */ +export interface DatabaseWriteOptions { + /** + * Reads to restart once a write succeeds. Default `true`: an include can + * reach this entity from any other, and the relation is not visible at + * runtime, so every mounted database read restarts. + */ + invalidate?: DatabaseInvalidation; +} + +/** Latest state of a database write hook. */ +export interface DatabaseWriteState { + /** The latest call's result, or `null` before it answers. */ + data: T | null; + /** Whether the latest call is in flight. */ + loading: boolean; + /** Why the latest call failed; `NOT_EXPOSED` when no route exists. */ + error: DatabaseApiError | null; +} + +interface DatabaseWrite< + Args extends unknown[], + T, +> extends DatabaseWriteState { + mutate(...args: Args): Promise; + reset(): void; +} + +const IDLE: DatabaseWriteState = { + data: null, + loading: false, + error: null, +}; + +/** Keep an inline entity list from changing the write's identity each render. */ +function useInvalidationScope( + invalidate: DatabaseInvalidation, +): DatabaseInvalidation { + const key = + typeof invalidate === "boolean" + ? String(invalidate) + : JSON.stringify(invalidate); + return useMemo( + () => (typeof invalidate === "boolean" ? invalidate : [...invalidate]), + [key], + ); +} + +/** + * 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. Only the latest call of a mounted hook updates its state, and + * a successful write restarts reads even after the hook unmounts, since the + * rows did change. A call resolves once its state and reads are updated. + */ +export function useDatabaseWrite( + send: (...args: Args) => Promise, + invalidate: DatabaseInvalidation, +): DatabaseWrite { + const [state, setState] = useState>(IDLE); + const mounted = useRef(true); + const latest = useRef(null); + const scope = useInvalidationScope(invalidate); + + // Set on every setup as well: a StrictMode remount runs cleanup first. + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const mutate = useCallback( + async (...args: Args): Promise => { + const call = Symbol("database write"); + latest.current = call; + const settle = (next: DatabaseWriteState) => { + if (mounted.current && latest.current === call) setState(next); + }; + + settle({ data: null, loading: true, error: null }); + let data: T; + try { + data = await send(...args); + } catch (cause) { + settle({ + data: null, + loading: false, + error: asDatabaseApiError(cause), + }); + return null; + } + settle({ data, loading: false, error: null }); + invalidateDatabaseReads(scope); + return data; + }, + [send, scope], + ); + + // A call still in flight keeps running, but no longer reports here. + const reset = useCallback(() => { + latest.current = null; + if (mounted.current) setState(IDLE); + }, []); + + return { ...state, mutate, reset }; +} diff --git a/packages/appkit/src/connectors/lakebase/endpoint-host.ts b/packages/appkit/src/connectors/lakebase/endpoint-host.ts new file mode 100644 index 000000000..f69a02532 --- /dev/null +++ b/packages/appkit/src/connectors/lakebase/endpoint-host.ts @@ -0,0 +1,111 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; + +import { createLogger } from "../../logging/logger"; + +const logger = createLogger("connectors:lakebase"); + +/** The lookup is advisory, so it never holds startup longer than this. */ +const HOST_CHECK_TIMEOUT_MS = 3_000; +const MAX_REASON_LENGTH = 240; +const ENDPOINT_NAME = /^projects\/[^/]+\/branches\/[^/]+\/endpoints\/[^/]+$/; + +/** One lookup per endpoint and host, shared by every pool that asks. */ +const checks = new Map>(); + +type HostCheckConfig = Pick< + Partial, + "endpoint" | "host" | "workspaceClient" +>; + +/** + * Warn when PGHOST is not a host of LAKEBASE_ENDPOINT. Tokens are issued for + * the endpoint but the pool connects to the host, so a stale PGHOST quietly + * serves another branch's database and its tables. The check never fails + * startup: an endpoint that cannot be read only skips it. + */ +export function warnOnEndpointHostMismatch( + config: HostCheckConfig, +): Promise { + const endpoint = config.endpoint ?? process.env.LAKEBASE_ENDPOINT; + const host = config.host ?? process.env.PGHOST; + const client = config.workspaceClient; + if (!endpoint || !host || !client || !ENDPOINT_NAME.test(endpoint)) { + return Promise.resolve(); + } + const key = `${endpoint}\n${host.toLowerCase()}`; + let check = checks.get(key); + if (!check) { + check = compareHosts(client, endpoint, host); + checks.set(key, check); + } + return check; +} + +async function compareHosts( + client: NonNullable, + endpoint: string, + host: string, +): Promise { + let timer: ReturnType | undefined; + try { + const response = await Promise.race([ + client.apiClient.request({ + path: `/api/2.0/postgres/${endpoint}`, + method: "GET", + headers: new Headers({ Accept: "application/json" }), + raw: false, + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), HOST_CHECK_TIMEOUT_MS); + }), + ]); + const hosts = endpointHosts(response); + if (hosts.length === 0) { + logger.debug("Skipped the PGHOST check: %s listed no hosts", endpoint); + return; + } + if (hosts.includes(host.toLowerCase())) return; + logger.warn( + "PGHOST %s is not a host of LAKEBASE_ENDPOINT %s (expected %s). Credentials are issued for the endpoint, but queries run against whichever database PGHOST serves. Set PGHOST to the endpoint's host.", + host, + endpoint, + hosts.join(" or "), + ); + } catch (error) { + // SDK errors embed the whole response body; its message comes first. + const reason = ( + error instanceof Error ? error.message : String(error) + ).slice(0, MAX_REASON_LENGTH); + // An endpoint that no longer exists is itself the misconfiguration. + if ( + typeof error === "object" && + error !== null && + "statusCode" in error && + error.statusCode === 404 + ) { + logger.warn( + "LAKEBASE_ENDPOINT %s was not found (%s). Check the project, branch, and endpoint names; `databricks postgres list-endpoints projects/{project}/branches/{branch}` lists them with their hosts.", + endpoint, + reason, + ); + return; + } + logger.debug("Skipped the PGHOST check for %s: %s", endpoint, reason); + } finally { + clearTimeout(timer); + } +} + +/** Read every host the endpoint serves, read-write and read-only. */ +function endpointHosts(response: unknown): string[] { + if (!response || typeof response !== "object") return []; + const status = Reflect.get(response, "status"); + if (!status || typeof status !== "object") return []; + const hosts = Reflect.get(status, "hosts"); + if (!hosts || typeof hosts !== "object") return []; + return Object.values(hosts) + .filter( + (value): value is string => typeof value === "string" && value !== "", + ) + .map((value) => value.toLowerCase()); +} diff --git a/packages/appkit/src/connectors/lakebase/index.ts b/packages/appkit/src/connectors/lakebase/index.ts index b7bf2e1d4..c9fb2eee9 100644 --- a/packages/appkit/src/connectors/lakebase/index.ts +++ b/packages/appkit/src/connectors/lakebase/index.ts @@ -10,6 +10,7 @@ import { ServiceContext } from "../../context/service-context"; import { ConfigurationError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { createWorkspaceClient } from "../../workspace-client"; +import { warnOnEndpointHostMismatch } from "./endpoint-host"; /** * Create a Lakebase pool with appkit's logger integration. @@ -47,7 +48,10 @@ export async function initializeLakebasePool( : createWorkspaceClient({ clientOptions: getClientOptions() }); resolved.workspaceClient = client.toLegacyWorkspaceClient(); } - const user = await getUsernameWithApiLookup(resolved); + const [user] = await Promise.all([ + getUsernameWithApiLookup(resolved), + warnOnEndpointHostMismatch(resolved), + ]); if (!user) { throw ConfigurationError.invalidConnection( "Lakebase", @@ -72,6 +76,7 @@ export { type RequestedResource, } from "@databricks/lakebase"; +export { warnOnEndpointHostMismatch } from "./endpoint-host"; export { createLakebasePoolManager, type LakebasePoolManager, diff --git a/packages/appkit/src/connectors/lakebase/tests/endpoint-host.test.ts b/packages/appkit/src/connectors/lakebase/tests/endpoint-host.test.ts new file mode 100644 index 000000000..5ca4062b4 --- /dev/null +++ b/packages/appkit/src/connectors/lakebase/tests/endpoint-host.test.ts @@ -0,0 +1,187 @@ +import type { LakebasePoolConfig } from "@databricks/lakebase"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { warnOnEndpointHostMismatch } from "../endpoint-host"; + +type Client = NonNullable; + +// Checks are shared per endpoint and host for the process, so every test +// names its own pair. +let sequence = 0; +function names() { + sequence += 1; + return { + endpoint: `projects/p${sequence}/branches/b/endpoints/primary`, + host: `ep-configured-${sequence}.database.example.com`, + }; +} + +function clientAnswering(response: unknown | Promise) { + const request = vi.fn(async () => response); + return { request, client: { apiClient: { request } } as unknown as Client }; +} + +const endpointWith = (hosts: Record) => ({ + name: "ignored", + status: { hosts }, +}); + +let warn: ReturnType; +const warnings = () => warn.mock.calls.flat().map(String).join(" "); + +beforeEach(() => { + vi.stubEnv("LAKEBASE_ENDPOINT", ""); + vi.stubEnv("PGHOST", ""); + warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("warnOnEndpointHostMismatch", () => { + test("warns with both hosts when PGHOST serves another endpoint", async () => { + const { endpoint, host } = names(); + const { request, client } = clientAnswering( + endpointWith({ host: "ep-expected.database.example.com" }), + ); + + await warnOnEndpointHostMismatch({ + endpoint, + host, + workspaceClient: client, + }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + path: `/api/2.0/postgres/${endpoint}`, + method: "GET", + }), + ); + expect(warnings()).toContain(host); + expect(warnings()).toContain(endpoint); + expect(warnings()).toContain("ep-expected.database.example.com"); + }); + + test("reads the endpoint and host from the environment", async () => { + const { endpoint, host } = names(); + vi.stubEnv("LAKEBASE_ENDPOINT", endpoint); + vi.stubEnv("PGHOST", host); + const { request, client } = clientAnswering( + endpointWith({ host: "ep-other.database.example.com" }), + ); + + await warnOnEndpointHostMismatch({ workspaceClient: client }); + + expect(request).toHaveBeenCalledOnce(); + expect(warnings()).toContain(host); + }); + + test.each([ + ["the read-write host", (host: string) => ({ host: host.toUpperCase() })], + [ + "a read-only host", + (host: string) => ({ + host: "ep-primary.database.example.com", + read_only_host: host, + }), + ], + ])("stays quiet when PGHOST is %s", async (_label, hostsFor) => { + const { endpoint, host } = names(); + const { client } = clientAnswering(endpointWith(hostsFor(host))); + + await warnOnEndpointHostMismatch({ + endpoint, + host, + workspaceClient: client, + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + test.each([ + ["no endpoint", { endpoint: undefined }], + ["no host", { host: undefined }], + ["no workspace client", { workspaceClient: undefined }], + [ + "an endpoint that is not a resource name", + { endpoint: "../../jobs/list" }, + ], + ])("sends no request with %s", async (_label, override) => { + const { request, client } = clientAnswering(endpointWith({})); + + await warnOnEndpointHostMismatch({ + ...names(), + workspaceClient: client, + ...override, + }); + + expect(request).not.toHaveBeenCalled(); + }); + + test.each([ + ["the lookup fails", () => Promise.reject(new Error("403 Forbidden"))], + ["the endpoint lists no hosts", () => endpointWith({})], + ["the response has no status", () => ({ name: "x" })], + ])("never throws or warns when %s", async (_label, answer) => { + const { endpoint, host } = names(); + const request = vi.fn(async () => answer()); + const client = { apiClient: { request } } as unknown as Client; + + await expect( + warnOnEndpointHostMismatch({ endpoint, host, workspaceClient: client }), + ).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + test("warns when the endpoint no longer exists", async () => { + const { endpoint, host } = names(); + const request = vi.fn(async () => { + throw Object.assign(new Error("branch id not found"), { + statusCode: 404, + }); + }); + const client = { apiClient: { request } } as unknown as Client; + + await warnOnEndpointHostMismatch({ + endpoint, + host, + workspaceClient: client, + }); + + expect(warnings()).toContain(`${endpoint} was not found`); + expect(warnings()).toContain("branch id not found"); + }); + + test("gives up on a slow lookup without holding startup", async () => { + vi.useFakeTimers(); + const { endpoint, host } = names(); + const { client } = clientAnswering(new Promise(() => undefined)); + + const check = warnOnEndpointHostMismatch({ + endpoint, + host, + workspaceClient: client, + }); + await vi.advanceTimersByTimeAsync(3_000); + + await expect(check).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + test("shares one lookup between pools for the same endpoint and host", async () => { + const { endpoint, host } = names(); + const { request, client } = clientAnswering( + endpointWith({ host: "ep-expected.database.example.com" }), + ); + + await Promise.all([ + warnOnEndpointHostMismatch({ endpoint, host, workspaceClient: client }), + warnOnEndpointHostMismatch({ endpoint, host, workspaceClient: client }), + ]); + + expect(request).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts b/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts index 4f16b9af9..22f3aef92 100644 --- a/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts +++ b/packages/appkit/src/connectors/lakebase/tests/initialize-pool.test.ts @@ -8,9 +8,11 @@ import type { UserContext } from "../../../context/user-context"; const mocks = vi.hoisted(() => { const me = vi.fn(); - const client = { currentUser: { me } }; + const request = vi.fn(); + const client = { currentUser: { me }, apiClient: { request } }; return { me, + request, client, createPool: vi.fn(), createWorkspaceClient: vi.fn(() => ({ @@ -200,4 +202,27 @@ describe("AppKit Lakebase connector initialization", () => { }); expect(mocks.createPool).not.toHaveBeenCalled(); }); + + test("warns at startup when PGHOST is not a host of the endpoint", async () => { + vi.stubEnv( + "LAKEBASE_ENDPOINT", + "projects/p/branches/fresh/endpoints/primary", + ); + vi.stubEnv("PGHOST", "ep-stale.database.example.test"); + mocks.request.mockResolvedValue({ + status: { hosts: { host: "ep-fresh.database.example.test" } }, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + expect(await initializeLakebasePool()).toBe(pool); + + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/2.0/postgres/projects/p/branches/fresh/endpoints/primary", + }), + ); + const output = warn.mock.calls.flat().map(String).join(" "); + expect(output).toContain("ep-stale.database.example.test"); + expect(output).toContain("ep-fresh.database.example.test"); + }); }); diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index ef8675d65..12d810d34 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -1,25 +1,17 @@ +import { + type DatabaseErrorCategory, + type DatabaseErrorDetail, + databaseErrorCategoryForStatus, +} from "shared"; + import { AppKitError } from "../errors"; import { createLogger } from "../logging/logger"; -const logger = createLogger("database"); - -export type DatabaseErrorCategory = - | "INVALID_REQUEST" - | "VALIDATION_FAILED" - | "NOT_FOUND" - | "CONFLICT" - | "FORBIDDEN" - | "TRANSIENT" - | "UNSUPPORTED_MEDIA_TYPE" - | "PAYLOAD_TOO_LARGE" - | "INTERNAL" - | "SETUP_FAILED"; +// 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"; -/** Which request field a rejection concerns; it never carries caller values. */ -export interface DatabaseErrorDetail { - readonly path: readonly string[]; - readonly message: string; -} +const logger = createLogger("database"); type DatabaseErrorPhase = | "setup" @@ -57,17 +49,6 @@ const definitions: Record< SETUP_FAILED: { message: "Database setup failed", statusCode: 500 }, }; -const categoryByStatus: Readonly> = { - 400: "INVALID_REQUEST", - 403: "FORBIDDEN", - 404: "NOT_FOUND", - 409: "CONFLICT", - 413: "PAYLOAD_TOO_LARGE", - 415: "UNSUPPORTED_MEDIA_TYPE", - 422: "VALIDATION_FAILED", - 503: "TRANSIENT", -}; - /** AppKit-facing database failure with stable metadata and no driver details. */ export class DatabasePluginError extends AppKitError { readonly code = "DATABASE_PLUGIN_ERROR"; @@ -167,6 +148,5 @@ export function databaseErrorFromStatus( status: number, phase: DatabaseErrorPhase, ): DatabasePluginError { - const category = categoryByStatus[status] ?? "INTERNAL"; - return new DatabasePluginError(category, phase); + return new DatabasePluginError(databaseErrorCategoryForStatus(status), phase); } diff --git a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts index b89bcc208..b1cc16a50 100644 --- a/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts +++ b/packages/appkit/src/database/runtime/engine/drizzle-data-path.ts @@ -159,27 +159,81 @@ function upsertUpdateValues( // nested `cause` rather than the thrown error. Walk a bounded chain to find it. const MAX_CAUSE_DEPTH = 5; -function sqlStateOf(error: unknown): string | undefined { +// These SQLSTATE classes describe the connection, the credentials, or schema +// objects, so the server's text names identifiers rather than row values: +// 08 connection, 28 authorization, 3D catalog, 3F schema, 42 undefined objects +// and privileges, 53 resources, 57 operator intervention. Data (22), +// constraint (23), and PL/pgSQL (P0) text can echo values and is never logged. +const DESCRIBED_SQLSTATE_CLASSES = new Set([ + "08", + "28", + "3D", + "3F", + "42", + "53", + "57", +]); +const MAX_DIAGNOSTIC_LENGTH = 500; + +interface DriverFailure { + readonly sqlState?: string; + /** Server-log-only driver text; it never reaches the thrown error. */ + readonly diagnostic?: string; +} + +/** Read the driver's own message, detail, and hint, never a wrapper's. */ +function driverText(carrier: object): string | undefined { + try { + const parts = ["message", "detail", "hint"] + .map((key) => Reflect.get(carrier, key)) + .filter( + (part): part is string => typeof part === "string" && part !== "", + ); + return parts.length > 0 + ? parts.join(" ").slice(0, MAX_DIAGNOSTIC_LENGTH) + : undefined; + } catch { + return undefined; + } +} + +function driverFailureOf(error: unknown): DriverFailure { let current = error; for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { - if (!current || typeof current !== "object") return undefined; + if (!current || typeof current !== "object") return {}; try { const candidate = Reflect.get(current, "code"); + // Node system errors (ECONNREFUSED, ENOTFOUND) name the host, not data. + if ( + typeof candidate === "string" && + /^E[A-Z]+$/.test(candidate) && + typeof Reflect.get(current, "syscall") === "string" + ) { + return { diagnostic: driverText(current) }; + } // SQLSTATE is always a five-character alphanumeric class code. if (typeof candidate === "string" && /^[0-9A-Z]{5}$/.test(candidate)) { - return candidate; + return { + sqlState: candidate, + diagnostic: DESCRIBED_SQLSTATE_CLASSES.has(candidate.slice(0, 2)) + ? driverText(current) + : undefined, + }; } current = Reflect.get(current, "cause"); } catch { - return undefined; + return {}; } } - return undefined; + return {}; } -/** Classify SQLSTATE without retaining the driver error or its properties. */ +/** + * Classify SQLSTATE without retaining the driver error or its properties. + * Described classes add the driver's text to the server log only. + */ function classifyDriverError(error: unknown): DatabasePluginError { - const code = sqlStateOf(error); + const { sqlState: code, diagnostic } = driverFailureOf(error); const category: DatabaseErrorCategory = code === "40001" || code === "40P01" || code === "57014" ? "TRANSIENT" @@ -189,9 +243,10 @@ function classifyDriverError(error: unknown): DatabasePluginError { ? "CONFLICT" : "INTERNAL"; logger.error( - "Database driver error classified as %s (SQLSTATE %s)", + "Database driver error classified as %s (SQLSTATE %s)%s", category, code ?? "unknown", + diagnostic ? `: ${diagnostic}` : "", ); return new DatabasePluginError(category, "runtime"); } diff --git a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts index fbf223733..040aa8fc2 100644 --- a/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts +++ b/packages/appkit/src/database/runtime/tests/drizzle-data-path.test.ts @@ -815,6 +815,131 @@ describe("database failures", () => { } }); + // Schema drift used to surface as a bare SQLSTATE, with no object named. + it.each([ + [ + "42703", + { + message: "column notes.board_id does not exist", + hint: 'Perhaps you meant to reference the column "notes.body".', + }, + ["column notes.board_id does not exist", '"notes.body"'], + ], + [ + "42P01", + { message: 'relation "public.boards" does not exist' }, + ['relation "public.boards" does not exist'], + ], + [ + "28000", + { + message: "External authorization failed.", + detail: "This could be due to paused instances.", + }, + ["External authorization failed.", "paused instances"], + ], + ] as const)( + "logs the driver's own text for described SQLSTATE %s, never the wrapper's", + async (code, fields, expected) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + const driver = Object.assign(new Error(fields.message), { + code, + ...fields, + }); + throw Object.assign( + new Error("Failed query: select * from users\nparams: alice@x.com"), + { cause: driver }, + ); + }; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + + const output = errorLog.mock.calls.flat().map(String).join(" "); + for (const text of expected) expect(output).toContain(text); + expect(output).not.toContain("Failed query"); + expect(output).not.toContain("alice@x.com"); + // The diagnostic stays in the log; callers still get the stable error. + expect(error.message).toBe("Database operation failed"); + expect(JSON.stringify(error)).not.toContain(fields.message); + } finally { + errorLog.mockRestore(); + } + }, + ); + + it.each([ + ["22P02", 'invalid input syntax for type integer: "alice@x.com"'], + ["23502", "null value in column alice@x.com"], + ["P0001", "raised for alice@x.com"], + ])( + "never logs driver text for value-bearing SQLSTATE %s", + async (code, message) => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw { code, message, detail: "Key (email)=(alice@x.com)" }; + }; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch(() => undefined); + + const output = errorLog.mock.calls.flat().map(String).join(" "); + expect(output).toContain(code); + expect(output).not.toContain("alice@x.com"); + } finally { + errorLog.mockRestore(); + } + }, + ); + + it("logs a connection failure's system error text", async () => { + const fake = makeFakeDb(); + const query = fake.db.query as unknown as Record< + string, + { findMany: () => Promise } + >; + query.users.findMany = async () => { + throw Object.assign( + new Error("getaddrinfo ENOTFOUND ep-stale.database.example.com"), + { code: "ENOTFOUND", syscall: "getaddrinfo" }, + ); + }; + const errorLog = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + const error = await createDrizzleDataPath(fake.db, schema) + .select(users, {}) + .catch((caught) => caught); + + const output = errorLog.mock.calls.flat().map(String).join(" "); + expect(output).toContain("ENOTFOUND ep-stale.database.example.com"); + expect(error).toMatchObject({ category: "INTERNAL" }); + } finally { + errorLog.mockRestore(); + } + }); + it("stops walking an error cause cycle", async () => { const fake = makeFakeDb(); const query = fake.db.query as unknown as Record< diff --git a/packages/appkit/src/plugins/database/crud/capabilities.ts b/packages/appkit/src/plugins/database/crud/capabilities.ts new file mode 100644 index 000000000..bb1547834 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/capabilities.ts @@ -0,0 +1,45 @@ +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. + */ +export interface ColumnHttpCapabilities { + /** A request may project 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`. */ + readonly publicKey: boolean; +} + +/** Derive one column's HTTP capabilities from its finalized metadata. */ +export function columnHttpCapabilities( + meta: ColumnMeta, +): ColumnHttpCapabilities { + const selectable = !meta.isPrivate; + // Database-generated identities belong to the server, never the caller. + const creatable = + selectable && + !meta.serverGenerated && + !(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. + updatable: + creatable && !meta.primaryKey && !meta.defaultNow && !meta.defaultRandom, + // A private key must not power `GET /:table/:id`: per-id probing would + // answer 200 or 404 on an identifier the schema hides, so over HTTP the + // table is keyless — no detail route, and lists must name their own order. + publicKey: meta.primaryKey && selectable, + }; +} diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts index 97b4d884d..44042178a 100644 --- a/packages/appkit/src/plugins/database/crud/contract.ts +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -1,8 +1,8 @@ import { DatabasePluginError } from "../../../database/errors"; import type { Row } from "../../../database/runtime"; import type { AppKitTable } from "../../../database/schema-builder"; -import { filterOperatorsForKind } from "../../../database/schema-builder/types"; 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. */ @@ -191,23 +191,13 @@ function compileTable(table: AppKitTable): MutableCrudTable { for (const meta of Object.values(table.$columns)) { const column = compileColumn(meta); columns.set(meta.columnName, column); - // A private key must not power `GET /:table/:id`: per-id probing would - // answer 200 or 404 on an identifier the schema hides, so over HTTP the - // table is keyless — no detail route, and lists must name their own order. - if (meta.primaryKey && !meta.isPrivate) primaryKey = column; - if (meta.isPrivate) continue; - selectable.add(meta.columnName); - if (filterOperatorsForKind(meta.kind).length > 0) { - queryable.add(meta.columnName); - } - // Database-generated identities belong to the server, never the caller. - if (meta.serverGenerated || (meta.primaryKey && meta.defaultRandom)) - continue; - creatable.add(meta.columnName); - // Rewriting a key would move a row out from under every existing reference, - // and rewriting a database-materialized stamp would rewrite history. - if (meta.primaryKey || meta.defaultNow || meta.defaultRandom) continue; - updatable.add(meta.columnName); + // Type generation reads the same predicates for the browser's types. + 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 = { diff --git a/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts new file mode 100644 index 000000000..8127bfb46 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + jsonb, + text, + timestamp, + uuid, +} from "../../../../database/schema-builder"; +import { walkSchema } from "../../../../type-generator/database/walk-schema"; +import { columnHttpCapabilities } from "../capabilities"; +import { compileCrudTables } from "../contract"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text().notNull(), + token: text().private(), + profile: jsonb(), + total: bigint(), + }); + const invites = builder.table("invites", { + code: text().primaryKey(), + email: text().notNull(), + label: text().default("guest"), + createdAt: timestamp().defaultNow(), + userId: fk(() => users.id), + }); + const sessions = builder.table("sessions", { + id: uuid().primaryKey().defaultRandom(), + token: uuid().defaultRandom(), + active: boolean().notNull(), + kind: enumColumn("session_kind", ["web", "cli"]), + }); + const audits = builder.table("audits", { + id: id().private(), + action: text(), + }); + const blobs = builder.table("blobs", { payload: jsonb() }); + return { users, invites, sessions, audits, blobs }; +}); + +/** Property names in one rendered object facet, in declaration order. */ +function facetKeys(facet: string): string[] { + return [...facet.matchAll(/^ +"([^"]+)"\??:/gm)].map((match) => match[1]); +} + +/** Members of one rendered literal union; `never` renders as none. */ +function unionMembers(union: string): string[] { + return union === "never" ? [] : union.split(" | ").map((m) => JSON.parse(m)); +} + +describe("columnHttpCapabilities", () => { + it("keeps a private column out of every HTTP capability", () => { + expect(columnHttpCapabilities(schema.$tables.users.$columns.token)).toEqual( + { + selectable: false, + queryable: false, + creatable: false, + updatable: false, + publicKey: false, + }, + ); + expect( + columnHttpCapabilities(schema.$tables.audits.$columns.id).publicKey, + ).toBe(false); + }); + + 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, + }); + expect(columnHttpCapabilities(invites.$columns.code)).toMatchObject({ + creatable: true, + updatable: false, + publicKey: true, + }); + expect(columnHttpCapabilities(invites.$columns.createdAt)).toMatchObject({ + creatable: true, + updatable: false, + }); + expect(columnHttpCapabilities(sessions.$columns.id)).toMatchObject({ + creatable: false, + publicKey: true, + }); + expect(columnHttpCapabilities(sessions.$columns.token)).toMatchObject({ + creatable: true, + updatable: false, + }); + expect(columnHttpCapabilities(users.$columns.profile)).toMatchObject({ + selectable: true, + queryable: false, + updatable: true, + }); + }); +}); + +describe("HTTP capability parity", () => { + const compiled = compileCrudTables(schema.$tables); + const entries = new Map(walkSchema(schema).map((e) => [e.name, e])); + + it.each(Object.keys(schema.$tables))( + "generates the %s api facet from the sets its routes compile", + (name) => { + const table = compiled.get(name); + const api = entries.get(name)?.api; + if (!table || !api) throw new Error(`missing ${name}`); + + 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] : [], + ); + }, + ); + + it("never renders a private column into an api facet", () => { + const users = entries.get("users"); + // The trusted facets keep the private column; the HTTP ones never see it. + expect(users?.insert).toContain('"token"'); + expect(Object.values(users?.api ?? {}).join("\n")).not.toContain('"token"'); + expect( + 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/wire-roundtrip.test.ts b/packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts new file mode 100644 index 000000000..462520733 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts @@ -0,0 +1,176 @@ +import { encodeDatabaseListQuery, encodeDatabaseRecordQuery } from "shared"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_LIMIT } from "../../../../database/contract"; +import { + bigint, + boolean, + defineSchema, + fk, + id, + integer, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { decodeDetailQuery, decodeListQuery } from "../query"; + +// The browser client encodes with the shared codec; these are the decoders its +// requests actually meet, so the two sides are proven against each other here. +const schema = defineSchema((builder) => { + const boards = builder.table("boards", { + id: id(), + title: text().notNull(), + archived: boolean().notNull(), + }); + const notes = builder.table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + body: text().notNull(), + rank: integer(), + views: bigint(), + created_at: timestamp(), + }); + const note_events = builder.table("note_events", { + id: id(), + note_id: fk(() => notes.id).notNull(), + action: text().notNull(), + }); + return { boards, notes, note_events }; +}); + +const tables = compileCrudTables(schema.$tables); +const boards = tables.get("boards") as CrudTable; +const notes = tables.get("notes") as CrudTable; + +describe("shared encoder against decodeListQuery", () => { + it("round-trips every list parameter", () => { + const decoded = decodeListQuery( + notes, + encodeDatabaseListQuery({ + where: { + board_id: 7, + body: { ilike: "%ship it%" }, + or: [{ rank: { gte: 1, lt: 5 } }, { id: { in: [1, 2, 3] } }], + and: [{ created_at: { is: null } }], + }, + order: { created_at: "desc", body: "asc" }, + select: ["id", "body"], + include: { boards: { select: ["title"] } }, + limit: 20, + offset: 40, + }), + ); + + expect(decoded).toEqual({ + where: { + board_id: 7, + body: { ilike: "%ship it%" }, + or: [{ rank: { gte: 1, lt: 5 } }, { id: { in: [1, 2, 3] } }], + and: [{ created_at: { is: null } }], + }, + order: { created_at: "desc", body: "asc" }, + select: ["id", "body"], + include: { boards: { select: ["title"] } }, + limit: 20, + offset: 40, + }); + // Sort priority is the order of the keys, so it has to survive the trip. + expect(Object.keys(decoded.order ?? {})).toEqual(["created_at", "body"]); + }); + + it("leaves omitted parameters to the server defaults", () => { + expect(decodeListQuery(notes, encodeDatabaseListQuery({}))).toEqual({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect( + decodeListQuery( + boards, + encodeDatabaseListQuery({ include: { notes: true }, limit: undefined }), + ), + ).toMatchObject({ include: { notes: { limit: DEFAULT_LIMIT } } }); + }); + + it("carries reserved and non-ASCII characters through unchanged", () => { + const text = "a+b & c=d %25 # ? / é 🚀"; + expect( + decodeListQuery( + notes, + encodeDatabaseListQuery({ where: { body: { eq: text } } }), + ).where, + ).toEqual({ body: { eq: text } }); + }); + + it("decodes a bigint operand from its decimal string", () => { + expect( + decodeListQuery( + notes, + encodeDatabaseListQuery({ + where: { + views: { gt: "9007199254740993" }, + or: [{ views: 9007199254740993n }], + }, + }), + ).where, + ).toEqual({ + views: { gt: 9007199254740993n }, + or: [{ views: 9007199254740993n }], + }); + }); + + it("round-trips a two-edge include with a to-many limit", () => { + expect( + decodeListQuery( + boards, + encodeDatabaseListQuery({ + include: { + notes: { + limit: 5, + order: { created_at: "desc" }, + where: { rank: { gt: 0 } }, + include: { note_events: { limit: 3 } }, + }, + }, + limit: 10, + }), + ).include, + ).toEqual({ + notes: { + limit: 5, + order: { created_at: "desc" }, + where: { rank: { gt: 0 } }, + include: { note_events: { limit: 3 } }, + }, + }); + }); +}); + +describe("shared encoder against decodeDetailQuery", () => { + it("round-trips projection and includes", () => { + expect( + decodeDetailQuery( + boards, + encodeDatabaseRecordQuery({ + select: ["id", "title"], + include: { + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }, + }), + ), + ).toEqual({ + select: ["id", "title"], + include: { + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }, + }); + expect(decodeDetailQuery(boards, encodeDatabaseRecordQuery({}))).toEqual({ + select: undefined, + include: undefined, + }); + }); +}); diff --git a/packages/appkit/src/plugins/database/lifecycle.ts b/packages/appkit/src/plugins/database/lifecycle.ts index 60fa80066..38347c54a 100644 --- a/packages/appkit/src/plugins/database/lifecycle.ts +++ b/packages/appkit/src/plugins/database/lifecycle.ts @@ -23,6 +23,7 @@ import type { SqlTag, TransactionClient, } from "./entity-types"; +import { assertSchemaMatchesDatabase } from "./schema-check"; import { createMutationScope, type MutationScope } from "./scope"; import type { DatabaseHooks } from "./types"; @@ -161,7 +162,10 @@ function buildDatabaseExports(context: ExportContext): DatabaseExports { return result; } -/** Validate the schema, create one pool-backed API, and verify connectivity. */ +/** + * Validate the schema, create one pool-backed API, and verify connectivity and + * that every declared table and column exists. + */ export async function createDatabaseState( schema: TSchema, execute: EntityExecute, @@ -200,6 +204,7 @@ export async function createDatabaseState( }); // Do not publish exports until an authenticated statement succeeds. await dataPath.raw`select 1`; + await assertSchemaMatchesDatabase(dataPath, schema); return { pool, exports, @@ -208,9 +213,15 @@ export async function createDatabaseState( }, }; } catch (error) { - logger.error("Database setup failed: %O", error); active = false; await pool?.end().catch(() => undefined); + // A setup failure names its own reason from schema metadata; anything + // else may carry connector or driver details and is replaced. + if (error instanceof DatabasePluginError && error.phase === "setup") { + logger.error("%s", error.message); + throw error; + } + logger.error("Database setup failed: %O", error); throw new DatabasePluginError("SETUP_FAILED", "setup"); } } diff --git a/packages/appkit/src/plugins/database/schema-check.ts b/packages/appkit/src/plugins/database/schema-check.ts new file mode 100644 index 000000000..9f012a4f0 --- /dev/null +++ b/packages/appkit/src/plugins/database/schema-check.ts @@ -0,0 +1,65 @@ +import { databaseSetupFailed } from "../../database/errors"; +import type { DataPath } from "../../database/runtime"; +import type { Schema } from "../../database/schema-builder"; + +interface CatalogColumn { + readonly table_name: string; + readonly column_name: string | null; +} + +/** + * Confirm every declared table and column exists before routes are published. + * The plugin never migrates, so drift would otherwise surface on each request + * as an undefined-table or undefined-column failure. pg_catalog is read rather + * than information_schema, which hides objects the role cannot access and + * would report a privilege gap as a missing column. + */ +export async function assertSchemaMatchesDatabase( + dataPath: DataPath, + schema: Schema, +): Promise { + const tables = Object.values(schema.$tables); + if (tables.length === 0) return; + const schemaName = schema.$schemaName; + const tableNames = tables.map((table) => table.$name); + // Tables, partitioned tables, views, materialized views, and foreign tables. + const rows = await dataPath.raw` + select c.relname::text as table_name, a.attname::text as column_name + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + left join pg_catalog.pg_attribute a + on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped + where n.nspname::text = ${schemaName} + and c.relname::text = any(${tableNames}::text[]) + and c.relkind in ('r', 'p', 'v', 'm', 'f')`; + + const found = new Map>(); + for (const row of rows) { + const columns = found.get(row.table_name) ?? new Set(); + if (row.column_name) columns.add(row.column_name); + found.set(row.table_name, columns); + } + + const problems: string[] = []; + for (const table of tables) { + const qualified = `${schemaName}.${table.$name}`; + const columns = found.get(table.$name); + if (!columns) { + problems.push(`table ${qualified} does not exist`); + continue; + } + const missing = Object.values(table.$columns) + .map((meta) => meta.columnName) + .filter((name) => !columns.has(name)); + if (missing.length > 0) { + problems.push( + `table ${qualified} is missing ${missing.length === 1 ? "column" : "columns"} ${missing.join(", ")}`, + ); + } + } + if (problems.length > 0) { + throw databaseSetupFailed( + `the declared schema does not match the database: ${problems.join("; ")}. Create or migrate these before starting the app; the plugin does not.`, + ); + } +} diff --git a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts index 7a2b6fe6f..6088a4315 100644 --- a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts @@ -20,6 +20,10 @@ vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ createDrizzleDb: mocks.createDrizzleDb, createDrizzleDataPath: mocks.createDrizzleDataPath, })); +// The catalog comparison has its own suite; this fake answers no catalog. +vi.mock("../schema-check", () => ({ + assertSchemaMatchesDatabase: async () => undefined, +})); import { DatabasePlugin } from "../database"; diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts index 20bbb04de..e70d822b5 100644 --- a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ initializeLakebasePool: vi.fn(), createDrizzleDb: vi.fn(), createDrizzleDataPath: vi.fn(), + assertSchemaMatchesDatabase: vi.fn(), })); vi.mock("../../../connectors/lakebase", () => ({ initializeLakebasePool: mocks.initializeLakebasePool, @@ -16,6 +17,10 @@ vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ createDrizzleDb: mocks.createDrizzleDb, createDrizzleDataPath: mocks.createDrizzleDataPath, })); +// The catalog comparison has its own suite; these fakes answer no catalog. +vi.mock("../schema-check", () => ({ + assertSchemaMatchesDatabase: mocks.assertSchemaMatchesDatabase, +})); import { IDLE_IN_TRANSACTION_TIMEOUT_MS, @@ -182,6 +187,29 @@ describe("createDatabaseState", () => { }, ); + test("checks the catalog after readiness and keeps a drift failure's reason", async () => { + const { pool, path, execute } = arrange(); + const drift = new DatabasePluginError( + "SETUP_FAILED", + "setup", + "Database setup failed: table public.tags does not exist", + ); + mocks.assertSchemaMatchesDatabase.mockRejectedValueOnce(drift); + + const error = await createDatabaseState(schema, execute).catch( + (caught) => caught, + ); + + expect(path.raw).toHaveBeenCalledTimes(1); + expect(mocks.assertSchemaMatchesDatabase).toHaveBeenCalledWith( + path, + schema, + ); + expect(error).toBe(drift); + expect(error.message).toContain("table public.tags does not exist"); + expect(pool.end).toHaveBeenCalledTimes(1); + }); + test("sanitizes connector initialization failures", async () => { const { execute } = arrange(); mocks.initializeLakebasePool.mockRejectedValueOnce( diff --git a/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts index 7aab7a0ed..514f60bc0 100644 --- a/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts @@ -22,6 +22,10 @@ vi.mock("../../../database/runtime/engine/drizzle-data-path", () => ({ createDrizzleDb: mocks.createDrizzleDb, createDrizzleDataPath: mocks.createDrizzleDataPath, })); +// The catalog comparison has its own suite; this fake answers no catalog. +vi.mock("../schema-check", () => ({ + assertSchemaMatchesDatabase: async () => undefined, +})); import { DatabasePlugin } from "../database"; diff --git a/packages/appkit/src/plugins/database/tests/schema-check.test.ts b/packages/appkit/src/plugins/database/tests/schema-check.test.ts new file mode 100644 index 000000000..df5a80c87 --- /dev/null +++ b/packages/appkit/src/plugins/database/tests/schema-check.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { DataPath } from "../../../database/runtime"; +import { defineSchema, fk, id, text } from "../../../database/schema-builder"; +import { assertSchemaMatchesDatabase } from "../schema-check"; + +const schema = defineSchema(({ table }) => { + const boards = table("boards", { id: id(), title: text().notNull() }); + const notes = table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + author_email: text().private(), + body: text().notNull(), + }); + return { boards, notes }; +}); + +type CatalogRow = { table_name: string; column_name: string | null }; + +/** Answer the catalog query with the given tables and columns. */ +function catalog(tables: Record) { + const rows: CatalogRow[] = Object.entries(tables).flatMap( + ([name, columns]): CatalogRow[] => + columns.length === 0 + ? [{ table_name: name, column_name: null }] + : columns.map((column) => ({ table_name: name, column_name: column })), + ); + const raw = vi.fn(async () => rows); + return { raw, path: { raw } as unknown as DataPath }; +} + +describe("assertSchemaMatchesDatabase", () => { + test("accepts a database with every declared column, and extra ones", async () => { + const { path } = catalog({ + boards: ["id", "title", "archived_at"], + notes: ["id", "board_id", "author_email", "body"], + }); + await expect( + assertSchemaMatchesDatabase(path, schema), + ).resolves.toBeUndefined(); + }); + + test("names every missing table and column in one setup failure", async () => { + // The shape a same-named table from another app leaves behind. + const { path } = catalog({ notes: ["id", "case_id", "author", "content"] }); + + const error = await assertSchemaMatchesDatabase(path, schema).catch( + (caught) => caught, + ); + + expect(error).toMatchObject({ category: "SETUP_FAILED", phase: "setup" }); + expect(error.message).toContain("table public.boards does not exist"); + expect(error.message).toContain( + "table public.notes is missing columns board_id, author_email, body", + ); + // Only the stable message crosses a request boundary. + expect(error.clientMessage).toBe("Database setup failed"); + }); + + test("uses the singular for one missing column", async () => { + const { path } = catalog({ + boards: ["id", "title"], + notes: ["id", "board_id", "body"], + }); + await expect(assertSchemaMatchesDatabase(path, schema)).rejects.toThrow( + "table public.notes is missing column author_email", + ); + }); + + test("reports a table with no readable columns as missing them all", async () => { + const { path } = catalog({ + boards: [], + notes: ["id", "board_id", "author_email", "body"], + }); + await expect(assertSchemaMatchesDatabase(path, schema)).rejects.toThrow( + "table public.boards is missing columns id, title", + ); + }); + + test("passes the schema and table names as parameter values", async () => { + const scoped = defineSchema( + ({ table }) => ({ tags: table("tags", { id: id() }) }), + { schemaName: "playground" }, + ); + const { raw, path } = catalog({ tags: ["id"] }); + + await assertSchemaMatchesDatabase(path, scoped); + + expect(raw).toHaveBeenCalledTimes(1); + const [strings, ...values] = raw.mock.calls[0] as unknown as [ + TemplateStringsArray, + ...unknown[], + ]; + expect(strings.join("?")).toContain("pg_catalog.pg_attribute"); + expect(values).toEqual(["playground", ["tags"]]); + }); + + test("skips the catalog query for an empty schema", async () => { + const { raw, path } = catalog({}); + await assertSchemaMatchesDatabase( + path, + defineSchema(() => ({})), + ); + expect(raw).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts index 8518b3ab2..edd5153e1 100644 --- a/packages/appkit/src/plugins/lakebase/lakebase.ts +++ b/packages/appkit/src/plugins/lakebase/lakebase.ts @@ -11,6 +11,7 @@ import { type LakebasePool, type LakebasePoolManager, RoutingPool, + warnOnEndpointHostMismatch, } from "../../connectors/lakebase"; import { getClientOptions } from "../../context/client-options"; import { getUserContext } from "../../context/execution-context"; @@ -89,7 +90,10 @@ export class LakebasePlugin extends Plugin implements ToolProvider { clientOptions: getClientOptions(), }).toLegacyWorkspaceClient(), }; - const user = await getUsernameWithApiLookup(poolConfig); + const [user] = await Promise.all([ + getUsernameWithApiLookup(poolConfig), + warnOnEndpointHostMismatch(poolConfig), + ]); const spPool = createLakebasePool({ ...poolConfig, user }); logger.info("Lakebase SP pool initialized"); diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts index 5d744ee72..eb557a498 100644 --- a/packages/appkit/src/type-generator/database/generate.ts +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -52,33 +52,53 @@ declare module "@databricks/appkit" { } `; -/** Render one `DatabaseRegistry` augmentation from a finalized schema. */ +/** + * Render one entries interface from a finalized schema and bind it into both + * `DatabaseRegistry` targets. It is still one registry: the two module blocks + * exist only because the server and UI packages do not depend on each other. + * A project without the UI package cannot resolve its block; like the + * analytics declarations, that relies on `skipLibCheck` to stay silent. + */ function render(schema: Schema): string { const entries = walkSchema(schema) .map( - (entry) => ` ${JSON.stringify(entry.name)}: { - row: ${entry.row}; - publicRow: ${entry.publicRow}; - insert: ${entry.insert}; - update: ${entry.update}; - filters: ${entry.filters}; - includes: ${entry.includes}; - hasPrimaryKey: ${entry.hasPrimaryKey}; - };`, + (entry) => ` ${JSON.stringify(entry.name)}: { + row: ${entry.row}; + publicRow: ${entry.publicRow}; + insert: ${entry.insert}; + update: ${entry.update}; + filters: ${entry.filters}; + includes: ${entry.includes}; + hasPrimaryKey: ${entry.hasPrimaryKey}; + api: { + insert: ${entry.api.insert}; + update: ${entry.api.update}; + filters: ${entry.api.filters}; + orderable: ${entry.api.orderable}; + key: ${entry.api.key}; + }; + };`, ) .join("\n"); return `// Auto-generated by AppKit - DO NOT EDIT import "@databricks/appkit"; +import "@databricks/appkit-ui/js/beta"; -declare module "@databricks/appkit" { - type DatabaseLogicalFilter = T & { - and?: readonly DatabaseLogicalFilter[]; - or?: readonly DatabaseLogicalFilter[]; - }; +type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; +}; - interface DatabaseRegistry { +interface GeneratedDatabaseRegistry { ${entries} - } +} + +declare module "@databricks/appkit" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} +} + +declare module "@databricks/appkit-ui/js/beta" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} } `; } diff --git a/packages/appkit/src/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts index b8a29271a..230d87508 100644 --- a/packages/appkit/src/type-generator/database/tests/generate.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -13,6 +13,7 @@ const roots: string[] = []; const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); const sourceRoot = path.join(appkitRoot, "src"); const builder = path.join(sourceRoot, "database/schema-builder/index.ts"); +const uiRoot = path.resolve(appkitRoot, "../appkit-ui/src/js"); afterEach(async () => Promise.all( @@ -82,7 +83,7 @@ describe("generateDatabaseTypes", () => { const users = output.slice( output.indexOf('"users": {'), - output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "posts": {\n row:'), ); expect(users.match(/"secret"\??: string;/g)).toHaveLength(3); expect(users).toContain("publicRow:"); @@ -92,8 +93,8 @@ describe("generateDatabaseTypes", () => { expect(users).not.toContain('update: {\n "slug"'); const posts = output.slice( - output.indexOf('\n "posts": {\n row:'), - output.indexOf('\n "events": {\n row:'), + output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "events": {\n row:'), ); expect(posts).not.toContain('insert: {\n "id"'); expect(posts).not.toContain('update: {\n "id"'); @@ -122,6 +123,68 @@ describe("generateDatabaseTypes", () => { expect(output).toContain("filters: DatabaseLogicalFilter<{}>;"); }); + test("renders HTTP api facets from the route capabilities", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + const api = (table: string) => { + const entry = output.indexOf(`\n ${JSON.stringify(table)}: {\n row:`); + const start = output.indexOf("\n api: {", entry); + return output.slice(start, output.indexOf("\n };", start)); + }; + + // A private column reaches the trusted facets but none of the HTTP ones. + const users = api("users"); + expect(users).not.toContain('"secret"'); + expect(users).toContain('insert: {\n "slug": string;'); + expect(users).toContain('"created_at"?: string;'); + expect(users).toContain( + 'update: {\n "name"?: string;\n "nickname"?: string | null;\n };', + ); + expect(users).toContain( + 'orderable: "slug" | "name" | "nickname" | "created_at";', + ); + expect(users).toContain('key: "slug";'); + + // Server identities are never inputs, and a random default is no update. + const posts = api("posts"); + expect(posts).not.toContain('insert: {\n "id"'); + expect(posts).toContain('"external_id"?: string;'); + expect(posts).not.toContain('update: {\n "id"'); + expect( + posts.slice(posts.indexOf("update:"), posts.indexOf("filters:")), + ).not.toContain('"external_id"'); + // HTTP filters have no bare-array shorthand and no JSON columns. + expect(posts).toContain( + '"status"?: "draft" | "live" | { eq?: "draft" | "live"; neq?: "draft" | "live"; in?: readonly ("draft" | "live")[]; };', + ); + expect(posts.slice(posts.indexOf("filters:"))).not.toContain('"payload"'); + expect(posts).toContain('key: "id";'); + + expect(api("events")).toContain('orderable: "message";'); + expect(api("events")).toContain("key: never;"); + expect(api("blobs")).toContain("filters: DatabaseLogicalFilter<{}>;"); + expect(api("blobs")).toContain("orderable: never;"); + }); + + test("binds one entries interface into the server and UI registries", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + + expect(output).toContain('import "@databricks/appkit";'); + expect(output).toContain('import "@databricks/appkit-ui/js/beta";'); + expect( + output.match(/interface GeneratedDatabaseRegistry \{/g), + ).toHaveLength(1); + expect(output).toContain( + 'declare module "@databricks/appkit" {\n interface DatabaseRegistry extends GeneratedDatabaseRegistry {}\n}', + ); + expect(output).toContain( + 'declare module "@databricks/appkit-ui/js/beta" {\n interface DatabaseRegistry extends GeneratedDatabaseRegistry {}\n}', + ); + }); + test("accepts named valid and explicitly empty schemas", async () => { const valid = await files(completeSchema); await generateDatabaseTypes(valid); @@ -133,7 +196,7 @@ describe("generateDatabaseTypes", () => { `); await generateDatabaseTypes(empty); expect(await fs.readFile(empty.outFile, "utf8")).toContain( - "interface DatabaseRegistry {\n\n }", + "interface GeneratedDatabaseRegistry {\n\n}", ); }); @@ -229,13 +292,12 @@ describe("generateDatabaseTypes", () => { expect((await fs.stat(options.outFile)).mtimeMs).toBe(before); }); - test("compiles a semantic consumer through the beta subpath", async () => { + // No UI package resolves here, so its binding must stay silent under skipLibCheck. + test("compiles a server-only semantic consumer through the beta subpath", async () => { const options = await files(completeSchema); await generateDatabaseTypes(options); - const consumer = path.join(options.root, "consumer.ts"); - const tsconfig = path.join(options.root, "tsconfig.json"); - await fs.writeFile( - consumer, + await compileConsumer( + options, ` import { database, type DatabaseExports, type IDatabaseConfig } from "@databricks/appkit/beta"; database(); @@ -279,42 +341,179 @@ describe("generateDatabaseTypes", () => { db.users.create({ slug: "ada", name: "Ada", secret: "token", nickname: 1 }); `, ); - await fs.writeFile( - tsconfig, - JSON.stringify({ - compilerOptions: { - strict: true, - noEmit: true, - target: "ES2022", - module: "ESNext", - moduleResolution: "Bundler", - esModuleInterop: true, - resolveJsonModule: true, - skipLibCheck: true, - baseUrl: options.root, - paths: { - "@databricks/appkit": [ - path.join(sourceRoot, "database/contract/index.ts"), - ], - "@databricks/appkit/beta": [ - path.join(sourceRoot, "plugins/database/index.ts"), - ], - shared: [path.resolve(appkitRoot, "../shared/src/index.ts")], - // CI runs unit tests before build, including imports of shared subpaths. - "shared/*": [path.resolve(appkitRoot, "../shared/src/*")], - "@databricks/lakebase": [ - path.resolve(appkitRoot, "../lakebase/src/index.ts"), - ], - }, - }, - files: [options.outFile, consumer], - }), - ); + }, 30_000); + + test("compiles a browser consumer bound to the same generated entries", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + await compileConsumer( + options, + ` + import { type DatabaseExports } from "@databricks/appkit/beta"; + import { + databaseApi, + DatabaseApiError, + type DatabaseEntity, + type DatabaseListRow, + } from "@databricks/appkit-ui/js/beta"; + + // The server entities and the browser entities are one registry. + declare const db: DatabaseExports; + const server: keyof DatabaseExports = "users"; + const entity: DatabaseEntity = "users"; + void [db, server, entity]; + // @ts-expect-error browser entities are generated table names + const missing: DatabaseEntity = "missing"; + void missing; + + async function reads() { + const users = await databaseApi.list("users", { + where: { name: { ilike: "%ada%" }, or: [{ nickname: { is: null } }] }, + order: { created_at: "desc" }, + include: { posts: { limit: 2, select: ["title", "total"] } }, + }); + const title: string | undefined = users.items[0]?.posts[0]?.title; + // A bigint travels as its decimal string. + const total: string | undefined = users.items[0]?.posts[0]?.total; + // @ts-expect-error private columns are absent from public rows + users.items[0]?.secret; + // @ts-expect-error unselected relation columns are absent + users.items[0]?.posts[0]?.score; + + const posts = await databaseApi.list("posts", { + select: ["id", "title"], + include: { users: { include: { posts: { limit: 1 } } } }, + }); + const owner: { slug: string; name: string } | null | undefined = + posts.items[0]?.users; + const nested: number | undefined = posts.items[0]?.users?.posts[0]?.id; + // @ts-expect-error unselected columns are absent + posts.items[0]?.score; + void [title, total, owner, nested]; - await expect( - execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { - cwd: path.resolve(appkitRoot, "../.."), - }), - ).resolves.toMatchObject({ stderr: "" }); + type Picked = DatabaseListRow<"posts", { select: readonly ["id"] }>; + const picked: Picked = { id: 1 }; + void picked; + + await databaseApi.list("posts", { where: { total: { gt: "9007199254740993" } } }); + await databaseApi.list("events", { order: { message: "asc" } }); + } + + async function rejected() { + // @ts-expect-error private columns are not HTTP filters, even beside a valid one + await databaseApi.list("users", { where: { name: "Ada", secret: "token" } }); + // @ts-expect-error private columns are not selectable + await databaseApi.list("users", { select: ["slug", "secret"] }); + // @ts-expect-error private columns cannot order a list + await databaseApi.list("users", { order: { name: "asc", secret: "asc" } }); + // @ts-expect-error JSON columns are not queryable + await databaseApi.list("posts", { where: { payload: { eq: 1 } } }); + // @ts-expect-error HTTP filters have no bare-array shorthand + await databaseApi.list("posts", { where: { status: ["draft"] } }); + // @ts-expect-error unknown operators are rejected inside an operator object + await databaseApi.list("posts", { where: { score: { gte: 1, near: 2 } } }); + // @ts-expect-error a to-one include takes no limit + await databaseApi.list("posts", { include: { users: { limit: 1 } } }); + // @ts-expect-error includes stop at two relation edges + await databaseApi.list("users", { include: { posts: { include: { users: { include: { posts: true } } } } } }); + // @ts-expect-error an include filter checks the target's public facets + await databaseApi.list("posts", { include: { users: { where: { name: "Ada", secret: "x" } } } }); + // @ts-expect-error generated routes decode includes as true or options, never false + await databaseApi.list("users", { include: { posts: false } }); + // @ts-expect-error list params are the generated route's parameters only + await databaseApi.list("users", { limit: 1, includeTotal: true }); + } + + async function writes() { + const post = await databaseApi.create("posts", { + user_slug: "ada", + title: "Hi", + total: "9007199254740993", + active: true, + status: "draft", + payload: { tags: ["a"] }, + }); + const total: string = post.total; + await databaseApi.update("posts", post.id, { score: null, status: "live" }); + await databaseApi.update("users", "ada", { nickname: "ada" }); + await databaseApi.remove("users", "ada"); + await databaseApi.create("events", { message: "keyless tables accept creates" }); + // @ts-expect-error private columns are not HTTP inputs + await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); + // @ts-expect-error a generated key is not an HTTP input + await databaseApi.create("posts", { id: 1, user_slug: "ada", title: "Hi", total: 1, active: true, status: "draft" }); + // @ts-expect-error default-stamped columns are not updatable + await databaseApi.update("users", "ada", { created_at: "2026-01-01" }); + // @ts-expect-error random-default columns are not updatable + await databaseApi.update("posts", 1, { external_id: "00000000-0000-0000-0000-000000000000" }); + // @ts-expect-error keyless entities have no update route + await databaseApi.update("events", "x", { message: "y" }); + void total; + } + + function failed(error: unknown) { + if (error instanceof DatabaseApiError && error.code === "NOT_EXPOSED") { + const status: number | null = error.status; + return status; + } + return error instanceof DatabaseApiError ? error.details[0]?.message : undefined; + } + void [reads, rejected, writes, failed]; + `, + { ui: true }, + ); }, 30_000); }); + +/** Type-check `source` against the generated declaration, as an app would. */ +async function compileConsumer( + options: { root: string; outFile: string }, + source: string, + { ui = false } = {}, +): Promise { + const consumer = path.join(options.root, "consumer.ts"); + const tsconfig = path.join(options.root, "tsconfig.json"); + await fs.writeFile(consumer, source); + await fs.writeFile( + tsconfig, + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + esModuleInterop: true, + resolveJsonModule: true, + skipLibCheck: true, + baseUrl: options.root, + paths: { + "@databricks/appkit": [ + path.join(sourceRoot, "database/contract/index.ts"), + ], + "@databricks/appkit/beta": [ + path.join(sourceRoot, "plugins/database/index.ts"), + ], + ...(ui + ? { + "@databricks/appkit-ui/js/beta": [path.join(uiRoot, "beta.ts")], + } + : {}), + shared: [path.resolve(appkitRoot, "../shared/src/index.ts")], + // CI runs unit tests before build, including imports of shared subpaths. + "shared/*": [path.resolve(appkitRoot, "../shared/src/*")], + "@databricks/lakebase": [ + path.resolve(appkitRoot, "../lakebase/src/index.ts"), + ], + }, + }, + files: [options.outFile, consumer], + }), + ); + + await expect( + execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { + cwd: path.resolve(appkitRoot, "../.."), + }), + ).resolves.toMatchObject({ stderr: "" }); +} diff --git a/packages/appkit/src/type-generator/database/walk-schema.ts b/packages/appkit/src/type-generator/database/walk-schema.ts index 1b6f49f21..06315e551 100644 --- a/packages/appkit/src/type-generator/database/walk-schema.ts +++ b/packages/appkit/src/type-generator/database/walk-schema.ts @@ -4,6 +4,19 @@ import type { Schema, } from "../../database/schema-builder"; import { filterOperatorsForKind } from "../../database/schema-builder/types"; +import { + type ColumnHttpCapabilities, + columnHttpCapabilities, +} from "../../plugins/database/crud/capabilities"; + +/** Render-ready HTTP facets, derived from the predicates the routes compile. */ +interface ApiEntry { + readonly insert: string; + readonly update: string; + readonly filters: string; + readonly orderable: string; + readonly key: string; +} /** Render-ready type facets for one database registry entry. */ interface RegistryEntry { @@ -15,8 +28,13 @@ interface RegistryEntry { readonly filters: string; readonly includes: string; readonly hasPrimaryKey: boolean; + readonly api: ApiEntry; } +/** Indentation of a facet's key: trusted facets under an entry, HTTP under `api`. */ +const TRUSTED = " "; +const API = " "; + /** Keep generated scalars aligned with the schema's canonical runtime values. */ function tsType(meta: ColumnMeta): string { switch (meta.kind) { @@ -42,13 +60,17 @@ function tsType(meta: ColumnMeta): string { } } -function objectFacet(lines: string[], empty = "{}"): string { - return lines.length ? `{\n${lines.join("\n")}\n }` : empty; +function objectFacet(lines: string[], indent: string, empty = "{}"): string { + return lines.length ? `{\n${lines.join("\n")}\n${indent}}` : empty; } -function property(meta: ColumnMeta, optional = false): string { +function property(meta: ColumnMeta, indent: string, optional = false): string { const nullable = meta.notNull ? "" : " | null"; - return ` ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; + return `${indent} ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; +} + +function literalUnion(names: string[]): string { + return names.map((name) => JSON.stringify(name)).join(" | ") || "never"; } // Trusted facets retain private columns; only public rows project them out. @@ -56,34 +78,40 @@ function rowType(table: AppKitTable, publicOnly: boolean): string { return objectFacet( Object.values(table.$columns) .filter((c) => !publicOnly || !c.isPrivate) - .map((c) => property(c)), + .map((c) => property(c, TRUSTED)), + TRUSTED, "Record", ); } // Write facets mirror trusted validators; updates additionally omit primary keys. -function insertType(table: AppKitTable): string { +function insertType(columns: ColumnMeta[], indent: string): string { return objectFacet( - Object.values(table.$columns) - .filter((c) => !c.serverGenerated) - .map((c) => property(c, !c.notNull || c.hasDefault)), + columns.map((c) => property(c, indent, !c.notNull || c.hasDefault)), + indent, "Record", ); } -function updateType(table: AppKitTable): string { +function updateType(columns: ColumnMeta[], indent: string): string { return objectFacet( - Object.values(table.$columns) - .filter((c) => !c.serverGenerated && !c.primaryKey) - .map((c) => property(c, true)), + columns.map((c) => property(c, indent, true)), + indent, "Record", ); } -/** Reuse the canonical operator matrix when rendering `where()` types. */ -function filtersType(table: AppKitTable): string { +/** + * Reuse the canonical operator matrix when rendering `where()` types. A + * trusted `where()` also takes a bare array as `in`; the HTTP decoder does not. + */ +function filtersType( + columns: ColumnMeta[], + indent: string, + arrayShorthand: boolean, +): string { const direct = objectFacet( - Object.values(table.$columns).flatMap((column) => { + columns.flatMap((column) => { const operators = filterOperatorsForKind(column.kind); if (operators.length === 0) return []; const value = tsType(column); @@ -92,10 +120,12 @@ function filtersType(table: AppKitTable): string { `${operator}?: ${operator === "in" ? `readonly (${value})[]` : value};`, ); if (!column.notNull) fields.push("is?: null;"); + const shorthand = arrayShorthand ? ` | readonly (${value})[]` : ""; return [ - ` ${JSON.stringify(column.columnName)}?: ${value} | readonly (${value})[] | { ${fields.join(" ")} };`, + `${indent} ${JSON.stringify(column.columnName)}?: ${value}${shorthand} | { ${fields.join(" ")} };`, ]; }), + indent, ); return `DatabaseLogicalFilter<${direct}>`; } @@ -105,23 +135,50 @@ function includesType(table: AppKitTable): string { return objectFacet( table.$relations.map( (relation) => - ` ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, + `${TRUSTED} ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, ), + TRUSTED, ); } +/** What the generated routes accept, from the predicates `compileTable` uses. */ +function apiEntry(table: AppKitTable): ApiEntry { + const columns = Object.values(table.$columns).map((meta) => ({ + meta, + can: columnHttpCapabilities(meta), + })); + const columnsThat = (capability: keyof ColumnHttpCapabilities) => + columns.filter(({ can }) => can[capability]).map(({ meta }) => meta); + const names = (metas: ColumnMeta[]) => metas.map((meta) => meta.columnName); + return { + insert: insertType(columnsThat("creatable"), API), + update: updateType(columnsThat("updatable"), API), + filters: filtersType(columnsThat("queryable"), API, false), + orderable: literalUnion(names(columnsThat("queryable"))), + key: literalUnion(names(columnsThat("publicKey"))), + }; +} + /** Preserve schema table identity as the generated registry key. */ export function walkSchema(schema: Schema): RegistryEntry[] { - return Object.entries(schema.$tables).map(([name, table]) => ({ - name, - row: rowType(table, false), - publicRow: rowType(table, true), - insert: insertType(table), - update: updateType(table), - filters: filtersType(table), - includes: includesType(table), - hasPrimaryKey: Object.values(table.$columns).some( - (column) => column.primaryKey, - ), - })); + return Object.entries(schema.$tables).map(([name, table]) => { + const columns = Object.values(table.$columns); + return { + name, + row: rowType(table, false), + publicRow: rowType(table, true), + insert: insertType( + columns.filter((c) => !c.serverGenerated), + TRUSTED, + ), + update: updateType( + columns.filter((c) => !c.serverGenerated && !c.primaryKey), + TRUSTED, + ), + filters: filtersType(columns, TRUSTED, true), + includes: includesType(table), + hasPrimaryKey: columns.some((column) => column.primaryKey), + api: apiEntry(table), + }; + }); } diff --git a/packages/shared/src/database/api-types.ts b/packages/shared/src/database/api-types.ts new file mode 100644 index 000000000..fcbf5c72f --- /dev/null +++ b/packages/shared/src/database/api-types.ts @@ -0,0 +1,216 @@ +/** + * Generic HTTP types over any generated database registry. The registry itself + * is one generated `database.d.ts`; these adapters only read the facets each + * entry already carries, so there is no second model of an entity here. + */ + +/** The HTTP-safe facets every generated registry entry carries. */ +export interface DatabaseApiEntry { + /** Non-private columns, as the trusted runtime types them. */ + readonly publicRow: Record; + /** Relation target table and cardinality, one entry per relation. */ + readonly includes: Record; + /** What the generated routes accept, computed by the server's own predicates. */ + readonly api: { + readonly insert: object; + readonly update: object; + readonly filters: object; + /** Columns a request may order by; `never` when there are none. */ + readonly orderable: string; + /** The public primary key; `never` when it is private or absent. */ + readonly key: string; + }; +} + +type Primitive = string | number | bigint | boolean | symbol | null | undefined; + +/** JSON carries a bigint as a decimal string, at every depth of a response. */ +export type WireOutput = T extends bigint + ? string + : T extends readonly (infer E)[] + ? WireOutput[] + : T extends object + ? { [K in keyof T]: WireOutput } + : T; + +/** A bigint input travels as a decimal string or a safe integer. */ +export type WireInput = T extends bigint + ? string | number + : T extends readonly (infer E)[] + ? readonly WireInput[] + : T extends object + ? { [K in keyof T]: WireInput } + : T; + +/** Literal entity names in `R`, or `never` while the registry is empty. */ +export type DatabaseApiEntityFor = Extract< + keyof { [K in keyof R as string extends K ? never : K]: true }, + string +>; + +type EntryOf = K extends keyof R + ? R[K] extends DatabaseApiEntry + ? R[K] + : never + : never; +type PublicRowOf = EntryOf["publicRow"]; +type ApiOf = EntryOf["api"]; +type IncludesOf = EntryOf["includes"]; +type RelationOf = IncludesOf[Relation & + keyof IncludesOf]; +type TargetOf = + RelationOf extends { to: infer Target } ? Target : never; +type ToManyOf = + RelationOf extends { many: true } ? true : false; + +type SelectableOf = keyof PublicRowOf & string; +type OrderFor = Partial["orderable"], "asc" | "desc">>; + +/** + * Entities in `R` with a public primary key. Only these have detail, update, + * and delete routes; `never` while the registry is empty. + */ +export type KeyedEntityFor = { + [K in DatabaseApiEntityFor]: [ApiOf["key"]] extends [never] + ? never + : K; +}[DatabaseApiEntityFor]; + +type KeyValueOf = PublicRowOf[ApiOf["key"] & + keyof PublicRowOf]; + +/** + * An id as a keyed route's path carries it. A bigint key reads back as its + * decimal string, so that string, a safe integer, or a `bigint` all address it. + * `Extract` keeps it a path segment even where `K` is still generic. + */ +export type IdFor = Extract< + KeyValueOf extends bigint + ? bigint | WireInput + : KeyValueOf, + string | number | bigint +>; + +/** + * Options for one included relation, checked against the target's own public + * facets. Only a to-many edge takes a limit, and only the first edge nests. + */ +type IncludeOptionsFor = { + readonly select?: readonly SelectableOf[]; + readonly where?: WireInput["filters"]>; + readonly order?: OrderFor; +} & (ToMany extends true + ? { readonly limit?: number } + : { readonly limit?: never }) & + (Nested extends true + ? { readonly include?: HttpIncludeArgFor } + : { readonly include?: never }); + +/** + * `include` as the generated routes decode it: `true` or an options object per + * relation (never `false`), at most two relation edges deep. + */ +export type HttpIncludeArgFor = { + readonly [Relation in keyof IncludesOf]?: + | true + | IncludeOptionsFor< + R, + TargetOf, + ToManyOf, + Nested + >; +}; + +/** The public query a generated list route accepts for `K`. */ +export type ListParamsFor = { + readonly where?: WireInput["filters"]>; + readonly order?: OrderFor; + readonly select?: readonly SelectableOf[]; + readonly include?: HttpIncludeArgFor; + readonly limit?: number; + readonly offset?: number; +}; + +/** The public query a generated detail route accepts for `K`. */ +export type RecordParamsFor = Pick< + ListParamsFor, + "select" | "include" +>; + +// An explicit selection narrows the public row; relations add to it. +type SelectedOf = P extends { + readonly select: infer Columns extends readonly PropertyKey[]; +} + ? Pick, Columns[number] & keyof PublicRowOf> + : PublicRowOf; + +type IncludedOf = P extends { readonly include: infer I } + ? { + [Relation in keyof I & keyof IncludesOf]: ToManyOf< + R, + K, + Relation + > extends true + ? RowOf, I[Relation]>[] + : RowOf, I[Relation]> | null; + } + : unknown; + +type RowOf = SelectedOf & IncludedOf; + +/** One list row as JSON carries it, for the params `P` the caller sent. */ +export type ListRowFor = WireOutput>; + +/** One detail row as JSON carries it; projection follows the list rules. */ +export type RecordRowFor = ListRowFor; + +/** The body a generated create route accepts for `K`, as JSON carries it. */ +export type InsertFor = WireInput["insert"]>; + +/** The body a generated update route accepts for `K`, as JSON carries it. */ +export type UpdateFor = WireInput["update"]>; + +/** + * The public row a generated create or update route answers with. A read + * serializer never reshapes a write's response, so this is always the row. + */ +export type PublicRowFor = WireOutput>; + +/** The envelope a generated list route answers with. */ +export interface DatabaseListPage { + items: Row[]; + limit: number; + offset: number; +} + +type KeysOf = S extends unknown ? keyof S : never; +type PropertyOf = S extends unknown + ? K extends keyof S + ? S[K] + : never + : never; +type ObjectPartOf = Exclude; +type ElementOf = S extends readonly (infer E)[] ? E : never; +// A JSON column takes any JSON value, so there is no shape to hold it to. +type ExactPropertyOf = unknown extends S + ? T + : ExactDatabaseParams>; + +/** + * Turn every key `Shape` does not declare into `never`, at every depth. + * TypeScript skips excess-property checks for an inferred generic argument, + * so `P & ExactDatabaseParams` restores them: a private or unknown + * column beside a valid one is a compile error, not a silent 400. Write + * values use it too, so a spread cannot carry a read-only field along. + */ +export type ExactDatabaseParams = [Shape] extends [T] + ? T + : T extends Primitive + ? T + : T extends readonly unknown[] + ? { readonly [I in keyof T]: ExactDatabaseParams> } + : { + [K in keyof T]: K extends KeysOf> + ? ExactPropertyOf, K>> + : never; + }; diff --git a/packages/shared/src/database/errors.ts b/packages/shared/src/database/errors.ts new file mode 100644 index 000000000..5a109e94f --- /dev/null +++ b/packages/shared/src/database/errors.ts @@ -0,0 +1,40 @@ +/** Stable failure category a generated database route answers with. */ +export type DatabaseErrorCategory = + | "INVALID_REQUEST" + | "VALIDATION_FAILED" + | "NOT_FOUND" + | "CONFLICT" + | "FORBIDDEN" + | "TRANSIENT" + | "UNSUPPORTED_MEDIA_TYPE" + | "PAYLOAD_TOO_LARGE" + | "INTERNAL" + | "SETUP_FAILED"; + +/** Which request field a rejection concerns; it never carries caller values. */ +export interface DatabaseErrorDetail { + readonly path: readonly string[]; + readonly message: string; +} + +/** + * The one status vocabulary both sides of a generated route read. Both 500 + * categories share a status, so a status alone never claims a setup failure. + */ +const categoryByStatus: Readonly> = { + 400: "INVALID_REQUEST", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 413: "PAYLOAD_TOO_LARGE", + 415: "UNSUPPORTED_MEDIA_TYPE", + 422: "VALIDATION_FAILED", + 503: "TRANSIENT", +}; + +/** Read a status back into its category; an unlisted status is `INTERNAL`. */ +export function databaseErrorCategoryForStatus( + status: number, +): DatabaseErrorCategory { + return categoryByStatus[status] ?? "INTERNAL"; +} diff --git a/packages/shared/src/database/index.ts b/packages/shared/src/database/index.ts new file mode 100644 index 000000000..a14429b93 --- /dev/null +++ b/packages/shared/src/database/index.ts @@ -0,0 +1,3 @@ +export * from "./api-types"; +export * from "./errors"; +export * from "./query-codec"; diff --git a/packages/shared/src/database/query-codec.test.ts b/packages/shared/src/database/query-codec.test.ts new file mode 100644 index 000000000..30c47145d --- /dev/null +++ b/packages/shared/src/database/query-codec.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { databaseErrorCategoryForStatus } from "./errors"; +import { + encodeDatabaseListQuery, + encodeDatabaseRecordQuery, +} from "./query-codec"; + +function decoded(query: string): Record { + return Object.fromEntries(new URLSearchParams(query)); +} + +describe("encodeDatabaseListQuery", () => { + it("encodes nothing for an empty or all-undefined request", () => { + expect(encodeDatabaseListQuery({})).toBe(""); + expect( + encodeDatabaseListQuery({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: undefined, + offset: undefined, + }), + ).toBe(""); + }); + + it("sends one JSON value per structured parameter and decimal pagination", () => { + const query = encodeDatabaseListQuery({ + where: { board_id: 7, or: [{ author: { ilike: "a%" } }] }, + order: { created_at: "desc", id: "asc" }, + select: ["id", "body"], + include: { notes: { limit: 5 } }, + limit: 20, + offset: 40, + }); + + expect(decoded(query)).toEqual({ + where: '{"board_id":7,"or":[{"author":{"ilike":"a%"}}]}', + order: '{"created_at":"desc","id":"asc"}', + select: '["id","body"]', + include: '{"notes":{"limit":5}}', + limit: "20", + offset: "40", + }); + }); + + it("is deterministic regardless of the caller's parameter order", () => { + const forward = encodeDatabaseListQuery({ + where: { id: 1 }, + limit: 5, + select: ["id"], + }); + const backward = encodeDatabaseListQuery({ + select: ["id"], + limit: 5, + where: { id: 1 }, + }); + expect(forward).toBe(backward); + expect([...new URLSearchParams(forward).keys()]).toEqual([ + "where", + "select", + "limit", + ]); + }); + + it("keeps order keys in the caller's sequence, since it is sort priority", () => { + const query = encodeDatabaseListQuery({ + order: { title: "asc", created_at: "desc" }, + }); + expect(decoded(query).order).toBe('{"title":"asc","created_at":"desc"}'); + }); + + it("omits undefined nested values and escapes reserved characters", () => { + const query = encodeDatabaseListQuery({ + where: { body: { like: "a+b&c=%" }, author: undefined }, + }); + expect(query).not.toContain("&c="); + expect(decoded(query).where).toBe('{"body":{"like":"a+b&c=%"}}'); + }); + + it("encodes a bigint operand as its decimal string", () => { + const query = encodeDatabaseListQuery({ + where: { total: { gt: 9007199254740993n } }, + }); + expect(decoded(query).where).toBe('{"total":{"gt":"9007199254740993"}}'); + }); + + it("ignores parameters the list route does not accept", () => { + const query = encodeDatabaseListQuery({ + limit: 1, + ...({ includeTotal: true } as object), + }); + expect(query).toBe("limit=1"); + }); +}); + +describe("encodeDatabaseRecordQuery", () => { + it("encodes projection and includes only", () => { + expect(encodeDatabaseRecordQuery({})).toBe(""); + const query = encodeDatabaseRecordQuery({ + include: { notes: { include: { note_events: true } } }, + select: ["id"], + }); + expect(decoded(query)).toEqual({ + select: '["id"]', + include: '{"notes":{"include":{"note_events":true}}}', + }); + expect( + encodeDatabaseRecordQuery({ + select: ["id"], + ...({ where: { id: 1 }, limit: 1 } as object), + }), + ).toBe(new URLSearchParams({ select: '["id"]' }).toString()); + }); +}); + +describe("databaseErrorCategoryForStatus", () => { + it("reads every generated status back into its stable category", () => { + expect( + [400, 403, 404, 409, 413, 415, 422, 503].map( + databaseErrorCategoryForStatus, + ), + ).toEqual([ + "INVALID_REQUEST", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "VALIDATION_FAILED", + "TRANSIENT", + ]); + }); + + it("treats any other status as INTERNAL", () => { + expect(databaseErrorCategoryForStatus(500)).toBe("INTERNAL"); + expect(databaseErrorCategoryForStatus(502)).toBe("INTERNAL"); + expect(databaseErrorCategoryForStatus(401)).toBe("INTERNAL"); + }); +}); diff --git a/packages/shared/src/database/query-codec.ts b/packages/shared/src/database/query-codec.ts new file mode 100644 index 000000000..96c12b4fb --- /dev/null +++ b/packages/shared/src/database/query-codec.ts @@ -0,0 +1,65 @@ +/** The query a generated list route decodes; structured values travel as JSON. */ +export interface DatabaseListQuery { + readonly where?: unknown; + readonly order?: unknown; + readonly select?: readonly string[]; + readonly include?: unknown; + readonly limit?: number; + readonly offset?: number; +} + +/** The query a generated detail route decodes: projection and includes only. */ +export interface DatabaseRecordQuery { + readonly select?: readonly string[]; + readonly include?: unknown; +} + +// A fixed parameter order keeps equal requests on equal strings, whatever key +// order the caller's object literal happened to use. +const LIST_PARAMS = [ + "where", + "order", + "select", + "include", + "limit", + "offset", +] as const; +const RECORD_PARAMS = ["select", "include"] as const; +const INTEGER_PARAMS: ReadonlySet = new Set(["limit", "offset"]); + +/** JSON has no bigint; the server reads a bigint operand from its decimal string. */ +function bigintAsDecimal(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +function encode( + params: T, + names: readonly (keyof T & string)[], +): string { + const search = new URLSearchParams(); + for (const name of names) { + const value = params[name]; + if (value === undefined) continue; + search.append( + name, + INTEGER_PARAMS.has(name) + ? String(value) + : JSON.stringify(value, bigintAsDecimal), + ); + } + return search.toString(); +} + +/** + * Encode `GET /:table` parameters the way `decodeListQuery` reads them: one + * JSON value per structured parameter, decimal integers for pagination, and + * nothing at all for an omitted parameter. Returns no leading `?`. + */ +export function encodeDatabaseListQuery(params: DatabaseListQuery): string { + return encode(params, LIST_PARAMS); +} + +/** Encode `GET /:table/:id` parameters the way `decodeDetailQuery` reads them. */ +export function encodeDatabaseRecordQuery(params: DatabaseRecordQuery): string { + return encode(params, RECORD_PARAMS); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c8e7e8fa5..1bcefb42a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from "./agent"; export * from "./cache"; +export * from "./database"; export * from "./execute"; export * from "./genie"; export * from "./metric-filter";