feat(appkit): add database mutations and transactional hooks - #528
Open
ditadi wants to merge 1 commit into
Open
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>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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