From 50bfcc32aff92268d62c4637c39a05f53ce1dd46 Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 22:13:36 +0100 Subject: [PATCH 1/8] feat(appkit-ui): add useDatabaseList and useDatabaseRecord read hooks Add two beta read hooks in @databricks/appkit-ui/react/beta on top of the databaseApi transport. 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, 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; its writes stay manual until the write hooks land. The database plugin docs gain a React hooks section. Co-authored-by: Isaac Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 228 ++++++------- docs/docs/plugins/database.md | 87 +++++ packages/appkit-ui/src/js/database/client.ts | 19 +- 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 ++++ 11 files changed, 1281 insertions(+), 142 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 ca4e8493f..1e6a4dece 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -364,6 +364,93 @@ field. Branch on `code` rather than `message`. | `OUTCOME_UNKNOWN` | `null` or a successful HTTP status | A write received no usable response; it may have committed. Check before retrying | | `INTERNAL` | 500 or other | Any other failure | +## React hooks (beta) + +`@databricks/appkit-ui/react/beta` provides React hooks over the +[browser client](#browser-client-beta). They take the same entity, id, and +[parameters](#parameters) as `databaseApi`, need the same +[setup](#setup), and report a failure as the same +[`DatabaseApiError`](#errors) in `error`. + +### 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`. + +### 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. + ## API reference - [`database`](../api/appkit/Function.database.md) diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index cff3bd72a..75bd7c8ab 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -28,10 +28,15 @@ import type { } 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 { @@ -154,7 +159,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, @@ -213,7 +218,7 @@ async function failure(response: Response): Promise { * sees `undefined`. 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, @@ -266,7 +271,9 @@ async function requestDatabase( } /** The `{ items, limit, offset }` envelope a list route answers with. */ -function isDatabaseListPage(body: unknown): body is DatabaseListPage { +export function isDatabaseListPage( + body: unknown, +): body is DatabaseListPage { return ( isRecord(body) && Array.isArray(body.items) && @@ -276,7 +283,7 @@ function isDatabaseListPage(body: unknown): body is DatabaseListPage { } /** A detail route answers one bare row; a serializer returns an object too. */ -function isDatabaseRow(body: unknown): body is Record { +export function isDatabaseRow(body: unknown): body is Record { return isRecord(body); } 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; +} From 2aa5567ac95d479292fcb42d07cd85a9a3bae6db Mon Sep 17 00:00:00 2001 From: ditadi Date: Sat, 26 Sep 2026 22:14:50 +0100 Subject: [PATCH 2/8] feat(appkit-ui): add database write hooks with read invalidation Add 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?)`. 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 | 91 ++++ packages/appkit-ui/src/js/database/client.ts | 14 +- 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 +++++ 13 files changed, 1189 insertions(+), 74 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 1e6a4dece..b71c2e3d0 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -424,6 +424,8 @@ holds the hook without a request. A missing row reports `NOT_FOUND`. 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 @@ -451,6 +453,95 @@ 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 follow the same [rules](#values) as `databaseApi.create` and `update`. +Update and delete need a public primary key, like record reads. + +### 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. + ## API reference - [`database`](../api/appkit/Function.database.md) diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index 75bd7c8ab..f356ad2c4 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -322,10 +322,10 @@ function jsonWrite( } /** - * Untyped create behind `databaseApi.create`; its signature carries the - * checks, while the entity is still a literal. + * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their + * signatures carry the checks, while the entity is still a literal. */ -async function createDatabaseRow( +export async function createDatabaseRow( entity: string, values: object, init: DatabaseRequestOptions = {}, @@ -338,8 +338,8 @@ async function createDatabaseRow( ); } -/** Untyped update behind `databaseApi.update`. */ -async function updateDatabaseRow( +/** Untyped update, shared by the typed client and the write hooks. */ +export async function updateDatabaseRow( entity: string, id: IdLike, values: object, @@ -353,8 +353,8 @@ async function updateDatabaseRow( ); } -/** Untyped delete behind `databaseApi.remove`. */ -async function deleteDatabaseRow( +/** Untyped delete, shared by the typed client and the write hooks. */ +export async function deleteDatabaseRow( entity: string, id: IdLike, init: DatabaseRequestOptions = {}, 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 }; +} From 817f30d8004a68e2fb4b94be3c6ce4e04b43d094 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 28 Sep 2026 12:32:47 +0100 Subject: [PATCH 3/8] fix(appkit-ui): keep database hooks safe with strict query types Signed-off-by: ditadi --- .../__tests__/use-database-list.test.tsx | 28 ++++++++++++ .../__tests__/use-database-record.test.tsx | 20 +++++++++ .../__tests__/use-database.types.test.ts | 4 ++ .../src/react/hooks/use-database-list.ts | 43 +++++++++++++++---- .../src/react/hooks/use-database-read.ts | 3 +- .../src/react/hooks/use-database-record.ts | 17 +++++++- 6 files changed, 104 insertions(+), 11 deletions(-) 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 index c2a59423d..c36348d13 100644 --- 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 @@ -163,6 +163,34 @@ describe("useDatabaseList", () => { expect(result.current.loading).toBe(true); }); + test("does not encode an incomplete filter while disabled", () => { + const { rerender } = renderHook( + ({ boardId }: { boardId: number | undefined }) => + useDatabaseList( + "notes", + { where: { board_id: boardId } }, + { enabled: boardId !== undefined }, + ), + { initialProps: { boardId: undefined as number | undefined } }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + rerender({ boardId: 7 }); + expect(requests[0]?.url).toContain("board_id%22%3A7"); + }); + + test("reports an incomplete enabled filter without sending a request", () => { + const { result } = renderHook(() => + useDatabaseList("notes", { where: { board_id: undefined } }), + ); + + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + 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 }))); 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 index dc6f7f264..2db31092b 100644 --- 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 @@ -91,6 +91,26 @@ describe("useDatabaseRecord", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes/7"); }); + test("does not encode incomplete includes while the record is disabled", () => { + const { result, rerender } = renderHook( + ({ id, author }: { id: number | null; author: string | undefined }) => + useDatabaseRecord("notes", id, { + include: { note_events: { where: { author } } }, + }), + { + initialProps: { + id: null as number | null, + author: undefined as string | undefined, + }, + }, + ); + + expect(result.current.error).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + rerender({ id: 7, author: "ada" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test("reads the new record when the id changes", async () => { const { result, rerender } = renderHook( ({ id }: { id: number }) => useDatabaseRecord("notes", id), 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 988475772..13a8ccafd 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 @@ -168,6 +168,10 @@ test("read hooks type entities, params, and rows from the generated registry", ( const gated = useDatabaseList("posts", { where: { id: 1 } }, { enabled: false }); const gatedTitle: string | undefined = gated.data?.items[0]?.title; useDatabaseList("sessions", { order: { user_slug: "asc" } }); + // @ts-expect-error a keyless list needs an explicit order + useDatabaseList("sessions"); + // @ts-expect-error keyless lists cannot sort by zero columns + useDatabaseList("sessions", { order: {} }); useDatabaseList("posts", { where: { total: { gt: "9007199254740993" } } }); void [title, total, failed, owner, bodies, deep, gatedTitle]; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts index 8702264f0..2ca895d17 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -1,12 +1,18 @@ import { type DatabaseListPage, + type DatabaseListQuery, encodeDatabaseListQuery, type ExactDatabaseParams, } from "shared"; import { isDatabaseListPage } from "@/js/database/client"; +import { + type DatabaseApiError, + invalidDatabaseQuery, +} from "@/js/database/errors"; import type { DatabaseEntity, + DatabaseKeyedEntity, DatabaseListParams, DatabaseListRow, } from "@/js/database/types"; @@ -41,23 +47,44 @@ import { * notes.data?.items.map((note) => note.body); * ``` */ +export function useDatabaseList< + K extends DatabaseKeyedEntity, + Row = DatabaseListRow, +>( + entity: K, + params?: undefined, + options?: DatabaseReadOptions, +): DatabaseReadResult>; export function useDatabaseList< K extends DatabaseEntity, - const P extends DatabaseListParams = Record, + const P extends DatabaseListParams, Row = DatabaseListRow, >( entity: K, - params?: P & ExactDatabaseParams>, - options: DatabaseReadOptions = {}, -): DatabaseReadResult> { + params: P & ExactDatabaseParams>, + options?: DatabaseReadOptions, +): DatabaseReadResult>; +export function useDatabaseList( + entity: string, + params?: DatabaseListQuery, + options: DatabaseReadOptions = {}, +): DatabaseReadResult> { + const enabled = options.enabled ?? true; + let query: string | DatabaseApiError = ""; + if (enabled) { + try { + query = encodeDatabaseListQuery(params ?? {}); + } catch { + query = invalidDatabaseQuery(); + } + } const read = useDatabaseRead( entity, "list", undefined, - encodeDatabaseListQuery(params ?? {}), - options.enabled ?? true, + query, + enabled, isDatabaseListPage, ); - // The server projected and encoded every row; the types describe that wire. - return read as DatabaseReadResult>; + 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 index 97c3dcfb6..d9291de71 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -84,12 +84,13 @@ export function useDatabaseRead( entity: string, operation: Extract, id: IdLike | undefined, - query: string, + query: string | DatabaseApiError, enabled: boolean, accept: (body: unknown) => body is object, ): DatabaseReadResult { const route = useMemo((): Route => { if (!enabled) return null; + if (query instanceof DatabaseApiError) return { error: query }; try { return { url: resolveDatabaseUrl(entity, operation, id, query) }; } catch (error) { diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts index 106f7b264..98989fc49 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -1,6 +1,10 @@ import { encodeDatabaseRecordQuery, type ExactDatabaseParams } from "shared"; import { isDatabaseRow } from "@/js/database/client"; +import { + type DatabaseApiError, + invalidDatabaseQuery, +} from "@/js/database/errors"; import type { DatabaseId, DatabaseKeyedEntity, @@ -46,12 +50,21 @@ export function useDatabaseRecord< params?: P & ExactDatabaseParams>, options: DatabaseReadOptions = {}, ): DatabaseReadResult { + const enabled = (options.enabled ?? true) && id !== null && id !== undefined; + let query: string | DatabaseApiError = ""; + if (enabled) { + try { + query = encodeDatabaseRecordQuery(params ?? {}); + } catch { + query = invalidDatabaseQuery(); + } + } const read = useDatabaseRead( entity, "detail", id ?? undefined, - encodeDatabaseRecordQuery(params ?? {}), - (options.enabled ?? true) && id !== null && id !== undefined, + query, + enabled, isDatabaseRow, ); // The server projected and encoded the row; the types describe that wire. From d1a4993189582374412d2ad982488a399186c887 Mon Sep 17 00:00:00 2001 From: ditadi Date: Mon, 28 Sep 2026 13:43:46 +0100 Subject: [PATCH 4/8] feat(appkit-ui): harden database hooks and refresh reads through published relations Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 14 +- docs/docs/plugins/database.md | 158 +++++-- .../appkit-ui/src/js/database/client.test.ts | 41 ++ packages/appkit-ui/src/js/database/client.ts | 65 ++- packages/appkit-ui/src/react/beta.ts | 30 +- .../hooks/__tests__/database-test-utils.ts | 157 +++++++ .../hooks/__tests__/request-store.test.ts | 81 +++- .../__tests__/use-database-list.test.tsx | 227 +++++++--- .../__tests__/use-database-mutations.test.tsx | 408 +++++++++++------- .../__tests__/use-database-record.test.tsx | 135 ++++-- .../__tests__/use-database.types.test.ts | 87 +++- .../react/hooks/analytics-request-store.ts | 2 +- .../src/react/hooks/database-request-store.ts | 146 +++++-- .../src/react/hooks/request-store.ts | 81 +++- .../src/react/hooks/use-database-create.ts | 52 ++- .../src/react/hooks/use-database-delete.ts | 50 ++- .../src/react/hooks/use-database-list.ts | 63 +-- .../src/react/hooks/use-database-read.ts | 227 ++++++++-- .../src/react/hooks/use-database-record.ts | 50 ++- .../src/react/hooks/use-database-update.ts | 58 ++- .../src/react/hooks/use-database-write.ts | 126 +++--- .../appkit/src/plugins/database/database.ts | 44 +- .../src/plugins/database/tests/plugin.test.ts | 37 ++ 23 files changed, 1752 insertions(+), 587 deletions(-) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.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 d1d93b651..ed32c474c 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -54,14 +54,16 @@ export function BoardExplorer() { boardItems.find((entry) => entry.slug === selected) ?? boardItems[0]; // Listing notes directly is what puts them through the entity's serializer. + // Null params hold the read until the boards answer. const notes = useDatabaseList( "notes", - { - where: { board_id: board?.id ?? 0 }, - order: { created_at: "desc" }, - limit: 5, - }, - { enabled: board !== undefined }, + board + ? { + where: { board_id: board.id }, + order: { created_at: "desc" }, + limit: 5, + } + : null, ); const noteItems = notes.data?.items ?? []; diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index b71c2e3d0..5abc8b7e6 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -250,7 +250,10 @@ project. It binds the server registry and a shared global interface used by The client finds routes in the endpoint map the server embeds in the page. When the `api` configuration does not expose an operation, a call to it fails with -`NOT_EXPOSED` and sends no request. +`NOT_EXPOSED` and sends no request. The page also carries the relations between +exposed tables, as relation and table names only, so the +[React hooks](#refresh-reads-after-a-write) can tell which reads a write +affects. These are the names `include` already accepts. ### Calls @@ -353,7 +356,7 @@ field. Branch on `code` rather than `message`. | `code` | `status` | Meaning | | --- | --- | --- | | `NOT_EXPOSED` | `null` | No published route for the operation. Nothing was sent | -| `INVALID_REQUEST` | 400 or `null` | Malformed or unsupported parameters; `null` means local validation rejected the call before sending it | +| `INVALID_REQUEST` | 400 or `null` | Malformed or unsupported parameters. `null` when the client refused them before sending: params that cannot be encoded, or an id of `""`, `"."`, or `".."`, which would resolve to another route | | `FORBIDDEN` | 403 | The database refused the operation | | `NOT_FOUND` | 404 | No row has this id | | `CONFLICT` | 409 | A constraint rejected the change | @@ -396,8 +399,37 @@ function Notes({ boardId }: { boardId: number }) { ``` `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. +first response arrives. To wait for a value the params depend on, pass `null` +as the params; the hook stays idle and sends nothing: + +```tsx +const notes = useDatabaseList( + "notes", + board ? { where: { board_id: board.id }, limit: 20 } : null, +); +``` + +`{ enabled: false }` in the third argument also holds the request. + +### Paginate + +New params start a new request, and `data` is `null` until it answers. For a +paginated list, pass `keepPreviousData: true` to keep showing the previous page +while the next one loads. `loading` stays `true` until it arrives, and a failure +for the new page shows no stale rows. + +```tsx +const [offset, setOffset] = useState(0); +const notes = useDatabaseList( + "notes", + { order: { id: "desc" }, limit: 20, offset }, + { keepPreviousData: true }, +); + +; +``` ### Read one record @@ -412,47 +444,74 @@ 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`. +holds the hook without a request. A missing row reports `NOT_FOUND`, and a +refetch that finds the row deleted clears `data` rather than showing it beside +the error. An id of `""`, `"."`, or `".."` reports `INVALID_REQUEST` without a +request. To pass options without params, use `{}` for the params: +`useDatabaseRecord("boards", boardId, {}, { keepPreviousData: true })`. ### Request lifecycle - Hooks that request the same entity with parameters that encode to the same query share one request while any of them is mounted. An inline parameter object does not refetch on every render. -- 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. + stays visible while it loads and if it fails, except for `NOT_FOUND`. - The request is aborted once the last hook using it unmounts. Nothing is cached after that. A React Strict Mode remount reuses the in-flight request. +- `error` keeps its identity across renders until it changes, so an effect that + depends on it, such as a toast, runs once per failure. - A successful write through a write hook restarts mounted reads. See [Refresh reads after a write](#refresh-reads-after-a-write). ### 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. +A read serializer can change the rows a list or detail route returns. The +`shape` option types the result, and can check it. The entity, id, and +parameters are still checked against the generated registry either way. + +To check each row at runtime, pass a function that takes one decoded row and +returns it typed, such as a zod schema's `parse`. It runs once per response, on +each list item or on the record, and its return value becomes the row. If it +throws, the read fails with `INTERNAL`; the thrown error is in `error.cause`, +not in the message, since it may quote row values. + +```tsx +import { useDatabaseList } from "@databricks/appkit-ui/react/beta"; +import { z } from "zod"; + +// server: serialize: (row) => ({ id: row.id, excerpt: String(row.body).slice(0, 80) }) +const NoteCard = z.object({ id: z.number(), excerpt: z.string() }); + +const notes = useDatabaseList( + "notes", + { limit: 20 }, + { shape: NoteCard.parse }, +); +notes.data?.items[0]?.excerpt; +``` + +Zod 4's `parse` works unbound. For a library whose parse method needs its +schema as `this`, pass an arrow: `shape: (row) => schema.parse(row)`. + +To only declare the type, pass `serialized()`. Nothing checks it at runtime, +so keep `T` in step with the serializer. ```tsx import { serialized, useDatabaseList } from "@databricks/appkit-ui/react/beta"; -// server: serialize: (row) => ({ ...row, excerpt: String(row.body).slice(0, 80) }) -interface NoteView { +interface NoteCard { id: number; - author: string; excerpt: string; } const notes = useDatabaseList( "notes", { limit: 20 }, - { shape: serialized() }, + { shape: serialized() }, ); ``` -`serialized()` is not checked at runtime. Keep `T` in step with the -serializer. - ### Write rows ```tsx @@ -493,54 +552,91 @@ hooks return `data`, the row the latest call answered with. Like for `remove`) and the `DatabaseApiError` is in `error`, so a handler needs no `try/catch`. To handle failures as exceptions, call `databaseApi` instead. +A successful call resolves once the reads it restarts have reloaded, and +`loading` stays `true` until then. A handler that clears its form after +`await create(...)` therefore sees the new row already in the lists. A read +that fails to reload does not fail the write. + Values follow the same [rules](#values) as `databaseApi.create` and `update`. Update and delete need a public primary key, like record reads. +### Observe every write + +`error` only shows the latest call, and only while a component renders it. To +report every outcome, for example to a toast or an error tracker, pass +`onSuccess` and `onError`: + +```tsx +const notes = useDatabaseCreate("notes", { + onSuccess: (note) => toast(`Added note #${note.id}`), + onError: (error, values) => report(error, { board: values.board_id }), +}); +``` + +| Hook | `onSuccess` | `onError` | +| --- | --- | --- | +| `useDatabaseCreate` | `(row, values)` | `(error, values)` | +| `useDatabaseUpdate` | `(row, id, values)` | `(error, id, values)` | +| `useDatabaseDelete` | `(id)` | `(error, id)` | + +The callbacks run for every call, including one a later call superseded and one +that finishes after the component unmounted, once the hook's state has settled. +`onSuccess` runs after the restarted reads reload. A callback that throws does +not change what the call resolves with; its error is reported as uncaught. +Inline callbacks do not change the identity of `create`, `update`, or `remove`. + ### Refresh reads after a write When a write succeeds, every mounted database read restarts, so lists and includes that show the changed row refresh without a manual `refetch()`. Each read keeps its last `data` while it reloads. A failed write restarts nothing. -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`: +The default restarts every read because a [mutation hook](#mutation-hooks) on +the server may write other tables in the same transaction, and the browser +cannot see that. When a page mounts many reads, pass `invalidate` to restart +only the reads that show the tables you name, or none: ```ts -// Restart only the reads of notes and boards. -useDatabaseCreate("notes", { invalidate: ["notes", "boards"] }); +// A board's title changes only boards rows. +useDatabaseUpdate("boards", { invalidate: ["boards"] }); // Restart nothing. Call refetch() on the reads that need it. useDatabaseCreate("notes", { invalidate: false }); ``` -A read of `boards` that includes `notes` belongs to `boards`, so -`invalidate: ["notes"]` does not restart it. +A read shows a table when it is rooted at it or when its `include` reaches it. +The server publishes the relations between exposed tables, so +`invalidate: ["notes"]` also restarts a `boards` read that includes `notes`, +and a `boards` read that includes `notes` and then `note_events`. A read whose +include the server did not describe is restarted by any table list. A write the hooks did not make, such as a `databaseApi` call or one of your own routes that changes rows, does not restart reads by itself. Call `invalidateDatabaseReads` from `@databricks/appkit-ui/react/beta` afterwards. It -takes the same scope as `invalidate` and defaults to every mounted read: +takes the same scope as `invalidate`, defaults to every mounted read, and +resolves once the restarted reads have reloaded: ```ts import { invalidateDatabaseReads } from "@databricks/appkit-ui/react/beta"; -await fetch(`/api/cases/${caseId}/sar`, { method: "POST" }); -invalidateDatabaseReads(["str_reports", "activity_log"]); +await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); +await invalidateDatabaseReads(["boards", "notes"]); ``` ### Write lifecycle - A write is never aborted, even when its component unmounts. Aborting the request would not undo a transaction the server already committed. -- A write that succeeds after its component unmounts still restarts reads, - because the rows did change. +- A write that succeeds after its component unmounts still restarts reads and + runs `onSuccess`, because the rows did change. - Only the latest call updates `data`, `loading`, and `error`. An earlier call still resolves for the code that awaits it, with `null` if it failed. +- `invalidate`, `onSuccess`, and `onError` are read when a call settles, so the + options of the latest render apply. - `reset()` returns the hook to idle. A call in flight keeps running, but no longer updates the hook. -- Writes are not queued or deduplicated. Each call sends its own request. +- Writes are not queued or deduplicated. Each call sends its own request, so + disable the submit control while `loading`. ## API reference diff --git a/packages/appkit-ui/src/js/database/client.test.ts b/packages/appkit-ui/src/js/database/client.test.ts index 087370f62..0b421f5fd 100644 --- a/packages/appkit-ui/src/js/database/client.test.ts +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -344,6 +344,32 @@ describe("databaseApi.get", () => { ); }); + test.each(["", ".", ".."])( + "refuses the id %j, which URL resolution would move off the detail route", + async (id) => { + // "" and "." resolve to the list route, ".." to the plugin root. + const error = await rejection(databaseApi.get("boards", id)); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database id must be a non-empty path segment", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + test("keeps an id that only contains dots among other characters", async () => { + await databaseApi.get("boards", "..."); + await databaseApi.get("boards", "v1.2"); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/database/boards/...", + "/api/database/boards/v1.2", + ]); + }); + test("maps a missing row to NOT_FOUND", async () => { fetchMock.mockResolvedValueOnce( json({ error: "Database record not found" }, 404), @@ -588,6 +614,21 @@ describe("databaseApi writes", () => { }); }); + test("refuses a write addressed by an id that is not one path segment", async () => { + const results = await Promise.all([ + rejection(databaseApi.update("notes", "..", { body: "x" })), + rejection(databaseApi.remove("notes", ".")), + rejection(databaseApi.remove("notes", "")), + ]); + + expect(results).toMatchObject([ + { code: "INVALID_REQUEST", status: null }, + { code: "INVALID_REQUEST", status: null }, + { code: "INVALID_REQUEST", status: null }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("refuses writes the plugin did not publish without sending a request", async () => { const results = await Promise.all([ rejection(databaseApi.create("note_events", { note_id: 1 })), diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index f356ad2c4..602753d55 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -27,7 +27,10 @@ import type { DatabaseUpdate, } from "./types"; -/** Suffix of the endpoint names `DatabasePlugin` publishes for each table. */ +/** + * Suffix of the endpoint names `DatabasePlugin` publishes for each table. + * @internal Shared with the React hooks; not part of the public surface. + */ export type DatabaseOperation = | "list" | "detail" @@ -35,7 +38,10 @@ export type DatabaseOperation = | "update" | "delete"; -/** An id as a keyed route addresses it in its path. */ +/** + * An id as a keyed route addresses it in its path. + * @internal Shared with the React hooks; not part of the public surface. + */ export type IdLike = string | number | bigint; /** Per-call options for a database request. */ @@ -154,10 +160,22 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * An id that URL resolution would not keep as one path segment: `""` and + * `"."` collapse onto the list route and `".."` climbs above the table, so a + * keyed request would silently reach another route. + */ +function isUnaddressableId(segment: string): boolean { + return segment === "" || segment === "." || segment === ".."; +} + /** * Find the route the server published for one operation. The plugin publishes * only what its `api` configuration exposes, so a missing entry is refused - * here with `NOT_EXPOSED` and no request is sent. + * here with `NOT_EXPOSED` and no request is sent. An id that is not one path + * segment is refused with `INVALID_REQUEST`, also without a request. + * + * @internal Shared with the React hooks; not part of the public surface. */ export function resolveDatabaseUrl( entity: string, @@ -178,10 +196,19 @@ export function resolveDatabaseUrl( `Database operation "${name}" is not exposed`, ); } - const url = - id === undefined - ? path - : path.replace(":id", encodeURIComponent(String(id))); + let url = path; + if (id !== undefined) { + const segment = String(id); + if (isUnaddressableId(segment)) { + // The id may be user input from a route param; do not echo it. + throw new DatabaseApiError( + "INVALID_REQUEST", + null, + "Database id must be a non-empty path segment", + ); + } + url = path.replace(":id", encodeURIComponent(segment)); + } return query ? `${url}?${query}` : url; } @@ -217,6 +244,8 @@ async function failure(response: Response): Promise { * anything but the shape `accept` expects. A `204` has no body, so `accept` * sees `undefined`. An abort rejects with the signal's own reason, so a * caller can tell cancellation from failure. + * + * @internal Shared with the React hooks; not part of the public surface. */ export async function requestDatabase( url: string, @@ -270,7 +299,10 @@ export async function requestDatabase( return body; } -/** The `{ items, limit, offset }` envelope a list route answers with. */ +/** + * The `{ items, limit, offset }` envelope a list route answers with. + * @internal Shared with the React hooks; not part of the public surface. + */ export function isDatabaseListPage( body: unknown, ): body is DatabaseListPage { @@ -282,7 +314,10 @@ export function isDatabaseListPage( ); } -/** A detail route answers one bare row; a serializer returns an object too. */ +/** + * A detail route answers one bare row; a serializer returns an object too. + * @internal Shared with the React hooks; not part of the public surface. + */ export function isDatabaseRow(body: unknown): body is Record { return isRecord(body); } @@ -324,6 +359,8 @@ function jsonWrite( /** * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their * signatures carry the checks, while the entity is still a literal. + * + * @internal Shared with the React hooks; not part of the public surface. */ export async function createDatabaseRow( entity: string, @@ -338,7 +375,10 @@ export async function createDatabaseRow( ); } -/** Untyped update, shared by the typed client and the write hooks. */ +/** + * Untyped update, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ export async function updateDatabaseRow( entity: string, id: IdLike, @@ -353,7 +393,10 @@ export async function updateDatabaseRow( ); } -/** Untyped delete, shared by the typed client and the write hooks. */ +/** + * Untyped delete, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ export async function deleteDatabaseRow( entity: string, id: IdLike, diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 286243600..5b1053b14 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -39,27 +39,39 @@ export { } from "@/js/beta"; export { invalidateDatabaseReads } from "./hooks/database-request-store"; export { - type DatabaseCreateResult, + type UseDatabaseCreateOptions, + type UseDatabaseCreateResult, useDatabaseCreate, } from "./hooks/use-database-create"; export { - type DatabaseDeleteResult, + type UseDatabaseDeleteOptions, + type UseDatabaseDeleteResult, useDatabaseDelete, } from "./hooks/use-database-delete"; -export { useDatabaseList } from "./hooks/use-database-list"; export { - type DatabaseReadOptions, - type DatabaseReadResult, + type UseDatabaseListOptions, + type UseDatabaseListResult, + useDatabaseList, +} from "./hooks/use-database-list"; +export { + type DatabaseRowShape, type DatabaseShape, serialized, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, } from "./hooks/use-database-read"; -export { useDatabaseRecord } from "./hooks/use-database-record"; export { - type DatabaseUpdateResult, + type UseDatabaseRecordOptions, + type UseDatabaseRecordResult, + useDatabaseRecord, +} from "./hooks/use-database-record"; +export { + type UseDatabaseUpdateOptions, + type UseDatabaseUpdateResult, useDatabaseUpdate, } from "./hooks/use-database-update"; export type { DatabaseInvalidation, - DatabaseWriteOptions, - DatabaseWriteState, + UseDatabaseWriteOptions, + UseDatabaseWriteState, } from "./hooks/use-database-write"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts new file mode 100644 index 000000000..1af1202fa --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts @@ -0,0 +1,157 @@ +import { vi } from "vitest"; + +import { _resetConfigCache } from "@/js/config"; +import type { DatabaseApiError } from "@/js/database/errors"; + +import { + invalidateDatabaseReads as typedInvalidateDatabaseReads, + resetDatabaseRequestStore, +} from "../database-request-store"; +import { useDatabaseCreate as typedUseDatabaseCreate } from "../use-database-create"; +import { useDatabaseDelete as typedUseDatabaseDelete } from "../use-database-delete"; +import { useDatabaseList as typedUseDatabaseList } from "../use-database-list"; +import type { UseDatabaseReadResult } from "../use-database-read"; +import { useDatabaseRecord as typedUseDatabaseRecord } from "../use-database-record"; +import { useDatabaseUpdate as typedUseDatabaseUpdate } from "../use-database-update"; +import type { UseDatabaseWriteState } from "../use-database-write"; + +// This package has no generated registry, so every entity name is `never` +// here. The typed surface is compiled in use-database.types.test.ts; the +// runtime tests drive the hooks through these string-typed views. + +export type Row = Record; +export type Page = { items: unknown[]; limit: number; offset: number }; + +interface ReadOptions { + enabled?: boolean; + keepPreviousData?: boolean; + shape?: (row: unknown) => unknown; +} + +interface WriteOptions { + invalidate?: boolean | readonly string[]; + onSuccess?: (result: T, ...args: Args) => void; + onError?: (error: DatabaseApiError, ...args: Args) => void; +} + +type Id = string | number | bigint; + +export const useDatabaseList = typedUseDatabaseList as unknown as ( + entity: string, + params?: object | null, + options?: ReadOptions, +) => UseDatabaseReadResult; + +export const useDatabaseRecord = typedUseDatabaseRecord as unknown as ( + entity: string, + id: Id | null | undefined, + params?: object, + options?: ReadOptions, +) => UseDatabaseReadResult; + +export const useDatabaseCreate = typedUseDatabaseCreate as unknown as ( + entity: string, + options?: WriteOptions<[values: object], Row>, +) => UseDatabaseWriteState & { + create(values: object): Promise; + reset(): void; +}; + +export const useDatabaseUpdate = typedUseDatabaseUpdate as unknown as ( + entity: string, + options?: WriteOptions<[id: Id, values: object], Row>, +) => UseDatabaseWriteState & { + update(id: Id, values: object): Promise; + reset(): void; +}; + +export const useDatabaseDelete = typedUseDatabaseDelete as unknown as ( + entity: string, + options?: { + invalidate?: boolean | readonly string[]; + onSuccess?: (id: Id) => void; + onError?: (error: DatabaseApiError, id: Id) => void; + }, +) => { + remove(id: Id): Promise; + loading: boolean; + error: DatabaseApiError | null; + reset(): void; +}; + +export const invalidateDatabaseReads = typedInvalidateDatabaseReads as ( + scope?: boolean | readonly string[], +) => Promise; + +/** One request the mocked `fetch` holds open until the test answers it. */ +export interface PendingRequest { + url: string; + method: string; + body: unknown; + signal: AbortSignal | undefined; + respond(body: unknown, status?: number): void; +} + +export function reply(body: unknown, status = 200): Response { + if (status === 204) return new Response(null, { status }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export function page(...items: unknown[]): Page { + return { items, limit: 50, offset: 0 }; +} + +/** + * Stub `fetch` so every request stays open until the test answers it, which + * lets a test control the order completions arrive in. Aborts are ignored, so + * a test can deliver a completion after its request was superseded. + */ +export function mockDatabaseFetch() { + const requests: PendingRequest[] = []; + const fetchMock = vi.fn( + (url: string, init: RequestInit = {}) => + new Promise((resolve) => { + requests.push({ + url, + method: init.method ?? "GET", + body: typeof init.body === "string" ? JSON.parse(init.body) : null, + signal: init.signal ?? undefined, + respond: (body, status) => resolve(reply(body, status)), + }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + /** Requests sent with `method`, in order. */ + const sent = (method: string) => + requests.filter((request) => request.method === method); + return { requests, fetchMock, sent }; +} + +/** Publish database routes, and optionally relations, as the server would. */ +export function publishDatabase( + endpoints: Record, + relations?: Record>, +): void { + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: { database: endpoints }, + plugins: relations ? { database: { relations } } : {}, + }; + _resetConfigCache(); + resetDatabaseRequestStore(); +} + +export function resetDatabaseTestEnvironment(): void { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + resetDatabaseRequestStore(); +} + +/** Let the deferred teardown of a released entry run. */ +export const nextTick = () => + new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/request-store.test.ts index 769ea6d01..66e52ed9d 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 @@ -67,7 +67,7 @@ describe("createRequestStore", () => { }); test("autoStart:false defers the run until start() is called", () => { - store.retain("k", run, false); + store.retain("k", run, { autoStart: false }); expect(run).not.toHaveBeenCalled(); store.start("k"); @@ -77,7 +77,7 @@ describe("createRequestStore", () => { 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.retain("deferred", deferred, { autoStart: false }); store.restartStarted(); @@ -91,20 +91,75 @@ describe("createRequestStore", () => { 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); + test("restartStarted restarts only the entries the predicate accepts, by key and meta", () => { + const tagged = createRequestStore(IDLE); + const notes = vi.fn((_c: RequestControls) => {}); + const boards = vi.fn((_c: RequestControls) => {}); + tagged.retain("/a", notes, { meta: { table: "notes" } }); + tagged.retain("/b", boards, { meta: { table: "boards" } }); + // A later joiner shares the entry, so its meta is ignored. + tagged.retain("/a", notes, { meta: { table: "boards" } }); + + const seen: [string, string | undefined][] = []; + void tagged.restartStarted((key, meta) => { + seen.push([key, meta?.table]); + return meta?.table === "notes"; + }); + + expect(seen.sort()).toEqual([ + ["/a", "notes"], + ["/b", "boards"], + ]); + expect(notes).toHaveBeenCalledTimes(2); + expect(boards).toHaveBeenCalledTimes(1); + }); + + test("restartStarted skips an entry whose last subscriber left before teardown", async () => { + const release = store.retain("gone", run); + store.retain("kept", run); + release(); - const seen: string[] = []; - store.restartStarted((key) => { - seen.push(key); - return key.startsWith("notes "); + // Teardown is deferred a tick; the released entry must not run again. + void store.restartStarted(); + expect(run).toHaveBeenCalledTimes(3); + expect(run.mock.calls.at(-1)?.[0].signal.aborted).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 0)); + store.retain("gone", run); + expect(run).toHaveBeenCalledTimes(4); + }); + + test("restartStarted resolves once every restarted run settles", async () => { + const pending: (() => void)[] = []; + store.retain( + "k", + () => + new Promise((resolve) => { + pending.push(resolve); + }), + ); + store.retain("void", run); + + let settled = false; + const restarted = store.restartStarted().then(() => { + settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); - expect(seen.sort()).toEqual(["boards /b", "notes /a"]); - expect(run).toHaveBeenCalledTimes(2); - expect(other).toHaveBeenCalledTimes(1); + pending[1]?.(); + await restarted; + expect(settled).toBe(true); + }); + + test("restartStarted resolves even when a runner rejects", async () => { + let runs = 0; + store.retain("k", () => { + runs += 1; + return runs === 1 ? undefined : Promise.reject(new Error("broken")); + }); + + await expect(store.restartStarted()).resolves.toBeUndefined(); }); test("restartStarted aborts the prior run before re-running with a fresh signal", () => { 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 index c36348d13..4f3308f8a 100644 --- 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 @@ -1,81 +1,32 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { StrictMode } from "react"; +import { StrictMode, useEffect } 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)); +import { + mockDatabaseFetch, + nextTick, + type PendingRequest, + page, + publishDatabase, + resetDatabaseTestEnvironment, + useDatabaseList, +} from "./database-test-utils"; describe("useDatabaseList", () => { let requests: PendingRequest[]; - let fetchMock: ReturnType; + let fetchMock: ReturnType["fetchMock"]; 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(); + ({ requests, fetchMock } = mockDatabaseFetch()); + publishDatabase({ + "notes.list": "/api/database/notes", + "boards.list": "/api/database/boards", + }); }); - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); + afterEach(resetDatabaseTestEnvironment); test("reads the published route with the encoded query", async () => { const { result } = renderHook(() => @@ -163,6 +114,28 @@ describe("useDatabaseList", () => { expect(result.current.loading).toBe(true); }); + test("null params hold the read idle until the params they depend on exist", () => { + const { result, rerender } = renderHook( + ({ boardId }: { boardId: number | undefined }) => + useDatabaseList( + "notes", + boardId === undefined ? null : { where: { board_id: boardId } }, + ), + { initialProps: { boardId: undefined as number | undefined } }, + ); + + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ boardId: 7 }); + expect(requests[0]?.url).toContain("board_id%22%3A7"); + expect(result.current.loading).toBe(true); + }); + test("does not encode an incomplete filter while disabled", () => { const { rerender } = renderHook( ({ boardId }: { boardId: number | undefined }) => @@ -188,9 +161,28 @@ describe("useDatabaseList", () => { code: "INVALID_REQUEST", status: null, }); + expect(result.current.loading).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); + test("keeps an INVALID_REQUEST error stable, so an effect keyed on it runs once", () => { + const seen: unknown[] = []; + const { result, rerender } = renderHook(() => { + const read = useDatabaseList("notes", { where: { board_id: undefined } }); + useEffect(() => { + seen.push(read.error); + }, [read.error]); + return read; + }); + const first = result.current.error; + + rerender(); + rerender(); + + expect(result.current.error).toBe(first); + expect(seen).toEqual([first]); + }); + test("refetch aborts the in-flight request, sends it again, and keeps the last page visible", async () => { const { result } = renderHook(() => useDatabaseList("notes")); await act(async () => requests[0]?.respond(page({ id: 1 }))); @@ -227,6 +219,109 @@ describe("useDatabaseList", () => { expect(result.current.loading).toBe(false); }); + test("new params show null while they load, unless keepPreviousData holds the last page", async () => { + const { result, rerender } = renderHook( + ({ offset, keep }: { offset: number; keep: boolean }) => + useDatabaseList( + "notes", + { limit: 1, offset }, + { keepPreviousData: keep }, + ), + { initialProps: { offset: 0, keep: false } }, + ); + await act(async () => requests[0]?.respond(page({ id: 0 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 1, keep: false }); + expect(result.current).toMatchObject({ data: null, loading: true }); + await act(async () => requests[1]?.respond(page({ id: 1 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 2, keep: true }); + expect(result.current).toMatchObject({ + data: page({ id: 1 }), + loading: true, + error: null, + }); + await act(async () => requests[2]?.respond(page({ id: 2 }))); + await waitFor(() => expect(result.current.data).toEqual(page({ id: 2 }))); + }); + + test("keepPreviousData does not show the last page beside a failure for the new params", async () => { + const { result, rerender } = renderHook( + ({ offset }: { offset: number }) => + useDatabaseList( + "notes", + { limit: 1, offset }, + { keepPreviousData: true }, + ), + { initialProps: { offset: 0 } }, + ); + await act(async () => requests[0]?.respond(page({ id: 0 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 1 }); + await act(async () => requests[1]?.respond({ error: "boom" }, 500)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toMatchObject({ code: "INTERNAL" }); + }); + + test("a shape function checks each row once per response and types the result", async () => { + const parse = vi.fn((row: unknown) => { + const { id } = row as { id: unknown }; + if (typeof id !== "number") throw new TypeError(`bad id ${String(id)}`); + return { id, label: `#${id}` }; + }); + const { result, rerender } = renderHook(() => + // An inline arrow: a new function on every render. + useDatabaseList("notes", {}, { shape: (row) => parse(row) }), + ); + await act(async () => requests[0]?.respond(page({ id: 1 }, { id: 2 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toEqual({ + items: [ + { id: 1, label: "#1" }, + { id: 2, label: "#2" }, + ], + limit: 50, + offset: 0, + }); + const shaped = result.current.data; + rerender(); + rerender(); + expect(result.current.data).toBe(shaped); + expect(parse).toHaveBeenCalledTimes(2); + }); + + test("a row that fails its shape fails the read with INTERNAL, without echoing the row", async () => { + const { result } = renderHook(() => + useDatabaseList( + "notes", + {}, + { + shape: (row) => { + throw new TypeError(`secret ${JSON.stringify(row)}`); + }, + }, + ), + ); + await act(async () => requests[0]?.respond(page({ id: "token" }))); + 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: "INTERNAL", + status: null, + message: "Database response does not match the read's shape", + }); + expect(result.current.error?.message).not.toContain("token"); + expect(result.current.error?.cause).toBeInstanceOf(TypeError); + }); + test("aborts the request once the last subscriber unmounts", async () => { const first = renderHook(() => useDatabaseList("notes")); const second = renderHook(() => useDatabaseList("notes")); 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 index 9a82233c2..e30f77e26 100644 --- 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 @@ -2,125 +2,58 @@ 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; + invalidateDatabaseReads, + mockDatabaseFetch, + page, + publishDatabase, + resetDatabaseTestEnvironment, + type Row, + useDatabaseCreate, + useDatabaseDelete, + useDatabaseList, + useDatabaseUpdate, +} from "./database-test-utils"; + +const ENDPOINTS = { + "notes.list": "/api/database/notes", + "notes.create": "/api/database/notes", + "notes.update": "/api/database/notes/:id", + "notes.delete": "/api/database/notes/:id", + "boards.list": "/api/database/boards", + "note_events.list": "/api/database/note_events", }; -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; + +// As `DatabasePlugin` publishes them: board → notes → note_events. +const RELATIONS = { + boards: { notes: "notes" }, + notes: { boards: "boards", note_events: "note_events" }, + note_events: { notes: "notes" }, }; -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; + let fetchMock: ReturnType["fetchMock"]; + let sent: ReturnType["sent"]; + + /** URLs of the reads sent after the first `after`. */ + const readsAfter = (after: number) => + sent("GET") + .slice(after) + .map((request) => request.url); - /** Requests to `method`, in the order they were sent. */ - const sent = (method: string) => - requests.filter((request) => request.method === method); + /** Answer every read still open, as the server would. */ + const answerReads = (from: number, body: unknown = page({ id: 1 })) => { + for (const request of sent("GET").slice(from)) request.respond(body); + }; beforeEach(() => { - 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(); + ({ fetchMock, sent } = mockDatabaseFetch()); + publishDatabase(ENDPOINTS, RELATIONS); }); - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); + afterEach(resetDatabaseTestEnvironment); /** Mount a notes read and a boards read, and answer both. */ async function mountReads() { @@ -128,9 +61,7 @@ describe("database write hooks", () => { notes: useDatabaseList("notes"), boards: useDatabaseList("boards"), })); - await act(async () => { - for (const request of sent("GET")) request.respond(page({ id: 1 })); - }); + await act(async () => answerReads(0)); await waitFor(() => expect(reads.result.current.boards.loading).toBe(false), ); @@ -266,41 +197,75 @@ describe("database write hooks", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - test("a successful write restarts every mounted read by default, keeping their data while they load", async () => { + test("refuses a write addressed by an unaddressable id, without a request", async () => { + const { result } = renderHook(() => useDatabaseDelete("notes")); + + let removed!: Promise; + await act(async () => { + removed = result.current.remove(".."); + await removed; + }); + + await expect(removed).resolves.toBe(false); + expect(result.current.error).toMatchObject({ code: "INVALID_REQUEST" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("a successful write restarts every mounted read by default and resolves once they reload", async () => { const reads = await mountReads(); const writer = renderHook(() => useDatabaseCreate("notes")); expect(sent("GET")).toHaveLength(2); let created!: Promise; + let settled = false; act(() => { created = writer.result.current.create({ body: "new" }); + void created.then(() => { + settled = true; + }); }); await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); - await created; - expect(sent("GET").map((request) => request.url)).toEqual([ - "/api/database/notes", - "/api/database/boards", + expect(readsAfter(2)).toEqual([ "/api/database/notes", "/api/database/boards", ]); + // Reads keep their data while they reload; the write waits for them. expect(reads.result.current.notes).toMatchObject({ data: page({ id: 1 }), loading: true, }); + expect(writer.result.current).toMatchObject({ loading: true, data: null }); + expect(settled).toBe(false); await act(async () => { sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 })); sent("GET")[3]?.respond(page({ id: 1 })); }); - await waitFor(() => - expect(reads.result.current.notes.data).toEqual( - page({ id: 1 }, { id: 2 }), - ), - ); + await expect(created).resolves.toEqual({ id: 2 }); + expect(reads.result.current.notes.data).toEqual(page({ id: 1 }, { id: 2 })); + expect(writer.result.current).toMatchObject({ + loading: false, + data: { id: 2 }, + }); + }); + + test("a restarted read that fails still lets the write resolve", async () => { + await mountReads(); + const writer = renderHook(() => useDatabaseCreate("notes")); + + let created!: Promise; + act(() => { + created = writer.result.current.create({ body: "new" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + await act(async () => answerReads(2, { error: "down" })); + + await expect(created).resolves.toEqual({ id: 2 }); + expect(writer.result.current.error).toBeNull(); }); - test("invalidate narrows the restart to reads of the named entities, or turns it off", async () => { + test("invalidate narrows the restart to reads of the named tables, or turns it off", async () => { await mountReads(); const writers = renderHook(() => ({ narrow: useDatabaseCreate("notes", { invalidate: ["notes"] }), @@ -312,12 +277,9 @@ describe("database write hooks", () => { created = writers.result.current.narrow.create({ body: "a" }); }); await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + expect(readsAfter(2)).toEqual(["/api/database/notes"]); + await act(async () => answerReads(2)); await created; - expect( - sent("GET") - .slice(2) - .map((request) => request.url), - ).toEqual(["/api/database/notes"]); act(() => { created = writers.result.current.none.create({ body: "b" }); @@ -327,26 +289,79 @@ describe("database write hooks", () => { expect(sent("GET")).toHaveLength(3); }); + test("a narrowed invalidate reaches the reads whose includes show the written table", async () => { + renderHook(() => ({ + // boards → notes, and boards → notes → note_events. + boards: useDatabaseList("boards", { include: { notes: { limit: 5 } } }), + timeline: useDatabaseList("boards", { + include: { notes: { include: { note_events: { limit: 5 } } } }, + }), + plain: useDatabaseList("boards"), + events: useDatabaseList("note_events"), + })); + await act(async () => answerReads(0)); + + act(() => { + void invalidateDatabaseReads(["notes"]); + }); + expect(readsAfter(4)).toEqual([ + "/api/database/boards?include=%7B%22notes%22%3A%7B%22limit%22%3A5%7D%7D", + expect.stringContaining("note_events"), + ]); + + act(() => { + void invalidateDatabaseReads(["note_events"]); + }); + expect(readsAfter(6)).toEqual([ + expect.stringContaining("note_events"), + "/api/database/note_events", + ]); + }); + + test("a read with an include the server did not describe restarts on any scoped invalidate", async () => { + renderHook(() => ({ + unknown: useDatabaseList("boards", { include: { archived: true } }), + plain: useDatabaseList("boards"), + })); + await act(async () => answerReads(0)); + + act(() => { + void invalidateDatabaseReads(["note_events"]); + }); + expect(readsAfter(2)).toEqual([ + "/api/database/boards?include=%7B%22archived%22%3Atrue%7D", + ]); + }); + test("invalidateDatabaseReads refreshes reads after a write the hooks did not make", async () => { await mountReads(); // A custom route or a databaseApi call changed boards rows. - act(() => invalidateDatabaseReads(["boards"])); - expect( - sent("GET") - .slice(2) - .map((request) => request.url), - ).toEqual(["/api/database/boards"]); + let refreshed!: Promise; + let done = false; + act(() => { + refreshed = invalidateDatabaseReads(["boards"]).then(() => { + done = true; + }); + }); + expect(readsAfter(2)).toEqual(["/api/database/boards"]); + await Promise.resolve(); + expect(done).toBe(false); + await act(async () => answerReads(2)); + await refreshed; + expect(done).toBe(true); // With no scope, every mounted read restarts. - act(() => invalidateDatabaseReads()); - expect( - sent("GET") - .slice(3) - .map((request) => request.url), - ).toEqual(["/api/database/notes", "/api/database/boards"]); - - act(() => invalidateDatabaseReads(false)); + act(() => { + void invalidateDatabaseReads(); + }); + expect(readsAfter(3)).toEqual([ + "/api/database/notes", + "/api/database/boards", + ]); + + await expect(invalidateDatabaseReads(false)).resolves.toBeUndefined(); + await expect(invalidateDatabaseReads([])).resolves.toBeUndefined(); expect(sent("GET")).toHaveLength(5); }); @@ -369,8 +384,8 @@ describe("database write hooks", () => { 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(() => {}); + const onSuccess = vi.fn(); + const writer = renderHook(() => useDatabaseCreate("notes", { onSuccess })); let created!: Promise; act(() => { @@ -381,11 +396,85 @@ describe("database write hooks", () => { // The write carries no abort signal: cancelling it would not undo it. expect(sent("POST")[0]?.signal).toBeUndefined(); await act(async () => sent("POST")[0]?.respond({ id: 9 }, 201)); + expect(sent("GET")).toHaveLength(4); + await act(async () => answerReads(2)); await expect(created).resolves.toEqual({ id: 9 }); - expect(sent("GET")).toHaveLength(4); - expect(errors).not.toHaveBeenCalled(); - errors.mockRestore(); + // The rows changed, so the callback runs even though the hook is gone. + expect(onSuccess).toHaveBeenCalledWith({ id: 9 }, { body: "late" }); + }); + + test("onSuccess and onError run for every call with its arguments, after the state settles", async () => { + const events: string[] = []; + const { result } = renderHook(() => { + const hook = useDatabaseUpdate("notes", { + onSuccess: (row, id, values) => + events.push(`ok ${JSON.stringify([row, id, values])}`), + onError: (error, id) => + events.push(`fail ${error.code} ${String(id)} ${hook.loading}`), + }); + return hook; + }); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.update(1, { body: "a" }); + second = result.current.update(2, { body: "b" }); + }); + await act(async () => { + sent("PATCH")[0]?.respond({ id: 1 }); + sent("PATCH")[1]?.respond({ error: "Database conflict" }, 409); + }); + await Promise.all([first, second]); + + // The stale first call does not update the state, but is still reported. + expect(events).toHaveLength(2); + expect(events).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^fail CONFLICT 2 /), + `ok ${JSON.stringify([{ id: 1 }, 1, { body: "a" }])}`, + ]), + ); + expect(result.current.error).toMatchObject({ code: "CONFLICT" }); + }); + + test("a delete reports the removed id to onSuccess", async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => + useDatabaseDelete("notes", { onSuccess }), + ); + + let removed!: Promise; + act(() => { + removed = result.current.remove(7); + }); + await act(async () => sent("DELETE")[0]?.respond(null, 204)); + + await expect(removed).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledWith(7); + }); + + test("a throwing callback is reported as uncaught without breaking the call", async () => { + const reported = vi.fn(); + vi.stubGlobal("reportError", reported); + const { result } = renderHook(() => + useDatabaseCreate("notes", { + onSuccess: () => { + throw new Error("callback bug"); + }, + }), + ); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "x" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + + await expect(created).resolves.toEqual({ id: 1 }); + expect(reported).toHaveBeenCalledWith(new Error("callback bug")); + expect(result.current).toMatchObject({ data: { id: 1 }, error: null }); }); test("only the latest call reports its state; an earlier call still resolves", async () => { @@ -445,10 +534,13 @@ describe("database write hooks", () => { expect(result.current).toMatchObject({ data: null, loading: false }); }); - test("keeps its write functions stable across renders with an inline entity list", () => { + test("keeps its write functions stable across renders with inline options", () => { const { result, rerender } = renderHook(() => ({ - create: useDatabaseCreate("notes", { invalidate: ["notes", "boards"] }), - remove: useDatabaseDelete("notes"), + create: useDatabaseCreate("notes", { + invalidate: ["notes", "boards"], + onSuccess: () => {}, + }), + remove: useDatabaseDelete("notes", { onError: () => {} }), })); const { create } = result.current.create; const { remove, reset } = result.current.remove; @@ -460,6 +552,26 @@ describe("database write hooks", () => { expect(result.current.remove.reset).toBe(reset); }); + test("uses the options of the latest render when a call settles", async () => { + await mountReads(); + const { result, rerender } = renderHook( + ({ scope }: { scope: readonly string[] }) => + useDatabaseCreate("notes", { invalidate: scope }), + { initialProps: { scope: ["notes"] as readonly string[] } }, + ); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "x" }); + }); + rerender({ scope: ["boards"] }); + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + + expect(readsAfter(2)).toEqual(["/api/database/boards"]); + await act(async () => answerReads(2)); + await created; + }); + test("reports its state under StrictMode", async () => { const { result } = renderHook(() => useDatabaseCreate("notes"), { wrapper: StrictMode, 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 index 2db31092b..8860246cf 100644 --- 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 @@ -1,58 +1,36 @@ 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 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" }, - }); -} +import { + invalidateDatabaseReads, + nextTick, + publishDatabase, + reply, + resetDatabaseTestEnvironment, + useDatabaseRecord, +} from "./database-test-utils"; describe("useDatabaseRecord", () => { let fetchMock: ReturnType; beforeEach(() => { + // Answers every detail read at once with a row named after its id. fetchMock = vi.fn(async (url: string) => { const id = decodeURIComponent(url.split("?")[0]?.split("/").pop() ?? ""); - return json({ id, body: `note ${id}` }); + return reply({ 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(); + publishDatabase({ + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + "events.list": "/api/database/events", + }); }); - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); + afterEach(resetDatabaseTestEnvironment); test("reads the detail route with the id as one path segment and the encoded query", async () => { const { result } = renderHook(() => @@ -91,6 +69,22 @@ describe("useDatabaseRecord", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes/7"); }); + test.each(["", ".", ".."])( + "refuses the id %j, which would resolve to another route, without a request", + (id) => { + const { result, rerender } = renderHook(() => + useDatabaseRecord("notes", id), + ); + const first = result.current.error; + + expect(first).toMatchObject({ code: "INVALID_REQUEST", status: null }); + expect(result.current).toMatchObject({ data: null, loading: false }); + expect(fetchMock).not.toHaveBeenCalled(); + rerender(); + expect(result.current.error).toBe(first); + }, + ); + test("does not encode incomplete includes while the record is disabled", () => { const { result, rerender } = renderHook( ({ id, author }: { id: number | null; author: string | undefined }) => @@ -124,9 +118,25 @@ describe("useDatabaseRecord", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + test("keepPreviousData shows the previous record while the next id loads", async () => { + const { result, rerender } = renderHook( + ({ id }: { id: number }) => + useDatabaseRecord("notes", id, {}, { keepPreviousData: true }), + { initialProps: { id: 1 } }, + ); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); + + rerender({ id: 2 }); + expect(result.current).toMatchObject({ + data: { id: "1" }, + loading: true, + }); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "2" })); + }); + test("reports a missing row as NOT_FOUND", async () => { fetchMock.mockResolvedValueOnce( - json({ error: "Database record not found" }, 404), + reply({ error: "Database record not found" }, 404), ); const { result } = renderHook(() => useDatabaseRecord("notes", 404)); @@ -141,6 +151,51 @@ describe("useDatabaseRecord", () => { }); }); + test("clears the row when a refetch finds it deleted, but keeps it through other failures", async () => { + const { result } = renderHook(() => useDatabaseRecord("notes", 1)); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); + + fetchMock.mockResolvedValueOnce(reply({ error: "Unavailable" }, 503)); + act(() => result.current.refetch()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toMatchObject({ id: "1" }); + expect(result.current.error).toMatchObject({ status: 503 }); + + // The row was deleted elsewhere; the restart after that write finds it gone. + fetchMock.mockResolvedValueOnce( + reply({ error: "Database record not found" }, 404), + ); + await act(() => invalidateDatabaseReads()); + expect(result.current.data).toBeNull(); + expect(result.current.error).toMatchObject({ code: "NOT_FOUND" }); + }); + + test("applies a shape function to the record", async () => { + const { result } = renderHook(() => + useDatabaseRecord( + "notes", + 7, + {}, + { + shape: (row) => ({ title: String((row as { body: string }).body) }), + }, + ), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual({ title: "note 7" }); + }); + + test("reads once under StrictMode and keeps the request across the remount", async () => { + const { result } = renderHook(() => useDatabaseRecord("notes", 3), { + wrapper: StrictMode, + }); + + await nextTick(); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "3" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test("reports NOT_EXPOSED for a table with no detail route, without a request", () => { const { result } = renderHook(() => useDatabaseRecord("events", 1)); 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 13a8ccafd..ee6c701cc 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 @@ -248,7 +248,7 @@ test("serialized() replaces the row type and keeps entity, id, and params che const diagnostics = compileTypeProbe(` import { type DatabaseListPage, - type DatabaseReadResult, + type UseDatabaseListResult, serialized, useDatabaseList, useDatabaseRecord, @@ -264,7 +264,8 @@ test("serialized() replaces the row type and keeps entity, id, and params che { order: { id: "desc" }, limit: 5 }, { shape: serialized() }, ); - const typed: DatabaseReadResult> = cards; + const typed: UseDatabaseListResult = cards; + const page: DatabaseListPage | null = cards.data; const headline: string | undefined = cards.data?.items[0]?.headline; // @ts-expect-error the shaped row replaces the inferred one cards.data?.items[0]?.title; @@ -274,7 +275,7 @@ test("serialized() replaces the row type and keeps entity, id, and params che enabled: false, }); const count: number | undefined = card.data?.comment_count; - void [typed, headline, count]; + void [typed, page, headline, count]; } export function StillChecked() { @@ -300,6 +301,56 @@ test("serialized() replaces the row type and keeps entity, id, and params che expect(diagnostics).toEqual([]); }); +test("read hooks take null params, keepPreviousData, and a parse shape", () => { + const diagnostics = compileTypeProbe(` + import { + type DatabaseListPage, + type UseDatabaseListResult, + type UseDatabaseRecordResult, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + interface PostCard { id: number; headline: string } + declare function parsePostCard(row: unknown): PostCard; + + export function Dependent(userSlug: string | undefined, offset: number) { + // Null params hold the read; the non-null branch keeps its exact type. + const posts = useDatabaseList( + "posts", + userSlug === undefined ? null : { where: { user_slug: userSlug }, limit: 20, offset }, + { keepPreviousData: true }, + ); + const title: string | undefined = posts.data?.items[0]?.title; + const typed: UseDatabaseListResult<{ id: number; user_slug: string; title: string; total: string; payload: unknown }> = posts; + // @ts-expect-error the non-null branch is still checked exactly + useDatabaseList("posts", userSlug ? { where: { secret: userSlug } } : null); + void [title, typed]; + } + + export function Parsed() { + const cards = useDatabaseList("posts", { limit: 5 }, { shape: parsePostCard }); + const page: DatabaseListPage | null = cards.data; + const inline = useDatabaseList("posts", { limit: 5 }, { + shape: (row) => ({ id: Number((row as { id: unknown }).id), headline: "x" }), + }); + const headline: string | undefined = inline.data?.items[0]?.headline; + // @ts-expect-error the parsed row replaces the inferred one + cards.data?.items[0]?.title; + + const card = useDatabaseRecord("posts", 7, {}, { shape: parsePostCard, keepPreviousData: true }); + const record: UseDatabaseRecordResult = card; + // @ts-expect-error a shape is a parse function or serialized(), not a value + useDatabaseList("posts", {}, { shape: parsePostCard(null) }); + void [page, headline, record]; + } + `); + + 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"; @@ -327,6 +378,32 @@ test("write hooks type values, ids, and rows from the generated registry", () => useDatabaseCreate("sessions").create({ user_slug: "ada" }); useDatabaseCreate("posts", { invalidate: ["posts", "users"] }); useDatabaseCreate("posts", { invalidate: false }); + useDatabaseCreate("posts", { + onSuccess: (row, values) => { + const id: number = row.id; + const title: string = values.title; + void [id, title]; + }, + onError: (error, values) => { + const code: string = error.code; + void [code, values.user_slug]; + }, + }); + useDatabaseUpdate("posts", { + onSuccess: (row, id, values) => { + const title: string = row.title; + const key: number = id; + const next: string | undefined = values.title; + void [title, key, next]; + }, + }); + useDatabaseDelete("ledger", { + onSuccess: (id) => { + const seq: string | number | bigint = id; + void seq; + }, + onError: (error, id) => void [error.status, id], + }); const edit = useDatabaseUpdate("posts"); const updated: Promise | null> = edit.update(7, { title: "New", total: "2" }); @@ -357,6 +434,10 @@ test("write hooks type values, ids, and rows from the generated registry", () => posts.create({ user_slug: "ada", title: 1, total: 1 }); // @ts-expect-error invalidation names generated tables useDatabaseCreate("posts", { invalidate: ["missing"] }); + // @ts-expect-error a create answers the public row, which has no private column + useDatabaseCreate("users", { onSuccess: (row) => row.secret }); + // @ts-expect-error a delete reports the id, not a row + useDatabaseDelete("posts", { onSuccess: (id) => id.title }); // @ts-expect-error keyless entities have no update route useDatabaseUpdate("events"); diff --git a/packages/appkit-ui/src/react/hooks/analytics-request-store.ts b/packages/appkit-ui/src/react/hooks/analytics-request-store.ts index 203bc0aea..b626a7374 100644 --- a/packages/appkit-ui/src/react/hooks/analytics-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/analytics-request-store.ts @@ -215,7 +215,7 @@ export function retain( options: AnalyticsRequestOptions, autoStart = true, ): () => void { - return store.retain(key, runAnalyticsRequest(options), autoStart); + return store.retain(key, runAnalyticsRequest(options), { autoStart }); } export const start = store.start; 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 d6bf2049f..b9e27a70a 100644 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -1,3 +1,4 @@ +import { getPluginClientConfig } from "@/js/config"; import { requestDatabase } from "@/js/database/client"; import { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseEntity } from "@/js/database/types"; @@ -6,15 +7,15 @@ 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. + * read, a table list for the reads that touch those tables, `false` for none. */ export type DatabaseInvalidation = boolean | readonly DatabaseEntity[]; /** * Shared in-flight read store for the database hooks: an instance of the * generic {@link createRequestStore} lifecycle wired to the database client's - * transport. Hook instances that resolve to the same route and encoded query - * share one request and one snapshot while any of them is mounted. + * transport. Hook instances that resolve to the same URL share one request and + * one snapshot while any of them is mounted. * * Nothing outlives its last subscriber, so this deduplicates reads; it is not * a cache. @@ -28,27 +29,68 @@ interface DatabaseReadSnapshot { } /** Snapshot for keys with no live entry. Referentially stable. */ -export const IDLE_DATABASE_READ: DatabaseReadSnapshot = { +export const IDLE_DATABASE_READ: DatabaseReadSnapshot = Object.freeze({ data: null, loading: false, error: null, -}; +}); /** Checks a decoded body is the envelope the read's route answers with. */ type ResponseGuard = (body: unknown) => body is object; /** - * 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. + * The tables one read shows rows of: the one it is rooted at and every table + * its includes reach. `open` marks a read with an include the server did not + * describe, which any scoped invalidation restarts rather than risk missing. */ -export function databaseReadKey(entity: string, url: string): string { - return `${entity} ${url}`; +export interface DatabaseReadScope { + readonly tables: ReadonlySet; + readonly open: boolean; } -/** The entity a read key is rooted at. */ -function readKeyEntity(key: string): string { - return key.slice(0, key.indexOf(" ")); +/** `{ table: { relation: targetTable } }`, as `DatabasePlugin` publishes it. */ +type PublishedRelations = Record>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function publishedRelations(): PublishedRelations { + const { relations } = getPluginClientConfig<{ relations?: unknown }>( + "database", + ); + return isRecord(relations) ? (relations as PublishedRelations) : {}; +} + +/** + * Walk a read's include tree through the relations the server published, so + * a write to `notes` can find a `boards` read that includes its notes. + */ +export function databaseReadScope( + entity: string, + include: unknown, +): DatabaseReadScope { + const relations = publishedRelations(); + const tables = new Set([entity]); + let open = false; + + const walk = (table: string, tree: unknown): void => { + if (!isRecord(tree)) return; + const edges = relations[table]; + for (const [name, options] of Object.entries(tree)) { + const target = + isRecord(edges) && Object.hasOwn(edges, name) ? edges[name] : undefined; + if (typeof target !== "string") { + open = true; + continue; + } + tables.add(target); + if (isRecord(options)) walk(target, options.include); + } + }; + walk(entity, include); + + return { tables, open }; } /** Anything a request rejects with other than an abort, as the hooks report it. */ @@ -63,10 +105,20 @@ export function asDatabaseApiError(error: unknown): DatabaseApiError { ); } +/** Resolves once `signal` aborts, even if the transport ignores it. */ +function whenAborted(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + /** * Build the runner for one read. A restart keeps the last result visible * while it loads, and a run superseded by a restart or torn down after its - * last release never patches the entry. + * last release never patches the entry. A `NOT_FOUND` drops the last result: + * the row is gone, so showing it beside the error would mislead. The returned + * promise settles once the run has patched its outcome or was aborted. */ function runDatabaseRead( url: string, @@ -74,31 +126,44 @@ function runDatabaseRead( ): RequestRunner { return ({ signal, patch }) => { patch({ loading: true, error: null }); - requestDatabase(url, { method: "GET", signal }, accept).then( + const outcome = requestDatabase( + url, + { method: "GET", signal }, + accept, + ).then( (data) => { if (!signal.aborted) patch({ data, loading: false, error: null }); }, - (error: unknown) => { - if (!signal.aborted) { - patch({ loading: false, error: asDatabaseApiError(error) }); - } + (cause: unknown) => { + if (signal.aborted) return; + const error = asDatabaseApiError(cause); + patch( + error.code === "NOT_FOUND" + ? { data: null, loading: false, error } + : { loading: false, error }, + ); }, ); + return Promise.race([outcome, whenAborted(signal)]); }; } -const store = createRequestStore(IDLE_DATABASE_READ); +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. + * Register a subscriber for the read of `url`, starting it on first use. + * `scope` is kept from the first subscriber; it is derived from the same + * params as `url`, so every subscriber of one URL has the same scope. + * Returns a `release` function that must be called on unmount. */ export function retainDatabaseRead( - key: string, url: string, accept: ResponseGuard, + scope: DatabaseReadScope, ): () => void { - return store.retain(key, runDatabaseRead(url, accept)); + return store.retain(url, runDatabaseRead(url, accept), { meta: scope }); } /** @@ -106,27 +171,32 @@ export function retainDatabaseRead( * 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. + * `true` (the default) restarts every read. A table list restarts the reads + * that show rows of those tables: reads rooted at them, and reads whose + * includes reach them through the relations the server published. `false` + * restarts none. + * + * Resolves once every restarted read has answered, failed, or been + * superseded; it never rejects. * * @example * ```ts - * await fetch(`/api/cases/${id}/sar`, { method: "POST" }); - * invalidateDatabaseReads(["str_reports", "activity_log"]); + * await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); + * await invalidateDatabaseReads(["boards", "notes"]); * ``` */ 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))); +): Promise { + if (scope === false) return Promise.resolve(); + if (scope === true) return store.restartStarted(); + const written = new Set(scope); + if (written.size === 0) return Promise.resolve(); + return store.restartStarted((_url, read) => { + if (read === undefined || read.open) return true; + for (const table of read.tables) if (written.has(table)) return true; + return false; + }); } export const startDatabaseRead = store.start; diff --git a/packages/appkit-ui/src/react/hooks/request-store.ts b/packages/appkit-ui/src/react/hooks/request-store.ts index b13eae1b1..9fd1a0072 100644 --- a/packages/appkit-ui/src/react/hooks/request-store.ts +++ b/packages/appkit-ui/src/react/hooks/request-store.ts @@ -24,35 +24,57 @@ export interface RequestControls { patch(next: Partial): void; } -/** Starts a request and reports state through `controls`. */ -export type RequestRunner = (controls: RequestControls) => void; +/** + * Starts a request and reports state through `controls`. It may return a + * promise that settles once the run has reported its outcome or was aborted; + * `restartStarted` waits on it. It must not reject. + */ +export type RequestRunner = ( + controls: RequestControls, +) => void | Promise; + +/** How `retain` creates an entry; ignored when the entry already exists. */ +export interface RetainOptions { + /** Start the request on creation. Default true. */ + autoStart?: boolean; + /** Caller data kept with the entry and handed to `restartStarted`'s match. */ + meta?: M; +} -interface RequestStore { +interface RequestStore { /** * Register a subscriber for `key`, creating and starting the shared request * on first use. Returns a `release` function to call on unmount. * - * @param run Runs the request; stored on the entry and re-invoked by + * @param run Runs the request; stored on the entry and re-invoked by * `start`. Only the first caller's `run` is used (later joiners share it). - * @param autoStart Start the request on creation. Default true. + * @param options `autoStart` and `meta`, both taken from the first caller. */ - retain(key: string, run: RequestRunner, autoStart?: boolean): () => void; + retain( + key: string, + run: RequestRunner, + options?: RetainOptions, + ): () => 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. + * `start` every subscribed entry that has run at least once and that + * `match` accepts (every such entry without one), then resolve once each + * restarted run settles. An entry retained with `autoStart: false` that + * never ran stays idle, and one whose last subscriber left is not restarted. */ - restartStarted(match?: (key: string) => boolean): void; + restartStarted( + match?: (key: string, meta: M | undefined) => boolean, + ): Promise; subscribe(key: string, listener: () => void): () => void; getSnapshot(key: string): S; /** Test-only: abort every in-flight request and clear the store. */ reset(): void; } -interface Entry { +interface Entry { snapshot: S; + meta: M | undefined; refCount: number; abortController: AbortController | null; teardownTimer: ReturnType | null; @@ -61,8 +83,8 @@ interface Entry { run: RequestRunner; } -export function createRequestStore(idle: S): RequestStore { - const entries = new Map>(); +export function createRequestStore(idle: S): RequestStore { + const entries = new Map>(); // Keyed separately from `entries`: `subscribe` can run before `retain` // creates the entry, so listeners must survive independently of entry life. @@ -74,9 +96,10 @@ export function createRequestStore(idle: S): RequestStore { for (const listener of listeners) listener(); } - function start(key: string): void { + /** Run `key` again; resolves when that run settles (at once for a void run). */ + function run(key: string): Promise { const entry = entries.get(key); - if (!entry) return; + if (!entry) return Promise.resolve(); entry.abortController?.abort(); entry.started = true; @@ -84,7 +107,7 @@ export function createRequestStore(idle: S): RequestStore { const abortController = new AbortController(); entry.abortController = abortController; - entry.run({ + const settled = entry.run({ signal: abortController.signal, abort: () => abortController.abort(), patch(next) { @@ -92,6 +115,12 @@ export function createRequestStore(idle: S): RequestStore { notify(key); }, }); + // A runner must not reject; guard anyway so a restart never does. + return Promise.resolve(settled).catch(() => {}); + } + + function start(key: string): void { + void run(key); } function release(key: string): void { @@ -111,16 +140,17 @@ export function createRequestStore(idle: S): RequestStore { } return { - retain(key, run, autoStart = true) { + retain(key, runner, { autoStart = true, meta } = {}) { let entry = entries.get(key); if (!entry) { entry = { snapshot: idle, + meta, refCount: 0, abortController: null, teardownTimer: null, started: false, - run, + run: runner, }; entries.set(key, entry); } @@ -141,12 +171,19 @@ export function createRequestStore(idle: S): RequestStore { start, - restartStarted(match) { - // Collect first: a restarted run patches, and a patch notifies. + async restartStarted(match) { + // Collect first: a restarted run patches, and a patch notifies. An entry + // with no subscriber is waiting for teardown; restarting it would only + // send a request that teardown aborts a tick later. const keys = [...entries] - .filter(([key, entry]) => entry.started && (!match || match(key))) + .filter( + ([key, entry]) => + entry.started && + entry.refCount > 0 && + (!match || match(key, entry.meta)), + ) .map(([key]) => key); - for (const key of keys) start(key); + await Promise.all(keys.map(run)); }, subscribe(key, listener) { diff --git a/packages/appkit-ui/src/react/hooks/use-database-create.ts b/packages/appkit-ui/src/react/hooks/use-database-create.ts index d3ced53fb..b64b23029 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-create.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-create.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import type { ExactDatabaseParams } from "shared"; import { createDatabaseRow } from "@/js/database/client"; +import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseEntity, DatabaseInsert, @@ -9,15 +10,28 @@ import type { } from "@/js/database/types"; import { - type DatabaseWriteOptions, - type DatabaseWriteState, + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, useDatabaseWrite, } from "./use-database-write"; +/** Options for {@link useDatabaseCreate}. */ +export interface UseDatabaseCreateOptions< + K extends DatabaseEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful create, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: (row: DatabaseRow, values: DatabaseInsert) => void; + /** Called for every failed create, with the error `error` also reports. */ + onError?: (error: DatabaseApiError, values: DatabaseInsert) => void; +} + /** What {@link useDatabaseCreate} returns. */ -export interface DatabaseCreateResult< +export interface UseDatabaseCreateResult< K extends DatabaseEntity, -> extends DatabaseWriteState> { +> extends UseDatabaseWriteState> { /** * Send `POST /api/database/` and resolve with the created row, or * with `null` when the write failed; the reason is in `error`. It never @@ -37,32 +51,38 @@ export interface DatabaseCreateResult< * 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 + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call * @returns `create`, the latest call's row, loading and error state, and `reset` * * @example * ```tsx - * const notes = useDatabaseCreate("notes"); + * const notes = useDatabaseCreate("notes", { + * onError: (error) => toast(error.message), + * }); * * async function add(body: string) { * const note = await notes.create({ board_id: boardId, author: "ada", body }); * if (note) setDraft(""); * } - * notes.error?.details[0]?.message; * ``` */ export function useDatabaseCreate( entity: K, - options: DatabaseWriteOptions = {}, -): DatabaseCreateResult { + options: UseDatabaseCreateOptions = {}, +): UseDatabaseCreateResult { const send = useCallback( - (values: object) => createDatabaseRow(entity, values), + (values: DatabaseInsert) => createDatabaseRow(entity, values as object), [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; + const { onSuccess, onError } = options; + const { mutate, ...write } = useDatabaseWrite(send, { + invalidate: options.invalidate, + // The server projected the row it holds; the types describe that wire. + onSuccess: + onSuccess && + ((row, [values]) => onSuccess(row as DatabaseRow, values)), + onError: onError && ((error, [values]) => onError(error, values)), + }); + return { ...write, create: mutate } as UseDatabaseCreateResult; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-delete.ts b/packages/appkit-ui/src/react/hooks/use-database-delete.ts index b8dcd5484..93faf1353 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-delete.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-delete.ts @@ -1,26 +1,38 @@ import { useCallback } from "react"; -import { deleteDatabaseRow, type IdLike } from "@/js/database/client"; +import { deleteDatabaseRow } from "@/js/database/client"; import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseId, DatabaseKeyedEntity } from "@/js/database/types"; import { - type DatabaseWriteOptions, + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, useDatabaseWrite, } from "./use-database-write"; -/** What {@link useDatabaseDelete} returns. */ -export interface DatabaseDeleteResult { +/** Options for {@link useDatabaseDelete}. */ +export interface UseDatabaseDeleteOptions< + K extends DatabaseKeyedEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful delete, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: (id: DatabaseId) => void; + /** Called for every failed delete, with the error `error` also reports. */ + onError?: (error: DatabaseApiError, id: DatabaseId) => void; +} + +/** What {@link useDatabaseDelete} returns. A delete answers no row. */ +export interface UseDatabaseDeleteResult< + K extends DatabaseKeyedEntity, +> extends Omit, "data"> { /** * Send `DELETE /api/database//:id` and resolve `true` once the row * is deleted, or `false` when the write failed; the reason is in `error`, * and a missing row is `NOT_FOUND`. It never rejects. */ remove(id: DatabaseId): Promise; - /** 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; } @@ -28,10 +40,12 @@ export interface DatabaseDeleteResult { /** * 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. + * database reads restart, so lists that showed the row drop it and a record + * read of it reports `NOT_FOUND` with no `data`. * * @param entity - A table the generated registry exposes with a public key - * @param options - `invalidate` to narrow or turn off the read restart + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call * @returns `remove`, loading and error state, and `reset` * * @example @@ -45,20 +59,22 @@ export interface DatabaseDeleteResult { */ export function useDatabaseDelete( entity: K, - options: DatabaseWriteOptions = {}, -): DatabaseDeleteResult { + options: UseDatabaseDeleteOptions = {}, +): UseDatabaseDeleteResult { // A delete answers no row, so success is the only result worth reporting. const send = useCallback( - async (id: IdLike) => { + async (id: DatabaseId) => { await deleteDatabaseRow(entity, id); return true as const; }, [entity], ); - const { mutate, reset, loading, error } = useDatabaseWrite( - send, - options.invalidate ?? true, - ); + const { onSuccess, onError } = options; + const { mutate, reset, loading, error } = useDatabaseWrite(send, { + invalidate: options.invalidate, + onSuccess: onSuccess && ((_deleted, [id]) => onSuccess(id)), + onError: onError && ((failure, [id]) => onError(failure, id)), + }); const remove = useCallback( async (id: DatabaseId) => (await mutate(id)) === true, [mutate], diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts index 2ca895d17..ff167b0b1 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -6,10 +6,6 @@ import { } from "shared"; import { isDatabaseListPage } from "@/js/database/client"; -import { - type DatabaseApiError, - invalidDatabaseQuery, -} from "@/js/database/errors"; import type { DatabaseEntity, DatabaseKeyedEntity, @@ -18,11 +14,19 @@ import type { } from "@/js/database/types"; import { - type DatabaseReadOptions, - type DatabaseReadResult, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, useDatabaseRead, } from "./use-database-read"; +/** Options for {@link useDatabaseList}. */ +export type UseDatabaseListOptions = UseDatabaseReadOptions; + +/** What {@link useDatabaseList} returns: one page of rows and its state. */ +export type UseDatabaseListResult = UseDatabaseReadResult< + DatabaseListPage +>; + /** * Subscribe to one page of `GET /api/database/`. Entity, params, and * rows are typed from the generated `database.d.ts`; the route comes from the @@ -33,16 +37,17 @@ import { * 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 + * @param params - `where`, `order`, `select`, `include`, `limit`, `offset`; + * `null` holds the hook idle, for params that depend on another read + * @param options - `enabled`, `keepPreviousData`, and `shape` * @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 }, + * board ? { where: { board_id: board.id }, limit: 20, offset } : null, + * { keepPreviousData: true }, * ); * notes.data?.items.map((note) => note.body); * ``` @@ -53,38 +58,42 @@ export function useDatabaseList< >( entity: K, params?: undefined, - options?: DatabaseReadOptions, -): DatabaseReadResult>; + options?: UseDatabaseListOptions, +): UseDatabaseListResult; export function useDatabaseList< K extends DatabaseEntity, const P extends DatabaseListParams, Row = DatabaseListRow, >( entity: K, - params: P & ExactDatabaseParams>, - options?: DatabaseReadOptions, -): DatabaseReadResult>; + params: (P & ExactDatabaseParams>) | null, + options?: UseDatabaseListOptions, +): UseDatabaseListResult; export function useDatabaseList( entity: string, - params?: DatabaseListQuery, - options: DatabaseReadOptions = {}, -): DatabaseReadResult> { - const enabled = options.enabled ?? true; - let query: string | DatabaseApiError = ""; + params?: DatabaseListQuery | null, + options: UseDatabaseListOptions = {}, +): UseDatabaseListResult { + const enabled = (options.enabled ?? true) && params !== null; + // Encode only an enabled read: a held read's params may still be incomplete. + let query: string | null = ""; if (enabled) { try { query = encodeDatabaseListQuery(params ?? {}); } catch { - query = invalidDatabaseQuery(); + query = null; } } - const read = useDatabaseRead( + const read = useDatabaseRead({ entity, - "list", - undefined, + operation: "list", + id: undefined, query, + include: params?.include, enabled, - isDatabaseListPage, - ); - return read as DatabaseReadResult>; + accept: isDatabaseListPage, + keepPreviousData: options.keepPreviousData ?? false, + shape: options.shape, + }); + return read as UseDatabaseListResult; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts index d9291de71..2e3d13f03 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -1,14 +1,20 @@ -import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; import { type DatabaseOperation, type IdLike, resolveDatabaseUrl, } from "@/js/database/client"; -import { DatabaseApiError } from "@/js/database/errors"; +import { DatabaseApiError, invalidDatabaseQuery } from "@/js/database/errors"; import { - databaseReadKey, + databaseReadScope, getDatabaseReadSnapshot, IDLE_DATABASE_READ, retainDatabaseRead, @@ -29,68 +35,170 @@ export interface DatabaseShape { const SERIALIZED = Object.freeze({}); /** - * Declare the row a read serializer returns for a hook's result. The entity, + * Declare, without checking, the row a read serializer returns. 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. + * runtime check: to check each row, pass a parse function as `shape` instead. * * @example * ```typescript - * interface CaseListView { id: number; alert_count: number } + * interface NoteCard { id: number; excerpt: string } * - * const cases = useDatabaseList("cases", { limit: 20 }, { - * shape: serialized(), + * const notes = useDatabaseList("notes", { limit: 20 }, { + * shape: serialized(), * }); - * cases.data?.items[0]?.alert_count; + * notes.data?.items[0]?.excerpt; * ``` */ export function serialized(): DatabaseShape { return SERIALIZED as DatabaseShape; } -/** Options shared by the database read hooks. */ -export interface DatabaseReadOptions { +/** + * The row a read answers with: `serialized()` to declare it, or a function + * that checks one decoded row and returns it typed, such as a zod schema's + * `parse`. A function that throws fails the read with `INTERNAL`. + */ +export type DatabaseRowShape = + | DatabaseShape + | ((row: unknown) => Row); + +/** Options shared by `useDatabaseList` and `useDatabaseRecord`. */ +export interface UseDatabaseReadOptions { /** 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; + /** + * While new params load, keep showing the previous params' `data` instead + * of `null`, so a paginated list does not blank between pages. `loading` + * stays true until the new response arrives. Default false. + */ + keepPreviousData?: boolean; + /** + * The row a read serializer returns. `serialized()` only declares it; a + * function checks every row at runtime (each list item, or the record) and + * its return value becomes the row. It runs once per response. + */ + shape?: DatabaseRowShape; } /** Latest state of one database read. */ -export interface DatabaseReadResult { +export interface UseDatabaseReadResult { /** * The last successful response, or `null` before one arrives. A refetch - * keeps it visible while it loads and after it fails. + * keeps it visible while it loads and after it fails, except a `NOT_FOUND`, + * which clears it. */ data: T | null; /** Whether a request for the current params is in flight. */ loading: boolean; - /** Why the latest request failed; `NOT_EXPOSED` when no route exists. */ + /** + * Why the latest request failed: `NOT_EXPOSED` when no route exists, + * `INVALID_REQUEST` for params or an id that cannot be sent, `INTERNAL` + * when a `shape` function rejected a row. Stable across renders. + */ error: DatabaseApiError | null; /** Abort any in-flight request and send it again. No-op while disabled. */ refetch: () => void; } +/** What one read hook asks `useDatabaseRead` to subscribe to. */ +export interface DatabaseReadRequest { + entity: string; + operation: Extract; + id: IdLike | undefined; + /** The encoded query, or `null` when the params cannot be encoded. */ + query: string | null; + /** The params' include tree, to find the tables the read shows. */ + include: unknown; + enabled: boolean; + accept: (body: unknown) => body is object; + keepPreviousData: boolean; + shape: DatabaseRowShape | undefined; +} + type Route = { url: string } | { error: DatabaseApiError } | null; const noop = () => {}; +/** Apply a `shape` function to each row of one response. */ +function shapeResponse( + operation: DatabaseReadRequest["operation"], + data: unknown, + parse: (row: unknown) => unknown, +): unknown { + if (operation === "detail") return parse(data); + const page = data as { items: unknown[] }; + return { ...page, items: page.items.map((row) => parse(row)) }; +} + +/** The shaped response, or why a row failed its `shape`. */ +interface Shaped { + source: unknown; + data: unknown; + error: DatabaseApiError | null; +} + +/** + * Run a `shape` function once per response. The result is cached on the + * response's identity, not the function's, so an inline arrow does not + * reparse on every render or give `data` a new identity. + */ +function useShapedData( + operation: DatabaseReadRequest["operation"], + source: unknown, + shape: DatabaseRowShape | undefined, +): Shaped { + const cache = useRef(null); + if (typeof shape !== "function" || source === null) { + return { source, data: source, error: null }; + } + const cached = cache.current; + if (cached !== null && cached.source === source) return cached; + let shaped: Shaped; + try { + shaped = { + source, + data: shapeResponse(operation, source, shape), + error: null, + }; + } catch (cause) { + // A schema error may quote row values; keep it in `cause` only. + shaped = { + source, + data: null, + error: new DatabaseApiError( + "INTERNAL", + null, + "Database response does not match the read's shape", + [], + { cause }, + ), + }; + } + cache.current = shaped; + return shaped; +} + /** * 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. + * did not publish resolves to a stable `NOT_EXPOSED` error without a request, + * and params that cannot be encoded to a stable `INVALID_REQUEST`. */ -export function useDatabaseRead( - entity: string, - operation: Extract, - id: IdLike | undefined, - query: string | DatabaseApiError, - enabled: boolean, - accept: (body: unknown) => body is object, -): DatabaseReadResult { +export function useDatabaseRead({ + entity, + operation, + id, + query, + include, + enabled, + accept, + keepPreviousData, + shape, +}: DatabaseReadRequest): UseDatabaseReadResult { const route = useMemo((): Route => { if (!enabled) return null; - if (query instanceof DatabaseApiError) return { error: query }; + if (query === null) return { error: invalidDatabaseQuery() }; try { return { url: resolveDatabaseUrl(entity, operation, id, query) }; } catch (error) { @@ -100,35 +208,62 @@ export function useDatabaseRead( }, [enabled, entity, operation, id, query]); const url = route !== null && "url" in route ? route.url : null; - const key = url === null ? null : databaseReadKey(entity, url); + const routeError = route !== null && "error" in route ? route.error : null; + + // Every subscriber of one URL encodes the same include, so the URL is the + // include tree's identity; the object passed in may be a fresh literal. + const scope = useMemo( + () => (url === null ? null : databaseReadScope(entity, include)), + [url], + ); const subscribe = useCallback( (listener: () => void) => - key === null ? noop : subscribeDatabaseRead(key, listener), - [key], + url === null ? noop : subscribeDatabaseRead(url, listener), + [url], ); const getSnapshot = useCallback( - () => (key === null ? IDLE_DATABASE_READ : getDatabaseReadSnapshot(key)), - [key], + () => (url === null ? IDLE_DATABASE_READ : getDatabaseReadSnapshot(url)), + [url], ); const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - // The first subscriber of a key starts the request; later ones share it. + // The first subscriber of a URL starts the request; later ones share it. useEffect(() => { - if (key === null || url === null) return; - return retainDatabaseRead(key, url, accept); - }, [key, url, accept]); + if (url === null || scope === null) return; + return retainDatabaseRead(url, accept, scope); + }, [url, accept, scope]); 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, - }; + if (url !== null) startDatabaseRead(url); + }, [url]); + + const shaped = useShapedData(operation, snapshot.data, shape); + + // Until the effect retains a new URL, its request is about to start. + const loading = + snapshot.loading || (url !== null && snapshot === IDLE_DATABASE_READ); + const error = routeError ?? snapshot.error ?? shaped.error; + + // The last data this hook showed for a URL, to hold across a params change. + const previous = useRef<{ url: string; data: unknown } | null>(null); + useEffect(() => { + if (url !== null && shaped.data !== null) { + previous.current = { url, data: shaped.data }; + } + }, [url, shaped.data]); + + let data = shaped.data; + if ( + keepPreviousData && + data === null && + loading && + error === null && + previous.current !== null && + previous.current.url !== url + ) { + data = previous.current.data; + } + + return { data, loading, error, refetch }; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts index 98989fc49..53fc0143b 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -1,10 +1,6 @@ import { encodeDatabaseRecordQuery, type ExactDatabaseParams } from "shared"; import { isDatabaseRow } from "@/js/database/client"; -import { - type DatabaseApiError, - invalidDatabaseQuery, -} from "@/js/database/errors"; import type { DatabaseId, DatabaseKeyedEntity, @@ -13,23 +9,30 @@ import type { } from "@/js/database/types"; import { - type DatabaseReadOptions, - type DatabaseReadResult, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, useDatabaseRead, } from "./use-database-read"; +/** Options for {@link useDatabaseRecord}. */ +export type UseDatabaseRecordOptions = UseDatabaseReadOptions; + +/** What {@link useDatabaseRecord} returns: one row and its state. */ +export type UseDatabaseRecordResult = UseDatabaseReadResult; + /** * Subscribe to one row from `GET /api/database//:id`. Only entities * with a public primary key have this route; a missing row surfaces as a - * `NOT_FOUND` error. + * `NOT_FOUND` error, and a refetch that finds the row gone clears `data`. * * A `null` or `undefined` id holds the hook idle without a request, so a - * record that depends on another read needs no separate `enabled` flag. + * record that depends on another read needs no separate `enabled` flag. An + * empty, `"."`, or `".."` id reports `INVALID_REQUEST` without a request. * * @param entity - A table the generated registry exposes with a public key * @param id - The row's public key, or `null`/`undefined` to wait - * @param params - `select` and `include` - * @param options - `enabled` to hold the request; `shape` for a serializer's row + * @param params - `select` and `include`; pass `{}` to reach `options` alone + * @param options - `enabled`, `keepPreviousData`, and `shape` * @returns The row, loading and error state, and `refetch` * * @example @@ -48,25 +51,30 @@ export function useDatabaseRecord< entity: K, id: DatabaseId | null | undefined, params?: P & ExactDatabaseParams>, - options: DatabaseReadOptions = {}, -): DatabaseReadResult { + options: UseDatabaseRecordOptions = {}, +): UseDatabaseRecordResult { const enabled = (options.enabled ?? true) && id !== null && id !== undefined; - let query: string | DatabaseApiError = ""; + const recordParams = (params ?? {}) as { include?: unknown }; + // Encode only an enabled read: a held read's params may still be incomplete. + let query: string | null = ""; if (enabled) { try { - query = encodeDatabaseRecordQuery(params ?? {}); + query = encodeDatabaseRecordQuery(recordParams); } catch { - query = invalidDatabaseQuery(); + query = null; } } - const read = useDatabaseRead( + const read = useDatabaseRead({ entity, - "detail", - id ?? undefined, + operation: "detail", + id: id ?? undefined, query, + include: recordParams.include, enabled, - isDatabaseRow, - ); + accept: isDatabaseRow, + keepPreviousData: options.keepPreviousData ?? false, + shape: options.shape as UseDatabaseReadOptions["shape"], + }); // The server projected and encoded the row; the types describe that wire. - return read as DatabaseReadResult; + return read as UseDatabaseRecordResult; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-update.ts b/packages/appkit-ui/src/react/hooks/use-database-update.ts index 6118a5afd..4d347f11e 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-update.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-update.ts @@ -1,7 +1,8 @@ import { useCallback } from "react"; import type { ExactDatabaseParams } from "shared"; -import { type IdLike, updateDatabaseRow } from "@/js/database/client"; +import { updateDatabaseRow } from "@/js/database/client"; +import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseId, DatabaseKeyedEntity, @@ -10,15 +11,36 @@ import type { } from "@/js/database/types"; import { - type DatabaseWriteOptions, - type DatabaseWriteState, + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, useDatabaseWrite, } from "./use-database-write"; +/** Options for {@link useDatabaseUpdate}. */ +export interface UseDatabaseUpdateOptions< + K extends DatabaseKeyedEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful update, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: ( + row: DatabaseRow, + id: DatabaseId, + values: DatabaseUpdate, + ) => void; + /** Called for every failed update, with the error `error` also reports. */ + onError?: ( + error: DatabaseApiError, + id: DatabaseId, + values: DatabaseUpdate, + ) => void; +} + /** What {@link useDatabaseUpdate} returns. */ -export interface DatabaseUpdateResult< +export interface UseDatabaseUpdateResult< K extends DatabaseKeyedEntity, -> extends DatabaseWriteState> { +> extends UseDatabaseWriteState> { /** * Send `PATCH /api/database//:id` and resolve with the updated row, * or with `null` when the write failed; the reason is in `error`, and a @@ -39,7 +61,8 @@ export interface DatabaseUpdateResult< * 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 + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call * @returns `update`, the latest call's row, loading and error state, and `reset` * * @example @@ -53,16 +76,21 @@ export interface DatabaseUpdateResult< */ export function useDatabaseUpdate( entity: K, - options: DatabaseWriteOptions = {}, -): DatabaseUpdateResult { + options: UseDatabaseUpdateOptions = {}, +): UseDatabaseUpdateResult { const send = useCallback( - (id: IdLike, values: object) => updateDatabaseRow(entity, id, values), + (id: DatabaseId, values: DatabaseUpdate) => + updateDatabaseRow(entity, id, values as object), [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; + const { onSuccess, onError } = options; + const { mutate, ...write } = useDatabaseWrite(send, { + invalidate: options.invalidate, + // The server projected the row it holds; the types describe that wire. + onSuccess: + onSuccess && + ((row, [id, values]) => onSuccess(row as DatabaseRow, id, values)), + onError: onError && ((error, [id, values]) => onError(error, id, values)), + }); + return { ...write, update: mutate } as UseDatabaseUpdateResult; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-write.ts b/packages/appkit-ui/src/react/hooks/use-database-write.ts index 424ae7a90..d4552865d 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-write.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-write.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { DatabaseApiError } from "@/js/database/errors"; @@ -10,52 +10,71 @@ import { 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 { +export interface UseDatabaseWriteState { /** The latest call's result, or `null` before it answers. */ data: T | null; - /** Whether the latest call is in flight. */ + /** + * Whether the latest call is in flight, including the reload of the reads + * it restarts. + */ loading: boolean; - /** Why the latest call failed; `NOT_EXPOSED` when no route exists. */ + /** + * Why the latest call failed: `NOT_EXPOSED` when no route exists, + * `OUTCOME_UNKNOWN` when it may have committed without an answer. + */ error: DatabaseApiError | null; } +/** Options every database write hook takes. */ +export interface UseDatabaseWriteOptions { + /** + * Reads to restart once a write succeeds. Default `true`: every mounted + * database read, since a server hook may write other tables in the same + * transaction. A table list restarts only the reads that show those tables, + * directly or through an include; `false` restarts none. + */ + invalidate?: DatabaseInvalidation; +} + +/** The generic write lifecycle's callbacks, before each hook names its args. */ +interface WriteCallbacks { + invalidate?: DatabaseInvalidation; + onSuccess?: (data: T, args: Args) => void; + onError?: (error: DatabaseApiError, args: Args) => void; +} + interface DatabaseWrite< Args extends unknown[], T, -> extends DatabaseWriteState { +> extends UseDatabaseWriteState { mutate(...args: Args): Promise; reset(): void; } -const IDLE: DatabaseWriteState = { +const IDLE: UseDatabaseWriteState = Object.freeze({ 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], - ); +/** + * Run a caller's callback without letting it break the write's promise: the + * call still resolves, and the exception still reaches the page's error + * handling (and any error tracker) as an uncaught error. + */ +function runCallback(callback: () => void): void { + try { + callback(); + } catch (error) { + if (typeof globalThis.reportError === "function") { + globalThis.reportError(error); + } else { + setTimeout(() => { + throw error; + }); + } + } } /** @@ -66,33 +85,33 @@ function useInvalidationScope( * `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. + * transaction. A successful call restarts the reads `invalidate` names and + * resolves once they have reloaded, so a handler that clears its form sees the + * new rows already on screen. Only the latest call of a hook updates its + * state; callbacks run for every call, even after the hook unmounts, since the + * rows did change. */ export function useDatabaseWrite( send: (...args: Args) => Promise, - invalidate: DatabaseInvalidation, + options: WriteCallbacks, ): DatabaseWrite { - const [state, setState] = useState>(IDLE); - const mounted = useRef(true); + const [state, setState] = useState>(IDLE); const latest = useRef(null); - const scope = useInvalidationScope(invalidate); - // Set on every setup as well: a StrictMode remount runs cleanup first. + // Read at call time, so inline callbacks and an inline `invalidate` list do + // not change `mutate`'s identity on every render. + const optionsRef = useRef(options); useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); + optionsRef.current = options; + }); 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); + // React ignores a state update after unmount, so only staleness matters. + const settle = (next: UseDatabaseWriteState) => { + if (latest.current === call) setState(next); }; settle({ data: null, loading: true, error: null }); @@ -100,24 +119,27 @@ export function useDatabaseWrite( try { data = await send(...args); } catch (cause) { - settle({ - data: null, - loading: false, - error: asDatabaseApiError(cause), - }); + const error = asDatabaseApiError(cause); + settle({ data: null, loading: false, error }); + const { onError } = optionsRef.current; + if (onError) runCallback(() => onError(error, args)); return null; } + // Hold `loading` until the restarted reads answer, so the hook does not + // report success beside rows that still show the old state. + await invalidateDatabaseReads(optionsRef.current.invalidate ?? true); settle({ data, loading: false, error: null }); - invalidateDatabaseReads(scope); + const { onSuccess } = optionsRef.current; + if (onSuccess) runCallback(() => onSuccess(data, args)); return data; }, - [send, scope], + [send], ); // A call still in flight keeps running, but no longer reports here. const reset = useCallback(() => { latest.current = null; - if (mounted.current) setState(IDLE); + setState(IDLE); }, []); return { ...state, mutate, reset }; diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index 907b226f5..ff4a49ac4 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -11,7 +11,7 @@ import { assertFinalizedSchema } from "../../database/schema-builder/define-sche import { Plugin } from "../../plugin"; import type { PluginManifest } from "../../registry"; import { assertDatabaseConfig } from "./config"; -import { compileCrudTables } from "./crud/contract"; +import { compileCrudTables, type CrudTable } from "./crud/contract"; import { type CrudExposure, resolveCrudExposure } from "./crud/exposure"; import { routeOutcome } from "./crud/response"; import { @@ -47,6 +47,7 @@ export class DatabasePlugin< private shutdownPromise: Promise | null = null; private exposure: CrudExposure = { tables: [], writes: new Map() }; private resolvedSchema: Schema | null = null; + private crudTables: Map | null = null; constructor(config: IDatabaseConfig = {}) { assertDatabaseConfig(config); @@ -108,16 +109,23 @@ export class DatabasePlugin< return this.setupPromise; } - /** Register generated CRUD, subject to the configured table and write restrictions. */ - injectRoutes(router: express.Router): void { - if (this.exposure.tables.length === 0) return; + /** The HTTP contract of every exposed table, compiled once after setup. */ + private exposedTables(): Map { + if (this.crudTables) return this.crudTables; const schema = this.resolvedSchema; if (!schema) throw databaseSetupFailed(); - const tables = compileCrudTables( + this.crudTables = compileCrudTables( Object.fromEntries( this.exposure.tables.map((name) => [name, schema.$tables[name]]), ), ); + return this.crudTables; + } + + /** Register generated CRUD, subject to the configured table and write restrictions. */ + injectRoutes(router: express.Router): void { + if (this.exposure.tables.length === 0) return; + const tables = this.exposedTables(); const hooks = this.hooks(); // Every exposed name is a declared table, so its export is an entity client. const entities = () => @@ -211,6 +219,32 @@ export class DatabasePlugin< console.log(""); } + /** + * Publish `{ relations: { table: { relation: targetTable } } }` so the React + * hooks can tell which reads a write affects: a `boards` read that includes + * `notes` shows notes rows. Only edges between exposed tables exist, and + * they are the names `include` already accepts, so this reveals nothing the + * routes and the generated types do not. + */ + clientConfig(): Record { + if (!this.resolvedSchema || this.exposure.tables.length === 0) return {}; + // Built as own entries, so no table or relation name reaches a prototype. + const relations = Object.fromEntries( + [...this.exposedTables().values()] + .filter((table) => table.relations.size > 0) + .map((table) => [ + table.name, + Object.fromEntries( + [...table.relations].map(([name, edge]) => [ + name, + edge.target.name, + ]), + ), + ]), + ); + return Object.keys(relations).length > 0 ? { relations } : {}; + } + /** Typed hook keys are schema table names, which routing addresses at runtime. */ private hooks(): DatabaseHooks | undefined { return this.config.hooks as DatabaseHooks | undefined; diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index fe2ecced6..305412642 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -686,6 +686,43 @@ describe("DatabasePlugin", () => { ); }); + test("publishes the relations between exposed tables for the client", async () => { + const { plugin } = await registerRoutes({ schema: routedSchema }); + + expect(plugin.clientConfig()).toEqual({ + relations: { + users: { notes: "notes" }, + notes: { users: "users" }, + }, + }); + }); + + test("publishes no edge to a table the API does not expose", async () => { + const { plugin } = await registerRoutes({ + schema: routedSchema, + api: { tables: ["notes", "events"] }, + }); + + // notes → users exists in the schema, but users has no routes. + expect(plugin.clientConfig()).toEqual({}); + }); + + test.each([false, { tables: [] }] as const)( + "publishes no client config with api=%j", + async (api) => { + const { plugin } = await registerRoutes({ schema: routedSchema, api }); + expect(plugin.clientConfig()).toEqual({}); + }, + ); + + test("publishes no client config when setup failed", async () => { + mocks.createDatabaseState.mockRejectedValue(new Error("boom")); + const plugin = new DatabasePlugin({ schema: routedSchema }); + await expect(plugin.setup()).rejects.toThrow(); + + expect(plugin.clientConfig()).toEqual({}); + }); + test("isolates plugin instances and drains their exports independently", async () => { const one = candidate("one"); const two = candidate("two"); From 74e215a9dc1ea5b79c2225f68adbfe61a5bb4909 Mon Sep 17 00:00:00 2001 From: ditadi Date: Wed, 7 Oct 2026 17:24:41 +0200 Subject: [PATCH 5/8] fix(appkit-ui): preserve database validation guidance in hooks Signed-off-by: ditadi --- .../__tests__/use-database-list.test.tsx | 39 +++++++++++++++++++ .../__tests__/use-database-mutations.test.tsx | 17 ++++++++ .../__tests__/use-database-record.test.tsx | 20 ++++++++++ .../__tests__/use-database.types.test.ts | 14 +++++++ .../src/react/hooks/use-database-list.ts | 8 ++-- .../src/react/hooks/use-database-read.ts | 23 ++++++++--- .../src/react/hooks/use-database-record.ts | 13 +++++-- 7 files changed, 122 insertions(+), 12 deletions(-) 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 index 4f3308f8a..5cb7ee1d4 100644 --- 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 @@ -165,6 +165,45 @@ describe("useDatabaseList", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + test("preserves local validation guidance and changes it only when the failure changes", () => { + const { result, rerender } = renderHook( + ({ where }: { where: object | undefined }) => + useDatabaseList("notes", { where }), + { initialProps: { where: {} as object | undefined } }, + ); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Filter cannot be empty; omit where to list all rows", + details: [ + { + path: ["where"], + message: "Filter cannot be empty; omit where to list all rows", + }, + ], + }); + const empty = result.current.error; + rerender({ where: {} }); + expect(result.current.error).toBe(empty); + + rerender({ where: { rank: NaN } }); + expect(result.current.error).toMatchObject({ + message: "Database query numbers must be finite", + details: [ + { path: ["where"], message: "Database query numbers must be finite" }, + ], + }); + const nonFinite = result.current.error; + expect(nonFinite).not.toBe(empty); + rerender({ where: { rank: NaN } }); + expect(result.current.error).toBe(nonFinite); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ where: undefined }); + expect(result.current.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + test("keeps an INVALID_REQUEST error stable, so an effect keyed on it runs once", () => { const seen: unknown[] = []; const { result, rerender } = renderHook(() => { 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 index e30f77e26..e6ce952be 100644 --- 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 @@ -131,6 +131,23 @@ describe("database write hooks", () => { expect(result.current).toMatchObject({ loading: true, error: null }); }); + test("rejects a non-finite update before sending it or invalidating reads", async () => { + const { result } = renderHook(() => useDatabaseUpdate("notes")); + let updated!: Row | null; + await act(async () => { + updated = await result.current.update(7, { rank: NaN }); + }); + expect(updated).toBeNull(); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: + "Database write numbers must be finite; use null explicitly to clear a value", + }); + expect(result.current.loading).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("update patches one id and delete removes one without a response body", async () => { const { result } = renderHook(() => ({ update: useDatabaseUpdate("notes"), 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 index 8860246cf..975bd1f08 100644 --- 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 @@ -105,6 +105,26 @@ describe("useDatabaseRecord", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + test("reports a non-finite include operand locally with stable field details", () => { + const { result, rerender } = renderHook(() => + useDatabaseRecord("notes", 7, { + include: { note_events: { where: { rank: NaN } } }, + }), + ); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database query numbers must be finite", + details: [ + { path: ["include"], message: "Database query numbers must be finite" }, + ], + }); + const first = result.current.error; + rerender(); + expect(result.current.error).toBe(first); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("reads the new record when the id changes", async () => { const { result, rerender } = renderHook( ({ id }: { id: number }) => useDatabaseRecord("notes", id), 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 ee6c701cc..89fc0cf51 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 @@ -176,6 +176,20 @@ test("read hooks type entities, params, and rows from the generated registry", ( void [title, total, failed, owner, bodies, deep, gatedTitle]; } + export function DynamicSelections() { + const columns: ("id" | "title")[] = ["id"]; + const list = useDatabaseList("posts", { select: columns }); + const record = useDatabaseRecord("posts", 7, { select: columns }); + if (list.data && record.data) { + const maybeTitle: string | undefined = list.data.items[0].title; + // @ts-expect-error a dynamic array does not guarantee every possible column + const listTitle: string = list.data.items[0].title; + // @ts-expect-error the record follows the same dynamic projection rule + const recordId: number = record.data.id; + void [maybeTitle, listTitle, recordId]; + } + } + export function RejectedLists() { // @ts-expect-error entities are generated table names useDatabaseList("missing"); diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts index ff167b0b1..3831cbf5b 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -1,6 +1,7 @@ import { type DatabaseListPage, type DatabaseListQuery, + DatabaseQueryEncodingError, encodeDatabaseListQuery, type ExactDatabaseParams, } from "shared"; @@ -14,6 +15,7 @@ import type { } from "@/js/database/types"; import { + type DatabaseReadRequest, type UseDatabaseReadOptions, type UseDatabaseReadResult, useDatabaseRead, @@ -76,12 +78,12 @@ export function useDatabaseList( ): UseDatabaseListResult { const enabled = (options.enabled ?? true) && params !== null; // Encode only an enabled read: a held read's params may still be incomplete. - let query: string | null = ""; + let query: DatabaseReadRequest["query"] = ""; if (enabled) { try { query = encodeDatabaseListQuery(params ?? {}); - } catch { - query = null; + } catch (error) { + query = error instanceof DatabaseQueryEncodingError ? error : null; } } const read = useDatabaseRead({ diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts index 2e3d13f03..1f69e7abe 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -5,6 +5,7 @@ import { useRef, useSyncExternalStore, } from "react"; +import { DatabaseQueryEncodingError } from "shared"; import { type DatabaseOperation, @@ -106,8 +107,8 @@ export interface DatabaseReadRequest { entity: string; operation: Extract; id: IdLike | undefined; - /** The encoded query, or `null` when the params cannot be encoded. */ - query: string | null; + /** Encoded params, a known validation failure, or `null` for an unknown failure. */ + query: string | DatabaseQueryEncodingError | null; /** The params' include tree, to find the tables the read shows. */ include: unknown; enabled: boolean; @@ -196,16 +197,28 @@ export function useDatabaseRead({ keepPreviousData, shape, }: DatabaseReadRequest): UseDatabaseReadResult { + const encodedQuery = typeof query === "string" ? query : null; + // Encoding runs every render; only the safe failure fields define its identity. + const parameter = + query instanceof DatabaseQueryEncodingError ? query.parameter : undefined; + const message = + query instanceof DatabaseQueryEncodingError ? query.message : undefined; const route = useMemo((): Route => { if (!enabled) return null; - if (query === null) return { error: invalidDatabaseQuery() }; + if (encodedQuery === null) { + const cause = + parameter !== undefined && message !== undefined + ? new DatabaseQueryEncodingError(parameter, message) + : undefined; + return { error: invalidDatabaseQuery(cause) }; + } try { - return { url: resolveDatabaseUrl(entity, operation, id, query) }; + return { url: resolveDatabaseUrl(entity, operation, id, encodedQuery) }; } catch (error) { if (error instanceof DatabaseApiError) return { error }; throw error; } - }, [enabled, entity, operation, id, query]); + }, [enabled, entity, operation, id, encodedQuery, parameter, message]); const url = route !== null && "url" in route ? route.url : null; const routeError = route !== null && "error" in route ? route.error : null; diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts index 53fc0143b..e8b280aa8 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -1,4 +1,8 @@ -import { encodeDatabaseRecordQuery, type ExactDatabaseParams } from "shared"; +import { + DatabaseQueryEncodingError, + encodeDatabaseRecordQuery, + type ExactDatabaseParams, +} from "shared"; import { isDatabaseRow } from "@/js/database/client"; import type { @@ -9,6 +13,7 @@ import type { } from "@/js/database/types"; import { + type DatabaseReadRequest, type UseDatabaseReadOptions, type UseDatabaseReadResult, useDatabaseRead, @@ -56,12 +61,12 @@ export function useDatabaseRecord< const enabled = (options.enabled ?? true) && id !== null && id !== undefined; const recordParams = (params ?? {}) as { include?: unknown }; // Encode only an enabled read: a held read's params may still be incomplete. - let query: string | null = ""; + let query: DatabaseReadRequest["query"] = ""; if (enabled) { try { query = encodeDatabaseRecordQuery(recordParams); - } catch { - query = null; + } catch (error) { + query = error instanceof DatabaseQueryEncodingError ? error : null; } } const read = useDatabaseRead({ From b78e1abf1825b0a1cd27f521a97274ac4194ab95 Mon Sep 17 00:00:00 2001 From: ditadi Date: Wed, 7 Oct 2026 17:53:11 +0200 Subject: [PATCH 6/8] chore(appkit-ui): refresh bundle baseline for database hooks Record the measured growth of the React database hooks over the client baseline. The react/beta consumer entry grows by about 3.8 KiB gzip with no bundled dependencies. Generated from a clean build. The 5% and 10 KiB budget gates are unchanged. Signed-off-by: ditadi --- bundle-size-baseline.json | 114 +++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 6e50f55c8..fb8b23a09 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 1265942, - "unpacked": 4349858 + "packed": 1267082, + "unpacked": 4353228 }, "dist": { "total": { - "raw": 4335229, - "gzip": 1486991 + "raw": 4338599, + "gzip": 1488137 }, "js": { - "raw": 1288798, - "gzip": 457434 + "raw": 1289704, + "gzip": 457768 }, "types": { - "raw": 466001, - "gzip": 169053 + "raw": 466539, + "gzip": 169289 }, "maps": { - "raw": 2569635, - "gzip": 856686 + "raw": 2571561, + "gzip": 857262 }, "css": { "raw": 0, @@ -36,17 +36,17 @@ "entries": [ { "id": ".", - "gzip": 107009, + "gzip": 107011, "composition": { - "initialGzip": 104436, - "lazyGzip": 2573, - "totalGzip": 107009, + "initialGzip": 104437, + "lazyGzip": 2574, + "totalGzip": 107011, "own": 339413, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 99766, + "gzip": 99767, "kind": "initial" }, { @@ -56,7 +56,7 @@ }, { "label": "remote-tunnel-manager.js", - "gzip": 2573, + "gzip": 2574, "kind": "lazy" } ] @@ -64,17 +64,17 @@ }, { "id": "./beta", - "gzip": 101222, + "gzip": 101326, "composition": { - "initialGzip": 100738, + "initialGzip": 100842, "lazyGzip": 484, - "totalGzip": 101222, - "own": 304130, + "totalGzip": 101326, + "own": 304570, "nodeModules": null, "chunks": [ { "label": "beta.js", - "gzip": 83152, + "gzip": 83258, "kind": "initial" }, { @@ -89,7 +89,7 @@ }, { "label": "databricks.js", - "gzip": 3399, + "gzip": 3397, "kind": "initial" }, { @@ -109,17 +109,17 @@ }, { "label": "supervisor-api.js", - "gzip": 193, + "gzip": 192, "kind": "lazy" }, { "label": "databricks.js", - "gzip": 176, + "gzip": 178, "kind": "lazy" }, { "label": "index.js", - "gzip": 115, + "gzip": 114, "kind": "lazy" } ] @@ -127,22 +127,22 @@ }, { "id": "./testing", - "gzip": 75875, + "gzip": 75873, "composition": { "initialGzip": 42855, - "lazyGzip": 33020, - "totalGzip": 75875, + "lazyGzip": 33018, + "totalGzip": 75873, "own": 219731, "nodeModules": null, "chunks": [ { "label": "manifest.js", - "gzip": 29312, + "gzip": 29311, "kind": "initial" }, { "label": "index.js", - "gzip": 10543, + "gzip": 10544, "kind": "initial" }, { @@ -157,7 +157,7 @@ }, { "label": "remote-tunnel-manager.js", - "gzip": 2587, + "gzip": 2585, "kind": "lazy" }, { @@ -209,25 +209,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 377349, - "unpacked": 1505945 + "packed": 405255, + "unpacked": 1602252 }, "dist": { "total": { - "raw": 1502000, - "gzip": 505901 + "raw": 1598307, + "gzip": 545126 }, "js": { - "raw": 415497, - "gzip": 140191 + "raw": 438061, + "gzip": 149769 }, "types": { - "raw": 252687, - "gzip": 92239 + "raw": 272380, + "gzip": 100371 }, "maps": { - "raw": 817418, - "gzip": 270215 + "raw": 871468, + "gzip": 291730 }, "css": { "raw": 16398, @@ -237,7 +237,7 @@ "raw": 0, "gzip": 0 }, - "fileCount": 523 + "fileCount": 555 }, "entries": [ { @@ -270,17 +270,17 @@ }, { "id": "./js/beta", - "gzip": 2113, + "gzip": 2166, "composition": { - "initialGzip": 2113, + "initialGzip": 2166, "lazyGzip": 0, - "totalGzip": 2113, - "own": 4968, + "totalGzip": 2166, + "own": 5121, "nodeModules": 0, "chunks": [ { "label": "beta.js", - "gzip": 2113, + "gzip": 2166, "kind": "initial" } ] @@ -288,17 +288,17 @@ }, { "id": "./react", - "gzip": 50664, + "gzip": 50801, "composition": { - "initialGzip": 442867, + "initialGzip": 442965, "lazyGzip": 49772, - "totalGzip": 492639, - "own": 181095, + "totalGzip": 492737, + "own": 181359, "nodeModules": 1403038, "chunks": [ { "label": "index.js", - "gzip": 440717, + "gzip": 440815, "kind": "initial" }, { @@ -316,17 +316,17 @@ }, { "id": "./react/beta", - "gzip": 1035, + "gzip": 4886, "composition": { - "initialGzip": 1035, + "initialGzip": 4886, "lazyGzip": 0, - "totalGzip": 1035, - "own": 1906, + "totalGzip": 4886, + "own": 12368, "nodeModules": 0, "chunks": [ { "label": "beta.js", - "gzip": 1035, + "gzip": 4886, "kind": "initial" } ] From d9f5de09096fb4364164e6d338781550e8b281fe Mon Sep 17 00:00:00 2001 From: ditadi Date: Wed, 7 Oct 2026 19:13:17 +0200 Subject: [PATCH 7/8] fix(appkit-ui): await current reads after overlapping database writes A write must not resolve when another refresh aborts its original reads. Track the latest run per retained entry and recheck the entire target set after settlement, including keys that completed before a later restart. Cover overlapping restartStarted calls and two concurrent write hooks, plus manual refetch, reentrant starts, re-retain, and entry replacement. Regenerate the measured bundle baseline from a clean build. Signed-off-by: ditadi --- bundle-size-baseline.json | 46 ++--- docs/docs/plugins/database.md | 6 +- .../hooks/__tests__/request-store.test.ts | 174 ++++++++++++++++++ .../__tests__/use-database-mutations.test.tsx | 68 +++++++ .../src/react/hooks/database-request-store.ts | 5 +- .../src/react/hooks/request-store.ts | 54 ++++-- 6 files changed, 314 insertions(+), 39 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index fb8b23a09..cc8258eb3 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,13 +3,13 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 1267082, + "packed": 1267078, "unpacked": 4353228 }, "dist": { "total": { "raw": 4338599, - "gzip": 1488137 + "gzip": 1488135 }, "js": { "raw": 1289704, @@ -17,7 +17,7 @@ }, "types": { "raw": 466539, - "gzip": 169289 + "gzip": 169287 }, "maps": { "raw": 2571561, @@ -209,25 +209,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 405255, - "unpacked": 1602252 + "packed": 406035, + "unpacked": 1604441 }, "dist": { "total": { - "raw": 1598307, - "gzip": 545126 + "raw": 1600496, + "gzip": 545804 }, "js": { - "raw": 438061, - "gzip": 149769 + "raw": 438717, + "gzip": 149961 }, "types": { - "raw": 272380, - "gzip": 100371 + "raw": 272461, + "gzip": 100413 }, "maps": { - "raw": 871468, - "gzip": 291730 + "raw": 872920, + "gzip": 292174 }, "css": { "raw": 16398, @@ -288,17 +288,17 @@ }, { "id": "./react", - "gzip": 50801, + "gzip": 50889, "composition": { - "initialGzip": 442965, + "initialGzip": 443050, "lazyGzip": 49772, - "totalGzip": 492737, - "own": 181359, + "totalGzip": 492822, + "own": 181622, "nodeModules": 1403038, "chunks": [ { "label": "index.js", - "gzip": 440815, + "gzip": 440900, "kind": "initial" }, { @@ -316,17 +316,17 @@ }, { "id": "./react/beta", - "gzip": 4886, + "gzip": 4994, "composition": { - "initialGzip": 4886, + "initialGzip": 4994, "lazyGzip": 0, - "totalGzip": 4886, - "own": 12368, + "totalGzip": 4994, + "own": 12631, "nodeModules": 0, "chunks": [ { "label": "beta.js", - "gzip": 4886, + "gzip": 4994, "kind": "initial" } ] diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 5abc8b7e6..7f8aa77e1 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -555,7 +555,11 @@ for `remove`) and the `DatabaseApiError` is in `error`, so a handler needs no A successful call resolves once the reads it restarts have reloaded, and `loading` stays `true` until then. A handler that clears its form after `await create(...)` therefore sees the new row already in the lists. A read -that fails to reload does not fail the write. +that fails to reload does not fail the write. If another write or a manual +refetch supersedes a reload, the waiting call follows the current run of each +read rather than resolving on the cancelled run. Reads with no subscribers +stop holding the write open. After teardown, remounting the same URL creates +a new entry, not part of that write's refresh. Values follow the same [rules](#values) as `databaseApi.create` and `update`. Update and delete need a public primary key, like record reads. 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 66e52ed9d..389d3acd0 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 @@ -15,6 +15,32 @@ function makeStore() { return { store, run }; } +function deferredRunner(onStart?: (controls: RequestControls) => void) { + const runs: { + controls: RequestControls; + complete(value: number): void; + }[] = []; + const run = (controls: RequestControls) => + new Promise((resolve) => { + runs.push({ + controls, + complete(value) { + if (!controls.signal.aborted) controls.patch({ value }); + resolve(); + }, + }); + if (controls.signal.aborted) resolve(); + else + controls.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + onStart?.(controls); + }); + return { run, runs }; +} + +const flushRuns = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe("createRequestStore", () => { let store: ReturnType["store"]; let run: ReturnType["run"]; @@ -152,6 +178,154 @@ describe("createRequestStore", () => { expect(settled).toBe(true); }); + test("overlapping restartStarted calls both wait for the current run", async () => { + const pending = deferredRunner(); + store.retain("k", pending.run); + pending.runs[0]?.complete(1); + + const settled: string[] = []; + const first = store.restartStarted().then(() => { + settled.push("first"); + }); + const second = store.restartStarted().then(() => { + settled.push("second"); + }); + expect(pending.runs[1]?.controls.signal.aborted).toBe(true); + await flushRuns(); + expect(settled).toEqual([]); + expect(store.getSnapshot("k").value).toBe(1); + + pending.runs[2]?.complete(3); + await Promise.all([first, second]); + expect(settled.sort()).toEqual(["first", "second"]); + expect(store.getSnapshot("k").value).toBe(3); + }); + + test("rechecks a key that settled before another restarted key was superseded", async () => { + const a = deferredRunner(); + const b = deferredRunner(); + store.retain("a", a.run); + store.retain("b", b.run); + a.runs[0]?.complete(1); + b.runs[0]?.complete(1); + + const settled: string[] = []; + const first = store.restartStarted().then(() => { + settled.push("first"); + }); + a.runs[1]?.complete(2); + await flushRuns(); + expect(settled).toEqual([]); + + const second = store.restartStarted().then(() => { + settled.push("second"); + }); + b.runs[2]?.complete(3); + await flushRuns(); + expect(settled).toEqual([]); + expect(store.getSnapshot("a").value).toBe(2); + + a.runs[2]?.complete(3); + await Promise.all([first, second]); + expect(settled.sort()).toEqual(["first", "second"]); + expect(store.getSnapshot("a").value).toBe(3); + }); + + test("follows a manual start that supersedes a pending restart", async () => { + const pending = deferredRunner(); + store.retain("k", pending.run); + pending.runs[0]?.complete(1); + let settled = false; + const restarted = store.restartStarted().then(() => { + settled = true; + }); + store.start("k"); + await flushRuns(); + expect(settled).toBe(false); + + pending.runs[2]?.complete(3); + await restarted; + expect(store.getSnapshot("k").value).toBe(3); + }); + + test("waits for an original entry re-retained before the refresh barrier settles", async () => { + const b = deferredRunner(); + let releaseB!: () => void; + let replaceB = false; + const a = deferredRunner(() => { + if (!replaceB) return; + replaceB = false; + releaseB(); + queueMicrotask(() => { + store.retain("b", b.run); + store.start("b"); + }); + }); + store.retain("a", a.run); + releaseB = store.retain("b", b.run); + a.runs[0]?.complete(1); + b.runs[0]?.complete(1); + replaceB = true; + + let settled = false; + const restarted = store.restartStarted().then(() => { + settled = true; + }); + a.runs[1]?.complete(2); + await flushRuns(); + expect(b.runs).toHaveLength(2); + expect(settled).toBe(false); + + b.runs[1]?.complete(2); + await restarted; + expect(store.getSnapshot("b").value).toBe(2); + }); + + test("does not follow a new entry that reuses a reset key", async () => { + const pending = deferredRunner(); + store.retain("k", pending.run); + pending.runs[0]?.complete(1); + let settled = false; + const restarted = store.restartStarted().then(() => { + settled = true; + }); + store.reset(); + + const replacement = deferredRunner(); + store.retain("k", replacement.run); + await flushRuns(); + expect(settled).toBe(true); + await restarted; + expect(pending.runs[1]?.controls.signal.aborted).toBe(true); + expect(replacement.runs[0]?.controls.signal.aborted).toBe(false); + expect(store.getSnapshot("k")).toBe(IDLE); + store.reset(); + }); + + test("waits for a run started synchronously by a snapshot subscriber", async () => { + const pending = deferredRunner((controls) => controls.patch({ value: 1 })); + store.retain("k", pending.run); + pending.runs[0]?.complete(1); + let replaced = false; + store.subscribe("k", () => { + if (replaced) return; + replaced = true; + store.start("k"); + }); + + let settled = false; + const restarted = store.restartStarted().then(() => { + settled = true; + }); + await flushRuns(); + expect(pending.runs[1]?.controls.signal.aborted).toBe(true); + expect(settled).toBe(false); + + pending.runs[2]?.complete(3); + await restarted; + expect(store.getSnapshot("k").value).toBe(3); + }); + test("restartStarted resolves even when a runner rejects", async () => { let runs = 0; store.retain("k", () => { 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 index e6ce952be..3e9a4ea5b 100644 --- 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 @@ -7,6 +7,7 @@ import { DatabaseApiError } from "@/js/database/errors"; import { invalidateDatabaseReads, mockDatabaseFetch, + nextTick, page, publishDatabase, resetDatabaseTestEnvironment, @@ -267,6 +268,73 @@ describe("database write hooks", () => { }); }); + test("overlapping writes wait for the latest reload of every read before resolving or onSuccess", async () => { + const reads = await mountReads(); + const createdSuccess = vi.fn(); + const updatedSuccess = vi.fn(); + const creator = renderHook(() => + useDatabaseCreate("notes", { onSuccess: createdSuccess }), + ); + const updater = renderHook(() => + useDatabaseUpdate("notes", { onSuccess: updatedSuccess }), + ); + const settled: string[] = []; + let created!: Promise; + let updated!: Promise; + act(() => { + created = creator.result.current.create({ body: "new" }); + void created.then(() => { + settled.push("create"); + }); + }); + await act(async () => + sent("POST")[0]?.respond({ id: 2, body: "new" }, 201), + ); + await act(async () => sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 }))); + expect(settled).toEqual([]); + + act(() => { + updated = updater.result.current.update(1, { body: "edited" }); + void updated.then(() => { + settled.push("update"); + }); + }); + await act(async () => { + sent("PATCH")[0]?.respond({ id: 1, body: "edited" }); + await nextTick(); + }); + expect(sent("GET")[3]?.signal?.aborted).toBe(true); + expect(sent("GET")).toHaveLength(6); + expect(settled).toEqual([]); + expect(createdSuccess).not.toHaveBeenCalled(); + expect(creator.result.current.loading).toBe(true); + expect(updater.result.current.loading).toBe(true); + + await act(async () => { + sent("GET")[5]?.respond(page({ id: "current board" })); + sent("GET")[3]?.respond(page({ id: "stale board" })); + await nextTick(); + }); + expect(settled).toEqual([]); + expect(createdSuccess).not.toHaveBeenCalled(); + expect(updatedSuccess).not.toHaveBeenCalled(); + + const freshNotes = page({ id: 1, body: "edited" }, { id: 2, body: "new" }); + await act(async () => sent("GET")[4]?.respond(freshNotes)); + await expect(Promise.all([created, updated])).resolves.toEqual([ + { id: 2, body: "new" }, + { id: 1, body: "edited" }, + ]); + expect(reads.result.current.notes.data).toEqual(freshNotes); + expect(reads.result.current.boards.data).toEqual( + page({ id: "current board" }), + ); + expect(createdSuccess).toHaveBeenCalledOnce(); + expect(updatedSuccess).toHaveBeenCalledOnce(); + expect(creator.result.current.loading).toBe(false); + expect(updater.result.current.loading).toBe(false); + }); + test("a restarted read that fails still lets the write resolve", async () => { await mountReads(); const writer = renderHook(() => useDatabaseCreate("notes")); 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 b9e27a70a..3bb3fe58f 100644 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -176,8 +176,9 @@ export function retainDatabaseRead( * includes reach them through the relations the server published. `false` * restarts none. * - * Resolves once every restarted read has answered, failed, or been - * superseded; it never rejects. + * Resolves once the current runs of those reads have answered, failed, or + * been torn down. A superseding refresh is followed rather than counted as + * complete; it never rejects. * * @example * ```ts diff --git a/packages/appkit-ui/src/react/hooks/request-store.ts b/packages/appkit-ui/src/react/hooks/request-store.ts index 9fd1a0072..3ca625a57 100644 --- a/packages/appkit-ui/src/react/hooks/request-store.ts +++ b/packages/appkit-ui/src/react/hooks/request-store.ts @@ -59,9 +59,10 @@ interface RequestStore { start(key: string): void; /** * `start` every subscribed entry that has run at least once and that - * `match` accepts (every such entry without one), then resolve once each - * restarted run settles. An entry retained with `autoStart: false` that - * never ran stays idle, and one whose last subscriber left is not restarted. + * `match` accepts (every such entry without one), then wait for their current + * runs, following any superseding restart. An entry retained with + * `autoStart: false` that never ran stays idle, and one whose last subscriber + * left is not restarted. */ restartStarted( match?: (key: string, meta: M | undefined) => boolean, @@ -80,6 +81,7 @@ interface Entry { teardownTimer: ReturnType | null; /** True once `start` has run at least once; guards re-run on late `retain`. */ started: boolean; + pending: Promise | null; run: RequestRunner; } @@ -116,7 +118,31 @@ export function createRequestStore(idle: S): RequestStore { }, }); // A runner must not reject; guard anyway so a restart never does. - return Promise.resolve(settled).catch(() => {}); + const pending = Promise.resolve(settled).catch(() => {}); + // A synchronous snapshot listener may have already started a newer run. + if (entry.abortController === abortController) entry.pending = pending; + return pending; + } + + async function waitForCurrentRuns( + targets: [string, Entry][], + ): Promise { + while (true) { + const pending = targets.map(([key, entry]) => + entries.get(key) === entry && entry.refCount > 0 ? entry.pending : null, + ); + await Promise.all(pending); + // One key may restart after settling while another key is still pending. + if ( + targets.every( + ([key, entry], index) => + entries.get(key) !== entry || + entry.refCount <= 0 || + entry.pending === pending[index], + ) + ) + return; + } } function start(key: string): void { @@ -150,6 +176,7 @@ export function createRequestStore(idle: S): RequestStore { abortController: null, teardownTimer: null, started: false, + pending: null, run: runner, }; entries.set(key, entry); @@ -175,15 +202,16 @@ export function createRequestStore(idle: S): RequestStore { // Collect first: a restarted run patches, and a patch notifies. An entry // with no subscriber is waiting for teardown; restarting it would only // send a request that teardown aborts a tick later. - const keys = [...entries] - .filter( - ([key, entry]) => - entry.started && - entry.refCount > 0 && - (!match || match(key, entry.meta)), - ) - .map(([key]) => key); - await Promise.all(keys.map(run)); + const targets = [...entries].filter( + ([key, entry]) => + entry.started && + entry.refCount > 0 && + (!match || match(key, entry.meta)), + ); + for (const [key, entry] of targets) { + if (entries.get(key) === entry && entry.refCount > 0) start(key); + } + await waitForCurrentRuns(targets); }, subscribe(key, listener) { From 9ca4fe47d64950204fa927e30da8bbdfd936cd18 Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 8 Oct 2026 00:39:44 +0200 Subject: [PATCH 8/8] fix(appkit-ui): keep database hook helper types private Page, DatabaseReadScope, and RetainOptions only have file-local consumers. Remove their unused exports rather than suppressing the Knip check. Regenerate the bundle baseline after rebasing onto the v0.86 main branch. Signed-off-by: ditadi --- bundle-size-baseline.json | 34 +++++++++---------- .../hooks/__tests__/database-test-utils.ts | 2 +- .../src/react/hooks/database-request-store.ts | 2 +- .../src/react/hooks/request-store.ts | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index cc8258eb3..4f11d0273 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -9,11 +9,11 @@ "dist": { "total": { "raw": 4338599, - "gzip": 1488135 + "gzip": 1488134 }, "js": { "raw": 1289704, - "gzip": 457768 + "gzip": 457767 }, "types": { "raw": 466539, @@ -64,11 +64,11 @@ }, { "id": "./beta", - "gzip": 101326, + "gzip": 101328, "composition": { - "initialGzip": 100842, + "initialGzip": 100844, "lazyGzip": 484, - "totalGzip": 101326, + "totalGzip": 101328, "own": 304570, "nodeModules": null, "chunks": [ @@ -89,7 +89,7 @@ }, { "label": "databricks.js", - "gzip": 3397, + "gzip": 3399, "kind": "initial" }, { @@ -109,17 +109,17 @@ }, { "label": "supervisor-api.js", - "gzip": 192, + "gzip": 193, "kind": "lazy" }, { "label": "databricks.js", - "gzip": 178, + "gzip": 176, "kind": "lazy" }, { "label": "index.js", - "gzip": 114, + "gzip": 115, "kind": "lazy" } ] @@ -137,12 +137,12 @@ "chunks": [ { "label": "manifest.js", - "gzip": 29311, + "gzip": 29312, "kind": "initial" }, { "label": "index.js", - "gzip": 10544, + "gzip": 10543, "kind": "initial" }, { @@ -209,13 +209,13 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 406035, - "unpacked": 1604441 + "packed": 406042, + "unpacked": 1604427 }, "dist": { "total": { - "raw": 1600496, - "gzip": 545804 + "raw": 1600482, + "gzip": 545806 }, "js": { "raw": 438717, @@ -223,10 +223,10 @@ }, "types": { "raw": 272461, - "gzip": 100413 + "gzip": 100415 }, "maps": { - "raw": 872920, + "raw": 872906, "gzip": 292174 }, "css": { diff --git a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts index 1af1202fa..20916cae4 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts @@ -20,7 +20,7 @@ import type { UseDatabaseWriteState } from "../use-database-write"; // runtime tests drive the hooks through these string-typed views. export type Row = Record; -export type Page = { items: unknown[]; limit: number; offset: number }; +type Page = { items: unknown[]; limit: number; offset: number }; interface ReadOptions { enabled?: boolean; 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 3bb3fe58f..233cc6053 100644 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -43,7 +43,7 @@ type ResponseGuard = (body: unknown) => body is object; * its includes reach. `open` marks a read with an include the server did not * describe, which any scoped invalidation restarts rather than risk missing. */ -export interface DatabaseReadScope { +interface DatabaseReadScope { readonly tables: ReadonlySet; readonly open: boolean; } diff --git a/packages/appkit-ui/src/react/hooks/request-store.ts b/packages/appkit-ui/src/react/hooks/request-store.ts index 3ca625a57..35ed6717a 100644 --- a/packages/appkit-ui/src/react/hooks/request-store.ts +++ b/packages/appkit-ui/src/react/hooks/request-store.ts @@ -34,7 +34,7 @@ export type RequestRunner = ( ) => void | Promise; /** How `retain` creates an entry; ignored when the entry already exists. */ -export interface RetainOptions { +interface RetainOptions { /** Start the request on creation. Default true. */ autoStart?: boolean; /** Caller data kept with the entry and handed to `restartStarted`'s match. */