Features Apograph CMS on GitHub

Database access

Database plugin @apograph/database

Getting at the database, and running work inside one transaction.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

One plugin owns the connection. Everything else injects it.

import { InjectDatabase, type Database } from '@apograph/database';

@Injectable()
export class ReviewsService {
    constructor(@InjectDatabase() private readonly db: Database) {}
}

The database plugin opens a single Drizzle client over the pg driver in its init hook — which is why it is first in the plugin list — and provides it from a global module. There is also a plain getDatabase() / getPool() pair for code outside dependency injection.

Annotate with Database, not the dialect-specific type. That is what makes a dialect change a one-line edit in one package.

Transactions

import { UnitOfWork } from '@apograph/database';

await this.uow.run(async () => {
    await this.reviews.insert(review);
    await this.outbox.append([reviewCreated(review)]);
});

run(fn) executes fn inside one transaction. A nested run joins the outer transaction rather than opening a new one, so a use case that calls another use case still commits once.

Repositories call current() to get the executor, so every query in the tree joins the ambient transaction implicitly — there is no tx parameter to thread through every function.

Outside a run, current() falls back to the pool, because a read outside a unit of work is an ordinary read.

The rules that matter

Lock a contended invariant — never count then write

Reading a count and then inserting based on it is a race: two requests both read “three of five used” and both insert. Take a row lock, or express the invariant as a constraint and handle the violation.

This is the single most common correctness bug in this kind of code, and the codebase’s own review checklist calls it out by name.

No foreign keys across plugin boundaries

Your plugin’s tables may not reference another plugin’s. A workspace_id is a plain uuid and the relationship is enforced in the application layer.

The upside is that a plugin can be removed, and that the schema does not become one interlocked graph. The cost is that referential integrity for those columns is your code’s responsibility, not Postgres’s.

Owning schema

Tables go in your package, with a drizzle.config.ts alongside them. The migration targets appear automatically:

npx nx run reviews-server:db:generate --name=create_reviews
npx nx run server:db:migrate

Each plugin’s migrations are tracked in its own table, named in the plugin’s migrations descriptor, so histories stay independent. See migrations.

Reads that outlive a request

Nothing in Apograph caches database reads across requests. There is no second-level cache, no query cache and no Redis. A read is a query.

If you need caching, it belongs in front of the API or inside your plugin, and it is yours to invalidate.

What is not here

One database, one dialect, no read replicas

There is one connection string and one pool. There is no read-replica routing, no sharding, no multi-tenant database-per-workspace, and PostgreSQL is the only dialect — Drizzle abstracts the query building, not the schema, and the generated content tables use Postgres types directly.