Framework integration ​
How a repo fits into a web server: where to create it, what to do per request, how to map errors to responses, and how to shut down. The examples are type-checked against Express 5, Fastify 5, Hono 4 and React 19 types.
One repo per process ​
// db.ts: one repo for the whole process, created when the module is first imported.
export const repo = createRepo({
adapter: postgres({ connectionString: process.env.DATABASE_URL, pool: { max: 10 } }),
});- Create the repo once, at module scope, and import it where it is needed. It holds the connection pool; creating one per request would open a pool per request.
- Nothing connects until the first query, so importing the module never fails because the database is down. Call
repo.ping()at start-up, or in a health check, to find out. - There is no per-request object to create. A request that needs a transaction opens one with
repo.transactionand passestxon.
The request lifecycle ​
Express ​
const app = express();
app.use(express.json());
app.get("/posts/:id", async (req, res) => {
const post = await repo.getOrThrow(Post, Number(req.params.id), {
context: { requestId: req.get("x-request-id") },
});
res.json(post);
});
app.post("/posts", async (req, res) => {
const result = await repo.insert(Post.changeset(Post, req.body));
if (!result.success) {
res.status(422).json({ errors: result.error.errorMessages() });
return;
}
res.status(201).json(result.data);
});
// Errors the library throws, mapped to responses once (Express 5 passes rejected promises here).
const errors: ErrorRequestHandler = (error, _req, res, next) => {
if (error instanceof NoResultsError) res.status(404).json({ error: "not found" });
else if (error instanceof InvalidChangesetError)
res.status(422).json({ errors: error.changeset.errorMessages() });
else if (error instanceof DatabaseError && error.code === "timeout")
res.status(503).json({ error: "try again" });
else next(error);
};
app.use(errors);- A write returns a result: invalid input becomes a 422 with the field errors, without a
try. - The errors the library throws have classes and stable
codes (see Errors), so one error handler can map them. contextis passed through to thequeryevents of the call: put the request id there to find a request's statements in the logs.
Fastify ​
const app = Fastify({ logger: true });
// Slow statements go to Fastify's logger, with the call's context (the request id below).
const stopListening = repo.on("query", (event) => {
if (event.durationMs > 200)
app.log.warn({ sql: event.sql, ms: event.durationMs, ...event.context }, "slow query");
});
app.get<{ Params: { id: string } }>("/posts/:id", async (request, reply) => {
const post = await repo.get(Post, Number(request.params.id), { context: { requestId: request.id } });
return post ?? reply.code(404).send({ error: "not found" });
});
// The repo outlives the app (tests may build several): only the listener goes with it.
// Close the repo itself at shutdown (below).
app.addHook("onClose", async () => stopListening());repo.on("query", …) sees every statement, with the call's context. Here it logs the slow ones through Fastify's logger; log: true on the repo writes every statement to standard error instead. repo.on returns the function that removes the listener, so an app built twice (in tests, say) does not leave the first app's listener behind.
Hono ​
const app = new Hono();
app.get("/posts", async (c) => {
const posts = await repo.all(
from(Post, "p")
.orderBy(({ p }) => p.id.desc())
.limit(20),
{ signal: c.req.raw.signal }, // a client that goes away cancels the query
);
return c.json(posts);
});
// One transaction for one request, when the handler needs it: passed on explicitly.
app.post("/posts/batch", async (c) => {
const titles = await c.req.json<string[]>();
const created = await repo.transactionOrThrow(async (tx) => {
const rows: Post[] = [];
for (const title of titles) rows.push(await tx.insertOrThrow(Post.changeset(Post, { title })));
return rows;
});
return c.json(created, 201);
});
app.onError((error, c) =>
error instanceof InvalidChangesetError
? c.json({ errors: error.changeset.errorMessages() }, 422)
: c.json({ error: "internal error" }, 500),
);signalcancels the query on the server when it aborts. Passing the request's signal stops the work of a request whose client went away, where the runtime aborts that signal.- A transaction is opened by the handler that needs it, and only the calls on
txare in it. There is no transaction per request behind your back.
Next.js ​
Reusing the repo in development ​
In development, Next.js evaluates modules again when files change, and a module-scope repo would open a new pool each time. Keep it on globalThis outside production:
// lib/db.ts in a Next.js app. In development the module is evaluated again on every change;
// keeping the repo on globalThis keeps one pool instead of one per reload.
const globalForDb = globalThis as typeof globalThis & { egonRepo?: Repo };
export const db =
globalForDb.egonRepo ?? createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
if (process.env.NODE_ENV !== "production") globalForDb.egonRepo = db;Server actions that return changeset errors ​
// app/posts/actions.ts
// "use server";
export async function createPost(_state: unknown, form: FormData) {
const result = await db.insert(Post.changeset(Post, { title: form.get("title") }));
if (!result.success) return { errors: result.error.errorMessages() }; // plain data: crosses to the client
return { id: result.data.id };
}errorMessages() is plain, serialisable data, typed by field, so a server action can return it to the client as it is. The form errors recipe shows the React side, with useActionState.
Typing responses ​
A row is written as JSON by res.json(row), c.json(row) or Response.json(row), and what the client receives is not the row's class: dates are strings, bigints are decimal strings, redacted and virtual fields are gone. Json<T> is that type, and JsonReturn<typeof fn> is Json of what a function resolves to, so the type of a response comes from the code that makes it:
// The response type comes from the function that makes it, preloads included.
export async function loadArticle(id: number) {
return db.getByOrThrow(from(Article, "a").preload("writer"), { id });
}
export type ArticleResponse = JsonReturn<typeof loadArticle>;
// { id: number; writerId: number; title: string; publishedAt: string | null;
// writer: { id: number; name: string } }To name the type from the schema instead, give Loaded the preloads; or write the spec once, as const satisfies PreloadSpec<…>, and use it for both the query and the type:
// Named from the schema: the row type with its preloads...
export type ArticleWithWriter = Json<Loaded<typeof Article, "writer">>;
// ...or one spec, used by the query and by the type, so that the two cannot drift apart.
const listPreloads = { writer: true } as const satisfies PreloadSpec<typeof Article>;
export type ArticleListItem = Json<Loaded<typeof Article, typeof listPreloads>>;
export const listArticles = () => db.all(from(Article, "a").preload(listPreloads));The errors a server action returns are typed the same way:
// What createPost returns, on the client: the errors are typed by field.
export type CreatePostState = JsonReturn<typeof createPost>;
export type PostErrors = ErrorMessages<ReturnType<typeof Post.changeset>>;- Type a React client component's props with
JsonReturnof the server function whose result it receives. Rows are class instances, which React does not pass from a server component to a client component; their JSON, from a route handler or a server action, is what the client gets. JsonReturnkeepsnull: a function that may find nothing has| nullin its type. Use the...OrThrowreads (a miss becomes a 404 in your error handler), orNonNullable<JsonReturn<…>>.- Write
JsonReturn<typeof fn>rather thanJson<Awaited<ReturnType<typeof fn>>>: on a loaded row,Json<Awaited<…>>can exceed TypeScript's instantiation depth ("Type instantiation is excessively deep"), depending on what was checked before it. - Errors as HTTP responses maps the errors to statuses.
Use the repo in server code only (server components, route handlers, server actions): it needs Node.js, not a browser or the Edge runtime.
Graceful shutdown ​
const server = app.listen(3000);
async function stop(signal: string) {
process.stderr.write(`${signal}: shutting down\n`);
// Stop accepting connections, and wait until the running requests have finished...
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
// ...then close the pool: no request is left to need it.
await repo.close();
}
process.once("SIGTERM", () => void stop("SIGTERM"));
process.once("SIGINT", () => void stop("SIGINT"));repo.close() waits for the queries that are running and for checked-out connections (a transaction in progress, an open stream), then ends the pool. Calls made after it reject with a DatabaseError whose code is "connection". So close the HTTP server first, and wait for its callback: server.close() returns at once, while requests that are still running may have queries left to run. A server with long-lived connections (keep-alive, streaming responses) may need a time limit on that wait.
Serverless functions ​
// A function handler: the repo lives outside it, so warm invocations reuse the connection.
const serverlessRepo = createRepo({
adapter: postgres({
connectionString: process.env.DATABASE_URL,
pool: { max: 1, idleTimeoutMs: 10_000, connectionTimeoutMs: 5_000 },
applicationName: "my-function",
}),
});
export async function handler(event: { pathParameters?: { id?: string } }) {
const post = await serverlessRepo.get(Post, Number(event.pathParameters?.id), { timeoutMs: 5_000 });
return post === null ? { statusCode: 404, body: "" } : { statusCode: 200, body: JSON.stringify(post) };
}- Create the repo outside the handler, so that warm invocations reuse its connection.
- Keep the pool small. Each running instance has its own pool: a hundred instances with
max: 10can ask for a thousand connections. Put a connection pooler such as PgBouncer in front of Postgres when instances scale out. - With a pooler in transaction mode, session state does not survive between transactions:
SETstatements and advisory locks taken throughrepo.checkoutare lost. Statements are sent without named server-side prepared statements, and cancellation requests pass through PgBouncer (see Adapters). - Set timeouts. A frozen or recycled instance can leave a request waiting;
timeoutMsper call, andconnectionTimeoutMson the pool, bound it. - Edge runtimes without Node.js's
netmodule cannot run thepg,mysql2orbetter-sqlite3drivers, and so cannot run the adapters.
Good to know
- No ambient state. The library keeps nothing per request: no current transaction, no identity map. Two requests share the pool and nothing else.
warnOnRootUseInTransaction: truein development warns when code inside a transaction callback uses the outer repo by mistake. See Transactions.- Testing a server: import the same repo in your tests and wrap each test in the sandbox, which routes the repo through a transaction that is rolled back.
See also ​
- Repo: setup, reads, writes
- Adapters: pool options and events
- Errors

