diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb25ba49..0edf4050c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,9 @@ jobs: run: pnpm --filter=dev-playground exec playwright install --with-deps chromium - name: Build packages run: pnpm build + # The generated database registry must reach appkit-ui's built declarations. + - name: Typecheck database components + run: pnpm --filter=dev-playground typecheck:database - name: Run Integration Tests run: pnpm --filter=dev-playground test:integration env: diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx index 09cf41785..bd63d2494 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -1,3 +1,4 @@ +import { DatabaseApiError, databaseApi } from "@databricks/appkit-ui/js/beta"; import { Badge, Button, @@ -17,21 +18,21 @@ import { useCallback, useEffect, useId, useState } from "react"; * is the row an `afterCreate` hook commits alongside each note. */ -interface Note { - id: number; - board_id: number; - author: string; - body: string; - created_at: string; -} +/** Only a short note preview is needed for the board picker. */ +const listBoards = () => + databaseApi.list("boards", { include: { notes: { limit: 5 } } }); -interface Board { - id: number; - slug: string; - title: string; - created_at: string; - notes?: Note[]; -} +/** Listing notes directly is what puts them through the entity's serializer. */ +const listNotes = (boardId: number) => + databaseApi.list("notes", { + where: { board_id: boardId }, + order: { created_at: "desc" }, + limit: 5, + }); + +// Row types come from the generated schema through the calls that read them. +type Board = Awaited>["items"][number]; +type Note = Awaited>["items"][number]; interface NoteEvent { id: number; @@ -44,23 +45,10 @@ interface TimelineNote extends Note { note_events?: NoteEvent[]; } -interface Timeline extends Board { +interface Timeline extends Omit { notes?: TimelineNote[]; } -/** Only a short note preview is needed for the board picker. */ -const BOARDS_URL = `/api/database/boards?include=${encodeURIComponent( - JSON.stringify({ notes: { limit: 5 } }), -)}`; - -/** Listing notes directly is what puts them through the entity's serializer. */ -const notesUrl = (boardId: number) => - `/api/database/notes?where=${encodeURIComponent( - JSON.stringify({ board_id: boardId }), - )}&order=${encodeURIComponent( - JSON.stringify({ created_at: "desc" }), - )}&limit=5`; - /** The audit trail is a read-only include on the generated board detail route. */ const timelineUrl = (boardId: number) => `/api/database/boards/${boardId}?include=${encodeURIComponent( @@ -89,6 +77,14 @@ async function getJson(url: string): Promise { return body as T; } +/** The client decodes the same envelope; a field detail still reads first. */ +function errorText(err: unknown): string { + if (err instanceof DatabaseApiError) { + return err.details[0]?.message ?? err.message; + } + return err instanceof Error ? err.message : String(err); +} + export function BoardExplorer() { const authorFieldId = useId(); const bodyFieldId = useId(); @@ -108,7 +104,7 @@ export function BoardExplorer() { const load = useCallback(async (slug?: string | null) => { setError(null); try { - const page = await getJson<{ items: Board[] }>(BOARDS_URL); + const page = await listBoards(); setBoards(page.items); const active = page.items.find((entry) => entry.slug === slug) ?? page.items[0]; @@ -120,13 +116,13 @@ export function BoardExplorer() { return; } const [listed, board] = await Promise.all([ - getJson<{ items: Note[] }>(notesUrl(active.id)), + listNotes(active.id), getJson(timelineUrl(active.id)), ]); setNotes(listed.items); setTimeline(board); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errorText(err)); } }, []); @@ -222,7 +218,7 @@ export function BoardExplorer() { variant="secondary" className="ml-2 tabular-nums font-normal" > - {entry.notes?.length ?? 0} notes + {entry.notes.length} notes ))} diff --git a/apps/dev-playground/client/tsconfig.database.json b/apps/dev-playground/client/tsconfig.database.json new file mode 100644 index 000000000..6447ceb48 --- /dev/null +++ b/apps/dev-playground/client/tsconfig.database.json @@ -0,0 +1,15 @@ +{ + // Type-check the database components against the built appkit-ui + // declarations, where the Vite alias points, so the generated registry is + // proven to reach `@databricks/appkit-ui/js/beta` through `dist`. Run + // `pnpm build` first. + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.database.tsbuildinfo", + "paths": { + "@/*": ["./src/*"], + "@databricks/appkit-ui/*": ["../../../packages/appkit-ui/dist/*"] + } + }, + "include": ["src/components/database", "../shared/appkit-types/database.d.ts"] +} diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index d07afce6d..7c057c4ef 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -13,6 +13,7 @@ "install": "cd client && npm install && cd ..", "preview": "vite preview", "check": "tsc", + "typecheck:database": "tsc -p client/tsconfig.database.json --noEmit", "clean": "rm -rf build && cd client && rm -rf dist", "clean:full": "rm -rf build node_modules && cd client && rm -rf dist node_modules", "test:integration": "playwright test", diff --git a/apps/dev-playground/shared/appkit-types/database.d.ts b/apps/dev-playground/shared/appkit-types/database.d.ts index f6e599c75..a388108d0 100644 --- a/apps/dev-playground/shared/appkit-types/database.d.ts +++ b/apps/dev-playground/shared/appkit-types/database.d.ts @@ -1,49 +1,67 @@ // Auto-generated by AppKit - DO NOT EDIT import "@databricks/appkit"; -declare module "@databricks/appkit" { - type DatabaseLogicalFilter = T & { - and?: readonly DatabaseLogicalFilter[]; - or?: readonly DatabaseLogicalFilter[]; - }; +type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; +}; - interface DatabaseRegistry { - "boards": { - row: { +interface GeneratedDatabaseRegistry { + "boards": { + row: { "id": number; "slug": string; "title": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "slug": string; "title": string; "created_at": string; }; - insert: { + insert: { "slug": string; "title": string; "created_at"?: string; }; - update: { + update: { "slug"?: string; "title"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "slug"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "title"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "notes": { to: "notes"; many: true }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "slug": string; + "title": string; + "created_at"?: string; + }; + update: { + "slug"?: string; + "title"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "slug"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "title"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + orderable: "id" | "slug" | "title" | "created_at"; + key: "id"; }; - "notes": { - row: { + }; + "notes": { + row: { "id": number; "board_id": number; "author": string; @@ -51,28 +69,28 @@ declare module "@databricks/appkit" { "body": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "board_id": number; "author": string; "body": string; "created_at": string; }; - insert: { + insert: { "board_id": number; "author": string; "author_email"?: string | null; "body": string; "created_at"?: string; }; - update: { + update: { "board_id"?: number; "author"?: string; "author_email"?: string | null; "body"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "board_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "author"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; @@ -80,45 +98,93 @@ declare module "@databricks/appkit" { "body"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "boards": { to: "boards"; many: false }; "note_events": { to: "note_events"; many: true }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "board_id": number; + "author": string; + "body": string; + "created_at"?: string; + }; + update: { + "board_id"?: number; + "author"?: string; + "body"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "board_id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "author"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "body"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + orderable: "id" | "board_id" | "author" | "body" | "created_at"; + key: "id"; }; - "note_events": { - row: { + }; + "note_events": { + row: { "id": number; "note_id": number; "action": string; "created_at": string; }; - publicRow: { + publicRow: { "id": number; "note_id": number; "action": string; "created_at": string; }; - insert: { + insert: { "note_id": number; "action": string; "created_at"?: string; }; - update: { + update: { "note_id"?: number; "action"?: string; "created_at"?: string; }; - filters: DatabaseLogicalFilter<{ + filters: DatabaseLogicalFilter<{ "id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "note_id"?: number | readonly (number)[] | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; "action"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; "created_at"?: string | readonly (string)[] | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; }>; - includes: { + includes: { "notes": { to: "notes"; many: false }; }; - hasPrimaryKey: true; + hasPrimaryKey: true; + api: { + insert: { + "note_id": number; + "action": string; + "created_at"?: string; + }; + update: { + "note_id"?: number; + "action"?: string; + }; + filters: DatabaseLogicalFilter<{ + "id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "note_id"?: number | { eq?: number; neq?: number; in?: readonly (number)[]; gt?: number; gte?: number; lt?: number; lte?: number; }; + "action"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; like?: string; ilike?: string; }; + "created_at"?: string | { eq?: string; neq?: string; in?: readonly (string)[]; gt?: string; gte?: string; lt?: string; lte?: string; }; + }>; + orderable: "id" | "note_id" | "action" | "created_at"; + key: "id"; }; - } + }; +} + +declare module "@databricks/appkit" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} +} + +declare global { + interface DatabricksAppKitDatabaseRegistry extends GeneratedDatabaseRegistry {} } diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 9ddca9e60..c77fc3537 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 1262586, - "unpacked": 4339472 + "packed": 1265908, + "unpacked": 4349752 }, "dist": { "total": { - "raw": 4324843, - "gzip": 1482635 + "raw": 4335123, + "gzip": 1486968 }, "js": { - "raw": 1285397, - "gzip": 455870 + "raw": 1288712, + "gzip": 457426 }, "types": { "raw": 466001, - "gzip": 169053 + "gzip": 169051 }, "maps": { - "raw": 2562650, - "gzip": 853894 + "raw": 2569615, + "gzip": 856673 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 883 + "fileCount": 887 }, "entries": [ { "id": ".", - "gzip": 106622, + "gzip": 107011, "composition": { - "initialGzip": 104048, + "initialGzip": 104437, "lazyGzip": 2574, - "totalGzip": 106622, - "own": 338343, + "totalGzip": 107011, + "own": 339413, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 99378, + "gzip": 99767, "kind": "initial" }, { @@ -64,17 +64,17 @@ }, { "id": "./beta", - "gzip": 101030, + "gzip": 101221, "composition": { - "initialGzip": 100546, + "initialGzip": 100737, "lazyGzip": 484, - "totalGzip": 101030, - "own": 303645, + "totalGzip": 101221, + "own": 304130, "nodeModules": null, "chunks": [ { "label": "beta.js", - "gzip": 82962, + "gzip": 83153, "kind": "initial" }, { @@ -127,12 +127,12 @@ }, { "id": "./testing", - "gzip": 75503, + "gzip": 75873, "composition": { "initialGzip": 42855, - "lazyGzip": 32648, - "totalGzip": 75503, - "own": 218661, + "lazyGzip": 33018, + "totalGzip": 75873, + "own": 219731, "nodeModules": null, "chunks": [ { @@ -152,7 +152,7 @@ }, { "label": "index.js", - "gzip": 28217, + "gzip": 28587, "kind": "lazy" }, { @@ -188,17 +188,17 @@ }, { "id": "./type-generator", - "gzip": 23322, + "gzip": 23691, "composition": { - "initialGzip": 23322, + "initialGzip": 23691, "lazyGzip": 0, - "totalGzip": 23322, - "own": 66936, + "totalGzip": 23691, + "own": 68004, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 23322, + "gzip": 23691, "kind": "initial" } ] @@ -209,25 +209,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 358082, - "unpacked": 1443634 + "packed": 377349, + "unpacked": 1505945 }, "dist": { "total": { - "raw": 1439689, - "gzip": 483823 + "raw": 1502000, + "gzip": 505901 }, "js": { - "raw": 404113, - "gzip": 135639 + "raw": 415497, + "gzip": 140191 }, "types": { - "raw": 234615, - "gzip": 85795 + "raw": 252687, + "gzip": 92239 }, "maps": { - "raw": 784563, - "gzip": 259133 + "raw": 817418, + "gzip": 270215 }, "css": { "raw": 16398, @@ -237,7 +237,7 @@ "raw": 0, "gzip": 0 }, - "fileCount": 503 + "fileCount": 523 }, "entries": [ { @@ -270,17 +270,17 @@ }, { "id": "./js/beta", - "gzip": 20, + "gzip": 2113, "composition": { - "initialGzip": 20, + "initialGzip": 2113, "lazyGzip": 0, - "totalGzip": 20, - "own": 0, + "totalGzip": 2113, + "own": 4968, "nodeModules": 0, "chunks": [ { "label": "beta.js", - "gzip": 20, + "gzip": 2113, "kind": "initial" } ] diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 6468b9e25..ca4e8493f 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -79,8 +79,10 @@ With the server plugin enabled, this registers: | PATCH | `/api/database/notes/:id` | Update a row | | DELETE | `/api/database/notes/:id` | Delete a row | -A table without a public primary key supports list and create only. `upsert` is -available to server code but has no generated HTTP route. +A table without a public primary key supports create and, if it has a sortable +public column, list with an explicit non-empty `order`. A keyless table with no +sortable columns has no list route. `upsert` is available to server code but has +no generated HTTP route. ## Schema discovery and overrides @@ -230,6 +232,138 @@ The callback deadline does not cancel arbitrary JavaScript, HTTP requests, or other external side effects. Avoid putting external side effects in hooks that need database rollback semantics. +## Browser client (beta) + +`@databricks/appkit-ui/js/beta` provides `databaseApi`, a typed client for the +generated routes. Entity names, parameters, values, and rows are typed from the +same generated registry as the server-side client, restricted to what the +generated routes accept. The client adds no authorization. Anyone who can load +the page can call the same routes. + +### Setup + +Run `appkit generate-types` or use the AppKit Vite plugin to write +`shared/appkit-types/database.d.ts`, and include it in the client's TypeScript +project. It binds the server registry and a shared global interface used by +`@databricks/appkit-ui/js/beta`; server-only apps do not need the UI package or +`skipLibCheck`. Until the file exists, every entity name is a type error. + +The client finds routes in the endpoint map the server embeds in the page. When +the `api` configuration does not expose an operation, a call to it fails with +`NOT_EXPOSED` and sends no request. + +### Calls + +```ts +import { databaseApi } from "@databricks/appkit-ui/js/beta"; + +const page = await databaseApi.list("notes", { + where: { board_id: 7 }, + order: { created_at: "desc" }, + limit: 20, +}); +const board = await databaseApi.get("boards", 7, { select: ["id", "title"] }); +const note = await databaseApi.create("notes", { + board_id: board.id, + author: "ada", + body: "Ship it", +}); +await databaseApi.update("notes", note.id, { body: "Shipped" }); +await databaseApi.remove("notes", note.id); +``` + +| Call | Route | Resolves with | +| --- | --- | --- | +| `list(entity, params?)` | `GET /api/database/` | The list envelope `{ items, limit, offset }` | +| `get(entity, id, params?)` | `GET /api/database//:id` | The row | +| `create(entity, values)` | `POST /api/database/` | The created row | +| `update(entity, id, values)` | `PATCH /api/database//:id` | The updated row | +| `remove(entity, id)` | `DELETE /api/database//:id` | Nothing | + +A failed call rejects with a [`DatabaseApiError`](#errors). An optional last +argument, `{ signal }`, cancels the request, and the promise then rejects with +the abort reason. Cancelling a write does not undo it if the server already +committed it. + +Only tables with a public primary key that the URL path can represent have +`get`, `update`, and `remove`. Boolean and JSONB primary keys, like private or +missing keys, are treated as keyless over HTTP: no keyed routes are published, +although creates and explicitly ordered lists remain available when supported. +Calling a keyed operation on these tables is a type error. A missing row on a +supported keyed route rejects with `NOT_FOUND`. + +### Parameters + +| Parameter | `list` | `get` | Accepts | +| --- | --- | --- | --- | +| `where` | Yes | No | Public, queryable columns. A value, or an operator object (`eq`, `neq`, `in`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte` by column kind, `is: null` for nullable columns), combined with `and` and `or` | +| `order` | Yes | No | Public, queryable columns mapped to `"asc"` or `"desc"`; required and non-empty for keyless lists | +| `select` | Yes | Yes | Public columns. The row type narrows to them | +| `include` | Yes | Yes | Exposed relations, `true` or options, at most two edges deep. Only a to-many relation takes a `limit` | +| `limit`, `offset` | Yes | No | Integers, 0 to 500 and 0 to 10,000 | + +Private columns, JSON columns in `where` or `order`, and unknown parameters are +compile errors. An explicit `where` must not be empty, and an undefined nested +filter value fails rather than broadening the read; omit `where` entirely to +list all rows. A top-level `where: {}` fails locally with guidance to omit the +parameter, without sending a request. For optional search controls, for example: + +```typescript +const where = search ? { body: { ilike: `%${search}%` } } : undefined; +await databaseApi.list("notes", { where }); +``` + +A to-many include adds an array to each row and a to-one include adds a row or +`null`. JSON carries bigint columns as decimal strings, so rows type them as +`string`, and filters accept a string or a safe integer. Dynamic `select` arrays +return optional properties for their possible columns; a literal tuple returns +those properties as required. This also applies to detail and included rows. +Params typed broadly with an optional `select` keep row fields optional. + +If a server-side read serializer changes a list or detail row, the schema-based +return type cannot describe that custom response. Validate or narrow custom +read results in the caller; the client does not infer serializer output. + +### Values + +`create` and `update` accept only the fields the generated route accepts. +Private columns, server-generated columns such as `id()`, and unknown fields are +compile errors, including fields that a spread carries in. Every update field is +optional, and primary keys and `defaultNow()` or `defaultRandom()` columns +cannot be updated. Bigint columns accept a decimal string or a safe integer. +NaN and positive or negative Infinity fail locally before a write is sent, +including inside JSON values. Use an explicit `null` to clear a nullable column; +a non-finite number never implicitly becomes null. Query numbers must also be +finite. + +Skip an update when a form has no changes. If an update remains empty after +`beforeUpdate`, the server returns `INVALID_REQUEST` with a `body` detail +explaining that at least one field is required. An initially empty patch is +still allowed when `beforeUpdate` supplies its values. + +The answered row is the public row the database holds after any `before*` hook +ran. A read serializer never reshapes a write's response. + +### Errors + +A `DatabaseApiError` has a stable `code`, the HTTP `status`, a `message`, and +`details`. Each detail is a `{ path, message }` pair that names a public request +field. Branch on `code` rather than `message`. + +| `code` | `status` | Meaning | +| --- | --- | --- | +| `NOT_EXPOSED` | `null` | No published route for the operation. Nothing was sent | +| `INVALID_REQUEST` | 400 or `null` | Malformed or unsupported parameters; `null` means local validation rejected the call before sending it | +| `FORBIDDEN` | 403 | The database refused the operation | +| `NOT_FOUND` | 404 | No row has this id | +| `CONFLICT` | 409 | A constraint rejected the change | +| `PAYLOAD_TOO_LARGE` | 413 | The response exceeded the size limit | +| `UNSUPPORTED_MEDIA_TYPE` | 415 | The request body was not JSON | +| `VALIDATION_FAILED` | 422 | A value failed validation | +| `TRANSIENT` | 503 or `null` | Temporarily unavailable, or a read received no response | +| `OUTCOME_UNKNOWN` | `null` or a successful HTTP status | A write received no usable response; it may have committed. Check before retrying | +| `INTERNAL` | 500 or other | Any other failure | + ## API reference - [`database`](../api/appkit/Function.database.md) diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts index 088ff80b1..dce640f45 100644 --- a/packages/appkit-ui/src/js/beta.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -1,2 +1,29 @@ // Beta JS utilities -- APIs may change between minor releases. // Import from '@databricks/appkit-ui/js' once graduated to stable. + +// Database client + types. Tracks the `database` plugin, which ships at beta +// from '@databricks/appkit/beta'. +export type { + DatabaseErrorCategory, + DatabaseErrorDetail, + DatabaseListPage, +} from "shared"; +export { + type DatabaseApi, + type DatabaseRequestOptions, + databaseApi, +} from "./database/client"; +export { DatabaseApiError, type DatabaseApiErrorCode } from "./database/errors"; +export type { DatabaseRegistry } from "./database/registry"; +export type { + DatabaseEntity, + DatabaseId, + DatabaseInsert, + DatabaseKeyedEntity, + DatabaseListParams, + DatabaseListRow, + DatabaseRecordParams, + DatabaseRecordRow, + DatabaseRow, + DatabaseUpdate, +} from "./database/types"; diff --git a/packages/appkit-ui/src/js/database/client.test.ts b/packages/appkit-ui/src/js/database/client.test.ts new file mode 100644 index 000000000..087370f62 --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -0,0 +1,638 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { _resetConfigCache } from "../config"; +import { databaseApi as typedApi, type DatabaseRequestOptions } from "./client"; +import { DatabaseApiError } from "./errors"; + +// This package has no generated registry, so every entity name is `never` +// here. The typed surface is compiled against a real schema in the appkit +// type-generator tests; these cover the runtime transport. +const databaseApi = typedApi as unknown as { + list( + entity: string, + params?: object, + init?: DatabaseRequestOptions, + ): Promise<{ items: unknown[]; limit: number; offset: number }>; + get( + entity: string, + id: string | number | bigint, + params?: object, + init?: DatabaseRequestOptions, + ): Promise>; + create( + entity: string, + values: object, + init?: DatabaseRequestOptions, + ): Promise>; + update( + entity: string, + id: string | number | bigint, + values: object, + init?: DatabaseRequestOptions, + ): Promise>; + remove( + entity: string, + id: string | number | bigint, + init?: DatabaseRequestOptions, + ): Promise; +}; + +const PAGE = { items: [{ id: 1, body: "hi" }], limit: 5, offset: 0 }; + +function publish(database: Record | undefined): void { + window.__appkit__ = { + appName: "test", + queries: {}, + endpoints: database ? { database } : {}, + plugins: {}, + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error("expected the request to fail"); + }, + (error: unknown) => error, + ); +} + +describe("databaseApi.list", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => json(PAGE)); + vi.stubGlobal("fetch", fetchMock); + publish({ + "notes.list": "/api/database/notes", + "notes.detail": "/api/database/notes/:id", + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete window.__appkit__; + _resetConfigCache(); + }); + + test("refuses an unpublished operation locally without sending a request", async () => { + const error = await rejection(databaseApi.list("boards")); + + expect(error).toBeInstanceOf(DatabaseApiError); + expect(error).toMatchObject({ + name: "DatabaseApiError", + code: "NOT_EXPOSED", + status: null, + details: [], + message: 'Database operation "boards.list" is not exposed', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("refuses every operation when the plugin published no routes", async () => { + _resetConfigCache(); + publish(undefined); + + await expect(databaseApi.list("notes")).rejects.toMatchObject({ + code: "NOT_EXPOSED", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the published route with the encoded query", async () => { + const page = await databaseApi.list("notes", { + where: { board_id: 7 }, + order: { created_at: "desc" }, + limit: 5, + }); + + expect(page).toEqual(PAGE); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [path, query] = url.split("?"); + expect(path).toBe("/api/database/notes"); + expect(Object.fromEntries(new URLSearchParams(query))).toEqual({ + where: '{"board_id":7}', + order: '{"created_at":"desc"}', + limit: "5", + }); + expect(init.method).toBe("GET"); + expect(new Headers(init.headers).get("Accept")).toBe("application/json"); + }); + + test("sends no query string when there are no params", async () => { + await databaseApi.list("notes"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/database/notes"); + }); + + test("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("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 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/client.ts b/packages/appkit-ui/src/js/database/client.ts new file mode 100644 index 000000000..cff3bd72a --- /dev/null +++ b/packages/appkit-ui/src/js/database/client.ts @@ -0,0 +1,457 @@ +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. */ +type DatabaseOperation = "list" | "detail" | "create" | "update" | "delete"; + +/** An id as a keyed route addresses it in its path. */ +type IdLike = string | number | bigint; + +/** Per-call options for a database request. */ +export interface DatabaseRequestOptions { + /** + * Cancels the request; the promise then rejects with the abort reason. + * 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 after any `beforeCreate` hook. Private, generated, and + * undeclared fields are compile errors, as the server refuses them. + * + * @example + * ```typescript + * const note = await databaseApi.create("notes", { + * board_id: 7, + * author: "ada", + * body: "Ship it", + * }); + * note.id; + * ``` + */ + create>( + entity: K, + values: V & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>; + + /** + * Change some fields of one row with `PATCH /api/database//:id` and + * return the updated row. A missing row rejects with `NOT_FOUND`. + * + * @example + * ```typescript + * await databaseApi.update("notes", 7, { body: "Shipped" }); + * ``` + */ + update>( + entity: K, + id: DatabaseId, + values: V & ExactDatabaseParams>, + init?: DatabaseRequestOptions, + ): Promise>; + + /** + * Delete one row with `DELETE /api/database//:id`. A missing row + * rejects with `NOT_FOUND`. + * + * @example + * ```typescript + * await databaseApi.remove("notes", 7); + * ``` + */ + remove( + entity: K, + id: DatabaseId, + init?: DatabaseRequestOptions, + ): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Find the route the server published for one operation. The plugin publishes + * only what its `api` configuration exposes, so a missing entry is refused + * here with `NOT_EXPOSED` and no request is sent. + */ +function resolveDatabaseUrl( + entity: string, + operation: DatabaseOperation, + id?: IdLike, + query?: string, +): string { + const name = `${entity}.${operation}`; + const endpoints = getClientConfig().endpoints.database; + const path = + isRecord(endpoints) && Object.hasOwn(endpoints, name) + ? endpoints[name] + : undefined; + if (typeof path !== "string") { + throw new DatabaseApiError( + "NOT_EXPOSED", + null, + `Database operation "${name}" is not exposed`, + ); + } + const url = + id === undefined + ? path + : path.replace(":id", encodeURIComponent(String(id))); + return query ? `${url}?${query}` : url; +} + +/** Keep only details shaped like the server's; they name public fields only. */ +function publicDetails(value: unknown): DatabaseErrorDetail[] { + if (!Array.isArray(value)) return []; + return value.flatMap((detail): DatabaseErrorDetail[] => + isRecord(detail) && + typeof detail.message === "string" && + Array.isArray(detail.path) && + detail.path.every((segment) => typeof segment === "string") + ? [{ path: [...detail.path], message: detail.message }] + : [], + ); +} + +/** Decode a failure envelope; a non-JSON body still keeps its category. */ +async function failure(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = isRecord(body) ? body : {}; + return new DatabaseApiError( + databaseErrorCategoryForStatus(response.status), + response.status, + typeof envelope.error === "string" + ? envelope.error + : `Database request failed with status ${response.status}`, + publicDetails(envelope.details), + ); +} + +/** + * Send one request and decode its JSON body, throwing `DatabaseApiError` for + * anything but the shape `accept` expects. A `204` has no body, so `accept` + * sees `undefined`. An abort rejects with the signal's own reason, so a + * caller can tell cancellation from failure. + */ +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. */ +function isDatabaseListPage(body: unknown): body is DatabaseListPage { + return ( + isRecord(body) && + Array.isArray(body.items) && + typeof body.limit === "number" && + typeof body.offset === "number" + ); +} + +/** A detail route answers one bare row; a serializer returns an object too. */ +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`; its signature carries the + * checks, while the entity is still a literal. + */ +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 behind `databaseApi.update`. */ +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 behind `databaseApi.remove`. */ +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 new file mode 100644 index 000000000..7001a49b6 --- /dev/null +++ b/packages/appkit-ui/src/js/database/errors.ts @@ -0,0 +1,61 @@ +import { + type DatabaseErrorCategory, + type DatabaseErrorDetail, + DatabaseQueryEncodingError, +} from "shared"; + +/** + * A server category, `NOT_EXPOSED` for unpublished routes, or + * `OUTCOME_UNKNOWN` when a write has no response and may have committed. + */ +export type DatabaseApiErrorCode = + | DatabaseErrorCategory + | "NOT_EXPOSED" + | "OUTCOME_UNKNOWN"; + +/** A failed database request, decoded from the generated `{ error, details }`. */ +export class DatabaseApiError extends Error { + /** Stable category; branch on this rather than on `message`. */ + readonly code: DatabaseApiErrorCode; + /** HTTP status, or `null` when no response was received. */ + readonly status: number | null; + /** Validation details naming request fields, from local checks or the server. */ + readonly details: readonly DatabaseErrorDetail[]; + + constructor( + code: DatabaseApiErrorCode, + status: number | null, + message: string, + details: readonly DatabaseErrorDetail[] = [], + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "DatabaseApiError"; + this.code = code; + this.status = status; + this.details = details; + } +} + +/** Do not echo a caller's invalid query values into the client-facing error. */ +export function invalidDatabaseQuery(error?: unknown): DatabaseApiError { + if (error instanceof DatabaseQueryEncodingError) { + return new DatabaseApiError("INVALID_REQUEST", null, error.message, [ + { path: [error.parameter], message: error.message }, + ]); + } + return new DatabaseApiError( + "INVALID_REQUEST", + null, + "Database query contains an unsupported value", + ); +} + +/** Local write failures name the body, never the values it would have sent. */ +export function invalidDatabaseWrite( + message = "Database write contains an unsupported value", +): DatabaseApiError { + return new DatabaseApiError("INVALID_REQUEST", null, message, [ + { path: ["body"], message }, + ]); +} diff --git a/packages/appkit-ui/src/js/database/registry.ts b/packages/appkit-ui/src/js/database/registry.ts new file mode 100644 index 000000000..c787e4148 --- /dev/null +++ b/packages/appkit-ui/src/js/database/registry.ts @@ -0,0 +1,12 @@ +/** + * Browser binding target for the application's generated database registry. + * The generated database.d.ts extends the global bridge without importing UI, + * so apps using only the server package can typecheck independently. + */ +declare global { + // oxlint-disable-next-line typescript/no-empty-object-type -- populated by typegen. + interface DatabricksAppKitDatabaseRegistry {} +} + +// oxlint-disable-next-line typescript/no-empty-object-type -- application registry. +export interface DatabaseRegistry extends DatabricksAppKitDatabaseRegistry {} diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts new file mode 100644 index 000000000..9ed7463c6 --- /dev/null +++ b/packages/appkit-ui/src/js/database/types.ts @@ -0,0 +1,84 @@ +import type { + DatabaseApiEntityFor, + IdFor, + InsertFor, + KeyedEntityFor, + ListParamsFor, + ListRowFor, + PublicRowFor, + RecordParamsFor, + RecordRowFor, + UpdateFor, +} from "shared"; + +import type { DatabaseRegistry } from "./registry"; + +/** Entity names the generated registry binds, or `never` before typegen runs. */ +export type DatabaseEntity = DatabaseApiEntityFor; + +/** + * Entities with a path-decodable public key, the only ones with a detail route. + * Private, missing, boolean, and JSON keys do not address generated HTTP routes. + */ +export type DatabaseKeyedEntity = KeyedEntityFor; + +/** The public primary key value that addresses one row of `K`. */ +export type DatabaseId = IdFor< + DatabaseRegistry, + K +>; + +/** + * The public query `GET /api/database/` accepts: filters and ordering + * over queryable columns, projection over public columns, and includes over + * relations, each checked against the target's own public facets. + */ +export type DatabaseListParams = ListParamsFor< + DatabaseRegistry, + K +>; + +/** + * One list row as JSON carries it: the public row, narrowed by `select` and + * widened by `include`, with bigint columns as decimal strings. + */ +export type DatabaseListRow< + K extends DatabaseEntity, + P = Record, +> = ListRowFor; + +/** The public query `GET /api/database//:id` accepts: `select` and `include`. */ +export type DatabaseRecordParams = RecordParamsFor< + DatabaseRegistry, + K +>; + +/** One detail row as JSON carries it; projection follows the list rules. */ +export type DatabaseRecordRow< + K extends DatabaseEntity, + P = Record, +> = RecordRowFor; + +/** + * The body `POST /api/database/` accepts: public columns the server + * does not generate, with bigint columns as a decimal string or safe integer. + */ +export type DatabaseInsert = InsertFor< + DatabaseRegistry, + K +>; + +/** + * The body `PATCH /api/database//:id` accepts: every field optional, + * and no key, generated, or default-stamped column. + */ +export type DatabaseUpdate = UpdateFor< + DatabaseRegistry, + K +>; + +/** The public row a create or update answers with, as JSON carries it. */ +export type DatabaseRow = PublicRowFor< + DatabaseRegistry, + K +>; diff --git a/packages/appkit/src/database/errors.ts b/packages/appkit/src/database/errors.ts index ef8675d65..12d810d34 100644 --- a/packages/appkit/src/database/errors.ts +++ b/packages/appkit/src/database/errors.ts @@ -1,25 +1,17 @@ +import { + type DatabaseErrorCategory, + type DatabaseErrorDetail, + databaseErrorCategoryForStatus, +} from "shared"; + import { AppKitError } from "../errors"; import { createLogger } from "../logging/logger"; -const logger = createLogger("database"); - -export type DatabaseErrorCategory = - | "INVALID_REQUEST" - | "VALIDATION_FAILED" - | "NOT_FOUND" - | "CONFLICT" - | "FORBIDDEN" - | "TRANSIENT" - | "UNSUPPORTED_MEDIA_TYPE" - | "PAYLOAD_TOO_LARGE" - | "INTERNAL" - | "SETUP_FAILED"; +// The category vocabulary is shared with the browser client, which reads the +// same statuses back into the same categories. +export type { DatabaseErrorCategory, DatabaseErrorDetail } from "shared"; -/** Which request field a rejection concerns; it never carries caller values. */ -export interface DatabaseErrorDetail { - readonly path: readonly string[]; - readonly message: string; -} +const logger = createLogger("database"); type DatabaseErrorPhase = | "setup" @@ -57,17 +49,6 @@ const definitions: Record< SETUP_FAILED: { message: "Database setup failed", statusCode: 500 }, }; -const categoryByStatus: Readonly> = { - 400: "INVALID_REQUEST", - 403: "FORBIDDEN", - 404: "NOT_FOUND", - 409: "CONFLICT", - 413: "PAYLOAD_TOO_LARGE", - 415: "UNSUPPORTED_MEDIA_TYPE", - 422: "VALIDATION_FAILED", - 503: "TRANSIENT", -}; - /** AppKit-facing database failure with stable metadata and no driver details. */ export class DatabasePluginError extends AppKitError { readonly code = "DATABASE_PLUGIN_ERROR"; @@ -167,6 +148,5 @@ export function databaseErrorFromStatus( status: number, phase: DatabaseErrorPhase, ): DatabasePluginError { - const category = categoryByStatus[status] ?? "INTERNAL"; - return new DatabasePluginError(category, phase); + return new DatabasePluginError(databaseErrorCategoryForStatus(status), phase); } diff --git a/packages/appkit/src/plugins/database/crud/capabilities.ts b/packages/appkit/src/plugins/database/crud/capabilities.ts new file mode 100644 index 000000000..427fb074e --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/capabilities.ts @@ -0,0 +1,55 @@ +import type { ColumnMeta } from "../../../database/schema-builder"; +import { filterOperatorsForKind } from "../../../database/schema-builder/types"; + +/** + * What one column may do over the generated HTTP routes. The route compiler + * and the type generator both read these, so the typed browser surface cannot + * drift from what the server actually accepts. + */ +export interface ColumnHttpCapabilities { + /** A request may project it; every non-private column. */ + readonly selectable: boolean; + /** A request may filter or order by it; its kind has an operator matrix. */ + readonly queryable: boolean; + /** A create body may set it, including a caller-chosen key. */ + readonly creatable: boolean; + /** An update body may set it; a key or a stamp is never one. */ + readonly updatable: boolean; + /** It can address one row in `/:table/:id`. */ + readonly publicKey: boolean; +} + +const PATH_KEY_KINDS: ReadonlySet = new Set([ + "number", + "bigint", + "string", + "uuid", + "enum", + "date", +]); + +/** Derive one column's HTTP capabilities from its finalized metadata. */ +export function columnHttpCapabilities( + meta: ColumnMeta, +): ColumnHttpCapabilities { + const selectable = !meta.isPrivate; + // Database-generated identities belong to the server, never the caller. + const creatable = + selectable && + !meta.serverGenerated && + !(meta.primaryKey && meta.defaultRandom); + return { + selectable, + queryable: selectable && filterOperatorsForKind(meta.kind).length > 0, + creatable, + // Rewriting a key would move a row out from under every existing reference, + // and rewriting a database-materialized stamp would rewrite history. + updatable: + creatable && !meta.primaryKey && !meta.defaultNow && !meta.defaultRandom, + // A private key must not power `GET /:table/:id`: per-id probing would + // answer 200 or 404 on an identifier the schema hides, so over HTTP the + // table is keyless — no detail route, and lists must name their own order. + // Boolean and JSON keys have no representation the path decoder accepts. + publicKey: meta.primaryKey && selectable && PATH_KEY_KINDS.has(meta.kind), + }; +} diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts index bdd9d9d9d..2a23afba4 100644 --- a/packages/appkit/src/plugins/database/crud/contract.ts +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -1,9 +1,9 @@ import { DatabasePluginError } from "../../../database/errors"; import type { Row } from "../../../database/runtime"; import type { AppKitTable } from "../../../database/schema-builder"; -import { filterOperatorsForKind } from "../../../database/schema-builder/types"; import { isPlainObject as hasPlainObjectPrototype } from "../../../utils/is-plain-object"; import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../defaults"; +import { columnHttpCapabilities } from "./capabilities"; import { type CompiledColumn, compileColumn, type JsonValue } from "./codecs"; /** One relation edge wired to the contract of its target table. */ @@ -191,23 +191,13 @@ function compileTable(table: AppKitTable): MutableCrudTable { for (const meta of Object.values(table.$columns)) { const column = compileColumn(meta); columns.set(meta.columnName, column); - // A private key must not power `GET /:table/:id`: per-id probing would - // answer 200 or 404 on an identifier the schema hides, so over HTTP the - // table is keyless — no detail route, and lists must name their own order. - if (meta.primaryKey && !meta.isPrivate) primaryKey = column; - if (meta.isPrivate) continue; - selectable.add(meta.columnName); - if (filterOperatorsForKind(meta.kind).length > 0) { - queryable.add(meta.columnName); - } - // Database-generated identities belong to the server, never the caller. - if (meta.serverGenerated || (meta.primaryKey && meta.defaultRandom)) - continue; - creatable.add(meta.columnName); - // Rewriting a key would move a row out from under every existing reference, - // and rewriting a database-materialized stamp would rewrite history. - if (meta.primaryKey || meta.defaultNow || meta.defaultRandom) continue; - updatable.add(meta.columnName); + // Type generation reads the same predicates for the browser's types. + const can = columnHttpCapabilities(meta); + if (can.publicKey) primaryKey = column; + if (can.selectable) selectable.add(meta.columnName); + if (can.queryable) queryable.add(meta.columnName); + if (can.creatable) creatable.add(meta.columnName); + if (can.updatable) updatable.add(meta.columnName); } const compiled: MutableCrudTable = { diff --git a/packages/appkit/src/plugins/database/crud/query.ts b/packages/appkit/src/plugins/database/crud/query.ts index 80394d0ab..db485245c 100644 --- a/packages/appkit/src/plugins/database/crud/query.ts +++ b/packages/appkit/src/plugins/database/crud/query.ts @@ -248,6 +248,9 @@ function decodeWhere( reject(parameter, `Expected at most ${MAX_WHERE_DEPTH} nesting levels`); } if (!isPlainObject(node)) reject(parameter, "Expected an object"); + if (Object.keys(node).length === 0) { + reject(parameter, "Filter cannot be empty"); + } const clause: Record = {}; for (const [key, value] of Object.entries(node)) { diff --git a/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts new file mode 100644 index 000000000..52b27c562 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/capabilities.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { + bigint, + boolean, + defineSchema, + enumColumn, + fk, + id, + jsonb, + text, + timestamp, + uuid, +} from "../../../../database/schema-builder"; +import { walkSchema } from "../../../../type-generator/database/walk-schema"; +import { columnHttpCapabilities } from "../capabilities"; +import { compileCrudTables } from "../contract"; + +const schema = defineSchema((builder) => { + const users = builder.table("users", { + id: id(), + name: text().notNull(), + token: text().private(), + profile: jsonb(), + total: bigint(), + }); + const invites = builder.table("invites", { + code: text().primaryKey(), + email: text().notNull(), + label: text().default("guest"), + createdAt: timestamp().defaultNow(), + userId: fk(() => users.id), + }); + const sessions = builder.table("sessions", { + id: uuid().primaryKey().defaultRandom(), + token: uuid().defaultRandom(), + active: boolean().notNull(), + kind: enumColumn("session_kind", ["web", "cli"]), + }); + const audits = builder.table("audits", { + id: id().private(), + action: text(), + }); + const blobs = builder.table("blobs", { payload: jsonb() }); + const flags = builder.table("flags", { key: boolean().primaryKey() }); + const documents = builder.table("documents", { key: jsonb().primaryKey() }); + return { users, invites, sessions, audits, blobs, flags, documents }; +}); + +/** Property names in one rendered object facet, in declaration order. */ +function facetKeys(facet: string): string[] { + return [...facet.matchAll(/^ +"([^"]+)"\??:/gm)].map((match) => match[1]); +} + +/** Members of one rendered literal union; `never` renders as none. */ +function unionMembers(union: string): string[] { + return union === "never" ? [] : union.split(" | ").map((m) => JSON.parse(m)); +} + +describe("columnHttpCapabilities", () => { + it("keeps a private column out of every HTTP capability", () => { + expect(columnHttpCapabilities(schema.$tables.users.$columns.token)).toEqual( + { + selectable: false, + queryable: false, + creatable: false, + updatable: false, + publicKey: false, + }, + ); + expect( + columnHttpCapabilities(schema.$tables.audits.$columns.id).publicKey, + ).toBe(false); + }); + + it.each(["flags", "documents"] as const)( + "keeps %s available for creates but never claims its key can address a path", + (name) => { + const meta = schema.$tables[name].$columns.key; + expect(meta.primaryKey).toBe(true); + expect(columnHttpCapabilities(meta)).toMatchObject({ + selectable: true, + creatable: true, + updatable: false, + publicKey: false, + }); + expect( + compileCrudTables(schema.$tables).get(name)?.primaryKey, + ).toBeUndefined(); + expect( + walkSchema(schema).find((entry) => entry.name === name)?.api.key, + ).toBe("never"); + }, + ); + + it("separates generated identities, caller keys, and stamps", () => { + const { users, invites, sessions } = schema.$tables; + expect(columnHttpCapabilities(users.$columns.id)).toMatchObject({ + queryable: true, + creatable: false, + publicKey: true, + }); + expect(columnHttpCapabilities(invites.$columns.code)).toMatchObject({ + creatable: true, + updatable: false, + publicKey: true, + }); + expect(columnHttpCapabilities(invites.$columns.createdAt)).toMatchObject({ + creatable: true, + updatable: false, + }); + expect(columnHttpCapabilities(sessions.$columns.id)).toMatchObject({ + creatable: false, + publicKey: true, + }); + expect(columnHttpCapabilities(sessions.$columns.token)).toMatchObject({ + creatable: true, + updatable: false, + }); + expect(columnHttpCapabilities(users.$columns.profile)).toMatchObject({ + selectable: true, + queryable: false, + updatable: true, + }); + }); +}); + +describe("HTTP capability parity", () => { + const compiled = compileCrudTables(schema.$tables); + const entries = new Map(walkSchema(schema).map((e) => [e.name, e])); + + it.each(Object.keys(schema.$tables))( + "generates the %s api facet from the sets its routes compile", + (name) => { + const table = compiled.get(name); + const api = entries.get(name)?.api; + if (!table || !api) throw new Error(`missing ${name}`); + + expect(facetKeys(api.insert)).toEqual([...table.creatable]); + expect(facetKeys(api.update)).toEqual([...table.updatable]); + expect(facetKeys(api.filters)).toEqual([...table.queryable]); + expect(unionMembers(api.orderable)).toEqual([...table.queryable]); + expect(unionMembers(api.key)).toEqual( + table.primaryKey ? [table.primaryKey.meta.columnName] : [], + ); + }, + ); + + it("never renders a private column into an api facet", () => { + const users = entries.get("users"); + // The trusted facets keep the private column; the HTTP ones never see it. + expect(users?.insert).toContain('"token"'); + expect(Object.values(users?.api ?? {}).join("\n")).not.toContain('"token"'); + expect( + Object.values(entries.get("audits")?.api ?? {}).join("\n"), + ).not.toContain('"id"'); + expect(entries.get("audits")?.api.key).toBe("never"); + expect(entries.get("blobs")?.api.orderable).toBe("never"); + }); +}); diff --git a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts index b20a45b19..87385c747 100644 --- a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -337,6 +337,49 @@ describe("serialization and response limits", () => { expect(response.json()).toEqual({ error: "Database operation failed" }); }); + it("lets a list serializer replace an include with a summary", async () => { + const entity = fakeEntity([ + { + id: 1, + name: "Ada", + token: "private", + notes: [{ id: 2, body: "hello" }], + }, + ]); + await createListHandler( + deps("users", entity, (row) => { + const { notes, ...view } = row; + return { ...view, note_count: (notes as unknown[]).length }; + }), + )(request("/users?include=%7B%22notes%22%3Atrue%7D"), response.res); + + expect(response.sent.status).toBe(200); + expect(response.json().items).toEqual([ + { id: 1, name: "Ada", note_count: 1 }, + ]); + }); + + it("lets a detail serializer regroup the row into a different view", async () => { + const entity = fakeEntity([], { + id: 1, + name: "Ada", + token: "private", + notes: [{ id: 2, body: "hello" }], + }); + await createDetailHandler( + deps("users", entity, (row) => ({ + subject: { id: row.id, name: row.name }, + note_ids: (row.notes as { id: number }[]).map((note) => note.id), + })), + )(request("/users/1", { id: "1" }), response.res); + + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ + subject: { id: 1, name: "Ada" }, + note_ids: [2], + }); + }); + it("rejects a response that exceeds the byte budget", async () => { const wide = "x".repeat(1024 * 1024); const entity = fakeEntity( diff --git a/packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts b/packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts new file mode 100644 index 000000000..0d3e8d972 --- /dev/null +++ b/packages/appkit/src/plugins/database/crud/tests/wire-roundtrip.test.ts @@ -0,0 +1,187 @@ +import { encodeDatabaseListQuery, encodeDatabaseRecordQuery } from "shared"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_LIMIT } from "../../../../database/contract"; +import { + bigint, + boolean, + defineSchema, + fk, + id, + integer, + text, + timestamp, +} from "../../../../database/schema-builder"; +import { type CrudTable, compileCrudTables } from "../contract"; +import { decodeDetailQuery, decodeListQuery } from "../query"; + +// The browser client encodes with the shared codec; these are the decoders its +// requests actually meet, so the two sides are proven against each other here. +const schema = defineSchema((builder) => { + const boards = builder.table("boards", { + id: id(), + title: text().notNull(), + archived: boolean().notNull(), + }); + const notes = builder.table("notes", { + id: id(), + board_id: fk(() => boards.id).notNull(), + body: text().notNull(), + rank: integer(), + views: bigint(), + created_at: timestamp(), + }); + const note_events = builder.table("note_events", { + id: id(), + note_id: fk(() => notes.id).notNull(), + action: text().notNull(), + }); + return { boards, notes, note_events }; +}); + +const tables = compileCrudTables(schema.$tables); +const boards = tables.get("boards") as CrudTable; +const notes = tables.get("notes") as CrudTable; + +describe("shared encoder against decodeListQuery", () => { + it("round-trips every list parameter", () => { + const decoded = decodeListQuery( + notes, + encodeDatabaseListQuery({ + where: { + board_id: 7, + body: { ilike: "%ship it%" }, + or: [{ rank: { gte: 1, lt: 5 } }, { id: { in: [1, 2, 3] } }], + and: [{ created_at: { is: null } }], + }, + order: { created_at: "desc", body: "asc" }, + select: ["id", "body"], + include: { boards: { select: ["title"] } }, + limit: 20, + offset: 40, + }), + ); + + expect(decoded).toEqual({ + where: { + board_id: 7, + body: { ilike: "%ship it%" }, + or: [{ rank: { gte: 1, lt: 5 } }, { id: { in: [1, 2, 3] } }], + and: [{ created_at: { is: null } }], + }, + order: { created_at: "desc", body: "asc" }, + select: ["id", "body"], + include: { boards: { select: ["title"] } }, + limit: 20, + offset: 40, + }); + // Sort priority is the order of the keys, so it has to survive the trip. + expect(Object.keys(decoded.order ?? {})).toEqual(["created_at", "body"]); + }); + + it("leaves omitted parameters to the server defaults", () => { + expect(decodeListQuery(notes, encodeDatabaseListQuery({}))).toEqual({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: DEFAULT_LIMIT, + offset: 0, + }); + expect( + decodeListQuery( + boards, + encodeDatabaseListQuery({ include: { notes: true }, limit: undefined }), + ), + ).toMatchObject({ include: { notes: { limit: DEFAULT_LIMIT } } }); + }); + + it("rejects empty filters instead of querying every row", () => { + expect(() => decodeListQuery(notes, "where=%7B%7D")).toThrow(); + expect(() => + decodeListQuery( + notes, + encodeDatabaseListQuery({ include: { boards: { where: {} } } }), + ), + ).toThrow(); + expect(decodeListQuery(notes, "").where).toBeUndefined(); + }); + + it("carries reserved and non-ASCII characters through unchanged", () => { + const text = "a+b & c=d %25 # ? / é 🚀"; + expect( + decodeListQuery( + notes, + encodeDatabaseListQuery({ where: { body: { eq: text } } }), + ).where, + ).toEqual({ body: { eq: text } }); + }); + + it("decodes a bigint operand from its decimal string", () => { + expect( + decodeListQuery( + notes, + encodeDatabaseListQuery({ + where: { + views: { gt: "9007199254740993" }, + or: [{ views: 9007199254740993n }], + }, + }), + ).where, + ).toEqual({ + views: { gt: 9007199254740993n }, + or: [{ views: 9007199254740993n }], + }); + }); + + it("round-trips a two-edge include with a to-many limit", () => { + expect( + decodeListQuery( + boards, + encodeDatabaseListQuery({ + include: { + notes: { + limit: 5, + order: { created_at: "desc" }, + where: { rank: { gt: 0 } }, + include: { note_events: { limit: 3 } }, + }, + }, + limit: 10, + }), + ).include, + ).toEqual({ + notes: { + limit: 5, + order: { created_at: "desc" }, + where: { rank: { gt: 0 } }, + include: { note_events: { limit: 3 } }, + }, + }); + }); +}); + +describe("shared encoder against decodeDetailQuery", () => { + it("round-trips projection and includes", () => { + expect( + decodeDetailQuery( + boards, + encodeDatabaseRecordQuery({ + select: ["id", "title"], + include: { + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }, + }), + ), + ).toEqual({ + select: ["id", "title"], + include: { + notes: { limit: 20, include: { note_events: { limit: 5 } } }, + }, + }); + expect(decodeDetailQuery(boards, encodeDatabaseRecordQuery({}))).toEqual({ + select: undefined, + include: undefined, + }); + }); +}); diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index 73f4fb0f5..907b226f5 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -131,12 +131,14 @@ export class DatabasePlugin< runRouteSpan: (operation, route, run) => this.runRouteSpan(table.name, operation, route, run), }; - this.route(router, { - name: `${table.name}.list`, - method: "get", - path: `/${table.name}`, - handler: createListHandler(deps), - }); + if (table.primaryKey || table.queryable.size > 0) { + this.route(router, { + name: `${table.name}.list`, + method: "get", + path: `/${table.name}`, + handler: createListHandler(deps), + }); + } const writes = this.exposure.writes.get(table.name); if (writes?.has("create")) { this.route(router, { diff --git a/packages/appkit/src/plugins/database/entity-client.ts b/packages/appkit/src/plugins/database/entity-client.ts index cdd3968ff..4e81d7235 100644 --- a/packages/appkit/src/plugins/database/entity-client.ts +++ b/packages/appkit/src/plugins/database/entity-client.ts @@ -454,7 +454,20 @@ export class EntityClient { ): Promise { const rejection = byHook ? "INTERNAL" : "INVALID_REQUEST"; if (kind === "update" && Object.keys(values).length === 0) { - throw new DatabasePluginError(rejection, "write"); + throw new DatabasePluginError( + rejection, + "write", + undefined, + byHook + ? undefined + : [ + { + path: ["body"], + message: + "Update must contain at least one field; skip the call when there are no changes", + }, + ], + ); } let parsed: Row; try { diff --git a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts index 6088a4315..fca5926fd 100644 --- a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts @@ -160,17 +160,22 @@ async function mount(hooks: Record) { delete: record("delete"), } as unknown as Parameters[0]); - const post = async (path: string, body: unknown) => { + const invoke = async ( + method: "post" | "patch", + path: string, + body: unknown, + params: Record = {}, + ) => { const response = fakeResponse(); - const handler = handlers.get(`post ${path}`) as unknown as ( + const handler = handlers.get(`${method} ${path}`) as unknown as ( req: Request, res: Response, ) => Promise; await handler( { - originalUrl: path, - url: path, - params: {}, + originalUrl: path.replace(":id", params.id ?? ":id"), + url: path.replace(":id", params.id ?? ":id"), + params, body, is: () => true, } as unknown as Request, @@ -179,7 +184,10 @@ async function mount(hooks: Record) { return response; }; - return { plugin, database, post }; + const post = (path: string, body: unknown) => invoke("post", path, body); + const patch = (path: string, id: string, body: unknown) => + invoke("patch", path, body, { id }); + return { plugin, database, post, patch }; } const notesOf = (plugin: DatabasePlugin) => @@ -273,6 +281,52 @@ describe("generated CRUD over hooked mutations", () => { expect(database.log).toEqual(["begin", "rollback"]); }); + test("explains an empty patch without changing the stored row", async () => { + const { database, patch } = await mount({}); + database.committed.notes.push({ id: 1, body: "original" }); + const response = await patch("/notes/:id", "1", {}); + + expect(response.sent.status).toBe(400); + expect(response.json()).toEqual({ + error: "Invalid database request", + details: [ + { + path: ["body"], + message: + "Update must contain at least one field; skip the call when there are no changes", + }, + ], + }); + expect(database.committed.notes).toEqual([{ id: 1, body: "original" }]); + expect(database.log).toEqual([]); + }); + + test("still lets beforeUpdate fill an initially empty patch", async () => { + const { database, patch } = await mount({ + notes: { beforeUpdate: () => ({ body: "from hook" }) }, + }); + database.committed.notes.push({ id: 1, body: "original" }); + const response = await patch("/notes/:id", "1", {}); + + expect(response.sent.status).toBe(200); + expect(response.json()).toEqual({ id: 1, body: "from hook" }); + expect(database.committed.notes).toEqual([{ id: 1, body: "from hook" }]); + expect(database.log).toEqual(["begin", "update:notes", "commit"]); + }); + + test("keeps an empty hook-authored patch an opaque server fault", async () => { + const { database, patch } = await mount({ + notes: { beforeUpdate: () => ({}) }, + }); + database.committed.notes.push({ id: 1, body: "original" }); + const response = await patch("/notes/:id", "1", { body: "changed" }); + + expect(response.sent.status).toBe(500); + expect(response.json()).toEqual({ error: "Database operation failed" }); + expect(database.committed.notes).toEqual([{ id: 1, body: "original" }]); + expect(database.log).toEqual(["begin", "rollback"]); + }); + test("gives a programmatic caller the same lifecycle as the route", async () => { const seen: string[] = []; const trace: Record = { diff --git a/packages/appkit/src/plugins/database/tests/entity-client.test.ts b/packages/appkit/src/plugins/database/tests/entity-client.test.ts index 7d61c88de..662250775 100644 --- a/packages/appkit/src/plugins/database/tests/entity-client.test.ts +++ b/packages/appkit/src/plugins/database/tests/entity-client.test.ts @@ -274,6 +274,14 @@ describe("EntityClient", () => { expect(() => client.where(cyclic as WhereClause)).toThrowError(); await expect(client.update(1, {})).rejects.toMatchObject({ category: "INVALID_REQUEST", + phase: "write", + details: [ + { + path: ["body"], + message: + "Update must contain at least one field; skip the call when there are no changes", + }, + ], }); }); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index eaf127ae1..fe2ecced6 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -1,7 +1,14 @@ import type express from "express"; import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import { defineSchema, fk, id, text } from "../../../database/schema-builder"; +import { + boolean, + defineSchema, + fk, + id, + jsonb, + text, +} from "../../../database/schema-builder"; const mocks = vi.hoisted(() => ({ createDatabaseState: vi.fn(), @@ -340,6 +347,51 @@ describe("DatabasePlugin", () => { }, ); + test("does not publish a list route that cannot be ordered", async () => { + const unorderable = defineSchema((builder) => ({ + blobs: builder.table("blobs", { payload: jsonb() }), + })); + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ schema: unorderable }); + await plugin.setup(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + + expect(routes).toEqual(["post /blobs"]); + expect(plugin.getEndpoints()).not.toHaveProperty("blobs.list"); + }); + + test("never publishes keyed routes for boolean or JSON primary keys", async () => { + const unsupported = defineSchema(({ table }) => ({ + flags: table("flags", { key: boolean().primaryKey(), label: text() }), + documents: table("documents", { + key: jsonb().primaryKey(), + label: text(), + }), + blobs: table("blobs", { key: jsonb().primaryKey() }), + })); + mocks.createDatabaseState.mockResolvedValue(candidate()); + const plugin = new DatabasePlugin({ schema: unsupported }); + await plugin.setup(); + const { router, routes } = fakeRouter(); + plugin.injectRoutes(router); + + expect(routes).toEqual([ + "get /flags", + "post /flags", + "get /documents", + "post /documents", + "post /blobs", + ]); + expect(plugin.getEndpoints()).toEqual({ + "flags.list": "/api/database/flags", + "flags.create": "/api/database/flags", + "documents.list": "/api/database/documents", + "documents.create": "/api/database/documents", + "blobs.create": "/api/database/blobs", + }); + }); + test.each([false, { tables: [] }] as const)( "disables generated routes with api=%j without disabling the typed API", async (api) => { diff --git a/packages/appkit/src/type-generator/database/generate.ts b/packages/appkit/src/type-generator/database/generate.ts index 5d744ee72..aea019c65 100644 --- a/packages/appkit/src/type-generator/database/generate.ts +++ b/packages/appkit/src/type-generator/database/generate.ts @@ -52,33 +52,49 @@ declare module "@databricks/appkit" { } `; -/** Render one `DatabaseRegistry` augmentation from a finalized schema. */ +/** + * Bind the schema to AppKit and to a global interface the optional UI package + * extends. Server-only apps need not install appkit-ui or enable skipLibCheck. + */ function render(schema: Schema): string { const entries = walkSchema(schema) .map( - (entry) => ` ${JSON.stringify(entry.name)}: { - row: ${entry.row}; - publicRow: ${entry.publicRow}; - insert: ${entry.insert}; - update: ${entry.update}; - filters: ${entry.filters}; - includes: ${entry.includes}; - hasPrimaryKey: ${entry.hasPrimaryKey}; - };`, + (entry) => ` ${JSON.stringify(entry.name)}: { + row: ${entry.row}; + publicRow: ${entry.publicRow}; + insert: ${entry.insert}; + update: ${entry.update}; + filters: ${entry.filters}; + includes: ${entry.includes}; + hasPrimaryKey: ${entry.hasPrimaryKey}; + api: { + insert: ${entry.api.insert}; + update: ${entry.api.update}; + filters: ${entry.api.filters}; + orderable: ${entry.api.orderable}; + key: ${entry.api.key}; + }; + };`, ) .join("\n"); return `// Auto-generated by AppKit - DO NOT EDIT import "@databricks/appkit"; -declare module "@databricks/appkit" { - type DatabaseLogicalFilter = T & { - and?: readonly DatabaseLogicalFilter[]; - or?: readonly DatabaseLogicalFilter[]; - }; +type DatabaseLogicalFilter = T & { + and?: readonly DatabaseLogicalFilter[]; + or?: readonly DatabaseLogicalFilter[]; +}; - interface DatabaseRegistry { +interface GeneratedDatabaseRegistry { ${entries} - } +} + +declare module "@databricks/appkit" { + interface DatabaseRegistry extends GeneratedDatabaseRegistry {} +} + +declare global { + interface DatabricksAppKitDatabaseRegistry extends GeneratedDatabaseRegistry {} } `; } diff --git a/packages/appkit/src/type-generator/database/tests/generate.test.ts b/packages/appkit/src/type-generator/database/tests/generate.test.ts index b8a29271a..590b30664 100644 --- a/packages/appkit/src/type-generator/database/tests/generate.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -13,6 +13,7 @@ const roots: string[] = []; const appkitRoot = path.resolve(import.meta.dirname, "../../../.."); const sourceRoot = path.join(appkitRoot, "src"); const builder = path.join(sourceRoot, "database/schema-builder/index.ts"); +const uiRoot = path.resolve(appkitRoot, "../appkit-ui/src/js"); afterEach(async () => Promise.all( @@ -82,7 +83,7 @@ describe("generateDatabaseTypes", () => { const users = output.slice( output.indexOf('"users": {'), - output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "posts": {\n row:'), ); expect(users.match(/"secret"\??: string;/g)).toHaveLength(3); expect(users).toContain("publicRow:"); @@ -92,8 +93,8 @@ describe("generateDatabaseTypes", () => { expect(users).not.toContain('update: {\n "slug"'); const posts = output.slice( - output.indexOf('\n "posts": {\n row:'), - output.indexOf('\n "events": {\n row:'), + output.indexOf('\n "posts": {\n row:'), + output.indexOf('\n "events": {\n row:'), ); expect(posts).not.toContain('insert: {\n "id"'); expect(posts).not.toContain('update: {\n "id"'); @@ -122,6 +123,99 @@ describe("generateDatabaseTypes", () => { expect(output).toContain("filters: DatabaseLogicalFilter<{}>;"); }); + test("renders HTTP api facets from the route capabilities", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + const api = (table: string) => { + const entry = output.indexOf(`\n ${JSON.stringify(table)}: {\n row:`); + const start = output.indexOf("\n api: {", entry); + return output.slice(start, output.indexOf("\n };", start)); + }; + + // A private column reaches the trusted facets but none of the HTTP ones. + const users = api("users"); + expect(users).not.toContain('"secret"'); + expect(users).toContain('insert: {\n "slug": string;'); + expect(users).toContain('"created_at"?: string;'); + expect(users).toContain( + 'update: {\n "name"?: string;\n "nickname"?: string | null;\n };', + ); + expect(users).toContain( + 'orderable: "slug" | "name" | "nickname" | "created_at";', + ); + expect(users).toContain('key: "slug";'); + + // Server identities are never inputs, and a random default is no update. + const posts = api("posts"); + expect(posts).not.toContain('insert: {\n "id"'); + expect(posts).toContain('"external_id"?: string;'); + expect(posts).not.toContain('update: {\n "id"'); + expect( + posts.slice(posts.indexOf("update:"), posts.indexOf("filters:")), + ).not.toContain('"external_id"'); + // HTTP filters have no bare-array shorthand and no JSON columns. + expect(posts).toContain( + '"status"?: "draft" | "live" | { eq?: "draft" | "live"; neq?: "draft" | "live"; in?: readonly ("draft" | "live")[]; };', + ); + expect(posts.slice(posts.indexOf("filters:"))).not.toContain('"payload"'); + expect(posts).toContain('key: "id";'); + + expect(api("events")).toContain('orderable: "message";'); + expect(api("events")).toContain("key: never;"); + expect(api("blobs")).toContain("filters: DatabaseLogicalFilter<{}>;"); + expect(api("blobs")).toContain("orderable: never;"); + }); + + test("treats boolean and JSON primary keys as keyless only over HTTP", async () => { + const options = await files(` + import { boolean, defineSchema, jsonb, text } from ${JSON.stringify(builder)}; + export const schema = defineSchema(({ table }) => ({ + flags: table("flags", { key: boolean().primaryKey(), label: text() }), + documents: table("documents", { key: jsonb().primaryKey(), label: text() }), + })); + `); + await generateDatabaseTypes(options); + await compileConsumer( + options, + ` + import { databaseApi, type DatabaseKeyedEntity } from "@databricks/appkit-ui/js/beta"; + import type { DatabaseRegistry } from "@databricks/appkit"; + const trustedKey: DatabaseRegistry["flags"]["hasPrimaryKey"] = true; + // @ts-expect-error a boolean key cannot address an HTTP path + const keyed: DatabaseKeyedEntity = "flags"; + // @ts-expect-error JSON keys have no detail route either + await databaseApi.get("documents", "x"); + // @ts-expect-error an HTTP-keyless list needs an explicit order + await databaseApi.list("flags"); + await databaseApi.list("flags", { order: { key: "asc" } }); + await databaseApi.list("documents", { order: { label: "asc" } }); + await databaseApi.create("flags", { key: true }); + await databaseApi.create("documents", { key: { code: "a" } }); + void [trustedKey, keyed]; + `, + { ui: true }, + ); + }, 30_000); + + test("binds one registry without importing an optional UI package", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + const output = await fs.readFile(options.outFile, "utf8"); + + expect(output).toContain('import "@databricks/appkit";'); + expect(output).not.toContain('import "@databricks/appkit-ui/js/beta";'); + expect( + output.match(/interface GeneratedDatabaseRegistry \{/g), + ).toHaveLength(1); + expect(output).toContain( + 'declare module "@databricks/appkit" {\n interface DatabaseRegistry extends GeneratedDatabaseRegistry {}\n}', + ); + expect(output).toContain( + "declare global {\n interface DatabricksAppKitDatabaseRegistry extends GeneratedDatabaseRegistry {}\n}", + ); + }); + test("accepts named valid and explicitly empty schemas", async () => { const valid = await files(completeSchema); await generateDatabaseTypes(valid); @@ -133,7 +227,7 @@ describe("generateDatabaseTypes", () => { `); await generateDatabaseTypes(empty); expect(await fs.readFile(empty.outFile, "utf8")).toContain( - "interface DatabaseRegistry {\n\n }", + "interface GeneratedDatabaseRegistry {\n\n}", ); }); @@ -229,13 +323,11 @@ describe("generateDatabaseTypes", () => { expect((await fs.stat(options.outFile)).mtimeMs).toBe(before); }); - test("compiles a semantic consumer through the beta subpath", async () => { + test("compiles a server-only semantic consumer through the beta subpath", async () => { const options = await files(completeSchema); await generateDatabaseTypes(options); - const consumer = path.join(options.root, "consumer.ts"); - const tsconfig = path.join(options.root, "tsconfig.json"); - await fs.writeFile( - consumer, + await compileConsumer( + options, ` import { database, type DatabaseExports, type IDatabaseConfig } from "@databricks/appkit/beta"; database(); @@ -279,42 +371,232 @@ describe("generateDatabaseTypes", () => { db.users.create({ slug: "ada", name: "Ada", secret: "token", nickname: 1 }); `, ); + }, 30_000); + + test("typechecks the generated server declaration without UI or skipLibCheck", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); await fs.writeFile( - tsconfig, - JSON.stringify({ - compilerOptions: { - strict: true, - noEmit: true, - target: "ES2022", - module: "ESNext", - moduleResolution: "Bundler", - esModuleInterop: true, - resolveJsonModule: true, - skipLibCheck: true, - baseUrl: options.root, - paths: { - "@databricks/appkit": [ - path.join(sourceRoot, "database/contract/index.ts"), - ], - "@databricks/appkit/beta": [ - path.join(sourceRoot, "plugins/database/index.ts"), - ], - shared: [path.resolve(appkitRoot, "../shared/src/index.ts")], - // CI runs unit tests before build, including imports of shared subpaths. - "shared/*": [path.resolve(appkitRoot, "../shared/src/*")], - "@databricks/lakebase": [ - path.resolve(appkitRoot, "../lakebase/src/index.ts"), - ], - }, - }, - files: [options.outFile, consumer], - }), + path.join(options.root, "appkit.d.ts"), + "export interface DatabaseRegistry {}", + ); + await compileConsumer( + options, + `import type { DatabaseRegistry } from "@databricks/appkit"; + const slug: string = ({} as DatabaseRegistry["users"]["publicRow"]).slug; + void slug;`, + { skipLibCheck: false, isolated: true }, ); + }, 30_000); + + test("compiles a browser consumer bound to the same generated entries", async () => { + const options = await files(completeSchema); + await generateDatabaseTypes(options); + await compileConsumer( + options, + ` + import { type DatabaseExports } from "@databricks/appkit/beta"; + import { + databaseApi, + DatabaseApiError, + type DatabaseEntity, + type DatabaseListRow, + } from "@databricks/appkit-ui/js/beta"; + + // The server entities and the browser entities are one registry. + declare const db: DatabaseExports; + const server: keyof DatabaseExports = "users"; + const entity: DatabaseEntity = "users"; + void [db, server, entity]; + // @ts-expect-error browser entities are generated table names + const missing: DatabaseEntity = "missing"; + void missing; - await expect( - execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { - cwd: path.resolve(appkitRoot, "../.."), - }), - ).resolves.toMatchObject({ stderr: "" }); + async function reads() { + const users = await databaseApi.list("users", { + where: { name: { ilike: "%ada%" }, or: [{ nickname: { is: null } }] }, + order: { created_at: "desc" }, + include: { posts: { limit: 2, select: ["title", "total"] } }, + }); + const title: string | undefined = users.items[0]?.posts[0]?.title; + // A bigint travels as its decimal string. + const total: string | undefined = users.items[0]?.posts[0]?.total; + // @ts-expect-error private columns are absent from public rows + users.items[0]?.secret; + // @ts-expect-error unselected relation columns are absent + users.items[0]?.posts[0]?.score; + + const posts = await databaseApi.list("posts", { + select: ["id", "title"], + include: { users: { include: { posts: { limit: 1 } } } }, + }); + const owner: { slug: string; name: string } | null | undefined = + posts.items[0]?.users; + const nested: number | undefined = posts.items[0]?.users?.posts[0]?.id; + // @ts-expect-error unselected columns are absent + posts.items[0]?.score; + void [title, total, owner, nested]; + + type Picked = DatabaseListRow<"posts", { select: readonly ["id"] }>; + const picked: Picked = { id: 1 }; + void picked; + + await databaseApi.list("posts", { where: { total: { gt: "9007199254740993" } } }); + await databaseApi.list("events", { order: { message: "asc" } }); + const params: import("@databricks/appkit-ui/js/beta").DatabaseListParams<"posts"> = { select: ["id"] }; + const dynamic = await databaseApi.list("posts", params); + // @ts-expect-error a broad optional selection may omit title + const unsafeTitle: string = dynamic.items[0].title; + void unsafeTitle; + + const columns: ("id" | "title")[] = ["id"]; + const selected = await databaseApi.list("posts", { select: columns }); + const maybeTitle: string | undefined = selected.items[0].title; + // @ts-expect-error an array does not guarantee every possible column is selected + const definiteTitle: string = selected.items[0].title; + // @ts-expect-error columns outside the array's element type remain absent + selected.items[0].score; + const record = await databaseApi.get("posts", 1, { select: columns }); + // @ts-expect-error detail projection follows the same dynamic selection rule + const definiteId: number = record.id; + const included = await databaseApi.list("users", { + include: { posts: { select: columns } }, + }); + // @ts-expect-error included rows also have optional dynamically selected columns + const includedTitle: string = included.items[0].posts[0].title; + void [maybeTitle, definiteTitle, definiteId, includedTitle]; + } + + async function rejected() { + // @ts-expect-error private columns are not HTTP filters, even beside a valid one + await databaseApi.list("users", { where: { name: "Ada", secret: "token" } }); + // @ts-expect-error private columns are not selectable + await databaseApi.list("users", { select: ["slug", "secret"] }); + // @ts-expect-error private columns cannot order a list + await databaseApi.list("users", { order: { name: "asc", secret: "asc" } }); + // @ts-expect-error JSON columns are not queryable + await databaseApi.list("posts", { where: { payload: { eq: 1 } } }); + // @ts-expect-error HTTP filters have no bare-array shorthand + await databaseApi.list("posts", { where: { status: ["draft"] } }); + // @ts-expect-error unknown operators are rejected inside an operator object + await databaseApi.list("posts", { where: { score: { gte: 1, near: 2 } } }); + // @ts-expect-error a to-one include takes no limit + await databaseApi.list("posts", { include: { users: { limit: 1 } } }); + // @ts-expect-error includes stop at two relation edges + await databaseApi.list("users", { include: { posts: { include: { users: { include: { posts: true } } } } } }); + // @ts-expect-error an include filter checks the target's public facets + await databaseApi.list("posts", { include: { users: { where: { name: "Ada", secret: "x" } } } }); + // @ts-expect-error generated routes decode includes as true or options, never false + await databaseApi.list("users", { include: { posts: false } }); + // @ts-expect-error list params are the generated route's parameters only + await databaseApi.list("users", { limit: 1, includeTotal: true }); + // @ts-expect-error keyless lists require an explicit order + await databaseApi.list("events"); + // @ts-expect-error an empty order is not a usable keyless sort + await databaseApi.list("events", { order: {} }); + // @ts-expect-error a keyless table without sortable columns cannot list + await databaseApi.list("blobs", { order: { payload: "asc" } }); + const filter: { name?: string; secret?: string } = { name: "Ada", secret: "token" }; + // @ts-expect-error optional private fields remain forbidden in filters + await databaseApi.list("users", { where: filter }); + } + + async function writes() { + const post = await databaseApi.create("posts", { + user_slug: "ada", + title: "Hi", + total: "9007199254740993", + active: true, + status: "draft", + payload: { tags: ["a"] }, + }); + const total: string = post.total; + await databaseApi.update("posts", post.id, { score: null, status: "live" }); + await databaseApi.update("users", "ada", { nickname: "ada" }); + await databaseApi.remove("users", "ada"); + await databaseApi.create("events", { message: "keyless tables accept creates" }); + // @ts-expect-error private columns are not HTTP inputs + await databaseApi.create("users", { slug: "ada", name: "Ada", secret: "token" }); + const values: { slug: string; name: string; secret?: string } = { slug: "ada", name: "Ada", secret: "token" }; + // @ts-expect-error an optional private field is still forbidden when spread + await databaseApi.create("users", values); + // @ts-expect-error a generated key is not an HTTP input + await databaseApi.create("posts", { id: 1, user_slug: "ada", title: "Hi", total: 1, active: true, status: "draft" }); + // @ts-expect-error default-stamped columns are not updatable + await databaseApi.update("users", "ada", { created_at: "2026-01-01" }); + // @ts-expect-error random-default columns are not updatable + await databaseApi.update("posts", 1, { external_id: "00000000-0000-0000-0000-000000000000" }); + // @ts-expect-error keyless entities have no update route + await databaseApi.update("events", "x", { message: "y" }); + void total; + } + + function failed(error: unknown) { + if (error instanceof DatabaseApiError && error.code === "NOT_EXPOSED") { + const status: number | null = error.status; + return status; + } + return error instanceof DatabaseApiError ? error.details[0]?.message : undefined; + } + void [reads, rejected, writes, failed]; + `, + { ui: true }, + ); }, 30_000); }); + +/** Type-check `source` against the generated declaration, as an app would. */ +async function compileConsumer( + options: { root: string; outFile: string }, + source: string, + { ui = false, skipLibCheck = true, isolated = false } = {}, +): Promise { + const consumer = path.join(options.root, "consumer.ts"); + const tsconfig = path.join(options.root, "tsconfig.json"); + await fs.writeFile(consumer, source); + await fs.writeFile( + tsconfig, + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + esModuleInterop: true, + resolveJsonModule: true, + skipLibCheck, + ...(isolated ? { types: [] } : {}), + baseUrl: options.root, + paths: { + "@databricks/appkit": [ + isolated + ? path.join(options.root, "appkit.d.ts") + : path.join(sourceRoot, "database/contract/index.ts"), + ], + "@databricks/appkit/beta": [ + path.join(sourceRoot, "plugins/database/index.ts"), + ], + ...(ui + ? { + "@databricks/appkit-ui/js/beta": [path.join(uiRoot, "beta.ts")], + } + : {}), + shared: [path.resolve(appkitRoot, "../shared/src/index.ts")], + // CI runs unit tests before build, including imports of shared subpaths. + "shared/*": [path.resolve(appkitRoot, "../shared/src/*")], + "@databricks/lakebase": [ + path.resolve(appkitRoot, "../lakebase/src/index.ts"), + ], + }, + }, + files: [options.outFile, consumer], + }), + ); + + await expect( + execFileAsync("pnpm", ["exec", "tsc", "--noEmit", "-p", tsconfig], { + cwd: path.resolve(appkitRoot, "../.."), + }), + ).resolves.toMatchObject({ stderr: "" }); +} diff --git a/packages/appkit/src/type-generator/database/walk-schema.ts b/packages/appkit/src/type-generator/database/walk-schema.ts index 1b6f49f21..06315e551 100644 --- a/packages/appkit/src/type-generator/database/walk-schema.ts +++ b/packages/appkit/src/type-generator/database/walk-schema.ts @@ -4,6 +4,19 @@ import type { Schema, } from "../../database/schema-builder"; import { filterOperatorsForKind } from "../../database/schema-builder/types"; +import { + type ColumnHttpCapabilities, + columnHttpCapabilities, +} from "../../plugins/database/crud/capabilities"; + +/** Render-ready HTTP facets, derived from the predicates the routes compile. */ +interface ApiEntry { + readonly insert: string; + readonly update: string; + readonly filters: string; + readonly orderable: string; + readonly key: string; +} /** Render-ready type facets for one database registry entry. */ interface RegistryEntry { @@ -15,8 +28,13 @@ interface RegistryEntry { readonly filters: string; readonly includes: string; readonly hasPrimaryKey: boolean; + readonly api: ApiEntry; } +/** Indentation of a facet's key: trusted facets under an entry, HTTP under `api`. */ +const TRUSTED = " "; +const API = " "; + /** Keep generated scalars aligned with the schema's canonical runtime values. */ function tsType(meta: ColumnMeta): string { switch (meta.kind) { @@ -42,13 +60,17 @@ function tsType(meta: ColumnMeta): string { } } -function objectFacet(lines: string[], empty = "{}"): string { - return lines.length ? `{\n${lines.join("\n")}\n }` : empty; +function objectFacet(lines: string[], indent: string, empty = "{}"): string { + return lines.length ? `{\n${lines.join("\n")}\n${indent}}` : empty; } -function property(meta: ColumnMeta, optional = false): string { +function property(meta: ColumnMeta, indent: string, optional = false): string { const nullable = meta.notNull ? "" : " | null"; - return ` ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; + return `${indent} ${JSON.stringify(meta.columnName)}${optional ? "?" : ""}: ${tsType(meta)}${nullable};`; +} + +function literalUnion(names: string[]): string { + return names.map((name) => JSON.stringify(name)).join(" | ") || "never"; } // Trusted facets retain private columns; only public rows project them out. @@ -56,34 +78,40 @@ function rowType(table: AppKitTable, publicOnly: boolean): string { return objectFacet( Object.values(table.$columns) .filter((c) => !publicOnly || !c.isPrivate) - .map((c) => property(c)), + .map((c) => property(c, TRUSTED)), + TRUSTED, "Record", ); } // Write facets mirror trusted validators; updates additionally omit primary keys. -function insertType(table: AppKitTable): string { +function insertType(columns: ColumnMeta[], indent: string): string { return objectFacet( - Object.values(table.$columns) - .filter((c) => !c.serverGenerated) - .map((c) => property(c, !c.notNull || c.hasDefault)), + columns.map((c) => property(c, indent, !c.notNull || c.hasDefault)), + indent, "Record", ); } -function updateType(table: AppKitTable): string { +function updateType(columns: ColumnMeta[], indent: string): string { return objectFacet( - Object.values(table.$columns) - .filter((c) => !c.serverGenerated && !c.primaryKey) - .map((c) => property(c, true)), + columns.map((c) => property(c, indent, true)), + indent, "Record", ); } -/** Reuse the canonical operator matrix when rendering `where()` types. */ -function filtersType(table: AppKitTable): string { +/** + * Reuse the canonical operator matrix when rendering `where()` types. A + * trusted `where()` also takes a bare array as `in`; the HTTP decoder does not. + */ +function filtersType( + columns: ColumnMeta[], + indent: string, + arrayShorthand: boolean, +): string { const direct = objectFacet( - Object.values(table.$columns).flatMap((column) => { + columns.flatMap((column) => { const operators = filterOperatorsForKind(column.kind); if (operators.length === 0) return []; const value = tsType(column); @@ -92,10 +120,12 @@ function filtersType(table: AppKitTable): string { `${operator}?: ${operator === "in" ? `readonly (${value})[]` : value};`, ); if (!column.notNull) fields.push("is?: null;"); + const shorthand = arrayShorthand ? ` | readonly (${value})[]` : ""; return [ - ` ${JSON.stringify(column.columnName)}?: ${value} | readonly (${value})[] | { ${fields.join(" ")} };`, + `${indent} ${JSON.stringify(column.columnName)}?: ${value}${shorthand} | { ${fields.join(" ")} };`, ]; }), + indent, ); return `DatabaseLogicalFilter<${direct}>`; } @@ -105,23 +135,50 @@ function includesType(table: AppKitTable): string { return objectFacet( table.$relations.map( (relation) => - ` ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, + `${TRUSTED} ${JSON.stringify(relation.name)}: { to: ${JSON.stringify(relation.targetTable)}; many: ${relation.cardinality === "toMany"} };`, ), + TRUSTED, ); } +/** What the generated routes accept, from the predicates `compileTable` uses. */ +function apiEntry(table: AppKitTable): ApiEntry { + const columns = Object.values(table.$columns).map((meta) => ({ + meta, + can: columnHttpCapabilities(meta), + })); + const columnsThat = (capability: keyof ColumnHttpCapabilities) => + columns.filter(({ can }) => can[capability]).map(({ meta }) => meta); + const names = (metas: ColumnMeta[]) => metas.map((meta) => meta.columnName); + return { + insert: insertType(columnsThat("creatable"), API), + update: updateType(columnsThat("updatable"), API), + filters: filtersType(columnsThat("queryable"), API, false), + orderable: literalUnion(names(columnsThat("queryable"))), + key: literalUnion(names(columnsThat("publicKey"))), + }; +} + /** Preserve schema table identity as the generated registry key. */ export function walkSchema(schema: Schema): RegistryEntry[] { - return Object.entries(schema.$tables).map(([name, table]) => ({ - name, - row: rowType(table, false), - publicRow: rowType(table, true), - insert: insertType(table), - update: updateType(table), - filters: filtersType(table), - includes: includesType(table), - hasPrimaryKey: Object.values(table.$columns).some( - (column) => column.primaryKey, - ), - })); + return Object.entries(schema.$tables).map(([name, table]) => { + const columns = Object.values(table.$columns); + return { + name, + row: rowType(table, false), + publicRow: rowType(table, true), + insert: insertType( + columns.filter((c) => !c.serverGenerated), + TRUSTED, + ), + update: updateType( + columns.filter((c) => !c.serverGenerated && !c.primaryKey), + TRUSTED, + ), + filters: filtersType(columns, TRUSTED, true), + includes: includesType(table), + hasPrimaryKey: columns.some((column) => column.primaryKey), + api: apiEntry(table), + }; + }); } diff --git a/packages/shared/src/database/api-types.ts b/packages/shared/src/database/api-types.ts new file mode 100644 index 000000000..814a3c65a --- /dev/null +++ b/packages/shared/src/database/api-types.ts @@ -0,0 +1,225 @@ +/** + * Generic HTTP types over any generated database registry. The registry itself + * is one generated `database.d.ts`; these adapters only read the facets each + * entry already carries, so there is no second model of an entity here. + */ + +/** The HTTP-safe facets every generated registry entry carries. */ +export interface DatabaseApiEntry { + /** Non-private columns, as the trusted runtime types them. */ + readonly publicRow: Record; + /** Relation target table and cardinality, one entry per relation. */ + readonly includes: Record; + /** What the generated routes accept, computed by the server's own predicates. */ + readonly api: { + readonly insert: object; + readonly update: object; + readonly filters: object; + /** Columns a request may order by; `never` when there are none. */ + readonly orderable: string; + /** The path-decodable public primary key; `never` for all other keys. */ + readonly key: string; + }; +} + +type Primitive = string | number | bigint | boolean | symbol | null | undefined; + +/** JSON carries a bigint as a decimal string, at every depth of a response. */ +export type WireOutput = T extends bigint + ? string + : T extends readonly (infer E)[] + ? WireOutput[] + : T extends object + ? { [K in keyof T]: WireOutput } + : T; + +/** A bigint input travels as a decimal string or a safe integer. */ +export type WireInput = T extends bigint + ? string | number + : T extends readonly (infer E)[] + ? readonly WireInput[] + : T extends object + ? { [K in keyof T]: WireInput } + : T; + +/** Literal entity names in `R`, or `never` while the registry is empty. */ +export type DatabaseApiEntityFor = Extract< + keyof { [K in keyof R as string extends K ? never : K]: true }, + string +>; + +type EntryOf = K extends keyof R + ? R[K] extends DatabaseApiEntry + ? R[K] + : never + : never; +type PublicRowOf = EntryOf["publicRow"]; +type ApiOf = EntryOf["api"]; +type IncludesOf = EntryOf["includes"]; +type RelationOf = IncludesOf[Relation & + keyof IncludesOf]; +type TargetOf = + RelationOf extends { to: infer Target } ? Target : never; +type ToManyOf = + RelationOf extends { many: true } ? true : false; + +type SelectableOf = keyof PublicRowOf & string; +type OrderFor = Partial["orderable"], "asc" | "desc">>; +type NonEmptyOrder = { + [Key in Keys]: Record & + Partial, "asc" | "desc">>; +}[Keys]; + +/** + * Entities in `R` with a path-decodable public key. Only these have detail, update, + * and delete routes; `never` while the registry is empty. + */ +export type KeyedEntityFor = { + [K in DatabaseApiEntityFor]: [ApiOf["key"]] extends [never] + ? never + : K; +}[DatabaseApiEntityFor]; + +type KeyValueOf = PublicRowOf[ApiOf["key"] & + keyof PublicRowOf]; + +/** + * An id as a keyed route's path carries it. A bigint key reads back as its + * decimal string, so that string, a safe integer, or a `bigint` all address it. + * `Extract` keeps it a path segment even where `K` is still generic. + */ +export type IdFor = Extract< + KeyValueOf extends bigint + ? bigint | WireInput + : KeyValueOf, + string | number | bigint +>; + +/** + * Options for one included relation, checked against the target's own public + * facets. Only a to-many edge takes a limit, and only the first edge nests. + */ +type IncludeOptionsFor = { + readonly select?: readonly SelectableOf[]; + readonly where?: WireInput["filters"]>; + readonly order?: OrderFor; +} & (ToMany extends true + ? { readonly limit?: number } + : { readonly limit?: never }) & + (Nested extends true + ? { readonly include?: HttpIncludeArgFor } + : { readonly include?: never }); + +/** + * `include` as the generated routes decode it: `true` or an options object per + * relation (never `false`), at most two relation edges deep. + */ +export type HttpIncludeArgFor = { + readonly [Relation in keyof IncludesOf]?: + | true + | IncludeOptionsFor< + R, + TargetOf, + ToManyOf, + Nested + >; +}; + +/** The public query a generated list route accepts for `K`. */ +export type ListParamsFor = { + readonly where?: WireInput["filters"]>; + readonly select?: readonly SelectableOf[]; + readonly include?: HttpIncludeArgFor; + readonly limit?: number; + readonly offset?: number; +} & ([ApiOf["key"]] extends [never] + ? { readonly order: NonEmptyOrder["orderable"]> } + : { readonly order?: OrderFor }); + +/** The public query a generated detail route accepts for `K`. */ +export type RecordParamsFor = Pick< + ListParamsFor, + "select" | "include" +>; + +// A tuple guarantees its columns; an array names only the possible columns. +type SelectedOf = P extends { + readonly select: infer Columns extends readonly PropertyKey[]; +} + ? number extends Columns["length"] + ? Partial< + Pick, Columns[number] & keyof PublicRowOf> + > + : Pick, Columns[number] & keyof PublicRowOf> + : "select" extends keyof P + ? Partial> + : PublicRowOf; + +type IncludedOf = P extends { readonly include: infer I } + ? { + [Relation in keyof I & keyof IncludesOf]: ToManyOf< + R, + K, + Relation + > extends true + ? RowOf, I[Relation]>[] + : RowOf, I[Relation]> | null; + } + : unknown; + +type RowOf = SelectedOf & IncludedOf; + +/** One list row as JSON carries it, for the params `P` the caller sent. */ +export type ListRowFor = WireOutput>; + +/** One detail row as JSON carries it; projection follows the list rules. */ +export type RecordRowFor = ListRowFor; + +/** The body a generated create route accepts for `K`, as JSON carries it. */ +export type InsertFor = WireInput["insert"]>; + +/** The body a generated update route accepts for `K`, as JSON carries it. */ +export type UpdateFor = WireInput["update"]>; + +/** + * The public row a generated create or update route answers with. A read + * serializer never reshapes a write's response, so this is always the row. + */ +export type PublicRowFor = WireOutput>; + +/** The envelope a generated list route answers with. */ +export interface DatabaseListPage { + items: Row[]; + limit: number; + offset: number; +} + +type KeysOf = S extends unknown ? keyof S : never; +type PropertyOf = S extends unknown + ? K extends keyof S + ? S[K] + : never + : never; +type ObjectPartOf = Exclude; +type ElementOf = S extends readonly (infer E)[] ? E : never; +// A JSON column takes any JSON value, so there is no shape to hold it to. +type ExactPropertyOf = unknown extends S + ? T + : ExactDatabaseParams>; + +/** + * Turn every key `Shape` does not declare into `never`, at every depth. + * TypeScript skips excess-property checks for an inferred generic argument, + * so `P & ExactDatabaseParams` restores them: a private or unknown + * column beside a valid one is a compile error, not a silent 400. Write + * values use it too, so a spread cannot carry a read-only field along. + */ +export type ExactDatabaseParams = T extends Primitive + ? T + : T extends readonly unknown[] + ? { readonly [I in keyof T]: ExactDatabaseParams> } + : { + [K in keyof T]: K extends KeysOf> + ? ExactPropertyOf, K>> + : never; + }; diff --git a/packages/shared/src/database/errors.ts b/packages/shared/src/database/errors.ts new file mode 100644 index 000000000..5a109e94f --- /dev/null +++ b/packages/shared/src/database/errors.ts @@ -0,0 +1,40 @@ +/** Stable failure category a generated database route answers with. */ +export type DatabaseErrorCategory = + | "INVALID_REQUEST" + | "VALIDATION_FAILED" + | "NOT_FOUND" + | "CONFLICT" + | "FORBIDDEN" + | "TRANSIENT" + | "UNSUPPORTED_MEDIA_TYPE" + | "PAYLOAD_TOO_LARGE" + | "INTERNAL" + | "SETUP_FAILED"; + +/** Which request field a rejection concerns; it never carries caller values. */ +export interface DatabaseErrorDetail { + readonly path: readonly string[]; + readonly message: string; +} + +/** + * The one status vocabulary both sides of a generated route read. Both 500 + * categories share a status, so a status alone never claims a setup failure. + */ +const categoryByStatus: Readonly> = { + 400: "INVALID_REQUEST", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 413: "PAYLOAD_TOO_LARGE", + 415: "UNSUPPORTED_MEDIA_TYPE", + 422: "VALIDATION_FAILED", + 503: "TRANSIENT", +}; + +/** Read a status back into its category; an unlisted status is `INTERNAL`. */ +export function databaseErrorCategoryForStatus( + status: number, +): DatabaseErrorCategory { + return categoryByStatus[status] ?? "INTERNAL"; +} diff --git a/packages/shared/src/database/index.ts b/packages/shared/src/database/index.ts new file mode 100644 index 000000000..a14429b93 --- /dev/null +++ b/packages/shared/src/database/index.ts @@ -0,0 +1,3 @@ +export * from "./api-types"; +export * from "./errors"; +export * from "./query-codec"; diff --git a/packages/shared/src/database/query-codec.test.ts b/packages/shared/src/database/query-codec.test.ts new file mode 100644 index 000000000..701e76c36 --- /dev/null +++ b/packages/shared/src/database/query-codec.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { databaseErrorCategoryForStatus } from "./errors"; +import { + encodeDatabaseListQuery, + encodeDatabaseRecordQuery, +} from "./query-codec"; + +function decoded(query: string): Record { + return Object.fromEntries(new URLSearchParams(query)); +} + +describe("encodeDatabaseListQuery", () => { + it("encodes nothing for an empty or all-undefined request", () => { + expect(encodeDatabaseListQuery({})).toBe(""); + expect( + encodeDatabaseListQuery({ + where: undefined, + order: undefined, + select: undefined, + include: undefined, + limit: undefined, + offset: undefined, + }), + ).toBe(""); + }); + + it("sends one JSON value per structured parameter and decimal pagination", () => { + const query = encodeDatabaseListQuery({ + where: { board_id: 7, or: [{ author: { ilike: "a%" } }] }, + order: { created_at: "desc", id: "asc" }, + select: ["id", "body"], + include: { notes: { limit: 5 } }, + limit: 20, + offset: 40, + }); + + expect(decoded(query)).toEqual({ + where: '{"board_id":7,"or":[{"author":{"ilike":"a%"}}]}', + order: '{"created_at":"desc","id":"asc"}', + select: '["id","body"]', + include: '{"notes":{"limit":5}}', + limit: "20", + offset: "40", + }); + }); + + it("is deterministic regardless of the caller's parameter order", () => { + const forward = encodeDatabaseListQuery({ + where: { id: 1 }, + limit: 5, + select: ["id"], + }); + const backward = encodeDatabaseListQuery({ + select: ["id"], + limit: 5, + where: { id: 1 }, + }); + expect(forward).toBe(backward); + expect([...new URLSearchParams(forward).keys()]).toEqual([ + "where", + "select", + "limit", + ]); + }); + + it("keeps order keys in the caller's sequence, since it is sort priority", () => { + const query = encodeDatabaseListQuery({ + order: { title: "asc", created_at: "desc" }, + }); + expect(decoded(query).order).toBe('{"title":"asc","created_at":"desc"}'); + }); + + it("escapes reserved characters without dropping filters", () => { + const query = encodeDatabaseListQuery({ + where: { body: { like: "a+b&c=%" } }, + }); + expect(query).not.toContain("&c="); + expect(decoded(query).where).toBe('{"body":{"like":"a+b&c=%"}}'); + }); + + it("rejects undefined filter values instead of silently broadening reads", () => { + expect(() => + encodeDatabaseListQuery({ where: { board_id: undefined } }), + ).toThrow(/undefined/); + expect(() => + encodeDatabaseListQuery({ + where: { board_id: 7 }, + include: { notes: { where: { author: undefined } } }, + }), + ).toThrow(/undefined/); + expect(() => + encodeDatabaseListQuery({ where: { or: [{ board_id: undefined }] } }), + ).toThrow(/undefined/); + }); + + it("rejects an empty top-level filter with guidance to omit it", () => { + expect(() => encodeDatabaseListQuery({ where: {} })).toThrow( + "Filter cannot be empty; omit where to list all rows", + ); + expect(encodeDatabaseListQuery({ where: undefined })).toBe(""); + }); + + it.each([NaN, Infinity, -Infinity])( + "rejects non-finite number %s in filters, includes, and pagination", + (value) => { + for (const params of [ + { where: { rank: value } }, + { where: { rank: { is: value } } }, + { include: { notes: { where: { rank: value } } } }, + { limit: value }, + { offset: value }, + ]) { + expect(() => encodeDatabaseListQuery(params)).toThrow( + "Database query numbers must be finite", + ); + } + }, + ); + + it("encodes a bigint operand as its decimal string", () => { + const query = encodeDatabaseListQuery({ + where: { total: { gt: 9007199254740993n } }, + }); + expect(decoded(query).where).toBe('{"total":{"gt":"9007199254740993"}}'); + }); + + it("ignores parameters the list route does not accept", () => { + const query = encodeDatabaseListQuery({ + limit: 1, + ...({ includeTotal: true } as object), + }); + expect(query).toBe("limit=1"); + }); +}); + +describe("encodeDatabaseRecordQuery", () => { + it("encodes projection and includes only", () => { + expect(encodeDatabaseRecordQuery({})).toBe(""); + const query = encodeDatabaseRecordQuery({ + include: { notes: { include: { note_events: true } } }, + select: ["id"], + }); + expect(decoded(query)).toEqual({ + select: '["id"]', + include: '{"notes":{"include":{"note_events":true}}}', + }); + expect( + encodeDatabaseRecordQuery({ + select: ["id"], + ...({ where: { id: 1 }, limit: 1 } as object), + }), + ).toBe(new URLSearchParams({ select: '["id"]' }).toString()); + }); +}); + +describe("databaseErrorCategoryForStatus", () => { + it("reads every generated status back into its stable category", () => { + expect( + [400, 403, 404, 409, 413, 415, 422, 503].map( + databaseErrorCategoryForStatus, + ), + ).toEqual([ + "INVALID_REQUEST", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "VALIDATION_FAILED", + "TRANSIENT", + ]); + }); + + it("treats any other status as INTERNAL", () => { + expect(databaseErrorCategoryForStatus(500)).toBe("INTERNAL"); + expect(databaseErrorCategoryForStatus(502)).toBe("INTERNAL"); + expect(databaseErrorCategoryForStatus(401)).toBe("INTERNAL"); + }); +}); diff --git a/packages/shared/src/database/query-codec.ts b/packages/shared/src/database/query-codec.ts new file mode 100644 index 000000000..75997ea78 --- /dev/null +++ b/packages/shared/src/database/query-codec.ts @@ -0,0 +1,100 @@ +/** The query a generated list route decodes; structured values travel as JSON. */ +export interface DatabaseListQuery { + readonly where?: unknown; + readonly order?: unknown; + readonly select?: readonly string[]; + readonly include?: unknown; + readonly limit?: number; + readonly offset?: number; +} + +/** The query a generated detail route decodes: projection and includes only. */ +export interface DatabaseRecordQuery { + readonly select?: readonly string[]; + readonly include?: unknown; +} + +// A fixed parameter order keeps equal requests on equal strings, whatever key +// order the caller's object literal happened to use. +const LIST_PARAMS = [ + "where", + "order", + "select", + "include", + "limit", + "offset", +] as const; +const RECORD_PARAMS = ["select", "include"] as const; +const INTEGER_PARAMS: ReadonlySet = new Set(["limit", "offset"]); + +/** A known query failure, safe for the client to report without its input values. */ +export class DatabaseQueryEncodingError extends TypeError { + constructor( + readonly parameter: string, + message: string, + ) { + super(message); + this.name = "DatabaseQueryEncodingError"; + } +} + +/** JSON must not broaden a predicate or turn a non-finite operand into null. */ +function jsonQueryValue(value: unknown, parameter: string): unknown { + if (value === undefined) { + throw new TypeError("Database query cannot contain undefined values"); + } + if (typeof value === "number" && !Number.isFinite(value)) { + throw new DatabaseQueryEncodingError( + parameter, + "Database query numbers must be finite", + ); + } + return typeof value === "bigint" ? value.toString() : value; +} + +function encode( + params: T, + names: readonly (keyof T & string)[], +): string { + const search = new URLSearchParams(); + for (const name of names) { + const value = params[name]; + if (value === undefined) continue; + if ( + name === "where" && + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + throw new DatabaseQueryEncodingError( + name, + "Filter cannot be empty; omit where to list all rows", + ); + } + const serializable = jsonQueryValue(value, name); + search.append( + name, + INTEGER_PARAMS.has(name) + ? String(serializable) + : JSON.stringify(serializable, (_key, child) => + jsonQueryValue(child, name), + ), + ); + } + return search.toString(); +} + +/** + * Encode `GET /:table` parameters the way `decodeListQuery` reads them: one + * JSON value per structured parameter, decimal integers for pagination, and + * nothing at all for an omitted parameter. Returns no leading `?`. + */ +export function encodeDatabaseListQuery(params: DatabaseListQuery): string { + return encode(params, LIST_PARAMS); +} + +/** Encode `GET /:table/:id` parameters the way `decodeDetailQuery` reads them. */ +export function encodeDatabaseRecordQuery(params: DatabaseRecordQuery): string { + return encode(params, RECORD_PARAMS); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7641302b4..85e75f699 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from "./agent"; export * from "./cache"; +export * from "./database"; export { createDevOboIdentityProvider, loadDevOboIdentity,