Skip to content

feat(appkit): add opt-in generated database reads - #527

Merged
ditadi merged 3 commits into
mainfrom
stack/database-mvp/03-crud-reads
Aug 31, 2026
Merged

feat(appkit): add opt-in generated database reads#527
ditadi merged 3 commits into
mainfrom
stack/database-mvp/03-crud-reads

Conversation

@ditadi

@ditadi ditadi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stack

Each PR targets the one above it, so the diff shown here is only the delta on top of #526. Review in order.

What

Adds the first HTTP surface: crudRoutes projects the typed read API from #526 onto generated GET /:table and GET /:table/:id routes. It is off by default and opt-in per table, because a generated route is reachable by anyone the app admits.

database({
  schema,
  crudRoutes: { tables: ["notes"] },
  hooks: {
    notes: {
      serialize: (row) => ({ ...row, excerpt: String(row.body).slice(0, 80) }),
    },
  },
});
GET /api/database/notes?where={"author":{"eq":"ada"}}&order={"createdAt":"desc"}&limit=20
→ { "items": [...], "limit": 20, "offset": 0 }

GET /api/database/notes/42
→ { "id": 42, "body": "...", "createdAt": "..." }

Only writes are missing after this PR; they arrive in the next one.

Changes

Exposure is a decision per table (crud/exposure.ts)

crudRoutes accepts false (the default), true, or { tables: [...] }, and the table names are checked against the schema type, so a typo does not silently expose nothing. An enabled table is also what makes it includable from its neighbours: a relation whose target is not enabled cannot be included, so one table's data sits behind exactly one decision rather than leaking through a join.

The query grammar is bounded before any SQL runs (crud/query.ts)

where, order, select, include, limit, and offset are decoded from the raw query string — not from Express's normalized req.query, which would accept repeated and array-shaped parameters. Every decoded piece is checked against the table's compiled columns: an unknown column, an operator the column's kind does not support, or a value that fails its codec is a 400 before the plugin is asked for anything.

The budgets are explicit constants in defaults.ts and are all enforced at decode time: query string size, where nesting depth and condition count, order field count, offset ceiling, and the number of rows the include tree may materialize. limit defaults to a page and is capped by the same wire cap a typed caller sees, so HTTP cannot ask for more than server code can.

Rejections name a fixed parameter and a fixed sentence. The decoder never echoes caller-supplied text back into the response.

Rows are shaped, not forwarded (crud/contract.ts, crud/codecs.ts)

Each enabled table compiles once into a CrudTable: its public columns, their codecs, its primary key decoder, and a projection that drops private columns. A row is projected before it reaches the optional serialize hook, so a serializer cannot re-expose a column the schema marked private, and the hook's output is re-sanitized against depth and node budgets afterwards. serialize is typed to return synchronously — a Promise does not compile — because it runs inside the response path.

Responses are bounded and never cached

The encoded body is measured before it is sent, so a request that would exceed the byte budget fails as 413 instead of streaming a partial answer. Every generated read sends Cache-Control: no-store: the same URL answers differently once the table changes.

Pagination is stable

The list handler appends the primary key to whatever order the caller asked for, so rows with equal sort keys cannot reshuffle between pages. A table without a primary key has no tie-breaker to append, so it must name its own order and is told so.

Spans

Each generated read runs inside a span named for its route template, not its URL, and a failure is recorded as not_found, rejected, or failed — derived from the safe status code, so cardinality stays bounded and no caller input reaches the span.

Known limitation

Text filters accept caller-supplied like/ilike patterns, and this beta adds no statement cancellation below the connector, so an expensive pattern runs to completion while holding its pooled connection. This is documented on CrudRoutesConfig alongside the note that generated routes carry no per-user filter.

Verification

  • pnpm vitest run — 4109 passing, 1 skipped; new suites cover the query decoder and its budgets, the codecs, the row contract, the route handlers, and the read spans
  • pnpm -r typecheck — clean across all packages
  • pnpm run generate:types, pnpm run sync:template, and pnpm run docs:build produce no drift

Comment thread packages/appkit/src/plugins/database/database.ts
Comment thread packages/appkit/src/plugins/database/crud/routes.ts
Comment thread packages/appkit/src/plugins/database/crud/query.ts
Comment thread packages/appkit/src/plugins/database/crud/contract.ts Outdated
@ditadi
ditadi force-pushed the stack/database-mvp/02-typed-api branch from 441393c to 84e6494 Compare August 24, 2026 16:05
Base automatically changed from stack/database-mvp/02-typed-api to main August 26, 2026 11:45
@atilafassina

Copy link
Copy Markdown
Contributor

2 possible PII-leak sites in stream logs: drizzle-data-path.ts:134 (primary — carries params) and errors.ts:110 (unclassified path, can also receive a DrizzleQueryError). The two lifecycle.ts sites are lower-risk — schema-validation and select 1 health-check errors carry no row params (setup can carry connection/infra detail, worth trimming eventually, but not the row-PII concern).

@ditadi

ditadi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

2 possible PII-leak sites in stream logs: drizzle-data-path.ts:134 (primary — carries params) and errors.ts:110 (unclassified path, can also receive a DrizzleQueryError). The two lifecycle.ts sites are lower-risk — schema-validation and select 1 health-check errors carry no row params (setup can carry connection/infra detail, worth trimming eventually, but not the row-PII concern).

Fixed.

The errors.ts unclassified path now logs only the error's constructor-name chain (never %O), so a DrizzleQueryError can't leak query text or params; the drizzle-data-path site was already fixed in an earlier push.

ditadi added 2 commits August 28, 2026 15:51
Project the typed entity API onto default-off list and detail routes that bound query
grammar, include depth, and result cost before execution, and that shape rows through a
private-safe projection and one synchronous serializer per table. Keep the declared table
names in the schema type so exposure config cannot name a table the schema does not have.

Signed-off-by: ditadi <victordperd@gmail.com>
- encode list responses row by row so the byte budget trips before
  more rows are projected, instead of after full materialization
- apply a server-side statement_timeout to pooled connections and
  classify query_canceled (57014) as transient
- treat a private primary key as no key over HTTP: no detail route,
  no existence oracle on a hidden identifier
- log unclassified database errors by constructor name only, so
  driver errors cannot leak SQL text or row parameters

Signed-off-by: ditadi <victordperd@gmail.com>
@ditadi
ditadi force-pushed the stack/database-mvp/03-crud-reads branch from 977c936 to 2b38f74 Compare August 28, 2026 14:52
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size report

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

@databricks/appkit ⚠️ over budget

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

dist raw gzip
JS (runtime) 1.0 MB (+55 KB) 371 KB (+20 KB)
Type declarations 388 KB (+9.3 KB) 137 KB (+3.7 KB)
Source maps 2.1 MB (+103 KB) 696 KB (+34 KB)
Other 11 KB 3.7 KB
Total 3.5 MB (+167 KB) 1.2 MB (+58 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 95 KB 2.5 KB 97 KB external 311 KB
./beta 74 KB (+9.5 KB) 457 B 75 KB (+9.5 KB) external 224 KB (+28 KB)
./testing 17 KB 0 B 17 KB external 50 KB
./tsdown 520 B 0 B 520 B external 813 B
./type-generator 22 KB 0 B 22 KB external 65 KB

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 91 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 58 KB
./beta stream-manager.js initial 5.8 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta databricks.js initial 3.0 KB
./beta configuration.js initial 2.1 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 434 B
./beta client-options.js initial 220 B
./beta supervisor-api.js lazy 192 B
./beta databricks.js lazy 142 B
./beta index.js lazy 123 B
./testing index.js initial 17 KB
./tsdown index.js initial 520 B
./type-generator index.js initial 22 KB

@databricks/appkit-ui

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

dist raw gzip
JS (runtime) 395 KB (+897 B) 132 KB (+410 B)
Type declarations 229 KB (+917 B) 84 KB (+415 B)
Source maps 766 KB (+2.7 KB) 253 KB (+1.0 KB)
CSS 16 KB 3.2 KB
Total 1.4 MB (+4.5 KB) 473 KB (+1.8 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 20 B 0 B 20 B 0 B 0 B
./react 432 KB (+214 B) 49 KB 481 KB (+214 B) 1.3 MB 177 KB (+323 B)
./react/beta 1.0 KB 0 B 1.0 KB 0 B 1.9 KB

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 20 B
./react index.js initial 430 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 1.0 KB

⚠️ Over budget: a package's shipped tarball, or a browser entry's consumer bundle (deps included), grew by more than 5% (and >10 KB). This check will fail — reduce the size, or acknowledge the increase by updating bundle-size-baseline.json.

@github-actions

github-actions Bot commented Aug 28, 2026

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 33379208910 -R databricks/appkit -n appkit-template-0.66.1-pr.0b7480d-stack-database-mvp-03-crud-reads-527 -D appkit-pr-527 \
  && unzip -o "appkit-pr-527/appkit-template-0.66.1-pr.0b7480d-stack-database-mvp-03-crud-reads-527.zip" -d "appkit-pr-527" \
  && databricks apps init --template "appkit-pr-527"

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.

@ditadi
ditadi enabled auto-merge (squash) August 28, 2026 15:01
@ditadi
ditadi merged commit 0b43f14 into main Aug 31, 2026
9 of 10 checks passed
@ditadi
ditadi deleted the stack/database-mvp/03-crud-reads branch August 31, 2026 09:52
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.

3 participants