From 506a9dc4f6a0780c00aeb08f650a7e8bd87a013f Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 15:09:53 +0100 Subject: [PATCH 1/6] feat(appkit-ui): add typed databaseApi.list for DatabasePlugin routes Generate an HTTP-safe `api` facet per entity from the same column capability predicates the server uses to compile its CRUD contract, and bind the generated entries into both @databricks/appkit and @databricks/appkit-ui/js/beta. The status-to-category table and the list/record query encoder move to `shared` so both sides agree on them. `databaseApi.list` resolves routes from the boot payload, fails locally with NOT_EXPOSED for routes the server has not published, and throws a typed DatabaseApiError. The playground's BoardExplorer lists boards and notes through it, and CI type-checks the database components against the built appkit-ui declarations. Co-authored-by: Isaac Signed-off-by: ditadi --- .github/workflows/ci.yml | 3 + .../components/database/board-explorer.tsx | 60 ++-- .../client/tsconfig.database.json | 15 + apps/dev-playground/package.json | 1 + .../shared/appkit-types/database.d.ts | 129 ++++++--- packages/appkit-ui/src/js/beta.ts | 20 ++ .../appkit-ui/src/js/database/client.test.ts | 229 +++++++++++++++ packages/appkit-ui/src/js/database/client.ts | 204 ++++++++++++++ packages/appkit-ui/src/js/database/errors.ts | 31 +++ .../appkit-ui/src/js/database/registry.ts | 14 + packages/appkit-ui/src/js/database/types.ts | 25 ++ packages/appkit/src/database/errors.ts | 42 +-- .../src/plugins/database/crud/capabilities.ts | 45 +++ .../src/plugins/database/crud/contract.ts | 26 +- .../database/crud/tests/capabilities.test.ts | 138 +++++++++ .../crud/tests/wire-roundtrip.test.ts | 176 ++++++++++++ .../src/type-generator/database/generate.ts | 54 ++-- .../database/tests/generate.test.ts | 262 +++++++++++++++--- .../type-generator/database/walk-schema.ts | 117 ++++++-- packages/shared/src/database/api-types.ts | 177 ++++++++++++ packages/shared/src/database/errors.ts | 40 +++ packages/shared/src/database/index.ts | 3 + .../shared/src/database/query-codec.test.ts | 141 ++++++++++ packages/shared/src/database/query-codec.ts | 65 +++++ packages/shared/src/index.ts | 1 + 25 files changed, 1814 insertions(+), 204 deletions(-) create mode 100644 apps/dev-playground/client/tsconfig.database.json create mode 100644 packages/appkit-ui/src/js/database/client.test.ts create mode 100644 packages/appkit-ui/src/js/database/client.ts create mode 100644 packages/appkit-ui/src/js/database/errors.ts create mode 100644 packages/appkit-ui/src/js/database/registry.ts create mode 100644 packages/appkit-ui/src/js/database/types.ts create mode 100644 packages/appkit/src/plugins/database/crud/capabilities.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts create mode 100644 packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts create mode 100644 packages/shared/src/database/api-types.ts create mode 100644 packages/shared/src/database/errors.ts create mode 100644 packages/shared/src/database/index.ts create mode 100644 packages/shared/src/database/query-codec.test.ts create mode 100644 packages/shared/src/database/query-codec.ts 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..bd63d2494 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -1,3 +1,4 @@ +import { DatabaseApiError, databaseApi } from "@databricks/appkit-ui/js/beta"; import { Badge, Button, @@ -17,21 +18,21 @@ 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; -} +/** Only a short note preview is needed for the board picker. */ +const listBoards = () => + databaseApi.list("boards", { include: { notes: { limit: 5 } } }); -interface Board { - id: number; - slug: string; - title: string; - created_at: string; - notes?: Note[]; -} +/** Listing notes directly is what puts them through the entity's serializer. */ +const listNotes = (boardId: number) => + databaseApi.list("notes", { + where: { board_id: boardId }, + order: { created_at: "desc" }, + limit: 5, + }); + +// Row types come from the generated schema through the calls that read them. +type Board = Awaited>["items"][number]; +type Note = Awaited>["items"][number]; interface NoteEvent { id: number; @@ -44,23 +45,10 @@ interface TimelineNote extends Note { note_events?: NoteEvent[]; } -interface Timeline extends Board { +interface Timeline extends Omit { 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( @@ -89,6 +77,14 @@ async function getJson(url: string): Promise { return body as T; } +/** The client decodes the same envelope; a field detail still reads first. */ +function errorText(err: unknown): string { + if (err instanceof DatabaseApiError) { + return err.details[0]?.message ?? err.message; + } + return err instanceof Error ? err.message : String(err); +} + export function BoardExplorer() { const authorFieldId = useId(); const bodyFieldId = useId(); @@ -108,7 +104,7 @@ export function BoardExplorer() { const load = useCallback(async (slug?: string | null) => { setError(null); try { - const page = await getJson<{ items: Board[] }>(BOARDS_URL); + const page = await listBoards(); setBoards(page.items); const active = page.items.find((entry) => entry.slug === slug) ?? page.items[0]; @@ -120,13 +116,13 @@ export function BoardExplorer() { return; } const [listed, board] = await Promise.all([ - getJson<{ items: Note[] }>(notesUrl(active.id)), + listNotes(active.id), getJson(timelineUrl(active.id)), ]); setNotes(listed.items); setTimeline(board); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errorText(err)); } }, []); @@ -222,7 +218,7 @@ export function BoardExplorer() { variant="secondary" className="ml-2 tabular-nums font-normal" > - {entry.notes?.length ?? 0} notes + {entry.notes.length} notes ))} 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/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index 088ff80b1..c265c6792 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -1,2 +1,22 @@ // 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, + DatabaseListParams, + DatabaseListRow, +} 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..b8469b288 --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -0,0 +1,229 @@ +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 }>; +}; + +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" }); + }); +}); 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..d58c40062 --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.ts @@ -0,0 +1,204 @@ +import { + type DatabaseErrorDetail, + type DatabaseListPage, + databaseErrorCategoryForStatus, + encodeDatabaseListQuery, + type ExactDatabaseParams, +} from "shared"; + +import { getClientConfig } from "../config"; +import { DatabaseApiError } from "./errors"; +import type { + DatabaseEntity, + DatabaseListParams, + DatabaseListRow, +} from "./types"; + +/** Suffix of the endpoint names `DatabasePlugin` publishes for each table. */ +type DatabaseOperation = "list" | "detail" | "create" | "update" | "delete"; + +/** An id as a keyed route addresses it in its path. */ +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. */ + 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>>; +} + +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. + */ +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. An abort rejects with the signal's + * own reason, so a caller can tell cancellation from failure. + */ +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 = 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; +} + +function isListPage(body: unknown): body is DatabaseListPage { + return ( + isRecord(body) && + Array.isArray(body.items) && + typeof body.limit === "number" && + typeof body.offset === "number" + ); +} + +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 }, + isListPage, + ); + // The server projected and encoded every row; the types describe that wire. + return page as DatabaseListPage>; +} + +/** + * 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 }; 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..519ef9c0c --- /dev/null +++ b/packages/appkit-ui/src/js/database/types.ts @@ -0,0 +1,25 @@ +import type { DatabaseApiEntityFor, ListParamsFor, ListRowFor } from "shared"; + +import type { DatabaseRegistry } from "./registry"; + +/** Entity names the generated registry binds, or `never` before typegen runs. */ +export type DatabaseEntity = DatabaseApiEntityFor; + +/** + * 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; 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/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/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..c43778b30 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,152 @@ 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; - await expect( - execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { - cwd: path.resolve(appkitRoot, "../.."), - }), - ).resolves.toMatchObject({ stderr: "" }); + 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]; + + 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 }); + } + + 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, 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..58d049c1e --- /dev/null +++ b/packages/shared/src/database/api-types.ts @@ -0,0 +1,177 @@ +/** + * 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">>; + +/** + * 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 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; + +/** + * 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. + */ +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> + ? ExactDatabaseParams< + T[K], + NonNullable, 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"; From 81efdc53de6efcde0a816929dd0d1a8c890524ee Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 15:29:40 +0100 Subject: [PATCH 2/6] fix(appkit): log Postgres text for schema and connection failures A driver failure reached the server log as a bare SQLSTATE, so a missing column logged only "SQLSTATE 42703" with no object named. Log the driver's own message, detail, and hint for SQLSTATE classes whose text names connections, credentials, or schema objects (08, 28, 3D, 3F, 42, 53, 57), and the text of Node system errors such as ENOTFOUND. Value-bearing classes (22 data, 23 constraints, P0 PL/pgSQL) still log only their code, the Drizzle wrapper's SQL and params are never read, and the thrown error and client message are unchanged. Co-authored-by: Isaac Signed-off-by: ditadi --- .../runtime/engine/drizzle-data-path.ts | 71 ++++++++-- .../runtime/tests/drizzle-data-path.test.ts | 125 ++++++++++++++++++ 2 files changed, 188 insertions(+), 8 deletions(-) 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< From 311d4db65edfb1b944b6402e3c779dadde6bc417 Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 15:29:54 +0100 Subject: [PATCH 3/6] feat(appkit): verify declared tables and columns at DatabasePlugin setup The plugin never migrates, so a database that did not match the declared schema passed setup and then failed every request with an opaque "Database operation failed". Setup now reads pg_catalog after the connectivity check and fails with the missing names, for example "table public.notes is missing columns board_id, author_email, body". pg_catalog is used because information_schema hides objects the role cannot access, which would report a privilege gap as a missing column. A setup failure now keeps its own reason when it propagates; other setup errors are still replaced so connector details stay out of it. The mvp integration test stubs the catalog check. Co-authored-by: Isaac Signed-off-by: ditadi --- .../client/src/routes/database.route.tsx | 2 +- docs/docs/plugins/database.md | 11 +- .../appkit/src/plugins/database/lifecycle.ts | 15 ++- .../src/plugins/database/schema-check.ts | 65 +++++++++++ .../database/tests/crud.integration.test.ts | 4 + .../plugins/database/tests/lifecycle.test.ts | 28 +++++ .../database/tests/mvp.integration.test.ts | 4 + .../database/tests/schema-check.test.ts | 106 ++++++++++++++++++ 8 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 packages/appkit/src/plugins/database/schema-check.ts create mode 100644 packages/appkit/src/plugins/database/tests/schema-check.test.ts 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/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index dd0b9db74..0d88e83b4 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 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(); + }); +}); From df07ec4c853594048afec90deaa231664a0b92cc Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 15:30:07 +0100 Subject: [PATCH 4/6] feat(appkit): warn when PGHOST is not a host of LAKEBASE_ENDPOINT Credentials are issued for LAKEBASE_ENDPOINT but the pool connects to PGHOST, so a host left over from another branch silently served that branch's tables. At startup, initializeLakebasePool and the lakebase plugin look the endpoint up and warn with both hosts when PGHOST is not among them, or when the endpoint no longer exists. The lookup runs alongside the identity lookup, is shared per endpoint and host, gives up after 3 seconds, and never fails startup. Co-authored-by: Isaac Signed-off-by: ditadi --- docs/docs/plugins/lakebase.md | 2 + .../src/connectors/lakebase/endpoint-host.ts | 111 +++++++++++ .../appkit/src/connectors/lakebase/index.ts | 7 +- .../lakebase/tests/endpoint-host.test.ts | 187 ++++++++++++++++++ .../lakebase/tests/initialize-pool.test.ts | 27 ++- .../appkit/src/plugins/lakebase/lakebase.ts | 6 +- 6 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 packages/appkit/src/connectors/lakebase/endpoint-host.ts create mode 100644 packages/appkit/src/connectors/lakebase/tests/endpoint-host.test.ts 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/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/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"); From d0c521ff4d8c863de634398b6e4cc077ecc16114 Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 16:49:20 +0100 Subject: [PATCH 5/6] feat(appkit-ui): add useDatabaseList and useDatabaseRecord read hooks Add `databaseApi.get` and two beta read hooks in @databricks/appkit-ui/react/beta. Reads share one request per resolved URL through the existing request store, so identical reads across components, re-renders with equal params, and StrictMode remounts reuse one fetch; the last subscriber's release aborts it. Params keep the exactness check from `databaseApi.list`, so private columns, undeclared keys, keyed reads on keyless or private-key tables, and to-one include limits stay compile errors. `serialized()` lets a serializer-shaped read declare its row type without losing entity and params checking. The playground's BoardExplorer now reads boards, notes, the timeline, and a note's full body through the hooks; writes remain manual until the mutation hooks land. The database plugin docs gain a frontend hooks section. Co-authored-by: Isaac Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 228 ++++++------- docs/docs/plugins/database.md | 146 ++++++++ packages/appkit-ui/src/js/beta.ts | 4 + .../appkit-ui/src/js/database/client.test.ts | 90 +++++ packages/appkit-ui/src/js/database/client.ts | 78 ++++- packages/appkit-ui/src/js/database/types.ts | 34 +- packages/appkit-ui/src/react/beta.ts | 25 ++ .../__tests__/use-database-list.test.tsx | 268 +++++++++++++++ .../__tests__/use-database-record.test.tsx | 134 ++++++++ .../__tests__/use-database.types.test.ts | 312 ++++++++++++++++++ .../src/react/hooks/database-request-store.ts | 95 ++++++ .../src/react/hooks/use-database-list.ts | 63 ++++ .../src/react/hooks/use-database-read.ts | 133 ++++++++ .../src/react/hooks/use-database-record.ts | 59 ++++ packages/shared/src/database/api-types.ts | 25 ++ 15 files changed, 1550 insertions(+), 144 deletions(-) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/database-request-store.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-list.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-read.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-record.ts diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx index bd63d2494..abeaff9a1 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -1,4 +1,3 @@ -import { DatabaseApiError, databaseApi } from "@databricks/appkit-ui/js/beta"; import { Badge, Button, @@ -9,8 +8,13 @@ import { CardTitle, Input, } from "@databricks/appkit-ui/react"; +import { + DatabaseApiError, + 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 @@ -18,45 +22,6 @@ import { useCallback, useEffect, useId, useState } from "react"; * is the row an `afterCreate` hook commits alongside each note. */ -/** Only a short note preview is needed for the board picker. */ -const listBoards = () => - databaseApi.list("boards", { include: { notes: { limit: 5 } } }); - -/** Listing notes directly is what puts them through the entity's serializer. */ -const listNotes = (boardId: number) => - databaseApi.list("notes", { - where: { board_id: boardId }, - order: { created_at: "desc" }, - limit: 5, - }); - -// Row types come from the generated schema through the calls that read them. -type Board = Awaited>["items"][number]; -type Note = Awaited>["items"][number]; - -interface NoteEvent { - id: number; - note_id: number; - action: string; - created_at: string; -} - -interface TimelineNote extends Note { - note_events?: NoteEvent[]; -} - -interface Timeline extends Omit { - notes?: TimelineNote[]; -} - -/** 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 { @@ -68,16 +33,7 @@ function failureMessage(body: unknown, fallback: string): string { 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; -} - -/** The client decodes the same envelope; a field detail still reads first. */ +/** The hooks decode the same envelope; a field detail still reads first. */ function errorText(err: unknown): string { if (err instanceof DatabaseApiError) { return err.details[0]?.message ?? err.message; @@ -90,47 +46,58 @@ 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 [writeError, setWriteError] = useState(null); - const load = useCallback(async (slug?: string | null) => { - setError(null); - try { - const page = await listBoards(); - 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([ - listNotes(active.id), - getJson(timelineUrl(active.id)), - ]); - setNotes(listed.items); - setTimeline(board); - } catch (err) { - setError(errorText(err)); - } - }, []); + // 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]; + + // 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 ?? []; + + // 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 ?? []; - useEffect(() => { - load(); - }, [load]); + // The list route truncates; the detail route does not. Same serializer. + const fullNote = useDatabaseRecord("notes", revealedId); - const board = boards.find((entry) => entry.slug === selected) ?? null; + const readError = + boards.error ?? notes.error ?? timeline.error ?? fullNote.error; + const error = writeError ?? (readError ? errorText(readError) : null); + + const refresh = () => { + boards.refetch(); + notes.refetch(); + timeline.refetch(); + fullNote.refetch(); + }; + + const selectBoard = (slug: string) => { + setSelected(slug); + setRevealedId(null); + }; const post = async (url: string, payload: unknown) => { const response = await fetch(url, { @@ -143,14 +110,14 @@ export function BoardExplorer() { return created; }; - const submit = async (run: () => Promise) => { + const submit = async (run: () => Promise) => { setBusy(true); - setError(null); + setWriteError(null); try { - const slug = await run(); - await load(slug ?? selected); + await run(); + refresh(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setWriteError(errorText(err)); } finally { setBusy(false); } @@ -166,7 +133,6 @@ export function BoardExplorer() { body, }); setBody(""); - return board.slug; }); }; @@ -180,20 +146,10 @@ export function BoardExplorer() { return submit(async () => { await post("/api/database/boards", { slug, title: title.trim() }); setTitle(""); - return slug; + selectBoard(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 && ( @@ -206,12 +162,12 @@ export function BoardExplorer() { Board - {boards.map((entry) => ( + {boardItems.map((entry) => ( ))} - @@ -272,34 +223,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 && ( - - )} -
- ))} + ); + })} @@ -319,13 +275,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/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 0d88e83b4..91b44f270 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -235,6 +235,152 @@ 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. + +### 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. + +### 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` and `databaseApi.get` in `@databricks/appkit-ui/js/beta` take +the same entity, id, and parameters as the hooks and return a promise. An +optional last argument, `{ signal }`, cancels the request. They reject with +`DatabaseApiError`, or with the abort reason after a cancel. + +```ts +import { databaseApi } from "@databricks/appkit-ui/js/beta"; + +const board = await databaseApi.get("boards", 7, { select: ["id", "title"] }); +``` + ## API reference - [`database`](../api/appkit/Function.database.md) diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index c265c6792..65bef5199 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -17,6 +17,10 @@ export { DatabaseApiError, type DatabaseApiErrorCode } from "./database/errors"; export type { DatabaseRegistry } from "./database/registry"; export type { DatabaseEntity, + DatabaseId, + DatabaseKeyedEntity, DatabaseListParams, DatabaseListRow, + DatabaseRecordParams, + DatabaseRecordRow, } 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 index b8469b288..7496e59af 100644 --- a/packages/appkit-ui/src/js/database/client.test.ts +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -13,6 +13,12 @@ const databaseApi = typedApi as unknown as { params?: object, init?: DatabaseRequestOptions, ): Promise<{ items: unknown[]; limit: number; offset: number }>; + get( + entity: string, + id: string | number | bigint, + params?: object, + init?: DatabaseRequestOptions, + ): Promise>; }; const PAGE = { items: [{ id: 1, body: "hi" }], limit: 5, offset: 0 }; @@ -227,3 +233,87 @@ describe("databaseApi.list", () => { 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", + }); + }); +}); diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index d58c40062..ee4d78faa 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -3,6 +3,7 @@ import { type DatabaseListPage, databaseErrorCategoryForStatus, encodeDatabaseListQuery, + encodeDatabaseRecordQuery, type ExactDatabaseParams, } from "shared"; @@ -10,15 +11,24 @@ import { getClientConfig } from "../config"; import { DatabaseApiError } from "./errors"; import type { DatabaseEntity, + DatabaseId, + DatabaseKeyedEntity, DatabaseListParams, DatabaseListRow, + DatabaseRecordParams, + DatabaseRecordRow, } from "./types"; /** Suffix of the endpoint names `DatabasePlugin` publishes for each table. */ -type DatabaseOperation = "list" | "detail" | "create" | "update" | "delete"; +export type DatabaseOperation = + | "list" + | "detail" + | "create" + | "update" + | "delete"; /** An id as a keyed route addresses it in its path. */ -type IdLike = string | number | bigint; +export type IdLike = string | number | bigint; /** Per-call options for a database request. */ export interface DatabaseRequestOptions { @@ -50,6 +60,29 @@ export interface DatabaseApi { 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>; } function isRecord(value: unknown): value is Record { @@ -61,7 +94,7 @@ function isRecord(value: unknown): value is Record { * only what its `api` configuration exposes, so a missing entry is refused * here with `NOT_EXPOSED` and no request is sent. */ -function resolveDatabaseUrl( +export function resolveDatabaseUrl( entity: string, operation: DatabaseOperation, id?: IdLike, @@ -119,7 +152,7 @@ async function failure(response: Response): Promise { * anything but the shape `accept` expects. An abort rejects with the signal's * own reason, so a caller can tell cancellation from failure. */ -async function requestDatabase( +export async function requestDatabase( url: string, init: RequestInit, accept: (body: unknown) => body is T, @@ -164,7 +197,10 @@ async function requestDatabase( return body; } -function isListPage(body: unknown): body is DatabaseListPage { +/** 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) && @@ -173,6 +209,11 @@ function isListPage(body: unknown): body is DatabaseListPage { ); } +/** A detail route answers one bare row; a serializer returns an object too. */ +export function isDatabaseRow(body: unknown): body is Record { + return isRecord(body); +} + async function list< K extends DatabaseEntity, const P extends DatabaseListParams = Record, @@ -190,15 +231,38 @@ async function list< const page = await requestDatabase( url, { method: "GET", signal: init.signal }, - isListPage, + 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; +} + /** * 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 }; +export const databaseApi: DatabaseApi = { list, get }; diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index 519ef9c0c..58cc88e04 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -1,10 +1,30 @@ -import type { DatabaseApiEntityFor, ListParamsFor, ListRowFor } from "shared"; +import type { + DatabaseApiEntityFor, + IdFor, + KeyedEntityFor, + ListParamsFor, + ListRowFor, + RecordParamsFor, + RecordRowFor, +} 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 @@ -23,3 +43,15 @@ 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; diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 992a79635..edd8a6fcf 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -16,3 +16,28 @@ export { type UseAiSearchQueryResult, useAiSearchQuery, } from "./hooks/use-ai-search-query"; + +// Database read 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 DatabaseKeyedEntity, + type DatabaseListPage, + type DatabaseListParams, + type DatabaseListRow, + type DatabaseRecordParams, + type DatabaseRecordRow, +} from "@/js/beta"; +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"; 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-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..4be29222e --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -0,0 +1,312 @@ +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("no entity exists before typegen binds the registry", () => { + const diagnostics = compileTypeProbe(` + import { 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); + } + `); + + 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..a57f1b4e1 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -0,0 +1,95 @@ +import { requestDatabase } from "@/js/database/client"; +import { DatabaseApiError } from "@/js/database/errors"; + +import { createRequestStore, type RequestRunner } from "./request-store"; + +/** + * 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}`; +} + +/** Anything a request rejects with other than an abort, as the hooks report it. */ +function readError(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: readError(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)); +} + +export const startDatabaseRead = store.start; +export const subscribeDatabaseRead = store.subscribe; +export const getDatabaseReadSnapshot = store.getSnapshot; + +/** Test-only: abort every in-flight read and clear the store. */ +export const resetDatabaseRequestStore = store.reset; diff --git a/packages/appkit-ui/src/react/hooks/use-database-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/shared/src/database/api-types.ts b/packages/shared/src/database/api-types.ts index 58d049c1e..eb9cec47f 100644 --- a/packages/shared/src/database/api-types.ts +++ b/packages/shared/src/database/api-types.ts @@ -66,6 +66,31 @@ type ToManyOf = 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. From 26656331f3195b5188598d0ac9da03cfc3a91a2a Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 19:42:35 +0100 Subject: [PATCH 6/6] feat(appkit-ui): add database write hooks with read invalidation Add `databaseApi.create`, `update`, and `remove`, and three beta hooks: useDatabaseCreate, useDatabaseUpdate, and useDatabaseDelete. Like useServingInvoke, a hook call never rejects: it resolves with the row (or `true` for a delete), or with `null`/`false` once the DatabaseApiError is in `error`, so handlers need no try/catch. `databaseApi` remains the layer that throws. A successful write restarts every mounted database read by default, since includes are invisible at runtime; `invalidate` narrows it to named entities or turns it off. `invalidateDatabaseReads` is exported so writes made through `databaseApi` or custom routes can refresh hook reads too. Writes are never aborted, only the latest call reports its state, and a write that succeeds after unmount still restarts reads. The generic request store gains `restartStarted(match?)`. Create and update values reject private, generated, and undeclared fields at compile time, and JSON columns accept any object value. The playground's BoardExplorer now writes through the hooks and refreshes without manual refetches. Co-authored-by: Isaac Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 95 ++-- docs/docs/plugins/database.md | 120 ++++- packages/appkit-ui/src/js/beta.ts | 3 + .../appkit-ui/src/js/database/client.test.ts | 201 ++++++++ packages/appkit-ui/src/js/database/client.ts | 174 ++++++- packages/appkit-ui/src/js/database/types.ts | 27 + packages/appkit-ui/src/react/beta.ts | 28 +- .../hooks/__tests__/request-store.test.ts | 64 +++ .../__tests__/use-database-mutations.test.tsx | 478 ++++++++++++++++++ .../__tests__/use-database.types.test.ts | 106 +++- .../src/react/hooks/database-request-store.ts | 46 +- .../src/react/hooks/request-store.ts | 14 + .../src/react/hooks/use-database-create.ts | 68 +++ .../src/react/hooks/use-database-delete.ts | 67 +++ .../src/react/hooks/use-database-update.ts | 68 +++ .../src/react/hooks/use-database-write.ts | 124 +++++ .../database/tests/generate.test.ts | 29 +- packages/shared/src/database/api-types.ts | 24 +- 18 files changed, 1654 insertions(+), 82 deletions(-) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx create mode 100644 packages/appkit-ui/src/react/hooks/use-database-create.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-delete.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-update.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-database-write.ts diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx index abeaff9a1..d1d93b651 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -9,7 +9,8 @@ import { Input, } from "@databricks/appkit-ui/react"; import { - DatabaseApiError, + type DatabaseApiError, + useDatabaseCreate, useDatabaseList, useDatabaseRecord, } from "@databricks/appkit-ui/react/beta"; @@ -22,23 +23,9 @@ import { useId, useState } from "react"; * is the row an `afterCreate` hook commits alongside each note. */ -/** 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; -} - -/** The hooks decode the same envelope; a field detail still reads first. */ -function errorText(err: unknown): string { - if (err instanceof DatabaseApiError) { - return err.details[0]?.message ?? err.message; - } - return err instanceof Error ? err.message : String(err); +/** 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() { @@ -51,8 +38,12 @@ export function BoardExplorer() { const [author, setAuthor] = useState("reviewer"); const [body, setBody] = useState(""); const [title, setTitle] = useState(""); - const [busy, setBusy] = useState(false); - const [writeError, setWriteError] = useState(null); + + // 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", { @@ -83,9 +74,14 @@ export function BoardExplorer() { // The list route truncates; the detail route does not. Same serializer. const fullNote = useDatabaseRecord("notes", revealedId); - const readError = - boards.error ?? notes.error ?? timeline.error ?? fullNote.error; - const error = writeError ?? (readError ? errorText(readError) : null); + const failure = + createNote.error ?? + createBoard.error ?? + boards.error ?? + notes.error ?? + timeline.error ?? + fullNote.error; + const error = failure ? errorText(failure) : null; const refresh = () => { boards.refetch(); @@ -99,55 +95,32 @@ export function BoardExplorer() { setRevealedId(null); }; - const post = async (url: string, payload: unknown) => { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const created: unknown = await response.json(); - if (!response.ok) throw new Error(failureMessage(created, "Create failed")); - return created; - }; - - const submit = async (run: () => Promise) => { - setBusy(true); - setWriteError(null); - try { - await run(); - refresh(); - } catch (err) { - setWriteError(errorText(err)); - } finally { - setBusy(false); - } - }; - - 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(""); + 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(""); - selectBoard(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); }; return ( diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 91b44f270..efa57f48d 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -322,6 +322,8 @@ type them as `string`, and filters accept a string or a safe integer. 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 @@ -349,6 +351,102 @@ const notes = useDatabaseList( `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 @@ -370,17 +468,31 @@ public request field. Branch on `code` rather than `message`. ### Without React -`databaseApi.list` and `databaseApi.get` in `@databricks/appkit-ui/js/beta` take -the same entity, id, and parameters as the hooks and return a promise. An -optional last argument, `{ signal }`, cancels the request. They reject with -`DatabaseApiError`, or with the abort reason after a cancel. +`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/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index 65bef5199..dce640f45 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -18,9 +18,12 @@ 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 index 7496e59af..ee0b8bc88 100644 --- a/packages/appkit-ui/src/js/database/client.test.ts +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -19,6 +19,22 @@ const databaseApi = typedApi as unknown as { 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 }; @@ -317,3 +333,188 @@ describe("databaseApi.get", () => { }); }); }); + +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 index ee4d78faa..4f5e5ad72 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -12,11 +12,14 @@ 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. */ @@ -32,7 +35,10 @@ 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. */ + /** + * 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; } @@ -83,6 +89,58 @@ export interface DatabaseApi { 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 { @@ -149,8 +207,9 @@ async function failure(response: Response): Promise { /** * Send one request and decode its JSON body, throwing `DatabaseApiError` for - * anything but the shape `accept` expects. An abort rejects with the signal's - * own reason, so a caller can tell cancellation from failure. + * 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, @@ -176,7 +235,7 @@ export async function requestDatabase( let body: unknown; try { - body = await response.json(); + body = response.status === 204 ? undefined : await response.json(); } catch (error) { if (init.signal?.aborted) throw error; throw new DatabaseApiError( @@ -214,6 +273,76 @@ 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, @@ -260,9 +389,44 @@ async function get< 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 }; +export const databaseApi: DatabaseApi = { list, get, create, update, remove }; diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index 58cc88e04..aab02368d 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -1,11 +1,14 @@ import type { DatabaseApiEntityFor, IdFor, + InsertFor, KeyedEntityFor, ListParamsFor, ListRowFor, + PublicRowFor, RecordParamsFor, RecordRowFor, + UpdateFor, } from "shared"; import type { DatabaseRegistry } from "./registry"; @@ -55,3 +58,27 @@ 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 edd8a6fcf..286243600 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -17,22 +17,35 @@ export { useAiSearchQuery, } from "./hooks/use-ai-search-query"; -// Database read 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. +// 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, @@ -41,3 +54,12 @@ export { 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-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.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts index 4be29222e..988475772 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -296,15 +296,119 @@ test("serialized() replaces the row type and keeps entity, id, and params che 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 { useDatabaseList, useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; + 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"); } `); diff --git a/packages/appkit-ui/src/react/hooks/database-request-store.ts b/packages/appkit-ui/src/react/hooks/database-request-store.ts index a57f1b4e1..d6bf2049f 100644 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -1,8 +1,15 @@ 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 @@ -39,8 +46,13 @@ 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. */ -function readError(error: unknown): DatabaseApiError { +export function asDatabaseApiError(error: unknown): DatabaseApiError { if (error instanceof DatabaseApiError) return error; return new DatabaseApiError( "INTERNAL", @@ -67,7 +79,9 @@ function runDatabaseRead( if (!signal.aborted) patch({ data, loading: false, error: null }); }, (error: unknown) => { - if (!signal.aborted) patch({ loading: false, error: readError(error) }); + if (!signal.aborted) { + patch({ loading: false, error: asDatabaseApiError(error) }); + } }, ); }; @@ -87,6 +101,34 @@ export function retainDatabaseRead( 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; 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-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/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts index c43778b30..230d87508 100644 --- a/packages/appkit/src/type-generator/database/tests/generate.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -424,6 +424,33 @@ describe("generateDatabaseTypes", () => { 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; @@ -431,7 +458,7 @@ describe("generateDatabaseTypes", () => { } return error instanceof DatabaseApiError ? error.details[0]?.message : undefined; } - void [reads, rejected, failed]; + void [reads, rejected, writes, failed]; `, { ui: true }, ); diff --git a/packages/shared/src/database/api-types.ts b/packages/shared/src/database/api-types.ts index eb9cec47f..fcbf5c72f 100644 --- a/packages/shared/src/database/api-types.ts +++ b/packages/shared/src/database/api-types.ts @@ -164,6 +164,18 @@ 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[]; @@ -179,12 +191,17 @@ type PropertyOf = S extends unknown : 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. + * 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 @@ -194,9 +211,6 @@ export type ExactDatabaseParams = [Shape] extends [T] ? { readonly [I in keyof T]: ExactDatabaseParams> } : { [K in keyof T]: K extends KeysOf> - ? ExactDatabaseParams< - T[K], - NonNullable, K>> - > + ? ExactPropertyOf, K>> : never; };