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..ed32c474c 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,14 @@ import { CardTitle, Input, } from "@databricks/appkit-ui/react"; +import { + type DatabaseApiError, + useDatabaseCreate, + useDatabaseList, + useDatabaseRecord, +} from "@databricks/appkit-ui/react/beta"; import { Loader2, PlusIcon, RefreshCwIcon } from "lucide-react"; -import { useCallback, useEffect, useId, useState } from "react"; +import { useId, useState } from "react"; /** * Everything on this panel comes from routes the app never wrote: the note list @@ -18,71 +23,9 @@ 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 { - error?: unknown; - details?: Array<{ message?: string }>; - } | null; - const detail = payload?.details?.[0]?.message; - if (typeof detail === "string") return detail; - return typeof payload?.error === "string" ? payload.error : fallback; -} - -async function getJson(url: string): Promise { - const response = await fetch(url); - const body: unknown = await response.json(); - if (!response.ok) { - throw new Error(failureMessage(body, `HTTP ${response.status}`)); - } - return body as T; -} - -/** The client decodes the same envelope; a field detail still reads first. */ -function errorText(err: unknown): string { - if (err instanceof DatabaseApiError) { - return err.details[0]?.message ?? err.message; - } - return err instanceof Error ? err.message : String(err); +/** 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() { @@ -90,110 +33,98 @@ export function BoardExplorer() { const bodyFieldId = useId(); const boardFieldId = useId(); - const [boards, setBoards] = useState([]); - const [notes, setNotes] = useState([]); - const [timeline, setTimeline] = useState(null); const [selected, setSelected] = useState(null); - const [fullBody, setFullBody] = useState>({}); + const [revealedId, setRevealedId] = useState(null); const [author, setAuthor] = useState("reviewer"); const [body, setBody] = useState(""); const [title, setTitle] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const load = useCallback(async (slug?: string | null) => { - setError(null); - try { - const page = await 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)); - } - }, []); + // 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; - useEffect(() => { - load(); - }, [load]); + // 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]; - const board = boards.find((entry) => entry.slug === selected) ?? null; + // 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", + board + ? { + where: { board_id: board.id }, + order: { created_at: "desc" }, + limit: 5, + } + : null, + ); + const noteItems = notes.data?.items ?? []; - const post = async (url: string, payload: unknown) => { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const created: unknown = await response.json(); - if (!response.ok) throw new Error(failureMessage(created, "Create failed")); - return created; + // The 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 ?? []; + + // The list route truncates; the detail route does not. Same serializer. + const fullNote = useDatabaseRecord("notes", revealedId); + + const failure = + createNote.error ?? + createBoard.error ?? + boards.error ?? + notes.error ?? + timeline.error ?? + fullNote.error; + const error = failure ? errorText(failure) : null; + + const refresh = () => { + boards.refetch(); + notes.refetch(); + timeline.refetch(); + fullNote.refetch(); }; - const submit = async (run: () => Promise) => { - setBusy(true); - setError(null); - try { - const slug = await run(); - await load(slug ?? selected); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } + const selectBoard = (slug: string) => { + setSelected(slug); + setRevealedId(null); }; - const addNote = (event: React.FormEvent) => { + const addNote = async (event: React.FormEvent) => { event.preventDefault(); if (!board || !body.trim()) return; - return submit(async () => { - await post("/api/database/notes", { - board_id: board.id, - author, - body, - }); - setBody(""); - return board.slug; + createBoard.reset(); + // A failure resolves null and lands in createNote.error for the banner. + const created = await createNote.create({ + board_id: board.id, + author, + body, }); + if (created) setBody(""); }; - const addBoard = (event: React.FormEvent) => { + const addBoard = async (event: React.FormEvent) => { event.preventDefault(); if (!title.trim()) return; const slug = title .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-"); - return submit(async () => { - await post("/api/database/boards", { slug, title: title.trim() }); - setTitle(""); - return slug; - }); + createNote.reset(); + // A failure resolves null and lands in createBoard.error for the banner. + const created = await createBoard.create({ slug, title: title.trim() }); + if (!created) return; + setTitle(""); + selectBoard(created.slug); }; - /** The list route truncates; the detail route does not. Same serializer. */ - const revealFullBody = async (id: number) => { - const note = await getJson(`/api/database/notes/${id}`); - setFullBody((current) => ({ ...current, [id]: note.body })); - }; - - const eventsByNote = new Map( - (timeline?.notes ?? []).map((note) => [note.id, note.note_events ?? []]), - ); - return (
{error && ( @@ -206,12 +137,12 @@ export function BoardExplorer() { Board - {boards.map((entry) => ( + {boardItems.map((entry) => ( ))} - @@ -272,34 +198,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 +250,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/bundle-size-baseline.json b/bundle-size-baseline.json index 6e50f55c8..4f11d0273 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 1265942, - "unpacked": 4349858 + "packed": 1267078, + "unpacked": 4353228 }, "dist": { "total": { - "raw": 4335229, - "gzip": 1486991 + "raw": 4338599, + "gzip": 1488134 }, "js": { - "raw": 1288798, - "gzip": 457434 + "raw": 1289704, + "gzip": 457767 }, "types": { - "raw": 466001, - "gzip": 169053 + "raw": 466539, + "gzip": 169287 }, "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": 101328, "composition": { - "initialGzip": 100738, + "initialGzip": 100844, "lazyGzip": 484, - "totalGzip": 101222, - "own": 304130, + "totalGzip": 101328, + "own": 304570, "nodeModules": null, "chunks": [ { "label": "beta.js", - "gzip": 83152, + "gzip": 83258, "kind": "initial" }, { @@ -127,11 +127,11 @@ }, { "id": "./testing", - "gzip": 75875, + "gzip": 75873, "composition": { "initialGzip": 42855, - "lazyGzip": 33020, - "totalGzip": 75875, + "lazyGzip": 33018, + "totalGzip": 75873, "own": 219731, "nodeModules": null, "chunks": [ @@ -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": 406042, + "unpacked": 1604427 }, "dist": { "total": { - "raw": 1502000, - "gzip": 505901 + "raw": 1600482, + "gzip": 545806 }, "js": { - "raw": 415497, - "gzip": 140191 + "raw": 438717, + "gzip": 149961 }, "types": { - "raw": 252687, - "gzip": 92239 + "raw": 272461, + "gzip": 100415 }, "maps": { - "raw": 817418, - "gzip": 270215 + "raw": 872906, + "gzip": 292174 }, "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": 50889, "composition": { - "initialGzip": 442867, + "initialGzip": 443050, "lazyGzip": 49772, - "totalGzip": 492639, - "own": 181095, + "totalGzip": 492822, + "own": 181622, "nodeModules": 1403038, "chunks": [ { "label": "index.js", - "gzip": 440717, + "gzip": 440900, "kind": "initial" }, { @@ -316,17 +316,17 @@ }, { "id": "./react/beta", - "gzip": 1035, + "gzip": 4994, "composition": { - "initialGzip": 1035, + "initialGzip": 4994, "lazyGzip": 0, - "totalGzip": 1035, - "own": 1906, + "totalGzip": 4994, + "own": 12631, "nodeModules": 0, "chunks": [ { "label": "beta.js", - "gzip": 1035, + "gzip": 4994, "kind": "initial" } ] diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index ca4e8493f..7f8aa77e1 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 | @@ -364,6 +367,281 @@ 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. 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 + +```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`, and a +refetch that finds the row deleted clears `data` rather than showing it beside +the error. An id of `""`, `"."`, or `".."` reports `INVALID_REQUEST` without a +request. To pass options without params, use `{}` for the params: +`useDatabaseRecord("boards", boardId, {}, { keepPreviousData: true })`. + +### Request lifecycle + +- Hooks that request the same entity with parameters that encode to the same + query share one request while any of them is mounted. An inline parameter + object does not refetch on every render. +- `refetch()` aborts the in-flight request and sends it again. The last `data` + stays visible while it loads and if it fails, except for `NOT_FOUND`. +- The request is aborted once the last hook using it unmounts. Nothing is cached + after that. A React Strict Mode remount reuses the in-flight request. +- `error` keeps its identity across renders until it changes, so an effect that + depends on it, such as a toast, runs once per failure. +- A successful write through a write hook restarts mounted reads. See + [Refresh reads after a write](#refresh-reads-after-a-write). + +### Serializer-shaped reads + +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"; + +interface NoteCard { + id: number; + excerpt: string; +} + +const notes = useDatabaseList( + "notes", + { limit: 20 }, + { shape: serialized() }, +); +``` + +### 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. + +A successful call resolves once the reads it restarts have reloaded, and +`loading` stays `true` until then. A handler that clears its form after +`await create(...)` therefore sees the new row already in the lists. A read +that fails to reload does not fail the write. If another write or a manual +refetch supersedes a reload, the waiting call follows the current run of each +read rather than resolving on the cancelled run. Reads with no subscribers +stop holding the write open. After teardown, remounting the same URL creates +a new entry, not part of that write's refresh. + +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. + +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 +// A board's title changes only boards rows. +useDatabaseUpdate("boards", { invalidate: ["boards"] }); + +// Restart nothing. Call refetch() on the reads that need it. +useDatabaseCreate("notes", { invalidate: false }); +``` + +A read shows a table when it is rooted at it or when its `include` reaches it. +The server publishes the relations between exposed tables, so +`invalidate: ["notes"]` also restarts a `boards` read that includes `notes`, +and a `boards` read that includes `notes` and then `note_events`. A read whose +include the server did not describe is restarted by any table list. + +A write the hooks did not make, such as a `databaseApi` call or one of your own +routes that changes rows, does not restart reads by itself. Call +`invalidateDatabaseReads` from `@databricks/appkit-ui/react/beta` afterwards. It +takes the same scope as `invalidate`, defaults to every mounted read, and +resolves once the restarted reads have reloaded: + +```ts +import { invalidateDatabaseReads } from "@databricks/appkit-ui/react/beta"; + +await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); +await invalidateDatabaseReads(["boards", "notes"]); +``` + +### Write lifecycle + +- A write is never aborted, even when its component unmounts. Aborting the + request would not undo a transaction the server already committed. +- A write that succeeds after its component unmounts still restarts reads and + runs `onSuccess`, because the rows did change. +- Only the latest call updates `data`, `loading`, and `error`. An earlier call + still resolves for the code that awaits it, with `null` if it failed. +- `invalidate`, `onSuccess`, and `onError` are read when a call settles, so the + options of the latest render apply. +- `reset()` returns the hook to idle. A call in flight keeps running, but no + longer updates the hook. +- Writes are not queued or deduplicated. Each call sends its own request, so + disable the submit control while `loading`. + ## API reference - [`database`](../api/appkit/Function.database.md) 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 cff3bd72a..602753d55 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -27,11 +27,22 @@ import type { DatabaseUpdate, } from "./types"; -/** Suffix of the endpoint names `DatabasePlugin` publishes for each table. */ -type DatabaseOperation = "list" | "detail" | "create" | "update" | "delete"; +/** + * Suffix of the endpoint names `DatabasePlugin` publishes for each table. + * @internal Shared with the React hooks; not part of the public surface. + */ +export type DatabaseOperation = + | "list" + | "detail" + | "create" + | "update" + | "delete"; -/** An id as a keyed route addresses it in its path. */ -type IdLike = string | number | bigint; +/** + * An id as a keyed route addresses it in its path. + * @internal Shared with the React hooks; not part of the public surface. + */ +export type IdLike = string | number | bigint; /** Per-call options for a database request. */ export interface DatabaseRequestOptions { @@ -149,12 +160,24 @@ 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. */ -function resolveDatabaseUrl( +export function resolveDatabaseUrl( entity: string, operation: DatabaseOperation, id?: IdLike, @@ -173,10 +196,19 @@ 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; } @@ -212,8 +244,10 @@ 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. */ -async function requestDatabase( +export async function requestDatabase( url: string, init: RequestInit, accept: (body: unknown) => body is T, @@ -265,8 +299,13 @@ async function requestDatabase( return body; } -/** The `{ items, limit, offset }` envelope a list route answers with. */ -function isDatabaseListPage(body: unknown): body is DatabaseListPage { +/** + * The `{ items, limit, offset }` envelope a list route answers with. + * @internal Shared with the React hooks; not part of the public surface. + */ +export function isDatabaseListPage( + body: unknown, +): body is DatabaseListPage { return ( isRecord(body) && Array.isArray(body.items) && @@ -275,8 +314,11 @@ 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 { +/** + * 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); } @@ -315,10 +357,12 @@ 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. + * + * @internal Shared with the React hooks; not part of the public surface. */ -async function createDatabaseRow( +export async function createDatabaseRow( entity: string, values: object, init: DatabaseRequestOptions = {}, @@ -331,8 +375,11 @@ async function createDatabaseRow( ); } -/** Untyped update behind `databaseApi.update`. */ -async function updateDatabaseRow( +/** + * Untyped update, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ +export async function updateDatabaseRow( entity: string, id: IdLike, values: object, @@ -346,8 +393,11 @@ async function updateDatabaseRow( ); } -/** Untyped delete behind `databaseApi.remove`. */ -async function deleteDatabaseRow( +/** + * Untyped delete, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ +export async function deleteDatabaseRow( entity: string, id: IdLike, init: DatabaseRequestOptions = {}, diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 992a79635..5b1053b14 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -16,3 +16,62 @@ export { type UseAiSearchQueryResult, useAiSearchQuery, } from "./hooks/use-ai-search-query"; + +// Database read and write hooks. Track the `database` plugin, which ships at +// beta from '@databricks/appkit/beta'. The client and the registry binding live +// in '@databricks/appkit-ui/js/beta'; the types the hooks mention are +// re-exported. +export { + DatabaseApiError, + type DatabaseApiErrorCode, + type DatabaseEntity, + type DatabaseErrorDetail, + type DatabaseId, + type DatabaseInsert, + type DatabaseKeyedEntity, + type DatabaseListPage, + type DatabaseListParams, + type DatabaseListRow, + type DatabaseRecordParams, + type DatabaseRecordRow, + type DatabaseRow, + type DatabaseUpdate, +} from "@/js/beta"; +export { invalidateDatabaseReads } from "./hooks/database-request-store"; +export { + type UseDatabaseCreateOptions, + type UseDatabaseCreateResult, + useDatabaseCreate, +} from "./hooks/use-database-create"; +export { + type UseDatabaseDeleteOptions, + type UseDatabaseDeleteResult, + useDatabaseDelete, +} from "./hooks/use-database-delete"; +export { + type UseDatabaseListOptions, + type UseDatabaseListResult, + useDatabaseList, +} from "./hooks/use-database-list"; +export { + type DatabaseRowShape, + type DatabaseShape, + serialized, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, +} from "./hooks/use-database-read"; +export { + type UseDatabaseRecordOptions, + type UseDatabaseRecordResult, + useDatabaseRecord, +} from "./hooks/use-database-record"; +export { + type UseDatabaseUpdateOptions, + type UseDatabaseUpdateResult, + useDatabaseUpdate, +} from "./hooks/use-database-update"; +export type { + DatabaseInvalidation, + UseDatabaseWriteOptions, + UseDatabaseWriteState, +} from "./hooks/use-database-write"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts new file mode 100644 index 000000000..20916cae4 --- /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; +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 966e07836..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"]; @@ -67,13 +93,280 @@ 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"); 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, { autoStart: 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 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(); + + // 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); + + pending[1]?.(); + await restarted; + 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", () => { + 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", () => { + const signals: AbortSignal[] = []; + store.retain("k", (c) => { + signals.push(c.signal); + }); + + store.restartStarted(); + + expect(signals).toHaveLength(2); + expect(signals[0]?.aborted).toBe(true); + expect(signals[1]?.aborted).toBe(false); + }); + + test("restartStarted keeps the snapshot and notifies through the restarted run", () => { + const listener = vi.fn(); + let runs = 0; + store.subscribe("k", listener); + store.retain("k", (c) => { + runs += 1; + if (runs === 1) c.patch({ value: 1 }); + }); + listener.mockClear(); + + store.restartStarted(); + + // The store keeps the last result; only the run decides what to patch. + expect(store.getSnapshot("k").value).toBe(1); + expect(listener).not.toHaveBeenCalled(); + expect(runs).toBe(2); + }); + test("reset aborts in-flight runs and clears entries", () => { let captured: AbortSignal | undefined; store.retain("k", (c) => { diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx new file mode 100644 index 000000000..5cb7ee1d4 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx @@ -0,0 +1,430 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { DatabaseApiError } from "@/js/database/errors"; + +import { + mockDatabaseFetch, + nextTick, + type PendingRequest, + page, + publishDatabase, + resetDatabaseTestEnvironment, + useDatabaseList, +} from "./database-test-utils"; + +describe("useDatabaseList", () => { + let requests: PendingRequest[]; + let fetchMock: ReturnType["fetchMock"]; + + beforeEach(() => { + ({ requests, fetchMock } = mockDatabaseFetch()); + publishDatabase({ + "notes.list": "/api/database/notes", + "boards.list": "/api/database/boards", + }); + }); + + afterEach(resetDatabaseTestEnvironment); + + test("reads the published route with the encoded query", async () => { + const { result } = renderHook(() => + useDatabaseList("notes", { where: { board_id: 7 }, limit: 5 }), + ); + + expect(result.current).toMatchObject({ + data: null, + loading: true, + error: null, + }); + expect(requests.map((request) => request.url)).toEqual([ + "/api/database/notes?where=%7B%22board_id%22%3A7%7D&limit=5", + ]); + + await act(async () => requests[0]?.respond(page({ id: 1 }))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual(page({ id: 1 })); + expect(result.current.error).toBeNull(); + }); + + test("is loading from its first render, before the request starts", () => { + const loading: boolean[] = []; + renderHook(() => { + const read = useDatabaseList("notes"); + loading.push(read.loading); + return read; + }); + + expect(loading[0]).toBe(true); + }); + + test("shares one request between hooks with equal params", async () => { + const first = renderHook(() => + useDatabaseList("notes", { order: { id: "desc" }, limit: 5 }), + ); + // A different key order encodes to the same query. + const second = renderHook(() => + useDatabaseList("notes", { limit: 5, order: { id: "desc" } }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + await act(async () => requests[0]?.respond(page({ id: 2 }))); + + await waitFor(() => + expect(first.result.current.data).toEqual(page({ id: 2 })), + ); + expect(second.result.current.data).toEqual(page({ id: 2 })); + }); + + test("does not refetch when an inline params literal re-renders", () => { + const { rerender } = renderHook( + ({ limit }: { limit: number }) => useDatabaseList("notes", { limit }), + { initialProps: { limit: 5 } }, + ); + + rerender({ limit: 5 }); + rerender({ limit: 5 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + rerender({ limit: 10 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requests[1]?.url).toBe("/api/database/notes?limit=10"); + }); + + test("sends nothing while disabled, and reads once enabled", () => { + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useDatabaseList("notes", {}, { enabled }), + { initialProps: { enabled: false } }, + ); + + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + act(() => result.current.refetch()); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.current.loading).toBe(true); + }); + + test("null params hold the read idle until the params they depend on exist", () => { + const { result, rerender } = renderHook( + ({ boardId }: { boardId: number | undefined }) => + useDatabaseList( + "notes", + boardId === undefined ? null : { where: { board_id: boardId } }, + ), + { initialProps: { boardId: undefined as number | undefined } }, + ); + + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ boardId: 7 }); + expect(requests[0]?.url).toContain("board_id%22%3A7"); + expect(result.current.loading).toBe(true); + }); + + test("does not encode an incomplete filter while disabled", () => { + const { rerender } = renderHook( + ({ boardId }: { boardId: number | undefined }) => + useDatabaseList( + "notes", + { where: { board_id: boardId } }, + { enabled: boardId !== undefined }, + ), + { initialProps: { boardId: undefined as number | undefined } }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + rerender({ boardId: 7 }); + expect(requests[0]?.url).toContain("board_id%22%3A7"); + }); + + test("reports an incomplete enabled filter without sending a request", () => { + const { result } = renderHook(() => + useDatabaseList("notes", { where: { board_id: undefined } }), + ); + + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + }); + expect(result.current.loading).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("preserves local validation guidance and changes it only when the failure changes", () => { + const { result, rerender } = renderHook( + ({ where }: { where: object | undefined }) => + useDatabaseList("notes", { where }), + { initialProps: { where: {} as object | undefined } }, + ); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Filter cannot be empty; omit where to list all rows", + details: [ + { + path: ["where"], + message: "Filter cannot be empty; omit where to list all rows", + }, + ], + }); + const empty = result.current.error; + rerender({ where: {} }); + expect(result.current.error).toBe(empty); + + rerender({ where: { rank: NaN } }); + expect(result.current.error).toMatchObject({ + message: "Database query numbers must be finite", + details: [ + { path: ["where"], message: "Database query numbers must be finite" }, + ], + }); + const nonFinite = result.current.error; + expect(nonFinite).not.toBe(empty); + rerender({ where: { rank: NaN } }); + expect(result.current.error).toBe(nonFinite); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ where: undefined }); + expect(result.current.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test("keeps an INVALID_REQUEST error stable, so an effect keyed on it runs once", () => { + const seen: unknown[] = []; + const { result, rerender } = renderHook(() => { + const read = useDatabaseList("notes", { where: { board_id: undefined } }); + useEffect(() => { + seen.push(read.error); + }, [read.error]); + return read; + }); + const first = result.current.error; + + rerender(); + rerender(); + + expect(result.current.error).toBe(first); + expect(seen).toEqual([first]); + }); + + test("refetch aborts the in-flight request, sends it again, and keeps the last page visible", async () => { + const { result } = renderHook(() => useDatabaseList("notes")); + await act(async () => requests[0]?.respond(page({ id: 1 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.refetch()); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.current).toMatchObject({ + data: page({ id: 1 }), + loading: true, + }); + + act(() => result.current.refetch()); + expect(requests[1]?.signal?.aborted).toBe(true); + expect(requests[2]?.signal?.aborted).toBe(false); + + await act(async () => requests[2]?.respond(page({ id: 1 }, { id: 2 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual(page({ id: 1 }, { id: 2 })); + }); + + test("ignores completions that arrive after their request was superseded", async () => { + const { result } = renderHook(() => useDatabaseList("notes")); + act(() => result.current.refetch()); + act(() => result.current.refetch()); + + await act(async () => requests[2]?.respond(page({ id: "fresh" }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => requests[0]?.respond(page({ id: "stale" }))); + await act(async () => requests[1]?.respond({ error: "late" }, 500)); + + expect(result.current.data).toEqual(page({ id: "fresh" })); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + test("new params show null while they load, unless keepPreviousData holds the last page", async () => { + const { result, rerender } = renderHook( + ({ offset, keep }: { offset: number; keep: boolean }) => + useDatabaseList( + "notes", + { limit: 1, offset }, + { keepPreviousData: keep }, + ), + { initialProps: { offset: 0, keep: false } }, + ); + await act(async () => requests[0]?.respond(page({ id: 0 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 1, keep: false }); + expect(result.current).toMatchObject({ data: null, loading: true }); + await act(async () => requests[1]?.respond(page({ id: 1 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 2, keep: true }); + expect(result.current).toMatchObject({ + data: page({ id: 1 }), + loading: true, + error: null, + }); + await act(async () => requests[2]?.respond(page({ id: 2 }))); + await waitFor(() => expect(result.current.data).toEqual(page({ id: 2 }))); + }); + + test("keepPreviousData does not show the last page beside a failure for the new params", async () => { + const { result, rerender } = renderHook( + ({ offset }: { offset: number }) => + useDatabaseList( + "notes", + { limit: 1, offset }, + { keepPreviousData: true }, + ), + { initialProps: { offset: 0 } }, + ); + await act(async () => requests[0]?.respond(page({ id: 0 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ offset: 1 }); + await act(async () => requests[1]?.respond({ error: "boom" }, 500)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toMatchObject({ code: "INTERNAL" }); + }); + + test("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")); + + first.unmount(); + await nextTick(); + expect(requests[0]?.signal?.aborted).toBe(false); + + second.unmount(); + expect(requests[0]?.signal?.aborted).toBe(false); + await nextTick(); + expect(requests[0]?.signal?.aborted).toBe(true); + }); + + test("reuses the in-flight request across a StrictMode remount", async () => { + const { result } = renderHook(() => useDatabaseList("notes"), { + wrapper: StrictMode, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + await nextTick(); + expect(requests[0]?.signal?.aborted).toBe(false); + + await act(async () => requests[0]?.respond(page({ id: 3 }))); + await waitFor(() => expect(result.current.data).toEqual(page({ id: 3 }))); + }); + + test("reports a failure as a DatabaseApiError and keeps the last page after a failed refetch", async () => { + const { result } = renderHook(() => useDatabaseList("notes")); + await act(async () => requests[0]?.respond(page({ id: 1 }))); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.refetch()); + await act(async () => + requests[1]?.respond( + { + error: "Invalid database request", + details: [{ path: ["where"], message: "Unknown column" }], + }, + 400, + ), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeInstanceOf(DatabaseApiError); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: 400, + details: [{ path: ["where"], message: "Unknown column" }], + }); + expect(result.current.data).toEqual(page({ id: 1 })); + }); + + test("reports NOT_EXPOSED for an unpublished route without sending a request", () => { + const { result, rerender } = renderHook(() => useDatabaseList("secrets")); + const first = result.current.error; + + expect(first).toBeInstanceOf(DatabaseApiError); + expect(first).toMatchObject({ code: "NOT_EXPOSED", status: null }); + expect(result.current.loading).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + + // The error is stable across renders, so effects keyed on it do not loop. + rerender(); + expect(result.current.error).toBe(first); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx new file mode 100644 index 000000000..3e9a4ea5b --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutations.test.tsx @@ -0,0 +1,675 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { DatabaseApiError } from "@/js/database/errors"; + +import { + invalidateDatabaseReads, + mockDatabaseFetch, + nextTick, + page, + publishDatabase, + resetDatabaseTestEnvironment, + type Row, + useDatabaseCreate, + useDatabaseDelete, + useDatabaseList, + useDatabaseUpdate, +} from "./database-test-utils"; + +const ENDPOINTS = { + "notes.list": "/api/database/notes", + "notes.create": "/api/database/notes", + "notes.update": "/api/database/notes/:id", + "notes.delete": "/api/database/notes/:id", + "boards.list": "/api/database/boards", + "note_events.list": "/api/database/note_events", +}; + +// As `DatabasePlugin` publishes them: board → notes → note_events. +const RELATIONS = { + boards: { notes: "notes" }, + notes: { boards: "boards", note_events: "note_events" }, + note_events: { notes: "notes" }, +}; + +describe("database write hooks", () => { + let fetchMock: ReturnType["fetchMock"]; + let sent: ReturnType["sent"]; + + /** URLs of the reads sent after the first `after`. */ + const readsAfter = (after: number) => + sent("GET") + .slice(after) + .map((request) => request.url); + + /** Answer every read still open, as the server would. */ + const answerReads = (from: number, body: unknown = page({ id: 1 })) => { + for (const request of sent("GET").slice(from)) request.respond(body); + }; + + beforeEach(() => { + ({ fetchMock, sent } = mockDatabaseFetch()); + publishDatabase(ENDPOINTS, RELATIONS); + }); + + afterEach(resetDatabaseTestEnvironment); + + /** Mount a notes read and a boards read, and answer both. */ + async function mountReads() { + const reads = renderHook(() => ({ + notes: useDatabaseList("notes"), + boards: useDatabaseList("boards"), + })); + await act(async () => answerReads(0)); + await waitFor(() => + expect(reads.result.current.boards.loading).toBe(false), + ); + return reads; + } + + test("create posts the values and moves from loading to the created row", async () => { + const { result } = renderHook(() => useDatabaseCreate("notes")); + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + + let created!: Promise; + act(() => { + created = result.current.create({ board_id: 7, body: "hi" }); + }); + + expect(result.current.loading).toBe(true); + expect(sent("POST")).toMatchObject([ + { url: "/api/database/notes", body: { board_id: 7, body: "hi" } }, + ]); + + await act(async () => sent("POST")[0]?.respond({ id: 1, body: "hi" }, 201)); + + await expect(created).resolves.toEqual({ id: 1, body: "hi" }); + expect(result.current).toMatchObject({ + data: { id: 1, body: "hi" }, + loading: false, + error: null, + }); + }); + + test("a failed write resolves null and reports the DatabaseApiError, without rejecting", async () => { + const { result } = renderHook(() => useDatabaseCreate("notes")); + + // No handler is attached: a rejection here would fail the run as unhandled. + let failed!: Promise; + act(() => { + failed = result.current.create({ body: "" }); + }); + await act(async () => + sent("POST")[0]?.respond( + { + error: "Database request failed validation", + details: [{ path: ["body"], message: "Must not be empty" }], + }, + 422, + ), + ); + + await expect(failed).resolves.toBeNull(); + const { error } = result.current; + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "VALIDATION_FAILED", + status: 422, + details: [{ path: ["body"], message: "Must not be empty" }], + }); + expect(result.current).toMatchObject({ data: null, loading: false }); + + // The next call starts clean. + act(() => { + void result.current.create({ body: "again" }); + }); + expect(result.current).toMatchObject({ loading: true, error: null }); + }); + + test("rejects a non-finite update before sending it or invalidating reads", async () => { + const { result } = renderHook(() => useDatabaseUpdate("notes")); + let updated!: Row | null; + await act(async () => { + updated = await result.current.update(7, { rank: NaN }); + }); + expect(updated).toBeNull(); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: + "Database write numbers must be finite; use null explicitly to clear a value", + }); + expect(result.current.loading).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("update patches one id and delete removes one without a response body", async () => { + const { result } = renderHook(() => ({ + update: useDatabaseUpdate("notes"), + remove: useDatabaseDelete("notes"), + })); + + let updated!: Promise; + let removed!: Promise; + act(() => { + updated = result.current.update.update(7, { body: "edited" }); + removed = result.current.remove.remove("a/b"); + }); + expect(result.current.update.loading).toBe(true); + expect(result.current.remove.loading).toBe(true); + expect(sent("PATCH")).toMatchObject([ + { url: "/api/database/notes/7", body: { body: "edited" } }, + ]); + expect(sent("DELETE")).toMatchObject([ + { url: "/api/database/notes/a%2Fb", body: null }, + ]); + + await act(async () => { + sent("PATCH")[0]?.respond({ id: 7, body: "edited" }); + sent("DELETE")[0]?.respond(null, 204); + }); + + await expect(updated).resolves.toEqual({ id: 7, body: "edited" }); + await expect(removed).resolves.toBe(true); + expect(result.current.update).toMatchObject({ + data: { id: 7, body: "edited" }, + loading: false, + }); + expect(result.current.remove).toMatchObject({ + loading: false, + error: null, + }); + expect(result.current.remove).not.toHaveProperty("data"); + }); + + test("reports NOT_EXPOSED for a write the plugin did not publish, without a request", async () => { + const { result } = renderHook(() => ({ + create: useDatabaseCreate("note_events"), + remove: useDatabaseDelete("note_events"), + })); + + let created!: Promise; + let removed!: Promise; + await act(async () => { + created = result.current.create.create({ action: "x" }); + removed = result.current.remove.remove(1); + await Promise.all([created, removed]); + }); + + await expect(created).resolves.toBeNull(); + await expect(removed).resolves.toBe(false); + expect(result.current.create.error).toMatchObject({ + code: "NOT_EXPOSED", + status: null, + }); + expect(result.current.remove.error).toMatchObject({ + code: "NOT_EXPOSED", + status: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("refuses a write addressed by an unaddressable id, without a request", async () => { + const { result } = renderHook(() => useDatabaseDelete("notes")); + + let removed!: Promise; + await act(async () => { + removed = result.current.remove(".."); + await removed; + }); + + await expect(removed).resolves.toBe(false); + expect(result.current.error).toMatchObject({ code: "INVALID_REQUEST" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("a successful write restarts every mounted read by default and resolves once they reload", async () => { + const reads = await mountReads(); + const writer = renderHook(() => useDatabaseCreate("notes")); + expect(sent("GET")).toHaveLength(2); + + let created!: Promise; + let settled = false; + act(() => { + created = writer.result.current.create({ body: "new" }); + void created.then(() => { + settled = true; + }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + + expect(readsAfter(2)).toEqual([ + "/api/database/notes", + "/api/database/boards", + ]); + // Reads keep their data while they reload; the write waits for them. + expect(reads.result.current.notes).toMatchObject({ + data: page({ id: 1 }), + loading: true, + }); + expect(writer.result.current).toMatchObject({ loading: true, data: null }); + expect(settled).toBe(false); + + await act(async () => { + sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 })); + sent("GET")[3]?.respond(page({ id: 1 })); + }); + await expect(created).resolves.toEqual({ id: 2 }); + expect(reads.result.current.notes.data).toEqual(page({ id: 1 }, { id: 2 })); + expect(writer.result.current).toMatchObject({ + loading: false, + data: { id: 2 }, + }); + }); + + test("overlapping writes wait for the latest reload of every read before resolving or onSuccess", async () => { + const reads = await mountReads(); + const createdSuccess = vi.fn(); + const updatedSuccess = vi.fn(); + const creator = renderHook(() => + useDatabaseCreate("notes", { onSuccess: createdSuccess }), + ); + const updater = renderHook(() => + useDatabaseUpdate("notes", { onSuccess: updatedSuccess }), + ); + const settled: string[] = []; + let created!: Promise; + let updated!: Promise; + act(() => { + created = creator.result.current.create({ body: "new" }); + void created.then(() => { + settled.push("create"); + }); + }); + await act(async () => + sent("POST")[0]?.respond({ id: 2, body: "new" }, 201), + ); + await act(async () => sent("GET")[2]?.respond(page({ id: 1 }, { id: 2 }))); + expect(settled).toEqual([]); + + act(() => { + updated = updater.result.current.update(1, { body: "edited" }); + void updated.then(() => { + settled.push("update"); + }); + }); + await act(async () => { + sent("PATCH")[0]?.respond({ id: 1, body: "edited" }); + await nextTick(); + }); + expect(sent("GET")[3]?.signal?.aborted).toBe(true); + expect(sent("GET")).toHaveLength(6); + expect(settled).toEqual([]); + expect(createdSuccess).not.toHaveBeenCalled(); + expect(creator.result.current.loading).toBe(true); + expect(updater.result.current.loading).toBe(true); + + await act(async () => { + sent("GET")[5]?.respond(page({ id: "current board" })); + sent("GET")[3]?.respond(page({ id: "stale board" })); + await nextTick(); + }); + expect(settled).toEqual([]); + expect(createdSuccess).not.toHaveBeenCalled(); + expect(updatedSuccess).not.toHaveBeenCalled(); + + const freshNotes = page({ id: 1, body: "edited" }, { id: 2, body: "new" }); + await act(async () => sent("GET")[4]?.respond(freshNotes)); + await expect(Promise.all([created, updated])).resolves.toEqual([ + { id: 2, body: "new" }, + { id: 1, body: "edited" }, + ]); + expect(reads.result.current.notes.data).toEqual(freshNotes); + expect(reads.result.current.boards.data).toEqual( + page({ id: "current board" }), + ); + expect(createdSuccess).toHaveBeenCalledOnce(); + expect(updatedSuccess).toHaveBeenCalledOnce(); + expect(creator.result.current.loading).toBe(false); + expect(updater.result.current.loading).toBe(false); + }); + + test("a restarted read that fails still lets the write resolve", async () => { + await mountReads(); + const writer = renderHook(() => useDatabaseCreate("notes")); + + let created!: Promise; + act(() => { + created = writer.result.current.create({ body: "new" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + await act(async () => answerReads(2, { error: "down" })); + + await expect(created).resolves.toEqual({ id: 2 }); + expect(writer.result.current.error).toBeNull(); + }); + + test("invalidate narrows the restart to reads of the named tables, or turns it off", async () => { + await mountReads(); + const writers = renderHook(() => ({ + narrow: useDatabaseCreate("notes", { invalidate: ["notes"] }), + none: useDatabaseCreate("notes", { invalidate: false }), + })); + + let created!: Promise; + act(() => { + created = writers.result.current.narrow.create({ body: "a" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 2 }, 201)); + expect(readsAfter(2)).toEqual(["/api/database/notes"]); + await act(async () => answerReads(2)); + await created; + + act(() => { + created = writers.result.current.none.create({ body: "b" }); + }); + await act(async () => sent("POST")[1]?.respond({ id: 3 }, 201)); + await created; + expect(sent("GET")).toHaveLength(3); + }); + + test("a narrowed invalidate reaches the reads whose includes show the written table", async () => { + renderHook(() => ({ + // boards → notes, and boards → notes → note_events. + boards: useDatabaseList("boards", { include: { notes: { limit: 5 } } }), + timeline: useDatabaseList("boards", { + include: { notes: { include: { note_events: { limit: 5 } } } }, + }), + plain: useDatabaseList("boards"), + events: useDatabaseList("note_events"), + })); + await act(async () => answerReads(0)); + + act(() => { + void invalidateDatabaseReads(["notes"]); + }); + expect(readsAfter(4)).toEqual([ + "/api/database/boards?include=%7B%22notes%22%3A%7B%22limit%22%3A5%7D%7D", + expect.stringContaining("note_events"), + ]); + + act(() => { + void invalidateDatabaseReads(["note_events"]); + }); + expect(readsAfter(6)).toEqual([ + expect.stringContaining("note_events"), + "/api/database/note_events", + ]); + }); + + test("a read with an include the server did not describe restarts on any scoped invalidate", async () => { + renderHook(() => ({ + unknown: useDatabaseList("boards", { include: { archived: true } }), + plain: useDatabaseList("boards"), + })); + await act(async () => answerReads(0)); + + act(() => { + void invalidateDatabaseReads(["note_events"]); + }); + expect(readsAfter(2)).toEqual([ + "/api/database/boards?include=%7B%22archived%22%3Atrue%7D", + ]); + }); + + test("invalidateDatabaseReads refreshes reads after a write the hooks did not make", async () => { + await mountReads(); + + // A custom route or a databaseApi call changed boards rows. + let refreshed!: Promise; + let done = false; + act(() => { + refreshed = invalidateDatabaseReads(["boards"]).then(() => { + done = true; + }); + }); + expect(readsAfter(2)).toEqual(["/api/database/boards"]); + await Promise.resolve(); + expect(done).toBe(false); + await act(async () => answerReads(2)); + await refreshed; + expect(done).toBe(true); + + // With no scope, every mounted read restarts. + act(() => { + void invalidateDatabaseReads(); + }); + expect(readsAfter(3)).toEqual([ + "/api/database/notes", + "/api/database/boards", + ]); + + await expect(invalidateDatabaseReads(false)).resolves.toBeUndefined(); + await expect(invalidateDatabaseReads([])).resolves.toBeUndefined(); + expect(sent("GET")).toHaveLength(5); + }); + + test("a failed write restarts no reads", async () => { + await mountReads(); + const writer = renderHook(() => useDatabaseCreate("notes")); + + let failed!: Promise; + act(() => { + failed = writer.result.current.create({ body: "" }); + }); + await act(async () => + sent("POST")[0]?.respond({ error: "Database conflict" }, 409), + ); + + await expect(failed).resolves.toBeNull(); + expect(writer.result.current.error).toMatchObject({ code: "CONFLICT" }); + expect(sent("GET")).toHaveLength(2); + }); + + test("unmounting keeps the write in flight, and its success still restarts reads", async () => { + await mountReads(); + const onSuccess = vi.fn(); + const writer = renderHook(() => useDatabaseCreate("notes", { onSuccess })); + + let created!: Promise; + act(() => { + created = writer.result.current.create({ body: "late" }); + }); + writer.unmount(); + + // The write carries no abort signal: cancelling it would not undo it. + expect(sent("POST")[0]?.signal).toBeUndefined(); + await act(async () => sent("POST")[0]?.respond({ id: 9 }, 201)); + expect(sent("GET")).toHaveLength(4); + await act(async () => answerReads(2)); + + await expect(created).resolves.toEqual({ id: 9 }); + // The rows changed, so the callback runs even though the hook is gone. + expect(onSuccess).toHaveBeenCalledWith({ id: 9 }, { body: "late" }); + }); + + test("onSuccess and onError run for every call with its arguments, after the state settles", async () => { + const events: string[] = []; + const { result } = renderHook(() => { + const hook = useDatabaseUpdate("notes", { + onSuccess: (row, id, values) => + events.push(`ok ${JSON.stringify([row, id, values])}`), + onError: (error, id) => + events.push(`fail ${error.code} ${String(id)} ${hook.loading}`), + }); + return hook; + }); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.update(1, { body: "a" }); + second = result.current.update(2, { body: "b" }); + }); + await act(async () => { + sent("PATCH")[0]?.respond({ id: 1 }); + sent("PATCH")[1]?.respond({ error: "Database conflict" }, 409); + }); + await Promise.all([first, second]); + + // The stale first call does not update the state, but is still reported. + expect(events).toHaveLength(2); + expect(events).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^fail CONFLICT 2 /), + `ok ${JSON.stringify([{ id: 1 }, 1, { body: "a" }])}`, + ]), + ); + expect(result.current.error).toMatchObject({ code: "CONFLICT" }); + }); + + test("a delete reports the removed id to onSuccess", async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => + useDatabaseDelete("notes", { onSuccess }), + ); + + let removed!: Promise; + act(() => { + removed = result.current.remove(7); + }); + await act(async () => sent("DELETE")[0]?.respond(null, 204)); + + await expect(removed).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledWith(7); + }); + + test("a throwing callback is reported as uncaught without breaking the call", async () => { + const reported = vi.fn(); + vi.stubGlobal("reportError", reported); + const { result } = renderHook(() => + useDatabaseCreate("notes", { + onSuccess: () => { + throw new Error("callback bug"); + }, + }), + ); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "x" }); + }); + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + + await expect(created).resolves.toEqual({ id: 1 }); + expect(reported).toHaveBeenCalledWith(new Error("callback bug")); + expect(result.current).toMatchObject({ data: { id: 1 }, error: null }); + }); + + test("only the latest call reports its state; an earlier call still resolves", async () => { + const { result } = renderHook(() => useDatabaseCreate("notes")); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.create({ body: "first" }); + second = result.current.create({ body: "second" }); + }); + + await act(async () => sent("POST")[1]?.respond({ id: 2 }, 201)); + await second; + expect(result.current).toMatchObject({ data: { id: 2 }, loading: false }); + + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + await expect(first).resolves.toEqual({ id: 1 }); + expect(result.current).toMatchObject({ data: { id: 2 }, loading: false }); + + // A stale failure does not replace the latest result either. + let third!: Promise; + let fourth!: Promise; + act(() => { + third = result.current.create({ body: "third" }); + fourth = result.current.create({ body: "fourth" }); + }); + await act(async () => sent("POST")[3]?.respond({ id: 4 }, 201)); + await fourth; + await act(async () => + sent("POST")[2]?.respond({ error: "Database conflict" }, 409), + ); + await expect(third).resolves.toBeNull(); + expect(result.current).toMatchObject({ + data: { id: 4 }, + loading: false, + error: null, + }); + }); + + test("reset returns to idle and ignores the call in flight", async () => { + const { result } = renderHook(() => useDatabaseCreate("notes")); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "x" }); + }); + act(() => result.current.reset()); + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + await expect(created).resolves.toEqual({ id: 1 }); + expect(result.current).toMatchObject({ data: null, loading: false }); + }); + + test("keeps its write functions stable across renders with inline options", () => { + const { result, rerender } = renderHook(() => ({ + create: useDatabaseCreate("notes", { + invalidate: ["notes", "boards"], + onSuccess: () => {}, + }), + remove: useDatabaseDelete("notes", { onError: () => {} }), + })); + const { create } = result.current.create; + const { remove, reset } = result.current.remove; + + rerender(); + + expect(result.current.create.create).toBe(create); + expect(result.current.remove.remove).toBe(remove); + expect(result.current.remove.reset).toBe(reset); + }); + + test("uses the options of the latest render when a call settles", async () => { + await mountReads(); + const { result, rerender } = renderHook( + ({ scope }: { scope: readonly string[] }) => + useDatabaseCreate("notes", { invalidate: scope }), + { initialProps: { scope: ["notes"] as readonly string[] } }, + ); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "x" }); + }); + rerender({ scope: ["boards"] }); + await act(async () => sent("POST")[0]?.respond({ id: 1 }, 201)); + + expect(readsAfter(2)).toEqual(["/api/database/boards"]); + await act(async () => answerReads(2)); + await created; + }); + + test("reports its state under StrictMode", async () => { + const { result } = renderHook(() => useDatabaseCreate("notes"), { + wrapper: StrictMode, + }); + + let created!: Promise; + act(() => { + created = result.current.create({ body: "strict" }); + }); + expect(result.current.loading).toBe(true); + + await act(async () => sent("POST")[0]?.respond({ id: 5 }, 201)); + await created; + expect(result.current).toMatchObject({ data: { id: 5 }, loading: false }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx new file mode 100644 index 000000000..975bd1f08 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx @@ -0,0 +1,229 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { DatabaseApiError } from "@/js/database/errors"; + +import { + invalidateDatabaseReads, + nextTick, + publishDatabase, + reply, + resetDatabaseTestEnvironment, + useDatabaseRecord, +} from "./database-test-utils"; + +describe("useDatabaseRecord", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + // Answers every detail read at once with a row named after its id. + fetchMock = vi.fn(async (url: string) => { + const id = decodeURIComponent(url.split("?")[0]?.split("/").pop() ?? ""); + return reply({ id, body: `note ${id}` }); + }); + vi.stubGlobal("fetch", fetchMock); + publishDatabase({ + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + "events.list": "/api/database/events", + }); + }); + + afterEach(resetDatabaseTestEnvironment); + + test("reads the detail route with the id as one path segment and the encoded query", async () => { + const { result } = renderHook(() => + useDatabaseRecord("notes", "a/b c", { + include: { note_events: { limit: 5 } }, + }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/api/database/notes/a%2Fb%20c?include=%7B%22note_events%22%3A%7B%22limit%22%3A5%7D%7D", + ); + expect(result.current.data).toEqual({ id: "a/b c", body: "note a/b c" }); + }); + + test("waits without a request while the id is null or undefined", async () => { + const { result, rerender } = renderHook( + ({ id }: { id: number | null | undefined }) => + useDatabaseRecord("notes", id), + { initialProps: { id: null as number | null | undefined } }, + ); + + expect(result.current).toMatchObject({ + data: null, + loading: false, + error: null, + }); + rerender({ id: undefined }); + act(() => result.current.refetch()); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ id: 7 }); + await waitFor(() => + expect(result.current.data).toEqual({ id: "7", body: "note 7" }), + ); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes/7"); + }); + + test.each(["", ".", ".."])( + "refuses the id %j, which would resolve to another route, without a request", + (id) => { + const { result, rerender } = renderHook(() => + useDatabaseRecord("notes", id), + ); + const first = result.current.error; + + expect(first).toMatchObject({ code: "INVALID_REQUEST", status: null }); + expect(result.current).toMatchObject({ data: null, loading: false }); + expect(fetchMock).not.toHaveBeenCalled(); + rerender(); + expect(result.current.error).toBe(first); + }, + ); + + test("does not encode incomplete includes while the record is disabled", () => { + const { result, rerender } = renderHook( + ({ id, author }: { id: number | null; author: string | undefined }) => + useDatabaseRecord("notes", id, { + include: { note_events: { where: { author } } }, + }), + { + initialProps: { + id: null as number | null, + author: undefined as string | undefined, + }, + }, + ); + + expect(result.current.error).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + rerender({ id: 7, author: "ada" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("reports a non-finite include operand locally with stable field details", () => { + const { result, rerender } = renderHook(() => + useDatabaseRecord("notes", 7, { + include: { note_events: { where: { rank: NaN } } }, + }), + ); + expect(result.current.error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database query numbers must be finite", + details: [ + { path: ["include"], message: "Database query numbers must be finite" }, + ], + }); + const first = result.current.error; + rerender(); + expect(result.current.error).toBe(first); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("reads the new record when the id changes", async () => { + const { result, rerender } = renderHook( + ({ id }: { id: number }) => useDatabaseRecord("notes", id), + { initialProps: { id: 1 } }, + ); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); + + rerender({ id: 2 }); + expect(result.current).toMatchObject({ data: null, loading: true }); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "2" })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("keepPreviousData shows the previous record while the next id loads", async () => { + const { result, rerender } = renderHook( + ({ id }: { id: number }) => + useDatabaseRecord("notes", id, {}, { keepPreviousData: true }), + { initialProps: { id: 1 } }, + ); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); + + rerender({ id: 2 }); + expect(result.current).toMatchObject({ + data: { id: "1" }, + loading: true, + }); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "2" })); + }); + + test("reports a missing row as NOT_FOUND", async () => { + fetchMock.mockResolvedValueOnce( + reply({ error: "Database record not found" }, 404), + ); + + const { result } = renderHook(() => useDatabaseRecord("notes", 404)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeInstanceOf(DatabaseApiError); + expect(result.current.error).toMatchObject({ + code: "NOT_FOUND", + status: 404, + message: "Database record not found", + }); + }); + + test("clears the row when a refetch finds it deleted, but keeps it through other failures", async () => { + const { result } = renderHook(() => useDatabaseRecord("notes", 1)); + await waitFor(() => expect(result.current.data).toMatchObject({ id: "1" })); + + fetchMock.mockResolvedValueOnce(reply({ error: "Unavailable" }, 503)); + act(() => result.current.refetch()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toMatchObject({ id: "1" }); + expect(result.current.error).toMatchObject({ status: 503 }); + + // The row was deleted elsewhere; the restart after that write finds it gone. + fetchMock.mockResolvedValueOnce( + reply({ error: "Database record not found" }, 404), + ); + await act(() => invalidateDatabaseReads()); + expect(result.current.data).toBeNull(); + expect(result.current.error).toMatchObject({ code: "NOT_FOUND" }); + }); + + test("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)); + + 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..89fc0cf51 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -0,0 +1,515 @@ +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" } }); + // @ts-expect-error a keyless list needs an explicit order + useDatabaseList("sessions"); + // @ts-expect-error keyless lists cannot sort by zero columns + useDatabaseList("sessions", { order: {} }); + useDatabaseList("posts", { where: { total: { gt: "9007199254740993" } } }); + void [title, total, failed, owner, bodies, deep, gatedTitle]; + } + + export function DynamicSelections() { + const columns: ("id" | "title")[] = ["id"]; + const list = useDatabaseList("posts", { select: columns }); + const record = useDatabaseRecord("posts", 7, { select: columns }); + if (list.data && record.data) { + const maybeTitle: string | undefined = list.data.items[0].title; + // @ts-expect-error a dynamic array does not guarantee every possible column + const listTitle: string = list.data.items[0].title; + // @ts-expect-error the record follows the same dynamic projection rule + const recordId: number = record.data.id; + void [maybeTitle, listTitle, recordId]; + } + } + + export function RejectedLists() { + // @ts-expect-error entities are generated table names + useDatabaseList("missing"); + // @ts-expect-error private columns are absent from public rows + useDatabaseList("users").data?.items[0]?.secret; + // @ts-expect-error private columns are not HTTP filters, even beside a valid one + useDatabaseList("users", { where: { name: "Ada", secret: "token" } }); + // @ts-expect-error private columns are not selectable + useDatabaseList("users", { select: ["slug", "secret"] }); + // @ts-expect-error JSON columns are not queryable + useDatabaseList("posts", { where: { payload: { eq: 1 } } }); + // @ts-expect-error a to-one relation is one row, not an array + useDatabaseList("posts", { include: { users: true } }).data?.items[0]?.users?.length; + // @ts-expect-error a to-one include takes no limit + useDatabaseList("posts", { include: { users: { limit: 1 } } }); + // @ts-expect-error includes stop at two relation edges + useDatabaseList("users", { include: { posts: { include: { comments: { include: { posts: true } } } } } }); + // @ts-expect-error list params are the generated route's parameters only + useDatabaseList("users", { limit: 1, includeTotal: true }); + } + + export function Records(boardId: number | undefined) { + const post = useDatabaseRecord("posts", 7, { include: { comments: { limit: 20 } } }); + const body: string | undefined = post.data?.comments[0]?.body; + const title: string | undefined = post.data?.title; + const user = useDatabaseRecord("users", "ada", { select: ["name"] }); + const name: string | undefined = user.data?.name; + // A null or undefined id waits without a request. + useDatabaseRecord("posts", null); + useDatabaseRecord("posts", boardId); + // A bigint key is addressed by its decimal string, a safe integer, or a bigint. + const seq: string | undefined = useDatabaseRecord("ledger", "9007199254740993").data?.seq; + useDatabaseRecord("ledger", 10); + useDatabaseRecord("ledger", 10n); + void [body, title, name, seq]; + } + + export function RejectedRecords() { + // @ts-expect-error keyless entities have no detail route + useDatabaseRecord("events", "x"); + // @ts-expect-error a private key is not addressable over HTTP + useDatabaseRecord("sessions", "token"); + // @ts-expect-error the id has the public key's type + useDatabaseRecord("posts", "7"); + // @ts-expect-error record params are select and include only + useDatabaseRecord("posts", 7, { where: { id: 7 } }); + // @ts-expect-error private columns are not selectable on a record + useDatabaseRecord("users", "ada", { select: ["secret"] }); + // @ts-expect-error unselected columns are absent + useDatabaseRecord("users", "ada", { select: ["name"] }).data?.slug; + } + + export async function client() { + const post = await databaseApi.get("posts", 7, { select: ["id", "title"] }); + const title: string = post.title; + // @ts-expect-error unselected columns are absent + post.total; + // @ts-expect-error keyless entities have no detail route + await databaseApi.get("events", "x"); + // @ts-expect-error record params are the generated route's parameters only + await databaseApi.get("posts", 7, { select: ["id"], limit: 1 }); + void title; + } + `); + + expect(diagnostics).toEqual([]); +}); + +test("serialized() replaces the row type and keeps entity, id, and params checked", () => { + const diagnostics = compileTypeProbe(` + import { + type DatabaseListPage, + type UseDatabaseListResult, + 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: 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; + + const card = useDatabaseRecord("posts", 7, undefined, { + shape: serialized(), + enabled: false, + }); + const count: number | undefined = card.data?.comment_count; + void [typed, page, 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("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"; + import { + type DatabaseInsert, + type DatabaseRow, + useDatabaseCreate, + useDatabaseDelete, + useDatabaseUpdate, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + export function Writes(existing: DatabaseRow<"posts">) { + const posts = useDatabaseCreate("posts"); + // A bigint input travels as a decimal string or a safe integer. + posts.create({ user_slug: "ada", title: "Hi", total: "9007199254740993" }); + posts.create({ user_slug: "ada", title: "Hi", total: 1, payload: { tags: ["a"], n: 1 } }); + const created: Promise | null> = posts.create({ user_slug: "ada", title: "Hi", total: 1 }); + // The answered row carries a bigint as its decimal string. + const total: string | undefined = posts.data?.total; + const values: DatabaseInsert<"posts"> = { user_slug: "ada", title: "Hi", total: "1" }; + posts.create(values); + useDatabaseCreate("events").create({ message: "keyless tables accept creates" }); + useDatabaseCreate("sessions").create({ user_slug: "ada" }); + useDatabaseCreate("posts", { invalidate: ["posts", "users"] }); + useDatabaseCreate("posts", { invalidate: false }); + useDatabaseCreate("posts", { + onSuccess: (row, values) => { + const id: number = row.id; + const title: string = values.title; + void [id, title]; + }, + onError: (error, values) => { + const code: string = error.code; + void [code, values.user_slug]; + }, + }); + useDatabaseUpdate("posts", { + onSuccess: (row, id, values) => { + const title: string = row.title; + const key: number = id; + const next: string | undefined = values.title; + void [title, key, next]; + }, + }); + useDatabaseDelete("ledger", { + onSuccess: (id) => { + const seq: string | number | bigint = id; + void seq; + }, + onError: (error, id) => void [error.status, id], + }); + + const edit = useDatabaseUpdate("posts"); + const updated: Promise | null> = edit.update(7, { title: "New", total: "2" }); + edit.update(7, { payload: null }); + useDatabaseUpdate("ledger").update("9007199254740993", { note: "x" }); + useDatabaseUpdate("ledger").update(10n, { note: "x" }); + useDatabaseUpdate("users").update("ada", { name: "Ada" }); + + const removal = useDatabaseDelete("posts"); + const removed: Promise = removal.remove(7); + const loading: boolean = removal.loading; + void [created, total, updated, removed, loading, existing]; + } + + export function RejectedWrites(existing: DatabaseRow<"posts">) { + const posts = useDatabaseCreate("posts"); + // @ts-expect-error entities are generated table names + useDatabaseCreate("missing"); + // @ts-expect-error a field the public insert lacks is refused, even beside valid ones + posts.create({ user_slug: "ada", title: "Hi", total: 1, secret: "token" }); + // @ts-expect-error a generated key is not an HTTP input + posts.create({ id: 1, user_slug: "ada", title: "Hi", total: 1 }); + // @ts-expect-error a spread cannot carry a read-only field along + posts.create({ ...existing, title: "Copy" }); + // @ts-expect-error required insert fields stay required + posts.create({ title: "Hi" }); + // @ts-expect-error values keep their column types + posts.create({ user_slug: "ada", title: 1, total: 1 }); + // @ts-expect-error invalidation names generated tables + useDatabaseCreate("posts", { invalidate: ["missing"] }); + // @ts-expect-error a create answers the public row, which has no private column + useDatabaseCreate("users", { onSuccess: (row) => row.secret }); + // @ts-expect-error a delete reports the id, not a row + useDatabaseDelete("posts", { onSuccess: (id) => id.title }); + + // @ts-expect-error keyless entities have no update route + useDatabaseUpdate("events"); + // @ts-expect-error a private key is not addressable over HTTP + useDatabaseUpdate("sessions"); + // @ts-expect-error the id has the public key's type + useDatabaseUpdate("posts").update("7", { title: "x" }); + // @ts-expect-error keys are not updatable + useDatabaseUpdate("posts").update(7, { id: 8 }); + // @ts-expect-error insert-only fields are not updatable + useDatabaseUpdate("users").update("ada", { slug: "grace" }); + + // @ts-expect-error keyless entities have no delete route + useDatabaseDelete("events"); + // @ts-expect-error the id has the public key's type + useDatabaseDelete("posts").remove("7"); + // @ts-expect-error a delete answers no row + useDatabaseDelete("posts").data; + } + + export async function client() { + const post = await databaseApi.create("posts", { user_slug: "ada", title: "Hi", total: "1" }); + const id: number = post.id; + const total: string = post.total; + await databaseApi.update("posts", 7, { title: "New" }); + const removed: void = await databaseApi.remove("posts", 7); + // @ts-expect-error a field the public insert lacks is refused + await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); + // @ts-expect-error keyless entities have no update route + await databaseApi.update("events", "x", { message: "y" }); + // @ts-expect-error keyless entities have no delete route + await databaseApi.remove("events", "x"); + // @ts-expect-error update values are the public update facet only + await databaseApi.update("posts", 7, { title: "New", user_slug: "grace" }); + void [id, total, removed]; + } + `); + + expect(diagnostics).toEqual([]); +}); + +test("no entity exists before typegen binds the registry", () => { + const diagnostics = compileTypeProbe(` + import { + useDatabaseCreate, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + export function Unbound() { + // @ts-expect-error the empty registry binds no entity + useDatabaseList("notes"); + // @ts-expect-error the empty registry binds no keyed entity + useDatabaseRecord("notes", 1); + // @ts-expect-error the empty registry binds no entity to write + useDatabaseCreate("notes"); + } + `); + + expect(diagnostics).toEqual([]); +}); diff --git a/packages/appkit-ui/src/react/hooks/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 new file mode 100644 index 000000000..233cc6053 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -0,0 +1,208 @@ +import { getPluginClientConfig } from "@/js/config"; +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, a table list for the reads that touch those tables, `false` for none. + */ +export type DatabaseInvalidation = boolean | readonly DatabaseEntity[]; + +/** + * Shared in-flight read store for the database hooks: an instance of the + * generic {@link createRequestStore} lifecycle wired to the database client's + * transport. Hook instances that resolve to the same URL share one request and + * one snapshot while any of them is mounted. + * + * Nothing outlives its last subscriber, so this deduplicates reads; it is not + * a cache. + */ + +/** Immutable per-key read state; mirrors the hooks' public result shape. */ +interface DatabaseReadSnapshot { + data: unknown; + loading: boolean; + error: DatabaseApiError | null; +} + +/** Snapshot for keys with no live entry. Referentially stable. */ +export const IDLE_DATABASE_READ: DatabaseReadSnapshot = Object.freeze({ + data: null, + loading: false, + error: null, +}); + +/** Checks a decoded body is the envelope the read's route answers with. */ +type ResponseGuard = (body: unknown) => body is object; + +/** + * The tables one read shows rows of: the one it is rooted at and every table + * its includes reach. `open` marks a read with an include the server did not + * describe, which any scoped invalidation restarts rather than risk missing. + */ +interface DatabaseReadScope { + readonly tables: ReadonlySet; + readonly open: boolean; +} + +/** `{ table: { relation: targetTable } }`, as `DatabasePlugin` publishes it. */ +type PublishedRelations = Record>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function publishedRelations(): PublishedRelations { + const { relations } = getPluginClientConfig<{ relations?: unknown }>( + "database", + ); + return isRecord(relations) ? (relations as PublishedRelations) : {}; +} + +/** + * Walk a read's include tree through the relations the server published, so + * a write to `notes` can find a `boards` read that includes its notes. + */ +export function databaseReadScope( + entity: string, + include: unknown, +): DatabaseReadScope { + const relations = publishedRelations(); + const tables = new Set([entity]); + let open = false; + + const walk = (table: string, tree: unknown): void => { + if (!isRecord(tree)) return; + const edges = relations[table]; + for (const [name, options] of Object.entries(tree)) { + const target = + isRecord(edges) && Object.hasOwn(edges, name) ? edges[name] : undefined; + if (typeof target !== "string") { + open = true; + continue; + } + tables.add(target); + if (isRecord(options)) walk(target, options.include); + } + }; + walk(entity, include); + + return { tables, open }; +} + +/** Anything a request rejects with other than an abort, as the hooks report it. */ +export function asDatabaseApiError(error: unknown): DatabaseApiError { + if (error instanceof DatabaseApiError) return error; + return new DatabaseApiError( + "INTERNAL", + null, + error instanceof Error ? error.message : "Database request failed", + [], + { cause: error }, + ); +} + +/** Resolves once `signal` aborts, even if the transport ignores it. */ +function whenAborted(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +/** + * Build the runner for one read. A restart keeps the last result visible + * while it loads, and a run superseded by a restart or torn down after its + * last release never patches the entry. A `NOT_FOUND` drops the last result: + * the row is gone, so showing it beside the error would mislead. The returned + * promise settles once the run has patched its outcome or was aborted. + */ +function runDatabaseRead( + url: string, + accept: ResponseGuard, +): RequestRunner { + return ({ signal, patch }) => { + patch({ loading: true, error: null }); + const outcome = requestDatabase( + url, + { method: "GET", signal }, + accept, + ).then( + (data) => { + if (!signal.aborted) patch({ data, loading: false, error: null }); + }, + (cause: unknown) => { + if (signal.aborted) return; + const error = asDatabaseApiError(cause); + patch( + error.code === "NOT_FOUND" + ? { data: null, loading: false, error } + : { loading: false, error }, + ); + }, + ); + return Promise.race([outcome, whenAborted(signal)]); + }; +} + +const store = createRequestStore( + IDLE_DATABASE_READ, +); + +/** + * Register a subscriber for the read of `url`, starting it on first use. + * `scope` is kept from the first subscriber; it is derived from the same + * params as `url`, so every subscriber of one URL has the same scope. + * Returns a `release` function that must be called on unmount. + */ +export function retainDatabaseRead( + url: string, + accept: ResponseGuard, + scope: DatabaseReadScope, +): () => void { + return store.retain(url, runDatabaseRead(url, accept), { meta: scope }); +} + +/** + * Restart mounted database reads after a write the hooks did not make, such + * as a `databaseApi` call or a custom route that changes rows. The write hooks + * call this on success with their `invalidate` option. + * + * `true` (the default) restarts every read. A table list restarts the reads + * that show rows of those tables: reads rooted at them, and reads whose + * includes reach them through the relations the server published. `false` + * restarts none. + * + * Resolves once the current runs of those reads have answered, failed, or + * been torn down. A superseding refresh is followed rather than counted as + * complete; it never rejects. + * + * @example + * ```ts + * await fetch(`/api/boards/${boardId}/archive`, { method: "POST" }); + * await invalidateDatabaseReads(["boards", "notes"]); + * ``` + */ +export function invalidateDatabaseReads( + scope: DatabaseInvalidation = true, +): Promise { + if (scope === false) return Promise.resolve(); + if (scope === true) return store.restartStarted(); + const written = new Set(scope); + if (written.size === 0) return Promise.resolve(); + return store.restartStarted((_url, read) => { + if (read === undefined || read.open) return true; + for (const table of read.tables) if (written.has(table)) return true; + return false; + }); +} + +export const startDatabaseRead = store.start; +export const subscribeDatabaseRead = store.subscribe; +export const getDatabaseReadSnapshot = store.getSnapshot; + +/** Test-only: abort every in-flight read and clear the store. */ +export const resetDatabaseRequestStore = store.reset; diff --git a/packages/appkit-ui/src/react/hooks/request-store.ts b/packages/appkit-ui/src/react/hooks/request-store.ts index 485a0f7fe..35ed6717a 100644 --- a/packages/appkit-ui/src/react/hooks/request-store.ts +++ b/packages/appkit-ui/src/react/hooks/request-store.ts @@ -24,39 +24,69 @@ 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. */ +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 subscribed entry that has run at least once and that + * `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, + ): 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; /** True once `start` has run at least once; guards re-run on late `retain`. */ started: boolean; + pending: Promise | null; 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. @@ -68,9 +98,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; @@ -78,7 +109,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) { @@ -86,6 +117,36 @@ export function createRequestStore(idle: S): RequestStore { notify(key); }, }); + // A runner must not reject; guard anyway so a restart never does. + 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 { + void run(key); } function release(key: string): void { @@ -105,16 +166,18 @@ 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, + pending: null, + run: runner, }; entries.set(key, entry); } @@ -135,6 +198,22 @@ export function createRequestStore(idle: S): RequestStore { start, + 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 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) { 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..b64b23029 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-create.ts @@ -0,0 +1,88 @@ +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, + DatabaseRow, +} from "@/js/database/types"; + +import { + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, + useDatabaseWrite, +} from "./use-database-write"; + +/** Options for {@link useDatabaseCreate}. */ +export interface UseDatabaseCreateOptions< + K extends DatabaseEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful create, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: (row: DatabaseRow, values: DatabaseInsert) => void; + /** Called for every failed create, with the error `error` also reports. */ + onError?: (error: DatabaseApiError, values: DatabaseInsert) => void; +} + +/** What {@link useDatabaseCreate} returns. */ +export interface UseDatabaseCreateResult< + K extends DatabaseEntity, +> extends UseDatabaseWriteState> { + /** + * Send `POST /api/database/` and resolve with the created row, or + * with `null` when the write failed; the reason is in `error`. It never + * rejects, so a handler needs no `try/catch`. + */ + create>( + values: V & ExactDatabaseParams>, + ): Promise | null>; + /** Return to the idle state; a call in flight no longer reports here. */ + reset(): void; +} + +/** + * Create rows through `POST /api/database/`. Values are typed from the + * generated `database.d.ts`, so private, generated, and undeclared fields are + * compile errors. Once a create succeeds, mounted database reads restart, so + * lists and includes that show the new row refresh without a manual refetch. + * + * @param entity - A table the generated registry exposes + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call + * @returns `create`, the latest call's row, loading and error state, and `reset` + * + * @example + * ```tsx + * const notes = useDatabaseCreate("notes", { + * onError: (error) => toast(error.message), + * }); + * + * async function add(body: string) { + * const note = await notes.create({ board_id: boardId, author: "ada", body }); + * if (note) setDraft(""); + * } + * ``` + */ +export function useDatabaseCreate( + entity: K, + options: UseDatabaseCreateOptions = {}, +): UseDatabaseCreateResult { + const send = useCallback( + (values: DatabaseInsert) => createDatabaseRow(entity, values as object), + [entity], + ); + const { onSuccess, onError } = options; + const { mutate, ...write } = useDatabaseWrite(send, { + invalidate: options.invalidate, + // The server projected the row it holds; the types describe that wire. + onSuccess: + onSuccess && + ((row, [values]) => onSuccess(row as DatabaseRow, values)), + onError: onError && ((error, [values]) => onError(error, values)), + }); + return { ...write, create: mutate } as UseDatabaseCreateResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-delete.ts b/packages/appkit-ui/src/react/hooks/use-database-delete.ts new file mode 100644 index 000000000..93faf1353 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-delete.ts @@ -0,0 +1,83 @@ +import { useCallback } from "react"; + +import { deleteDatabaseRow } from "@/js/database/client"; +import type { DatabaseApiError } from "@/js/database/errors"; +import type { DatabaseId, DatabaseKeyedEntity } from "@/js/database/types"; + +import { + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, + useDatabaseWrite, +} from "./use-database-write"; + +/** Options for {@link useDatabaseDelete}. */ +export interface UseDatabaseDeleteOptions< + K extends DatabaseKeyedEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful delete, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: (id: DatabaseId) => void; + /** Called for every failed delete, with the error `error` also reports. */ + onError?: (error: DatabaseApiError, id: DatabaseId) => void; +} + +/** What {@link useDatabaseDelete} returns. A delete answers no row. */ +export interface UseDatabaseDeleteResult< + K extends DatabaseKeyedEntity, +> extends Omit, "data"> { + /** + * Send `DELETE /api/database//:id` and resolve `true` once the row + * is deleted, or `false` when the write failed; the reason is in `error`, + * and a missing row is `NOT_FOUND`. It never rejects. + */ + remove(id: DatabaseId): Promise; + /** Return to the idle state; a call in flight no longer reports here. */ + reset(): void; +} + +/** + * Delete rows through `DELETE /api/database//:id`. Only entities with + * a public primary key have this route. Once a delete succeeds, mounted + * database reads restart, so lists that showed the row drop it and a record + * read of it reports `NOT_FOUND` with no `data`. + * + * @param entity - A table the generated registry exposes with a public key + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call + * @returns `remove`, loading and error state, and `reset` + * + * @example + * ```tsx + * const notes = useDatabaseDelete("notes"); + * + * ; + * ``` + */ +export function useDatabaseDelete( + entity: K, + options: UseDatabaseDeleteOptions = {}, +): UseDatabaseDeleteResult { + // A delete answers no row, so success is the only result worth reporting. + const send = useCallback( + async (id: DatabaseId) => { + await deleteDatabaseRow(entity, id); + return true as const; + }, + [entity], + ); + const { onSuccess, onError } = options; + const { mutate, reset, loading, error } = useDatabaseWrite(send, { + invalidate: options.invalidate, + onSuccess: onSuccess && ((_deleted, [id]) => onSuccess(id)), + onError: onError && ((failure, [id]) => onError(failure, id)), + }); + const remove = useCallback( + async (id: DatabaseId) => (await mutate(id)) === true, + [mutate], + ); + return { remove, reset, loading, error }; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts new file mode 100644 index 000000000..3831cbf5b --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -0,0 +1,101 @@ +import { + type DatabaseListPage, + type DatabaseListQuery, + DatabaseQueryEncodingError, + encodeDatabaseListQuery, + type ExactDatabaseParams, +} from "shared"; + +import { isDatabaseListPage } from "@/js/database/client"; +import type { + DatabaseEntity, + DatabaseKeyedEntity, + DatabaseListParams, + DatabaseListRow, +} from "@/js/database/types"; + +import { + type DatabaseReadRequest, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, + useDatabaseRead, +} from "./use-database-read"; + +/** Options for {@link useDatabaseList}. */ +export type UseDatabaseListOptions = UseDatabaseReadOptions; + +/** What {@link useDatabaseList} returns: one page of rows and its state. */ +export type UseDatabaseListResult = UseDatabaseReadResult< + DatabaseListPage +>; + +/** + * Subscribe to one page of `GET /api/database/`. Entity, params, and + * rows are typed from the generated `database.d.ts`; the route comes from the + * endpoints the server published. + * + * Mounted hooks whose params encode to the same query share one request, so + * an inline params literal does not refetch on every render. The request is + * aborted once its last subscriber unmounts. + * + * @param entity - A table the generated registry exposes + * @param params - `where`, `order`, `select`, `include`, `limit`, `offset`; + * `null` holds the hook idle, for params that depend on another read + * @param options - `enabled`, `keepPreviousData`, and `shape` + * @returns The page, loading and error state, and `refetch` + * + * @example + * ```tsx + * const notes = useDatabaseList( + * "notes", + * board ? { where: { board_id: board.id }, limit: 20, offset } : null, + * { keepPreviousData: true }, + * ); + * notes.data?.items.map((note) => note.body); + * ``` + */ +export function useDatabaseList< + K extends DatabaseKeyedEntity, + Row = DatabaseListRow, +>( + entity: K, + params?: undefined, + options?: UseDatabaseListOptions, +): UseDatabaseListResult; +export function useDatabaseList< + K extends DatabaseEntity, + const P extends DatabaseListParams, + Row = DatabaseListRow, +>( + entity: K, + params: (P & ExactDatabaseParams>) | null, + options?: UseDatabaseListOptions, +): UseDatabaseListResult; +export function useDatabaseList( + entity: string, + params?: DatabaseListQuery | null, + options: UseDatabaseListOptions = {}, +): UseDatabaseListResult { + const enabled = (options.enabled ?? true) && params !== null; + // Encode only an enabled read: a held read's params may still be incomplete. + let query: DatabaseReadRequest["query"] = ""; + if (enabled) { + try { + query = encodeDatabaseListQuery(params ?? {}); + } catch (error) { + query = error instanceof DatabaseQueryEncodingError ? error : null; + } + } + const read = useDatabaseRead({ + entity, + operation: "list", + id: undefined, + query, + include: params?.include, + enabled, + accept: isDatabaseListPage, + keepPreviousData: options.keepPreviousData ?? false, + 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 new file mode 100644 index 000000000..1f69e7abe --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -0,0 +1,282 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; +import { DatabaseQueryEncodingError } from "shared"; + +import { + type DatabaseOperation, + type IdLike, + resolveDatabaseUrl, +} from "@/js/database/client"; +import { DatabaseApiError, invalidDatabaseQuery } from "@/js/database/errors"; + +import { + databaseReadScope, + 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, 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: to check each row, pass a parse function as `shape` instead. + * + * @example + * ```typescript + * interface NoteCard { id: number; excerpt: string } + * + * const notes = useDatabaseList("notes", { limit: 20 }, { + * shape: serialized(), + * }); + * notes.data?.items[0]?.excerpt; + * ``` + */ +export function serialized(): DatabaseShape { + return SERIALIZED as DatabaseShape; +} + +/** + * 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; + /** + * 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 UseDatabaseReadResult { + /** + * The last successful response, or `null` before one arrives. A refetch + * keeps it visible while it loads and after it fails, except a `NOT_FOUND`, + * which clears it. + */ + data: T | null; + /** Whether a request for the current params is in flight. */ + loading: boolean; + /** + * Why the latest request failed: `NOT_EXPOSED` when no route exists, + * `INVALID_REQUEST` for params or an id that cannot be sent, `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; + /** Encoded params, a known validation failure, or `null` for an unknown failure. */ + query: string | DatabaseQueryEncodingError | null; + /** The params' include tree, to find the tables the read shows. */ + include: unknown; + enabled: boolean; + accept: (body: unknown) => body is object; + keepPreviousData: boolean; + 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, + * and params that cannot be encoded to a stable `INVALID_REQUEST`. + */ +export function useDatabaseRead({ + entity, + operation, + id, + query, + include, + enabled, + accept, + 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 (encodedQuery === null) { + const cause = + parameter !== undefined && message !== undefined + ? new DatabaseQueryEncodingError(parameter, message) + : undefined; + return { error: invalidDatabaseQuery(cause) }; + } + try { + return { url: resolveDatabaseUrl(entity, operation, id, encodedQuery) }; + } catch (error) { + if (error instanceof DatabaseApiError) return { error }; + throw error; + } + }, [enabled, entity, operation, id, encodedQuery, parameter, message]); + + const url = route !== null && "url" in route ? route.url : null; + const routeError = route !== null && "error" in route ? route.error : null; + + // Every subscriber of one URL encodes the same include, so the URL is the + // include tree's identity; the object passed in may be a fresh literal. + const scope = useMemo( + () => (url === null ? null : databaseReadScope(entity, include)), + [url], + ); + + const subscribe = useCallback( + (listener: () => void) => + url === null ? noop : subscribeDatabaseRead(url, listener), + [url], + ); + const getSnapshot = useCallback( + () => (url === null ? IDLE_DATABASE_READ : getDatabaseReadSnapshot(url)), + [url], + ); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + // The first subscriber of a URL starts the request; later ones share it. + useEffect(() => { + if (url === null || scope === null) return; + return retainDatabaseRead(url, accept, scope); + }, [url, accept, scope]); + + const refetch = useCallback(() => { + if (url !== null) startDatabaseRead(url); + }, [url]); + + 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 new file mode 100644 index 000000000..e8b280aa8 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -0,0 +1,85 @@ +import { + DatabaseQueryEncodingError, + encodeDatabaseRecordQuery, + type ExactDatabaseParams, +} from "shared"; + +import { isDatabaseRow } from "@/js/database/client"; +import type { + DatabaseId, + DatabaseKeyedEntity, + DatabaseRecordParams, + DatabaseRecordRow, +} from "@/js/database/types"; + +import { + type DatabaseReadRequest, + type UseDatabaseReadOptions, + type UseDatabaseReadResult, + useDatabaseRead, +} from "./use-database-read"; + +/** Options for {@link useDatabaseRecord}. */ +export type UseDatabaseRecordOptions = UseDatabaseReadOptions; + +/** What {@link useDatabaseRecord} returns: one row and its state. */ +export type UseDatabaseRecordResult = UseDatabaseReadResult; + +/** + * Subscribe to one row from `GET /api/database//:id`. Only entities + * with a public primary key have this route; a missing row surfaces as a + * `NOT_FOUND` error, and a refetch that finds the row gone clears `data`. + * + * A `null` or `undefined` id holds the hook idle without a request, so a + * record that depends on another read needs no separate `enabled` flag. An + * empty, `"."`, or `".."` id reports `INVALID_REQUEST` without a request. + * + * @param entity - A table the generated registry exposes with a public key + * @param id - The row's public key, or `null`/`undefined` to wait + * @param params - `select` and `include`; pass `{}` to reach `options` alone + * @param options - `enabled`, `keepPreviousData`, and `shape` + * @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: UseDatabaseRecordOptions = {}, +): UseDatabaseRecordResult { + const enabled = (options.enabled ?? true) && id !== null && id !== undefined; + const recordParams = (params ?? {}) as { include?: unknown }; + // Encode only an enabled read: a held read's params may still be incomplete. + let query: DatabaseReadRequest["query"] = ""; + if (enabled) { + try { + query = encodeDatabaseRecordQuery(recordParams); + } catch (error) { + query = error instanceof DatabaseQueryEncodingError ? error : null; + } + } + const read = useDatabaseRead({ + entity, + operation: "detail", + id: id ?? undefined, + query, + include: recordParams.include, + enabled, + accept: isDatabaseRow, + keepPreviousData: options.keepPreviousData ?? false, + shape: options.shape as UseDatabaseReadOptions["shape"], + }); + // The server projected and encoded the row; the types describe that wire. + return read as UseDatabaseRecordResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-update.ts b/packages/appkit-ui/src/react/hooks/use-database-update.ts new file mode 100644 index 000000000..4d347f11e --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-update.ts @@ -0,0 +1,96 @@ +import { useCallback } from "react"; +import type { ExactDatabaseParams } from "shared"; + +import { updateDatabaseRow } from "@/js/database/client"; +import type { DatabaseApiError } from "@/js/database/errors"; +import type { + DatabaseId, + DatabaseKeyedEntity, + DatabaseRow, + DatabaseUpdate, +} from "@/js/database/types"; + +import { + type UseDatabaseWriteOptions, + type UseDatabaseWriteState, + useDatabaseWrite, +} from "./use-database-write"; + +/** Options for {@link useDatabaseUpdate}. */ +export interface UseDatabaseUpdateOptions< + K extends DatabaseKeyedEntity, +> extends UseDatabaseWriteOptions { + /** + * Called for every successful update, once the restarted reads reloaded, + * even if the component unmounted meanwhile. + */ + onSuccess?: ( + row: DatabaseRow, + id: DatabaseId, + values: DatabaseUpdate, + ) => void; + /** Called for every failed update, with the error `error` also reports. */ + onError?: ( + error: DatabaseApiError, + id: DatabaseId, + values: DatabaseUpdate, + ) => void; +} + +/** What {@link useDatabaseUpdate} returns. */ +export interface UseDatabaseUpdateResult< + K extends DatabaseKeyedEntity, +> extends UseDatabaseWriteState> { + /** + * Send `PATCH /api/database//:id` and resolve with the updated row, + * or with `null` when the write failed; the reason is in `error`, and a + * missing row is `NOT_FOUND`. It never rejects. + */ + update>( + id: DatabaseId, + values: V & ExactDatabaseParams>, + ): Promise | null>; + /** Return to the idle state; a call in flight no longer reports here. */ + reset(): void; +} + +/** + * Update rows through `PATCH /api/database//:id`. Only entities with a + * public primary key have this route, and keys, generated, and + * default-stamped columns are not updatable. Once an update succeeds, mounted + * database reads restart. + * + * @param entity - A table the generated registry exposes with a public key + * @param options - `invalidate` to narrow the read restart; `onSuccess` and + * `onError` to observe every call + * @returns `update`, the latest call's row, loading and error state, and `reset` + * + * @example + * ```tsx + * const notes = useDatabaseUpdate("notes"); + * + * ; + * ``` + */ +export function useDatabaseUpdate( + entity: K, + options: UseDatabaseUpdateOptions = {}, +): UseDatabaseUpdateResult { + const send = useCallback( + (id: DatabaseId, values: DatabaseUpdate) => + updateDatabaseRow(entity, id, values as object), + [entity], + ); + const { onSuccess, onError } = options; + const { mutate, ...write } = useDatabaseWrite(send, { + invalidate: options.invalidate, + // The server projected the row it holds; the types describe that wire. + onSuccess: + onSuccess && + ((row, [id, values]) => onSuccess(row as DatabaseRow, id, values)), + onError: onError && ((error, [id, values]) => onError(error, id, values)), + }); + return { ...write, update: mutate } as UseDatabaseUpdateResult; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-write.ts b/packages/appkit-ui/src/react/hooks/use-database-write.ts new file mode 100644 index 000000000..d4552865d --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-write.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { DatabaseApiError } from "@/js/database/errors"; + +import { + asDatabaseApiError, + type DatabaseInvalidation, + invalidateDatabaseReads, +} from "./database-request-store"; + +export type { DatabaseInvalidation } from "./database-request-store"; + +/** Latest state of a database write hook. */ +export interface UseDatabaseWriteState { + /** The latest call's result, or `null` before it answers. */ + data: T | null; + /** + * Whether the latest call is in flight, including the reload of the reads + * it restarts. + */ + loading: boolean; + /** + * Why the latest call failed: `NOT_EXPOSED` when no route exists, + * `OUTCOME_UNKNOWN` when it may have committed without an answer. + */ + error: DatabaseApiError | null; +} + +/** Options every database write hook takes. */ +export interface UseDatabaseWriteOptions { + /** + * Reads to restart once a write succeeds. Default `true`: every mounted + * database read, since a 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 UseDatabaseWriteState { + mutate(...args: Args): Promise; + reset(): void; +} + +const IDLE: UseDatabaseWriteState = Object.freeze({ + data: null, + loading: false, + error: null, +}); + +/** + * Run a caller's callback without letting it break the write's promise: the + * call still resolves, and the exception still reaches the page's error + * handling (and any error tracker) as an uncaught error. + */ +function runCallback(callback: () => void): void { + try { + callback(); + } catch (error) { + if (typeof globalThis.reportError === "function") { + globalThis.reportError(error); + } else { + setTimeout(() => { + throw error; + }); + } + } +} + +/** + * One write hook's state around `send`, which must be stable per entity. + * + * A call never rejects, like `useServingInvoke`: it resolves with the result, + * or with `null` once the failure is in `error`, so a handler needs no + * `try/catch`. Callers that want an exception use `databaseApi` directly. + * + * A write is never aborted: cancelling the request would not undo a committed + * transaction. A successful call restarts the reads `invalidate` names and + * resolves once they have reloaded, so a handler that clears its form sees the + * new rows already on screen. Only the latest call of a hook updates its + * state; callbacks run for every call, even after the hook unmounts, since the + * rows did change. + */ +export function useDatabaseWrite( + send: (...args: Args) => Promise, + options: WriteCallbacks, +): DatabaseWrite { + const [state, setState] = useState>(IDLE); + const latest = useRef(null); + + // Read at call time, so inline callbacks and an inline `invalidate` list do + // not change `mutate`'s identity on every render. + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }); + + const mutate = useCallback( + async (...args: Args): Promise => { + const call = Symbol("database write"); + latest.current = call; + // React ignores a state update after unmount, so only staleness matters. + const settle = (next: UseDatabaseWriteState) => { + if (latest.current === call) setState(next); + }; + + settle({ data: null, loading: true, error: null }); + let data: T; + try { + data = await send(...args); + } catch (cause) { + const error = asDatabaseApiError(cause); + settle({ data: null, loading: false, error }); + const { onError } = optionsRef.current; + if (onError) runCallback(() => onError(error, args)); + return null; + } + // Hold `loading` until the restarted reads answer, so the hook does not + // report success beside rows that still show the old state. + await invalidateDatabaseReads(optionsRef.current.invalidate ?? true); + settle({ data, loading: false, error: null }); + const { onSuccess } = optionsRef.current; + if (onSuccess) runCallback(() => onSuccess(data, args)); + return data; + }, + [send], + ); + + // A call still in flight keeps running, but no longer reports here. + const reset = useCallback(() => { + latest.current = null; + setState(IDLE); + }, []); + + return { ...state, mutate, reset }; +} diff --git a/packages/appkit/src/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");