Skip to main content
Ship’s data layer is a single, typed service per table. You never hand-write a query builder or a repository — codegen-db.ts reads your schema files and generates a DbService for every pgTable, exported from @/db. One obvious way to read and write, the same on every table.
Standardised patterns. Zero guesswork.

Where it comes from

DbService lives in the @ship/db package; the generated wiring lives in apps/api/src/db.ts. Add or change a *.schema.ts file, run codegen, and @/db gains a fully typed service for that table:
The generated @/db looks like this — one DbService per table, plus transaction:
You import the default export and call methods off the table name:

Methods

Every db.<table> exposes the same surface. Reads take a uniform options object; writes return the affected rows. Inputs and outputs are inferred from the Drizzle table, so data, where columns and returned rows are all typed against the real schema.

Reads

Pagination

findPage runs the page query and a matching count in parallel and returns { results, count, pagesCount } — the exact shape of listResultSchema(itemSchema) from @/resources/base.schema, so it drops straight into an endpoint’s .output():

Writes

Every mutation returns the affected row(s) via RETURNING, so you rarely need a follow-up read.

The filter API

where is a plain object. A bare value means equality; an object of operators expresses everything else. OR and AND take arrays of nested filters.
Supported operators: A bare null is shorthand for isNull, which is why soft-delete reads spell it deletedAt: null. OR and AND nest arbitrarily.
Need raw SQL? where also accepts a Drizzle SQL expression directly, so you can drop down for anything the operator set doesn’t cover.

Ordering

orderBy maps columns to a direction:

Soft delete

Every table built on baseColumns (from @/resources/base.schema) carries a deletedAt timestamp:
There’s no magic auto-exclusion: a soft delete is an updateOne that sets deletedAt, and live reads filter deletedAt: null explicitly.
deleteOne / deleteMany are real DELETE statements. Prefer the soft-delete pattern for user-facing data and keep deletedAt: null in your read filters.

Relation loading

Pass with or columns to findFirst / find / findPage and the call transparently delegates to Drizzle’s relational query builder, keeping precise inference of the joined result.
Relation loading requires an apps/api/src/relations.ts. When it exists, codegen-db.ts emits the relations-aware generic — DbService<typeof table, typeof rawDb.query.table> — so with/columns results are fully typed. Without it, the generated service is DbService<typeof table> and plain reads still work.

Mutations emit events

Any resource with a handlers/ directory is auto-wired so its DbService publishes a typed MutationEvent after each write commits:
Subscribe in <resource>/handlers/*.ts to run side effects without coupling them to the endpoint that caused the change:
Event keys are <table>.{insert,update,delete}. See the event handlers model for how this keeps writes decoupled from their side effects.

In an endpoint

The data service is the body of most handlers. Compose gates with .use(), declare the contract with .input()/.output(), then call db:
For writes that span more than one table, wrap them in db.transaction.