Chitragupt is a migration engine and a runtime ORM. The generated repository adapter isn't a thin SQL wrapper — it carries the behaviors that make persistence correct: the transaction boundary, tenant isolation, the outbox write, concurrency control, and the translation of database errors into typed application errors. You author entities and operations; the adapter does all of this without a line of persistence code from you.
The Unit of Work — one transaction per operation
Every command or use-case step runs inside a Unit of Work — a single database transaction the framework opens before the handler runs and commits when it succeeds (rolling back on any error). The generated adapter routes all of its reads and writes onto that transaction, so a multi-step operation is atomic by construction. The UoW spans two connection pools — the least-privileged app pool for request-path work, and the elevated system pool for the rare run_as_sudo write — but a normal operation lives entirely on the app pool, RLS-enforced.
Tenant isolation is injected per request
This is what makes RLS actually work at runtime. When the UoW opens its transaction, it sets the caller's identity as transaction-local session variables — app.tenant_id from the authenticated caller, plus one app.caller_<axis> per caller axis — before any query runs:
SET LOCAL app.tenant_id = '…'; -- from the authenticated callerEvery RLS policy reads current_setting('app.tenant_id'), so the moment the transaction is scoped, every read and write in it is filtered to the caller's tenant automatically. Because it's SET LOCAL, it resets at commit — no leakage between requests. A guard expression that references caller.role lowers to current_setting('app.caller_role'), so authorization expressions read the same injected identity.
The transactional outbox write
When a command emits a domain event, the adapter writes that event into the outbox table on the same transaction as the aggregate write — so the state change and the announcement commit or roll back together. This is the mechanism behind the transactional outbox: there is no window where an order is placed but OrderPlaced was lost, because both rows land in one commit. The outbox write refuses to run outside a UoW — the atomicity guarantee is enforced, not hoped for.
Concurrency, soft-delete, and audit — opt-in, handled for you
Several per-entity behaviors are the adapter's job, turned on by declaring them on the entity:
- Optimistic locking — when an entity carries a version field,
Updatebumps the version inSETand addsAND version = $expectedto theWHERE. If a concurrent write already moved the version, zero rows match and the update fails with a typed conflict rather than silently clobbering — lost-update protection with no locking code. - Soft-delete — when an entity opts into soft-delete, reads transparently add
AND deleted_at IS NULLandDeletebecomesUPDATE … SET deleted_at = now(). The row is retained but invisible, and the framework even generates the partial unique index (UNIQUE (…) WHERE deleted_at IS NULL) so a "deleted" slug can be reused. - Audit timestamps — Chowk's aggregates declare
audit timestamps, and the adapter treatscreated_at/updated_atas database-managed: they're server-stamped on write and returned into the struct, never bound from client input. The timestamps are authoritative because the database sets them.
Invariants enforced inside the write
A persistence invariant is a database CHECK, but the adapter also enforces invariants at write time: after the INSERT/UPDATE succeeds but before the method returns — still inside the transaction — it evaluates the entity's invariants, so a violation rolls the whole operation back. Chowk's StockCoversReservations can never be left violated: the CHECK refuses it at the database, and the framework surfaces the failure as the declared error.
Database errors become typed application errors
The last thing the adapter does is translate. A raw PostgreSQL error is a SQLSTATE code; the generated code maps each to the framework's typed errors so a caller — and a generated client — gets a meaningful, stable failure instead of a 500:
| PostgreSQL condition | SQLSTATE | Becomes |
|---|---|---|
| Unique violation | 23505 | AlreadyExists (409) |
| Check violation (invariant/spec) | 23514 | a validation error (422) |
| Foreign-key violation | 23503 | an invalid-reference error (422) |
| No row for a required read | — | not-found (404) |
So when Chowk's StockCoversReservations CHECK rejects an over-reservation, the caller doesn't see a database stack trace — they see the typed InsufficientStock 409 the model declared. The persistence layer speaks the domain's error language, because the adapter maps it there.
The whole picture
A single command handler, wrapped by the generated adapter, runs: open the UoW transaction → inject the caller's tenant/identity (RLS now active) → apply the guarded write → enforce invariants → write any emitted event to the outbox → commit — or, on any failure, roll all of it back and return a typed error. Every one of those steps is generated from your model. That is what it means for Chitragupt to be the ORM: the correctness of persistence is a property of the declaration, not of hand-written data-access code.