# Monorepos

> One runtime package owns defineSupabase and the request context; domain packages type their repositories from its db and never import the generated client.

Source: https://bettersupabase.com/docs/guides/monorepo

In a monorepo, keep one package that owns the Supabase definition and the
request context, and let every domain package take its `db` as a parameter.
The generated client then has one importer, a schema change regenerates one
file, and domain code works the same in a route handler, a job or a test.

```text
packages/
  runtime/   defineSupabase, the generated client, the server, the context types
  crm/       customers repositories: import type { Db } from '@acme/runtime'
  commerce/  shipping repositories: import type { Db } from '@acme/runtime'
apps/
  web/       bs.route and bs.action call crm and commerce with ctx.db
```

## The runtime package [#the-runtime-package]

```ts title="packages/runtime/src/index.ts"
import { defineSupabase } from "better-supabase";
import { createEdge, type EdgeOptions } from "better-supabase/edge";
import {
  type AuthSession,
  type ServerContext,
  toSession,
} from "better-supabase/server";

import { type Functions, type Models, schema } from "./generated.ts";

export const betterSupabase = defineSupabase(schema);

export type AppContext = ServerContext<Models, Functions, unknown>;
export type Db = AppContext["db"];

export function sessionOf(ctx: AppContext): AuthSession {
  return toSession(ctx.auth);
}

export function createRuntime(options: EdgeOptions) {
  return createEdge(betterSupabase, options);
}
```

Point `better-supabase gen` at this package (`"output": "src/generated.ts"` in
its `better-supabase.config.json`). A Next.js app calls `createNext(betterSupabase, ...)`
with the same `betterSupabase`, so every adapter shares one definition.

## The CLI in a workspace [#the-cli-in-a-workspace]

Run the CLI from any package, or pass `--cwd packages/runtime`. The
`supabase` directory usually stays at the repository root, and the CLI finds
it the way the Supabase CLI does: it reads `supabase/config.toml` from `--cwd`
or the nearest parent directory that has one, and stops at the directory that
holds `.git`. Ports, Auth hooks, `schema_paths` and the migrations then come
from that directory, and `keys` writes `signing_keys.json` next to it.

`better-supabase.config.*` is found the same way: the CLI loads the first one
it meets walking up from `--cwd`, so a single config at the repository root
serves every package, and `pnpm --filter @acme/runtime exec better-supabase gen`
needs no `--cwd`. Paths inside the config are relative to the directory that
holds the config file. `--config` takes precedence and stays relative to
`--cwd`.

With the config in the package, point the SQL modules and the seed at the root
`supabase` directory when you use them:

```ts title="packages/runtime/better-supabase.config.ts"
export default defineConfig({
  output: "src/generated.ts",
  sql: { dir: "../../supabase/schemas", testsDir: "../../supabase/tests" },
  seed: { output: "../../supabase/seeds/000_better_supabase.sql" },
});
```

`@supabase/postgrest-typegen`, which `gen` loads to match `supabase gen
types`, pins `oxfmt` as an optional peer. When the workspace uses a newer
`oxfmt`, pnpm reports a peer mismatch; allow it in `pnpm-workspace.yaml`:

```yaml title="pnpm-workspace.yaml"
peerDependencyRules:
  allowedVersions:
    "@supabase/postgrest-typegen>oxfmt": "*"
```

## Domain packages [#domain-packages]

Domain packages import only types from the runtime:

```ts title="packages/crm/src/customers.ts"
import type { Db } from "@acme/runtime";

export function activeCustomers(db: Db) {
  return db.customers.findMany({
    select: ["id", "name", "status"],
    where: { status: "active" },
    orderBy: { name: "asc" },
  });
}
```

Rows keep the runtime's casing, the `status` column keeps its enum type, and
the function returns a `Result`, so a route handler can return it as is.
Because `Db` is a type import, the domain package has no runtime dependency
on the generated client and no import cycle with the app.

The
[monorepo example](https://github.com/ScaleDockHQ/better-supabase/tree/main/apps/examples/monorepo)
has this layout as workspace packages: `runtime`, `crm` and `billing`, and a
Hono `api` that passes the caller's `db` to both. Its domain packages carry a
Turbo boundaries tag that rejects a dependency from one domain package on
another:

```json title="turbo.json"
{
  "boundaries": {
    "tags": { "domain": { "dependencies": { "deny": ["domain"] } } }
  }
}
```

To keep the boundary inside one package, add a test that fails when a domain
file imports anything but the runtime. The
[`validation-monorepo`](https://github.com/ScaleDockHQ/better-supabase/tree/main/tests/validation-monorepo)
workspace in this repository has one, plus type tests that pin each
repository's parameter to the runtime's `Db`.

## Bearer callers in domain code [#bearer-callers-in-domain-code]

`sessionOf(ctx)` gives domain code the caller as plain data. For an OAuth
client or an agent, `session.actor` and `session.delegation` say who acts
for the user and with which scopes (see
[bearer callers](/docs/frameworks/next#bearer-callers)). Pass the session
down when a domain function records who made a change; RLS keeps deciding
the rows.

## Errors across packages [#errors-across-packages]

Domain packages that use [better-result](https://github.com/dmmulroy/better-result)
convert at the boundary with `toBetterResult(result, Result, mapError)` and
back with `fromBetterResult(value)`. A `DbError`, or an error whose `cause`
is one, survives the round trip; other errors go through `mapError`. Both
work on `Result` and `AsyncResult`.