feat(appkit): add database mutations and transactional hooks - #528
Conversation
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>
| } | ||
|
|
||
| /** `DELETE /:table/:id` — `204` with no body, or 404 when nothing matched. */ | ||
| export function createDeleteHandler(deps: CrudRouteDeps): RouteHandler { |
There was a problem hiding this comment.
[High] Writes are not opt-in separately from reads. A single crudRoutes value registers GET and POST/PATCH/DELETE for each exposed table; there is no read-only mode and no per-operation gate. Combined with service-principal execution and no per-user/row authorization (see #527), any caller the app admits can create/update/delete any row of any exposed table — and a table opted into generated reads is silently upgraded to public destructive writes on adopting this PR. Consider a separate write opt-in plus an authorization/owner seam.
Automated review finding.
| if (!open) throw new DatabasePluginError("INTERNAL", "transaction"); | ||
| }; | ||
| // The outer transaction owns execution; inner clients use its connection. | ||
| const directExecute: EntityExecute = async (operation) => ({ |
There was a problem hiding this comment.
[Medium-High] Hooks run inside a transaction with no per-hook / per-transaction deadline. The transaction is opened via getDataPath().transaction() (not through ctx.execute), and directExecute runs each op with no interceptors — so there is no AppKit timeout, and there is no PG statement_timeout / idle_in_transaction_session_timeout. A slow or hanging before/after hook holds the pooled connection + row/index locks for its full duration (the pool's connectionTimeoutMillis bounds waiters, not the holder); enough concurrent slow-hook writes exhaust the pool. Bound hook/transaction wall-clock.
Automated review finding.
| }, | ||
|
|
||
| /** Open one frame, refusing a repeated entity/operation pair or deep nesting. */ | ||
| async runMutation<T>( |
There was a problem hiding this comment.
[Medium] The re-entrancy guard bounds nesting depth but not sibling fan-out. runMutation caps nesting (MAX_MUTATION_DEPTH=8) and refuses repeated (entity, operation) pairs, but sibling writes at one level do not accumulate frames — so a hook can issue an unbounded number of writes in a loop (e.g. beforeCreate iterating an attacker-controlled array field), all inside the single no-deadline transaction → an arbitrarily long-held transaction / lock set.
Automated review finding.
| "delete", | ||
| (entity) => entity.delete(id), | ||
| async (context) => { | ||
| await this.runHook(() => hooks?.beforeDelete?.(id, context)); |
There was a problem hiding this comment.
[Medium] Before-hook writes commit even when the update/delete matches no row and the route returns 404. beforeUpdate/beforeDelete run unconditionally inside the transaction and may write via ctx.app.database; when the primary op matches 0 rows it returns null/false without throwing, so the transaction commits the hook's side effects and the 404 is thrown afterward in the route handler. E.g. a beforeDelete that writes an audit row on DELETE /notes/9999 (nonexistent) commits the audit row, then the caller gets 404 — persisted state is inconsistent with the reported outcome. Throw NOT_FOUND inside the transaction on a no-match.
Automated review finding.
| queryable.add(meta.columnName); | ||
| } | ||
| // A generated identity belongs to the server, never the caller. | ||
| if (meta.serverGenerated) continue; |
There was a problem hiding this comment.
[Medium] creatable lets the client choose the primary key for natural / defaultRandom PKs. creatable excludes only serverGenerated columns, so a uuid().primaryKey().defaultRandom() (or any natural PK) is settable via the create body: POST /:table {id:"chosen-uuid", …} overrides the generated default and lets a caller pick primary keys (id-squatting / prediction) — which, with no per-user authz, any caller can do. id()/bigid() tables are safe. Consider excluding all primary keys from creatable.
Automated review finding.
| } | ||
|
|
||
| upsert(values: Row, options: { onConflict: string }): Promise<Row> { | ||
| async upsert(values: Row, options: { onConflict: string }): Promise<Row> { |
There was a problem hiding this comment.
[Medium] upsert PK-rewrite is reachable through the typed upsert (carry-forward from #526). beforeUpsert replacements are re-validated by $insertSchema, which still includes natural PKs, and the underlying DataPath.upsert reuses the full payload as ON CONFLICT DO UPDATE SET — so a natural PK + cross-column onConflict rewrites the existing row's PK. Not reachable over HTTP (no upsert route), but a real integrity hole in the programmatic upsert. Fix at the runtime (set should exclude the PK).
Automated review finding.
Stack
Each PR targets the one above it, so the diff shown here is only the delta on top of #527. Review in order.
What
Completes the plugin: the typed entity API and the generated routes from #527 gain
create,update,upsert, anddelete, and a table can declare before/after hooks that run inside the mutation's own transaction. A write a hook issues commits or rolls back with the mutation that triggered it — that is the whole point of the design, and it is what lets a hook call another plugin's write and still get all-or-nothing semantics.Changes
Hooks share the mutation's transaction (
hooks.ts,scope.ts)A mutation opens its transaction first, then runs
before*, the write, andafter*inside it. The transaction is published through anAsyncLocalStorageowned by the plugin instance, soctx.app.databaseresolves to a client bound to that transaction without the caller threading it through. The storage is per-instance and per-async-context, so two plugin instances and two concurrent requests cannot observe each other's transaction.The same scope bounds recursion: hook-issued mutations open frames, and a repeated entity/operation pair or a chain deeper than 8 frames is refused rather than allowed to run until the pool or the stack gives out.
A
before*hook may return a replacement payload. It is revalidated against the trusted schema before it is persisted, so a hook cannot write a column the schema does not accept.A hook can reject deliberately (
errors/database-validation.ts)DatabaseValidationErroris exported from the root and answers a generated route with422. Only the issues naming a public column are echoed, and at most 50 of them. Every other failure raised inside a hook stays an opaque server error, so a hook cannot accidentally turn an internal message into a client-visible one.HTTP writes are narrower than trusted code's (
crud/request.ts)The write allowlist is derived per table and is deliberately smaller than what server code may set: the primary key, generated identities, and materialized stamps stay server-owned. A body naming an unknown or read-only field is refused rather than having the field silently dropped.
A rejection names the field only when that field is a public column of the table. Anything else — a private column, an unknown key, arbitrary caller markup — is answered against the generic
["body"]path, so an error response never reflects caller-controlled text back or confirms that a private column exists.Failure responses carry the same byte budget (
crud/response.ts)sendErrormeasures its encoded body like the success path does. If the issues would push the response past the limit, the answer keeps its status and its safe message and drops the details, so no error path can be used to return an unbounded body.where()now binds every terminal operation (entity-client.ts)update(id)anddelete(id)narrow by the accumulated predicate the same wayfind(id)already did, so a scoped client cannot be used to change a row outside its scope.createandupsertdo not select rows, so a predicate cannot apply to them — instead of ignoring it, they reject. No terminal operation silently discards fluent state anymore.jsonbvalues with a__proto__key (crud/contract.ts)The row sanitizer builds its objects with a null prototype, so
__proto__inside ajsonbpayload is carried as ordinary data and round-trips instead of reparenting the object it lands in.Verification
pnpm vitest run— 4162 passing, 1 skipped; new suites cover the hook lifecycle and its transaction, the recursion guard, the write allowlist, the response budgets, and an end-to-end CRUD integration pathpnpm -r typecheck— clean across all packagespnpm run generate:types,pnpm run sync:template, andpnpm run docs:buildproduce no drift