Skip to content

feat(appkit): add database mutations and transactional hooks - #528

Open
ditadi wants to merge 1 commit into
stack/database-mvp/03-crud-readsfrom
stack/database-mvp/04-mutations-hooks
Open

feat(appkit): add database mutations and transactional hooks#528
ditadi wants to merge 1 commit into
stack/database-mvp/03-crud-readsfrom
stack/database-mvp/04-mutations-hooks

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 #527. Review in order.

What

Completes the plugin: the typed entity API and the generated routes from #527 gain create, update, upsert, and delete, 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.

database({
  schema,
  crudRoutes: { tables: ["notes"] },
  hooks: {
    notes: {
      beforeCreate(values) {
        if (values.body.length > 5_000) {
          throw new DatabaseValidationError("Note too long", [
            { path: ["body"], message: "Must be at most 5000 characters" },
          ]);
        }
        return { ...values, body: values.body.trim() };
      },
      async afterCreate(row, ctx) {
        await ctx.app.database.audit.create({ noteId: row.id });
      },
    },
  },
});
POST   /api/database/notes      → 201
PATCH  /api/database/notes/42   → 200
PUT    /api/database/notes/42   → 200
DELETE /api/database/notes/42   → 204

Changes

Hooks share the mutation's transaction (hooks.ts, scope.ts)

A mutation opens its transaction first, then runs before*, the write, and after* inside it. The transaction is published through an AsyncLocalStorage owned by the plugin instance, so ctx.app.database resolves 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)

DatabaseValidationError is exported from the root and answers a generated route with 422. 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)

sendError measures 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) and delete(id) narrow by the accumulated predicate the same way find(id) already did, so a scoped client cannot be used to change a row outside its scope. create and upsert do not select rows, so a predicate cannot apply to them — instead of ignoring it, they reject. No terminal operation silently discards fluent state anymore.

jsonb values with a __proto__ key (crud/contract.ts)

The row sanitizer builds its objects with a null prototype, so __proto__ inside a jsonb payload 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 path
  • pnpm -r typecheck — clean across all packages
  • pnpm run generate:types, pnpm run sync:template, and pnpm run docs:build produce no drift
  • Each of the four security fixes above was verified by reverting it and confirming the new test fails

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>
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