From fa6ff7038ed8813aeb9a229aeea9ff82ded04fa1 Mon Sep 17 00:00:00 2001 From: ditadi Date: Thu, 22 Oct 2026 13:00:38 +0100 Subject: [PATCH] refactor(appkit): remove database hooks and read serializers Remove entity lifecycle callbacks and serializer-only client APIs. Keep explicit transactions and the schema-derived HTTP contract. Database hooks and serialize are removed from the hidden database plugin. Database read hooks no longer accept shape or caller-selected row overrides. The plugin is not publicly exposed yet, so this is not a public breaking change. Signed-off-by: ditadi --- .../components/database/board-explorer.tsx | 110 +++---- .../components/database/hook-lifecycle.tsx | 102 ------ .../client/src/components/database/index.ts | 1 - apps/dev-playground/client/src/lib/nav.ts | 2 +- .../client/src/routes/database.route.tsx | 140 +------- apps/dev-playground/config/database/schema.ts | 4 +- .../server/agents/redactor/agent.ts | 13 - apps/dev-playground/server/database.test.ts | 153 +-------- apps/dev-playground/server/index.ts | 56 +--- .../appkit/Class.DatabaseValidationError.md | 7 +- docs/docs/api/appkit/Function.defineSchema.md | 2 +- .../appkit/Interface.EntityMutationHooks.md | 170 ---------- docs/docs/api/appkit/Interface.HookApp.md | 11 - docs/docs/api/appkit/Interface.HookContext.md | 19 -- .../appkit/Interface.ReadSerializerContext.md | 19 -- docs/docs/api/appkit/TypeAlias.EntityHooks.md | 23 -- .../api/appkit/TypeAlias.IDatabaseConfig.md | 9 - .../api/appkit/TypeAlias.ReadSerializer.md | 20 -- docs/docs/api/appkit/index.md | 10 +- docs/docs/api/appkit/typedoc-sidebar.ts | 30 -- docs/docs/plugins/database.md | 132 +++----- packages/appkit-ui/src/js/database/client.ts | 6 +- packages/appkit-ui/src/react/beta.ts | 9 +- .../hooks/__tests__/database-test-utils.ts | 1 - .../__tests__/use-database-list.test.tsx | 56 +--- .../__tests__/use-database-record.test.tsx | 16 - .../__tests__/use-database.types.test.ts | 100 ++---- .../src/react/hooks/use-database-list.ts | 21 +- .../src/react/hooks/use-database-read.ts | 126 +------ .../src/react/hooks/use-database-record.ts | 12 +- .../src/react/hooks/use-database-write.ts | 6 +- packages/appkit/src/beta.ts | 6 - .../database/schema-builder/define-schema.ts | 2 +- .../appkit/src/errors/database-validation.ts | 7 +- .../src/plugins/database/crud/contract.ts | 59 +--- .../src/plugins/database/crud/request.ts | 1 - .../src/plugins/database/crud/response.ts | 6 +- .../src/plugins/database/crud/routes.ts | 26 +- .../database/crud/tests/contract.test.ts | 43 ++- .../database/crud/tests/request.test.ts | 5 +- .../database/crud/tests/routes.test.ts | 126 ++----- .../appkit/src/plugins/database/database.ts | 25 +- .../appkit/src/plugins/database/defaults.ts | 7 +- .../src/plugins/database/entity-client.ts | 299 +++-------------- packages/appkit/src/plugins/database/hooks.ts | 73 ---- packages/appkit/src/plugins/database/index.ts | 4 - .../appkit/src/plugins/database/lifecycle.ts | 26 +- packages/appkit/src/plugins/database/scope.ts | 98 ++---- .../database/tests/crud.integration.test.ts | 176 ++++------ .../database/tests/entity-client.test.ts | 311 ++++-------------- .../plugins/database/tests/lifecycle.test.ts | 295 +++++++++++------ .../database/tests/mvp.integration.test.ts | 19 +- .../src/plugins/database/tests/plugin.test.ts | 26 +- .../src/plugins/database/tests/scope.test.ts | 56 +--- packages/appkit/src/plugins/database/types.ts | 31 +- .../database/tests/generate.test.ts | 14 +- packages/shared/src/database/api-types.ts | 5 +- 57 files changed, 699 insertions(+), 2433 deletions(-) delete mode 100644 apps/dev-playground/client/src/components/database/hook-lifecycle.tsx delete mode 100644 apps/dev-playground/server/agents/redactor/agent.ts delete mode 100644 docs/docs/api/appkit/Interface.EntityMutationHooks.md delete mode 100644 docs/docs/api/appkit/Interface.HookApp.md delete mode 100644 docs/docs/api/appkit/Interface.HookContext.md delete mode 100644 docs/docs/api/appkit/Interface.ReadSerializerContext.md delete mode 100644 docs/docs/api/appkit/TypeAlias.EntityHooks.md delete mode 100644 docs/docs/api/appkit/TypeAlias.ReadSerializer.md delete mode 100644 packages/appkit/src/plugins/database/hooks.ts diff --git a/apps/dev-playground/client/src/components/database/board-explorer.tsx b/apps/dev-playground/client/src/components/database/board-explorer.tsx index ed32c474c..8b0aa77b0 100644 --- a/apps/dev-playground/client/src/components/database/board-explorer.tsx +++ b/apps/dev-playground/client/src/components/database/board-explorer.tsx @@ -20,7 +20,7 @@ import { useId, useState } from "react"; /** * Everything on this panel comes from routes the app never wrote: the note list * is a generated read, the two forms are generated writes, and the audit trail - * is the row an `afterCreate` hook commits alongside each note. + * is a nested include on the generated board detail route. */ /** Generated routes answer `{ error, details? }`; a field detail reads first. */ @@ -34,13 +34,12 @@ export function BoardExplorer() { const boardFieldId = useId(); const [selected, setSelected] = useState(null); - const [revealedId, setRevealedId] = useState(null); const [author, setAuthor] = useState("reviewer"); const [body, setBody] = useState(""); const [title, setTitle] = useState(""); - // A write restarts every mounted read, so the lists, the board previews, and - // the audit trail `afterCreate` writes refresh on their own once it commits. + // A write restarts every mounted read, so the lists and the board previews + // refresh on their own once it commits. const createBoard = useDatabaseCreate("boards"); const createNote = useDatabaseCreate("notes"); const busy = createBoard.loading || createNote.loading; @@ -53,7 +52,6 @@ export function BoardExplorer() { const board = boardItems.find((entry) => entry.slug === selected) ?? boardItems[0]; - // Listing notes directly is what puts them through the entity's serializer. // Null params hold the read until the boards answer. const notes = useDatabaseList( "notes", @@ -73,28 +71,18 @@ export function BoardExplorer() { }); const timelineNotes = timeline.data?.notes ?? []; - // The list route truncates; the detail route does not. Same serializer. - const fullNote = useDatabaseRecord("notes", revealedId); - const failure = createNote.error ?? createBoard.error ?? boards.error ?? notes.error ?? - timeline.error ?? - fullNote.error; + timeline.error; const error = failure ? errorText(failure) : null; const refresh = () => { boards.refetch(); notes.refetch(); timeline.refetch(); - fullNote.refetch(); - }; - - const selectBoard = (slug: string) => { - setSelected(slug); - setRevealedId(null); }; const addNote = async (event: React.FormEvent) => { @@ -122,7 +110,7 @@ export function BoardExplorer() { const created = await createBoard.create({ slug, title: title.trim() }); if (!created) return; setTitle(""); - selectBoard(created.slug); + setSelected(created.slug); }; return ( @@ -142,7 +130,7 @@ export function BoardExplorer() { key={entry.slug} size="sm" variant={entry.slug === board?.slug ? "default" : "outline"} - onClick={() => selectBoard(entry.slug)} + onClick={() => setSelected(entry.slug)} > {entry.title} {!notes.loading && noteItems.length === 0 && (

- No notes yet. Add one and watch the audit trail fill in. + No notes yet. Add one below.

)} - {noteItems.map((note) => { - const full = - note.id === revealedId ? fullNote.data?.body : undefined; - return ( -
-
- {note.author} - - {(full ?? note.body).length} chars - -
-

- {full ?? note.body} -

- {full === undefined && note.body.length === 120 && ( - - )} + {noteItems.map((note) => ( +
+
+ {note.author} + + {note.body.length} chars +
- ); - })} +

+ {note.body} +

+
+ ))} - - Audit trail written by a hook - + Nested read GET /api/database/boards/{board?.id ?? ":id"}?include=… {" "} reads notes and their events through nested includes. The generated API exposes note_events{" "} - for reading only; the hook writes it inside the note's - transaction. + for reading only. @@ -255,19 +225,23 @@ export function BoardExplorer() {
note #{note.id} by {note.author}
-
    - {note.note_events.map((event) => ( -
  • - {event.action} - - {new Date(event.created_at).toLocaleTimeString()} - -
  • - ))} -
+ {note.note_events.length === 0 ? ( +

No events

+ ) : ( +
    + {note.note_events.map((event) => ( +
  • + {event.action} + + {new Date(event.created_at).toLocaleTimeString()} + +
  • + ))} +
+ )}
))} {!timeline.loading && timelineNotes.length === 0 && ( @@ -283,9 +257,9 @@ export function BoardExplorer() { Generated write - POST /api/database/notes — the note - and its created event commit - together or not at all. + POST /api/database/notes — the body + is checked against the generated insert schema before the row is + written. @@ -308,7 +282,7 @@ export function BoardExplorer() { setBody(event.target.value)} /> diff --git a/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx b/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx deleted file mode 100644 index ce2b44465..000000000 --- a/apps/dev-playground/client/src/components/database/hook-lifecycle.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Badge } from "@databricks/appkit-ui/react"; - -/** - * The create transaction and the separate read serializer. Only database - * writes through the transaction-bound client share the mutation's rollback. - */ - -interface Step { - name: string; - kind: "async" | "sql" | "sync"; - detail: string; -} - -const IN_TRANSACTION: Step[] = [ - { - name: "beforeCreate(values, ctx)", - kind: "async", - detail: - "Calls the redaction agent with a 10-second cancellation signal, rejects failed or blank output, and stamps the private author_email.", - }, - { - name: "INSERT", - kind: "sql", - detail: "The row the caller asked for, plus whatever the hook added.", - }, - { - name: "afterCreate(row, ctx)", - kind: "async", - detail: - "Sees the persisted row. Writes through ctx.app.database join this transaction — here, the note_events entry.", - }, -]; - -const KIND_LABEL: Record = { - async: "async", - sql: "sql", - sync: "sync", -}; - -function StepRow({ step }: { step: Step }) { - return ( -
- - {KIND_LABEL[step.kind]} - -
- {step.name} -

{step.detail}

-
-
- ); -} - -export function HookLifecycle() { - return ( -
- POST /api/database/notes - -
-
- - one transaction - - - a throw anywhere rolls back everything below - -
-
- {IN_TRANSACTION.map((step) => ( - - ))} -
-
- -
-
- - on a subsequent read - -
- -
- -

- ctx.app.database provides the transaction-bound database - client. Import other APIs separately and give network calls their own - cancellation signal: the database deadline does not cancel them, and - rollback does not undo their external effects. -

-
- ); -} diff --git a/apps/dev-playground/client/src/components/database/index.ts b/apps/dev-playground/client/src/components/database/index.ts index bd948b805..d1a4d20d5 100644 --- a/apps/dev-playground/client/src/components/database/index.ts +++ b/apps/dev-playground/client/src/components/database/index.ts @@ -1,3 +1,2 @@ export { BoardExplorer } from "./board-explorer"; -export { HookLifecycle } from "./hook-lifecycle"; export { RefusalProbe } from "./refusal-probe"; diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 5e6eda2a9..00df84afe 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -64,7 +64,7 @@ export const NAV_GROUPS: ReadonlyArray = [ to: "/database", label: "Database", description: - "Declare a Postgres schema and get typed entities, generated CRUD routes, and transactional hooks.", + "Declare a Postgres schema and get typed entities and generated CRUD routes.", icon: DatabaseZapIcon, }, { diff --git a/apps/dev-playground/client/src/routes/database.route.tsx b/apps/dev-playground/client/src/routes/database.route.tsx index daa3e1251..c5861fcb4 100644 --- a/apps/dev-playground/client/src/routes/database.route.tsx +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -10,11 +10,7 @@ import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { codeToHtml } from "shiki"; -import { - BoardExplorer, - HookLifecycle, - RefusalProbe, -} from "@/components/database"; +import { BoardExplorer, RefusalProbe } from "@/components/database"; import { Header } from "@/components/layout/header"; export const Route = createFileRoute("/database")({ @@ -130,7 +126,6 @@ database({ schema, // All tables are readable; only boards and notes accept HTTP writes. api: { writes: { tables: ["boards", "notes"] } }, - hooks: { /* inline hooks shown below */ }, })`; const PRIVATE_COLUMN = `const notes = table("notes", { @@ -146,61 +141,6 @@ await db.notes.create({ author_email }); // accepted // Over HTTP, the same declaration removes it from every surface: // the select list, where, the create body, and the update body.`; -const INLINE_HOOKS = `// server/index.ts -// Model-based redaction is best-effort, not a privacy guarantee. -const redactor = createAgent({ - instructions: - "Replace every personal name and email address in the user's text with [redacted]. " + - "Return only the rewritten text, nothing else.", -}); - -database({ - schema, - api: { writes: { tables: ["boards", "notes"] } }, - hooks: { - notes: { - async beforeCreate(values) { - // Cancel the model request before the 30-second transaction deadline. - const signal = AbortSignal.timeout(10_000); - const answer = await runAgent(redactor, { - messages: String(values.body), - signal, - }); - // A cancelled stream can return partial text instead of throwing. - signal.throwIfAborted(); - if (answer.events.some(event => - event.type === "status" && event.status === "error" - )) { - throw new Error("Note redaction failed"); - } - const body = answer.text.trim(); - if (!body) throw new Error("Note redaction returned no text"); - - return { - ...values, - body, - // Demo only; production apps should use the authenticated session. - author_email: \`\${values.author}@example.com\`, - }; - }, - - async afterCreate(row, ctx) { - // HTTP writes are disabled for this table, but this client is trusted - // and bound to the note's transaction. - await ctx.app.database.note_events.create({ - note_id: row.id, - action: "created", - }); - }, - - serialize: (row, { operation }) => - operation === "list" - ? { ...row, body: String(row.body).slice(0, 120) } - : row, - }, - }, -});`; - const TIMELINE_QUERY = `// Browser: use the generated board detail route, not a custom controller. const include = encodeURIComponent(JSON.stringify({ notes: { @@ -225,7 +165,7 @@ const GENERATED_ROUTES = [ { method: "POST", suffix: "", - purpose: "Create, with before/after hooks in one transaction", + purpose: "Create one row against the generated insert schema", }, { method: "PATCH", @@ -235,7 +175,7 @@ const GENERATED_ROUTES = [ { method: "DELETE", suffix: "/:id", - purpose: "Delete one row, with the same hooks available", + purpose: "Delete one row by primary key", }, ] as const; @@ -292,7 +232,7 @@ function DatabaseRoute() {
@@ -301,8 +241,8 @@ function DatabaseRoute() { Live - Boards, their notes, and the audit trail a hook keeps for them. - Every request below hits a route this app never wrote. + Boards, their notes, and the read-only events nested under each + note. Every request below hits a route this app never wrote. @@ -353,20 +293,21 @@ function DatabaseRoute() {

Set api: false to disable generated endpoints while keeping the typed database client available to your own - routes and hooks. Use api: {"{ writes: false }"}{" "} - for read-only routes on every table. Here,{" "} + routes. Use api: {"{ writes: false }"} for + read-only routes on every table. Here,{" "} api.writes.tables allows writes to boards and - notes while keeping the audit trail read-only. + notes while keeping note_events read-only.

- Try writing directly to the read-only audit trail + Try writing directly to the read-only note_events{" "} + table

fetch("/api/database/note_events", { @@ -396,9 +337,8 @@ function DatabaseRoute() { query is built, so it never leaves Postgres; it is refused in{" "} where, so nobody can guess it a character at a time; it is refused in write bodies rather than quietly ignored; - and it is stripped on the way out even if a serializer puts it - back. Server code, meanwhile, reads and writes it normally — - which is how author_email gets set at all. + and it is stripped on the way out. Server code can still read + and write it.

- - - The lifecycle of one write - - Where your code runs, and what it is allowed to do there. This - is the flow the panel at the top of the page executes. - - - - - - - - - - Inline hooks - - Redaction, audit writes, and read previews are defined directly - in the plugin registration, without a wrapper or custom routes. - - - - -

- A hook needs nothing from the plugin system to do this.{" "} - createAgent returns a definition, and the inline - hook passes it to runAgent. The capability from{" "} - ctx is ctx.app.database, which is - bound to the current transaction. Calls to a model or another - service do not join that transaction, and rollback cannot undo - their external effects. -

-

- The model request receives a 10-second cancellation signal, - shorter than the database transaction's 30-second deadline. - Cancellation, an agent error status, or blank output rejects the - mutation instead of saving the original or partial text. The - transaction occupies a pooled connection while it waits, so keep - this work short. -

-

- This demonstrates best-effort redaction, not guaranteed removal - of personal data. A non-empty model response can still miss - names or email addresses; do not use it as the sole privacy - control for sensitive data. -

-
-
- Nested reads through the generated API - Load a board, its notes, and their audit events in one bounded + Load a board, its notes, and their{" "} + note_events in one bounded read. A read-only table can participate in includes without exposing write operations. diff --git a/apps/dev-playground/config/database/schema.ts b/apps/dev-playground/config/database/schema.ts index 2bc9a0126..627230778 100644 --- a/apps/dev-playground/config/database/schema.ts +++ b/apps/dev-playground/config/database/schema.ts @@ -1,5 +1,5 @@ -// Annotations a reviewer leaves on a saved dashboard, plus the audit trail the -// plugin keeps for them. Three tables is enough to exercise a two-edge include; +// Annotations a reviewer leaves on a saved dashboard, plus an audit trail table +// for them. Three tables is enough to exercise a two-edge include; // the DatabasePlugin does not create them, so the app expects them to exist. import { diff --git a/apps/dev-playground/server/agents/redactor/agent.ts b/apps/dev-playground/server/agents/redactor/agent.ts deleted file mode 100644 index 677648d79..000000000 --- a/apps/dev-playground/server/agents/redactor/agent.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createAgent } from "@databricks/appkit/beta"; - -// Called from the DatabasePlugin's beforeCreate hook (server/database-hooks.ts) -// to show that a hook is server code and can reach any other plugin. Discovered -// automatically from server/agents/redactor/, so it is also registered as an -// agent — the hook drives it through runAgent(), not through those routes. -// -// Best-effort redaction; it is not a guarantee that all personal data is removed. -export default createAgent({ - instructions: - "Replace every personal name and email address in the user's text with [redacted]. " + - "Return only the rewritten text, nothing else.", -}); diff --git a/apps/dev-playground/server/database.test.ts b/apps/dev-playground/server/database.test.ts index 2840d54b2..0bbbd4599 100644 --- a/apps/dev-playground/server/database.test.ts +++ b/apps/dev-playground/server/database.test.ts @@ -1,20 +1,15 @@ -import type { HookContext, IDatabaseConfig } from "@databricks/appkit/beta"; +import type { IDatabaseConfig } from "@databricks/appkit/beta"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { schema } from "../config/database/schema"; const mocks = vi.hoisted(() => ({ createApp: vi.fn(async (_config: unknown) => undefined), - runAgent: vi.fn(), })); vi.mock("@databricks/appkit", async (importOriginal) => ({ ...(await importOriginal()), createApp: mocks.createApp, })); -vi.mock("@databricks/appkit/beta", async (importOriginal) => ({ - ...(await importOriginal()), - runAgent: mocks.runAgent, -})); // Capture the real bootstrap configuration without starting services or loading ORMs. vi.mock("./lakebase-examples-plugin", () => ({ lakebaseExamples: () => ({ name: "lakebaseExamples" }), @@ -27,7 +22,6 @@ vi.mock("./telemetry-example-plugin", () => ({ })); type DatabaseConfig = IDatabaseConfig; -type Hooks = NonNullable["notes"]; interface CapturedAppConfig { plugins: Array<{ name: string; config: unknown }>; onPluginsReady(appkit: unknown): Promise; @@ -44,47 +38,22 @@ async function loadApp(endpoint = "test-endpoint"): Promise { return call[0] as CapturedAppConfig; } -function requiredHooks(hooks: Hooks) { - if (!hooks?.beforeCreate || !hooks.afterCreate || !hooks.serialize) { - throw new Error("The inline database hooks are missing"); - } - return { - beforeCreate: hooks.beforeCreate, - afterCreate: hooks.afterCreate, - serialize: hooks.serialize, - }; -} - -const values = { - board_id: 7, - author: "reviewer", - body: "Original note text", -}; -const context = { - entity: "notes", - app: { database: {} }, -} as HookContext; let appConfig: CapturedAppConfig; let databaseConfig: DatabaseConfig; -let hooks: ReturnType; beforeEach(async () => { - mocks.runAgent.mockReset(); appConfig = await loadApp(); const registration = appConfig.plugins.find( (plugin) => plugin.name === "database", ); if (!registration) throw new Error("The database plugin was not registered"); databaseConfig = registration.config as DatabaseConfig; - hooks = requiredHooks(databaseConfig.hooks?.notes); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); -const beforeCreate = () => hooks.beforeCreate(values, context); - describe("inline playground database registration", () => { test("declares three tables in the conventional schema file", () => { expect(Object.keys(schema.$tables)).toEqual([ @@ -104,6 +73,10 @@ describe("inline playground database registration", () => { }); }); + test("does not register a removed lifecycle hooks config", () => { + expect(databaseConfig).not.toHaveProperty("hooks"); + }); + test("does not register the database when Lakebase is not configured", async () => { const app = await loadApp(""); expect(app.plugins.some((plugin) => plugin.name === "database")).toBe( @@ -133,119 +106,3 @@ describe("inline playground database registration", () => { ).toBe(false); }); }); - -describe("inline database hooks", () => { - test("uses non-empty model output without changing the original payload", async () => { - mocks.runAgent.mockResolvedValue({ - text: " [redacted] note text ", - events: [], - }); - const result = await beforeCreate(); - expect(result).toEqual({ - ...values, - body: "[redacted] note text", - author_email: "reviewer@example.com", - }); - expect(values.body).toBe("Original note text"); - expect(result).not.toBe(values); - }); - - test.each(["", " ", "\n\t "])( - "rejects blank model output %j rather than returning the original body", - async (text) => { - mocks.runAgent.mockResolvedValue({ text, events: [] }); - await expect(beforeCreate()).rejects.toThrow( - "Note redaction returned no text", - ); - expect(values.body).toBe("Original note text"); - }, - ); - - test("propagates model failures so the mutation can roll back", async () => { - const error = new Error("Model unavailable"); - mocks.runAgent.mockRejectedValue(error); - await expect(beforeCreate()).rejects.toBe(error); - }); - - test("rejects partial output accompanied by an agent error status", async () => { - mocks.runAgent.mockResolvedValue({ - text: "Partial output", - events: [{ type: "status", status: "error", error: "upstream detail" }], - }); - await expect(beforeCreate()).rejects.toThrow("Note redaction failed"); - }); - - test("forwards a 10-second abort signal to the model call", async () => { - const controller = new AbortController(); - const timeout = vi - .spyOn(AbortSignal, "timeout") - .mockReturnValue(controller.signal); - mocks.runAgent.mockResolvedValue({ text: "Processed note", events: [] }); - await beforeCreate(); - expect(timeout).toHaveBeenCalledExactlyOnceWith(10_000); - expect(mocks.runAgent).toHaveBeenCalledWith(expect.anything(), { - messages: values.body, - signal: controller.signal, - }); - }); - - test("propagates a model request cancelled by the timeout signal", async () => { - const controller = new AbortController(); - vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); - mocks.runAgent.mockImplementation( - (_agent, { signal }: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(signal.reason), { - once: true, - }); - }), - ); - const error = new DOMException("Redaction timed out", "TimeoutError"); - const result = beforeCreate(); - const rejected = expect(result).rejects.toBe(error); - controller.abort(error); - await rejected; - }); - - test("rejects accumulated text even if the model resolves after cancellation", async () => { - const controller = new AbortController(); - vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); - const error = new DOMException("Redaction timed out", "TimeoutError"); - mocks.runAgent.mockImplementation(async () => { - controller.abort(error); - return { - text: "Partial text returned by a cancelled stream", - events: [], - }; - }); - await expect(beforeCreate()).rejects.toBe(error); - }); - - test("keeps the audit write on the transaction-bound client", async () => { - const create = vi.fn().mockResolvedValue({ id: 1 }); - const row = { - ...values, - id: 2, - author_email: null, - created_at: "2026-01-01T00:00:00Z", - }; - await hooks.afterCreate(row, { - entity: "notes", - app: { database: { note_events: { create } } }, - } as unknown as HookContext); - expect(create).toHaveBeenCalledExactlyOnceWith({ - note_id: 2, - action: "created", - }); - }); - - test("preserves list previews and full detail responses", () => { - const row = { body: "x".repeat(200) }; - expect( - hooks.serialize(row, { entity: "notes", operation: "list" }), - ).toEqual({ body: "x".repeat(120) }); - expect(hooks.serialize(row, { entity: "notes", operation: "detail" })).toBe( - row, - ); - }); -}); diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index f2c22a7ca..2ec5a51b4 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -12,9 +12,8 @@ import { serving, WRITE_ACTIONS, } from "@databricks/appkit"; -import { agents, aiSearch, database, runAgent } from "@databricks/appkit/beta"; +import { agents, aiSearch, database } from "@databricks/appkit/beta"; -import redactor from "./agents/redactor/agent"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -77,57 +76,8 @@ createApp({ // The schema is loaded from config/database/schema.ts. database({ // Reads are generated for all three tables. Only boards and notes - // accept HTTP writes; the audit trail is written by the hook. + // accept HTTP writes; note_events is exposed read-only. api: { writes: { tables: ["boards", "notes"] } }, - // Example of Hooks integration between Database and Agents - hooks: { - // Hook per entity - notes: { - // Before a new Note is created, receive the values to be insert - // You can use this moment to do another operation like call a LLM and add the value in the values to be inserted - async beforeCreate(values) { - // Cancel the model call before the 30-second transaction deadline. - const signal = AbortSignal.timeout(10_000); - const answer = await runAgent(redactor, { - messages: String(values.body), - signal, - }); - // A cancelled stream can return accumulated text instead of throwing. - signal.throwIfAborted(); - if ( - answer.events.some( - (event) => - event.type === "status" && event.status === "error", - ) - ) { - throw new Error("Note redaction failed"); - } - const body = answer.text.trim(); - if (!body) throw new Error("Note redaction returned no text"); - - return { - ...values, - body, - // Demo only; production apps should use the authenticated session. - author_email: `${values.author}@example.com`, - }; - }, - // After a note is created, receiving the inserted value. - // You can use this moment to trigger a new operation immediately that relies on the insert - async afterCreate(row, ctx) { - // The audit write joins the note's transaction, not the HTTP API. - await ctx.app.database.note_events.create({ - note_id: row.id, - action: "created", - }); - }, - // Utility callback to allow you to change the payload from a list operation if necessary. - serialize: (row, { operation }) => - operation === "list" - ? { ...row, body: String(row.body).slice(0, 120) } - : row, - }, - }, }), ] : []), @@ -169,7 +119,7 @@ createApp({ serving(), agents({ // Every agent lives under server/agents// — code agents as agent.ts - // (helper, supervisor, sql_analyst, dashboard_pilot, redactor), markdown agents as + // (helper, supervisor, sql_analyst, dashboard_pilot), markdown agents as // agent.md (query, insights, anomaly, autocomplete). `query` (markdown // dispatcher) delegates to the code `sql_analyst` + `dashboard_pilot` to // wire the /smart-dashboard route. `insights` and `anomaly` are ephemeral diff --git a/docs/docs/api/appkit/Class.DatabaseValidationError.md b/docs/docs/api/appkit/Class.DatabaseValidationError.md index d9c49752b..41d0c41d2 100644 --- a/docs/docs/api/appkit/Class.DatabaseValidationError.md +++ b/docs/docs/api/appkit/Class.DatabaseValidationError.md @@ -1,8 +1,9 @@ # Class: DatabaseValidationError -Deliberate validation failure raised by a database mutation hook. Generated -routes answer `422` and echo only the issues naming a public column; every -other failure raised inside a hook stays an opaque server error. +Deliberate validation failure. An explicit transaction callback can throw it, +and the transaction rethrows that same error. Generated routes answer `422` +and echo only the issues naming a public column. Every other failure stays +an opaque server error. ## Extends diff --git a/docs/docs/api/appkit/Function.defineSchema.md b/docs/docs/api/appkit/Function.defineSchema.md index 29ca4efc8..0e9b671fd 100644 --- a/docs/docs/api/appkit/Function.defineSchema.md +++ b/docs/docs/api/appkit/Function.defineSchema.md @@ -5,7 +5,7 @@ function defineSchema(builder: (context: SchemaBuilderContext) => TTabl ``` Compile one declared schema. The returned type keeps the table names the -builder returned, so `api.tables` and `hooks` can name only real tables. +builder returned, so `api.tables` can name only real tables. ## Type Parameters diff --git a/docs/docs/api/appkit/Interface.EntityMutationHooks.md b/docs/docs/api/appkit/Interface.EntityMutationHooks.md deleted file mode 100644 index 1cc9a8da2..000000000 --- a/docs/docs/api/appkit/Interface.EntityMutationHooks.md +++ /dev/null @@ -1,170 +0,0 @@ -# Interface: EntityMutationHooks\ - -Mutation lifecycle for one entity. A before hook may return a replacement -payload, which is revalidated against the trusted schema before it is -persisted. Every hook, the mutation, and any write a hook issues through -`ctx.app.database` share one transaction, so a rejection anywhere rolls all -of them back. Throw `DatabaseValidationError` to answer a generated route -with `422`; any other failure stays an opaque server error. - -## Type Parameters - -| Type Parameter | Default type | -| ------ | ------ | -| `TTable` *extends* `string` | `string` | - -## Methods - -### afterCreate()? - -```ts -optional afterCreate(row: FacetOf, context: HookContext): MaybePromise; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `row` | `FacetOf`\<`TTable`, `"row"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void`\> - -*** - -### afterDelete()? - -```ts -optional afterDelete(id: IdValue, context: HookContext): MaybePromise; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `id` | `IdValue` | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void`\> - -*** - -### afterUpdate()? - -```ts -optional afterUpdate(row: FacetOf, context: HookContext): MaybePromise; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `row` | `FacetOf`\<`TTable`, `"row"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void`\> - -*** - -### afterUpsert()? - -```ts -optional afterUpsert(row: FacetOf, context: HookContext): MaybePromise; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `row` | `FacetOf`\<`TTable`, `"row"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void`\> - -*** - -### beforeCreate()? - -```ts -optional beforeCreate(values: FacetOf, context: HookContext): MaybePromise>; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `values` | `FacetOf`\<`TTable`, `"insert"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"insert"`\>\> - -*** - -### beforeDelete()? - -```ts -optional beforeDelete(id: IdValue, context: HookContext): MaybePromise; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `id` | `IdValue` | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void`\> - -*** - -### beforeUpdate()? - -```ts -optional beforeUpdate( - id: IdValue, - values: FacetOf, -context: HookContext): MaybePromise>; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `id` | `IdValue` | -| `values` | `FacetOf`\<`TTable`, `"update"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"update"`\>\> - -*** - -### beforeUpsert()? - -```ts -optional beforeUpsert(values: FacetOf, context: HookContext): MaybePromise>; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `values` | `FacetOf`\<`TTable`, `"insert"`\> | -| `context` | [`HookContext`](Interface.HookContext.md) | - -#### Returns - -`MaybePromise`\<`void` \| `FacetOf`\<`TTable`, `"insert"`\>\> diff --git a/docs/docs/api/appkit/Interface.HookApp.md b/docs/docs/api/appkit/Interface.HookApp.md deleted file mode 100644 index 0f9189fac..000000000 --- a/docs/docs/api/appkit/Interface.HookApp.md +++ /dev/null @@ -1,11 +0,0 @@ -# Interface: HookApp - -The only capability a hook receives: entities bound to its transaction. - -## Properties - -### database - -```ts -readonly database: TransactionClient; -``` diff --git a/docs/docs/api/appkit/Interface.HookContext.md b/docs/docs/api/appkit/Interface.HookContext.md deleted file mode 100644 index 62a956c75..000000000 --- a/docs/docs/api/appkit/Interface.HookContext.md +++ /dev/null @@ -1,19 +0,0 @@ -# Interface: HookContext - -Which entity is being mutated, and the surface a hook may write through. - -## Properties - -### app - -```ts -readonly app: HookApp; -``` - -*** - -### entity - -```ts -readonly entity: string; -``` diff --git a/docs/docs/api/appkit/Interface.ReadSerializerContext.md b/docs/docs/api/appkit/Interface.ReadSerializerContext.md deleted file mode 100644 index d8dc3b4ad..000000000 --- a/docs/docs/api/appkit/Interface.ReadSerializerContext.md +++ /dev/null @@ -1,19 +0,0 @@ -# Interface: ReadSerializerContext - -Which entity and generated operation produced the row being shaped. - -## Properties - -### entity - -```ts -readonly entity: string; -``` - -*** - -### operation - -```ts -readonly operation: "detail" | "list"; -``` diff --git a/docs/docs/api/appkit/TypeAlias.EntityHooks.md b/docs/docs/api/appkit/TypeAlias.EntityHooks.md deleted file mode 100644 index 19ef8afa9..000000000 --- a/docs/docs/api/appkit/TypeAlias.EntityHooks.md +++ /dev/null @@ -1,23 +0,0 @@ -# Type Alias: EntityHooks\ - -```ts -type EntityHooks = EntityMutationHooks & { - serialize?: ReadSerializer; -}; -``` - -Response shaping and mutation lifecycle declared for one table. - -## Type Declaration - -### serialize? - -```ts -readonly optional serialize: ReadSerializer; -``` - -## Type Parameters - -| Type Parameter | Default type | -| ------ | ------ | -| `TTable` *extends* `string` | `string` | diff --git a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md index 2461127b6..3e8a88782 100644 --- a/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md +++ b/docs/docs/api/appkit/TypeAlias.IDatabaseConfig.md @@ -3,7 +3,6 @@ ```ts type IDatabaseConfig = { api?: DatabaseApiConfig; - hooks?: { readonly [TTable in SchemaTableName]?: EntityHooks }; schema?: TSchema; }; ``` @@ -41,14 +40,6 @@ true *** -### hooks? - -```ts -readonly optional hooks: { readonly [TTable in SchemaTableName]?: EntityHooks }; -``` - -*** - ### schema? ```ts diff --git a/docs/docs/api/appkit/TypeAlias.ReadSerializer.md b/docs/docs/api/appkit/TypeAlias.ReadSerializer.md deleted file mode 100644 index b036d9ad7..000000000 --- a/docs/docs/api/appkit/TypeAlias.ReadSerializer.md +++ /dev/null @@ -1,20 +0,0 @@ -# Type Alias: ReadSerializer() - -```ts -type ReadSerializer = (row: Record, context: ReadSerializerContext) => Record; -``` - -Shape one already private-safe row before it reaches the wire. A `Promise` -is not assignable to the return type, so an async callback fails to compile: -serializers run inside the response path and must not add latency there. - -## Parameters - -| Parameter | Type | -| ------ | ------ | -| `row` | `Record`\<`string`, `unknown`\> | -| `context` | [`ReadSerializerContext`](Interface.ReadSerializerContext.md) | - -## Returns - -`Record`\<`string`, `unknown`\> diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ee6db8b67..e29fd2dcd 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -20,7 +20,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AuthenticationError](Class.AuthenticationError.md) | Error thrown when authentication fails. Use for missing tokens, invalid credentials, or authorization failures. | | [ConfigurationError](Class.ConfigurationError.md) | Error thrown when configuration is missing or invalid. Use for missing environment variables, invalid settings, or setup issues. | | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | -| [DatabaseValidationError](Class.DatabaseValidationError.md) | Deliberate validation failure raised by a database mutation hook. Generated routes answer `422` and echo only the issues naming a public column; every other failure raised inside a hook stays an opaque server error. | +| [DatabaseValidationError](Class.DatabaseValidationError.md) | Deliberate validation failure. An explicit transaction callback can throw it, and the transaction rethrows that same error. Generated routes answer `422` and echo only the issues naming a public column. Every other failure stays an opaque server error. | | [DatabricksAdapter](Class.DatabricksAdapter.md) | Adapter that talks directly to Databricks Model Serving `/invocations` endpoint. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [IdentityExpiredError](Class.IdentityExpiredError.md) | The downstream service rejected the active caller's credentials. | @@ -64,7 +64,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [~~DownloadResponse~~](Interface.DownloadResponse.md) | Response object for file downloads containing a readable stream. | | [DriveResult](Interface.DriveResult.md) | What a driver returns for a single `t.send`. | | [EndpointConfig](Interface.EndpointConfig.md) | - | -| [EntityMutationHooks](Interface.EntityMutationHooks.md) | Mutation lifecycle for one entity. A before hook may return a replacement payload, which is revalidated against the trusted schema before it is persisted. Every hook, the mutation, and any write a hook issues through `ctx.app.database` share one transaction, so a rejection anywhere rolls all of them back. Throw `DatabaseValidationError` to answer a generated route with `422`; any other failure stays an opaque server error. | | [EvalDefinition](Interface.EvalDefinition.md) | A single eval, default-exported from a `*.eval.ts` file. | | [EvalDriver](Interface.EvalDriver.md) | Abstraction over how the agent is driven. The HTTP driver posts to a running app's agents endpoint; future drivers (in-process) implement the same shape. | | [EvalResult](Interface.EvalResult.md) | The outcome of running one eval. | @@ -76,8 +75,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [FunctionTool](Interface.FunctionTool.md) | - | | [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials | | [GenerationParams](Interface.GenerationParams.md) | Optional generation parameters forwarded to the OpenAI-compatible serving request body. Names match the serving API wire keys. Only keys that are set are sent — undefined values are omitted so the endpoint applies its own defaults. Ranges are not validated here; the serving endpoint validates. | -| [HookApp](Interface.HookApp.md) | The only capability a hook receives: entities bound to its transaction. | -| [HookContext](Interface.HookContext.md) | Which entity is being mutated, and the surface a hook may write through. | | [HostedSupervisorTool](Interface.HostedSupervisorTool.md) | Tagged record returned by every [supervisorTools](Variable.supervisorTools.md) factory. The `__kind` discriminator lets the agents plugin (and standalone `runAgent`) classify these tools without a structural match against the wire format — keeps the SA wire shape free to evolve and avoids namespace collisions with MCP hosted tools (which use `type: "genie-space"` hyphenated, vs SA's `type: "genie_space"` underscored). | | [HttpDriverOptions](Interface.HttpDriverOptions.md) | - | | [IAiSearchConfig](Interface.IAiSearchConfig.md) | Base configuration interface for AppKit plugins | @@ -100,7 +97,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [PostResult](Interface.PostResult.md) | Structured result for a best-effort POST that must not throw. | | [PromptContext](Interface.PromptContext.md) | Context passed to `baseSystemPrompt` callbacks. | | [ReadEvalDatasetOptions](Interface.ReadEvalDatasetOptions.md) | - | -| [ReadSerializerContext](Interface.ReadSerializerContext.md) | Which entity and generated operation produced the row being shaped. | | [RegisteredAgent](Interface.RegisteredAgent.md) | - | | [ReportOutcome](Interface.ReportOutcome.md) | - | | [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables | @@ -153,7 +149,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [DatabaseApiWriteOperation](TypeAlias.DatabaseApiWriteOperation.md) | Generated HTTP write operations. | | [DatabaseApiWritesConfig](TypeAlias.DatabaseApiWritesConfig.md) | All writes by default; false keeps reads only, and an object narrows writes. | | [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | -| [EntityHooks](TypeAlias.EntityHooks.md) | Response shaping and mutation lifecycle declared for one table. | | [EvalProgress](TypeAlias.EvalProgress.md) | - | | [ExecutionContext](TypeAlias.ExecutionContext.md) | - | | [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. | @@ -168,7 +163,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [Matcher](TypeAlias.Matcher.md) | A deterministic matcher: inspects a string value and returns a result. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | -| [ReadSerializer](TypeAlias.ReadSerializer.md) | Shape one already private-safe row before it reaches the wire. A `Promise` is not assignable to the return type, so an async callback fails to compile: serializers run inside the response path and must not add latency there. | | [ResolvedToolEntry](TypeAlias.ResolvedToolEntry.md) | Internal tool-index entry after a tool record has been resolved to a dispatchable form. | | [ResourceFieldEntry](TypeAlias.ResourceFieldEntry.md) | - | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | @@ -218,7 +212,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [defineEval](Function.defineEval.md) | Define an agent eval. Default-export the result from a `server/agents//evals/*.eval.ts` file. | | [defineEvalConfig](Function.defineEvalConfig.md) | Define per-directory eval config. Default-export from `evals.config.ts`. | | [defineManifest](Function.defineManifest.md) | Validates a raw manifest (typically a `manifest.json` import) against the canonical Zod schema and returns it as a strict [PluginManifest](Interface.PluginManifest.md). | -| [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `api.tables` and `hooks` can name only real tables. | +| [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `api.tables` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | | [discoverEvalConfigs](Function.discoverEvalConfigs.md) | Discover the per-agent `evals.config.ts` (from [defineEvalConfig](Function.defineEvalConfig.md)) at `/server/agents//evals/evals.config.ts`. Config is per-agent: each agent's config applies only to that agent's evals. Agents without a config file are omitted. Returns a stable, sorted list. | | [discoverEvalFiles](Function.discoverEvalFiles.md) | Discover evals under `/server/agents//evals/` — co-located with each agent's `agent.{md,ts}` (same folder-per-agent layout the agents plugin discovers). The agent id is the folder name; the eval id is the file path relative to that evals dir with `.eval.ts` stripped. Sorted + stable. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index dca9af23b..6d389ed01 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -254,11 +254,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.EndpointConfig", label: "EndpointConfig" }, - { - type: "doc", - id: "api/appkit/Interface.EntityMutationHooks", - label: "EntityMutationHooks" - }, { type: "doc", id: "api/appkit/Interface.EvalDefinition", @@ -314,16 +309,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.GenerationParams", label: "GenerationParams" }, - { - type: "doc", - id: "api/appkit/Interface.HookApp", - label: "HookApp" - }, - { - type: "doc", - id: "api/appkit/Interface.HookContext", - label: "HookContext" - }, { type: "doc", id: "api/appkit/Interface.HostedSupervisorTool", @@ -434,11 +419,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ReadEvalDatasetOptions", label: "ReadEvalDatasetOptions" }, - { - type: "doc", - id: "api/appkit/Interface.ReadSerializerContext", - label: "ReadSerializerContext" - }, { type: "doc", id: "api/appkit/Interface.RegisteredAgent", @@ -680,11 +660,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.DatabaseExports", label: "DatabaseExports" }, - { - type: "doc", - id: "api/appkit/TypeAlias.EntityHooks", - label: "EntityHooks" - }, { type: "doc", id: "api/appkit/TypeAlias.EvalProgress", @@ -755,11 +730,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.Plugins", label: "Plugins" }, - { - type: "doc", - id: "api/appkit/TypeAlias.ReadSerializer", - label: "ReadSerializer" - }, { type: "doc", id: "api/appkit/TypeAlias.ResolvedToolEntry", diff --git a/docs/docs/plugins/database.md b/docs/docs/plugins/database.md index 7f8aa77e1..30c9accd8 100644 --- a/docs/docs/plugins/database.md +++ b/docs/docs/plugins/database.md @@ -110,7 +110,7 @@ An explicit schema always takes precedence and skips file discovery. An invalid explicit schema fails setup instead of falling back to another file. Run `appkit generate-types` to generate the database registry. With that registry, -configuration without an explicit schema still infers table names and hook payloads. +configuration without an explicit schema still infers table names. An explicitly supplied schema also checks configuration keys against its own table names. @@ -152,7 +152,7 @@ database({ `api.tables: []` disables all generated routes. An empty write-table or write-operation list keeps reads and disables writes. Tables omitted from `api.tables` also cannot be included through relations on exposed tables. -These restrictions apply to HTTP only, not to the server-side client or hooks. +These restrictions apply to HTTP only, not to the server-side client. An omitted restriction uses its default. A malformed restriction fails setup. For example, `{ api: { write: false } }` is an error, not permission to generate @@ -192,45 +192,42 @@ Validation is not authorization. A valid request can still read or change any exposed row. Use custom authorized routes when that is not the desired access model. -## Mutation hooks +## Transactions -A table can declare `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, -`beforeUpsert`, `afterUpsert`, `beforeDelete`, and `afterDelete`. Hooks and the -mutation run in one database transaction. Related writes through -`ctx.app.database` join that same transaction. This does not make writes through -other plugins or external services transactional. +A root write outside `database.transaction()` uses the connection pool directly. +Related writes belong in one explicit transaction. The callback receives a +transaction client with the same entity methods and `sql` tag. A later +`transaction()` call inside that callback joins the open transaction instead of +opening another one. A root write made while the callback is running also joins +it, and keeps the predicate from the query that issued the write. A read or an +`sql` call made on the root client does not join. ```ts -import { DatabaseValidationError } from "@databricks/appkit"; - -database({ - hooks: { - notes: { - beforeCreate(values) { - if (typeof values.body === "string" && values.body.length > 5_000) { - throw new DatabaseValidationError("Note too long", [ - { path: ["body"], message: "Must be at most 5000 characters" }, - ]); - } - }, - }, - }, +const note = await appkit.database.transaction(async (tx) => { + const created = await tx.notes.create({ body: "Ship it" }); + await tx.note_events.create({ note_id: created.id, action: "created" }); + return created; }); ``` -A `before*` hook can return replacement values, which are validated again before -persistence. `DatabaseValidationError` produces HTTP 422 with issues limited to -public columns. Other hook failures return an opaque server error. +The callback commits when it returns and rolls back when it throws. A thrown +`DatabaseValidationError` keeps its identity. Any other thrown error becomes an +opaque transaction failure. The transaction does not cover writes through other +plugins or external services. -Keep hooks short and await all database work. A transaction has a 30-second -callback deadline, a shared budget of 100 database operations, and a maximum -mutation nesting depth of 8. Repeating the same entity and mutation operation in -a nested hook chain is rejected. PostgreSQL also enforces a 30-second -`statement_timeout` and a 30-second `idle_in_transaction_session_timeout`. +`update()` returns `null` and `delete()` returns `false` when no row matches. +Those results do not abort the caller's transaction. A later write in the same +callback can still commit. + +Await every database call inside the callback. The callback has a 30-second +deadline and one shared budget. That budget counts entity writes and `tx.sql` +statements. It does not count reads. The 101st counted operation rejects the +transaction. PostgreSQL also enforces a 30-second `statement_timeout` and a +30-second `idle_in_transaction_session_timeout`. 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. +other external side effects. Avoid starting external work that needs the +database rollback to undo it. ## Browser client (beta) @@ -323,10 +320,6 @@ 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. @@ -339,13 +332,11 @@ 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. +Skip an update when a form has no changes. An empty update returns +`INVALID_REQUEST` with a `body` detail explaining that at least one field is +required, and the stored row does not change. -The answered row is the public row the database holds after any `before*` hook -ran. A read serializer never reshapes a write's response. +The answered row is the public row the database holds after the write. ### Errors @@ -464,54 +455,6 @@ request. To pass options without params, use `{}` for the params: - A successful write through a write hook restarts mounted reads. See [Refresh reads after a write](#refresh-reads-after-a-write). -### Serializer-shaped reads - -A read serializer can change the rows a list or detail route returns. The -`shape` option types the result, and can check it. The entity, id, and -parameters are still checked against the generated registry either way. - -To check each row at runtime, pass a function that takes one decoded row and -returns it typed, such as a zod schema's `parse`. It runs once per response, on -each list item or on the record, and its return value becomes the row. If it -throws, the read fails with `INTERNAL`; the thrown error is in `error.cause`, -not in the message, since it may quote row values. - -```tsx -import { useDatabaseList } from "@databricks/appkit-ui/react/beta"; -import { z } from "zod"; - -// server: serialize: (row) => ({ id: row.id, excerpt: String(row.body).slice(0, 80) }) -const NoteCard = z.object({ id: z.number(), excerpt: z.string() }); - -const notes = useDatabaseList( - "notes", - { limit: 20 }, - { shape: NoteCard.parse }, -); -notes.data?.items[0]?.excerpt; -``` - -Zod 4's `parse` works unbound. For a library whose parse method needs its -schema as `this`, pass an arrow: `shape: (row) => schema.parse(row)`. - -To only declare the type, pass `serialized()`. Nothing checks it at runtime, -so keep `T` in step with the serializer. - -```tsx -import { serialized, useDatabaseList } from "@databricks/appkit-ui/react/beta"; - -interface NoteCard { - id: number; - excerpt: string; -} - -const notes = useDatabaseList( - "notes", - { limit: 20 }, - { shape: serialized() }, -); -``` - ### Write rows ```tsx @@ -595,10 +538,10 @@ When a write succeeds, every mounted database read restarts, so lists and includes that show the changed row refresh without a manual `refetch()`. Each read keeps its last `data` while it reloads. A failed write restarts nothing. -The default restarts every read because a [mutation hook](#mutation-hooks) on -the server may write other tables in the same transaction, and the browser -cannot see that. When a page mounts many reads, pass `invalidate` to restart -only the reads that show the tables you name, or none: +The default restarts every read because a foreign key declared with +`onDelete("cascade")` can change other tables, and the browser cannot see that. +When a page mounts many reads, pass `invalidate` to restart only the reads that +show the tables you name, or none: ```ts // A board's title changes only boards rows. @@ -647,4 +590,3 @@ await invalidateDatabaseReads(["boards", "notes"]); - [`database`](../api/appkit/Function.database.md) - [`IDatabaseConfig`](../api/appkit/TypeAlias.IDatabaseConfig.md) - [`DatabaseApiConfig`](../api/appkit/TypeAlias.DatabaseApiConfig.md) -- [`EntityHooks`](../api/appkit/TypeAlias.EntityHooks.md) diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index 602753d55..32137f78a 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -105,8 +105,8 @@ export interface DatabaseApi { /** * 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. + * database holds it. Private, generated, and undeclared fields are compile + * errors, as the server refuses them. * * @example * ```typescript @@ -315,7 +315,7 @@ export function isDatabaseListPage( } /** - * A detail route answers one bare row; a serializer returns an object too. + * A detail route answers one bare row. * @internal Shared with the React hooks; not part of the public surface. */ export function isDatabaseRow(body: unknown): body is Record { diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 5b1053b14..6bf2a9ff3 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -53,12 +53,9 @@ export { type UseDatabaseListResult, useDatabaseList, } from "./hooks/use-database-list"; -export { - type DatabaseRowShape, - type DatabaseShape, - serialized, - type UseDatabaseReadOptions, - type UseDatabaseReadResult, +export type { + UseDatabaseReadOptions, + UseDatabaseReadResult, } from "./hooks/use-database-read"; export { type UseDatabaseRecordOptions, diff --git a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts index 20916cae4..fc55ae3e2 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/database-test-utils.ts @@ -25,7 +25,6 @@ type Page = { items: unknown[]; limit: number; offset: number }; interface ReadOptions { enabled?: boolean; keepPreviousData?: boolean; - shape?: (row: unknown) => unknown; } interface WriteOptions { diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx index 5cb7ee1d4..ef5350973 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-list.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { StrictMode, useEffect } from "react"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { DatabaseApiError } from "@/js/database/errors"; @@ -307,60 +307,6 @@ describe("useDatabaseList", () => { expect(result.current.error).toMatchObject({ code: "INTERNAL" }); }); - test("a shape function checks each row once per response and types the result", async () => { - const parse = vi.fn((row: unknown) => { - const { id } = row as { id: unknown }; - if (typeof id !== "number") throw new TypeError(`bad id ${String(id)}`); - return { id, label: `#${id}` }; - }); - const { result, rerender } = renderHook(() => - // An inline arrow: a new function on every render. - useDatabaseList("notes", {}, { shape: (row) => parse(row) }), - ); - await act(async () => requests[0]?.respond(page({ id: 1 }, { id: 2 }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - expect(result.current.data).toEqual({ - items: [ - { id: 1, label: "#1" }, - { id: 2, label: "#2" }, - ], - limit: 50, - offset: 0, - }); - const shaped = result.current.data; - rerender(); - rerender(); - expect(result.current.data).toBe(shaped); - expect(parse).toHaveBeenCalledTimes(2); - }); - - test("a row that fails its shape fails the read with INTERNAL, without echoing the row", async () => { - const { result } = renderHook(() => - useDatabaseList( - "notes", - {}, - { - shape: (row) => { - throw new TypeError(`secret ${JSON.stringify(row)}`); - }, - }, - ), - ); - await act(async () => requests[0]?.respond(page({ id: "token" }))); - await waitFor(() => expect(result.current.loading).toBe(false)); - - expect(result.current.data).toBeNull(); - expect(result.current.error).toBeInstanceOf(DatabaseApiError); - expect(result.current.error).toMatchObject({ - code: "INTERNAL", - status: null, - message: "Database response does not match the read's shape", - }); - expect(result.current.error?.message).not.toContain("token"); - expect(result.current.error?.cause).toBeInstanceOf(TypeError); - }); - test("aborts the request once the last subscriber unmounts", async () => { const first = renderHook(() => useDatabaseList("notes")); const second = renderHook(() => useDatabaseList("notes")); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx index 975bd1f08..fb7fe39ea 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database-record.test.tsx @@ -190,22 +190,6 @@ describe("useDatabaseRecord", () => { expect(result.current.error).toMatchObject({ code: "NOT_FOUND" }); }); - test("applies a shape function to the record", async () => { - const { result } = renderHook(() => - useDatabaseRecord( - "notes", - 7, - {}, - { - shape: (row) => ({ title: String((row as { body: string }).body) }), - }, - ), - ); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toEqual({ title: "note 7" }); - }); - test("reads once under StrictMode and keeps the request across the remount", async () => { const { result } = renderHook(() => useDatabaseRecord("notes", 3), { wrapper: StrictMode, diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts index 7cde2201f..a005ab01b 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-database.types.test.ts @@ -262,67 +262,47 @@ test("read hooks type entities, params, and rows from the generated registry", ( expect(diagnostics).toEqual([]); }); -test("serialized() replaces the row type and keeps entity, id, and params checked", () => { +test("read rows come only from the registry: no shape, serialized(), or row type argument", () => { const diagnostics = compileTypeProbe(` - import { - type DatabaseListPage, - type UseDatabaseListResult, - serialized, - useDatabaseList, - useDatabaseRecord, - } from "@databricks/appkit-ui/react/beta"; + import { useDatabaseList, useDatabaseRecord } from "@databricks/appkit-ui/react/beta"; ${REGISTRY} - interface PostCard { id: number; headline: string; comment_count: number } + interface PostCard { id: number; headline: string } - export function Shaped() { - const cards = useDatabaseList( - "posts", - { order: { id: "desc" }, limit: 5 }, - { shape: serialized() }, - ); - const typed: UseDatabaseListResult = cards; - const page: DatabaseListPage | null = cards.data; - const headline: string | undefined = cards.data?.items[0]?.headline; - // @ts-expect-error the shaped row replaces the inferred one - cards.data?.items[0]?.title; - - const card = useDatabaseRecord("posts", 7, undefined, { - shape: serialized(), - enabled: false, - }); - const count: number | undefined = card.data?.comment_count; - void [typed, page, headline, count]; + export function Controls() { + // The probes below differ from these calls only in what was removed. + useDatabaseList<"posts", {}>("posts", {}); + useDatabaseRecord<"posts", {}>("posts", 7); + useDatabaseList("posts", {}, { enabled: false }); + useDatabaseRecord("posts", 7, {}, { enabled: false }); } - export function StillChecked() { - // @ts-expect-error the entity stays checked with a shape - useDatabaseList("missing", {}, { shape: serialized() }); - // @ts-expect-error private filters stay rejected with a shape - useDatabaseList("users", { where: { secret: "token" } }, { shape: serialized() }); - // @ts-expect-error unknown params stay rejected with a shape - useDatabaseList("users", { includeTotal: true }, { shape: serialized() }); - // @ts-expect-error includes stay bounded with a shape - useDatabaseList("posts", { include: { users: { limit: 1 } } }, { shape: serialized() }); - // @ts-expect-error keyless entities stay rejected with a shape - useDatabaseRecord("events", "x", {}, { shape: serialized() }); - // @ts-expect-error the id stays checked with a shape - useDatabaseRecord("posts", "7", {}, { shape: serialized() }); - // @ts-expect-error record params stay checked with a shape - useDatabaseRecord("users", "ada", { select: ["secret"] }, { shape: serialized() }); - // @ts-expect-error a shape is only built by serialized() - useDatabaseList("posts", {}, { shape: { headline: "x" } }); + export function Removed() { + // @ts-expect-error a list takes no caller-chosen row type + useDatabaseList<"posts", {}, PostCard>("posts", {}); + // @ts-expect-error a record takes no caller-chosen row type + useDatabaseRecord<"posts", {}, PostCard>("posts", 7); + // @ts-expect-error a list takes no shape option + useDatabaseList("posts", {}, { shape: (row: unknown) => row as PostCard }); + // @ts-expect-error a record takes no shape option + useDatabaseRecord("posts", 7, {}, { shape: (row: unknown) => row as PostCard }); } + + // @ts-expect-error serialized() is not exported + export type Serialized = typeof import("@databricks/appkit-ui/react/beta").serialized; + // @ts-expect-error DatabaseShape is not exported + export type Shape = import("@databricks/appkit-ui/react/beta").DatabaseShape; + // @ts-expect-error DatabaseRowShape is not exported + export type RowShape = import("@databricks/appkit-ui/react/beta").DatabaseRowShape; `); expect(diagnostics).toEqual([]); }); -test("read hooks take null params, keepPreviousData, and a parse shape", () => { +test("read hooks take null params and keepPreviousData", () => { const diagnostics = compileTypeProbe(` import { - type DatabaseListPage, type UseDatabaseListResult, type UseDatabaseRecordResult, useDatabaseList, @@ -331,9 +311,6 @@ test("read hooks take null params, keepPreviousData, and a parse shape", () => { ${REGISTRY} - interface PostCard { id: number; headline: string } - declare function parsePostCard(row: unknown): PostCard; - export function Dependent(userSlug: string | undefined, offset: number) { // Null params hold the read; the non-null branch keeps its exact type. const posts = useDatabaseList( @@ -345,24 +322,13 @@ test("read hooks take null params, keepPreviousData, and a parse shape", () => { const typed: UseDatabaseListResult<{ id: number; user_slug: string; title: string; total: string; payload: unknown }> = posts; // @ts-expect-error the non-null branch is still checked exactly useDatabaseList("posts", userSlug ? { where: { secret: userSlug } } : null); - void [title, typed]; - } - - export function Parsed() { - const cards = useDatabaseList("posts", { limit: 5 }, { shape: parsePostCard }); - const page: DatabaseListPage | null = cards.data; - const inline = useDatabaseList("posts", { limit: 5 }, { - shape: (row) => ({ id: Number((row as { id: unknown }).id), headline: "x" }), - }); - const headline: string | undefined = inline.data?.items[0]?.headline; - // @ts-expect-error the parsed row replaces the inferred one - cards.data?.items[0]?.title; - - const card = useDatabaseRecord("posts", 7, {}, { shape: parsePostCard, keepPreviousData: true }); - const record: UseDatabaseRecordResult = card; - // @ts-expect-error a shape is a parse function or serialized(), not a value - useDatabaseList("posts", {}, { shape: parsePostCard(null) }); - void [page, headline, record]; + const post: UseDatabaseRecordResult<{ title: string }> = useDatabaseRecord( + "posts", + 7, + { select: ["title"] }, + { keepPreviousData: true }, + ); + void [title, typed, post]; } `); diff --git a/packages/appkit-ui/src/react/hooks/use-database-list.ts b/packages/appkit-ui/src/react/hooks/use-database-list.ts index 3831cbf5b..a90922d54 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-list.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-list.ts @@ -22,7 +22,7 @@ import { } from "./use-database-read"; /** Options for {@link useDatabaseList}. */ -export type UseDatabaseListOptions = UseDatabaseReadOptions; +export type UseDatabaseListOptions = UseDatabaseReadOptions; /** What {@link useDatabaseList} returns: one page of rows and its state. */ export type UseDatabaseListResult = UseDatabaseReadResult< @@ -41,7 +41,7 @@ export type UseDatabaseListResult = UseDatabaseReadResult< * @param entity - A table the generated registry exposes * @param params - `where`, `order`, `select`, `include`, `limit`, `offset`; * `null` holds the hook idle, for params that depend on another read - * @param options - `enabled`, `keepPreviousData`, and `shape` + * @param options - `enabled` and `keepPreviousData` * @returns The page, loading and error state, and `refetch` * * @example @@ -54,27 +54,23 @@ export type UseDatabaseListResult = UseDatabaseReadResult< * notes.data?.items.map((note) => note.body); * ``` */ -export function useDatabaseList< - K extends DatabaseKeyedEntity, - Row = DatabaseListRow, ->( +export function useDatabaseList( entity: K, params?: undefined, - options?: UseDatabaseListOptions, -): UseDatabaseListResult; + options?: UseDatabaseListOptions, +): UseDatabaseListResult>; export function useDatabaseList< K extends DatabaseEntity, const P extends DatabaseListParams, - Row = DatabaseListRow, >( entity: K, params: (P & ExactDatabaseParams>) | null, - options?: UseDatabaseListOptions, -): UseDatabaseListResult; + options?: UseDatabaseListOptions, +): UseDatabaseListResult>; export function useDatabaseList( entity: string, params?: DatabaseListQuery | null, - options: UseDatabaseListOptions = {}, + options: UseDatabaseListOptions = {}, ): UseDatabaseListResult { const enabled = (options.enabled ?? true) && params !== null; // Encode only an enabled read: a held read's params may still be incomplete. @@ -95,7 +91,6 @@ export function useDatabaseList( enabled, accept: isDatabaseListPage, keepPreviousData: options.keepPreviousData ?? false, - shape: options.shape, }); return read as UseDatabaseListResult; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-read.ts b/packages/appkit-ui/src/react/hooks/use-database-read.ts index 1f69e7abe..fe5cefc63 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-read.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-read.ts @@ -23,49 +23,8 @@ import { subscribeDatabaseRead, } from "./database-request-store"; -declare const DATABASE_SHAPE: unique symbol; - -/** - * Phantom marker for the row a read serializer returns. It only carries a - * type; build one with {@link serialized}. - */ -export interface DatabaseShape { - readonly [DATABASE_SHAPE]: T; -} - -const SERIALIZED = Object.freeze({}); - -/** - * Declare, without checking, the row a read serializer returns. The entity, - * id, and params stay checked against the generated registry; only the row - * type is replaced. It is a promise about the server's serializer, not a - * runtime check: to check each row, pass a parse function as `shape` instead. - * - * @example - * ```typescript - * interface NoteCard { id: number; excerpt: string } - * - * const notes = useDatabaseList("notes", { limit: 20 }, { - * shape: serialized(), - * }); - * notes.data?.items[0]?.excerpt; - * ``` - */ -export function serialized(): DatabaseShape { - return SERIALIZED as DatabaseShape; -} - -/** - * The row a read answers with: `serialized()` to declare it, or a function - * that checks one decoded row and returns it typed, such as a zod schema's - * `parse`. A function that throws fails the read with `INTERNAL`. - */ -export type DatabaseRowShape = - | DatabaseShape - | ((row: unknown) => Row); - /** Options shared by `useDatabaseList` and `useDatabaseRecord`. */ -export interface UseDatabaseReadOptions { +export interface UseDatabaseReadOptions { /** Send the request. `false` keeps the hook idle and sends nothing. Default true. */ enabled?: boolean; /** @@ -74,12 +33,6 @@ export interface UseDatabaseReadOptions { * stays true until the new response arrives. Default false. */ keepPreviousData?: boolean; - /** - * The row a read serializer returns. `serialized()` only declares it; a - * function checks every row at runtime (each list item, or the record) and - * its return value becomes the row. It runs once per response. - */ - shape?: DatabaseRowShape; } /** Latest state of one database read. */ @@ -94,8 +47,8 @@ export interface UseDatabaseReadResult { loading: boolean; /** * Why the latest request failed: `NOT_EXPOSED` when no route exists, - * `INVALID_REQUEST` for params or an id that cannot be sent, `INTERNAL` - * when a `shape` function rejected a row. Stable across renders. + * `INVALID_REQUEST` for params or an id that cannot be sent. Stable across + * renders. */ error: DatabaseApiError | null; /** Abort any in-flight request and send it again. No-op while disabled. */ @@ -114,72 +67,12 @@ export interface DatabaseReadRequest { enabled: boolean; accept: (body: unknown) => body is object; keepPreviousData: boolean; - shape: DatabaseRowShape | undefined; } type Route = { url: string } | { error: DatabaseApiError } | null; const noop = () => {}; -/** Apply a `shape` function to each row of one response. */ -function shapeResponse( - operation: DatabaseReadRequest["operation"], - data: unknown, - parse: (row: unknown) => unknown, -): unknown { - if (operation === "detail") return parse(data); - const page = data as { items: unknown[] }; - return { ...page, items: page.items.map((row) => parse(row)) }; -} - -/** The shaped response, or why a row failed its `shape`. */ -interface Shaped { - source: unknown; - data: unknown; - error: DatabaseApiError | null; -} - -/** - * Run a `shape` function once per response. The result is cached on the - * response's identity, not the function's, so an inline arrow does not - * reparse on every render or give `data` a new identity. - */ -function useShapedData( - operation: DatabaseReadRequest["operation"], - source: unknown, - shape: DatabaseRowShape | undefined, -): Shaped { - const cache = useRef(null); - if (typeof shape !== "function" || source === null) { - return { source, data: source, error: null }; - } - const cached = cache.current; - if (cached !== null && cached.source === source) return cached; - let shaped: Shaped; - try { - shaped = { - source, - data: shapeResponse(operation, source, shape), - error: null, - }; - } catch (cause) { - // A schema error may quote row values; keep it in `cause` only. - shaped = { - source, - data: null, - error: new DatabaseApiError( - "INTERNAL", - null, - "Database response does not match the read's shape", - [], - { cause }, - ), - }; - } - cache.current = shaped; - return shaped; -} - /** * One subscribed read, keyed by the resolved URL so params with equal encoded * values share a request whatever their object identity. A route the server @@ -195,7 +88,6 @@ export function useDatabaseRead({ enabled, accept, keepPreviousData, - shape, }: DatabaseReadRequest): UseDatabaseReadResult { const encodedQuery = typeof query === "string" ? query : null; // Encoding runs every render; only the safe failure fields define its identity. @@ -251,22 +143,20 @@ export function useDatabaseRead({ if (url !== null) startDatabaseRead(url); }, [url]); - const shaped = useShapedData(operation, snapshot.data, shape); - // Until the effect retains a new URL, its request is about to start. const loading = snapshot.loading || (url !== null && snapshot === IDLE_DATABASE_READ); - const error = routeError ?? snapshot.error ?? shaped.error; + const error = routeError ?? snapshot.error; // The last data this hook showed for a URL, to hold across a params change. const previous = useRef<{ url: string; data: unknown } | null>(null); useEffect(() => { - if (url !== null && shaped.data !== null) { - previous.current = { url, data: shaped.data }; + if (url !== null && snapshot.data !== null) { + previous.current = { url, data: snapshot.data }; } - }, [url, shaped.data]); + }, [url, snapshot.data]); - let data = shaped.data; + let data = snapshot.data; if ( keepPreviousData && data === null && diff --git a/packages/appkit-ui/src/react/hooks/use-database-record.ts b/packages/appkit-ui/src/react/hooks/use-database-record.ts index e8b280aa8..cb87e981e 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-record.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-record.ts @@ -20,7 +20,7 @@ import { } from "./use-database-read"; /** Options for {@link useDatabaseRecord}. */ -export type UseDatabaseRecordOptions = UseDatabaseReadOptions; +export type UseDatabaseRecordOptions = UseDatabaseReadOptions; /** What {@link useDatabaseRecord} returns: one row and its state. */ export type UseDatabaseRecordResult = UseDatabaseReadResult; @@ -37,7 +37,7 @@ export type UseDatabaseRecordResult = UseDatabaseReadResult; * @param entity - A table the generated registry exposes with a public key * @param id - The row's public key, or `null`/`undefined` to wait * @param params - `select` and `include`; pass `{}` to reach `options` alone - * @param options - `enabled`, `keepPreviousData`, and `shape` + * @param options - `enabled` and `keepPreviousData` * @returns The row, loading and error state, and `refetch` * * @example @@ -51,13 +51,12 @@ export type UseDatabaseRecordResult = UseDatabaseReadResult; export function useDatabaseRecord< K extends DatabaseKeyedEntity, const P extends DatabaseRecordParams = Record, - Row = DatabaseRecordRow, >( entity: K, id: DatabaseId | null | undefined, params?: P & ExactDatabaseParams>, - options: UseDatabaseRecordOptions = {}, -): UseDatabaseRecordResult { + options: UseDatabaseRecordOptions = {}, +): UseDatabaseRecordResult> { const enabled = (options.enabled ?? true) && id !== null && id !== undefined; const recordParams = (params ?? {}) as { include?: unknown }; // Encode only an enabled read: a held read's params may still be incomplete. @@ -78,8 +77,7 @@ export function useDatabaseRecord< enabled, accept: isDatabaseRow, keepPreviousData: options.keepPreviousData ?? false, - shape: options.shape as UseDatabaseReadOptions["shape"], }); // The server projected and encoded the row; the types describe that wire. - return read as UseDatabaseRecordResult; + return read as UseDatabaseRecordResult>; } diff --git a/packages/appkit-ui/src/react/hooks/use-database-write.ts b/packages/appkit-ui/src/react/hooks/use-database-write.ts index d4552865d..8e977d46a 100644 --- a/packages/appkit-ui/src/react/hooks/use-database-write.ts +++ b/packages/appkit-ui/src/react/hooks/use-database-write.ts @@ -30,9 +30,9 @@ export interface UseDatabaseWriteState { export interface UseDatabaseWriteOptions { /** * Reads to restart once a write succeeds. Default `true`: every mounted - * database read, since a server hook may write other tables in the same - * transaction. A table list restarts only the reads that show those tables, - * directly or through an include; `false` restarts none. + * database read, since a foreign key declared with `onDelete("cascade")` can + * change other tables too. A table list restarts only the reads that show + * those tables, directly or through an include; `false` restarts none. */ invalidate?: DatabaseInvalidation; } diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 03d574653..d1a939570 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -127,12 +127,6 @@ export type { DatabaseApiWriteOperation, DatabaseApiWritesConfig, DatabaseExports, - EntityHooks, - EntityMutationHooks, - HookApp, - HookContext, IDatabaseConfig, - ReadSerializer, - ReadSerializerContext, TransactionClient, } from "./plugins/database"; diff --git a/packages/appkit/src/database/schema-builder/define-schema.ts b/packages/appkit/src/database/schema-builder/define-schema.ts index 64ed5ff2d..0970a5f35 100644 --- a/packages/appkit/src/database/schema-builder/define-schema.ts +++ b/packages/appkit/src/database/schema-builder/define-schema.ts @@ -341,7 +341,7 @@ export function assertFinalizedSchema(value: unknown): asserts value is Schema { /** * Compile one declared schema. The returned type keeps the table names the - * builder returned, so `api.tables` and `hooks` can name only real tables. + * builder returned, so `api.tables` can name only real tables. */ export function defineSchema>( builder: (context: SchemaBuilderContext) => TTables, diff --git a/packages/appkit/src/errors/database-validation.ts b/packages/appkit/src/errors/database-validation.ts index 49a61a9cd..9bbd2bb99 100644 --- a/packages/appkit/src/errors/database-validation.ts +++ b/packages/appkit/src/errors/database-validation.ts @@ -10,9 +10,10 @@ export interface DatabaseValidationIssue { } /** - * Deliberate validation failure raised by a database mutation hook. Generated - * routes answer `422` and echo only the issues naming a public column; every - * other failure raised inside a hook stays an opaque server error. + * Deliberate validation failure. An explicit transaction callback can throw it, + * and the transaction rethrows that same error. Generated routes answer `422` + * and echo only the issues naming a public column. Every other failure stays + * an opaque server error. */ export class DatabaseValidationError extends AppKitError { readonly code = "DATABASE_VALIDATION_ERROR"; diff --git a/packages/appkit/src/plugins/database/crud/contract.ts b/packages/appkit/src/plugins/database/crud/contract.ts index 2a23afba4..b2e5f0a24 100644 --- a/packages/appkit/src/plugins/database/crud/contract.ts +++ b/packages/appkit/src/plugins/database/crud/contract.ts @@ -27,7 +27,6 @@ export interface CrudTable { readonly updatable: ReadonlySet; readonly relations: ReadonlyMap; projectPublicRow(row: Row): JsonValue; - sanitizeSerializedRow(row: unknown): JsonValue; } type MutableCrudTable = Omit & { @@ -49,16 +48,14 @@ export function isPlainObject( return hasPlainObjectPrototype(value); } -/** A serializer that breaks its contract is trusted code failing, not input. */ -function serializerFault(): never { - throw new DatabasePluginError("INTERNAL", "read"); +function rejectUnboundedJson(): never { + throw new DatabasePluginError("INVALID_REQUEST", "write"); } -/** Charge one value against the output budget before descending into it. */ function countNode(depth: number, state: SanitizeState): void { state.nodes += 1; if (state.nodes > MAX_SERIALIZED_NODES || depth > MAX_SERIALIZED_DEPTH) { - serializerFault(); + rejectUnboundedJson(); } } @@ -68,7 +65,7 @@ function enterObject( state: SanitizeState, visit: () => T, ): T { - if (state.ancestors.has(value)) serializerFault(); + if (state.ancestors.has(value)) rejectUnboundedJson(); state.ancestors.add(value); try { return visit(); @@ -77,7 +74,7 @@ function enterObject( } } -/** Accept a serializer's own added value only where it is already JSON. */ +/** Accept caller JSON only where it is already a finite JSON value. */ function sanitizeJson( value: unknown, depth: number, @@ -92,7 +89,7 @@ function sanitizeJson( return value; } if (typeof value === "number") { - if (!Number.isFinite(value)) serializerFault(); + if (!Number.isFinite(value)) rejectUnboundedJson(); return value; } if (Array.isArray(value)) { @@ -100,7 +97,7 @@ function sanitizeJson( value.map((item) => sanitizeJson(item, depth + 1, state)), ); } - if (!isPlainObject(value)) serializerFault(); + if (!isPlainObject(value)) rejectUnboundedJson(); return enterObject(value, state, () => { // A null prototype keeps `__proto__` an ordinary key instead of a setter. const out: Record = Object.create(null); @@ -112,45 +109,7 @@ function sanitizeJson( }); } -/** Keep an included row under its own table's policy, one row or many. */ -function sanitizeRelation( - target: CrudTable, - value: unknown, - depth: number, - state: SanitizeState, -): JsonValue { - if (value === null) return null; - if (!Array.isArray(value)) return sanitizeRow(target, value, depth, state); - countNode(depth, state); - return enterObject(value, state, () => - value.map((row) => sanitizeRow(target, row, depth + 1, state)), - ); -} - -/** Re-apply the private-column policy wherever the output stays contracted. */ -function sanitizeRow( - table: CrudTable, - row: unknown, - depth: number, - state: SanitizeState, -): JsonValue { - countNode(depth, state); - if (!isPlainObject(row)) serializerFault(); - return enterObject(row, state, () => { - const out: Record = {}; - for (const [key, child] of Object.entries(row)) { - if (child === undefined) continue; - if (table.columns.get(key)?.meta.isPrivate) continue; - const relation = table.relations.get(key); - out[key] = relation - ? sanitizeRelation(relation.target, child, depth + 1, state) - : sanitizeJson(child, depth + 1, state); - } - return out; - }); -} - -/** The budget a caller's own JSON has to fit, same as the one going back out. */ +/** Copy finite acyclic JSON, bounded by depth and node count. */ export function boundedJson(value: unknown): JsonValue { return sanitizeJson(value, 0, { nodes: 0, ancestors: new Set() }); } @@ -210,8 +169,6 @@ function compileTable(table: AppKitTable): MutableCrudTable { updatable, relations: new Map(), projectPublicRow: (row) => projectRow(compiled, row), - sanitizeSerializedRow: (row) => - sanitizeRow(compiled, row, 0, { nodes: 0, ancestors: new Set() }), }; return compiled; } diff --git a/packages/appkit/src/plugins/database/crud/request.ts b/packages/appkit/src/plugins/database/crud/request.ts index 2a59cd213..39336de6c 100644 --- a/packages/appkit/src/plugins/database/crud/request.ts +++ b/packages/appkit/src/plugins/database/crud/request.ts @@ -32,7 +32,6 @@ function decodeWriteValue( if (raw === null) return column.meta.notNull ? undefined : null; if (column.meta.kind !== "json") return column.decode(raw); try { - // JSON columns accept any JSON the response budget can carry back. return boundedJson(raw); } catch { return undefined; diff --git a/packages/appkit/src/plugins/database/crud/response.ts b/packages/appkit/src/plugins/database/crud/response.ts index 5ad92f59b..943616d17 100644 --- a/packages/appkit/src/plugins/database/crud/response.ts +++ b/packages/appkit/src/plugins/database/crud/response.ts @@ -79,9 +79,9 @@ export function sendEmpty(res: Response, status: number): void { } /** - * Convert a failure into its safe category. A hook's deliberate validation - * error is the one signal that reaches the caller, and only through the issues - * naming a public column of this table. + * Convert a failure into its safe category. A deliberate validation error is + * the one signal that reaches the caller, and only through the issues naming a + * public column of this table. */ function safeError( table: CrudTable, diff --git a/packages/appkit/src/plugins/database/crud/routes.ts b/packages/appkit/src/plugins/database/crud/routes.ts index 636b3c2d0..cc164d4de 100644 --- a/packages/appkit/src/plugins/database/crud/routes.ts +++ b/packages/appkit/src/plugins/database/crud/routes.ts @@ -11,8 +11,6 @@ import type { Row, WhereClause, } from "../../../database/runtime"; -import type { ReadSerializer } from "../types"; -import type { JsonValue } from "./codecs"; import type { CrudTable } from "./contract"; import { decodeDetailQuery, decodeListQuery } from "./query"; import { decodeCreateBody, decodeId, decodeUpdateBody } from "./request"; @@ -41,7 +39,6 @@ export interface CrudRouteDeps { readonly table: CrudTable; /** Resolved per request so a draining plugin cannot serve a stale client. */ entity(): CrudEntity; - readonly serialize?: ReadSerializer; runRouteSpan( operation: CrudOperation, route: string, @@ -78,20 +75,6 @@ function stableOrder( return order; } -function serializeRow( - deps: CrudRouteDeps, - operation: "list" | "detail", - row: Row, -): JsonValue { - const projected = deps.table.projectPublicRow(row); - if (!deps.serialize) return projected; - const shaped = deps.serialize(projected as Record, { - entity: deps.table.name, - operation, - }); - return deps.table.sanitizeSerializedRow(shaped); -} - /** * A write takes its whole input from the path and the body. A query string * would silently look like a filter, so it is refused rather than ignored. @@ -131,7 +114,7 @@ export function createListHandler(deps: CrudRouteDeps): RouteHandler { sendListPage( res, rows, - (row) => serializeRow(deps, "list", row), + (row) => deps.table.projectPublicRow(row), decoded.limit, decoded.offset, ); @@ -157,7 +140,7 @@ export function createDetailHandler(deps: CrudRouteDeps): RouteHandler { const row = await query.find(id); if (row === null) throw new DatabasePluginError("NOT_FOUND", "read"); - sendJson(res, 200, serializeRow(deps, "detail", row)); + sendJson(res, 200, deps.table.projectPublicRow(row)); }); } catch (error) { sendError(res, deps.table, "read", error); @@ -165,10 +148,7 @@ export function createDetailHandler(deps: CrudRouteDeps): RouteHandler { }; } -/** - * `POST /:table` — the created row at `201`. A mutation answers the row the - * database actually holds, so a read serializer never reshapes it. - */ +/** `POST /:table` — the created row at `201`. */ export function createCreateHandler(deps: CrudRouteDeps): RouteHandler { const route = `/${deps.table.name}`; diff --git a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts index 92e5325d9..dfce58109 100644 --- a/packages/appkit/src/plugins/database/crud/tests/contract.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/contract.test.ts @@ -9,8 +9,8 @@ import { timestamp, uuid, } from "../../../../database/schema-builder"; -import { MAX_SERIALIZED_DEPTH } from "../../defaults"; -import { compileCrudTables } from "../contract"; +import { MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES } from "../../defaults"; +import { boundedJson, compileCrudTables } from "../contract"; const schema = defineSchema((builder) => { const users = builder.table("users", { @@ -128,45 +128,44 @@ describe("projectPublicRow", () => { }); }); -describe("sanitizeSerializedRow", () => { - it("re-applies the private-column policy to serializer output", () => { - const sanitized = users.sanitizeSerializedRow({ - id: 1, - token: "leaked", - computed: { label: "Ada", tags: ["a", "b"] }, - skipped: undefined, - notes: [{ id: 2, draft: "leaked", body: "hello" }], - }); - expect(sanitized).toEqual({ - id: 1, - computed: { label: "Ada", tags: ["a", "b"] }, - notes: [{ id: 2, body: "hello" }], - }); +describe("boundedJson", () => { + it("keeps finite JSON and drops undefined object entries", () => { + expect( + boundedJson({ + id: 1, + label: "Ada", + tags: ["a", "b"], + skipped: undefined, + }), + ).toEqual({ id: 1, label: "Ada", tags: ["a", "b"] }); }); - it("treats a broken serializer as an internal fault", () => { + it("rejects values that are not bounded JSON", () => { const cyclic: Record = { id: 1 }; cyclic.self = cyclic; let deep: Record = {}; for (let level = 0; level <= MAX_SERIALIZED_DEPTH; level += 1) { deep = { deep }; } + const wide = Array.from({ length: MAX_SERIALIZED_NODES }, () => 1); for (const broken of [ cyclic, deep, - "not-an-object", + wide, { id: Number.POSITIVE_INFINITY }, { at: new Date() }, ]) { - expect(() => users.sanitizeSerializedRow(broken)).toThrow( - expect.objectContaining({ category: "INTERNAL" }), + expect(() => boundedJson(broken)).toThrow( + expect.objectContaining({ + category: "INVALID_REQUEST", + phase: "write", + }), ); } }); it("carries a __proto__ key as data rather than dropping it", () => { - const sanitized = users.sanitizeSerializedRow({ - id: 1, + const sanitized = boundedJson({ computed: JSON.parse('{"__proto__":{"owned":true},"keep":1}'), }) as { computed: unknown }; diff --git a/packages/appkit/src/plugins/database/crud/tests/request.test.ts b/packages/appkit/src/plugins/database/crud/tests/request.test.ts index d43c3dc6f..8a06739cb 100644 --- a/packages/appkit/src/plugins/database/crud/tests/request.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/request.test.ts @@ -136,7 +136,10 @@ describe("decodeCreateBody / decodeUpdateBody", () => { deep = { deep }; } expect(() => decodeCreateBody(users, { profile: deep })).toThrow( - expect.objectContaining({ category: "INVALID_REQUEST" }), + expect.objectContaining({ + category: "INVALID_REQUEST", + details: [{ path: ["profile"], message: expect.any(String) }], + }), ); }); }); 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 87385c747..5dda67821 100644 --- a/packages/appkit/src/plugins/database/crud/tests/routes.test.ts +++ b/packages/appkit/src/plugins/database/crud/tests/routes.test.ts @@ -13,7 +13,6 @@ import { import { DatabaseValidationError } from "../../../../errors"; import { MAX_RESPONSE_BYTES } from "../../defaults"; import type { EntityClient } from "../../entity-client"; -import type { ReadSerializer } from "../../types"; import { type CrudTable, compileCrudTables } from "../contract"; import { type CrudEntity, @@ -144,15 +143,10 @@ function writeRequest( } as unknown as Request; } -function deps( - table: string, - entity: CrudEntity, - serialize?: ReadSerializer, -): CrudRouteDeps { +function deps(table: string, entity: CrudEntity): CrudRouteDeps { return { table: tables.get(table) as CrudTable, entity: () => entity, - serialize, runRouteSpan: (_operation, _route, run) => run(), }; } @@ -297,86 +291,41 @@ describe("detail route", () => { }); }); -describe("serialization and response limits", () => { - it("keeps the serializer contract synchronous", () => { - // @ts-expect-error a serializer may not defer work into the response path - const deferred: ReadSerializer = async (row) => row; - expect(deferred).toBeTypeOf("function"); - }); - - it("applies a synchronous serializer and re-checks private columns", async () => { - const serialize = vi.fn((row) => ({ - ...row, - label: `#${row.id}`, - token: "reintroduced", - })); - const entity = fakeEntity([{ id: 1, name: "Ada", token: "secret" }]); - await createListHandler(deps("users", entity, serialize))( - request("/users"), - response.res, - ); - expect(serialize).toHaveBeenCalledWith( - { id: 1, name: "Ada" }, - { entity: "users", operation: "list" }, - ); - expect(response.json().items).toEqual([ - { id: 1, name: "Ada", label: "#1" }, - ]); - }); - - it("maps a broken serializer to an opaque server error", async () => { - const entity = fakeEntity([{ id: 1, name: "Ada" }]); - const throwing = vi.fn(() => { - throw new Error("boom: select * from users"); - }); - await createListHandler(deps("users", entity, throwing))( - request("/users"), - response.res, - ); - expect(response.sent.status).toBe(500); - expect(response.json()).toEqual({ error: "Database operation failed" }); - }); - - it("lets a list serializer replace an include with a summary", async () => { +describe("projection and response limits", () => { + it("projects public columns and included rows", async () => { const entity = fakeEntity([ { id: 1, name: "Ada", - token: "private", - notes: [{ id: 2, body: "hello" }], + token: "secret", + notes: [{ id: 2, body: "hello", draft: "hidden" }], }, ]); - 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); + await createListHandler(deps("users", entity))( + request("/users?include=%7B%22notes%22%3Atrue%7D"), + response.res, + ); expect(response.json().items).toEqual([ - { id: 1, name: "Ada", note_count: 1 }, + { id: 1, name: "Ada", notes: [{ id: 2, body: "hello" }] }, ]); }); - it("lets a detail serializer regroup the row into a different view", async () => { + it("projects one detail row without its private columns", async () => { const entity = fakeEntity([], { id: 1, name: "Ada", token: "private", - notes: [{ id: 2, body: "hello" }], + notes: [{ id: 2, body: "hello", draft: "hidden" }], }); - 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); - + await createDetailHandler(deps("users", entity))( + 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], + id: 1, + name: "Ada", + notes: [{ id: 2, body: "hello" }], }); }); @@ -398,18 +347,26 @@ describe("serialization and response limits", () => { it("stops shaping rows once the byte budget is exceeded", async () => { const wide = "x".repeat(2 * 1024 * 1024); - const rows = Array.from({ length: 8 }, (_, index) => ({ - id: index, - name: wide, - })); - const serialize = vi.fn((row) => row); - await createListHandler(deps("users", fakeEntity(rows), serialize))( + let touchedSentinel = false; + const rows = [ + ...Array.from({ length: 4 }, (_, index) => ({ id: index, name: wide })), + { + id: 99, + get name(): string { + touchedSentinel = true; + return "sentinel"; + }, + }, + ]; + await createListHandler(deps("users", fakeEntity(rows)))( request("/users"), response.res, ); expect(response.sent.status).toBe(413); - // Rows past the budget are never projected, serialized, or encoded. - expect(serialize.mock.calls.length).toBeLessThan(rows.length); + expect(response.json()).toEqual({ + error: "Database response is too large", + }); + expect(touchedSentinel).toBe(false); }); it("maps an entity failure to its safe category", async () => { @@ -441,17 +398,6 @@ describe("write routes", () => { expect(response.sent.headers["Cache-Control"]).toBe("no-store"); }); - it("never reshapes a mutation response with a read serializer", async () => { - const serialize = vi.fn((row) => ({ ...row, extra: true })); - const entity = fakeEntity([]); - await createCreateHandler(deps("users", entity, serialize))( - writeRequest("/users", { name: "Ada" }), - response.res, - ); - expect(serialize).not.toHaveBeenCalled(); - expect(response.json()).toEqual({ id: 1, name: "Ada" }); - }); - it("updates a row at 200 and deletes one at 204", async () => { const entity = fakeEntity([], { id: 7, name: "Ada", token: "secret" }); await createUpdateHandler(deps("users", entity))( @@ -560,7 +506,7 @@ describe("write routes", () => { expect(entity.calls.create).toBeUndefined(); }); - it("answers a hook's validation failure with 422 and its public issues", async () => { + it("answers a validation failure with 422 and its public issues", async () => { const entity = fakeEntity([]); entity.create = async () => { throw new DatabaseValidationError("rejected", [ diff --git a/packages/appkit/src/plugins/database/database.ts b/packages/appkit/src/plugins/database/database.ts index ff4a49ac4..32b09def9 100644 --- a/packages/appkit/src/plugins/database/database.ts +++ b/packages/appkit/src/plugins/database/database.ts @@ -28,11 +28,7 @@ import type { DatabaseExports } from "./entity-types"; import { createDatabaseState, type DatabaseState } from "./lifecycle"; import { loadDefaultDatabaseSchema } from "./load-schema"; import manifest from "./manifest.json"; -import type { - DatabaseHooks, - DefaultDatabaseSchema, - IDatabaseConfig, -} from "./types"; +import type { DefaultDatabaseSchema, IDatabaseConfig } from "./types"; /** Schema-driven database plugin */ export class DatabasePlugin< @@ -55,7 +51,6 @@ export class DatabasePlugin< this.config = { schema: config.schema, api: config.api, - hooks: config.hooks, }; } @@ -80,18 +75,9 @@ export class DatabasePlugin< this.config.api, Object.keys(schema.$tables), ); - // A hook key naming no declared table would silently never run. - for (const name of Object.keys(this.hooks() ?? {})) { - if (!Object.hasOwn(schema.$tables, name)) { - throw databaseSetupFailed( - `hooks names undeclared table ${JSON.stringify(name)}. Use a table declared in schema.`, - ); - } - } const candidate = await createDatabaseState( schema, (operation, options) => this.execute(operation, options), - this.hooks(), ); if (this.draining) { // Setup may finish while shutdown is waiting; never publish that state. @@ -126,7 +112,6 @@ export class DatabasePlugin< injectRoutes(router: express.Router): void { if (this.exposure.tables.length === 0) return; const tables = this.exposedTables(); - const hooks = this.hooks(); // Every exposed name is a declared table, so its export is an entity client. const entities = () => this.exports() as unknown as Record; @@ -135,7 +120,6 @@ export class DatabasePlugin< const deps: CrudRouteDeps = { table, entity: () => entities()[table.name], - serialize: hooks?.[table.name]?.serialize, runRouteSpan: (operation, route, run) => this.runRouteSpan(table.name, operation, route, run), }; @@ -221,7 +205,7 @@ export class DatabasePlugin< /** * Publish `{ relations: { table: { relation: targetTable } } }` so the React - * hooks can tell which reads a write affects: a `boards` read that includes + * clients can tell which reads a write affects: a `boards` read that includes * `notes` shows notes rows. Only edges between exposed tables exist, and * they are the names `include` already accepts, so this reveals nothing the * routes and the generated types do not. @@ -245,11 +229,6 @@ export class DatabasePlugin< return Object.keys(relations).length > 0 ? { relations } : {}; } - /** Typed hook keys are schema table names, which routing addresses at runtime. */ - private hooks(): DatabaseHooks | undefined { - return this.config.hooks as DatabaseHooks | undefined; - } - /** Return the typed database API only while the plugin is active. */ exports() { if (!this.state || this.draining) diff --git a/packages/appkit/src/plugins/database/defaults.ts b/packages/appkit/src/plugins/database/defaults.ts index b91fb2242..2a473ce84 100644 --- a/packages/appkit/src/plugins/database/defaults.ts +++ b/packages/appkit/src/plugins/database/defaults.ts @@ -23,7 +23,7 @@ export const STATEMENT_TIMEOUT_MS = 30_000; /** Server-side limit for an idle open transaction, independent of JS timers (ms). */ export const IDLE_IN_TRANSACTION_TIMEOUT_MS = 30_000; -/** Deadline for the transaction callback, not cancellation of arbitrary hook work (ms). */ +/** Deadline for one explicit transaction callback (ms). */ export const TRANSACTION_TIMEOUT_MS = 30_000; // Generated-read limits. The wire caps a typed caller shares with HTTP live in @@ -45,10 +45,9 @@ export const MAX_OFFSET = 10_000; /** Max rows one generated read may materialize across its include tree. */ export const MAX_MATERIALIZED_NODES = 10_000; -// Response shaping: bounds trusted output on its way to the wire. -/** Max nesting depth of read-serializer output. */ +/** Max nesting depth of one bounded JSON value. */ export const MAX_SERIALIZED_DEPTH = 32; -/** Max node count of read-serializer output. */ +/** Max node count of one bounded JSON value. */ export const MAX_SERIALIZED_NODES = 10_000; /** Max UTF-8 byte length of one generated read response body. */ export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; diff --git a/packages/appkit/src/plugins/database/entity-client.ts b/packages/appkit/src/plugins/database/entity-client.ts index 4e81d7235..25adcbee3 100644 --- a/packages/appkit/src/plugins/database/entity-client.ts +++ b/packages/appkit/src/plugins/database/entity-client.ts @@ -21,12 +21,9 @@ import { validateOffset, } from "../../database/runtime/data-path"; import type { AppKitTable } from "../../database/schema-builder"; -import { DatabaseValidationError } from "../../errors"; import type { ExecutionResult } from "../../plugin/execution-result"; import { databaseReadDefaults, databaseWriteDefaults } from "./defaults"; -import type { HookContext, MutationOperation } from "./hooks"; -import type { MutationScope } from "./scope"; -import type { EntityHooks } from "./types"; +import type { TransactionScope } from "./scope"; /** Apply AppKit execution policy to an entity operation. */ export type EntityExecute = ( @@ -40,21 +37,11 @@ export interface EntityClientContext { readonly getDataPath: () => DataPath; readonly execute: EntityExecute; readonly assertActive: () => void; - readonly scope: MutationScope; - readonly hooks?: EntityHooks; + readonly scope: TransactionScope; /** True once this client is bound to an already open transaction. */ readonly transactionBound: boolean; - /** Continue on the same table's client inside a new or reused transaction. */ - readonly runInTransaction: ( - run: (entity: EntityClient) => Promise, - ) => Promise; -} - -/** Internal rollback signal for a hooked keyed mutation that matched no row. */ -class MutationNoMatchError extends DatabasePluginError { - constructor() { - super("NOT_FOUND", "write"); - } + /** The table's entity on the transaction that is already open. */ + readonly activeEntity: () => EntityClient | undefined; } /** Clone plain acyclic input so caller mutation cannot change a built query. */ @@ -101,11 +88,6 @@ function validateReadInput(validate: () => T): T { } } -/** A hook replaced the payload only if it returned an object to persist. */ -function isReplacement(value: unknown): value is Row { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** * Plugin-facing client that composes immutable query state. The DataPath remains * the schema-aware validation and execution boundary. @@ -192,160 +174,39 @@ export class EntityClient { async create(values: Row): Promise { this.assertUnfiltered("create"); - const hooks = this.ctx.hooks; - const insert = (payload: Row, byHook = false) => - this.write( - "create", - payload, - (dataPath, parsed) => dataPath.insert(this.ctx.table, parsed), - byHook, - ); - if (this.isDirect("create")) { - return this.directMutation("create", () => insert(values)); - } - return this.mutate( - "create", - (entity) => entity.create(values), - async (context) => { - const payload = await this.runBefore( - () => hooks?.beforeCreate?.(values, context), - values, - ); - const row = await insert(payload, payload !== values); - await this.runHook(() => hooks?.afterCreate?.(row, context)); - return row; - }, + return this.mutate((entity) => + entity.write("create", values, (dataPath, parsed) => + dataPath.insert(this.ctx.table, parsed), + ), ); } update(id: IdValue, values: Row): Promise { - return this.updateWithNoMatch(id, values, new MutationNoMatchError()); - } - - private async updateWithNoMatch( - id: IdValue, - values: Row, - noMatch: MutationNoMatchError, - ): Promise { - const hooks = this.ctx.hooks; - const apply = (payload: Row, byHook = false) => - this.write( - "update", - payload, - (dataPath, parsed) => - dataPath.update(this.ctx.table, id, parsed, this.state.where), - byHook, - ); - if (this.isDirect("update")) { - return this.directMutation("update", () => apply(values)); - } - try { - return await this.mutate( - "update", - (entity) => entity.updateWithNoMatch(id, values, noMatch), - async (context) => { - const payload = await this.runBefore( - () => hooks?.beforeUpdate?.(id, values, context), - values, - ); - const row = await apply(payload, payload !== values); - if (row === null) throw noMatch; - await this.runHook(() => hooks?.afterUpdate?.(row, context)); - return row; - }, - ); - } catch (error) { - if ( - error === noMatch && - !this.ctx.transactionBound && - !this.ctx.scope.activeTransaction() - ) { - return null; - } - throw error; - } + return this.mutate((entity) => + entity.write("update", values, (dataPath, parsed) => + dataPath.update(this.ctx.table, id, parsed, entity.state.where), + ), + ); } async upsert(values: Row, options: { onConflict: string }): Promise { this.assertUnfiltered("upsert"); - const hooks = this.ctx.hooks; const onConflict = options.onConflict; - const apply = (payload: Row, byHook = false) => - this.write( - "create", - payload, - (dataPath, parsed) => - dataPath.upsert(this.ctx.table, parsed, onConflict), - byHook, - ); - if (this.isDirect("upsert")) { - return this.directMutation("upsert", () => apply(values)); - } - return this.mutate( - "upsert", - (entity) => entity.upsert(values, { onConflict }), - async (context) => { - const payload = await this.runBefore( - () => hooks?.beforeUpsert?.(values, context), - values, - ); - // Which branch the database took stays invisible to the hook. - const row = await apply(payload, payload !== values); - await this.runHook(() => hooks?.afterUpsert?.(row, context)); - return row; - }, + return this.mutate((entity) => + entity.write("create", values, (dataPath, parsed) => + dataPath.upsert(this.ctx.table, parsed, onConflict), + ), ); } delete(id: IdValue): Promise { - return this.deleteWithNoMatch(id, new MutationNoMatchError()); - } - - private async deleteWithNoMatch( - id: IdValue, - noMatch: MutationNoMatchError, - ): Promise { - const hooks = this.ctx.hooks; - const remove = () => - this.run( + return this.mutate((entity) => + entity.run( "write", - (dataPath) => dataPath.delete(this.ctx.table, id, this.state.where), + (dataPath) => dataPath.delete(this.ctx.table, id, entity.state.where), databaseWriteDefaults, - ); - if (this.isDirect("delete")) { - return this.directMutation("delete", remove); - } - try { - return await this.mutate( - "delete", - (entity) => entity.deleteWithNoMatch(id, noMatch), - async (context) => { - await this.runHook(() => hooks?.beforeDelete?.(id, context)); - const deleted = await remove(); - if (!deleted) throw noMatch; - await this.runHook(() => hooks?.afterDelete?.(id, context)); - return true; - }, - ); - } catch (error) { - if ( - error === noMatch && - !this.ctx.transactionBound && - !this.ctx.scope.activeTransaction() - ) { - return false; - } - throw error; - } - } - - /** Count every write that runs on an already bound transaction. */ - private directMutation( - operation: MutationOperation, - run: () => Promise, - ): Promise { - if (!this.ctx.transactionBound) return run(); - return this.ctx.scope.runMutation(this.ctx.table.$name, operation, run); + ), + ); } /** An insert matches no existing row, so a predicate could only be dropped. */ @@ -358,90 +219,24 @@ export class EntityClient { } /** - * Go straight to the DataPath only when there is nothing to sequence and no - * open transaction to join. A root client that skipped the second check would - * commit on the pool while a transaction it cannot see is still open. - */ - private isDirect(operation: MutationOperation): boolean { - if (this.hasHooks(operation)) return false; - return this.ctx.transactionBound || !this.ctx.scope.activeTransaction(); - } - - private hasHooks(operation: MutationOperation): boolean { - const hooks = this.ctx.hooks; - if (!hooks) return false; - switch (operation) { - case "create": - return Boolean(hooks.beforeCreate ?? hooks.afterCreate); - case "update": - return Boolean(hooks.beforeUpdate ?? hooks.afterUpdate); - case "upsert": - return Boolean(hooks.beforeUpsert ?? hooks.afterUpsert); - case "delete": - return Boolean(hooks.beforeDelete ?? hooks.afterDelete); - } - } - - /** - * Run one mutation atomically. A top-level call restarts itself on the - * transaction-bound client, so hooks, validation, the mutation, and its after - * hook commit or roll back together. An unhooked root mutation reaches this - * only to join an open transaction; its bound client counts the direct write - * without constructing a hook context. + * Run one write on the active transaction when one is open. A root write + * outside a transaction stays on this client. Joining clones this query onto + * the bound entity so the shared transaction client is not mutated. */ private async mutate( - operation: MutationOperation, - restart: (entity: EntityClient) => Promise, - body: (context: HookContext) => Promise, + write: (entity: EntityClient) => Promise, ): Promise { if (this.ctx.transactionBound) { - return this.ctx.scope.runMutation(this.ctx.table.$name, operation, () => - body(this.hookContext()), - ); - } - try { - return await this.ctx.runInTransaction((entity) => - // Change the execution context without losing the caller's query state - // or mutating the shared transaction client. - restart(entity.clone(this.state)), - ); - } catch (error) { - if ( - error instanceof DatabaseValidationError || - error instanceof MutationNoMatchError - ) { - throw error; + try { + this.ctx.scope.consumeTransactionOperation(); + } catch (error) { + throw classifyDatabaseError(error, "write"); } - // BEGIN and COMMIT fail outside the inner classification boundary. - throw classifyDatabaseError(error, "write"); - } - } - - /** Resolve the surface at hook time so it cannot outlive its transaction. */ - private hookContext(): HookContext { - const database = this.ctx.scope.activeTransaction(); - if (!database) throw new DatabasePluginError("INTERNAL", "write"); - return { entity: this.ctx.table.$name, app: { database } }; - } - - /** Substitute the payload a before hook returned, if it returned one. */ - private async runBefore(invoke: () => unknown, values: Row): Promise { - const replacement = await this.runHook(invoke); - if (replacement === undefined || replacement === null) return values; - if (!isReplacement(replacement)) { - throw new DatabasePluginError("INTERNAL", "write"); - } - return replacement; - } - - /** Keep a deliberate validation signal; collapse every other hook failure. */ - private async runHook(invoke: () => unknown): Promise { - try { - return await invoke(); - } catch (error) { - if (error instanceof DatabaseValidationError) throw error; - throw classifyDatabaseError(error, "write"); + return write(this); } + const active = this.ctx.activeEntity(); + if (!active) return write(this); + return active.clone(this.state).mutate(write); } /** Validate caller values against the finalized trusted-write schema. */ @@ -449,25 +244,15 @@ export class EntityClient { kind: "create" | "update", values: Row, operation: (dataPath: DataPath, values: Row) => Promise, - // A payload a hook replaced is server-authored, so blaming the caller lies. - byHook = false, ): Promise { - const rejection = byHook ? "INTERNAL" : "INVALID_REQUEST"; if (kind === "update" && Object.keys(values).length === 0) { - 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", - }, - ], - ); + throw new DatabasePluginError("INVALID_REQUEST", "write", undefined, [ + { + path: ["body"], + message: + "Update must contain at least one field; skip the call when there are no changes", + }, + ]); } let parsed: Row; try { @@ -478,7 +263,7 @@ export class EntityClient { ) as { parse(value: unknown): Row }; parsed = validator.parse(values); } catch { - throw new DatabasePluginError(rejection, "write"); + throw new DatabasePluginError("INVALID_REQUEST", "write"); } return this.run( "write", diff --git a/packages/appkit/src/plugins/database/hooks.ts b/packages/appkit/src/plugins/database/hooks.ts deleted file mode 100644 index fdeff90df..000000000 --- a/packages/appkit/src/plugins/database/hooks.ts +++ /dev/null @@ -1,73 +0,0 @@ -// biome-ignore-all lint/suspicious/noConfusingVoidType: a before hook either returns a replacement payload or nothing. - -import type { - DatabaseRegistry, - DatabaseRegistryEntry, - IdValue, -} from "../../database/contract"; -import type { Row } from "../../database/runtime"; -import type { TransactionClient } from "./entity-types"; - -/** The only capability a hook receives: entities bound to its transaction. */ -export interface HookApp { - readonly database: TransactionClient; -} - -/** Which entity is being mutated, and the surface a hook may write through. */ -export interface HookContext { - readonly entity: string; - readonly app: HookApp; -} - -// Payload facets stay untyped rows until typegen augments the registry. -type FacetOf< - TTable, - TFacet extends "row" | "insert" | "update", -> = TTable extends keyof DatabaseRegistry - ? DatabaseRegistry[TTable] extends DatabaseRegistryEntry - ? DatabaseRegistry[TTable][TFacet] - : Row - : Row; - -type MaybePromise = T | Promise; - -/** - * Mutation lifecycle for one entity. A before hook may return a replacement - * payload, which is revalidated against the trusted schema before it is - * persisted. Every hook, the mutation, and any write a hook issues through - * `ctx.app.database` share one transaction, so a rejection anywhere rolls all - * of them back. Throw `DatabaseValidationError` to answer a generated route - * with `422`; any other failure stays an opaque server error. - */ -export interface EntityMutationHooks { - beforeCreate?( - values: FacetOf, - context: HookContext, - ): MaybePromise | void>; - afterCreate?( - row: FacetOf, - context: HookContext, - ): MaybePromise; - beforeUpdate?( - id: IdValue, - values: FacetOf, - context: HookContext, - ): MaybePromise | void>; - afterUpdate?( - row: FacetOf, - context: HookContext, - ): MaybePromise; - beforeUpsert?( - values: FacetOf, - context: HookContext, - ): MaybePromise | void>; - afterUpsert?( - row: FacetOf, - context: HookContext, - ): MaybePromise; - beforeDelete?(id: IdValue, context: HookContext): MaybePromise; - afterDelete?(id: IdValue, context: HookContext): MaybePromise; -} - -/** Which mutation a hook frame belongs to. */ -export type MutationOperation = "create" | "update" | "upsert" | "delete"; diff --git a/packages/appkit/src/plugins/database/index.ts b/packages/appkit/src/plugins/database/index.ts index 5a5a88748..ba94cf581 100644 --- a/packages/appkit/src/plugins/database/index.ts +++ b/packages/appkit/src/plugins/database/index.ts @@ -1,12 +1,8 @@ export { database } from "./database"; export type { DatabaseExports, TransactionClient } from "./entity-types"; -export type { EntityMutationHooks, HookApp, HookContext } from "./hooks"; export type { DatabaseApiConfig, DatabaseApiWriteOperation, DatabaseApiWritesConfig, - EntityHooks, IDatabaseConfig, - ReadSerializer, - ReadSerializerContext, } from "./types"; diff --git a/packages/appkit/src/plugins/database/lifecycle.ts b/packages/appkit/src/plugins/database/lifecycle.ts index c50badf6f..8826a3758 100644 --- a/packages/appkit/src/plugins/database/lifecycle.ts +++ b/packages/appkit/src/plugins/database/lifecycle.ts @@ -24,8 +24,7 @@ import type { TransactionClient, } from "./entity-types"; import { assertSchemaMatchesDatabase } from "./schema-check"; -import { createMutationScope, type MutationScope } from "./scope"; -import type { DatabaseHooks } from "./types"; +import { createTransactionScope, type TransactionScope } from "./scope"; const logger = createLogger("database"); @@ -41,8 +40,7 @@ interface ExportContext { readonly getDataPath: () => DataPath; readonly execute: EntityExecute; readonly assertActive: () => void; - readonly scope: MutationScope; - readonly hooks?: DatabaseHooks; + readonly scope: TransactionScope; readonly transactionBound: boolean; } @@ -75,10 +73,8 @@ function buildTransactionClient(context: ExportContext): TransactionClient { execute: context.execute, assertActive: context.assertActive, scope: context.scope, - hooks: context.hooks?.[name], transactionBound: context.transactionBound, - runInTransaction: (run) => - runTransaction(context, (tx) => run(entityOf(tx, name))), + activeEntity: () => entityOf(context.scope.activeTransaction(), name), }); } result.sql = buildSql(context); @@ -86,7 +82,11 @@ function buildTransactionClient(context: ExportContext): TransactionClient { } /** Typegen owns the entity keys, so reaching one by table name stays dynamic. */ -function entityOf(tx: TransactionClient, name: string): EntityClient { +function entityOf( + tx: TransactionClient | undefined, + name: string, +): EntityClient | undefined { + if (!tx) return undefined; return (tx as unknown as Record)[name]; } @@ -106,9 +106,9 @@ function runWithTransactionDeadline(run: () => Promise): Promise { } /** - * Run inside one transaction, joining the active one rather than nesting: - * PostgreSQL has no nested transaction, and a savepoint would let part of a - * hooked mutation survive a rollback. + * Run inside one transaction, joining the active one rather than nesting. + * PostgreSQL has no nested transaction, and a savepoint would let part of the + * work survive a rollback. */ function runTransaction( context: ExportContext, @@ -169,7 +169,6 @@ function buildDatabaseExports(context: ExportContext): DatabaseExports { export async function createDatabaseState( schema: TSchema, execute: EntityExecute, - hooks?: DatabaseHooks, ): Promise { try { assertFinalizedSchema(schema); @@ -198,8 +197,7 @@ export async function createDatabaseState( execute, assertActive, // One scope per state keeps concurrent instances from sharing a transaction. - scope: createMutationScope(), - hooks, + scope: createTransactionScope(), transactionBound: false, }); // Do not publish exports until an authenticated statement succeeds. diff --git a/packages/appkit/src/plugins/database/scope.ts b/packages/appkit/src/plugins/database/scope.ts index 1fbe929e5..3f3eea2b0 100644 --- a/packages/appkit/src/plugins/database/scope.ts +++ b/packages/appkit/src/plugins/database/scope.ts @@ -2,103 +2,51 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { DatabasePluginError } from "../../database/errors"; import type { TransactionClient } from "./entity-types"; -import type { MutationOperation } from "./hooks"; -/** How deep hook-issued mutations may nest before the guard rejects. */ -const MAX_MUTATION_DEPTH = 8; /** Max database operations admitted by one transaction. */ export const MAX_TRANSACTION_OPERATIONS = 100; -interface MutationFrame { - readonly entity: string; - readonly operation: MutationOperation; +interface TransactionState { + readonly client: TransactionClient; + readonly budget: { used: number }; } -interface MutationBudget { - used: number; -} - -interface ScopeState { - readonly transaction?: TransactionClient; - readonly frames: readonly MutationFrame[]; - readonly budget: MutationBudget; -} - -/** Charge one operation to a transaction-owned shared budget. */ -function consumeBudget( - state: ScopeState | undefined, - phase: "transaction" | "write", -): MutationBudget { - const budget = state?.budget ?? { used: 0 }; - if (budget.used >= MAX_TRANSACTION_OPERATIONS) { - throw new DatabasePluginError("INTERNAL", phase); - } - budget.used += 1; - return budget; -} - -/** Instance-owned async context created by `createMutationScope()`. */ -export type MutationScope = ReturnType; +/** Instance-owned async context created by `createTransactionScope()`. */ +export type TransactionScope = ReturnType; /** - * Async context owned by one plugin instance. It carries the transaction, - * mutation budget, and frames that bound recursion, so neither two instances - * nor two concurrent calls can observe each other. + * Async context owned by one plugin instance. It carries the open transaction + * client and the budget that transaction shares, so neither two instances nor + * two concurrent calls can observe each other. */ -export function createMutationScope() { - const storage = new AsyncLocalStorage(); +export function createTransactionScope() { + const storage = new AsyncLocalStorage(); return { - /** The transaction a hook must join, or `undefined` outside one. */ + /** The transaction a later write must join, or `undefined` outside one. */ activeTransaction(): TransactionClient | undefined { - return storage.getStore()?.transaction; + return storage.getStore()?.client; }, - /** Publish one transaction to everything the callback awaits. */ + /** Publish one transaction client to everything the callback awaits. */ runWithTransaction( - transaction: TransactionClient, + client: TransactionClient, run: () => Promise, ): Promise { - const current = storage.getStore(); - return storage.run( - { - transaction, - frames: current?.frames ?? [], - budget: current?.budget ?? { used: 0 }, - }, - run, - ); + return storage.run({ client, budget: { used: 0 } }, run); }, - /** Charge SQL and other direct work bound to the active transaction. */ + /** + * Charge one operation to the active transaction. A root call outside a + * transaction has no shared budget to charge. + */ consumeTransactionOperation(): void { const state = storage.getStore(); - if (state?.transaction) consumeBudget(state, "transaction"); - }, - - /** Open one frame, refusing a repeated entity/operation pair or deep nesting. */ - async runMutation( - entity: string, - operation: MutationOperation, - run: () => Promise, - ): Promise { - const state = storage.getStore(); - const frames = state?.frames ?? []; - const repeated = frames.some( - (frame) => frame.entity === entity && frame.operation === operation, - ); - if (repeated || frames.length >= MAX_MUTATION_DEPTH) { - throw new DatabasePluginError("INTERNAL", "write"); + if (!state) return; + if (state.budget.used >= MAX_TRANSACTION_OPERATIONS) { + throw new DatabasePluginError("INTERNAL", "transaction"); } - const budget = consumeBudget(state, "write"); - return storage.run( - { - transaction: state?.transaction, - frames: [...frames, { entity, operation }], - budget, - }, - run, - ); + state.budget.used += 1; }, }; } 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 fca5926fd..ac561cb84 100644 --- a/packages/appkit/src/plugins/database/tests/crud.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/crud.integration.test.ts @@ -1,11 +1,11 @@ +import { mockServiceContext } from "@tools/test-helpers"; import type { Request, RequestHandler, Response } from "express"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { DataPath, Row } from "../../../database/runtime"; import { defineSchema, id, text } from "../../../database/schema-builder"; import { DatabaseValidationError } from "../../../errors"; import type { ITelemetry } from "../../../telemetry"; -import type { EntityHooks } from "../types"; const mocks = vi.hoisted(() => ({ initializeLakebasePool: vi.fn(), @@ -127,7 +127,7 @@ function fakeResponse() { }; } -async function mount(hooks: Record) { +async function mount() { const database = memoryDataPath(); mocks.initializeLakebasePool.mockResolvedValue({ end: vi.fn(async () => undefined), @@ -137,14 +137,19 @@ async function mount(hooks: Record) { const plugin = new DatabasePlugin({ schema, - hooks, }); (plugin as unknown as { telemetry: ITelemetry }).telemetry = { startActiveSpan: ( _name: string, _options: unknown, run: (span: unknown) => Promise, - ) => run({ setAttribute: () => undefined, end: () => undefined }), + ) => + run({ + setAttribute: () => undefined, + recordException: () => undefined, + setStatus: () => undefined, + end: () => undefined, + }), } as unknown as ITelemetry; await plugin.setup(); @@ -190,36 +195,41 @@ async function mount(hooks: Record) { return { plugin, database, post, patch }; } -const notesOf = (plugin: DatabasePlugin) => +const databaseOf = (plugin: DatabasePlugin) => plugin.exports() as unknown as { notes: { create(values: Row): Promise }; + transaction( + callback: (tx: { + notes: { create(values: Row): Promise }; + audits: { create(values: Row): Promise }; + }) => Promise, + ): Promise; }; -const auditingHooks: Record = { - notes: { - afterCreate: async (row, context) => { - const tx = context.app.database as unknown as { - audits: { create(values: Row): Promise }; - }; - await tx.audits.create({ action: `created:${row.id}` }); - }, - }, -}; +let context: Awaited>; -beforeEach(() => { +beforeEach(async () => { mocks.initializeLakebasePool.mockReset(); mocks.createDrizzleDb.mockReset(); mocks.createDrizzleDataPath.mockReset(); + context = await mockServiceContext(); }); -describe("generated CRUD over hooked mutations", () => { - test("commits the mutation and the hook's related write together", async () => { - const { database, post } = await mount(auditingHooks); +afterEach(() => { + context.restore(); +}); - const response = await post("/notes", { body: "hello" }); +describe("generated CRUD and explicit transactions", () => { + test("commits a note and its related audit write together", async () => { + const { plugin, database } = await mount(); - expect(response.sent.status).toBe(201); - expect(response.json()).toEqual({ id: 1, body: "hello" }); + const created = await databaseOf(plugin).transaction(async (tx) => { + const note = await tx.notes.create({ body: "hello" }); + await tx.audits.create({ action: `created:${note.id}` }); + return note; + }); + + expect(created).toEqual({ id: 1, body: "hello" }); expect(database.committed.notes).toEqual([{ id: 1, body: "hello" }]); expect(database.committed.audits).toEqual([{ id: 2, action: "created:1" }]); expect(database.log).toEqual([ @@ -230,24 +240,19 @@ describe("generated CRUD over hooked mutations", () => { ]); }); - test("rolls both writes back when the hook fails after the mutation", async () => { - const { database, post } = await mount({ - notes: { - afterCreate: async (_row, context) => { - const tx = context.app.database as unknown as { - audits: { create(values: Row): Promise }; - }; - await tx.audits.create({ action: "created" }); - throw new Error("audit sink rejected 'hello'"); - }, - }, - }); + test("rolls both writes back when the transaction callback fails", async () => { + const { plugin, database } = await mount(); - const response = await post("/notes", { body: "hello" }); + const error = await databaseOf(plugin) + .transaction(async (tx) => { + await tx.notes.create({ body: "hello" }); + await tx.audits.create({ action: "created" }); + throw new Error("audit sink rejected 'hello'"); + }) + .catch((caught) => caught); - expect(response.sent.status).toBe(500); - expect(response.json()).toEqual({ error: "Database operation failed" }); - expect(response.sent.body).not.toContain("audit sink"); + expect(error).toMatchObject({ category: "INTERNAL", phase: "transaction" }); + expect(error.message).not.toContain("audit sink"); expect(database.committed).toEqual({ notes: [], audits: [] }); expect(database.log).toEqual([ "begin", @@ -257,32 +262,27 @@ describe("generated CRUD over hooked mutations", () => { ]); }); - test("answers a before hook's validation failure with 422 and writes nothing", async () => { - const { database, post } = await mount({ - notes: { - beforeCreate: () => { - throw new DatabaseValidationError("invalid note", [ - { path: ["body"], message: "must not be empty" }, - { path: ["secret"], message: "leaks a private column" }, - ]); - }, - }, - }); - - const response = await post("/notes", { body: "" }); - - expect(response.sent.status).toBe(422); - expect(response.json()).toEqual({ - error: "Database request failed validation", - details: [{ path: ["body"], message: "must not be empty" }], - }); - expect(response.sent.body).not.toContain("secret"); + test("keeps a transaction callback's validation error and writes nothing", async () => { + const { plugin, database } = await mount(); + const issues = [ + { path: ["body"], message: "must not be empty" }, + { path: ["secret"], message: "leaks a private column" }, + ]; + + const error = await databaseOf(plugin) + .transaction(async () => { + throw new DatabaseValidationError("invalid note", issues); + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(DatabaseValidationError); + expect(error.issues).toEqual(issues); expect(database.committed.notes).toEqual([]); expect(database.log).toEqual(["begin", "rollback"]); }); test("explains an empty patch without changing the stored row", async () => { - const { database, patch } = await mount({}); + const { database, patch } = await mount(); database.committed.notes.push({ id: 1, body: "original" }); const response = await patch("/notes/:id", "1", {}); @@ -301,55 +301,15 @@ describe("generated CRUD over hooked mutations", () => { 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" }); + test("commits a direct create without opening a transaction", async () => { + const { plugin, database, post } = await mount(); - 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 = { - notes: { - beforeCreate: (values) => { - seen.push("before"); - return { ...values, body: `${values.body}!` }; - }, - afterCreate: (row) => { - seen.push(`after:${row.body}`); - }, - }, - }; - - const http = await mount(trace); - await http.post("/notes", { body: "a" }); - const overHttp = [...seen, ...http.database.log]; - - seen.length = 0; - const direct = await mount(trace); - await notesOf(direct.plugin).notes.create({ body: "a" }); + const response = await post("/notes", { body: "hello" }); + const direct = await databaseOf(plugin).notes.create({ body: "again" }); - expect([...seen, ...direct.database.log]).toEqual(overHttp); - expect(direct.database.committed.notes).toEqual([{ id: 1, body: "a!" }]); + expect(response.sent.status).toBe(201); + expect(response.json()).toEqual({ id: 1, body: "hello" }); + expect(direct).toEqual({ id: 2, body: "again" }); + expect(database.log).toEqual(["insert:notes", "insert:notes"]); }); }); 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 662250775..ce6d8c025 100644 --- a/packages/appkit/src/plugins/database/tests/entity-client.test.ts +++ b/packages/appkit/src/plugins/database/tests/entity-client.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import { MAX_LIMIT, MAX_OFFSET } from "../../../database/contract"; -import { DatabasePluginError } from "../../../database/errors"; import type { DataPath, QuerySpec, @@ -9,11 +8,9 @@ import type { WhereClause, } from "../../../database/runtime"; import { defineSchema, id, text } from "../../../database/schema-builder"; -import { DatabaseValidationError } from "../../../errors"; import { EntityClient, type EntityClientContext } from "../entity-client"; import type { TransactionClient } from "../entity-types"; -import { createMutationScope, MAX_TRANSACTION_OPERATIONS } from "../scope"; -import type { EntityHooks } from "../types"; +import { createTransactionScope, MAX_TRANSACTION_OPERATIONS } from "../scope"; const schema = defineSchema(({ table }) => { const notes = table("notes", { id: id(), body: text().notNull() }); @@ -21,10 +18,10 @@ const schema = defineSchema(({ table }) => { }); /** - * Mirrors the transaction wiring `lifecycle.ts` builds, so hooked mutations - * exercise the same redirect, scope publication, and reuse the plugin does. + * Mirrors the transaction wiring `lifecycle.ts` builds, so joined writes + * exercise the same scope publication and reuse the plugin does. */ -function harness(hooks?: EntityHooks) { +function harness() { const calls: Array<[string, unknown, unknown?]> = []; const overrides: Partial = {}; const dataPath: DataPath = { @@ -68,7 +65,7 @@ function harness(hooks?: EntityHooks) { : callback(dataPath); }, }; - const scope = createMutationScope(); + const scope = createTransactionScope(); const contextFor = (transactionBound: boolean): EntityClientContext => ({ table: schema.$tables.notes, @@ -76,16 +73,10 @@ function harness(hooks?: EntityHooks) { assertActive: vi.fn(), execute: async (operation) => ({ ok: true, data: await operation() }), scope, - hooks, transactionBound, - runInTransaction: (run) => { + activeEntity: () => { const active = scope.activeTransaction(); - if (active) return run(notesOf(active)); - const bound = new EntityClient(contextFor(true)); - const tx = { notes: bound } as unknown as TransactionClient; - return dataPath.transaction(() => - scope.runWithTransaction(tx, () => run(bound)), - ); + return active ? notesOf(active) : undefined; }, }); @@ -94,6 +85,7 @@ function harness(hooks?: EntityHooks) { client: new EntityClient(context), boundClient: () => new EntityClient(contextFor(true)), context, + scope, calls, overrides, }; @@ -313,12 +305,19 @@ describe("EntityClient", () => { test("refuses inserts that would silently drop a predicate", async () => { const { client, calls } = harness(); const scoped = client.where({ body: "owned" }); - await expect(scoped.create({ body: "a" })).rejects.toMatchObject({ + const rejected = [ + scoped.create({ body: "a" }), + scoped.upsert({ body: "a" }, { onConflict: "id" }), + ]; + await expect(rejected[0]).rejects.toMatchObject({ category: "INVALID_REQUEST", + phase: "runtime", }); - await expect( - scoped.upsert({ body: "a" }, { onConflict: "id" }), - ).rejects.toMatchObject({ category: "INVALID_REQUEST" }); + await expect(rejected[1]).rejects.toMatchObject({ + category: "INVALID_REQUEST", + }); + const settled = await Promise.allSettled(rejected); + expect(settled.every((result) => result.status === "rejected")).toBe(true); expect(calls).toEqual([]); }); @@ -330,261 +329,75 @@ describe("EntityClient", () => { await operation; expect(calls).toContainEqual(["upsert", "id"]); }); -}); -describe("EntityClient mutation hooks", () => { - test("keeps an unhooked mutation on the direct path", async () => { - const { client, calls } = harness({ afterUpdate: vi.fn() }); + test("keeps a root mutation on the direct path", async () => { + const { client, calls } = harness(); await client.create({ body: "a" }); await client.delete(1); expect(names(calls)).toEqual(["insert", "delete"]); }); - test("wraps a hooked mutation in one transaction and runs hooks in order", async () => { - const order: string[] = []; - const { client, calls } = harness({ - beforeCreate: (values) => { - order.push(`before:${values.body}`); - }, - afterCreate: (row) => { - order.push(`after:${row.id}`); - }, - }); - await expect(client.create({ body: "a" })).resolves.toEqual({ - id: 1, - body: "a", - }); - expect(names(calls)).toEqual(["begin", "insert"]); - expect(order).toEqual(["before:a", "after:1"]); - }); - - test("persists and revalidates a replacement payload", async () => { - const { client, calls } = harness({ - beforeCreate: (values) => ({ ...values, body: `${values.body}!` }), - }); - await client.create({ body: "a" }); - expect(calls).toContainEqual(["insert", { body: "a!" }]); + test("joins an open transaction without opening another", async () => { + const joined = harness(); + const bound = joined.boundClient(); + const transaction = { notes: bound } as unknown as TransactionClient; - // The caller's payload was valid, so the hook's replacement is our fault. - const rejected = harness({ beforeCreate: () => ({ unknown: true }) }); - await expect(rejected.client.create({ body: "a" })).rejects.toMatchObject({ - category: "INTERNAL", - phase: "write", - }); - expect(names(rejected.calls)).toEqual(["begin"]); - }); + await joined.scope.runWithTransaction(transaction, () => + joined.client.create({ body: "joined" }), + ); - test("gives upsert its own branch-opaque lifecycle", async () => { - const seen: string[] = []; - const { client } = harness({ - beforeCreate: () => { - seen.push("beforeCreate"); - }, - afterCreate: () => { - seen.push("afterCreate"); - }, - beforeUpsert: () => { - seen.push("beforeUpsert"); - }, - afterUpsert: (row) => { - // The hook sees the resulting row, never which branch produced it. - seen.push(`afterUpsert:${Object.keys(row).join()}`); - }, - }); - await client.upsert({ body: "a" }, { onConflict: "id" }); - expect(seen).toEqual(["beforeUpsert", "afterUpsert:id,body"]); + expect(names(joined.calls)).toEqual(["insert"]); }); - test("runs after hooks only once the mutation matched a row", async () => { - const afterUpdate = vi.fn(); - const afterDelete = vi.fn(); - const missing = harness({ afterUpdate, afterDelete }); - missing.overrides.update = async () => null; - missing.overrides.delete = async () => false; - await expect(missing.client.update(1, { body: "b" })).resolves.toBeNull(); - await expect(missing.client.delete(1)).resolves.toBe(false); - expect(afterUpdate).not.toHaveBeenCalled(); - expect(afterDelete).not.toHaveBeenCalled(); - - const matched = harness({ afterUpdate, afterDelete }); - await matched.client.update(1, { body: "b" }); - await matched.client.delete(1); - expect(afterUpdate).toHaveBeenCalledTimes(1); - expect(afterDelete).toHaveBeenCalledWith(1, expect.anything()); - }); + test("clones a joined write's predicate onto the bound client", async () => { + const joined = harness(); + const transaction = { + notes: joined.boundClient(), + } as unknown as TransactionClient; - test("rolls before-hook writes back when update or delete matches no row", async () => { - const update = harness({ - beforeUpdate: async (_id, _values, context) => { - await notesOf(context.app.database).create({ body: "audit" }); - }, - }); - update.overrides.update = async () => null; - let updateRolledBack = false; - update.overrides.transaction = async (callback) => { - try { - return await callback(update.context.getDataPath()); - } catch (error) { - updateRolledBack = true; - throw error; - } - }; - await expect( - update.client.update(999, { body: "missing" }), - ).resolves.toBeNull(); - expect(updateRolledBack).toBe(true); - expect(names(update.calls)).toEqual(["begin", "insert", "update"]); + await joined.scope.runWithTransaction(transaction, () => + joined.client.where({ body: "owned" }).update(1, { body: "b" }), + ); - const remove = harness({ - beforeDelete: async (_id, context) => { - await notesOf(context.app.database).create({ body: "audit" }); - }, - }); - remove.overrides.delete = async () => false; - let deleteRolledBack = false; - remove.overrides.transaction = async (callback) => { - try { - return await callback(remove.context.getDataPath()); - } catch (error) { - deleteRolledBack = true; - throw error; - } - }; - await expect(remove.client.delete(999)).resolves.toBe(false); - expect(deleteRolledBack).toBe(true); - expect(names(remove.calls)).toEqual(["begin", "insert", "delete"]); + expect(joined.calls).toEqual([ + ["update", { body: "b" }, { body: "owned" }], + ]); + await joined.client.where({ body: "other" }).update(1, { body: "c" }); + expect(joined.calls.at(-1)).toEqual([ + "update", + { body: "c" }, + { body: "other" }, + ]); }); - test("keeps no-match as a rollback signal inside an explicit transaction", async () => { - const joined = harness({ beforeUpdate: vi.fn() }); + test("returns no-match from a joined update without treating it as a fault", async () => { + const joined = harness(); joined.overrides.update = async () => null; const transaction = { notes: joined.boundClient(), } as unknown as TransactionClient; await expect( - joined.context.scope.runWithTransaction(transaction, () => + joined.scope.runWithTransaction(transaction, () => joined.client.update(999, { body: "missing" }), ), - ).rejects.toMatchObject({ category: "NOT_FOUND", phase: "write" }); - }); - - test("does not mistake a hook-issued no-match for the primary result", async () => { - const nested = harness({ - afterUpdate: async (_row, context) => { - await notesOf(context.app.database).delete(999); - }, - beforeDelete: vi.fn(), - }); - nested.overrides.delete = async () => false; - - await expect( - nested.client.update(1, { body: "changed" }), - ).rejects.toMatchObject({ category: "NOT_FOUND", phase: "write" }); + ).resolves.toBeNull(); }); - test("skips the after hook when the mutation itself is rejected", async () => { - const afterCreate = vi.fn(); - const { client, overrides } = harness({ - beforeCreate: vi.fn(), - afterCreate, - }); - overrides.insert = async () => { - throw new DatabasePluginError("CONFLICT", "write"); - }; - await expect(client.create({ body: "a" })).rejects.toMatchObject({ - category: "CONFLICT", - }); - expect(afterCreate).not.toHaveBeenCalled(); - }); + test("charges one budget entry for each bound write", async () => { + const joined = harness(); + const bound = joined.boundClient(); + const transaction = { notes: bound } as unknown as TransactionClient; - test("exposes the entity and the transaction surface, and nothing else", async () => { - let seen: unknown; - const { client, calls } = harness({ - beforeCreate: (_values, context) => { - seen = context; - }, - afterCreate: async (_row, context) => { - await notesOf(context.app.database).update(1, { body: "related" }); - }, + const overflow = joined.scope.runWithTransaction(transaction, async () => { + for (let index = 0; index <= MAX_TRANSACTION_OPERATIONS; index++) { + await bound.create({ body: `${index}` }); + } }); - await client.create({ body: "a" }); - expect(seen).toMatchObject({ entity: "notes" }); - expect(Object.keys(seen as { app: object }).sort()).toEqual([ - "app", - "entity", - ]); - expect(Object.keys((seen as { app: Record }).app)).toEqual( - ["database"], + const caught = await overflow.catch((error) => error); + expect(caught).toMatchObject({ category: "INTERNAL", phase: "write" }); + expect(names(joined.calls).filter((name) => name === "insert").length).toBe( + MAX_TRANSACTION_OPERATIONS, ); - // The related write joins the open transaction instead of starting one. - expect(names(calls)).toEqual(["begin", "insert", "update"]); - }); - - test("keeps a deliberate validation failure and collapses every other fault", async () => { - const issues = [{ path: ["body"], message: "too short" }]; - const rejected = harness({ - beforeCreate: () => { - throw new DatabaseValidationError("invalid note", issues); - }, - }); - const validation = await rejected.client - .create({ body: "a" }) - .catch((error) => error); - expect(validation).toBeInstanceOf(DatabaseValidationError); - expect(validation.issues).toEqual(issues); - - const broken = harness({ - afterCreate: () => { - throw new Error("insert into notes values ('secret')"); - }, - }); - const failure = await broken.client - .create({ body: "a" }) - .catch((error) => error); - expect(failure).toMatchObject({ category: "INTERNAL", phase: "write" }); - expect(failure.message).not.toContain("secret"); - expect(failure.cause).toBeUndefined(); - }); - - test("collapses a failure raised by the transaction itself", async () => { - const { client, overrides } = harness({ afterCreate: vi.fn() }); - overrides.transaction = async () => { - throw new Error("COMMIT failed on notes_pkey"); - }; - const failure = await client.create({ body: "a" }).catch((error) => error); - expect(failure).toBeInstanceOf(DatabasePluginError); - expect(failure.message).not.toContain("notes_pkey"); - }); - - test("bounds sibling writes issued by one hook", async () => { - const { client, calls } = harness({ - beforeUpdate: async (_id, _values, context) => { - for (let index = 0; index < MAX_TRANSACTION_OPERATIONS; index++) { - await notesOf(context.app.database).create({ body: `${index}` }); - } - }, - }); - - await expect(client.update(1, { body: "changed" })).rejects.toMatchObject({ - category: "INTERNAL", - phase: "write", - }); - expect( - names(calls).filter((name) => name === "insert").length, - ).toBeLessThan(MAX_TRANSACTION_OPERATIONS); - expect(names(calls)).not.toContain("update"); - }); - - test("refuses a hook that re-enters the same mutation", async () => { - const { client } = harness({ - beforeCreate: async (values, context) => { - await notesOf(context.app.database).create(values); - }, - }); - await expect(client.create({ body: "a" })).rejects.toMatchObject({ - category: "INTERNAL", - phase: "write", - }); }); }); diff --git a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts index aae7c04d2..c50d4a569 100644 --- a/packages/appkit/src/plugins/database/tests/lifecycle.test.ts +++ b/packages/appkit/src/plugins/database/tests/lifecycle.test.ts @@ -65,7 +65,7 @@ function fakePath(overrides: Partial = {}): DataPath { type TestEntity = Pick< EntityClient, - "create" | "toArray" | "where" | "update" | "delete" + "create" | "upsert" | "toArray" | "where" | "update" | "delete" >; type TestTransaction = { notes: TestEntity; @@ -79,7 +79,6 @@ type TestExports = TestTransaction & { // The registry is empty until typegen runs, so entities are reached by name. const surface = (state: { exports: unknown }) => state.exports as unknown as TestExports; -const txSurface = (tx: unknown) => tx as TestTransaction; function arrange(path = fakePath()) { const pool = { end: vi.fn(async () => undefined) }; @@ -263,6 +262,23 @@ describe("createDatabaseState", () => { category: "INTERNAL", phase: "read", }); + + const bound = fakePath(); + const root = fakePath({ + transaction: vi.fn(async (callback) => callback(bound)), + }); + const boundState = await createDatabaseState(schema, arrange(root).execute); + vi.mocked(bound.raw).mockRejectedValueOnce( + new DatabasePluginError("INTERNAL", "runtime", "bound secret"), + ); + const caught = await surface(boundState) + .transaction(async (tx) => tx.sql`bad`) + .catch((error: unknown) => error); + expect(caught).toMatchObject({ + category: "INTERNAL", + phase: "transaction", + }); + expect(JSON.stringify(caught)).not.toContain("bound secret"); }); test("commits, rolls back, binds tx capabilities, and expires them", async () => { @@ -312,21 +328,18 @@ describe("createDatabaseState", () => { await expect(stateTwo.exports.sql`select 1`).resolves.toEqual([]); }); - test("runs a hooked mutation in one transaction that hook writes reuse", async () => { + test("runs related writes on the one transaction the callback receives", async () => { const txPath = fakePath(); const rootPath = fakePath({ transaction: vi.fn(async (callback) => callback(txPath)), }); const { execute } = arrange(rootPath); - const state = await createDatabaseState(schema, execute, { - notes: { - afterCreate: async (_row, context) => { - await txSurface(context.app.database).tags.create({ label: "audit" }); - }, - }, - }); + const state = await createDatabaseState(schema, execute); - await surface(state).notes.create({ body: "hooked" }); + await surface(state).transaction(async (tx) => { + await tx.notes.create({ body: "created" }); + await tx.tags.create({ label: "audit" }); + }); expect(rootPath.transaction).toHaveBeenCalledTimes(1); expect(rootPath.insert).not.toHaveBeenCalled(); @@ -335,7 +348,7 @@ describe("createDatabaseState", () => { expect(txPath.transaction).not.toHaveBeenCalled(); }); - test("bounds SQL fan-out issued by one hook", async () => { + test("bounds SQL fan-out inside one transaction", async () => { let rolledBack = false; const txPath = fakePath(); const rootPath = fakePath({ @@ -349,24 +362,145 @@ describe("createDatabaseState", () => { }), }); const { execute } = arrange(rootPath); - const state = await createDatabaseState(schema, execute, { - notes: { - beforeCreate: async (_values, context) => { - for (let index = 0; index < MAX_TRANSACTION_OPERATIONS; index++) { - await context.app.database.sql`select ${index}`; - } - }, - }, - }); + const state = await createDatabaseState(schema, execute); await expect( - surface(state).notes.create({ body: "hooked" }), - ).rejects.toMatchObject({ category: "INTERNAL", phase: "write" }); + surface(state).transaction(async (tx) => { + for (let index = 0; index <= MAX_TRANSACTION_OPERATIONS; index++) { + await tx.sql`select ${index}`; + } + }), + ).rejects.toMatchObject({ category: "INTERNAL", phase: "transaction" }); expect(rolledBack).toBe(true); - expect(txPath.raw).toHaveBeenCalledTimes(MAX_TRANSACTION_OPERATIONS - 1); + expect(txPath.raw).toHaveBeenCalledTimes(MAX_TRANSACTION_OPERATIONS); expect(txPath.insert).not.toHaveBeenCalled(); }); + test("admits exactly 100 mixed writes and rolls back the 101st", async () => { + let rolledBack = false; + const txPath = fakePath(); + const rootPath = fakePath({ + transaction: vi.fn(async (callback) => { + try { + return await callback(txPath); + } catch (error) { + rolledBack = true; + throw error; + } + }), + }); + const { execute } = arrange(rootPath); + const state = await createDatabaseState(schema, execute); + const exports = surface(state); + + const overflow = exports.transaction(async (tx) => { + for (let index = 0; index < 40; index++) { + await tx.sql`select ${index}`; + } + for (let index = 0; index < 30; index++) { + await tx.notes.create({ body: `bound-${index}` }); + } + for (let index = 0; index < 30; index++) { + await exports.notes.create({ body: `joined-${index}` }); + } + await exports.notes.create({ body: "one-past-the-budget" }); + }); + const [settled] = await Promise.allSettled([overflow]); + + expect(settled.status).toBe("rejected"); + expect( + settled.status === "rejected" ? settled.reason : undefined, + ).toMatchObject({ category: "INTERNAL", phase: "transaction" }); + expect(rolledBack).toBe(true); + expect(txPath.raw).toHaveBeenCalledTimes(40); + expect(txPath.insert).toHaveBeenCalledTimes(60); + expect(rootPath.insert).not.toHaveBeenCalled(); + expect(rootPath.transaction).toHaveBeenCalledTimes(1); + }); + + test("lets a nested transaction see the same client without a new budget", async () => { + const txPath = fakePath(); + const rootPath = fakePath({ + transaction: vi.fn(async (callback) => callback(txPath)), + }); + const { execute } = arrange(rootPath); + const state = await createDatabaseState(schema, execute); + const exports = surface(state); + + await exports.transaction(async (outer) => { + for (let index = 0; index < MAX_TRANSACTION_OPERATIONS - 1; index++) { + await outer.sql`select ${index}`; + } + await exports.transaction(async (inner) => { + expect(inner).toBe(outer); + expect(inner.notes).toBe(outer.notes); + await inner.notes.create({ body: "last-admitted" }); + }); + await expect( + exports.transaction(async (inner) => { + await inner.notes.create({ body: "past-the-shared-budget" }); + }), + ).rejects.toMatchObject({ + category: "INTERNAL", + phase: "transaction", + }); + }); + + expect(rootPath.transaction).toHaveBeenCalledTimes(1); + expect(txPath.transaction).not.toHaveBeenCalled(); + expect(txPath.raw).toHaveBeenCalledTimes(MAX_TRANSACTION_OPERATIONS - 1); + expect(txPath.insert).toHaveBeenCalledTimes(1); + }); + + test("continues after a predicate misses and commits the later write", async () => { + const txPath = fakePath({ + update: vi.fn(async (_table, id, values) => + id === 999 ? null : { id, ...values }, + ), + delete: vi.fn(async (_table, id) => id !== 999), + }); + const rootPath = fakePath({ + transaction: vi.fn(async (callback) => callback(txPath)), + }); + const { execute } = arrange(rootPath); + const state = await createDatabaseState(schema, execute); + const exports = surface(state); + const scoped = exports.notes.where({ body: { eq: "missing" } }); + + await expect( + exports.transaction(async (tx) => { + const missed = tx.notes.where({ body: { eq: "missing" } }); + await expect( + missed.update(999, { body: "absent" }), + ).resolves.toBeNull(); + await expect(missed.delete(999)).resolves.toBe(false); + await expect( + scoped.update(999, { body: "absent" }), + ).resolves.toBeNull(); + await expect(scoped.delete(999)).resolves.toBe(false); + return tx.notes.create({ body: "kept" }); + }), + ).resolves.toMatchObject({ id: 1, body: "kept" }); + + expect(rootPath.transaction).toHaveBeenCalledTimes(1); + expect(txPath.insert).toHaveBeenCalledTimes(1); + expect(txPath.update).toHaveBeenNthCalledWith( + 1, + schema.$tables.notes, + 999, + { body: "absent" }, + { body: { eq: "missing" } }, + ); + expect(txPath.delete).toHaveBeenNthCalledWith( + 1, + schema.$tables.notes, + 999, + { + body: { eq: "missing" }, + }, + ); + }); + test("rolls back and closes a transaction when its wall-clock deadline expires", async () => { vi.useFakeTimers(); try { @@ -386,31 +520,25 @@ describe("createDatabaseState", () => { }), }); const { execute } = arrange(rootPath); - const state = await createDatabaseState(schema, execute, { - notes: { - beforeCreate: async (_values, context) => { - started.resolve(undefined); - await resume.promise; - try { - await txSurface(context.app.database).tags.create({ - label: "late", - }); - } finally { - finished.resolve(undefined); - } - }, - }, - }); + const state = await createDatabaseState(schema, execute); const failure = surface(state) - .notes.create({ body: "hooked" }) + .transaction(async (tx) => { + started.resolve(undefined); + await resume.promise; + try { + await tx.tags.create({ label: "late" }); + } finally { + finished.resolve(undefined); + } + }) .catch((error) => error); await started.promise; await vi.advanceTimersByTimeAsync(TRANSACTION_TIMEOUT_MS); await expect(failure).resolves.toMatchObject({ category: "TRANSIENT", - phase: "write", + phase: "transaction", }); expect(rolledBack).toBe(true); resume.resolve(undefined); @@ -421,18 +549,16 @@ describe("createDatabaseState", () => { } }); - test("keeps an unhooked mutation off the transaction path", async () => { + test("keeps a root mutation off the transaction path", async () => { const rootPath = fakePath(); const { execute } = arrange(rootPath); - const state = await createDatabaseState(schema, execute, { - notes: { serialize: (row) => row }, - }); + const state = await createDatabaseState(schema, execute); await surface(state).notes.create({ body: "direct" }); expect(rootPath.transaction).not.toHaveBeenCalled(); expect(rootPath.insert).toHaveBeenCalledTimes(1); }); - test("makes an unhooked root mutation join an open transaction", async () => { + test("makes a root mutation join an open transaction", async () => { const txPath = fakePath(); const rootPath = fakePath({ transaction: vi.fn(async (callback) => callback(txPath)), @@ -453,26 +579,17 @@ describe("createDatabaseState", () => { }); test.each([ - ["hooked root", "automatic", true], - ["root joining a transaction", "root", false], - ["hooked root joining a transaction", "root", true], - ["transaction client", "bound", false], - ["hooked transaction client", "bound", true], + ["root joining a transaction", "root"], + ["transaction client", "bound"], ] as const)( "preserves keyed mutation predicates for a %s", - async (_name, mode, hooked) => { + async (_name, mode) => { const txPath = fakePath(); const rootPath = fakePath({ transaction: vi.fn(async (callback) => callback(txPath)), }); const { execute } = arrange(rootPath); - const beforeUpdate = vi.fn(); - const beforeDelete = vi.fn(); - const state = await createDatabaseState( - schema, - execute, - hooked ? { notes: { beforeUpdate, beforeDelete } } : undefined, - ); + const state = await createDatabaseState(schema, execute); const exports = surface(state); const predicate = { and: [{ body: { eq: "original" } }, { id: { gt: 0 } }], @@ -485,21 +602,17 @@ describe("createDatabaseState", () => { await scoped.delete(1); }; - if (mode === "automatic") { - await mutate(exports.notes); - } else { - await exports.transaction(async (tx) => { - await mutate(mode === "root" ? exports.notes : tx.notes); - // A scoped operation must not change the shared transaction client. - await tx.notes.update(2, { body: "unfiltered" }); - expect(txPath.update).toHaveBeenLastCalledWith( - schema.$tables.notes, - 2, - { body: "unfiltered" }, - undefined, - ); - }); - } + await exports.transaction(async (tx) => { + await mutate(mode === "root" ? exports.notes : tx.notes); + // A scoped operation must not change the shared transaction client. + await tx.notes.update(2, { body: "unfiltered" }); + expect(txPath.update).toHaveBeenLastCalledWith( + schema.$tables.notes, + 2, + { body: "unfiltered" }, + undefined, + ); + }); expect(txPath.update).toHaveBeenNthCalledWith( 1, @@ -515,18 +628,12 @@ describe("createDatabaseState", () => { ); expect(rootPath.update).not.toHaveBeenCalled(); expect(rootPath.delete).not.toHaveBeenCalled(); - expect(rootPath.transaction).toHaveBeenCalledTimes( - mode === "automatic" ? 2 : 1, - ); + expect(rootPath.transaction).toHaveBeenCalledTimes(1); expect(txPath.transaction).not.toHaveBeenCalled(); - expect(beforeUpdate).toHaveBeenCalledTimes( - hooked ? (mode === "automatic" ? 1 : 2) : 0, - ); - expect(beforeDelete).toHaveBeenCalledTimes(hooked ? 1 : 0); }, ); - test("rolls the hook's related write back with the primary mutation", async () => { + test("rolls a related write back with the primary mutation", async () => { let rolledBack = false; const txPath = fakePath({ insert: vi.fn(async (table, values) => { @@ -546,20 +653,17 @@ describe("createDatabaseState", () => { }), }); const { execute } = arrange(rootPath); - const state = await createDatabaseState(schema, execute, { - notes: { - afterCreate: async (_row, context) => { - await txSurface(context.app.database).tags.create({ label: "audit" }); - }, - }, - }); + const state = await createDatabaseState(schema, execute); const error = await surface(state) - .notes.create({ body: "hooked" }) + .transaction(async (tx) => { + await tx.notes.create({ body: "created" }); + await tx.tags.create({ label: "audit" }); + }) .catch((caught) => caught); expect(rolledBack).toBe(true); - expect(error).toMatchObject({ category: "INTERNAL", phase: "write" }); + expect(error).toMatchObject({ category: "INTERNAL", phase: "transaction" }); expect(error.message).not.toContain("audit trail is full"); }); @@ -575,16 +679,15 @@ describe("createDatabaseState", () => { transaction: vi.fn(async (callback) => callback(fakePath())), }), ); - const stateTwo = await createDatabaseState(schema, second.execute, { - notes: { afterCreate: vi.fn() }, - }); + const stateTwo = await createDatabaseState(schema, second.execute); await surface(stateOne).transaction(async () => { await surface(stateTwo).notes.create({ body: "independent" }); }); - // The second instance opened its own transaction instead of joining. - expect(second.path.transaction).toHaveBeenCalledTimes(1); + // A root write stays on its own pool instead of joining another instance. + expect(second.path.transaction).not.toHaveBeenCalled(); + expect(second.path.insert).toHaveBeenCalledTimes(1); expect(first.path.insert).not.toHaveBeenCalled(); }); }); diff --git a/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts index 514f60bc0..33697b3ce 100644 --- a/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts +++ b/packages/appkit/src/plugins/database/tests/mvp.integration.test.ts @@ -7,7 +7,6 @@ import type { DataPath, QuerySpec, Row } from "../../../database/runtime"; import { defineSchema, fk, id, text } from "../../../database/schema-builder"; import type { ITelemetry } from "../../../telemetry"; import type { DatabaseExports } from "../entity-types"; -import type { EntityHooks } from "../types"; const mocks = vi.hoisted(() => ({ initializeLakebasePool: vi.fn(), @@ -109,7 +108,7 @@ function fakeResponse() { }; } -async function mount(hooks?: Record) { +async function mount() { const database = recordingDataPath(); const end = vi.fn(async () => undefined); mocks.initializeLakebasePool.mockResolvedValue({ end }); @@ -119,7 +118,6 @@ async function mount(hooks?: Record) { const plugin = new DatabasePlugin({ schema, api: { writes: { tables: ["boards", "notes"] } }, - hooks, }); (plugin as unknown as { telemetry: ITelemetry }).telemetry = createMockTelemetry(); @@ -242,12 +240,8 @@ describe("the assembled MVP", () => { expect(response.sent.body).not.toContain("retention_note"); }); - test("shapes the response without the private column", async () => { - const { list, detail } = await mount({ - boards: { - serialize: (row, { operation }) => ({ ...row, read_as: operation }), - }, - }); + test("projects the public row and drops the private column", async () => { + const { list, detail } = await mount(); const page = await list(); const one = await detail("7"); @@ -258,13 +252,16 @@ describe("the assembled MVP", () => { id: 7, title: "Q3 review", notes: [{ id: 1, board_id: 7, body: "looks off" }], - read_as: "list", }, ], limit: DEFAULT_LIMIT, offset: 0, }); - expect(one.json().read_as).toBe("detail"); + expect(one.json()).toEqual({ + id: 7, + title: "Q3 review", + notes: [{ id: 1, board_id: 7, body: "looks off" }], + }); expect(page.sent.body).not.toContain("retention_note"); expect(one.sent.body).not.toContain("retention_note"); }); diff --git a/packages/appkit/src/plugins/database/tests/plugin.test.ts b/packages/appkit/src/plugins/database/tests/plugin.test.ts index 305412642..70696f148 100644 --- a/packages/appkit/src/plugins/database/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/database/tests/plugin.test.ts @@ -143,23 +143,19 @@ describe("DatabasePlugin", () => { 1, routedSchema, expect.any(Function), - undefined, ); }, ); - test("applies API restrictions and hooks to the discovered schema", async () => { + test("applies API restrictions to the discovered schema", async () => { mocks.loadDefaultDatabaseSchema.mockResolvedValue(routedSchema); - const hooks = { notes: { beforeCreate: vi.fn() } }; const { routes } = await registerRoutes({ api: { tables: ["notes"], writes: false }, - hooks, }); expect(routes).toEqual(["get /notes", "get /notes/:id"]); expect(mocks.createDatabaseState).toHaveBeenCalledWith( routedSchema, expect.any(Function), - hooks, ); }); @@ -413,10 +409,6 @@ describe("DatabasePlugin", () => { writes: { operations: ["create", "update"] }, }, }); - database({ - schema: routedSchema, - hooks: { notes: { serialize: (row) => row } }, - }); database({ schema: routedSchema, // @ts-expect-error only a declared table can be exposed @@ -424,8 +416,8 @@ describe("DatabasePlugin", () => { }); database({ schema: routedSchema, - // @ts-expect-error only a declared table can shape its own responses - hooks: { missing: { serialize: (row) => row } }, + // @ts-expect-error lifecycle hooks were removed + hooks: { notes: {} }, }); }; const assertApiTypes = () => { @@ -588,17 +580,6 @@ describe("DatabasePlugin", () => { } }); - test("fails setup on a hook key that names no declared table", async () => { - mocks.createDatabaseState.mockResolvedValue(candidate()); - const plugin = new DatabasePlugin({ - schema: routedSchema, - hooks: { missing: { beforeCreate: () => undefined } } as never, - }); - await expect(plugin.setup()).rejects.toMatchObject({ - category: "SETUP_FAILED", - }); - }); - test.each([undefined, true] as const)( "refuses ambiguous route names with api=%j", async (api) => { @@ -682,7 +663,6 @@ describe("DatabasePlugin", () => { expect(mocks.createDatabaseState).toHaveBeenCalledWith( internalSchema, expect.any(Function), - undefined, ); }); diff --git a/packages/appkit/src/plugins/database/tests/scope.test.ts b/packages/appkit/src/plugins/database/tests/scope.test.ts index 2595eb97a..66b9ee51d 100644 --- a/packages/appkit/src/plugins/database/tests/scope.test.ts +++ b/packages/appkit/src/plugins/database/tests/scope.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import type { TransactionClient } from "../entity-types"; -import { createMutationScope, MAX_TRANSACTION_OPERATIONS } from "../scope"; +import { createTransactionScope, MAX_TRANSACTION_OPERATIONS } from "../scope"; const surface = (marker: string) => ({ marker }) as unknown as TransactionClient; @@ -9,9 +9,9 @@ const surface = (marker: string) => const markerOf = (transaction: TransactionClient | undefined) => (transaction as unknown as { marker: string } | undefined)?.marker; -describe("createMutationScope", () => { +describe("createTransactionScope", () => { test("publishes a transaction only inside its own callback", async () => { - const scope = createMutationScope(); + const scope = createTransactionScope(); const tx = surface("one"); expect(scope.activeTransaction()).toBeUndefined(); await scope.runWithTransaction(tx, async () => { @@ -21,8 +21,8 @@ describe("createMutationScope", () => { }); test("keeps concurrent async trees and separate instances apart", async () => { - const scope = createMutationScope(); - const other = createMutationScope(); + const scope = createTransactionScope(); + const other = createTransactionScope(); const observe = (name: string) => scope.runWithTransaction(surface(name), async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -35,49 +35,25 @@ describe("createMutationScope", () => { ).resolves.toEqual(["one", "two"]); }); - test("refuses a repeated entity and operation pair", async () => { - const scope = createMutationScope(); - await expect( - scope.runMutation("notes", "create", () => - scope.runMutation("notes", "create", async () => "unreachable"), - ), - ).rejects.toMatchObject({ category: "INTERNAL", phase: "write" }); - - await expect( - scope.runMutation("notes", "create", () => - scope.runMutation("notes", "update", async () => "reached"), - ), - ).resolves.toBe("reached"); - }); - - test("bounds sibling mutations across one transaction", async () => { - const scope = createMutationScope(); + test("charges only the active transaction and shares one budget", async () => { + const scope = createTransactionScope(); const tx = surface("bounded"); + expect(() => scope.consumeTransactionOperation()).not.toThrow(); + await expect( scope.runWithTransaction(tx, async () => { for (let index = 0; index < MAX_TRANSACTION_OPERATIONS; index++) { - await scope.runMutation(`table${index}`, "create", async () => index); + scope.consumeTransactionOperation(); } - return scope.runMutation("overflow", "create", async () => -1); + scope.consumeTransactionOperation(); }), - ).rejects.toMatchObject({ category: "INTERNAL", phase: "write" }); + ).rejects.toMatchObject({ category: "INTERNAL", phase: "transaction" }); await expect( - scope.runWithTransaction(tx, () => - scope.runMutation("fresh", "create", async () => "reached"), - ), + scope.runWithTransaction(tx, async () => { + scope.consumeTransactionOperation(); + return "reached"; + }), ).resolves.toBe("reached"); }); - - test("stops runaway nesting at a fixed depth", async () => { - const scope = createMutationScope(); - // A distinct entity per level keeps the cycle guard out of the way. - const nest = (level: number): Promise => - level === 0 - ? Promise.resolve(0) - : scope.runMutation(`table${level}`, "create", () => nest(level - 1)); - - await expect(nest(4)).resolves.toBe(0); - await expect(nest(20)).rejects.toMatchObject({ category: "INTERNAL" }); - }); }); diff --git a/packages/appkit/src/plugins/database/types.ts b/packages/appkit/src/plugins/database/types.ts index 80824ffe6..145416725 100644 --- a/packages/appkit/src/plugins/database/types.ts +++ b/packages/appkit/src/plugins/database/types.ts @@ -1,6 +1,5 @@ import type { DatabaseRegistry } from "../../database/contract"; import type { Schema } from "../../database/schema-builder"; -import type { EntityMutationHooks } from "./hooks"; type RegistryTableName = Extract; @@ -10,7 +9,7 @@ export type DefaultDatabaseSchema = Schema< >; /** Table names declared by one finalized schema. */ -export type SchemaTableName = Extract< +type SchemaTableName = Extract< keyof TSchema["$tables"], string >; @@ -54,31 +53,6 @@ export type DatabaseApiConfig = readonly writes?: DatabaseApiWritesConfig; }; -/** Which entity and generated operation produced the row being shaped. */ -export interface ReadSerializerContext { - readonly entity: string; - readonly operation: "list" | "detail"; -} - -/** - * Shape one already private-safe row before it reaches the wire. A `Promise` - * is not assignable to the return type, so an async callback fails to compile: - * serializers run inside the response path and must not add latency there. - */ -export type ReadSerializer = ( - row: Record, - context: ReadSerializerContext, -) => Record; - -/** Response shaping and mutation lifecycle declared for one table. */ -export type EntityHooks = - EntityMutationHooks & { - readonly serialize?: ReadSerializer; - }; - -/** Hooks addressed by runtime table name, once the typed keys are erased. */ -export type DatabaseHooks = Readonly>; - /** Configuration for one schema-bound DatabasePlugin instance. */ export type IDatabaseConfig = { /** @@ -100,7 +74,4 @@ export type IDatabaseConfig = { * @defaultValue true */ readonly api?: DatabaseApiConfig; - readonly hooks?: { - readonly [TTable in SchemaTableName]?: EntityHooks; - }; }; 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 590b30664..4a20a59c7 100644 --- a/packages/appkit/src/type-generator/database/tests/generate.test.ts +++ b/packages/appkit/src/type-generator/database/tests/generate.test.ts @@ -336,21 +336,11 @@ describe("generateDatabaseTypes", () => { database(defaults); database({ api: { tables: ["posts"], writes: { operations: ["create"] } }, - hooks: { - posts: { - beforeCreate(values) { - const title: string = values.title; - // @ts-expect-error inferred hook fields are not any - const invalid: number = values.title; - return { ...values, title }; - }, - }, - }, }); // @ts-expect-error default API restrictions use generated table names database({ api: { tables: ["missing"] } }); - // @ts-expect-error default hooks use generated table names - database({ hooks: { missing: { beforeCreate() {} } } }); + // @ts-expect-error lifecycle hooks were removed + database({ hooks: { posts: {} } }); declare const db: DatabaseExports; db.users.where({ name: { ilike: "%ada%" } }).include({ posts: { limit: 2 } }); db.posts.where({ score: { gte: 1 }, and: [{ status: ["draft"] }] }); diff --git a/packages/shared/src/database/api-types.ts b/packages/shared/src/database/api-types.ts index 814a3c65a..9babb3f3a 100644 --- a/packages/shared/src/database/api-types.ts +++ b/packages/shared/src/database/api-types.ts @@ -181,10 +181,7 @@ 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. - */ +/** The public row a generated create or update route answers with. */ export type PublicRowFor = WireOutput>; /** The envelope a generated list route answers with. */