Skip to content

feat(appkit): define named database operations and policies - #688

Draft
ditadi wants to merge 1 commit into
stack/database-stable/01-remove-hooksfrom
stack/database-stable/02-operation-definitions
Draft

ditadi wants to merge 1 commit into
stack/database-stable/01-remove-hooksfrom
stack/database-stable/02-operation-definitions

Conversation

@ditadi

@ditadi ditadi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What did you change, and why?

Why: Generated table routes leave no place to declare who may call what. An app needs named operations whose authorization the types require and setup checks.

Change (beta API only):

  • Adds defineOperation({ name, entity, method, authorize, execute, input?, output? }) and definePolicy to @databricks/appkit/beta. name is required and is the public key. The entity and the method infer the default input and output; Zod input and output override them.
  • Adds the declaration validator that generation and setup share: name rules, reserved names, duplicates, unknown entities, and malformed contracts. A declaration without authorize is skipped with a warning naming its file and export.
  • Lists the workspace-client types explicitly, so the built declarations type-check without skipLibCheck.
  • Safe to deploy alone: nothing loads or serves an operation yet.

Interface

New in @databricks/appkit/beta. The signatures are simplified from the generic overloads.

function defineOperation(definition: {
  name: string; // the public key, such as "notes.update"; unique
  entity: EntityName; // a table of config/database/schema.ts
  method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
  input?: z.ZodType; // replaces the default input
  output?: z.ZodType; // replaces the default answer
  authorize: Policy | ((context: PolicyContext<Input>) => PolicyDecision | Promise<PolicyDecision>);
  execute: (context: OperationContext<Input, Resource>) => Result | Promise<Result>;
}): OperationDefinition;

// Decides from the actor and the input.
function definePolicy(policy: {
  allow: (context: OperationContext<Input>) => PolicyDecision | Promise<PolicyDecision>;
}): Policy;
// Loads one resource first; null or undefined ends the request with 404 before allow runs.
function definePolicy(policy: {
  resource: (context: PolicyContext<Input>) => Resource | null | undefined | Promise<Resource | null | undefined>;
  allow: (context: OperationContext<Input, Resource>) => PolicyDecision | Promise<PolicyDecision>;
}): Policy;

interface Actor { readonly id: string; readonly email?: string }
interface PolicyContext<In> { actor: Actor; db: DatabaseExports; appkit: OperationAppKit; input: In }
interface OperationContext<In, R> extends PolicyContext<In> { resource: R }
type PolicyDecision = true | false | { deny: "forbidden" | "not-found"; reason?: string };

Only a literal true admits. Without input or output, the entity and the method decide the contract:

Method Default input execute returns Answer
GET { id } the row, or null the public row (200), or 404
POST the insertable public columns the row the public row (201)
PATCH { id, changes }, at least one change the row, or null the public row, or 404
PUT { id, values } the row, or null the public row, or 404
DELETE { id } boolean 204, or 404

Without input and output, the entity and the method are the whole contract. With the playground's schema:

const signedIn = definePolicy({ allow: () => true });

// A resource policy: loads the note, then admits only its author.
const noteAuthor = definePolicy({
  resource: ({ db, input }: PolicyContext<{ readonly id: number }>) => db.notes.find(input.id),
  allow: ({ actor, resource }) => (resource.author === actor.id ? true : { deny: "forbidden" }),
});

export const getNote = defineOperation({
  name: "notes.get",
  entity: "notes",
  method: "GET", // input { id }; answers the public row, or 404 for null
  authorize: signedIn,
  execute: ({ db, input }) => db.notes.find(input.id),
});

export const createBoard = defineOperation({
  name: "boards.create",
  entity: "boards",
  method: "POST", // input { slug, title, created_at? }; answers the public row with 201
  authorize: signedIn,
  execute: ({ db, input }) => db.boards.create(input),
});

export const updateBoard = defineOperation({
  name: "boards.update",
  entity: "boards",
  method: "PATCH", // input { id, changes: { slug?, title? } }; answers the public row, or 404
  authorize: signedIn,
  execute: ({ db, input }) => db.boards.update(input.id, input.changes),
});

export const deleteNote = defineOperation({
  name: "notes.delete",
  entity: "notes",
  method: "DELETE", // input { id }; answers 204, or 404 for false
  authorize: noteAuthor,
  execute: ({ db, input }) => db.notes.delete(input.id),
});

Pass input or output only when the default does not fit. Here the server sets the author, so the browser sends only the board and the body:

export const createNote = defineOperation({
  name: "notes.create",
  entity: "notes",
  method: "POST",
  input: z.object({ board_id: z.number().int(), body: z.string().trim().min(1).max(2000) }),
  authorize: signedIn,
  execute: ({ actor, db, input }) => db.notes.create({ ...input, author: actor.id }),
});

// The JSON a caller sends and receives; generation renders the same types for the browser.
type GetNoteInput = OperationWireInput<typeof getNote>; // { id: number }
type GetNoteOutput = OperationWireOutput<typeof getNote>; // the public row, without author_email

These examples type-check against the playground's schema.

A hand-written literal that execute returns against a custom output with literal types needs as const, because TypeScript widens "ok" to string.

How do you know it works?

Testing: operations.test.ts covers the validator. operations.types.test.ts covers the inferred contracts and each misuse diagnostic, against the sources and against the built declarations without skipLibCheck. The entity-type and registry tests are updated. The database, contract, and type-generator suites pass (53 files, 1105 tests).


Stack created with GitHub Stacks CLI • Give Feedback 💬

Add defineOperation and definePolicy to @databricks/appkit/beta. A

required, unique name identifies each operation, and the entity and

method infer its default input and output. The declaration validator

rejects malformed and duplicate operations and skips those without

authorization. Nothing registers or serves an operation yet.

Signed-off-by: ditadi <victordperd@gmail.com>
@ditadi
ditadi added this pull request to stack #693 October 23, 2026 15:01
@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 1.2 MB (+2.3 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 1.3 MB (-8.0 KB) 456 KB (-2.0 KB)
Type declarations 476 KB (+12 KB) 172 KB (+3.7 KB)
Source maps 2.5 MB (+1.8 KB) 857 KB (+1.2 KB)
Other 11 KB 3.7 KB
Total 4.2 MB (+5.8 KB) 1.5 MB (+3.0 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 106 KB (-1 B) 2.5 KB (+1 B) 108 KB external 343 KB
./beta 102 KB (-867 B) 485 B 102 KB (-867 B) external 305 KB (-3.3 KB)
./testing 45 KB (+1 B) 32 KB (-3 B) 77 KB (-2 B) external 223 KB
./tsdown 520 B 0 B 520 B external 813 B
./type-generator 26 KB 0 B 26 KB external 74 KB

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 101 KB
. utils.js initial 4.6 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 82 KB
./beta modular.js initial 6.0 KB
./beta stream-manager.js initial 5.9 KB
./beta service-context.js initial 4.2 KB
./beta databricks.js initial 3.3 KB
./beta client.js initial 647 B
./beta index.js initial 20 B
./beta supervisor-api.js lazy 192 B
./beta databricks.js lazy 178 B
./beta index.js lazy 115 B
./testing manifest.js initial 32 KB
./testing index.js initial 10 KB
./testing wide-event-emitter.js initial 2.9 KB
./testing index.js lazy 28 KB
./testing remote-tunnel-manager.js lazy 2.5 KB
./testing utils.js lazy 1.8 KB
./tsdown index.js initial 520 B
./type-generator index.js initial 26 KB

@databricks/appkit-ui

npm tarball (packed): 395 KB (-2.4 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 427 KB (-1.9 KB) 146 KB (-774 B)
Type declarations 266 KB (-920 B) 98 KB (-222 B)
Source maps 849 KB (-4.5 KB) 284 KB (-1.4 KB)
CSS 16 KB 3.2 KB
Total 1.5 MB (-7.3 KB) 531 KB (-2.4 KB)
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 5.3 KB 49 KB 55 KB 208 KB 14 KB
./js/beta 2.1 KB 0 B 2.1 KB 0 B 5.0 KB
./react 433 KB 49 KB 481 KB 1.3 MB 177 KB
./react/beta 4.7 KB (-199 B) 0 B 4.7 KB (-199 B) 0 B 12 KB (-561 B)

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 5.2 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 2.1 KB
./react index.js initial 431 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 4.7 KB

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 39974579325 -R databricks/appkit -n appkit-template-0.89.0-pr.0b522a6-stack-database-stable-02-operation-definitions-688 -D appkit-pr-688 \
  && unzip -o "appkit-pr-688/appkit-template-0.89.0-pr.0b522a6-stack-database-stable-02-operation-definitions-688.zip" -d "appkit-pr-688" \
  && databricks apps init --template "appkit-pr-688"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant