# Dynamic repositories

> Resolve a delegate by name at runtime with repository(), by TypeScript key or database table name.

URL: https://better-drizzle.com/docs/guides/dynamic-repositories

When the table is only known at runtime, `client.repository(name)` returns the same delegate you would get from `client.users`.

```ts
const users = client.repository('users');

const { data, pagination } = await users.paginate({ limit: 20 });
```

## Schema key or table name

Both names resolve to the same delegate object. With `users = sqliteTable('app_users', ...)` in the schema:

```ts
client.repository('users') === client.users; // true, schema key
client.repository('app_users') === client.users; // true, database table name
```

The accepted names are typed. `BetterRepositoryKey<typeof relations>` is the union of every schema key and database table name, and it is what `repository()` accepts:

```ts
import type { BetterRepositoryKey } from 'better-drizzle';

type RepositoryName = BetterRepositoryKey<typeof relations>;
// 'users' | 'app_users' | 'posts' | 'app_posts' | ...
```

The return type follows the name: `repository('app_users')` is typed as the `users` delegate.

## When the name does not exist

An unknown name throws a `BetterDrizzleError`:

| Field     | Value                            |
| --------- | -------------------------------- |
| `code`    | `REPOSITORY_NOT_FOUND`           |
| `status`  | `500`                            |
| `message` | `Repository "<name>" not found.` |
| `details` | `{ name }`                       |

The status is 500 because, for typed code, a missing repository is a programming error. When the name comes from a URL or job payload, check it against an allowlist first and answer 404 yourself, instead of letting user input reach `repository()`:

```ts title="lib/resources.ts"
import type { BetterRepositoryKey } from 'better-drizzle';

export const resources = [
	'users',
	'posts',
] as const satisfies readonly BetterRepositoryKey<typeof relations>[];

export type Resource = (typeof resources)[number];

export const isResource = (name: string): name is Resource =>
	(resources as readonly string[]).includes(name);
```

The `satisfies` clause fails to compile if an entry is not a real repository name, and the allowlist also keeps tables such as sessions or audit logs out of generic endpoints.

## Inside transactions and scoped clients

`repository()` exists on every client, and resolves against that client:

* `tx.repository('users')` returns the transaction-bound delegate (`tx.users`), so its queries run inside the transaction.
* `scoped.repository('users')` on a `$withContext()` clone carries the scoped `meta`.

```ts
import { resources, type Resource } from './lib/resources';

export async function purge(
	requestId: string,
	idsByResource: Record<Resource, number[]>,
) {
	await client.$withContext({ requestId }).transaction(async (tx) => {
		for (const name of resources) {
			const { count } = await tx.repository(name).deleteMany({
				where: { id: { in: idsByResource[name] } },
			});
			log.info('purged', { name, count });
		}
	});
}
```

Inside the callback, use `tx.repository(name)`, not `client.repository(name)`: only the transaction client's delegates are bound to the transaction.

## Example: generic admin list endpoint

One handler serves every allowlisted resource with offset pagination:



**better-drizzle**

```ts  
import { isResource } from './lib/resources';

export async function listResource(name: string, page: number, perPage = 25) {
	if (!isResource(name)) return null; // map to 404

	const { data, pagination } = await client.repository(name).paginate({
		limit: perPage,
		skip: (page - 1) * perPage,
		orderBy: [{ id: 'asc' }],
	});

	return { data, pagination };
}
```

**Drizzle**

```ts  
import { asc, count } from 'drizzle-orm';

const tables = { users, posts } as const;

export async function listResource(name: string, page: number, perPage = 25) {
	if (!(name in tables)) return null; // map to 404
	const table = tables[name as keyof typeof tables];
	const skip = (page - 1) * perPage;

	const [data, [{ total }]] = await Promise.all([
		db.select().from(table).orderBy(asc(table.id)).limit(perPage).offset(skip),
		db.select({ total: count() }).from(table),
	]);

	return {
		data,
		pagination: {
			type: 'offset',
			page,
			perPage,
			total,
			pageCount: Math.ceil(total / perPage),
			hasNext: skip + data.length < total,
			hasPrevious: page > 1,
		},
	};
}
```

`paginate()` takes `limit` and `skip`, not a page number; it derives `page` and `pageCount` for the response.

## Example: export job

A background job walks every allowlisted table in primary-key order with `cursor()`, so each batch is one indexed query regardless of table size:

```ts
import { resources, type Resource } from './lib/resources';

export async function exportAll(write: (name: Resource, rows: unknown[]) => Promise<void>) {
	const db = client.$withContext({ job: 'nightly-export' });

	for (const name of resources) {
		let after: { id: number } | undefined;

		do {
			const { data, pagination } = await db.repository(name).cursor({
				limit: 1_000,
				after,
				orderBy: [{ id: 'asc' }],
			});

			await write(name, data);
			after = (pagination.nextCursor ?? undefined) as { id: number } | undefined;
		} while (after);
	}
}
```

`cursor()` without `orderBy` pages by primary key ascending. Pass `orderBy` when you need another order, ending with a unique column.

## Rule of thumb

* Prefer `client.users` when the table is known statically. It reads better and needs no string.
* Use `repository()` when dispatch by name is the requirement: admin panels, generic CRUD routes, export or backfill jobs.
* Keep the arguments table-agnostic. `where`, `select`, `orderBy`, and `data` are typed per table, so options that only one table understands belong in code that uses that table's delegate directly.
