feat(appkit): add database mutations and transactional hooks - #528
feat(appkit): add database mutations and transactional hooks#528ditadi wants to merge 7 commits into
Conversation
fe60ed6 to
d75540a
Compare
Extend the typed entity API and the generated routes with create, update, upsert, and delete, and let an entity declare before/after hooks that run inside the mutation's own transaction, so writes a hook issues commit or roll back with it. Keep the HTTP write allowlist narrower than trusted code's: a key, a generated identity, and a materialized stamp stay server-owned. Answer a hook's DatabaseValidationError with 422 carrying only the issues that name a public column, and leave every other hook failure opaque. Keyed mutations narrow by the accumulated predicate as find(id) already does, and an insert that would silently drop one is rejected, so no terminal operation ignores fluent state. Signed-off-by: ditadi <victordperd@gmail.com>
Signed-off-by: ditadi <victordperd@gmail.com>
Signed-off-by: ditadi <victordperd@gmail.com>
3da27b2 to
60ea7e3
Compare
📦 Bundle size reportCompared against
|
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 1.1 MB (+21 KB) | 404 KB (+6.9 KB) |
| Type declarations | 412 KB (+6.2 KB) | 148 KB (+2.4 KB) |
| Source maps | 2.2 MB (+43 KB) | 758 KB (+13 KB) |
| Other | 11 KB | 3.7 KB |
| Total | 3.8 MB (+71 KB) | 1.3 MB (+22 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 (+119 B) | 2.5 KB | 98 KB (+119 B) | external | 313 KB (+321 B) |
./beta |
84 KB (+2.7 KB) | 457 B | 84 KB (+2.7 KB) | external | 251 KB (+9.4 KB) |
./testing |
17 KB (+32 B) | 0 B | 17 KB (+32 B) | external | 52 KB (+112 B) |
./tsdown |
520 B | 0 B | 520 B | external | 813 B |
./type-generator |
23 KB | 0 B | 23 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 | 67 KB |
./beta |
stream-manager.js |
initial | 5.8 KB |
./beta |
wide-event-emitter.js |
initial | 3.2 KB |
./beta |
databricks.js |
initial | 3.2 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 | 23 KB |
@databricks/appkit-ui
npm tarball (packed): 350 KB (+4 B) — gzipped download (dist + bin; excludes release-only docs/NOTICE).
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 395 KB | 132 KB |
| Type declarations | 229 KB | 84 KB (+1 B) |
| Source maps | 766 KB | 253 KB |
| CSS | 16 KB | 3.2 KB |
| Total | 1.4 MB | 473 KB (+1 B) |
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 | 49 KB | 481 KB | 1.3 MB | 177 KB |
./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 |
🤖 AppKit PR bot🔬 Run evalsStart an eval for this PR from the evals-monitor app: Go to Evals Monitor → 📦 Try this PR's app templateScaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh run download 34157722684 -R databricks/appkit -n appkit-template-0.72.0-pr.9317162-stack-database-mvp-04-mutations-hooks-528 -D appkit-pr-528 \
&& unzip -o "appkit-pr-528/appkit-template-0.72.0-pr.9317162-stack-database-mvp-04-mutations-hooks-528.zip" -d "appkit-pr-528" \
&& databricks apps init --template "appkit-pr-528"The template pins |
Signed-off-by: ditadi <victordperd@gmail.com>
Signed-off-by: ditadi <victordperd@gmail.com>
| return Promise.race([Promise.resolve().then(run), deadline]).finally(() => | ||
| clearTimeout(timer), | ||
| ); |
There was a problem hiding this comment.
I'm wondering what will happen if a deadline hits and then a run crashes. It will be an uncaught error during the transaction still.
if so, should we catch() this one?
There was a problem hiding this comment.
Promise.race already handles rejections from every input promise even after the deadline wins, so a late rejection from run does not become unhandled and no additional .catch() is needed
Signed-off-by: ditadi <victordperd@gmail.com>
Follow-up status
The
apirename, CRUD-on default, configuration diagnostics, transaction predicate fix, and pool timeout forwarding described below are implemented and validated locally, but have not been pushed yet. The current GitHub head may still showcrudRoutesand opt-in writes.Stack
This PR now targets
main.What
Adds
create,update,upsert, anddeleteto the typed entity API, plus transactional before/after hooks.database({ schema })generates full HTTP CRUD by default. No separate write opt-in is required.The public configuration option is
api, replacingcrudRoutes:Access model
The database plugin executes as the app's service principal in deployed Databricks Apps. OBO is not supported and is not planned for this plugin. Generated routes do not implement per-user or per-row authorization. Every caller who can reach them receives every enabled operation on every exposed row.
CRUD-on is the intended AppKit default, consistent with its opinionated, plug-and-play application model. It does not imply row-level isolation. Applications with narrower access requirements must restrict or disable generated routes and implement authorization in custom server routes. App admission and least-privilege database grants remain application/deployment responsibilities.
Restrictions reject invalid configuration rather than silently enabling more routes. Unknown keys, invalid values, undeclared tables, duplicate names, and unsupported operations fail setup before pool creation. The removed
crudRoutesoption is rejected with migration guidance so an old opt-out cannot silently turn into full CRUD.Changes
Hooks share the mutation's transaction
A hooked mutation opens its transaction before running
before*, the write, andafter*. Writes throughctx.app.databasejoin the same transaction and commit or roll back together. This does not extend transactions to other plugins or external services.An instance-owned
AsyncLocalStoragekeeps concurrent requests and plugin instances isolated. A shared budget caps each transaction at 100 database operations, including direct SQL. Nested mutations reject repeated entity/operation pairs and depth beyond 8 frames.Transactions have a 30-second callback deadline. PostgreSQL also receives a 30-second
statement_timeoutand a 30-secondidle_in_transaction_session_timeoutthrough the Lakebase pool configuration. The JavaScript deadline is not cancellation of arbitrary hook code or external side effects.Before-hook replacement values are revalidated against the trusted schema before persistence. An update or delete that matches no row throws inside the mutation transaction so hook writes roll back before the generated route returns 404.
Deliberate validation failures
DatabaseValidationErrorproduces HTTP 422. Only issues naming public columns are returned, with at most 50 issues. Other hook failures remain opaque server errors.HTTP writes and row integrity
Generated write allowlists exclude private columns and server-owned fields. Generated primary keys, including UUID primary keys with
defaultRandom, cannot be supplied by HTTP callers. Natural primary keys may be supplied on create, but no primary key can be updated over HTTP.Programmatic upsert excludes primary keys, server-generated fields, and the conflict target from the update values. There is no generated upsert route.
Unknown or read-only write fields are rejected rather than silently discarded. Rejections identify a field only when it is a public column; other fields use the generic
["body"]path.Query state survives transaction redirection
Redirecting an entity mutation to a transaction client now clones the caller's query state.
where()predicates remain attached to update/delete through hooks and explicit transactions without mutating the shared transaction client. Tests cover all five transaction paths identified in review.createandupsertreject accumulated predicates rather than silently ignoring them.Diagnostics and documentation
Invalid route names and configuration restrictions now produce actionable server-side setup errors. Errors name the problematic key or table and explain how to fix it. Non-routable tables are not silently omitted: rename them, select a routable subset with
api.tables, or disable generated routes withapi: false.Client messages remain generic, and request boundaries strip setup diagnostics. The new database plugin guide documents the access model, defaults, restrictions, private columns, hooks, and timeout limitations. API references include the exported API configuration types.
Bounded responses
Success and error responses share the encoded byte budget. Error details are dropped if they exceed it. The row sanitizer uses null-prototype objects so JSON keys such as
__proto__remain data.Local verification
pnpm exec vitest run --maxWorkers=4: 4,778 passed, 1 skipped.pnpm build && pnpm docs:build: passed.pnpm check:fix && pnpm -r typecheck: passed; lint reports four existing unused-import/variable warnings outside these changes. Untracked local scratch directories were excluded from formatting.No commit or push has been made for the local follow-up yet.