diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index dce640f45..8b5709d35 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -22,6 +22,11 @@ export type { DatabaseKeyedEntity, DatabaseListParams, DatabaseListRow, + DatabaseMutationName, + DatabaseOperationInput, + DatabaseOperationName, + DatabaseOperationOutput, + DatabaseQueryName, DatabaseRecordParams, DatabaseRecordRow, DatabaseRow, diff --git a/packages/appkit-ui/src/js/database/client.http.test.ts b/packages/appkit-ui/src/js/database/client.http.test.ts new file mode 100644 index 000000000..8726b701a --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.http.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment node +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, + vi, +} from "vitest"; + +import { _resetConfigCache } from "../config"; +import { databaseApi as typedApi } from "./client"; +import { DATABASE_ACTOR_HEADER, resetDatabaseSession } from "./session"; + +// Real HTTP against a local stub that follows the operation transport +// contract: one JSON `input` query value for GET, a JSON body for writes, the +// actor header on authenticated replies, 204 for no body, and 401 without a +// session. It is not the plugin's handler, which ships with the runtime. +const databaseApi = typedApi as unknown as { + execute( + name: string, + input: unknown, + options?: { signal?: AbortSignal }, + ): Promise; +}; + +interface Seen { + method: string; + path: string; + query: string[]; + input: unknown; + body: unknown; + headers: http.IncomingHttpHeaders; +} + +const seen: Seen[] = []; +/** Replies the test releases by hand, keyed by the input's `id`. */ +const held = new Map void>(); +let actor: string | null = "actor-a"; +let origin = ""; + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://stub"); + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + const query = [...url.searchParams.keys()]; + const input = + req.method === "GET" + ? JSON.parse(url.searchParams.get("input") ?? "null") + : text + ? JSON.parse(text) + : undefined; + seen.push({ + method: req.method ?? "", + path: url.pathname, + query, + input, + body: text || undefined, + headers: req.headers, + }); + const send = (status: number, body: unknown, as: string | null) => { + if (as !== null) res.setHeader(DATABASE_ACTOR_HEADER, as); + res.setHeader("Cache-Control", "no-store"); + if (body === undefined) { + res.writeHead(status).end(); + return; + } + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (actor === null) return send(401, { error: "Sign in" }, null); + const name = decodeURIComponent(url.pathname.split("/").pop() ?? ""); + if (name === "notes.get") { + const id = (input as { id: number }).id; + if (id >= 100) { + held.set(id, (as) => send(200, { id, as }, as)); + return; + } + return send(200, { id, input }, actor); + } + if (name === "notes.huge") { + res.setHeader(DATABASE_ACTOR_HEADER, actor); + res.writeHead(200, { "Content-Type": "application/json" }); + const block = Buffer.alloc(1024 * 1024, 32); + for (let i = 0; i < 17; i += 1) res.write(block); + return res.end("{}"); + } + if (name === "notes.create") + return send(201, { id: 7, ...(input as object) }, actor); + if (name === "notes.delete") return send(204, undefined, actor); + return send(404, { error: "Not found" }, actor); + }); +}); + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), +); + +function boot(): void { + const names = ["notes.get", "notes.huge", "notes.create", "notes.delete"]; + const methods: Record = { + "notes.get": "GET", + "notes.huge": "GET", + "notes.create": "POST", + "notes.delete": "DELETE", + }; + // A browser page at the server's origin, and its fetch, which resolves the + // published path against the page. + vi.stubGlobal("window", { + location: new URL(`${origin}/`), + __appkit__: { + appName: "test", + queries: {}, + endpoints: { + database: Object.fromEntries( + names.map((name) => [ + name, + `/api/database/operations/${encodeURIComponent(name)}`, + ]), + ), + }, + plugins: { database: { operations: methods } }, + }, + }); + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", (path: string, init?: RequestInit) => + realFetch(new URL(path, origin), init), + ); + _resetConfigCache(); + resetDatabaseSession(); +} + +afterEach(() => { + vi.unstubAllGlobals(); + _resetConfigCache(); + resetDatabaseSession(); + seen.length = 0; + held.clear(); + actor = "actor-a"; +}); + +describe("databaseApi over HTTP", () => { + test("a GET input arrives as exactly one JSON query value", async () => { + boot(); + const input = { id: 1, total: 9007199254740993n, text: "a b&c=d?é" }; + + const answer = await databaseApi.execute("notes.get", input); + + expect(answer).toEqual({ + id: 1, + input: { id: 1, total: "9007199254740993", text: "a b&c=d?é" }, + }); + expect(seen[0]).toMatchObject({ + method: "GET", + path: "/api/database/operations/notes.get", + query: ["input"], + body: undefined, + }); + expect(seen[0]?.headers.accept).toBe("application/json"); + }); + + test("writes send a JSON body, and a 204 resolves undefined", async () => { + boot(); + + await expect( + databaseApi.execute("notes.create", { body: "hi" }), + ).resolves.toEqual({ id: 7, body: "hi" }); + await expect( + databaseApi.execute("notes.delete", { id: 7 }), + ).resolves.toBeUndefined(); + + expect( + seen.map(({ method, query, input }) => ({ method, query, input })), + ).toEqual([ + { method: "POST", query: [], input: { body: "hi" } }, + { method: "DELETE", query: [], input: { id: 7 } }, + ]); + expect(seen[0]?.headers["content-type"]).toBe("application/json"); + }); + + test("a late reply for the previous actor is refused", async () => { + boot(); + await databaseApi.execute("notes.get", { id: 1 }); + const older = databaseApi.execute("notes.get", { id: 100 }); + const newer = databaseApi.execute("notes.get", { id: 101 }); + await vi.waitFor(() => expect(held.size).toBe(2)); + + held.get(101)?.("actor-b"); + await expect(newer).resolves.toEqual({ id: 101, as: "actor-b" }); + held.get(100)?.("actor-a"); + await expect(older).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + }); + + test("a 401 is UNAUTHENTICATED", async () => { + boot(); + actor = null; + + await expect( + databaseApi.execute("notes.get", { id: 1 }), + ).rejects.toMatchObject({ + code: "UNAUTHENTICATED", + status: 401, + message: "Sign in", + }); + }); + + test("an abort rejects with the abort, not a database error", async () => { + boot(); + const controller = new AbortController(); + const pending = databaseApi.execute( + "notes.get", + { id: 102 }, + { signal: controller.signal }, + ); + await vi.waitFor(() => expect(held.has(102)).toBe(true)); + + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + }); + + test("a body over the response limit is refused while it streams", async () => { + boot(); + + await expect(databaseApi.execute("notes.huge", {})).rejects.toMatchObject({ + code: "INTERNAL", + message: "Database response is too large", + }); + }); +}); diff --git a/packages/appkit-ui/src/js/database/client.test.ts b/packages/appkit-ui/src/js/database/client.test.ts index 0b421f5fd..ecbf9e882 100644 --- a/packages/appkit-ui/src/js/database/client.test.ts +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -1,679 +1,635 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { _resetConfigCache } from "../config"; import { databaseApi as typedApi, type DatabaseRequestOptions } from "./client"; import { DatabaseApiError } from "./errors"; - -// This package has no generated registry, so every entity name is `never` -// here. The typed surface is compiled against a real schema in the appkit -// type-generator tests; these cover the runtime transport. +import { + flush, + mockDatabaseFetch, + operationPath, + publishBoot, + publishOperations, + reply, + resetDatabaseTestEnvironment, +} from "./test-utils"; + +// This package has no generated registry, so every operation name is `never` +// here. The typed surface is compiled in use-database.types.test.ts; these +// tests drive the runtime through a string-typed view. const databaseApi = typedApi as unknown as { - list( - entity: string, - params?: object, - init?: DatabaseRequestOptions, - ): Promise<{ items: unknown[]; limit: number; offset: number }>; - get( - entity: string, - id: string | number | bigint, - params?: object, - init?: DatabaseRequestOptions, - ): Promise>; - create( - entity: string, - values: object, - init?: DatabaseRequestOptions, - ): Promise>; - update( - entity: string, - id: string | number | bigint, - values: object, - init?: DatabaseRequestOptions, - ): Promise>; - remove( - entity: string, - id: string | number | bigint, - init?: DatabaseRequestOptions, - ): Promise; + execute( + name: string, + input: unknown, + options?: DatabaseRequestOptions, + ): Promise; }; -const PAGE = { items: [{ id: 1, body: "hi" }], limit: 5, offset: 0 }; - -function publish(database: Record | undefined): void { - window.__appkit__ = { - appName: "test", - queries: {}, - endpoints: database ? { database } : {}, - plugins: {}, - }; -} - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} +const OPERATIONS = { + "notes.get": "GET", + "notes.create": "POST", + "notes.update": "PATCH", + "notes.replace": "PUT", + "notes.delete": "DELETE", +}; async function rejection(promise: Promise): Promise { return promise.then( () => { - throw new Error("expected the request to fail"); + throw new Error("expected the call to fail"); }, (error: unknown) => error, ); } -describe("databaseApi.list", () => { - let fetchMock: ReturnType; +let server: ReturnType; - beforeEach(() => { - fetchMock = vi.fn(async () => json(PAGE)); - vi.stubGlobal("fetch", fetchMock); - publish({ - "notes.list": "/api/database/notes", - "notes.detail": "/api/database/notes/:id", - }); - }); +beforeEach(() => { + server = mockDatabaseFetch(); + publishOperations(OPERATIONS); +}); - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); +afterEach(resetDatabaseTestEnvironment); - test("refuses an unpublished operation locally without sending a request", async () => { - const error = await rejection(databaseApi.list("boards")); +describe("resolving an operation", () => { + test("refuses a name the server did not publish without sending a request", async () => { + const error = await rejection(databaseApi.execute("boards.get", { id: 1 })); expect(error).toBeInstanceOf(DatabaseApiError); expect(error).toMatchObject({ - name: "DatabaseApiError", code: "NOT_EXPOSED", status: null, details: [], - message: 'Database operation "boards.list" is not exposed', + message: 'Database operation "boards.get" is not exposed', }); - expect(fetchMock).not.toHaveBeenCalled(); + expect(server.fetchMock).not.toHaveBeenCalled(); }); - test("refuses every operation when the plugin published no routes", async () => { - _resetConfigCache(); - publish(undefined); + test.each([ + [ + "a legacy CRUD route without operation metadata", + { endpoints: { "notes.list": "/api/database/notes" } }, + "notes.list", + ], + [ + "a method without an endpoint", + { operations: { "notes.get": "GET" } }, + "notes.get", + ], + [ + "an unknown method", + { + endpoints: { "notes.get": operationPath("notes.get") }, + operations: { "notes.get": "get" }, + }, + "notes.get", + ], + [ + "a cross-origin endpoint", + { + endpoints: { "notes.get": "https://elsewhere.example/notes" }, + operations: { "notes.get": "GET" }, + }, + "notes.get", + ], + [ + "a protocol-relative endpoint", + { + endpoints: { "notes.get": "//elsewhere.example/notes" }, + operations: { "notes.get": "GET" }, + }, + "notes.get", + ], + [ + "an endpoint a backslash turns protocol-relative", + { + endpoints: { "notes.get": "/\\elsewhere.example/notes" }, + operations: { "notes.get": "GET" }, + }, + "notes.get", + ], + ])( + "refuses %s with NOT_EXPOSED and no request", + async (_case, boot, name) => { + publishBoot(boot); + + await expect(databaseApi.execute(name, { id: 1 })).rejects.toMatchObject({ + code: "NOT_EXPOSED", + }); + expect(server.fetchMock).not.toHaveBeenCalled(); + }, + ); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "NOT_EXPOSED", - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); + test.each(["toString", "constructor", "__proto__", "hasOwnProperty"])( + "never resolves %s through a prototype", + async (name) => { + await expect(databaseApi.execute(name, {})).rejects.toMatchObject({ + code: "NOT_EXPOSED", + }); + expect(server.fetchMock).not.toHaveBeenCalled(); + }, + ); - test("calls the published route with the encoded query", async () => { - const page = await databaseApi.list("notes", { - where: { board_id: 7 }, - order: { created_at: "desc" }, - limit: 5, + test("calls the URL the server published, not a local convention", async () => { + publishBoot({ + endpoints: { "notes.get": "/custom/base/read-note" }, + operations: { "notes.get": "GET" }, }); - expect(page).toEqual(PAGE); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const [path, query] = url.split("?"); - expect(path).toBe("/api/database/notes"); - expect(Object.fromEntries(new URLSearchParams(query))).toEqual({ - where: '{"board_id":7}', - order: '{"created_at":"desc"}', - limit: "5", - }); - expect(init.method).toBe("GET"); - expect(new Headers(init.headers).get("Accept")).toBe("application/json"); - }); + const pending = databaseApi.execute("notes.get", { id: 7 }); + server.request(0).respond({ id: 7 }); + await pending; - test("sends no query string when there are no params", async () => { - await databaseApi.list("notes"); - expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes"); + expect(server.request(0).url).toBe( + `/custom/base/read-note?input=${encodeURIComponent('{"id":7}')}`, + ); }); +}); - test("rejects an incomplete filter locally without widening the request", async () => { - await expect( - databaseApi.list("notes", { where: { board_id: undefined } }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database query contains an unsupported value", +describe("sending input", () => { + test("a GET carries its input as one JSON query value and no body", async () => { + const pending = databaseApi.execute("notes.get", { + id: 9007199254740993n, + filter: { tags: ["a b", "c&d"] }, }); - expect(fetchMock).not.toHaveBeenCalled(); - }); + const request = server.request(0); + request.respond({ id: "9007199254740993", body: "hi" }); - test("explains an empty filter locally without broadening the read", async () => { - await expect( - databaseApi.list("notes", { where: {} }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Filter cannot be empty; omit where to list all rows", - details: [ - { - path: ["where"], - message: "Filter cannot be empty; omit where to list all rows", - }, - ], + await expect(pending).resolves.toEqual({ + id: "9007199254740993", + body: "hi", }); - expect(fetchMock).not.toHaveBeenCalled(); + const url = new URL(request.url, "http://localhost"); + expect(url.pathname).toBe(operationPath("notes.get")); + expect([...url.searchParams.keys()]).toEqual(["input"]); + expect(request.input).toEqual({ + id: "9007199254740993", + filter: { tags: ["a b", "c&d"] }, + }); + expect(request.method).toBe("GET"); + expect(request.body).toBeUndefined(); + expect(request.cache).toBe("no-store"); + expect(request.headers.get("Accept")).toBe("application/json"); + expect(request.headers.get("Content-Type")).toBeNull(); + }); + + test.each([ + ["notes.create", "POST"], + ["notes.update", "PATCH"], + ["notes.replace", "PUT"], + ["notes.delete", "DELETE"], + ])("%s sends its declared %s with a JSON body", async (name, method) => { + const pending = databaseApi.execute(name, { id: 7, total: 10n }); + const request = server.request(0); + request.respond({ id: 7 }); + await pending; + + expect(request.url).toBe(operationPath(name)); + expect(request.method).toBe(method); + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(request.headers.get("Accept")).toBe("application/json"); + expect(request.body).toBe('{"id":7,"total":"10"}'); }); test.each([NaN, Infinity, -Infinity])( - "rejects non-finite filter %s before JSON can turn it into null", + "refuses the non-finite number %s before JSON turns it into null", async (value) => { - await expect( - databaseApi.list("notes", { where: { rank: { is: value } } }), - ).rejects.toMatchObject({ - code: "INVALID_REQUEST", - status: null, - message: "Database query numbers must be finite", - details: [ - { path: ["where"], message: "Database query numbers must be finite" }, - ], - }); - expect(fetchMock).not.toHaveBeenCalled(); + for (const name of ["notes.get", "notes.update"]) { + await expect( + databaseApi.execute(name, { id: 7, changes: { rank: [value] } }), + ).rejects.toMatchObject({ + code: "INVALID_REQUEST", + status: null, + details: [{ path: ["input"] }], + message: + "Database input numbers must be finite; use null explicitly to clear a value", + }); + } + expect(server.fetchMock).not.toHaveBeenCalled(); }, ); - test("uses the route path the server published, not a local convention", async () => { - _resetConfigCache(); - publish({ "notes.list": "/custom/base/notes" }); + test("refuses input with no JSON form without echoing it", async () => { + const cyclic: Record = { secret: "sentinel-value" }; + cyclic.self = cyclic; + const throwing = { + toJSON() { + throw new Error("sentinel-value"); + }, + }; - await databaseApi.list("notes", { limit: 1 }); - expect(fetchMock.mock.calls[0]?.[0]).toBe("/custom/base/notes?limit=1"); + for (const input of [undefined, cyclic, throwing, () => 1]) { + const error = await rejection(databaseApi.execute("notes.create", input)); + expect(error).toMatchObject({ code: "INVALID_REQUEST", status: null }); + expect(JSON.stringify(error)).not.toContain("sentinel-value"); + expect((error as Error).message).not.toContain("sentinel-value"); + } + expect(server.fetchMock).not.toHaveBeenCalled(); }); +}); - test("decodes a failure envelope into its stable category and details", async () => { - fetchMock.mockResolvedValueOnce( - json( - { - error: "Invalid database request", - details: [ - { path: ["where"], message: "Names an unknown column" }, - { path: "where", message: "not a detail" }, - { message: "no path" }, - ], - }, - 400, - ), - ); +describe("decoding replies", () => { + test("a 204 resolves undefined without reading a body", async () => { + const response = reply(undefined, { status: 204 }); + const pending = databaseApi.execute("notes.delete", { id: 7 }); + server.request(0).respondWith(response); - const error = await rejection(databaseApi.list("notes")); + await expect(pending).resolves.toBeUndefined(); + expect(response.bodyUsed).toBe(false); + }); - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "INVALID_REQUEST", - status: 400, - message: "Invalid database request", - details: [{ path: ["where"], message: "Names an unknown column" }], - }); + test("an explicit JSON answer, null included, is the result", async () => { + const deleted = databaseApi.execute("notes.delete", { id: 7 }); + server.request(0).respond({ deleted: 1 }); + await expect(deleted).resolves.toEqual({ deleted: 1 }); + + const missing = databaseApi.execute("notes.get", { id: 8 }); + server.request(1).respond(null); + await expect(missing).resolves.toBeNull(); }); test.each([ + [400, "INVALID_REQUEST"], + [401, "UNAUTHENTICATED"], [403, "FORBIDDEN"], [404, "NOT_FOUND"], [409, "CONFLICT"], [413, "PAYLOAD_TOO_LARGE"], + [415, "UNSUPPORTED_MEDIA_TYPE"], [422, "VALIDATION_FAILED"], [503, "TRANSIENT"], [500, "INTERNAL"], [502, "INTERNAL"], ])("maps status %i to %s", async (status, code) => { - fetchMock.mockResolvedValueOnce(json({ error: "stable" }, status)); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ + const pending = databaseApi.execute("notes.get", { id: 7 }); + server.request(0).respond({ error: "Stable message" }, { status }); + + await expect(pending).rejects.toMatchObject({ + name: "DatabaseApiError", code, status, + message: "Stable message", }); }); - test("keeps the category when a failure body is not JSON", async () => { - fetchMock.mockResolvedValueOnce( - new Response("Bad gateway", { status: 502 }), + test("keeps only bounded details shaped like the server's", async () => { + const pending = databaseApi.execute("notes.create", { body: "x" }); + server.request(0).respond( + { + error: "Database request failed validation", + details: [ + { path: ["body"], message: "Must be at most 5000 characters" }, + { path: "body", message: "not a detail" }, + { path: [1], message: "not a field name" }, + { message: "no path" }, + { path: ["body"], message: "x".repeat(501) }, + ...Array.from({ length: 30 }, (_, index) => ({ + path: [`field${index}`], + message: "Required", + })), + ], + }, + { status: 422 }, ); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "INTERNAL", - status: 502, - message: "Database request failed with status 502", - details: [], + const error = (await rejection(pending)) as DatabaseApiError; + expect(error.code).toBe("VALIDATION_FAILED"); + expect(error.details[0]).toEqual({ + path: ["body"], + message: "Must be at most 5000 characters", }); + // Only the first 20 entries are read, and the malformed ones dropped. + expect(error.details).toHaveLength(16); }); - test("rejects a success body that is not the list envelope", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 1 }])); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ - code: "INTERNAL", - status: 200, - }); + test.each([ + ["is not JSON", new Response("Bad gateway", { status: 502 })], + [ + "has an oversized message", + reply({ error: "x".repeat(501) }, { status: 500 }), + ], + [ + "exceeds the error body limit", + reply( + { error: "Stable", padding: "x".repeat(70 * 1024) }, + { status: 500 }, + ), + ], + ])("keeps the category when the failure body %s", async (_case, response) => { + const pending = databaseApi.execute("notes.get", { id: 7 }); + server.request(0).respondWith(response); - fetchMock.mockResolvedValueOnce( - new Response("", { status: 200 }), - ); - await expect(databaseApi.list("notes")).rejects.toMatchObject({ + await expect(pending).rejects.toMatchObject({ code: "INTERNAL", - status: 200, - message: "Database response is not JSON", + message: `Database request failed with status ${response.status}`, + details: [], }); }); - test("reports an unanswered read as TRANSIENT without claiming the server never saw it", async () => { - const cause = new TypeError("Failed to fetch"); - fetchMock.mockRejectedValueOnce(cause); - - const error = await rejection(databaseApi.list("notes")); - - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ code: "TRANSIENT", status: null, cause }); - expect((error as Error).message).not.toContain("did not reach the server"); - }); - - test("passes the caller's signal and rejects with its abort, not a database error", async () => { - const controller = new AbortController(); - fetchMock.mockImplementationOnce( - (_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - init.signal?.addEventListener("abort", () => - reject(init.signal?.reason), - ); + test.each([ + ["notes.get", "INTERNAL", "Database response is not JSON"], + [ + "notes.create", + "OUTCOME_UNKNOWN", + "Database write may have completed; response is not JSON", + ], + ])( + "%s reports a success body that is not JSON as %s", + async (name, code, message) => { + const pending = databaseApi.execute(name, { id: 7 }); + server.request(0).respondWith( + new Response("", { + status: 200, + headers: { "x-appkit-database-actor": "actor-a" }, }), - ); - - const pending = databaseApi.list( - "notes", - {}, - { signal: controller.signal }, - ); - controller.abort(); - const error = await rejection(pending); + ); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - signal: controller.signal, - }); - expect(error).not.toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ name: "AbortError" }); - }); -}); - -describe("databaseApi.get", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(async () => json({ id: 7, title: "Roadmap" })); - vi.stubGlobal("fetch", fetchMock); - publish({ - "boards.list": "/api/database/boards", - "boards.detail": "/api/database/boards/:id", - "events.list": "/api/database/events", - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); - }); + await expect(pending).rejects.toMatchObject({ + code, + status: 200, + message, + }); + }, + ); - test("calls the published detail route with the id and the encoded query", async () => { - const row = await databaseApi.get("boards", 7, { - include: { notes: { limit: 20 } }, - select: ["id", "title"], + test("refuses a success body larger than the response limit", async () => { + const declared = databaseApi.execute("notes.get", { id: 7 }); + server + .request(0) + .respondWith( + reply( + { id: 7 }, + { headers: { "Content-Length": String(17 * 2 ** 20) } }, + ), + ); + await expect(declared).rejects.toMatchObject({ + code: "INTERNAL", + message: "Database response is too large", }); - expect(row).toEqual({ id: 7, title: "Roadmap" }); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const [path, query] = url.split("?"); - expect(path).toBe("/api/database/boards/7"); - expect([...new URLSearchParams(query)]).toEqual([ - ["select", '["id","title"]'], - ["include", '{"notes":{"limit":20}}'], - ]); - expect(init.method).toBe("GET"); - }); - - test("encodes the id as one path segment", async () => { - await databaseApi.get("boards", "a/b c?d"); - await databaseApi.get("boards", 9007199254740993n); - - expect(fetchMock.mock.calls[0]?.[0]).toBe( - "/api/database/boards/a%2Fb%20c%3Fd", - ); - expect(fetchMock.mock.calls[1]?.[0]).toBe( - "/api/database/boards/9007199254740993", + const streamed = databaseApi.execute("notes.get", { id: 7 }); + const chunk = new Uint8Array(17 * 2 ** 20).fill(32); + server.request(1).respondWith( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.close(); + }, + }), + { headers: { "x-appkit-database-actor": "actor-a" } }, + ), ); + await expect(streamed).rejects.toMatchObject({ + code: "INTERNAL", + message: "Database response is too large", + }); }); - 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)); + test.each([ + ["notes.get", "INTERNAL"], + ["notes.create", "OUTCOME_UNKNOWN"], + ])( + "%s never returns a success that does not name its actor", + async (name, code) => { + for (const actor of [null, "", "x".repeat(257)]) { + const index = server.requests.length; + const pending = databaseApi.execute(name, { id: 7 }); + server + .request(index) + .respond({ id: 7, body: "unattributed" }, { actor }); + await expect(pending).rejects.toMatchObject({ + code, + message: expect.stringContaining("does not name its actor"), + }); + } + }, + ); - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "INVALID_REQUEST", + test.each([ + [ + "notes.get", + "TRANSIENT", + "Database read failed before receiving a response", + ], + [ + "notes.create", + "OUTCOME_UNKNOWN", + "Database write may have completed; check its result before retrying", + ], + ])( + "%s reports an unanswered request as %s and never retries it", + async (name, code, message) => { + const cause = new TypeError("Failed to fetch"); + const pending = databaseApi.execute(name, { id: 7 }); + server.request(0).fail(cause); + + await expect(pending).rejects.toMatchObject({ + code, status: null, - message: "Database id must be a non-empty path segment", + message, + cause, }); - expect(fetchMock).not.toHaveBeenCalled(); + await flush(); + expect(server.fetchMock).toHaveBeenCalledTimes(1); }, ); - 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), - ); + test("a write the server answered after it ran rejects with OUTCOME_UNKNOWN", async () => { + const unknown = { + error: "Database write may have completed", + outcome: "unknown", + }; + const write = databaseApi.execute("notes.create", { body: "x" }); + server.request(0).respond(unknown, { status: 500 }); + const read = databaseApi.execute("notes.get", { id: 1 }); + server.request(1).respond(unknown, { status: 500 }); - await expect(databaseApi.get("boards", 404)).rejects.toMatchObject({ - name: "DatabaseApiError", - code: "NOT_FOUND", - status: 404, - message: "Database record not found", + await expect(write).rejects.toMatchObject({ + code: "OUTCOME_UNKNOWN", + status: 500, }); + // A read changes nothing, so the same envelope stays a plain failure. + await expect(read).rejects.toMatchObject({ code: "INTERNAL", status: 500 }); }); - test("refuses a keyless table locally, since it has no detail route", async () => { - const error = await rejection(databaseApi.get("events", 1)); + test("a failed write is never sent again", async () => { + const pending = databaseApi.execute("notes.create", { body: "x" }); + server.request(0).respond({ error: "Unavailable" }, { status: 503 }); - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "NOT_EXPOSED", - status: null, - message: 'Database operation "events.detail" is not exposed', - }); - expect(fetchMock).not.toHaveBeenCalled(); + await expect(pending).rejects.toMatchObject({ code: "TRANSIENT" }); + await flush(); + expect(server.fetchMock).toHaveBeenCalledTimes(1); }); - test("rejects a success body that is not one row", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 7 }])); - - await expect(databaseApi.get("boards", 7)).rejects.toMatchObject({ - code: "INTERNAL", - status: 200, - message: "Database response has an unexpected shape", - }); - }); -}); + test("passes the caller's signal and rejects with its abort, not a database error", async () => { + const controller = new AbortController(); + const pending = databaseApi.execute( + "notes.get", + { id: 7 }, + { signal: controller.signal }, + ); + const request = server.request(0); + expect(request.signal).toBe(controller.signal); -describe("databaseApi writes", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(async () => json({ id: 7, body: "hi" }, 201)); - vi.stubGlobal("fetch", fetchMock); - publish({ - "notes.list": "/api/database/notes", - "notes.detail": "/api/database/notes/:id", - "notes.create": "/api/database/notes", - "notes.update": "/api/database/notes/:id", - "notes.delete": "/api/database/notes/:id", - "ledger.create": "/api/database/ledger", - // Read-only: the plugin published reads but no writes. - "note_events.list": "/api/database/note_events", - "note_events.detail": "/api/database/note_events/:id", - }); - }); + controller.abort(); + // The transport ignored the abort; its late answer is still not delivered. + request.respond({ id: 7 }); + const error = await rejection(pending); - afterEach(() => { - vi.unstubAllGlobals(); - delete window.__appkit__; - _resetConfigCache(); + expect(error).not.toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ name: "AbortError" }); }); +}); - function sent(index = 0): { url: string; init: RequestInit } { - const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; - return { url, init }; +describe("session isolation", () => { + /** Settle the session on `actor` with one answered read. */ + async function signIn(actor: string): Promise { + const index = server.requests.length; + const pending = databaseApi.execute("notes.get", { id: 0 }); + server.request(index).respond({ id: 0 }, { actor }); + await pending; } - test("creates with a JSON body on the published route and returns the row", async () => { - const row = await databaseApi.create("notes", { - board_id: 7, - author: "ada", - body: "hi", - }); - - expect(row).toEqual({ id: 7, body: "hi" }); - const { url, init } = sent(); - expect(url).toBe("/api/database/notes"); - expect(init.method).toBe("POST"); - const headers = new Headers(init.headers); - expect(headers.get("Content-Type")).toBe("application/json"); - expect(headers.get("Accept")).toBe("application/json"); - expect(JSON.parse(init.body as string)).toEqual({ - board_id: 7, - author: "ada", - body: "hi", - }); - }); - - test("sends a bigint value as its decimal string, which the server reads back exactly", async () => { - await databaseApi.create("ledger", { - seq: 9007199254740993n, - note: "x", - }); - - expect(sent().init.body).toBe('{"seq":"9007199254740993","note":"x"}'); - }); - - test.each([NaN, Infinity, -Infinity])( - "rejects non-finite write %s rather than clearing a nullable value", - async (value) => { - for (const write of [ - () => databaseApi.create("notes", { rank: value }), - () => databaseApi.update("notes", 7, { rank: value }), - () => databaseApi.update("notes", 7, { payload: { scores: [value] } }), - ]) { - await expect(write()).rejects.toMatchObject({ - name: "DatabaseApiError", - code: "INVALID_REQUEST", - status: null, - message: - "Database write numbers must be finite; use null explicitly to clear a value", - details: [ - { - path: ["body"], - message: - "Database write numbers must be finite; use null explicitly to clear a value", - }, - ], - }); - } - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); + test("a late reply for the previous actor is refused once a later reply names another", async () => { + await signIn("actor-a"); + const older = databaseApi.execute("notes.get", { id: 1 }); + const newer = databaseApi.execute("notes.get", { id: 2 }); - test("keeps an explicit null and finite numbers unchanged", async () => { - await databaseApi.update("notes", 7, { rank: null, payload: { score: 0 } }); - expect(sent().init.body).toBe('{"rank":null,"payload":{"score":0}}'); - }); + server.request(2).respond({ id: 2, owner: "b" }, { actor: "actor-b" }); + await expect(newer).resolves.toEqual({ id: 2, owner: "b" }); - test("sanitizes serialization failures before any write is sent", async () => { - const values = { - toJSON: () => { - throw new Error("private serialization detail"); - }, - }; - await expect(databaseApi.create("notes", values)).rejects.toMatchObject({ - code: "INVALID_REQUEST", + server.request(1).respond({ id: 1, owner: "a" }, { actor: "actor-a" }); + await expect(older).rejects.toMatchObject({ + code: "SESSION_CHANGED", status: null, - message: "Database write contains an unsupported value", + message: "The session changed before the read answered", }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - test("reports an unanswered write as unknown because it may have committed", async () => { - const cause = new TypeError("Connection lost after sending the request"); - fetchMock.mockRejectedValue(cause); - - for (const request of [ - () => - databaseApi.create("notes", { board_id: 7, author: "ada", body: "hi" }), - () => databaseApi.update("notes", 7, { body: "edited" }), - () => databaseApi.remove("notes", 7), - ]) { - await expect(request()).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: null, - cause, - message: expect.stringContaining("may have completed"), - }); - } + // The session stays with the later actor: no flip back. + const next = databaseApi.execute("notes.get", { id: 3 }); + server.request(3).respond({ id: 3 }, { actor: "actor-b" }); + await expect(next).resolves.toEqual({ id: 3 }); }); - test("updates one row with PATCH on its encoded id", async () => { - fetchMock.mockResolvedValueOnce(json({ id: 7, body: "edited" })); + test("replies to requests sent before a change was seen cannot flip it back", async () => { + await signIn("actor-a"); + const revealing = databaseApi.execute("notes.get", { id: 1 }); + const sentLater = databaseApi.execute("notes.get", { id: 2 }); - const row = await databaseApi.update("notes", "a/b", { body: "edited" }); + server.request(1).respond({ id: 1 }, { actor: "actor-b" }); + await expect(revealing).resolves.toEqual({ id: 1 }); + // Sent after the revealing request, but before its reply was seen. + server.request(2).respond({ id: 2 }, { actor: "actor-a" }); + await expect(sentLater).rejects.toMatchObject({ code: "SESSION_CHANGED" }); - expect(row).toEqual({ id: 7, body: "edited" }); - const { url, init } = sent(); - expect(url).toBe("/api/database/notes/a%2Fb"); - expect(init.method).toBe("PATCH"); - expect(new Headers(init.headers).get("Content-Type")).toBe( - "application/json", - ); - expect(init.body).toBe('{"body":"edited"}'); + const next = databaseApi.execute("notes.get", { id: 3 }); + server.request(3).respond({ id: 3 }, { actor: "actor-b" }); + await expect(next).resolves.toEqual({ id: 3 }); }); - test("deletes one row and resolves without reading the empty 204 body", async () => { - const response = new Response(null, { status: 204 }); - const readBody = vi.spyOn(response, "json"); - fetchMock.mockResolvedValueOnce(response); - - await expect(databaseApi.remove("notes", 7)).resolves.toBeUndefined(); + test("an earlier-sent reply cannot flip the first actor seen back", async () => { + const first = databaseApi.execute("notes.get", { id: 1 }); + const second = databaseApi.execute("notes.get", { id: 2 }); + const third = databaseApi.execute("notes.get", { id: 3 }); - const { url, init } = sent(); - expect(url).toBe("/api/database/notes/7"); - expect(init.method).toBe("DELETE"); - expect(init.body).toBeUndefined(); - expect(readBody).not.toHaveBeenCalled(); + server.request(1).respond({ id: 2 }, { actor: "actor-b" }); + await expect(second).resolves.toEqual({ id: 2 }); + server.request(0).respond({ id: 1 }, { actor: "actor-a" }); + await expect(first).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + server.request(2).respond({ id: 3 }, { actor: "actor-b" }); + await expect(third).resolves.toEqual({ id: 3 }); }); - test("keeps the server's validation details on a 422", async () => { - fetchMock.mockResolvedValueOnce( - json( - { - error: "Database request failed validation", - details: [ - { path: ["body"], message: "Must be at most 5000 characters" }, - ], - }, - 422, - ), - ); - - const error = await rejection( - databaseApi.create("notes", { board_id: 7, author: "ada", body: "x" }), - ); + test("a 401 ends the session, and replies sent before it are refused", async () => { + await signIn("actor-a"); + const before = databaseApi.execute("notes.get", { id: 1 }); + const write = databaseApi.execute("notes.create", { body: "x" }); + const expired = databaseApi.execute("notes.get", { id: 2 }); - expect(error).toBeInstanceOf(DatabaseApiError); - expect(error).toMatchObject({ - code: "VALIDATION_FAILED", - status: 422, - message: "Database request failed validation", - details: [{ path: ["body"], message: "Must be at most 5000 characters" }], + server.request(3).respond({ error: "Sign in again" }, { status: 401 }); + await expect(expired).rejects.toMatchObject({ + code: "UNAUTHENTICATED", + status: 401, }); - }); - test("maps a 415 to UNSUPPORTED_MEDIA_TYPE", async () => { - fetchMock.mockResolvedValueOnce( - json({ error: "Database request body must be JSON" }, 415), - ); - - await expect( - databaseApi.update("notes", 7, { body: "x" }), - ).rejects.toMatchObject({ - code: "UNSUPPORTED_MEDIA_TYPE", - status: 415, - message: "Database request body must be JSON", + server.request(1).respond({ id: 1 }, { actor: "actor-a" }); + await expect(before).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + server.request(2).respond({ id: 9 }, { actor: "actor-a" }); + await expect(write).rejects.toMatchObject({ + code: "SESSION_CHANGED", + message: + "The session changed before the write answered; it may have completed", }); - }); - test("maps a missing row on update or delete to NOT_FOUND", async () => { - fetchMock.mockResolvedValue( - json({ error: "Database record not found" }, 404), + // Signing in again starts the next session from its first reply. + const after = databaseApi.execute("notes.get", { id: 3 }); + server.request(4).respond({ id: 3 }, { actor: "actor-a" }); + await expect(after).resolves.toEqual({ id: 3 }); + }); + + test("a late 401 sent before an authenticated reply does not end the session", async () => { + const stale = databaseApi.execute("notes.get", { id: 1 }); + const signedIn = databaseApi.execute("notes.get", { id: 2 }); + const inFlight = databaseApi.execute("notes.get", { id: 3 }); + + server.request(1).respond({ id: 2 }, { actor: "actor-a" }); + await signedIn; + server.request(0).respond({ error: "Sign in" }, { status: 401 }); + await expect(stale).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + + // Had the 401 opened a new session, this earlier-sent reply would be refused. + server.request(2).respond({ id: 3 }, { actor: "actor-a" }); + await expect(inFlight).resolves.toEqual({ id: 3 }); + }); + + test("a reply without session information is still refused once stale", async () => { + await signIn("actor-a"); + const proxied = databaseApi.execute("notes.get", { id: 1 }); + const switched = databaseApi.execute("notes.get", { id: 2 }); + + server.request(2).respond({ id: 2 }, { actor: "actor-b" }); + await switched; + server.request(1).respondWith(new Response("Bad gateway", { status: 502 })); + await expect(proxied).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + }); + + test("an authenticated error reply also moves the session", async () => { + await signIn("actor-a"); + const older = databaseApi.execute("notes.get", { id: 1 }); + const denied = databaseApi.execute("notes.get", { id: 2 }); + + server + .request(2) + .respond({ error: "Not found" }, { status: 404, actor: "actor-b" }); + await expect(denied).rejects.toMatchObject({ code: "NOT_FOUND" }); + server.request(1).respond({ id: 1 }, { actor: "actor-a" }); + await expect(older).rejects.toMatchObject({ code: "SESSION_CHANGED" }); + }); + + test("a session change while a body is still arriving refuses that body", async () => { + await signIn("actor-a"); + let finish!: () => void; + const slow = databaseApi.execute("notes.get", { id: 1 }); + server.request(1).respondWith( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"id":1,')); + finish = () => { + controller.enqueue(new TextEncoder().encode('"owner":"a"}')); + controller.close(); + }; + }, + }), + { headers: { "x-appkit-database-actor": "actor-a" } }, + ), ); + await flush(); - await expect( - databaseApi.update("notes", 404, { body: "x" }), - ).rejects.toMatchObject({ code: "NOT_FOUND", status: 404 }); - await expect(databaseApi.remove("notes", 404)).rejects.toMatchObject({ - code: "NOT_FOUND", - status: 404, - }); - }); - - test("refuses a write addressed by an id that is not one path segment", async () => { - const results = await Promise.all([ - rejection(databaseApi.update("notes", "..", { body: "x" })), - rejection(databaseApi.remove("notes", ".")), - rejection(databaseApi.remove("notes", "")), - ]); - - expect(results).toMatchObject([ - { code: "INVALID_REQUEST", status: null }, - { code: "INVALID_REQUEST", status: null }, - { code: "INVALID_REQUEST", status: null }, - ]); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("refuses writes the plugin did not publish without sending a request", async () => { - const results = await Promise.all([ - rejection(databaseApi.create("note_events", { note_id: 1 })), - rejection(databaseApi.update("note_events", 1, { action: "x" })), - rejection(databaseApi.remove("note_events", 1)), - ]); - - expect(results.map((error) => error instanceof DatabaseApiError)).toEqual([ - true, - true, - true, - ]); - expect(results).toMatchObject([ - { - code: "NOT_EXPOSED", - status: null, - message: 'Database operation "note_events.create" is not exposed', - }, - { code: "NOT_EXPOSED", message: expect.stringContaining(".update") }, - { code: "NOT_EXPOSED", message: expect.stringContaining(".delete") }, - ]); - expect(fetchMock).not.toHaveBeenCalled(); - }); + const switched = databaseApi.execute("notes.get", { id: 2 }); + server.request(2).respond({ id: 2 }, { actor: "actor-b" }); + await switched; + finish(); - test("reports a successful write with an invalid response as outcome unknown", async () => { - fetchMock.mockResolvedValueOnce(json([{ id: 7 }], 201)); - await expect( - databaseApi.create("notes", { board_id: 7, author: "a", body: "b" }), - ).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: 201, - message: expect.stringContaining("may have completed"), - }); - - fetchMock.mockResolvedValueOnce(json({ id: 7 }, 200)); - await expect(databaseApi.remove("notes", 7)).rejects.toMatchObject({ - code: "OUTCOME_UNKNOWN", - status: 200, - }); - - fetchMock.mockResolvedValueOnce( - new Response("broken JSON", { status: 200 }), - ); - await expect( - databaseApi.update("notes", 7, { body: "edited" }), - ).rejects.toMatchObject({ code: "OUTCOME_UNKNOWN", status: 200 }); + await expect(slow).rejects.toMatchObject({ code: "SESSION_CHANGED" }); }); }); diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index 32137f78a..ddd847e7b 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -1,507 +1,414 @@ import { type DatabaseErrorDetail, - type DatabaseListPage, - type DatabaseListQuery, + type DatabaseOperationMethod, databaseErrorCategoryForStatus, - encodeDatabaseListQuery, - encodeDatabaseRecordQuery, - type ExactDatabaseParams, } from "shared"; -import { getClientConfig } from "../config"; +import { getClientConfig, getPluginClientConfig } from "../config"; import { - DatabaseApiError, - invalidDatabaseQuery, - invalidDatabaseWrite, -} from "./errors"; + type DatabaseApi as DatabaseEntityApi, + databaseApi as databaseEntityApi, +} from "./entity-client"; +import { DatabaseApiError, invalidDatabaseInput } from "./errors"; +import { + acceptDatabaseReply, + DATABASE_ACTOR_HEADER, + isCurrentDatabaseRequest, + openDatabaseRequest, +} from "./session"; import type { - DatabaseEntity, - DatabaseId, - DatabaseInsert, - DatabaseKeyedEntity, - DatabaseListParams, - DatabaseListRow, - DatabaseRecordParams, - DatabaseRecordRow, - DatabaseRow, - DatabaseUpdate, + DatabaseOperationInput, + DatabaseOperationName, + DatabaseOperationOutput, } from "./types"; -/** - * Suffix of the endpoint names `DatabasePlugin` publishes for each table. - * @internal Shared with the React hooks; not part of the public surface. - */ -export type DatabaseOperation = - | "list" - | "detail" - | "create" - | "update" - | "delete"; - -/** - * An id as a keyed route addresses it in its path. - * @internal Shared with the React hooks; not part of the public surface. - */ -export type IdLike = string | number | bigint; - -/** Per-call options for a database request. */ +/** Per-call options for a database operation. */ export interface DatabaseRequestOptions { /** - * Cancels the request; the promise then rejects with the abort reason. + * Cancels the call; the promise then rejects with the abort reason. * Cancelling a write does not undo it once the server has committed. */ readonly signal?: AbortSignal; } -/** Typed calls to the routes `DatabasePlugin` generates under `/api/database`. */ -export interface DatabaseApi { - /** - * Read one page from `GET /api/database/`. Rows are public rows as - * JSON carries them, narrowed by `select` and widened by `include`. - * - * @example - * ```typescript - * const page = await databaseApi.list("notes", { - * where: { board_id: 7 }, - * order: { created_at: "desc" }, - * limit: 5, - * }); - * page.items[0]?.body; - * ``` - */ - list( - entity: K, - params?: undefined, - init?: DatabaseRequestOptions, - ): Promise>>; - list>( - entity: K, - params: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>>; - - /** - * Read one row from `GET /api/database//:id`. Only entities with a - * public primary key have this route; a missing row rejects with - * `NOT_FOUND`. - * - * @example - * ```typescript - * const board = await databaseApi.get("boards", 7, { - * include: { notes: { limit: 20 } }, - * }); - * board.notes.length; - * ``` - */ - get< - K extends DatabaseKeyedEntity, - const P extends DatabaseRecordParams = Record, - >( - entity: K, - id: DatabaseId, - params?: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; - +/** + * Typed calls to the operations `DatabasePlugin` publishes, by name. The entity + * calls remain until the plugin serves operations instead of entity routes. + */ +export interface DatabaseApi extends DatabaseEntityApi { /** - * Create one row with `POST /api/database/` and return it as the - * database holds it. Private, generated, and undeclared fields are compile - * errors, as the server refuses them. + * Call one operation with its declared method and resolve with its JSON + * answer, or `undefined` when it answers without a body. The name, input, + * and output come from the generated `database-operations.d.ts`; the URL and method + * come from what the server published at boot. A name the server did not + * publish rejects with `NOT_EXPOSED` without a request. * * @example * ```typescript - * const note = await databaseApi.create("notes", { - * board_id: 7, - * author: "ada", - * body: "Ship it", - * }); - * note.id; + * const note = await databaseApi.execute("notes.get", { id: 7 }); + * await databaseApi.execute("notes.delete", { id: note.id }); * ``` */ - create>( - entity: K, - values: V & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; + execute( + name: N, + input: DatabaseOperationInput, + options?: DatabaseRequestOptions, + ): Promise>; +} - /** - * Change some fields of one row with `PATCH /api/database//:id` and - * return the updated row. A missing row rejects with `NOT_FOUND`. - * - * @example - * ```typescript - * await databaseApi.update("notes", 7, { body: "Shipped" }); - * ``` - */ - update>( - entity: K, - id: DatabaseId, - values: V & ExactDatabaseParams>, - init?: DatabaseRequestOptions, - ): Promise>; +/** Which methods a hook runs: a query only GET, a mutation only the others. */ +type DatabaseOperationKind = "query" | "mutation"; - /** - * Delete one row with `DELETE /api/database//:id`. A missing row - * rejects with `NOT_FOUND`. - * - * @example - * ```typescript - * await databaseApi.remove("notes", 7); - * ``` - */ - remove( - entity: K, - id: DatabaseId, - init?: DatabaseRequestOptions, - ): Promise; +/** + * One resolved and encoded call. A query's URL carries its input, so equal + * inputs share a URL whatever their object identity. + * @internal Shared with the React hooks; not part of the public surface. + */ +export interface DatabaseOperationCall { + readonly url: string; + send(signal?: AbortSignal): Promise; } +const METHODS: ReadonlySet = new Set([ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", +]); +/** An error envelope is a short message and a few details; a longer body is not read. */ +const MAX_ERROR_BYTES = 64 * 1024; +/** Far above any operation answer, so a runaway body cannot exhaust the page. */ +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; +const MAX_TEXT_LENGTH = 500; +const MAX_DETAILS = 20; +const MAX_ACTOR_LENGTH = 256; + 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 === ".."; +function isMethod(value: unknown): value is DatabaseOperationMethod { + return typeof value === "string" && METHODS.has(value); } -/** - * Find the route the server published for one operation. The plugin publishes - * only what its `api` configuration exposes, so a missing entry is refused - * here with `NOT_EXPOSED` and no request is sent. An id that is not one path - * segment is refused with `INVALID_REQUEST`, also without a request. - * - * @internal Shared with the React hooks; not part of the public surface. - */ -export function resolveDatabaseUrl( - entity: string, - operation: DatabaseOperation, - id?: IdLike, - query?: string, -): string { - const name = `${entity}.${operation}`; - const endpoints = getClientConfig().endpoints.database; - const path = - isRecord(endpoints) && Object.hasOwn(endpoints, name) - ? endpoints[name] - : undefined; - if (typeof path !== "string") { - throw new DatabaseApiError( - "NOT_EXPOSED", - null, - `Database operation "${name}" is not exposed`, - ); - } - let url = path; - if (id !== undefined) { - const segment = String(id); - if (isUnaddressableId(segment)) { - // The id may be user input from a route param; do not echo it. - throw new DatabaseApiError( - "INVALID_REQUEST", - null, - "Database id must be a non-empty path segment", - ); - } - url = path.replace(":id", encodeURIComponent(segment)); +function isBoundedText(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_TEXT_LENGTH; +} + +/** The input must not travel to another origin, whatever the boot payload says. */ +function isSameOrigin(url: string): boolean { + try { + return new URL(url, window.location.href).origin === window.location.origin; + } catch { + return false; } - return query ? `${url}?${query}` : url; } -/** Keep only details shaped like the server's; they name public fields only. */ -function publicDetails(value: unknown): DatabaseErrorDetail[] { - if (!Array.isArray(value)) return []; - return value.flatMap((detail): DatabaseErrorDetail[] => - isRecord(detail) && - typeof detail.message === "string" && - Array.isArray(detail.path) && - detail.path.every((segment) => typeof segment === "string") - ? [{ path: [...detail.path], message: detail.message }] - : [], - ); +/** An own entry of boot metadata, so no name resolves through a prototype. */ +function published(record: unknown, name: string): unknown { + return isRecord(record) && Object.hasOwn(record, name) + ? record[name] + : undefined; } -/** Decode a failure envelope; a non-JSON body still keeps its category. */ -async function failure(response: Response): Promise { - const body: unknown = await response.json().catch(() => undefined); - const envelope = isRecord(body) ? body : {}; +function notExposed(name: string, reason: string): DatabaseApiError { return new DatabaseApiError( - databaseErrorCategoryForStatus(response.status), - response.status, - typeof envelope.error === "string" - ? envelope.error - : `Database request failed with status ${response.status}`, - publicDetails(envelope.details), + "NOT_EXPOSED", + null, + `Database operation ${JSON.stringify(String(name).slice(0, 100))} ${reason}`, ); } /** - * Send one request and decode its JSON body, throwing `DatabaseApiError` for - * anything but the shape `accept` expects. A `204` has no body, so `accept` - * sees `undefined`. An abort rejects with the signal's own reason, so a - * caller can tell cancellation from failure. - * - * @internal Shared with the React hooks; not part of the public surface. + * Find the URL and method the server published for `name`. Both come from + * the boot payload: the URL from the endpoints `Plugin.route` registered, the + * method from the plugin's client config. Without both, or for the wrong kind + * of hook, the call is refused with `NOT_EXPOSED` and nothing is sent. */ -export async function requestDatabase( - url: string, - init: RequestInit, - accept: (body: unknown) => body is T, -): Promise { - const headers = new Headers(init.headers); - headers.set("Accept", "application/json"); - const write = init.method !== "GET"; - let response: Response; - try { - response = await fetch(url, { ...init, headers }); - } catch (error) { - if (init.signal?.aborted) throw error; - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "TRANSIENT", - null, - write - ? "Database write may have completed; check its result before retrying" - : "Database read failed before receiving a response", - [], - { cause: error }, - ); +function resolve( + name: string, + kind: DatabaseOperationKind | undefined, +): { url: string; method: DatabaseOperationMethod } { + const url = published(getClientConfig().endpoints.database, name); + const method = published( + getPluginClientConfig<{ operations?: unknown }>("database").operations, + name, + ); + if (typeof url !== "string" || !isSameOrigin(url) || !isMethod(method)) { + throw notExposed(name, "is not exposed"); } - if (!response.ok) throw await failure(response); - - let body: unknown; - try { - body = response.status === 204 ? undefined : await response.json(); - } catch (error) { - if (init.signal?.aborted) throw error; - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "INTERNAL", - response.status, - write - ? "Database write may have completed; response is not JSON" - : "Database response is not JSON", - [], - { cause: error }, - ); + if (kind === "query" && method !== "GET") { + throw notExposed(name, "is not a query"); } - if (!accept(body)) { - throw new DatabaseApiError( - write ? "OUTCOME_UNKNOWN" : "INTERNAL", - response.status, - write - ? "Database write may have completed; response has an unexpected shape" - : "Database response has an unexpected shape", - ); + if (kind === "mutation" && method === "GET") { + throw notExposed(name, "is not a mutation"); } - return body; -} - -/** - * The `{ items, limit, offset }` envelope a list route answers with. - * @internal Shared with the React hooks; not part of the public surface. - */ -export function isDatabaseListPage( - body: unknown, -): body is DatabaseListPage { - return ( - isRecord(body) && - Array.isArray(body.items) && - typeof body.limit === "number" && - typeof body.offset === "number" - ); + return { url, method }; } -/** - * A detail route answers one bare row. - * @internal Shared with the React hooks; not part of the public surface. - */ -export function isDatabaseRow(body: unknown): body is Record { - return isRecord(body); -} - -/** A delete answers `204` with nothing to decode. */ -function isNoContent(body: unknown): body is undefined { - return body === undefined; -} - -/** A write must not lose a non-finite number to JSON's implicit null coercion. */ -function jsonWriteValue(_key: string, value: unknown): unknown { +/** JSON must not turn a non-finite number into null, nor fail on a bigint. */ +function jsonValue(_key: string, value: unknown): unknown { if (typeof value === "number" && !Number.isFinite(value)) { - throw invalidDatabaseWrite( - "Database write numbers must be finite; use null explicitly to clear a value", + throw invalidDatabaseInput( + "Database input numbers must be finite; use null explicitly to clear a value", ); } return typeof value === "bigint" ? value.toString() : value; } -/** A write sends its values as a JSON body, the only type its route parses. */ -function jsonWrite( - method: "POST" | "PATCH", - values: object, - signal: AbortSignal | undefined, -): RequestInit { +function encode(input: unknown): string { + let json: string | undefined; try { - return { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values, jsonWriteValue), - signal, - }; + json = JSON.stringify(input, jsonValue); } catch (error) { if (error instanceof DatabaseApiError) throw error; - throw invalidDatabaseWrite(); + throw invalidDatabaseInput(); } + // `undefined`, a function, or a symbol has no JSON form at all. + if (json === undefined) { + throw invalidDatabaseInput("Database input must be a JSON value"); + } + return json; } /** - * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their - * signatures carry the checks, while the entity is still a literal. + * Resolve and encode one call of `name` without sending it. Throws + * `NOT_EXPOSED` or `INVALID_REQUEST`; the call's `send` performs one request + * per invocation and never retries. * * @internal Shared with the React hooks; not part of the public surface. */ -export async function createDatabaseRow( - entity: string, - values: object, - init: DatabaseRequestOptions = {}, -): Promise> { - const url = resolveDatabaseUrl(entity, "create"); - return requestDatabase( - url, - jsonWrite("POST", values, init.signal), - isDatabaseRow, - ); +export function prepareDatabaseOperation( + name: string, + input: unknown, + kind?: DatabaseOperationKind, +): DatabaseOperationCall { + const { url: path, method } = resolve(name, kind); + const json = encode(input); + if (method === "GET") { + const url = `${path}?input=${encodeURIComponent(json)}`; + const init: RequestInit = { + method, + cache: "no-store", + headers: { Accept: "application/json" }, + }; + return { url, send: (signal) => send(url, { ...init, signal }, false) }; + } + const init: RequestInit = { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: json, + }; + return { url: path, send: (signal) => send(path, { ...init, signal }, true) }; } -/** - * Untyped update, shared by the typed client and the write hooks. - * @internal Not part of the public surface. - */ -export async function updateDatabaseRow( - entity: string, - id: IdLike, - values: object, - init: DatabaseRequestOptions = {}, -): Promise> { - const url = resolveDatabaseUrl(entity, "update", id); - return requestDatabase( - url, - jsonWrite("PATCH", values, init.signal), - isDatabaseRow, - ); +function discard(response: Response): void { + response.body?.cancel().catch(() => {}); } -/** - * Untyped delete, shared by the typed client and the write hooks. - * @internal Not part of the public surface. - */ -export async function deleteDatabaseRow( - entity: string, - id: IdLike, - init: DatabaseRequestOptions = {}, -): Promise { - const url = resolveDatabaseUrl(entity, "delete", id); - await requestDatabase( - url, - { method: "DELETE", signal: init.signal }, - isNoContent, - ); +/** Read at most `limit` bytes of a body; `null` when it is longer. */ +async function readText( + response: Response, + limit: number, +): Promise { + if (Number(response.headers.get("Content-Length")) > limit) { + discard(response); + return null; + } + if (response.body === null) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) return text + decoder.decode(); + size += value.byteLength; + if (size > limit) { + reader.cancel().catch(() => {}); + return null; + } + text += decoder.decode(value, { stream: true }); + } +} + +/** Keep only bounded details shaped like the server's; they name fields only. */ +function publicDetails(value: unknown): DatabaseErrorDetail[] { + if (!Array.isArray(value)) return []; + return value + .slice(0, MAX_DETAILS) + .flatMap((detail): DatabaseErrorDetail[] => + isRecord(detail) && + isBoundedText(detail.message) && + Array.isArray(detail.path) && + detail.path.length <= MAX_DETAILS && + detail.path.every(isBoundedText) + ? [{ path: [...detail.path], message: detail.message }] + : [], + ); } -function list( - entity: K, - params?: undefined, - init?: DatabaseRequestOptions, -): Promise>>; -function list>( - entity: K, - params: P & ExactDatabaseParams>, - init?: DatabaseRequestOptions, -): Promise>>; -async function list( - entity: string, - params?: DatabaseListQuery, - init: DatabaseRequestOptions = {}, -): Promise> { - let query: string; +/** + * Decode a failure envelope; an oversized or non-JSON body keeps its category. + * A write the server answered as `outcome: "unknown"` failed after it ran. + */ +async function failure( + response: Response, + write: boolean, +): Promise { + const { status } = response; + const text = await readText(response, MAX_ERROR_BYTES).catch(() => null); + let body: unknown; try { - query = encodeDatabaseListQuery(params ?? {}); - } catch (error) { - throw invalidDatabaseQuery(error); + body = text === null ? undefined : JSON.parse(text); + } catch { + body = undefined; + } + const envelope = isRecord(body) ? body : {}; + if (write && envelope.outcome === "unknown") { + return new DatabaseApiError( + "OUTCOME_UNKNOWN", + status, + "Database write may have completed; check its result before retrying", + ); } - const url = resolveDatabaseUrl(entity, "list", undefined, query); - const page = await requestDatabase( - url, - { method: "GET", signal: init.signal }, - isDatabaseListPage, + return new DatabaseApiError( + status === 401 ? "UNAUTHENTICATED" : databaseErrorCategoryForStatus(status), + status, + isBoundedText(envelope.error) && envelope.error !== "" + ? envelope.error + : `Database request failed with status ${status}`, + publicDetails(envelope.details), ); - return page; } -async function get< - K extends DatabaseKeyedEntity, - const P extends DatabaseRecordParams = Record, ->( - entity: K, - id: DatabaseId, - params?: P & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - let query: string; - try { - query = encodeDatabaseRecordQuery(params ?? {}); - } catch (error) { - throw invalidDatabaseQuery(error); - } - const url = resolveDatabaseUrl(entity, "detail", id, query); - const row = await requestDatabase( - url, - { method: "GET", signal: init.signal }, - isDatabaseRow, +/** A reply that answered but cannot be used; a write may still have committed. */ +function unusable( + write: boolean, + status: number, + problem: string, + cause?: unknown, +): DatabaseApiError { + return new DatabaseApiError( + write ? "OUTCOME_UNKNOWN" : "INTERNAL", + status, + write + ? `Database write may have completed; response ${problem}` + : `Database response ${problem}`, + [], + cause === undefined ? undefined : { cause }, ); - return row as DatabaseRecordRow; } -async function create< - K extends DatabaseEntity, - const V extends DatabaseInsert, ->( - entity: K, - values: V & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - // `values` is an insert object; a still-generic `K` only widens its type. - const row = await createDatabaseRow(entity, values as object, init); - // The server projected the row it holds; the types describe that wire. - return row as DatabaseRow; +function sessionChanged(write: boolean): DatabaseApiError { + return new DatabaseApiError( + "SESSION_CHANGED", + null, + write + ? "The session changed before the write answered; it may have completed" + : "The session changed before the read answered", + ); } -async function update< - K extends DatabaseKeyedEntity, - const V extends DatabaseUpdate, ->( - entity: K, - id: DatabaseId, - values: V & ExactDatabaseParams>, - init: DatabaseRequestOptions = {}, -): Promise> { - const row = await updateDatabaseRow(entity, id, values as object, init); - return row as DatabaseRow; +/** The reply's actor, or `undefined` when it names none usable. */ +function actorOf(response: Response): string | undefined { + const actor = response.headers.get(DATABASE_ACTOR_HEADER); + return actor !== null && actor !== "" && actor.length <= MAX_ACTOR_LENGTH + ? actor + : undefined; } -function remove( - entity: K, - id: DatabaseId, - init: DatabaseRequestOptions = {}, -): Promise { - return deleteDatabaseRow(entity, id, init); +/** + * Send one request and decode its JSON answer. A `204` resolves `undefined`. + * An abort rejects with the signal's own reason, so a caller can tell + * cancellation from failure. A reply from an earlier session rejects with + * `SESSION_CHANGED`, and a successful reply that does not name its actor is + * refused rather than shown. + */ +async function send( + url: string, + init: RequestInit, + write: boolean, +): Promise { + const { signal } = init; + const ticket = openDatabaseRequest(); + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + if (signal?.aborted) throw error; + if (!isCurrentDatabaseRequest(ticket)) throw sessionChanged(write); + throw new DatabaseApiError( + write ? "OUTCOME_UNKNOWN" : "TRANSIENT", + null, + write + ? "Database write may have completed; check its result before retrying" + : "Database read failed before receiving a response", + [], + { cause: error }, + ); + } + + const actor = response.status === 401 ? null : actorOf(response); + const current = + actor === undefined + ? isCurrentDatabaseRequest(ticket) + : acceptDatabaseReply(ticket, actor); + if (!current) { + discard(response); + throw sessionChanged(write); + } + if (!response.ok) { + const error = await failure(response, write); + signal?.throwIfAborted(); + throw error; + } + if (actor === undefined) { + discard(response); + throw unusable(write, response.status, "does not name its actor"); + } + + let body: unknown; + if (response.status !== 204) { + let text: string | null; + try { + text = await readText(response, MAX_RESPONSE_BYTES); + } catch (error) { + if (signal?.aborted) throw error; + throw unusable(write, response.status, "could not be read", error); + } + if (text === null) throw unusable(write, response.status, "is too large"); + try { + body = JSON.parse(text); + } catch (error) { + throw unusable(write, response.status, "is not JSON", error); + } + } + signal?.throwIfAborted(); + if (!isCurrentDatabaseRequest(ticket)) throw sessionChanged(write); + return body; } /** - * Typed browser client for the routes `DatabasePlugin` generates. Entity names, - * params, and rows come from the generated `database.d.ts`; routes come from - * the endpoints the server published in the boot payload. + * Typed browser client for the named operations `DatabasePlugin` publishes. + * Names and JSON types come from the generated `database-operations.d.ts`; URLs and + * methods come from the server's boot payload. */ -export const databaseApi: DatabaseApi = { list, get, create, update, remove }; +export const databaseApi: DatabaseApi = { + ...databaseEntityApi, + async execute( + name: N, + input: DatabaseOperationInput, + options: DatabaseRequestOptions = {}, + ): Promise> { + const answer = await prepareDatabaseOperation(name, input).send( + options.signal, + ); + // Generation describes the JSON this operation's route answers with. + return answer as DatabaseOperationOutput; + }, +}; diff --git a/packages/appkit-ui/src/js/database/entity-client.test.ts b/packages/appkit-ui/src/js/database/entity-client.test.ts new file mode 100644 index 000000000..e504298b2 --- /dev/null +++ b/packages/appkit-ui/src/js/database/entity-client.test.ts @@ -0,0 +1,682 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "../config"; +import { + databaseApi as typedApi, + type DatabaseRequestOptions, +} from "./entity-client"; +import { DatabaseApiError } from "./errors"; + +// This package has no generated registry, so every entity name is `never` +// here. The typed surface is compiled against a real schema in the appkit +// type-generator tests; these cover the runtime transport. +const databaseApi = typedApi as unknown as { + list( + entity: string, + params?: object, + init?: DatabaseRequestOptions, + ): Promise<{ items: unknown[]; limit: number; offset: number }>; + get( + entity: string, + id: string | number | bigint, + params?: object, + init?: DatabaseRequestOptions, + ): Promise>; + create( + entity: string, + values: object, + init?: DatabaseRequestOptions, + ): Promise>; + update( + entity: string, + id: string | number | bigint, + values: object, + init?: DatabaseRequestOptions, + ): Promise>; + remove( + entity: string, + id: string | number | bigint, + init?: DatabaseRequestOptions, + ): Promise; +}; + +const PAGE = { items: [{ id: 1, body: "hi" }], limit: 5, offset: 0 }; + +function publish(database: Record | undefined): void { + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: database ? { database } : {}, + plugins: {}, + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error("expected the request to fail"); + }, + (error: unknown) => error, + ); +} + +describe("databaseApi.list", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => json(PAGE)); + vi.stubGlobal("fetch", fetchMock); + publish({ + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + test("refuses an unpublished operation locally without sending a request", async () => { + const error = await rejection(databaseApi.list("boards")); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + name: "DatabaseApiError", + code: "NOT_EXPOSED", + status: null, + details: [], + message: 'Database operation "boards.list" is not exposed', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("refuses every operation when the plugin published no routes", async () => { + _resetConfigCache(); + publish(undefined); + + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code: "NOT_EXPOSED", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the published route with the encoded query", async () => { + const page = await databaseApi.list("notes", { + where: { board_id: 7 }, + order: { created_at: "desc" }, + limit: 5, + }); + + expect(page).toEqual(PAGE); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [path, query] = url.split("?"); + expect(path).toBe("/api/database/notes"); + expect(Object.fromEntries(new URLSearchParams(query))).toEqual({ + where: '{"board_id":7}', + order: '{"created_at":"desc"}', + limit: "5", + }); + expect(init.method).toBe("GET"); + expect(new Headers(init.headers).get("Accept")).toBe("application/json"); + }); + + test("sends no query string when there are no params", async () => { + await databaseApi.list("notes"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes"); + }); + + test("rejects an incomplete filter locally without widening the request", async () => { + await expect( + databaseApi.list("notes", { where: { board_id: undefined } }), + ).rejects.toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database query contains an unsupported value", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("explains an empty filter locally without broadening the read", async () => { + await expect( + databaseApi.list("notes", { where: {} }), + ).rejects.toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Filter cannot be empty; omit where to list all rows", + details: [ + { + path: ["where"], + message: "Filter cannot be empty; omit where to list all rows", + }, + ], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test.each([NaN, Infinity, -Infinity])( + "rejects non-finite filter %s before JSON can turn it into null", + async (value) => { + await expect( + databaseApi.list("notes", { where: { rank: { is: value } } }), + ).rejects.toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database query numbers must be finite", + details: [ + { path: ["where"], message: "Database query numbers must be finite" }, + ], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + test("uses the route path the server published, not a local convention", async () => { + _resetConfigCache(); + publish({ "notes.list": "/custom/base/notes" }); + + await databaseApi.list("notes", { limit: 1 }); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/custom/base/notes?limit=1"); + }); + + test("decodes a failure envelope into its stable category and details", async () => { + fetchMock.mockResolvedValueOnce( + json( + { + error: "Invalid database request", + details: [ + { path: ["where"], message: "Names an unknown column" }, + { path: "where", message: "not a detail" }, + { message: "no path" }, + ], + }, + 400, + ), + ); + + const error = await rejection(databaseApi.list("notes")); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "INVALID_REQUEST", + status: 400, + message: "Invalid database request", + details: [{ path: ["where"], message: "Names an unknown column" }], + }); + }); + + test.each([ + [403, "FORBIDDEN"], + [404, "NOT_FOUND"], + [409, "CONFLICT"], + [413, "PAYLOAD_TOO_LARGE"], + [422, "VALIDATION_FAILED"], + [503, "TRANSIENT"], + [500, "INTERNAL"], + [502, "INTERNAL"], + ])("maps status %i to %s", async (status, code) => { + fetchMock.mockResolvedValueOnce(json({ error: "stable" }, status)); + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code, + status, + }); + }); + + test("keeps the category when a failure body is not JSON", async () => { + fetchMock.mockResolvedValueOnce( + new Response("Bad gateway", { status: 502 }), + ); + + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code: "INTERNAL", + status: 502, + message: "Database request failed with status 502", + details: [], + }); + }); + + test("rejects a success body that is not the list envelope", async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 1 }])); + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code: "INTERNAL", + status: 200, + }); + + fetchMock.mockResolvedValueOnce( + new Response("", { status: 200 }), + ); + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code: "INTERNAL", + status: 200, + message: "Database response is not JSON", + }); + }); + + test("reports an unanswered read as TRANSIENT without claiming the server never saw it", async () => { + const cause = new TypeError("Failed to fetch"); + fetchMock.mockRejectedValueOnce(cause); + + const error = await rejection(databaseApi.list("notes")); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ code: "TRANSIENT", status: null, cause }); + expect((error as Error).message).not.toContain("did not reach the server"); + }); + + test("passes the caller's signal and rejects with its abort, not a database error", async () => { + const controller = new AbortController(); + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + + const pending = databaseApi.list( + "notes", + {}, + { signal: controller.signal }, + ); + controller.abort(); + const error = await rejection(pending); + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + signal: controller.signal, + }); + expect(error).not.toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ name: "AbortError" }); + }); +}); + +describe("databaseApi.get", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => json({ id: 7, title: "Roadmap" })); + vi.stubGlobal("fetch", fetchMock); + publish({ + "boards.list": "/api/database/boards", + "boards.detail": "/api/database/boards/:id", + "events.list": "/api/database/events", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + test("calls the published detail route with the id and the encoded query", async () => { + const row = await databaseApi.get("boards", 7, { + include: { notes: { limit: 20 } }, + select: ["id", "title"], + }); + + expect(row).toEqual({ id: 7, title: "Roadmap" }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [path, query] = url.split("?"); + expect(path).toBe("/api/database/boards/7"); + expect([...new URLSearchParams(query)]).toEqual([ + ["select", '["id","title"]'], + ["include", '{"notes":{"limit":20}}'], + ]); + expect(init.method).toBe("GET"); + }); + + test("encodes the id as one path segment", async () => { + await databaseApi.get("boards", "a/b c?d"); + await databaseApi.get("boards", 9007199254740993n); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/api/database/boards/a%2Fb%20c%3Fd", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "/api/database/boards/9007199254740993", + ); + }); + + test.each(["", ".", ".."])( + "refuses the id %j, which URL resolution would move off the detail route", + async (id) => { + // "" and "." resolve to the list route, ".." to the plugin root. + const error = await rejection(databaseApi.get("boards", id)); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database id must be a non-empty path segment", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + test("keeps an id that only contains dots among other characters", async () => { + await databaseApi.get("boards", "..."); + await databaseApi.get("boards", "v1.2"); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/database/boards/...", + "/api/database/boards/v1.2", + ]); + }); + + test("maps a missing row to NOT_FOUND", async () => { + fetchMock.mockResolvedValueOnce( + json({ error: "Database record not found" }, 404), + ); + + await expect(databaseApi.get("boards", 404)).rejects.toMatchObject({ + name: "DatabaseApiError", + code: "NOT_FOUND", + status: 404, + message: "Database record not found", + }); + }); + + test("refuses a keyless table locally, since it has no detail route", async () => { + const error = await rejection(databaseApi.get("events", 1)); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "NOT_EXPOSED", + status: null, + message: 'Database operation "events.detail" is not exposed', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("rejects a success body that is not one row", async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 7 }])); + + await expect(databaseApi.get("boards", 7)).rejects.toMatchObject({ + code: "INTERNAL", + status: 200, + message: "Database response has an unexpected shape", + }); + }); +}); + +describe("databaseApi writes", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => json({ id: 7, body: "hi" }, 201)); + vi.stubGlobal("fetch", fetchMock); + publish({ + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + "notes.create": "/api/database/notes", + "notes.update": "/api/database/notes/:id", + "notes.delete": "/api/database/notes/:id", + "ledger.create": "/api/database/ledger", + // Read-only: the plugin published reads but no writes. + "note_events.list": "/api/database/note_events", + "note_events.detail": "/api/database/note_events/:id", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + function sent(index = 0): { url: string; init: RequestInit } { + const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; + return { url, init }; + } + + test("creates with a JSON body on the published route and returns the row", async () => { + const row = await databaseApi.create("notes", { + board_id: 7, + author: "ada", + body: "hi", + }); + + expect(row).toEqual({ id: 7, body: "hi" }); + const { url, init } = sent(); + expect(url).toBe("/api/database/notes"); + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Accept")).toBe("application/json"); + expect(JSON.parse(init.body as string)).toEqual({ + board_id: 7, + author: "ada", + body: "hi", + }); + }); + + test("sends a bigint value as its decimal string, which the server reads back exactly", async () => { + await databaseApi.create("ledger", { + seq: 9007199254740993n, + note: "x", + }); + + expect(sent().init.body).toBe('{"seq":"9007199254740993","note":"x"}'); + }); + + test.each([NaN, Infinity, -Infinity])( + "rejects non-finite write %s rather than clearing a nullable value", + async (value) => { + for (const write of [ + () => databaseApi.create("notes", { rank: value }), + () => databaseApi.update("notes", 7, { rank: value }), + () => databaseApi.update("notes", 7, { payload: { scores: [value] } }), + ]) { + await expect(write()).rejects.toMatchObject({ + name: "DatabaseApiError", + code: "INVALID_REQUEST", + status: null, + message: + "Database write numbers must be finite; use null explicitly to clear a value", + details: [ + { + path: ["body"], + message: + "Database write numbers must be finite; use null explicitly to clear a value", + }, + ], + }); + } + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + test("keeps an explicit null and finite numbers unchanged", async () => { + await databaseApi.update("notes", 7, { rank: null, payload: { score: 0 } }); + expect(sent().init.body).toBe('{"rank":null,"payload":{"score":0}}'); + }); + + test("sanitizes serialization failures before any write is sent", async () => { + const values = { + toJSON: () => { + throw new Error("private serialization detail"); + }, + }; + await expect(databaseApi.create("notes", values)).rejects.toMatchObject({ + code: "INVALID_REQUEST", + status: null, + message: "Database write contains an unsupported value", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("reports an unanswered write as unknown because it may have committed", async () => { + const cause = new TypeError("Connection lost after sending the request"); + fetchMock.mockRejectedValue(cause); + + for (const request of [ + () => + databaseApi.create("notes", { board_id: 7, author: "ada", body: "hi" }), + () => databaseApi.update("notes", 7, { body: "edited" }), + () => databaseApi.remove("notes", 7), + ]) { + await expect(request()).rejects.toMatchObject({ + code: "OUTCOME_UNKNOWN", + status: null, + cause, + message: expect.stringContaining("may have completed"), + }); + } + }); + + test("updates one row with PATCH on its encoded id", async () => { + fetchMock.mockResolvedValueOnce(json({ id: 7, body: "edited" })); + + const row = await databaseApi.update("notes", "a/b", { body: "edited" }); + + expect(row).toEqual({ id: 7, body: "edited" }); + const { url, init } = sent(); + expect(url).toBe("/api/database/notes/a%2Fb"); + expect(init.method).toBe("PATCH"); + expect(new Headers(init.headers).get("Content-Type")).toBe( + "application/json", + ); + expect(init.body).toBe('{"body":"edited"}'); + }); + + test("deletes one row and resolves without reading the empty 204 body", async () => { + const response = new Response(null, { status: 204 }); + const readBody = vi.spyOn(response, "json"); + fetchMock.mockResolvedValueOnce(response); + + await expect(databaseApi.remove("notes", 7)).resolves.toBeUndefined(); + + const { url, init } = sent(); + expect(url).toBe("/api/database/notes/7"); + expect(init.method).toBe("DELETE"); + expect(init.body).toBeUndefined(); + expect(readBody).not.toHaveBeenCalled(); + }); + + test("keeps the server's validation details on a 422", async () => { + fetchMock.mockResolvedValueOnce( + json( + { + error: "Database request failed validation", + details: [ + { path: ["body"], message: "Must be at most 5000 characters" }, + ], + }, + 422, + ), + ); + + const error = await rejection( + databaseApi.create("notes", { board_id: 7, author: "ada", body: "x" }), + ); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + code: "VALIDATION_FAILED", + status: 422, + message: "Database request failed validation", + details: [{ path: ["body"], message: "Must be at most 5000 characters" }], + }); + }); + + test("maps a 415 to UNSUPPORTED_MEDIA_TYPE", async () => { + fetchMock.mockResolvedValueOnce( + json({ error: "Database request body must be JSON" }, 415), + ); + + await expect( + databaseApi.update("notes", 7, { body: "x" }), + ).rejects.toMatchObject({ + code: "UNSUPPORTED_MEDIA_TYPE", + status: 415, + message: "Database request body must be JSON", + }); + }); + + test("maps a missing row on update or delete to NOT_FOUND", async () => { + fetchMock.mockResolvedValue( + json({ error: "Database record not found" }, 404), + ); + + await expect( + databaseApi.update("notes", 404, { body: "x" }), + ).rejects.toMatchObject({ code: "NOT_FOUND", status: 404 }); + await expect(databaseApi.remove("notes", 404)).rejects.toMatchObject({ + code: "NOT_FOUND", + status: 404, + }); + }); + + test("refuses a write addressed by an id that is not one path segment", async () => { + const results = await Promise.all([ + rejection(databaseApi.update("notes", "..", { body: "x" })), + rejection(databaseApi.remove("notes", ".")), + rejection(databaseApi.remove("notes", "")), + ]); + + expect(results).toMatchObject([ + { code: "INVALID_REQUEST", status: null }, + { code: "INVALID_REQUEST", status: null }, + { code: "INVALID_REQUEST", status: null }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("refuses writes the plugin did not publish without sending a request", async () => { + const results = await Promise.all([ + rejection(databaseApi.create("note_events", { note_id: 1 })), + rejection(databaseApi.update("note_events", 1, { action: "x" })), + rejection(databaseApi.remove("note_events", 1)), + ]); + + expect(results.map((error) => error instanceof DatabaseApiError)).toEqual([ + true, + true, + true, + ]); + expect(results).toMatchObject([ + { + code: "NOT_EXPOSED", + status: null, + message: 'Database operation "note_events.create" is not exposed', + }, + { code: "NOT_EXPOSED", message: expect.stringContaining(".update") }, + { code: "NOT_EXPOSED", message: expect.stringContaining(".delete") }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("reports a successful write with an invalid response as outcome unknown", async () => { + fetchMock.mockResolvedValueOnce(json([{ id: 7 }], 201)); + await expect( + databaseApi.create("notes", { board_id: 7, author: "a", body: "b" }), + ).rejects.toMatchObject({ + code: "OUTCOME_UNKNOWN", + status: 201, + message: expect.stringContaining("may have completed"), + }); + + fetchMock.mockResolvedValueOnce(json({ id: 7 }, 200)); + await expect(databaseApi.remove("notes", 7)).rejects.toMatchObject({ + code: "OUTCOME_UNKNOWN", + status: 200, + }); + + fetchMock.mockResolvedValueOnce( + new Response("broken JSON", { status: 200 }), + ); + await expect( + databaseApi.update("notes", 7, { body: "edited" }), + ).rejects.toMatchObject({ code: "OUTCOME_UNKNOWN", status: 200 }); + }); +}); diff --git a/packages/appkit-ui/src/js/database/entity-client.ts b/packages/appkit-ui/src/js/database/entity-client.ts new file mode 100644 index 000000000..32137f78a --- /dev/null +++ b/packages/appkit-ui/src/js/database/entity-client.ts @@ -0,0 +1,507 @@ +import { + type DatabaseErrorDetail, + type DatabaseListPage, + type DatabaseListQuery, + databaseErrorCategoryForStatus, + encodeDatabaseListQuery, + encodeDatabaseRecordQuery, + type ExactDatabaseParams, +} from "shared"; + +import { getClientConfig } from "../config"; +import { + DatabaseApiError, + invalidDatabaseQuery, + invalidDatabaseWrite, +} from "./errors"; +import type { + DatabaseEntity, + DatabaseId, + DatabaseInsert, + DatabaseKeyedEntity, + DatabaseListParams, + DatabaseListRow, + DatabaseRecordParams, + DatabaseRecordRow, + DatabaseRow, + DatabaseUpdate, +} from "./types"; + +/** + * Suffix of the endpoint names `DatabasePlugin` publishes for each table. + * @internal Shared with the React hooks; not part of the public surface. + */ +export type DatabaseOperation = + | "list" + | "detail" + | "create" + | "update" + | "delete"; + +/** + * An id as a keyed route addresses it in its path. + * @internal Shared with the React hooks; not part of the public surface. + */ +export type IdLike = string | number | bigint; + +/** Per-call options for a database request. */ +export interface DatabaseRequestOptions { + /** + * Cancels the request; the promise then rejects with the abort reason. + * Cancelling a write does not undo it once the server has committed. + */ + readonly signal?: AbortSignal; +} + +/** Typed calls to the routes `DatabasePlugin` generates under `/api/database`. */ +export interface DatabaseApi { + /** + * Read one page from `GET /api/database/`. Rows are public rows as + * JSON carries them, narrowed by `select` and widened by `include`. + * + * @example + * ```typescript + * const page = await databaseApi.list("notes", { + * where: { board_id: 7 }, + * order: { created_at: "desc" }, + * limit: 5, + * }); + * page.items[0]?.body; + * ``` + */ + list( + entity: K, + params?: undefined, + init?: DatabaseRequestOptions, + ): Promise>>; + list>( + entity: K, + params: P & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>>; + + /** + * Read one row from `GET /api/database//:id`. Only entities with a + * public primary key have this route; a missing row rejects with + * `NOT_FOUND`. + * + * @example + * ```typescript + * const board = await databaseApi.get("boards", 7, { + * include: { notes: { limit: 20 } }, + * }); + * board.notes.length; + * ``` + */ + get< + K extends DatabaseKeyedEntity, + const P extends DatabaseRecordParams = Record, + >( + entity: K, + id: DatabaseId, + params?: P & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>; + + /** + * Create one row with `POST /api/database/` and return it as the + * database holds it. Private, generated, and undeclared fields are compile + * errors, as the server refuses them. + * + * @example + * ```typescript + * const note = await databaseApi.create("notes", { + * board_id: 7, + * author: "ada", + * body: "Ship it", + * }); + * note.id; + * ``` + */ + create>( + entity: K, + values: V & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>; + + /** + * Change some fields of one row with `PATCH /api/database//:id` and + * return the updated row. A missing row rejects with `NOT_FOUND`. + * + * @example + * ```typescript + * await databaseApi.update("notes", 7, { body: "Shipped" }); + * ``` + */ + update>( + entity: K, + id: DatabaseId, + values: V & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>; + + /** + * Delete one row with `DELETE /api/database//:id`. A missing row + * rejects with `NOT_FOUND`. + * + * @example + * ```typescript + * await databaseApi.remove("notes", 7); + * ``` + */ + remove( + entity: K, + id: DatabaseId, + init?: DatabaseRequestOptions, + ): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * An id that URL resolution would not keep as one path segment: `""` and + * `"."` collapse onto the list route and `".."` climbs above the table, so a + * keyed request would silently reach another route. + */ +function isUnaddressableId(segment: string): boolean { + return segment === "" || segment === "." || segment === ".."; +} + +/** + * Find the route the server published for one operation. The plugin publishes + * only what its `api` configuration exposes, so a missing entry is refused + * here with `NOT_EXPOSED` and no request is sent. An id that is not one path + * segment is refused with `INVALID_REQUEST`, also without a request. + * + * @internal Shared with the React hooks; not part of the public surface. + */ +export function resolveDatabaseUrl( + entity: string, + operation: DatabaseOperation, + id?: IdLike, + query?: string, +): string { + const name = `${entity}.${operation}`; + const endpoints = getClientConfig().endpoints.database; + const path = + isRecord(endpoints) && Object.hasOwn(endpoints, name) + ? endpoints[name] + : undefined; + if (typeof path !== "string") { + throw new DatabaseApiError( + "NOT_EXPOSED", + null, + `Database operation "${name}" is not exposed`, + ); + } + let url = path; + if (id !== undefined) { + const segment = String(id); + if (isUnaddressableId(segment)) { + // The id may be user input from a route param; do not echo it. + throw new DatabaseApiError( + "INVALID_REQUEST", + null, + "Database id must be a non-empty path segment", + ); + } + url = path.replace(":id", encodeURIComponent(segment)); + } + return query ? `${url}?${query}` : url; +} + +/** Keep only details shaped like the server's; they name public fields only. */ +function publicDetails(value: unknown): DatabaseErrorDetail[] { + if (!Array.isArray(value)) return []; + return value.flatMap((detail): DatabaseErrorDetail[] => + isRecord(detail) && + typeof detail.message === "string" && + Array.isArray(detail.path) && + detail.path.every((segment) => typeof segment === "string") + ? [{ path: [...detail.path], message: detail.message }] + : [], + ); +} + +/** Decode a failure envelope; a non-JSON body still keeps its category. */ +async function failure(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = isRecord(body) ? body : {}; + return new DatabaseApiError( + databaseErrorCategoryForStatus(response.status), + response.status, + typeof envelope.error === "string" + ? envelope.error + : `Database request failed with status ${response.status}`, + publicDetails(envelope.details), + ); +} + +/** + * Send one request and decode its JSON body, throwing `DatabaseApiError` for + * anything but the shape `accept` expects. A `204` has no body, so `accept` + * sees `undefined`. An abort rejects with the signal's own reason, so a + * caller can tell cancellation from failure. + * + * @internal Shared with the React hooks; not part of the public surface. + */ +export async function requestDatabase( + url: string, + init: RequestInit, + accept: (body: unknown) => body is T, +): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + const write = init.method !== "GET"; + let response: Response; + try { + response = await fetch(url, { ...init, headers }); + } catch (error) { + if (init.signal?.aborted) throw error; + throw new DatabaseApiError( + write ? "OUTCOME_UNKNOWN" : "TRANSIENT", + null, + write + ? "Database write may have completed; check its result before retrying" + : "Database read failed before receiving a response", + [], + { cause: error }, + ); + } + if (!response.ok) throw await failure(response); + + let body: unknown; + try { + body = response.status === 204 ? undefined : await response.json(); + } catch (error) { + if (init.signal?.aborted) throw error; + throw new DatabaseApiError( + write ? "OUTCOME_UNKNOWN" : "INTERNAL", + response.status, + write + ? "Database write may have completed; response is not JSON" + : "Database response is not JSON", + [], + { cause: error }, + ); + } + if (!accept(body)) { + throw new DatabaseApiError( + write ? "OUTCOME_UNKNOWN" : "INTERNAL", + response.status, + write + ? "Database write may have completed; response has an unexpected shape" + : "Database response has an unexpected shape", + ); + } + return body; +} + +/** + * The `{ items, limit, offset }` envelope a list route answers with. + * @internal Shared with the React hooks; not part of the public surface. + */ +export function isDatabaseListPage( + body: unknown, +): body is DatabaseListPage { + return ( + isRecord(body) && + Array.isArray(body.items) && + typeof body.limit === "number" && + typeof body.offset === "number" + ); +} + +/** + * A detail route answers one bare row. + * @internal Shared with the React hooks; not part of the public surface. + */ +export function isDatabaseRow(body: unknown): body is Record { + return isRecord(body); +} + +/** A delete answers `204` with nothing to decode. */ +function isNoContent(body: unknown): body is undefined { + return body === undefined; +} + +/** A write must not lose a non-finite number to JSON's implicit null coercion. */ +function jsonWriteValue(_key: string, value: unknown): unknown { + if (typeof value === "number" && !Number.isFinite(value)) { + throw invalidDatabaseWrite( + "Database write numbers must be finite; use null explicitly to clear a value", + ); + } + return typeof value === "bigint" ? value.toString() : value; +} + +/** A write sends its values as a JSON body, the only type its route parses. */ +function jsonWrite( + method: "POST" | "PATCH", + values: object, + signal: AbortSignal | undefined, +): RequestInit { + try { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values, jsonWriteValue), + signal, + }; + } catch (error) { + if (error instanceof DatabaseApiError) throw error; + throw invalidDatabaseWrite(); + } +} + +/** + * Untyped create behind `databaseApi.create` and `useDatabaseCreate`; their + * signatures carry the checks, while the entity is still a literal. + * + * @internal Shared with the React hooks; not part of the public surface. + */ +export async function createDatabaseRow( + entity: string, + values: object, + init: DatabaseRequestOptions = {}, +): Promise> { + const url = resolveDatabaseUrl(entity, "create"); + return requestDatabase( + url, + jsonWrite("POST", values, init.signal), + isDatabaseRow, + ); +} + +/** + * Untyped update, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ +export async function updateDatabaseRow( + entity: string, + id: IdLike, + values: object, + init: DatabaseRequestOptions = {}, +): Promise> { + const url = resolveDatabaseUrl(entity, "update", id); + return requestDatabase( + url, + jsonWrite("PATCH", values, init.signal), + isDatabaseRow, + ); +} + +/** + * Untyped delete, shared by the typed client and the write hooks. + * @internal Not part of the public surface. + */ +export async function deleteDatabaseRow( + entity: string, + id: IdLike, + init: DatabaseRequestOptions = {}, +): Promise { + const url = resolveDatabaseUrl(entity, "delete", id); + await requestDatabase( + url, + { method: "DELETE", signal: init.signal }, + isNoContent, + ); +} + +function list( + entity: K, + params?: undefined, + init?: DatabaseRequestOptions, +): Promise>>; +function list>( + entity: K, + params: P & ExactDatabaseParams>, + init?: DatabaseRequestOptions, +): Promise>>; +async function list( + entity: string, + params?: DatabaseListQuery, + init: DatabaseRequestOptions = {}, +): Promise> { + let query: string; + try { + query = encodeDatabaseListQuery(params ?? {}); + } catch (error) { + throw invalidDatabaseQuery(error); + } + const url = resolveDatabaseUrl(entity, "list", undefined, query); + const page = await requestDatabase( + url, + { method: "GET", signal: init.signal }, + isDatabaseListPage, + ); + return page; +} + +async function get< + K extends DatabaseKeyedEntity, + const P extends DatabaseRecordParams = Record, +>( + entity: K, + id: DatabaseId, + params?: P & ExactDatabaseParams>, + init: DatabaseRequestOptions = {}, +): Promise> { + let query: string; + try { + query = encodeDatabaseRecordQuery(params ?? {}); + } catch (error) { + throw invalidDatabaseQuery(error); + } + const url = resolveDatabaseUrl(entity, "detail", id, query); + const row = await requestDatabase( + url, + { method: "GET", signal: init.signal }, + isDatabaseRow, + ); + return row as DatabaseRecordRow; +} + +async function create< + K extends DatabaseEntity, + const V extends DatabaseInsert, +>( + entity: K, + values: V & ExactDatabaseParams>, + init: DatabaseRequestOptions = {}, +): Promise> { + // `values` is an insert object; a still-generic `K` only widens its type. + const row = await createDatabaseRow(entity, values as object, init); + // The server projected the row it holds; the types describe that wire. + return row as DatabaseRow; +} + +async function update< + K extends DatabaseKeyedEntity, + const V extends DatabaseUpdate, +>( + entity: K, + id: DatabaseId, + values: V & ExactDatabaseParams>, + init: DatabaseRequestOptions = {}, +): Promise> { + const row = await updateDatabaseRow(entity, id, values as object, init); + return row as DatabaseRow; +} + +function remove( + entity: K, + id: DatabaseId, + init: DatabaseRequestOptions = {}, +): Promise { + return deleteDatabaseRow(entity, id, init); +} + +/** + * Typed browser client for the routes `DatabasePlugin` generates. Entity names, + * params, and rows come from the generated `database.d.ts`; routes come from + * the endpoints the server published in the boot payload. + */ +export const databaseApi: DatabaseApi = { list, get, create, update, remove }; diff --git a/packages/appkit-ui/src/js/database/errors.ts b/packages/appkit-ui/src/js/database/errors.ts index 7001a49b6..ec3ced716 100644 --- a/packages/appkit-ui/src/js/database/errors.ts +++ b/packages/appkit-ui/src/js/database/errors.ts @@ -5,15 +5,19 @@ import { } from "shared"; /** - * A server category, `NOT_EXPOSED` for unpublished routes, or - * `OUTCOME_UNKNOWN` when a write has no response and may have committed. + * A server category, or a client-side outcome: `NOT_EXPOSED` for an operation + * the server did not publish, `UNAUTHENTICATED` for a 401, `SESSION_CHANGED` + * for a reply that belongs to an earlier session, and `OUTCOME_UNKNOWN` when a + * write has no usable reply and may have committed. */ export type DatabaseApiErrorCode = | DatabaseErrorCategory | "NOT_EXPOSED" + | "UNAUTHENTICATED" + | "SESSION_CHANGED" | "OUTCOME_UNKNOWN"; -/** A failed database request, decoded from the generated `{ error, details }`. */ +/** A failed database operation call, decoded from the server's `{ error, details }`. */ export class DatabaseApiError extends Error { /** Stable category; branch on this rather than on `message`. */ readonly code: DatabaseApiErrorCode; @@ -37,6 +41,23 @@ export class DatabaseApiError extends Error { } } +/** Anything a call 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, "Database request failed", [], { + cause: error, + }); +} + +/** Local input failures name the input, never the values it would have sent. */ +export function invalidDatabaseInput( + message = "Database input contains an unsupported value", +): DatabaseApiError { + return new DatabaseApiError("INVALID_REQUEST", null, message, [ + { path: ["input"], message }, + ]); +} + /** Do not echo a caller's invalid query values into the client-facing error. */ export function invalidDatabaseQuery(error?: unknown): DatabaseApiError { if (error instanceof DatabaseQueryEncodingError) { diff --git a/packages/appkit-ui/src/js/database/session.ts b/packages/appkit-ui/src/js/database/session.ts new file mode 100644 index 000000000..fbe3a88be --- /dev/null +++ b/packages/appkit-ui/src/js/database/session.ts @@ -0,0 +1,87 @@ +/** + * Which signed-in actor the page's database data belongs to. + * + * Every authenticated operation reply names its actor in an opaque header. + * Requests are numbered as they are sent and stamped with the session epoch. + * A reply from an earlier epoch, or one that contradicts a reply to a later + * request, is stale and never delivered. A reply to a later request that names + * another actor, or a 401 after an authenticated reply, opens a new epoch, and + * subscribers drop what they show for the old one. + */ + +/** The reply header that names, opaquely, the actor a reply was computed for. */ +export const DATABASE_ACTOR_HEADER = "x-appkit-database-actor"; + +/** One request's place in the session history. */ +interface DatabaseRequestTicket { + readonly sequence: number; + /** The epoch it was sent in, moved forward when its own reply opens one. */ + epoch: number; +} + +let sequence = 0; +let epoch = 0; +/** The actor this epoch's replies named; `null` before one, or after a 401. */ +let actor: string | null = null; +/** The latest request whose reply agreed with `actor`. */ +let confirmed = 0; +const listeners = new Set<() => void>(); + +export function openDatabaseRequest(): DatabaseRequestTicket { + sequence += 1; + return { sequence, epoch }; +} + +/** + * Record what a reply says about the session and report whether to deliver + * it. `observed` is the reply's actor, or `null` for a 401. A reply that opens + * a new epoch is delivered in that epoch. + */ +export function acceptDatabaseReply( + ticket: DatabaseRequestTicket, + observed: string | null, +): boolean { + if (ticket.epoch !== epoch) return false; + if (observed === actor) { + confirmed = Math.max(confirmed, ticket.sequence); + return true; + } + // A request sent before one that answered as `actor` saw an older session. + if (ticket.sequence < confirmed) return false; + const opensEpoch = actor !== null; + actor = observed; + confirmed = ticket.sequence; + if (opensEpoch) { + epoch += 1; + ticket.epoch = epoch; + for (const listener of [...listeners]) listener(); + } + return true; +} + +/** Whether a reply to `ticket` still belongs to the current session. */ +export function isCurrentDatabaseRequest( + ticket: DatabaseRequestTicket, +): boolean { + return ticket.epoch === epoch; +} + +export function getDatabaseSessionEpoch(): number { + return epoch; +} + +/** Call `listener` whenever a new epoch opens. Returns the unsubscribe. */ +export function subscribeDatabaseSession(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Test-only: forget the session. Subscribers stay attached. */ +export function resetDatabaseSession(): void { + sequence = 0; + epoch = 0; + actor = null; + confirmed = 0; +} diff --git a/packages/appkit-ui/src/js/database/test-utils.ts b/packages/appkit-ui/src/js/database/test-utils.ts new file mode 100644 index 000000000..47fddc2cf --- /dev/null +++ b/packages/appkit-ui/src/js/database/test-utils.ts @@ -0,0 +1,115 @@ +import { vi } from "vitest"; + +import { _resetConfigCache } from "../config"; +import { DATABASE_ACTOR_HEADER, resetDatabaseSession } from "./session"; + +/** Which operations the boot payload publishes: URLs and methods by name. */ +interface PublishedOperations { + endpoints?: Record; + operations?: Record; +} + +/** The URL `Plugin.route` registers for an operation in these tests. */ +export function operationPath(name: string): string { + return `/api/database/operations/${encodeURIComponent(name)}`; +} + +/** Publish named operations the way the server's boot payload carries them. */ +export function publishOperations(methods: Record): void { + publishBoot({ + endpoints: Object.fromEntries( + Object.keys(methods).map((name) => [name, operationPath(name)]), + ), + operations: methods, + }); +} + +export function publishBoot({ endpoints, operations }: PublishedOperations) { + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: endpoints ? { database: endpoints } : {}, + plugins: operations ? { database: { operations } } : {}, + }; + _resetConfigCache(); + resetDatabaseSession(); +} + +export function resetDatabaseTestEnvironment(): void { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + resetDatabaseSession(); +} + +interface ReplyOptions { + status?: number; + /** The opaque actor header; `null` sends none. Default `"actor-a"`. */ + actor?: string | null; + headers?: Record; +} + +export function reply(body: unknown, options: ReplyOptions = {}): Response { + const { status = 200, actor = "actor-a", headers = {} } = options; + const all = new Headers(headers); + if (actor !== null) all.set(DATABASE_ACTOR_HEADER, actor); + if (status === 204) return new Response(null, { status, headers: all }); + all.set("Content-Type", "application/json"); + return new Response(JSON.stringify(body), { status, headers: all }); +} + +/** One request the mocked `fetch` holds open until the test answers it. */ +export interface PendingRequest { + readonly url: string; + readonly method: string; + readonly headers: Headers; + readonly body: string | undefined; + readonly cache: RequestCache | undefined; + readonly signal: AbortSignal | undefined; + /** The decoded `input` query parameter of a GET. */ + readonly input: unknown; + respond(body?: unknown, options?: ReplyOptions): void; + respondWith(response: Response): void; + fail(error: unknown): void; +} + +/** + * Stub `fetch` so every request stays open until the test answers it, which + * lets a test choose the order replies arrive in. Aborts are ignored, so a + * test can still deliver a reply after its request was cancelled. + */ +export function mockDatabaseFetch() { + const requests: PendingRequest[] = []; + const fetchMock = vi.fn( + (url: string, init: RequestInit = {}) => + new Promise((resolve, reject) => { + const query = new URL(url, "http://localhost").searchParams.get( + "input", + ); + requests.push({ + url, + method: init.method ?? "GET", + headers: new Headers(init.headers), + body: typeof init.body === "string" ? init.body : undefined, + cache: init.cache, + signal: init.signal ?? undefined, + input: query === null ? undefined : JSON.parse(query), + respond: (body, options) => resolve(reply(body, options)), + respondWith: resolve, + fail: reject, + }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + /** The request sent `index`-th; throws when there is none. */ + const request = (index: number): PendingRequest => { + const found = requests[index]; + if (!found) throw new Error(`no request #${index} was sent`); + return found; + }; + return { requests, fetchMock, request }; +} + +/** Let pending promise reactions run. */ +export const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index 9ed7463c6..bd31cc546 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -1,5 +1,7 @@ import type { DatabaseApiEntityFor, + DatabaseOperationMethod, + DatabricksAppKitDatabaseOperations, IdFor, InsertFor, KeyedEntityFor, @@ -82,3 +84,33 @@ export type DatabaseRow = PublicRowFor< DatabaseRegistry, K >; + +type Operations = DatabricksAppKitDatabaseOperations; + +/** Names of the generated database operations, or `never` before generation. */ +export type DatabaseOperationName = Extract; + +type NamesWithMethod = { + [N in DatabaseOperationName]: Operations[N] extends { readonly method: M } + ? N + : never; +}[DatabaseOperationName]; + +/** GET operations, the names `useDatabaseQuery` reads. */ +export type DatabaseQueryName = NamesWithMethod<"GET">; + +/** POST, PATCH, PUT, and DELETE operations, the names `useDatabaseMutation` runs. */ +export type DatabaseMutationName = NamesWithMethod< + Exclude +>; + +/** The JSON input operation `N` accepts, as generation rendered it. */ +export type DatabaseOperationInput = + Operations[N] extends { readonly input: infer Input } ? Input : never; + +/** + * The JSON a successful call of `N` answers with, as generation rendered it. + * `void` when the operation answers without a body. + */ +export type DatabaseOperationOutput = + Operations[N] extends { readonly output: infer Output } ? Output : never; diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 6bf2a9ff3..2289599b1 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -17,10 +17,10 @@ export { 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. +// Database hooks: the entity read and write hooks, and the operation query and +// mutation hooks. Track the `database` plugin, which ships at beta from +// '@databricks/appkit/beta'. The client and the registry binding live in +// '@databricks/appkit-ui/js/beta'; the types the hooks mention are re-exported. export { DatabaseApiError, type DatabaseApiErrorCode, @@ -32,6 +32,11 @@ export { type DatabaseListPage, type DatabaseListParams, type DatabaseListRow, + type DatabaseMutationName, + type DatabaseOperationInput, + type DatabaseOperationName, + type DatabaseOperationOutput, + type DatabaseQueryName, type DatabaseRecordParams, type DatabaseRecordRow, type DatabaseRow, @@ -62,6 +67,16 @@ export { type UseDatabaseRecordResult, useDatabaseRecord, } from "./hooks/use-database-record"; +export { + type DatabaseMutationOutcome, + type UseDatabaseMutationResult, + useDatabaseMutation, +} from "./hooks/use-database-mutation"; +export { + type UseDatabaseQueryOptions, + type UseDatabaseQueryResult, + useDatabaseQuery, +} from "./hooks/use-database-query"; export { type UseDatabaseUpdateOptions, type UseDatabaseUpdateResult, diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts new file mode 100644 index 000000000..a005ab01b --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-entity.types.test.ts @@ -0,0 +1,485 @@ +import path from "node:path"; + +import ts from "typescript"; +import { expect, test, vi } from "vitest"; + +const packageRoot = + path.basename(process.cwd()) === "appkit-ui" + ? process.cwd() + : path.join(process.cwd(), "packages", "appkit-ui"); + +// Each probe type-checks the package with the TypeScript compiler, which can +// exceed Vitest's 5s default on CI's 2-core runners. +vi.setConfig({ testTimeout: 30_000 }); + +function compileTypeProbe(source: string): string[] { + const configPath = path.join(packageRoot, "tsconfig.json"); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + packageRoot, + ); + const filename = path.join(packageRoot, "__type-tests__", "database.ts"); + const host = ts.createCompilerHost(parsed.options); + const getSourceFile = host.getSourceFile.bind(host); + + host.fileExists = (candidate) => + candidate === filename || ts.sys.fileExists(candidate); + host.readFile = (candidate) => + candidate === filename ? source : ts.sys.readFile(candidate); + host.getSourceFile = (candidate, languageVersion, onError, shouldCreate) => + candidate === filename + ? ts.createSourceFile( + candidate, + source, + languageVersion, + true, + ts.ScriptKind.TS, + ) + : getSourceFile(candidate, languageVersion, onError, shouldCreate); + + const program = ts.createProgram([filename], parsed.options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ); +} + +// Entries as `appkit generate-types` renders them (trusted facets omitted: the +// browser reads only `publicRow`, `includes`, and `api`), bound the same way. +const REGISTRY = ` + type Filter = T & { and?: readonly Filter[]; or?: readonly Filter[] }; + type Text = string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + type Int = number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + type Big = bigint | { eq?: bigint; neq?: bigint; in?: readonly (bigint)[]; gt?: bigint; gte?: bigint; lt?: bigint; lte?: bigint; }; + + interface Fixture { + "users": { + publicRow: { "slug": string; "name": string }; + includes: { "posts": { to: "posts"; many: true } }; + api: { + insert: { "slug": string; "name": string }; + update: { "name"?: string }; + filters: Filter<{ "slug"?: Text; "name"?: Text }>; + orderable: "slug" | "name"; + key: "slug"; + }; + }; + "posts": { + publicRow: { "id": number; "user_slug": string; "title": string; "total": bigint; "payload": unknown | null }; + includes: { + "users": { to: "users"; many: false }; + "comments": { to: "comments"; many: true }; + }; + api: { + insert: { "user_slug": string; "title": string; "total": bigint; "payload"?: unknown | null }; + update: { "title"?: string; "total"?: bigint; "payload"?: unknown | null }; + filters: Filter<{ "id"?: Int; "user_slug"?: Text; "title"?: Text; "total"?: Big }>; + orderable: "id" | "user_slug" | "title" | "total"; + key: "id"; + }; + }; + "comments": { + publicRow: { "id": number; "post_id": number; "body": string }; + includes: { "posts": { to: "posts"; many: false } }; + api: { + insert: { "post_id": number; "body": string }; + update: { "body"?: string }; + filters: Filter<{ "id"?: Int; "post_id"?: Int; "body"?: Text }>; + orderable: "id" | "post_id" | "body"; + key: "id"; + }; + }; + "ledger": { + publicRow: { "seq": bigint; "note": string }; + includes: {}; + api: { + insert: { "seq": bigint; "note": string }; + update: { "note"?: string }; + filters: Filter<{ "seq"?: Big; "note"?: Text }>; + orderable: "seq" | "note"; + key: "seq"; + }; + }; + "sessions": { + publicRow: { "user_slug": string }; + includes: { "users": { to: "users"; many: false } }; + api: { + insert: { "user_slug": string }; + update: { "user_slug"?: string }; + filters: Filter<{ "user_slug"?: Text }>; + orderable: "user_slug"; + key: never; + }; + }; + "events": { + publicRow: { "message": string }; + includes: {}; + api: { + insert: { "message": string }; + update: { "message"?: string }; + filters: Filter<{ "message"?: Text }>; + orderable: "message"; + key: never; + }; + }; + } + + declare module "@databricks/appkit-ui/js/beta" { + interface DatabaseRegistry extends Fixture {} + } +`; + +test("read hooks type entities, params, and rows from the generated registry", () => { + const diagnostics = compileTypeProbe(` + import { databaseApi } from "@databricks/appkit-ui/js/beta"; + import { + type DatabaseEntity, + type DatabaseKeyedEntity, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + const keyed: DatabaseKeyedEntity[] = ["users", "posts", "comments", "ledger"]; + const listable: DatabaseEntity[] = ["sessions", "events"]; + void [keyed, listable]; + + export function Lists() { + const users = useDatabaseList("users", { + where: { name: { ilike: "%ada%" } }, + include: { posts: { limit: 2, select: ["title", "total"] } }, + }); + const title: string | undefined = users.data?.items[0]?.posts[0]?.title; + // A bigint travels as its decimal string. + const total: string | undefined = users.data?.items[0]?.posts[0]?.total; + // @ts-expect-error unselected relation columns are absent + users.data?.items[0]?.posts[0]?.user_slug; + const failed: "NOT_EXPOSED" | "NOT_FOUND" | undefined = + users.error?.code === "NOT_EXPOSED" || users.error?.code === "NOT_FOUND" + ? users.error.code + : undefined; + users.refetch(); + + const posts = useDatabaseList("posts", { include: { users: true, comments: { limit: 3 } } }); + const owner: { slug: string; name: string } | null | undefined = posts.data?.items[0]?.users; + const bodies: string[] | undefined = posts.data?.items[0]?.comments.map((c) => c.body); + const nested = useDatabaseList("users", { include: { posts: { include: { comments: { limit: 1 } } } } }); + const deep: number | undefined = nested.data?.items[0]?.posts[0]?.comments[0]?.id; + const gated = useDatabaseList("posts", { where: { id: 1 } }, { enabled: false }); + const gatedTitle: string | undefined = gated.data?.items[0]?.title; + useDatabaseList("sessions", { order: { user_slug: "asc" } }); + // @ts-expect-error a keyless list needs an explicit order + useDatabaseList("sessions"); + // @ts-expect-error keyless lists cannot sort by zero columns + useDatabaseList("sessions", { order: {} }); + useDatabaseList("posts", { where: { total: { gt: "9007199254740993" } } }); + void [title, total, failed, owner, bodies, deep, gatedTitle]; + } + + export function DynamicSelections() { + const columns: ("id" | "title")[] = ["id"]; + const list = useDatabaseList("posts", { select: columns }); + const record = useDatabaseRecord("posts", 7, { select: columns }); + if (list.data && record.data) { + const maybeTitle: string | undefined = list.data.items[0].title; + // @ts-expect-error a dynamic array does not guarantee every possible column + const listTitle: string = list.data.items[0].title; + // @ts-expect-error the record follows the same dynamic projection rule + const recordId: number = record.data.id; + void [maybeTitle, listTitle, recordId]; + } + } + + export function RejectedLists() { + // @ts-expect-error entities are generated table names + useDatabaseList("missing"); + // @ts-expect-error private columns are absent from public rows + useDatabaseList("users").data?.items[0]?.secret; + // @ts-expect-error private columns are not HTTP filters, even beside a valid one + useDatabaseList("users", { where: { name: "Ada", secret: "token" } }); + // @ts-expect-error private columns are not selectable + useDatabaseList("users", { select: ["slug", "secret"] }); + // @ts-expect-error JSON columns are not queryable + useDatabaseList("posts", { where: { payload: { eq: 1 } } }); + // @ts-expect-error a to-one relation is one row, not an array + useDatabaseList("posts", { include: { users: true } }).data?.items[0]?.users?.length; + // @ts-expect-error a to-one include takes no limit + useDatabaseList("posts", { include: { users: { limit: 1 } } }); + // @ts-expect-error includes stop at two relation edges + useDatabaseList("users", { include: { posts: { include: { comments: { include: { posts: true } } } } } }); + // @ts-expect-error list params are the generated route's parameters only + useDatabaseList("users", { limit: 1, includeTotal: true }); + } + + export function Records(boardId: number | undefined) { + const post = useDatabaseRecord("posts", 7, { include: { comments: { limit: 20 } } }); + const body: string | undefined = post.data?.comments[0]?.body; + const title: string | undefined = post.data?.title; + const user = useDatabaseRecord("users", "ada", { select: ["name"] }); + const name: string | undefined = user.data?.name; + // A null or undefined id waits without a request. + useDatabaseRecord("posts", null); + useDatabaseRecord("posts", boardId); + // A bigint key is addressed by its decimal string, a safe integer, or a bigint. + const seq: string | undefined = useDatabaseRecord("ledger", "9007199254740993").data?.seq; + useDatabaseRecord("ledger", 10); + useDatabaseRecord("ledger", 10n); + void [body, title, name, seq]; + } + + export function RejectedRecords() { + // @ts-expect-error keyless entities have no detail route + useDatabaseRecord("events", "x"); + // @ts-expect-error a private key is not addressable over HTTP + useDatabaseRecord("sessions", "token"); + // @ts-expect-error the id has the public key's type + useDatabaseRecord("posts", "7"); + // @ts-expect-error record params are select and include only + useDatabaseRecord("posts", 7, { where: { id: 7 } }); + // @ts-expect-error private columns are not selectable on a record + useDatabaseRecord("users", "ada", { select: ["secret"] }); + // @ts-expect-error unselected columns are absent + useDatabaseRecord("users", "ada", { select: ["name"] }).data?.slug; + } + + export async function client() { + const post = await databaseApi.get("posts", 7, { select: ["id", "title"] }); + const title: string = post.title; + // @ts-expect-error unselected columns are absent + post.total; + // @ts-expect-error keyless entities have no detail route + await databaseApi.get("events", "x"); + // @ts-expect-error record params are the generated route's parameters only + await databaseApi.get("posts", 7, { select: ["id"], limit: 1 }); + void title; + } + `); + + expect(diagnostics).toEqual([]); +}); + +test("read rows come only from the registry: no shape, serialized(), or row type argument", () => { + const diagnostics = compileTypeProbe(` + import { useDatabaseList, useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + interface PostCard { id: number; headline: string } + + export function Controls() { + // The probes below differ from these calls only in what was removed. + useDatabaseList<"posts", {}>("posts", {}); + useDatabaseRecord<"posts", {}>("posts", 7); + useDatabaseList("posts", {}, { enabled: false }); + useDatabaseRecord("posts", 7, {}, { enabled: false }); + } + + export function Removed() { + // @ts-expect-error a list takes no caller-chosen row type + useDatabaseList<"posts", {}, PostCard>("posts", {}); + // @ts-expect-error a record takes no caller-chosen row type + useDatabaseRecord<"posts", {}, PostCard>("posts", 7); + // @ts-expect-error a list takes no shape option + useDatabaseList("posts", {}, { shape: (row: unknown) => row as PostCard }); + // @ts-expect-error a record takes no shape option + useDatabaseRecord("posts", 7, {}, { shape: (row: unknown) => row as PostCard }); + } + + // @ts-expect-error serialized() is not exported + export type Serialized = typeof import("@databricks/appkit-ui/react/beta").serialized; + // @ts-expect-error DatabaseShape is not exported + export type Shape = import("@databricks/appkit-ui/react/beta").DatabaseShape; + // @ts-expect-error DatabaseRowShape is not exported + export type RowShape = import("@databricks/appkit-ui/react/beta").DatabaseRowShape; + `); + + expect(diagnostics).toEqual([]); +}); + +test("read hooks take null params and keepPreviousData", () => { + const diagnostics = compileTypeProbe(` + import { + type UseDatabaseListResult, + type UseDatabaseRecordResult, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + export function Dependent(userSlug: string | undefined, offset: number) { + // Null params hold the read; the non-null branch keeps its exact type. + const posts = useDatabaseList( + "posts", + userSlug === undefined ? null : { where: { user_slug: userSlug }, limit: 20, offset }, + { keepPreviousData: true }, + ); + const title: string | undefined = posts.data?.items[0]?.title; + const typed: UseDatabaseListResult<{ id: number; user_slug: string; title: string; total: string; payload: unknown }> = posts; + // @ts-expect-error the non-null branch is still checked exactly + useDatabaseList("posts", userSlug ? { where: { secret: userSlug } } : null); + const post: UseDatabaseRecordResult<{ title: string }> = useDatabaseRecord( + "posts", + 7, + { select: ["title"] }, + { keepPreviousData: true }, + ); + void [title, typed, post]; + } + `); + + expect(diagnostics).toEqual([]); +}); + +test("write hooks type values, ids, and rows from the generated registry", () => { + const diagnostics = compileTypeProbe(` + import { databaseApi } from "@databricks/appkit-ui/js/beta"; + import { + type DatabaseInsert, + type DatabaseRow, + useDatabaseCreate, + useDatabaseDelete, + useDatabaseUpdate, + } from "@databricks/appkit-ui/react/beta"; + + ${REGISTRY} + + export function Writes(existing: DatabaseRow<"posts">) { + const posts = useDatabaseCreate("posts"); + // A bigint input travels as a decimal string or a safe integer. + posts.create({ user_slug: "ada", title: "Hi", total: "9007199254740993" }); + posts.create({ user_slug: "ada", title: "Hi", total: 1, payload: { tags: ["a"], n: 1 } }); + const created: Promise | null> = posts.create({ user_slug: "ada", title: "Hi", total: 1 }); + // The answered row carries a bigint as its decimal string. + const total: string | undefined = posts.data?.total; + const values: DatabaseInsert<"posts"> = { user_slug: "ada", title: "Hi", total: "1" }; + posts.create(values); + useDatabaseCreate("events").create({ message: "keyless tables accept creates" }); + useDatabaseCreate("sessions").create({ user_slug: "ada" }); + useDatabaseCreate("posts", { invalidate: ["posts", "users"] }); + useDatabaseCreate("posts", { invalidate: false }); + useDatabaseCreate("posts", { + onSuccess: (row, values) => { + const id: number = row.id; + const title: string = values.title; + void [id, title]; + }, + onError: (error, values) => { + const code: string = error.code; + void [code, values.user_slug]; + }, + }); + useDatabaseUpdate("posts", { + onSuccess: (row, id, values) => { + const title: string = row.title; + const key: number = id; + const next: string | undefined = values.title; + void [title, key, next]; + }, + }); + useDatabaseDelete("ledger", { + onSuccess: (id) => { + const seq: string | number | bigint = id; + void seq; + }, + onError: (error, id) => void [error.status, id], + }); + + const edit = useDatabaseUpdate("posts"); + const updated: Promise | null> = edit.update(7, { title: "New", total: "2" }); + edit.update(7, { payload: null }); + useDatabaseUpdate("ledger").update("9007199254740993", { note: "x" }); + useDatabaseUpdate("ledger").update(10n, { note: "x" }); + useDatabaseUpdate("users").update("ada", { name: "Ada" }); + + const removal = useDatabaseDelete("posts"); + const removed: Promise = removal.remove(7); + const loading: boolean = removal.loading; + void [created, total, updated, removed, loading, existing]; + } + + export function RejectedWrites(existing: DatabaseRow<"posts">) { + const posts = useDatabaseCreate("posts"); + // @ts-expect-error entities are generated table names + useDatabaseCreate("missing"); + // @ts-expect-error a field the public insert lacks is refused, even beside valid ones + posts.create({ user_slug: "ada", title: "Hi", total: 1, secret: "token" }); + // @ts-expect-error a generated key is not an HTTP input + posts.create({ id: 1, user_slug: "ada", title: "Hi", total: 1 }); + // @ts-expect-error a spread cannot carry a read-only field along + posts.create({ ...existing, title: "Copy" }); + // @ts-expect-error required insert fields stay required + posts.create({ title: "Hi" }); + // @ts-expect-error values keep their column types + posts.create({ user_slug: "ada", title: 1, total: 1 }); + // @ts-expect-error invalidation names generated tables + useDatabaseCreate("posts", { invalidate: ["missing"] }); + // @ts-expect-error a create answers the public row, which has no private column + useDatabaseCreate("users", { onSuccess: (row) => row.secret }); + // @ts-expect-error a delete reports the id, not a row + useDatabaseDelete("posts", { onSuccess: (id) => id.title }); + + // @ts-expect-error keyless entities have no update route + useDatabaseUpdate("events"); + // @ts-expect-error a private key is not addressable over HTTP + useDatabaseUpdate("sessions"); + // @ts-expect-error the id has the public key's type + useDatabaseUpdate("posts").update("7", { title: "x" }); + // @ts-expect-error keys are not updatable + useDatabaseUpdate("posts").update(7, { id: 8 }); + // @ts-expect-error insert-only fields are not updatable + useDatabaseUpdate("users").update("ada", { slug: "grace" }); + + // @ts-expect-error keyless entities have no delete route + useDatabaseDelete("events"); + // @ts-expect-error the id has the public key's type + useDatabaseDelete("posts").remove("7"); + // @ts-expect-error a delete answers no row + useDatabaseDelete("posts").data; + } + + export async function client() { + const post = await databaseApi.create("posts", { user_slug: "ada", title: "Hi", total: "1" }); + const id: number = post.id; + const total: string = post.total; + await databaseApi.update("posts", 7, { title: "New" }); + const removed: void = await databaseApi.remove("posts", 7); + // @ts-expect-error a field the public insert lacks is refused + await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); + // @ts-expect-error keyless entities have no update route + await databaseApi.update("events", "x", { message: "y" }); + // @ts-expect-error keyless entities have no delete route + await databaseApi.remove("events", "x"); + // @ts-expect-error update values are the public update facet only + await databaseApi.update("posts", 7, { title: "New", user_slug: "grace" }); + void [id, total, removed]; + } + `); + + expect(diagnostics).toEqual([]); +}); + +test("no entity exists before typegen binds the registry", () => { + const diagnostics = compileTypeProbe(` + import { + useDatabaseCreate, + useDatabaseList, + useDatabaseRecord, + } from "@databricks/appkit-ui/react/beta"; + + export function Unbound() { + // @ts-expect-error the empty registry binds no entity + useDatabaseList("notes"); + // @ts-expect-error the empty registry binds no keyed entity + useDatabaseRecord("notes", 1); + // @ts-expect-error the empty registry binds no entity to write + useDatabaseCreate("notes"); + } + `); + + expect(diagnostics).toEqual([]); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutation.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutation.test.tsx new file mode 100644 index 000000000..c3e9d767e --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-mutation.test.tsx @@ -0,0 +1,317 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { + flush, + mockDatabaseFetch, + operationPath, + type PendingRequest, + publishOperations, + resetDatabaseTestEnvironment, +} from "@/js/database/test-utils"; + +import { + type DatabaseMutationOutcome, + type UseDatabaseMutationResult, + useDatabaseMutation as typedUseDatabaseMutation, +} from "../use-database-mutation"; +import { + type UseDatabaseQueryResult, + useDatabaseQuery as typedUseDatabaseQuery, +} from "../use-database-query"; + +// This package has no generated registry, so every operation name is `never` +// here. The typed surface is compiled in use-database.types.test.ts. +const useDatabaseMutation = typedUseDatabaseMutation as unknown as ( + name: string, +) => UseDatabaseMutationResult; +const useDatabaseQuery = typedUseDatabaseQuery as unknown as ( + name: string, + input: unknown, +) => UseDatabaseQueryResult; + +type Outcome = DatabaseMutationOutcome; + +let server: ReturnType; + +beforeEach(() => { + server = mockDatabaseFetch(); + publishOperations({ + "notes.get": "GET", + "notes.create": "POST", + "notes.delete": "DELETE", + }); +}); + +// This project runs without vitest globals, so nothing unmounts on its own. +afterEach(() => { + cleanup(); + resetDatabaseTestEnvironment(); +}); + +async function answer( + request: PendingRequest, + body: unknown, + options?: Parameters[1], +): Promise { + await act(async () => { + request.respond(body, options); + await flush(); + }); +} + +/** Start one call inside `act` and hand back its pending outcome. */ +function start( + hook: { current: UseDatabaseMutationResult }, + input: unknown, +): Promise { + let outcome!: Promise; + act(() => { + outcome = hook.current.mutate(input); + }); + return outcome; +} + +describe("useDatabaseMutation", () => { + test("mounting sends nothing; mutate sends the write once and resolves its outcome", async () => { + const { result, rerender } = renderHook(() => + useDatabaseMutation("notes.create"), + ); + rerender(); + await act(flush); + expect(server.fetchMock).not.toHaveBeenCalled(); + expect(result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: null, + }); + + const outcome = start(result, { body: "hi" }); + expect(result.current.isLoading).toBe(true); + expect(server.requests).toHaveLength(1); + expect(server.request(0)).toMatchObject({ + url: operationPath("notes.create"), + method: "POST", + body: '{"body":"hi"}', + signal: undefined, + }); + + await answer(server.request(0), { id: 7, body: "hi" }, { status: 201 }); + await expect(outcome).resolves.toEqual({ + data: { id: 7, body: "hi" }, + error: null, + }); + expect(result.current).toMatchObject({ + data: { id: 7, body: "hi" }, + isLoading: false, + error: null, + }); + }); + + test("a delete that answers 204 succeeds with no data", async () => { + const { result } = renderHook(() => useDatabaseMutation("notes.delete")); + const outcome = start(result, { id: 7 }); + + expect(server.request(0)).toMatchObject({ + method: "DELETE", + body: '{"id":7}', + }); + await answer(server.request(0), undefined, { status: 204 }); + await expect(outcome).resolves.toEqual({ data: undefined, error: null }); + }); + + test.each([ + [ + "an error reply", + (request: PendingRequest) => + request.respond({ error: "Busy" }, { status: 503 }), + "TRANSIENT", + ], + [ + "no reply", + (request: PendingRequest) => + request.fail(new TypeError("Failed to fetch")), + "OUTCOME_UNKNOWN", + ], + ])( + "%s resolves the failure, lands in error, and is never retried", + async (_case, fail, code) => { + const { result } = renderHook(() => useDatabaseMutation("notes.create")); + const outcome = start(result, { body: "hi" }); + + await act(async () => { + fail(server.request(0)); + await flush(); + }); + await expect(outcome).resolves.toMatchObject({ + data: undefined, + error: { code }, + }); + expect(result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: { code }, + }); + await act(flush); + expect(server.fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + test("a read operation is never sent as a mutation", async () => { + const { result } = renderHook(() => useDatabaseMutation("notes.get")); + const outcome = await act(() => result.current.mutate({ id: 1 })); + + expect(outcome).toMatchObject({ + data: undefined, + error: { + code: "NOT_EXPOSED", + message: expect.stringContaining("is not a mutation"), + }, + }); + expect(server.fetchMock).not.toHaveBeenCalled(); + }); + + test("only the latest call reports in state; every call resolves its own outcome", async () => { + const { result } = renderHook(() => useDatabaseMutation("notes.create")); + const first = start(result, { body: "first" }); + const second = start(result, { body: "second" }); + + await answer(server.request(1), { id: 2 }); + await answer(server.request(0), { id: 1 }); + + await expect(first).resolves.toEqual({ data: { id: 1 }, error: null }); + await expect(second).resolves.toEqual({ data: { id: 2 }, error: null }); + expect(result.current.data).toEqual({ id: 2 }); + }); + + test("reset returns to idle, and a call in flight no longer reports", async () => { + const { result } = renderHook(() => useDatabaseMutation("notes.create")); + const outcome = start(result, { body: "hi" }); + act(() => result.current.reset()); + + expect(result.current).toMatchObject({ data: undefined, isLoading: false }); + await answer(server.request(0), { id: 7 }); + await expect(outcome).resolves.toEqual({ data: { id: 7 }, error: null }); + expect(result.current).toMatchObject({ data: undefined, isLoading: false }); + }); + + test("unmounting does not abort a write in flight", async () => { + const { result, unmount } = renderHook(() => + useDatabaseMutation("notes.create"), + ); + const outcome = start(result, { body: "hi" }); + unmount(); + + expect(server.request(0).signal).toBeUndefined(); + await answer(server.request(0), { id: 7 }); + await expect(outcome).resolves.toEqual({ data: { id: 7 }, error: null }); + }); + + test("a successful write restarts mounted queries, keeping their data while they reload", async () => { + const { result } = renderHook(() => ({ + note: useDatabaseQuery("notes.get", { id: 1 }), + create: useDatabaseMutation("notes.create"), + })); + await answer(server.request(0), { id: 1, count: 1 }); + + const failed = start({ current: result.current.create }, { body: "x" }); + await answer(server.request(1), { error: "Invalid" }, { status: 422 }); + await failed; + expect(server.requests).toHaveLength(2); + + const created = start({ current: result.current.create }, { body: "y" }); + await answer(server.request(2), { id: 2 }); + await created; + expect(server.requests).toHaveLength(4); + expect(server.request(3).input).toEqual({ id: 1 }); + expect(result.current.note).toMatchObject({ + data: { id: 1, count: 1 }, + isLoading: true, + }); + + await answer(server.request(3), { id: 1, count: 2 }); + expect(result.current.note).toMatchObject({ + data: { id: 1, count: 2 }, + isLoading: false, + }); + }); + + test("a write whose outcome is unknown reports it and restarts mounted queries", async () => { + const { result } = renderHook(() => ({ + note: useDatabaseQuery("notes.get", { id: 1 }), + create: useDatabaseMutation("notes.create"), + })); + await answer(server.request(0), { id: 1, count: 1 }); + + const outcome = start({ current: result.current.create }, { body: "x" }); + await answer( + server.request(1), + { error: "Database write may have completed", outcome: "unknown" }, + { status: 500 }, + ); + + await expect(outcome).resolves.toMatchObject({ + data: undefined, + error: { code: "OUTCOME_UNKNOWN", status: 500 }, + }); + expect(result.current.create.error).toMatchObject({ + code: "OUTCOME_UNKNOWN", + }); + expect(server.requests).toHaveLength(3); + expect(server.request(2).input).toEqual({ id: 1 }); + }); + + test("a write answered 401 after an authenticated read shows UNAUTHENTICATED", async () => { + const { result } = renderHook(() => ({ + note: useDatabaseQuery("notes.get", { id: 1 }), + create: useDatabaseMutation("notes.create"), + })); + await answer(server.request(0), { id: 1 }); + + const outcome = start({ current: result.current.create }, { body: "x" }); + await answer( + server.request(1), + { error: "Authentication required" }, + { status: 401, actor: null }, + ); + + await expect(outcome).resolves.toMatchObject({ + error: { code: "UNAUTHENTICATED" }, + }); + expect(result.current.create).toMatchObject({ + data: undefined, + isLoading: false, + error: { code: "UNAUTHENTICATED" }, + }); + }); + + test("a write answered after the session changed reports SESSION_CHANGED and shows nothing", async () => { + const { result } = renderHook(() => ({ + note: useDatabaseQuery("notes.get", { id: 1 }), + create: useDatabaseMutation("notes.create"), + })); + await answer(server.request(0), { id: 1 }); + + const outcome = start({ current: result.current.create }, { body: "hi" }); + act(() => result.current.note.refetch()); + await answer(server.request(2), { id: 1 }, { actor: "actor-b" }); + expect(result.current.create).toMatchObject({ + data: undefined, + isLoading: false, + error: null, + }); + + const sent = server.requests.length; + await answer(server.request(1), { id: 9 }, { actor: "actor-a" }); + await expect(outcome).resolves.toMatchObject({ + data: undefined, + error: { code: "SESSION_CHANGED" }, + }); + expect(result.current.create).toMatchObject({ + data: undefined, + isLoading: false, + error: null, + }); + expect(server.requests).toHaveLength(sent); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-query.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-query.test.tsx new file mode 100644 index 000000000..5656a2c51 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-query.test.tsx @@ -0,0 +1,331 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { + flush, + mockDatabaseFetch, + type PendingRequest, + publishOperations, + resetDatabaseTestEnvironment, +} from "@/js/database/test-utils"; + +import { + type UseDatabaseQueryOptions, + type UseDatabaseQueryResult, + useDatabaseQuery as typedUseDatabaseQuery, +} from "../use-database-query"; + +// This package has no generated registry, so every operation name is `never` +// here. The typed surface is compiled in use-database.types.test.ts. +const useDatabaseQuery = typedUseDatabaseQuery as unknown as ( + name: string, + input: unknown, + options?: UseDatabaseQueryOptions, +) => UseDatabaseQueryResult; + +let server: ReturnType; + +beforeEach(() => { + server = mockDatabaseFetch(); + publishOperations({ "notes.get": "GET", "notes.create": "POST" }); +}); + +// This project runs without vitest globals, so nothing unmounts on its own. +afterEach(() => { + cleanup(); + resetDatabaseTestEnvironment(); +}); + +async function answer( + request: PendingRequest, + body: unknown, + options?: Parameters[1], +): Promise { + await act(async () => { + request.respond(body, options); + await flush(); + }); +} + +/** Render a query and keep every result it rendered. */ +function renderQuery( + initial: { input: unknown; enabled?: boolean; name?: string } = { + input: { id: 1 }, + }, +) { + const renders: UseDatabaseQueryResult[] = []; + const hook = renderHook( + ({ name = "notes.get", input, enabled }) => { + const result = useDatabaseQuery(name, input, { enabled }); + renders.push(result); + return result; + }, + { initialProps: initial }, + ); + return { ...hook, renders }; +} + +describe("useDatabaseQuery", () => { + test("reads on mount and moves from loading to the answer", async () => { + const { result } = renderQuery(); + + expect(result.current).toMatchObject({ + data: undefined, + isLoading: true, + error: null, + }); + expect(server.requests).toHaveLength(1); + expect(server.request(0)).toMatchObject({ + method: "GET", + input: { id: 1 }, + }); + + await answer(server.request(0), { id: 1, body: "hi" }); + expect(result.current).toMatchObject({ + data: { id: 1, body: "hi" }, + isLoading: false, + error: null, + }); + }); + + test("an equal input with a new identity keeps its one request", async () => { + const { rerender } = renderQuery(); + rerender({ input: { id: 1 } }); + rerender({ input: { id: 1 } }); + + expect(server.requests).toHaveLength(1); + }); + + test("a new input aborts the previous request and never shows its answer", async () => { + const { result, rerender, renders } = renderQuery(); + await answer(server.request(0), { id: 1 }); + + const before = renders.length; + rerender({ input: { id: 2 } }); + // The render that takes the new input already drops the old answer. + expect(renders[before]).toMatchObject({ + data: undefined, + isLoading: true, + error: null, + }); + + rerender({ input: { id: 3 } }); + expect(server.request(1).signal?.aborted).toBe(true); + await answer(server.request(1), { id: 2 }); + expect( + renders.slice(before).map((render) => render.data), + ).not.toContainEqual({ id: 2 }); + + await answer(server.request(2), { id: 3 }); + expect(result.current).toMatchObject({ data: { id: 3 }, isLoading: false }); + }); + + test("a failure clears the data, and a new input clears the error", async () => { + const { result, rerender, renders } = renderQuery(); + await answer(server.request(0), { id: 1 }); + act(() => result.current.refetch()); + + await answer( + server.request(1), + { error: "Database record not found" }, + { status: 404 }, + ); + expect(result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: { code: "NOT_FOUND", status: 404 }, + }); + + const before = renders.length; + rerender({ input: { id: 2 } }); + expect(renders[before]).toMatchObject({ error: null, isLoading: true }); + }); + + test("refetch keeps the answer visible while it reloads and aborts the request in flight", async () => { + const { result } = renderQuery(); + await answer(server.request(0), { id: 1, body: "old" }); + + act(() => result.current.refetch()); + expect(result.current).toMatchObject({ + data: { id: 1, body: "old" }, + isLoading: true, + }); + act(() => result.current.refetch()); + expect(server.request(1).signal?.aborted).toBe(true); + + await answer(server.request(1), { id: 1, body: "superseded" }); + expect(result.current.data).toEqual({ id: 1, body: "old" }); + await answer(server.request(2), { id: 1, body: "new" }); + expect(result.current).toMatchObject({ + data: { id: 1, body: "new" }, + isLoading: false, + }); + }); + + test("enabled: false sends nothing until it is enabled", async () => { + const { result, rerender } = renderQuery({ + input: { id: 1 }, + enabled: false, + }); + + expect(result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: null, + }); + act(() => result.current.refetch()); + expect(server.requests).toHaveLength(0); + + rerender({ input: { id: 1 }, enabled: true }); + expect(server.requests).toHaveLength(1); + }); + + test("re-enabling a query never shows its earlier answer as settled", async () => { + const { rerender, renders } = renderQuery({ input: { id: 1 } }); + await answer(server.request(0), { id: 1 }); + rerender({ input: { id: 1 }, enabled: false }); + + renders.length = 0; + rerender({ input: { id: 1 }, enabled: true }); + expect(server.requests).toHaveLength(2); + expect(renders).not.toContainEqual( + expect.objectContaining({ data: { id: 1 }, isLoading: false }), + ); + await answer(server.request(1), { id: 1, again: true }); + expect(renders.at(-1)).toMatchObject({ + data: { id: 1, again: true }, + isLoading: false, + }); + }); + + test.each([ + ["an unpublished name", "boards.get", "is not exposed"], + ["a write operation", "notes.create", "is not a query"], + ])( + "%s reports NOT_EXPOSED and is never sent", + async (_case, name, reason) => { + const { result, rerender } = renderQuery({ name, input: { id: 1 } }); + const first = result.current.error; + rerender({ name, input: { id: 1 } }); + act(() => result.current.refetch()); + await act(flush); + + expect(result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: { + code: "NOT_EXPOSED", + message: expect.stringContaining(reason), + }, + }); + expect(result.current.error).toBe(first); + expect(server.fetchMock).not.toHaveBeenCalled(); + }, + ); + + test("an input that cannot be sent reports INVALID_REQUEST without a request", () => { + const { result } = renderQuery({ input: { id: Number.NaN } }); + + expect(result.current).toMatchObject({ + isLoading: false, + error: { code: "INVALID_REQUEST", status: null }, + }); + expect(server.fetchMock).not.toHaveBeenCalled(); + }); + + test("unmounting aborts the request", () => { + const { unmount } = renderQuery(); + unmount(); + + expect(server.request(0).signal?.aborted).toBe(true); + }); + + test("hooks with the same input do not share a request", async () => { + const first = renderQuery(); + const second = renderQuery(); + expect(server.requests).toHaveLength(2); + + await answer(server.request(1), { id: 1, from: "second" }); + expect(second.result.current.data).toEqual({ id: 1, from: "second" }); + expect(first.result.current).toMatchObject({ + data: undefined, + isLoading: true, + }); + }); + + describe("session changes", () => { + test("when another query reveals a new actor, this one drops the old actor's data and asks again", async () => { + const notes = renderQuery({ input: { id: 1 } }); + const other = renderQuery({ input: { id: 2 } }); + await answer(server.request(0), { id: 1, owner: "a" }); + await answer(server.request(1), { id: 2, owner: "a" }); + + // Both hooks restart; the first one's reply is still on the way. + act(() => notes.result.current.refetch()); + act(() => other.result.current.refetch()); + const before = notes.renders.length; + await answer( + server.request(3), + { id: 2, owner: "b" }, + { actor: "actor-b" }, + ); + + expect(notes.result.current).toMatchObject({ + data: undefined, + isLoading: true, + }); + expect(server.request(2).signal?.aborted).toBe(true); + await answer( + server.request(2), + { id: 1, owner: "a" }, + { actor: "actor-a" }, + ); + + // The session change restarted the first hook with its own request. + const restarts = server.requests + .slice(4) + .filter((request) => (request.input as { id: number }).id === 1); + expect(restarts).toHaveLength(1); + await answer( + restarts[0] as PendingRequest, + { id: 1, owner: "b" }, + { + actor: "actor-b", + }, + ); + expect(notes.result.current).toMatchObject({ + data: { id: 1, owner: "b" }, + isLoading: false, + }); + expect( + notes.renders.slice(before).map((render) => render.data), + ).not.toContainEqual({ id: 1, owner: "a" }); + }); + + test("a 401 clears every query, which then report UNAUTHENTICATED", async () => { + const notes = renderQuery({ input: { id: 1 } }); + const other = renderQuery({ input: { id: 2 } }); + await answer(server.request(0), { id: 1 }); + await answer(server.request(1), { id: 2 }); + + act(() => other.result.current.refetch()); + await answer(server.request(2), { error: "Sign in" }, { status: 401 }); + expect(notes.result.current.data).toBeUndefined(); + expect(other.result.current.data).toBeUndefined(); + + // Each query asks once more in the new session; neither loops. + for (const request of server.requests.slice(3)) { + await answer(request, { error: "Sign in" }, { status: 401 }); + } + await act(flush); + expect(server.requests).toHaveLength(5); + for (const hook of [notes, other]) { + expect(hook.result.current).toMatchObject({ + data: undefined, + isLoading: false, + error: { code: "UNAUTHENTICATED", status: 401 }, + }); + } + }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts index a005ab01b..2ecb1d99e 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -12,474 +12,246 @@ const packageRoot = // exceed Vitest's 5s default on CI's 2-core runners. vi.setConfig({ testTimeout: 30_000 }); +const PROBE = path.join(packageRoot, "__type-tests__", "database.ts"); + +/** + * Compile `source` as the only consumer in its own program, so a fixture's + * global augmentation never reaches another probe or the package itself. + * Returns every diagnostic as `line TScode message`. + */ function compileTypeProbe(source: string): string[] { - const configPath = path.join(packageRoot, "tsconfig.json"); - const config = ts.readConfigFile(configPath, ts.sys.readFile); + const config = ts.readConfigFile( + path.join(packageRoot, "tsconfig.json"), + 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 = (file) => file === PROBE || ts.sys.fileExists(file); + host.readFile = (file) => (file === PROBE ? source : ts.sys.readFile(file)); + host.getSourceFile = (file, language, onError, shouldCreate) => + file === PROBE + ? ts.createSourceFile(file, source, language, true, ts.ScriptKind.TS) + : getSourceFile(file, language, onError, shouldCreate); + + const program = ts.createProgram([PROBE], parsed.options, host); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => { + const where = + diagnostic.file?.fileName === PROBE + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0) + .line + 1 + : (diagnostic.file?.fileName ?? "global"); + return `${where} TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`; + }); +} - 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 DIRECTIVE = /\/\/ @ts-expect-error (TS\d+) /g; - const program = ts.createProgram([filename], parsed.options, host); - return ts - .getPreEmitDiagnostics(program) - .map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ); +/** The `line TScode` each directive promises for the line after it. */ +function expectedProbes(source: string): string[] { + return source.split("\n").flatMap((text, index) => { + const match = /\/\/ @ts-expect-error (TS\d+) /.exec(text); + return match ? [`${index + 2} ${match[1]}`] : []; + }); } -// 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; }; +/** + * Each probe must fail for the reason its directive names: with the + * directives disabled, every probe line reports exactly its own code. + */ +function expectEachProbeRejected(source: string): void { + expect(compileTypeProbe(source)).toEqual([]); + const raw = compileTypeProbe(source.replace(DIRECTIVE, "// probe $1 ")); + expect( + raw + .map((finding) => finding.split(" ").slice(0, 2).join(" ")) + .sort((a, b) => Number(a.split(" ")[0]) - Number(b.split(" ")[0])), + ).toEqual(expectedProbes(source)); +} - 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"; +// Entries as generation renders them into the global registry. +const GENERATED = ` + declare global { + interface DatabricksAppKitDatabaseOperations { + "notes.get": { + method: "GET"; + input: { id: number }; + output: { id: number; body: string; total: string }; }; - }; - "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 }; + "notes.search": { + method: "GET"; + input: { board_id: number; text?: string }; + output: { id: number; body: string }[]; }; - 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"; + "notes.find": { + method: "GET"; + input: { id: number }; + output: { id: number; body: string } | null; }; - }; - "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"; + "notes.create": { + method: "POST"; + input: { board_id: number; body: string; total?: string | number }; + output: { id: number; body: string; total: string }; }; - }; - "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"; + "notes.update": { + method: "PATCH"; + input: { id: number; changes: { body?: string } }; + output: { id: number; body: string; total: string }; }; - }; - "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; + "notes.replace": { + method: "PUT"; + input: { id: number; values: { body: string } }; + output: { id: number; body: string; total: string }; }; - }; - "events": { - publicRow: { "message": string }; - includes: {}; - api: { - insert: { "message": string }; - update: { "message"?: string }; - filters: Filter<{ "message"?: Text }>; - orderable: "message"; - key: never; + "notes.delete": { + method: "DELETE"; + input: { id: number }; + output: void; }; - }; + } } +`; - declare module "@databricks/appkit-ui/js/beta" { - interface DatabaseRegistry extends Fixture {} - } +const IMPORTS = ` + import { databaseApi } from "@databricks/appkit-ui/js/beta"; + import { + type DatabaseApiError, + type DatabaseMutationName, + type DatabaseMutationOutcome, + type DatabaseOperationInput, + type DatabaseOperationName, + type DatabaseOperationOutput, + type DatabaseQueryName, + useDatabaseMutation, + useDatabaseQuery, + } from "@databricks/appkit-ui/react/beta"; `; -test("read hooks type entities, params, and rows from the generated registry", () => { +test("names, inputs, and answers come from the generated operations", () => { 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]; + ${IMPORTS} + ${GENERATED} + + type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + type Expect = T; + type Note = { id: number; body: string; total: string }; + + export type Names = [ + Expect>, + Expect>, + Expect>, + Expect, { id: number; changes: { body?: string } }>>, + Expect, void>>, + ]; + + export function Reads(selected: number | undefined) { + const note = useDatabaseQuery("notes.get", { id: 1 }); + const loading: boolean = note.isLoading; + const error: DatabaseApiError | null = note.error; + note.refetch(); + const found = useDatabaseQuery("notes.find", { id: 1 }); + const search = useDatabaseQuery( + "notes.search", + { board_id: selected ?? 0 }, + { enabled: selected !== undefined }, + ); + const bodies: string[] | undefined = search.data?.map((row) => row.body); + void [loading, error, bodies]; + return [ + true as Expect>, + // An explicit nullable answer keeps null apart from "not yet". + true as Expect>, + ]; } - 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 async function Writes() { + const create = useDatabaseMutation("notes.create"); + const outcome = await create.mutate({ board_id: 1, body: "hi", total: "9007199254740993" }); + if (outcome.error === null) { + const created: Note = outcome.data; + void created; + } else { + const code: string = outcome.error.code; + const nothing: undefined = outcome.data; + void [code, nothing]; } + const removed = await useDatabaseMutation("notes.delete").mutate({ id: 1 }); + await useDatabaseMutation("notes.update").mutate({ id: 1, changes: {} }); + await useDatabaseMutation("notes.replace").mutate({ id: 1, values: { body: "x" } }); + create.reset(); + return [ + true as Expect>, + true as Expect>, + true as Expect>>, + ]; } - 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(signal: AbortSignal) { + const note = await databaseApi.execute("notes.get", { id: 1 }, { signal }); + const removed = await databaseApi.execute("notes.delete", { id: 1 }); + const created = await databaseApi.execute("notes.create", { board_id: 1, body: "x" }); + return [ + true as Expect>, + true as Expect>, + true as Expect>, + ]; } - - export async function client() { - const post = await databaseApi.get("posts", 7, { select: ["id", "title"] }); - const title: string = post.title; - // @ts-expect-error unselected columns are absent - post.total; - // @ts-expect-error keyless entities have no detail route - await databaseApi.get("events", "x"); - // @ts-expect-error record params are the generated route's parameters only - await databaseApi.get("posts", 7, { select: ["id"], limit: 1 }); - void title; - } - `); - - expect(diagnostics).toEqual([]); -}); - -test("read rows come only from the registry: no shape, serialized(), or row type argument", () => { - const diagnostics = compileTypeProbe(` - import { useDatabaseList, useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - interface PostCard { id: number; headline: string } - - export function Controls() { - // The probes below differ from these calls only in what was removed. - useDatabaseList<"posts", {}>("posts", {}); - useDatabaseRecord<"posts", {}>("posts", 7); - useDatabaseList("posts", {}, { enabled: false }); - useDatabaseRecord("posts", 7, {}, { enabled: false }); - } - - export function Removed() { - // @ts-expect-error a list takes no caller-chosen row type - useDatabaseList<"posts", {}, PostCard>("posts", {}); - // @ts-expect-error a record takes no caller-chosen row type - useDatabaseRecord<"posts", {}, PostCard>("posts", 7); - // @ts-expect-error a list takes no shape option - useDatabaseList("posts", {}, { shape: (row: unknown) => row as PostCard }); - // @ts-expect-error a record takes no shape option - useDatabaseRecord("posts", 7, {}, { shape: (row: unknown) => row as PostCard }); - } - - // @ts-expect-error serialized() is not exported - export type Serialized = typeof import("@databricks/appkit-ui/react/beta").serialized; - // @ts-expect-error DatabaseShape is not exported - export type Shape = import("@databricks/appkit-ui/react/beta").DatabaseShape; - // @ts-expect-error DatabaseRowShape is not exported - export type RowShape = import("@databricks/appkit-ui/react/beta").DatabaseRowShape; `); expect(diagnostics).toEqual([]); }); -test("read hooks take null params and keepPreviousData", () => { - const diagnostics = compileTypeProbe(` - import { - type UseDatabaseListResult, - type UseDatabaseRecordResult, - useDatabaseList, - useDatabaseRecord, - } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - export function Dependent(userSlug: string | undefined, offset: number) { - // Null params hold the read; the non-null branch keeps its exact type. - const posts = useDatabaseList( - "posts", - userSlug === undefined ? null : { where: { user_slug: userSlug }, limit: 20, offset }, - { keepPreviousData: true }, - ); - const title: string | undefined = posts.data?.items[0]?.title; - const typed: UseDatabaseListResult<{ id: number; user_slug: string; title: string; total: string; payload: unknown }> = posts; - // @ts-expect-error the non-null branch is still checked exactly - useDatabaseList("posts", userSlug ? { where: { secret: userSlug } } : null); - const post: UseDatabaseRecordResult<{ title: string }> = useDatabaseRecord( - "posts", - 7, - { select: ["title"] }, - { keepPreviousData: true }, - ); - void [title, typed, post]; - } - `); - - expect(diagnostics).toEqual([]); -}); - -test("write hooks type values, ids, and rows from the generated registry", () => { - const diagnostics = compileTypeProbe(` - import { databaseApi } from "@databricks/appkit-ui/js/beta"; - import { - type DatabaseInsert, - type DatabaseRow, - useDatabaseCreate, - useDatabaseDelete, - useDatabaseUpdate, - } from "@databricks/appkit-ui/react/beta"; - - ${REGISTRY} - - export function Writes(existing: DatabaseRow<"posts">) { - const posts = useDatabaseCreate("posts"); - // A bigint input travels as a decimal string or a safe integer. - posts.create({ user_slug: "ada", title: "Hi", total: "9007199254740993" }); - posts.create({ user_slug: "ada", title: "Hi", total: 1, payload: { tags: ["a"], n: 1 } }); - const created: Promise | null> = posts.create({ user_slug: "ada", title: "Hi", total: 1 }); - // The answered row carries a bigint as its decimal string. - const total: string | undefined = posts.data?.total; - const values: DatabaseInsert<"posts"> = { user_slug: "ada", title: "Hi", total: "1" }; - posts.create(values); - useDatabaseCreate("events").create({ message: "keyless tables accept creates" }); - useDatabaseCreate("sessions").create({ user_slug: "ada" }); - useDatabaseCreate("posts", { invalidate: ["posts", "users"] }); - useDatabaseCreate("posts", { invalidate: false }); - useDatabaseCreate("posts", { - onSuccess: (row, values) => { - const id: number = row.id; - const title: string = values.title; - void [id, title]; - }, - onError: (error, values) => { - const code: string = error.code; - void [code, values.user_slug]; - }, - }); - useDatabaseUpdate("posts", { - onSuccess: (row, id, values) => { - const title: string = row.title; - const key: number = id; - const next: string | undefined = values.title; - void [title, key, next]; - }, - }); - useDatabaseDelete("ledger", { - onSuccess: (id) => { - const seq: string | number | bigint = id; - void seq; - }, - onError: (error, id) => void [error.status, id], - }); - - const edit = useDatabaseUpdate("posts"); - const updated: Promise | null> = edit.update(7, { title: "New", total: "2" }); - edit.update(7, { payload: null }); - useDatabaseUpdate("ledger").update("9007199254740993", { note: "x" }); - useDatabaseUpdate("ledger").update(10n, { note: "x" }); - useDatabaseUpdate("users").update("ada", { name: "Ada" }); - - const removal = useDatabaseDelete("posts"); - const removed: Promise = removal.remove(7); - const loading: boolean = removal.loading; - void [created, total, updated, removed, loading, existing]; - } - - export function RejectedWrites(existing: DatabaseRow<"posts">) { - const posts = useDatabaseCreate("posts"); - // @ts-expect-error entities are generated table names - useDatabaseCreate("missing"); - // @ts-expect-error a field the public insert lacks is refused, even beside valid ones - posts.create({ user_slug: "ada", title: "Hi", total: 1, secret: "token" }); - // @ts-expect-error a generated key is not an HTTP input - posts.create({ id: 1, user_slug: "ada", title: "Hi", total: 1 }); - // @ts-expect-error a spread cannot carry a read-only field along - posts.create({ ...existing, title: "Copy" }); - // @ts-expect-error required insert fields stay required - posts.create({ title: "Hi" }); - // @ts-expect-error values keep their column types - posts.create({ user_slug: "ada", title: 1, total: 1 }); - // @ts-expect-error invalidation names generated tables - useDatabaseCreate("posts", { invalidate: ["missing"] }); - // @ts-expect-error a create answers the public row, which has no private column - useDatabaseCreate("users", { onSuccess: (row) => row.secret }); - // @ts-expect-error a delete reports the id, not a row - useDatabaseDelete("posts", { onSuccess: (id) => id.title }); - - // @ts-expect-error keyless entities have no update route - useDatabaseUpdate("events"); - // @ts-expect-error a private key is not addressable over HTTP - useDatabaseUpdate("sessions"); - // @ts-expect-error the id has the public key's type - useDatabaseUpdate("posts").update("7", { title: "x" }); - // @ts-expect-error keys are not updatable - useDatabaseUpdate("posts").update(7, { id: 8 }); - // @ts-expect-error insert-only fields are not updatable - useDatabaseUpdate("users").update("ada", { slug: "grace" }); - - // @ts-expect-error keyless entities have no delete route - useDatabaseDelete("events"); - // @ts-expect-error the id has the public key's type - useDatabaseDelete("posts").remove("7"); - // @ts-expect-error a delete answers no row - useDatabaseDelete("posts").data; - } - - export async function client() { - const post = await databaseApi.create("posts", { user_slug: "ada", title: "Hi", total: "1" }); - const id: number = post.id; - const total: string = post.total; - await databaseApi.update("posts", 7, { title: "New" }); - const removed: void = await databaseApi.remove("posts", 7); - // @ts-expect-error a field the public insert lacks is refused - await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); - // @ts-expect-error keyless entities have no update route - await databaseApi.update("events", "x", { message: "y" }); - // @ts-expect-error keyless entities have no delete route - await databaseApi.remove("events", "x"); - // @ts-expect-error update values are the public update facet only - await databaseApi.update("posts", 7, { title: "New", user_slug: "grace" }); - void [id, total, removed]; - } - `); - - expect(diagnostics).toEqual([]); +test("each misuse fails with its own diagnostic", () => { + expectEachProbeRejected(`${IMPORTS} +${GENERATED} +export async function Rejected() { + // @ts-expect-error TS2345 a write is not a query, so it never runs on mount + useDatabaseQuery("notes.create", { board_id: 1, body: "x" }); + // @ts-expect-error TS2345 a read is not a mutation + useDatabaseMutation("notes.get"); + // @ts-expect-error TS2345 names come from the generated registry + useDatabaseQuery("notes.missing", { id: 1 }); + // @ts-expect-error TS2322 the input keeps its JSON types + useDatabaseQuery("notes.get", { id: "1" }); + // @ts-expect-error TS2353 an undeclared input field is refused + useDatabaseQuery("notes.get", { id: 1, secret: true }); + // @ts-expect-error TS2554 the input is required + useDatabaseQuery("notes.get"); + // @ts-expect-error TS2353 query options are enabled only + useDatabaseQuery("notes.get", { id: 1 }, { keepPreviousData: true }); + // @ts-expect-error TS2532 data is undefined until it arrives + useDatabaseQuery("notes.get", { id: 1 }).data.body; + // @ts-expect-error TS2345 required write fields stay required + useDatabaseMutation("notes.create").mutate({ board_id: 1 }); + // @ts-expect-error TS2353 an undeclared write field is refused + useDatabaseMutation("notes.create").mutate({ board_id: 1, body: "x", id: 9 }); + // @ts-expect-error TS2554 a mutation takes its input only through mutate + useDatabaseMutation("notes.create", { board_id: 1, body: "x" }); + const outcome = await useDatabaseMutation("notes.create").mutate({ board_id: 1, body: "x" }); + // @ts-expect-error TS18048 the answer exists only once the outcome is narrowed + outcome.data.id; + // @ts-expect-error TS2339 a delete answers no body + (await databaseApi.execute("notes.delete", { id: 1 })).id; + // @ts-expect-error TS2345 the client takes generated names only + await databaseApi.execute("notes.missing", {}); +} +`); }); -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([]); +test("no operation exists before generation fills the registry", () => { + expectEachProbeRejected(`${IMPORTS} +export async function Unbound() { + // @ts-expect-error TS2345 the empty registry declares no query + useDatabaseQuery("notes.get", { id: 1 }); + // @ts-expect-error TS2345 the empty registry declares no mutation + useDatabaseMutation("notes.create"); + // @ts-expect-error TS2345 the empty registry declares no operation + await databaseApi.execute("notes.get", { id: 1 }); +} +`); }); diff --git a/packages/appkit-ui/src/react/hooks/database-request-store.ts b/packages/appkit-ui/src/react/hooks/database-request-store.ts index 233cc6053..766a05808 100644 --- a/packages/appkit-ui/src/react/hooks/database-request-store.ts +++ b/packages/appkit-ui/src/react/hooks/database-request-store.ts @@ -1,5 +1,5 @@ import { getPluginClientConfig } from "@/js/config"; -import { requestDatabase } from "@/js/database/client"; +import { requestDatabase } from "@/js/database/entity-client"; import { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseEntity } from "@/js/database/types"; diff --git a/packages/appkit-ui/src/react/hooks/use-database-create.ts b/packages/appkit-ui/src/react/hooks/use-database-create.ts index b64b23029..2fd7f7d76 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-create.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-create.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import type { ExactDatabaseParams } from "shared"; -import { createDatabaseRow } from "@/js/database/client"; +import { createDatabaseRow } from "@/js/database/entity-client"; import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseEntity, diff --git a/packages/appkit-ui/src/react/hooks/use-database-delete.ts b/packages/appkit-ui/src/react/hooks/use-database-delete.ts index 93faf1353..e8fe5d040 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-delete.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-delete.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { deleteDatabaseRow } from "@/js/database/client"; +import { deleteDatabaseRow } from "@/js/database/entity-client"; import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseId, DatabaseKeyedEntity } from "@/js/database/types"; diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts index a90922d54..e97fd3fd9 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -6,7 +6,7 @@ import { type ExactDatabaseParams, } from "shared"; -import { isDatabaseListPage } from "@/js/database/client"; +import { isDatabaseListPage } from "@/js/database/entity-client"; import type { DatabaseEntity, DatabaseKeyedEntity, diff --git a/packages/appkit-ui/src/react/hooks/use-database-mutation.ts b/packages/appkit-ui/src/react/hooks/use-database-mutation.ts new file mode 100644 index 000000000..e164116f2 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-mutation.ts @@ -0,0 +1,154 @@ +import { useCallback, useRef, useState, useSyncExternalStore } from "react"; + +import { prepareDatabaseOperation } from "@/js/database/client"; +import { + asDatabaseApiError, + type DatabaseApiError, +} from "@/js/database/errors"; +import { + getDatabaseSessionEpoch, + subscribeDatabaseSession, +} from "@/js/database/session"; +import type { + DatabaseMutationName, + DatabaseOperationInput, + DatabaseOperationOutput, +} from "@/js/database/types"; + +import { restartDatabaseQueries } from "./use-database-query"; + +/** How one `mutate` call ended. `error` is `null` exactly when it succeeded. */ +export type DatabaseMutationOutcome = + | { readonly data: T; readonly error: null } + | { readonly data: undefined; readonly error: DatabaseApiError }; + +/** What {@link useDatabaseMutation} returns. */ +export interface UseDatabaseMutationResult { + /** + * Send the write once and resolve with its outcome. It never rejects and + * is never retried or aborted, since a write may commit before its reply. + */ + readonly mutate: (input: Input) => Promise>; + /** The latest call's answer, or `undefined` until it succeeds. */ + readonly data: T | undefined; + /** Whether the latest call is in flight. */ + readonly isLoading: boolean; + /** + * Why the latest call failed: `NOT_EXPOSED` when the server published no + * such write, `OUTCOME_UNKNOWN` when it may have committed without a usable + * reply. Mounted queries restart after success and after `OUTCOME_UNKNOWN`. + */ + readonly error: DatabaseApiError | null; + /** Return to idle; a call in flight still runs but no longer reports here. */ + readonly reset: () => void; +} + +interface MutationState { + readonly epoch: number; + readonly data: unknown; + readonly error: DatabaseApiError | null; + readonly isLoading: boolean; +} + +const IDLE: MutationState = Object.freeze({ + epoch: -1, + data: undefined, + error: null, + isLoading: false, +}); + +/** + * Run one named write operation, only when `mutate` is called; mounting the + * hook sends nothing. The name, input, and answer are typed from the + * generated `database-operations.d.ts`. Only the latest call reports in the hook's + * state, and a session change clears it. A successful call restarts every + * mounted {@link useDatabaseQuery}. + * + * @param name - A POST, PATCH, PUT, or DELETE operation the registry declares + * @returns `mutate`, the latest call's data, loading and error state, and `reset` + * + * @example + * ```tsx + * const createNote = useDatabaseMutation("notes.create"); + * + * async function add(body: string) { + * const { error } = await createNote.mutate({ board_id: boardId, body }); + * if (error === null) setDraft(""); + * } + * ``` + */ +export function useDatabaseMutation( + name: N, +): UseDatabaseMutationResult< + DatabaseOperationInput, + DatabaseOperationOutput +> { + type Output = DatabaseOperationOutput; + const epoch = useSyncExternalStore( + subscribeDatabaseSession, + getDatabaseSessionEpoch, + getDatabaseSessionEpoch, + ); + const [state, setState] = useState(IDLE); + const latest = useRef(null); + + const mutate = useCallback( + async ( + input: DatabaseOperationInput, + ): Promise> => { + const call = Symbol(name); + latest.current = call; + const started = getDatabaseSessionEpoch(); + // An answer, even a 401 or a new actor's reply that opens an epoch, + // belongs to the epoch it was accepted in; a stale one stays in the old. + const settle = ( + next: Omit, + epoch = getDatabaseSessionEpoch(), + ) => { + if (latest.current === call) setState({ ...next, epoch }); + }; + settle({ data: undefined, error: null, isLoading: true }, started); + try { + const data = (await prepareDatabaseOperation( + name, + input, + "mutation", + ).send()) as Output; + settle({ data, error: null, isLoading: false }); + // A new session restarts every query on its own. + if (getDatabaseSessionEpoch() === started) restartDatabaseQueries(); + return { data, error: null }; + } catch (cause) { + const error = asDatabaseApiError(cause); + settle( + { data: undefined, error, isLoading: false }, + error.code === "SESSION_CHANGED" ? started : undefined, + ); + // A write that may have committed refreshes what it may have changed. + if ( + error.code === "OUTCOME_UNKNOWN" && + getDatabaseSessionEpoch() === started + ) { + restartDatabaseQueries(); + } + return { data: undefined, error }; + } + }, + [name], + ); + + const reset = useCallback(() => { + latest.current = null; + setState(IDLE); + }, []); + + const view = state.epoch === epoch ? state : IDLE; + return { + mutate, + // Generation describes the JSON this operation's route answers with. + data: view.data as Output | undefined, + isLoading: view.isLoading, + error: view.error, + reset, + }; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-query.ts b/packages/appkit-ui/src/react/hooks/use-database-query.ts new file mode 100644 index 000000000..31d13b6d0 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-database-query.ts @@ -0,0 +1,201 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; + +import { + type DatabaseOperationCall, + prepareDatabaseOperation, +} from "@/js/database/client"; +import { + asDatabaseApiError, + type DatabaseApiError, +} from "@/js/database/errors"; +import { + getDatabaseSessionEpoch, + subscribeDatabaseSession, +} from "@/js/database/session"; +import type { + DatabaseOperationInput, + DatabaseOperationOutput, + DatabaseQueryName, +} from "@/js/database/types"; + +/** Options for {@link useDatabaseQuery}. */ +export interface UseDatabaseQueryOptions { + /** `false` holds the query idle and sends nothing. Default true. */ + readonly enabled?: boolean; +} + +/** Latest state of one {@link useDatabaseQuery}. */ +export interface UseDatabaseQueryResult { + /** + * The answer for the current name and input, or `undefined` until it + * arrives. A refetch keeps it visible while it loads; a new input, a failed + * request, and a session change clear it. + */ + readonly data: T | undefined; + /** Whether a request for the current name and input is in flight. */ + readonly isLoading: boolean; + /** + * Why the latest request failed: `NOT_EXPOSED` when the server published no + * such query, `INVALID_REQUEST` for an input that cannot be sent, + * `UNAUTHENTICATED` once the session ended. + */ + readonly error: DatabaseApiError | null; + /** Abort any request in flight and send it again. No-op while idle. */ + readonly refetch: () => void; +} + +interface QueryState { + readonly url: string; + readonly epoch: number; + readonly data: unknown; + readonly error: DatabaseApiError | null; + readonly isLoading: boolean; +} + +type Prepared = + | { readonly call: DatabaseOperationCall } + | { readonly error: DatabaseApiError }; + +let restarts = 0; +const restartListeners = new Set<() => void>(); + +function subscribeRestarts(listener: () => void): () => void { + restartListeners.add(listener); + return () => { + restartListeners.delete(listener); + }; +} + +const getRestarts = () => restarts; + +/** + * Send every mounted query again, keeping its data visible while it loads. + * A successful mutation calls this, since an operation can change any row. + * + * @internal Shared with `useDatabaseMutation`; not part of the public surface. + */ +export function restartDatabaseQueries(): void { + restarts += 1; + for (const listener of [...restartListeners]) listener(); +} + +function prepare(name: string, input: unknown): Prepared { + try { + return { call: prepareDatabaseOperation(name, input, "query") }; + } catch (error) { + return { error: asDatabaseApiError(error) }; + } +} + +/** + * Read one named GET operation. The name, input, and data are typed from the + * generated `database-operations.d.ts`; the URL comes from the endpoints the server + * published, so a name it did not publish reports `NOT_EXPOSED` without a + * request. + * + * Each hook sends its own request; nothing is shared or cached between hooks. + * A new input aborts the previous request and clears its data and error; + * unmounting aborts too. When another reply shows the signed-in actor changed or + * the session ended, every query drops its data and asks again. A successful + * {@link useDatabaseMutation} restarts every mounted query. + * + * @param name - A GET operation the generated registry declares + * @param input - The operation's JSON input; an equal value on a later render + * keeps the request in flight + * @param options - `enabled: false` holds the query idle + * @returns The data, loading and error state, and `refetch` + * + * @example + * ```tsx + * const note = useDatabaseQuery("notes.get", { id: noteId }); + * if (note.isLoading) return ; + * return

{note.data?.body}

; + * ``` + */ +export function useDatabaseQuery( + name: N, + input: DatabaseOperationInput, + options: UseDatabaseQueryOptions = {}, +): UseDatabaseQueryResult> { + const enabled = options.enabled ?? true; + // Preparation encodes the input every render, so its URL is the identity + // of a request whatever the input object's identity. + const fresh = enabled ? prepare(name, input) : null; + const identity = + fresh === null + ? null + : "call" in fresh + ? fresh.call.url + : `${fresh.error.code}\0${fresh.error.message}`; + const prepared = useMemo(() => fresh, [identity]); + const call = prepared !== null && "call" in prepared ? prepared.call : null; + + const epoch = useSyncExternalStore( + subscribeDatabaseSession, + getDatabaseSessionEpoch, + getDatabaseSessionEpoch, + ); + const restart = useSyncExternalStore( + subscribeRestarts, + getRestarts, + getRestarts, + ); + const [refetches, setRefetches] = useState(0); + const [state, setState] = useState(null); + + useEffect(() => { + if (call === null) { + // A disabled or invalid query forgets its answer, so re-enabling it + // never shows that answer as settled before it is asked again. + setState(null); + return; + } + const controller = new AbortController(); + const { url } = call; + setState((previous) => + previous?.url === url && previous.epoch === epoch + ? { ...previous, isLoading: true } + : { url, epoch, data: undefined, error: null, isLoading: true }, + ); + const settle = (data: unknown, error: DatabaseApiError | null) => { + if (!controller.signal.aborted) { + setState({ url, epoch, data, error, isLoading: false }); + } + }; + call.send(controller.signal).then( + (data) => settle(data, null), + (cause: unknown) => settle(undefined, asDatabaseApiError(cause)), + ); + return () => controller.abort(); + }, [call, epoch, restart, refetches]); + + const refetch = useCallback(() => { + if (call !== null) setRefetches((count) => count + 1); + }, [call]); + + let view: Omit; + if (prepared === null) { + view = { data: undefined, error: null, isLoading: false }; + } else if ("error" in prepared) { + view = { data: undefined, error: prepared.error, isLoading: false }; + } else if (state?.url !== prepared.call.url || state.epoch !== epoch) { + // Before the effect starts this input's request, nothing of the previous + // input or session may show. + view = { data: undefined, error: null, isLoading: true }; + } else { + view = state; + } + return { + // Generation describes the JSON this operation's route answers with. + data: view.data as DatabaseOperationOutput | undefined, + isLoading: view.isLoading, + error: view.error, + refetch, + }; +} diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts index fe5cefc63..db89f9dac 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -11,7 +11,7 @@ import { type DatabaseOperation, type IdLike, resolveDatabaseUrl, -} from "@/js/database/client"; +} from "@/js/database/entity-client"; import { DatabaseApiError, invalidDatabaseQuery } from "@/js/database/errors"; import { diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts index cb87e981e..a5283b5ff 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -4,7 +4,7 @@ import { type ExactDatabaseParams, } from "shared"; -import { isDatabaseRow } from "@/js/database/client"; +import { isDatabaseRow } from "@/js/database/entity-client"; import type { DatabaseId, DatabaseKeyedEntity, diff --git a/packages/appkit-ui/src/react/hooks/use-database-update.ts b/packages/appkit-ui/src/react/hooks/use-database-update.ts index 4d347f11e..18863098a 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-update.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-update.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import type { ExactDatabaseParams } from "shared"; -import { updateDatabaseRow } from "@/js/database/client"; +import { updateDatabaseRow } from "@/js/database/entity-client"; import type { DatabaseApiError } from "@/js/database/errors"; import type { DatabaseId,