@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ # @spfn/core/db — Type-safe PostgreSQL data access (Drizzle ORM)
2
+
3
+ Standalone CRUD helpers, a `BaseRepository` base class, transaction-aware read/write
4
+ routing, schema helpers, and PostgreSQL error mapping — built on PostgreSQL Drizzle drivers,
5
+ with postgres.js as the default and external providers such as PGlite supported. Connection
6
+ management, schema column helpers, and transactions each live in a
7
+ sub-module with its own README (linked below); this file covers the **main module**
8
+ (helpers, repository, query-utils, postgres-errors) and the connection/transaction entry
9
+ points re-exported from it.
10
+
11
+ ## Import paths
12
+
13
+ Everything ships from a **single** entry point:
14
+
15
+ ```typescript
16
+ import {
17
+ // CRUD helpers
18
+ findOne, findMany, create, createMany, upsert,
19
+ updateOne, updateMany, deleteOne, deleteMany, count,
20
+ // Repository
21
+ BaseRepository, RepositoryError,
22
+ // Connection (sub-module: manager/)
23
+ initDatabase, getDatabase, closeDatabase, getDatabaseInfo,
24
+ // Transactions (sub-module: transaction/)
25
+ Transactional, runWithTransaction, getTransaction,
26
+ onBeforeCommit, onAfterCommit, onAfterRollback,
27
+ // Schema helpers (sub-module: schema/)
28
+ id, uuid, timestamps, foreignKey, enumText, typedJsonb, createSchema,
29
+ // Error mapping
30
+ fromPostgresError,
31
+ } from '@spfn/core/db';
32
+ ```
33
+
34
+ There is no deeper public import path (no `@spfn/core/db/helpers`); import from
35
+ `@spfn/core/db`.
36
+
37
+ ---
38
+
39
+ ## Public API (complete)
40
+
41
+ Everything re-exported from `@spfn/core/db` (`src/db/index.ts`):
42
+
43
+ **CRUD helpers** (`helpers.ts`) — standalone, no class needed:
44
+ `findOne`, `findMany`, `create`, `createMany`, `upsert`, `updateOne`, `updateMany`,
45
+ `deleteOne`, `deleteMany`, `count`
46
+
47
+ **Repository** (`repository.ts`):
48
+ `BaseRepository` (abstract class), `RepositoryError` (class), `RepositoryDatabase` (type)
49
+
50
+ **PostgreSQL error mapping** (`postgres-errors.ts`):
51
+ `fromPostgresError`
52
+
53
+ **Connection / manager** (sub-module — see [manager/README](./manager/README.md)):
54
+ `createDatabaseFromEnv`, `initDatabase`, `getDatabase`, `setDatabase`,
55
+ `setDatabaseProvider`, `closeDatabase`,
56
+ `getDatabaseInfo`, `forceReconnectDatabase`, `createDatabaseConnection`, `checkConnection`,
57
+ `reportDatabaseError`, `isConnectionLevelError`, `resetConnectionErrorCounter`,
58
+ `getDrizzleConfig`, `detectDialect`, `generateDrizzleConfigFile`
59
+ Types: `DatabaseClients`, `DatabaseInitOptions`, `DatabaseOptions`, `DatabaseProvider`,
60
+ `DatabaseTransaction`, `DefaultDatabase`, `DrizzleDatabase`, `PoolConfig`, `RetryConfig`,
61
+ `DrizzleConfigOptions`
62
+
63
+ **Transactions** (sub-module — see [transaction/README](./transaction/README.md)):
64
+ `Transactional`, `getTransaction`, `runWithTransaction`, `runInTransaction`,
65
+ `onBeforeCommit`, `onAfterCommit`, `onAfterRollback`
66
+ Types: `TransactionContext`, `TransactionDB`, `TransactionalOptions`,
67
+ `RunInTransactionOptions`, `BeforeCommitCallback`, `AfterCommitCallback`,
68
+ `AfterRollbackCallback`
69
+
70
+ **Schema helpers** (sub-module — see [schema/README](./schema/README.md), re-exported via
71
+ `export * from './schema'`):
72
+ `id`, `uuid`, `timestamps`, `foreignKey`, `optionalForeignKey`, `auditFields`,
73
+ `publishingFields`, `softDelete`, `verificationTimestamp`, `utcTimestamp`, `enumText`,
74
+ `typedJsonb`, `createSchema`, `packageNameToSchema`, `getSchemaInfo`
75
+
76
+ > **No such API.** There is **no** `update`, `deleteById`, `findById`, `save`, or `insert`
77
+ > top-level helper — the helpers are exactly the ten listed above. `BaseRepository` exposes
78
+ > CRUD as **protected `_`-prefixed methods** (`_findOne`, `_create`, …) — they are not
79
+ > callable from outside the class. There is no public `getDatabaseOrThrow` (use
80
+ > `getDatabase`, which already throws when uninitialized) and no `db.update()` chain helper
81
+ > beyond what Drizzle itself provides.
82
+
83
+ ---
84
+
85
+ ## Quick Start
86
+
87
+ ```typescript
88
+ import { BaseRepository, initDatabase, Transactional } from '@spfn/core/db';
89
+ import { id, timestamps, enumText } from '@spfn/core/db';
90
+ import { pgTable, text } from 'drizzle-orm/pg-core';
91
+ import { desc, isNull } from 'drizzle-orm';
92
+
93
+ // 1. Define a table with schema helpers
94
+ const USER_STATUS = ['active', 'inactive'] as const;
95
+
96
+ export const users = pgTable('users', {
97
+ id: id(), // bigserial PK
98
+ email: text('email').notNull().unique(),
99
+ name: text('name'),
100
+ status: enumText('status', USER_STATUS).default('active').notNull(),
101
+ ...timestamps(), // createdAt, updatedAt (timestamptz)
102
+ });
103
+
104
+ export type User = typeof users.$inferSelect;
105
+ export type NewUser = typeof users.$inferInsert;
106
+
107
+ // 2. Repository — extend BaseRepository, export a singleton
108
+ export class UserRepository extends BaseRepository
109
+ {
110
+ async findById(id: number): Promise<User | null>
111
+ {
112
+ return this._findOne(users, { id });
113
+ }
114
+
115
+ async findActive(limit = 10): Promise<User[]>
116
+ {
117
+ return this._findMany(users, {
118
+ where: { status: 'active' },
119
+ orderBy: desc(users.createdAt),
120
+ limit,
121
+ });
122
+ }
123
+
124
+ async create(data: NewUser): Promise<User>
125
+ {
126
+ return this._create(users, data);
127
+ }
128
+ }
129
+
130
+ export const userRepo = new UserRepository();
131
+
132
+ // 3. Initialize once (startServer() does this automatically; manual only in scripts)
133
+ await initDatabase();
134
+
135
+ // 4. Use inside a transactional route — auto commit / rollback
136
+ export const middlewares = [Transactional()];
137
+
138
+ export async function POST(c: RouteContext)
139
+ {
140
+ const user = await userRepo.create(await c.req.json());
141
+ return c.json(user, 201);
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## CRUD helpers (`helpers.ts`)
148
+
149
+ Standalone functions for operations that don't need a repository class. Each one resolves
150
+ the global DB instance internally (`getDatabase('read'|'write')`) and is transaction-aware
151
+ only through that instance — they do **not** read the `AsyncLocalStorage` transaction
152
+ context. **Inside a transaction, use `BaseRepository` methods or `getTransaction()`
153
+ instead** (see Pitfalls).
154
+
155
+ | Function | Signature | Returns |
156
+ |----------|-----------|---------|
157
+ | `findOne` | `findOne(table, where)` | `T \| null` |
158
+ | `findMany` | `findMany(table, options?)` | `T[]` |
159
+ | `create` | `create(table, data)` | `T` |
160
+ | `createMany` | `createMany(table, data[])` | `T[]` |
161
+ | `upsert` | `upsert(table, data, { target, set? })` | `T` |
162
+ | `updateOne` | `updateOne(table, where, data)` | `T \| null` |
163
+ | `updateMany` | `updateMany(table, where, data)` | `T[]` |
164
+ | `deleteOne` | `deleteOne(table, where)` | `T \| null` |
165
+ | `deleteMany` | `deleteMany(table, where)` | `T[]` |
166
+ | `count` | `count(table, where?)` | `number` |
167
+
168
+ Note the argument order: `where` comes **before** `data` for updates
169
+ (`updateOne(table, where, data)`), and delete takes only `where`
170
+ (`deleteOne(table, where)`). `T` is inferred from the table (`table.$inferSelect`).
171
+
172
+ ```typescript
173
+ import {
174
+ findOne, findMany, create, upsert, updateOne, deleteOne, count,
175
+ } from '@spfn/core/db';
176
+ import { eq, and, gt, desc } from 'drizzle-orm';
177
+
178
+ // Find — object where (equality, ANDed) OR a Drizzle SQL condition
179
+ const user = await findOne(users, { id: 1 });
180
+ const adult = await findOne(users, and(eq(users.id, 1), gt(users.age, 18)));
181
+ const active = await findMany(users, {
182
+ where: { status: 'active' },
183
+ orderBy: desc(users.createdAt),
184
+ limit: 10,
185
+ offset: 0,
186
+ });
187
+
188
+ // Create
189
+ const created = await create(users, { email: 'a@b.com', name: 'A' });
190
+
191
+ // Upsert (INSERT … ON CONFLICT DO UPDATE) — target is required, set defaults to data
192
+ const cache = await upsert(cmsCache, data, {
193
+ target: [cmsCache.section, cmsCache.locale],
194
+ set: { content: data.content, updatedAt: new Date() },
195
+ });
196
+
197
+ // Update — where then data; returns null if nothing matched
198
+ const updated = await updateOne(users, { id: 1 }, { name: 'New' });
199
+
200
+ // Delete — returns the deleted row(s)
201
+ const deleted = await deleteOne(users, { id: 1 });
202
+
203
+ // Count — where is optional
204
+ const total = await count(users);
205
+ const activeOnly = await count(users, { status: 'active' });
206
+ ```
207
+
208
+ `findOne` / `updateOne` / `updateMany` / `deleteOne` / `deleteMany` **throw** if the
209
+ resolved where clause is empty (`'<op> requires at least one where condition'`) — you
210
+ cannot accidentally update/delete the whole table. `findMany` and `count` allow no where.
211
+
212
+ ---
213
+
214
+ ## BaseRepository (`repository.ts`)
215
+
216
+ Abstract base class. Extend it to get transaction-aware connections plus the same CRUD set
217
+ as protected methods.
218
+
219
+ ```typescript
220
+ import { BaseRepository } from '@spfn/core/db';
221
+ import { eq, isNull, desc } from 'drizzle-orm';
222
+
223
+ export class UserRepository extends BaseRepository
224
+ {
225
+ // Protected getters provided by BaseRepository:
226
+ // this.db → write instance (tx-aware: uses the active transaction if any)
227
+ // this.readDb → read instance (replica if configured; tx-aware)
228
+
229
+ async findById(id: number)
230
+ {
231
+ return this._findOne(users, { id });
232
+ }
233
+
234
+ async findActive()
235
+ {
236
+ // Drop to raw Drizzle for anything the helpers can't express
237
+ return this.readDb.select().from(users).where(isNull(users.deletedAt));
238
+ }
239
+ }
240
+
241
+ export const userRepo = new UserRepository();
242
+ ```
243
+
244
+ ### Protected CRUD methods
245
+
246
+ Same semantics and argument order as the standalone helpers, prefixed with `_`. Use these
247
+ inside repository methods.
248
+
249
+ | Method | Returns |
250
+ |--------|---------|
251
+ | `_findOne(table, where)` | `T \| null` |
252
+ | `_findMany(table, options?)` | `T[]` |
253
+ | `_create(table, data)` | `T` |
254
+ | `_createMany(table, data[])` | `T[]` |
255
+ | `_upsert(table, data, { target, set? })` | `T` |
256
+ | `_updateOne(table, where, data)` | `T \| null` |
257
+ | `_updateMany(table, where, data)` | `T[]` |
258
+ | `_deleteOne(table, where)` | `T \| null` |
259
+ | `_deleteMany(table, where)` | `T[]` |
260
+ | `_count(table, where?)` | `number` |
261
+
262
+ These are `protected` — calling `userRepo._findOne(...)` from outside is a TypeScript
263
+ error. Expose a domain method (`findById`) instead.
264
+
265
+ ### `this.db` vs `this.readDb`
266
+
267
+ Both getters first check for an active transaction (`getTransaction()`); if one exists,
268
+ both return that transaction's DB so all work runs in the same transaction. Outside a
269
+ transaction, `this.db` returns the write/primary instance and `this.readDb` the read/replica
270
+ instance. Use `readDb` for SELECT, `db` for INSERT/UPDATE/DELETE. The `_`-helpers already do
271
+ this (`_findOne`/`_findMany`/`_count` use `readDb`; writes use `db`).
272
+
273
+ Their type is the injected database **or its matching transaction type**. Common Drizzle
274
+ query methods remain available in both contexts, while driver-only members such as a raw
275
+ `$client` are intentionally not exposed through these transaction-aware getters. Use the
276
+ provider outside repository operations when direct driver access is required.
277
+
278
+ ### `withContext` — error tracking
279
+
280
+ Wrap a raw query to attach repository/method/table context to failures and feed the
281
+ reconnect fast-path (see manager/README). On error it throws a `RepositoryError` carrying
282
+ `{ repository, method, table, originalError }`.
283
+
284
+ ```typescript
285
+ async findById(id: number)
286
+ {
287
+ return this.withContext(
288
+ () => this.readDb.select().from(users).where(eq(users.id, id)),
289
+ { method: 'findById', table: 'users' },
290
+ );
291
+ }
292
+ ```
293
+
294
+ The built-in `_`-helpers do **not** auto-wrap with `withContext`; wrap raw `this.db` /
295
+ `this.readDb` queries yourself when you want the enriched error + reconnect reporting.
296
+
297
+ ### Typed relations (optional)
298
+
299
+ ```typescript
300
+ import { defineRelations } from 'drizzle-orm';
301
+ import * as schema from './schema';
302
+
303
+ export const relations = defineRelations(schema);
304
+ export type AppRelations = typeof relations;
305
+
306
+ export class UserRepository extends BaseRepository<AppRelations>
307
+ {
308
+ // this.db and this.readDb preserve AppRelations
309
+ }
310
+ ```
311
+
312
+ For an injected driver, pass its database type as the second generic:
313
+
314
+ ```typescript
315
+ import type { PgliteDatabase } from 'drizzle-orm/pglite';
316
+
317
+ type AppDatabase = PgliteDatabase<AppRelations>;
318
+
319
+ export class UserRepository extends BaseRepository<AppRelations, AppDatabase>
320
+ {
321
+ // this.db and this.readDb are AppDatabase
322
+ }
323
+ ```
324
+
325
+ ---
326
+
327
+ ## Where clauses & query options (`query-utils.ts`)
328
+
329
+ Both the helpers and `BaseRepository._*` methods accept the same two `where` forms,
330
+ resolved by the internal `buildWhereFromObject` / `isSQLWrapper` utilities (not exported):
331
+
332
+ ```typescript
333
+ import { eq, and, or, gt, like, isNull, inArray, desc, asc } from 'drizzle-orm';
334
+
335
+ // 1. Object form — equality only, ANDed together. undefined values are dropped.
336
+ await this._findOne(users, { email: 'a@b.com', status: 'active' });
337
+ // → WHERE email = 'a@b.com' AND status = 'active'
338
+
339
+ // 2. SQL form — any Drizzle condition, for non-equality / OR / IN / NULL / etc.
340
+ await this._findMany(users, { where: and(eq(users.role, 'admin'), gt(users.age, 18)) });
341
+ await this._findMany(users, { where: or(eq(users.role, 'admin'), eq(users.role, 'mod')) });
342
+ await this._findMany(users, { where: inArray(users.id, [1, 2, 3]) });
343
+ await this._findMany(users, { where: isNull(users.deletedAt) });
344
+ await this._findMany(users, { where: like(users.email, '%@example.com') });
345
+ ```
346
+
347
+ `findMany` / `_findMany` options: `{ where?, orderBy?, limit?, offset? }`.
348
+ `orderBy` accepts a single `SQL` or an array:
349
+
350
+ ```typescript
351
+ await this._findMany(users, {
352
+ where: { status: 'active' },
353
+ orderBy: [desc(users.createdAt), asc(users.name)],
354
+ limit: 20,
355
+ offset: 40,
356
+ });
357
+ ```
358
+
359
+ > An **empty object** `{}` (or one whose values are all `undefined`) resolves to *no
360
+ > condition*. For `findOne`/`updateOne`/`deleteOne` that triggers the
361
+ > "requires at least one where condition" throw — build the SQL `where` conditionally
362
+ > (`conditions.length ? and(...conditions) : undefined`) only for `findMany`/`count`,
363
+ > which permit it.
364
+
365
+ ---
366
+
367
+ ## PostgreSQL error mapping (`postgres-errors.ts`)
368
+
369
+ `fromPostgresError(error)` maps a postgres.js / Drizzle error (by SQLSTATE `code`) to a
370
+ typed `@spfn/core/errors` class with the right HTTP status:
371
+
372
+ ```typescript
373
+ import { fromPostgresError } from '@spfn/core/db';
374
+
375
+ try {
376
+ await create(users, data);
377
+ } catch (err) {
378
+ throw fromPostgresError(err);
379
+ }
380
+ ```
381
+
382
+ | SQLSTATE | Mapped error |
383
+ |----------|--------------|
384
+ | `08xxx`, `53xxx`, `57xxx` (connection / resources / operator) | `ConnectionError` |
385
+ | `23505` unique_violation | `DuplicateEntryError` (parses `Key (field)=(value)`) |
386
+ | `23502`/`23503`/`23514`/`23000`/`23001` constraints | `ConstraintViolationError` |
387
+ | `40001` etc. transaction rollback | `TransactionError` |
388
+ | `40P01` deadlock_detected | `DeadlockError` |
389
+ | `42xxx` syntax / undefined object | `QueryError` (status 400) |
390
+ | anything else | `QueryError` (status 500) |
391
+
392
+ Inside `BaseRepository.withContext` and `Transactional` middleware, query errors are already
393
+ reported to the reconnect fast-path; `fromPostgresError` is for explicit conversion to a
394
+ client-facing typed error.
395
+
396
+ ---
397
+
398
+ ## Sub-modules
399
+
400
+ These have their own READMEs — do not duplicate their APIs here, link to them:
401
+
402
+ - [manager/README.md](./manager/README.md) — connection lifecycle (`initDatabase`,
403
+ `getDatabase`, `closeDatabase`, `getDatabaseInfo`), pooling, env vars
404
+ (`DATABASE_URL` / `DATABASE_WRITE_URL` / `DATABASE_READ_URL`), health checks, pool
405
+ rebuild / reconnect (`forceReconnectDatabase`, `reportDatabaseError`), and the Drizzle
406
+ config generator.
407
+ - [transaction/README.md](./transaction/README.md) — `Transactional()` middleware,
408
+ `runWithTransaction` / `runInTransaction`, `getTransaction`, the `onBeforeCommit` /
409
+ `onAfterCommit` / `onAfterRollback` hooks, and the `AsyncLocalStorage`-based context.
410
+ - [schema/README.md](./schema/README.md) — column helpers (`id`, `uuid`, `timestamps`,
411
+ `foreignKey`, `enumText`, `typedJsonb`, …) and PostgreSQL schema isolation
412
+ (`createSchema`, `packageNameToSchema`, `getSchemaInfo`).
413
+ - [migrations/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts)
414
+ (linked to the source on GitHub: this sub-module has no README, and the package ships
415
+ only READMEs) — which migrations each installed function
416
+ package ships and which the database has applied: `discoverFunctionMigrations`,
417
+ `collectMigrationStatus`, `pendingMigrationTargets`, `countPendingMigrations`. Read-only
418
+ — applying migrations is `spfn db migrate`. One implementation behind `spfn db status`,
419
+ the server's migration boot gate and the detailed health payload, so they cannot
420
+ disagree about what "pending" means.
421
+
422
+ ---
423
+
424
+ ## Pitfalls & anti-patterns
425
+
426
+ - **`getDatabase()` throws when uninitialized — it does not return `null`.** Call
427
+ `initDatabase()` first (the server does this for you; only scripts/tests need it).
428
+ Helpers and repositories surface this as a thrown "Database not initialized" error.
429
+ - **Standalone helpers are not transaction-context aware.** `findOne`/`create`/… resolve the
430
+ *global* read/write instance, not the `AsyncLocalStorage` transaction. Inside a
431
+ `Transactional()` route or `runInTransaction`, call **`BaseRepository._*` methods**
432
+ (which check `getTransaction()`) or `getTransaction()` directly — otherwise the write
433
+ escapes the transaction and won't roll back.
434
+ - **`_findOne`/`_updateOne`/`_deleteOne` (and their plural update/delete forms) throw on an
435
+ empty where.** This is a safety rail against full-table writes — pass a real condition.
436
+ Only `findMany`/`_findMany`/`count`/`_count` accept "no where".
437
+ - **Argument order is `(table, where, data)` for updates** and `(table, where)` for deletes.
438
+ Don't pass `data` before `where`.
439
+ - **Object where is equality + AND only.** For `>`, `<`, `LIKE`, `IN`, `IS NULL`, or `OR`,
440
+ use a Drizzle SQL condition (`and(eq(...), gt(...))`) — passing an object can't express
441
+ those.
442
+ - **Protected `_` methods aren't callable externally.** `userRepo._findOne(...)` is a
443
+ compile error by design; wrap them in a domain method on the repository.
444
+ - **Use `this.readDb` for reads, `this.db` for writes in raw queries.** Reaching for
445
+ `this.db` on a SELECT skips the read replica; reaching for `this.readDb` on a write hits
446
+ the replica (read-only / stale). The `_`-helpers already route correctly.
447
+ - **Export repositories as singletons.** `export const userRepo = new UserRepository()`.
448
+ Transaction propagation works through `AsyncLocalStorage`, so a shared instance is correct
449
+ — you do not pass a `db`/`tx` handle around. (Fresh instances in tests are fine.)
450
+ - **Don't start your own transaction inside repository write methods.** Let route
451
+ `Transactional()` middleware (or an explicit `runInTransaction`) own the boundary.
452
+ - **`upsert` requires `target`.** `set` is optional and defaults to the inserted `data`;
453
+ pass `set` explicitly (e.g. `updatedAt: new Date()`, or a `sql\`…\`` expression) when the
454
+ conflict update should differ from the insert.
455
+
456
+ ---
457
+
458
+ ## Complete example
459
+
460
+ ```typescript
461
+ // src/server/entities/users.ts
462
+ import { pgTable, text } from 'drizzle-orm/pg-core';
463
+ import { id, timestamps, softDelete, enumText } from '@spfn/core/db';
464
+
465
+ const ROLE = ['user', 'admin'] as const;
466
+
467
+ export const users = pgTable('users', {
468
+ id: id(),
469
+ email: text('email').notNull().unique(),
470
+ name: text('name'),
471
+ role: enumText('role', ROLE).default('user').notNull(),
472
+ ...softDelete(), // deletedAt, deletedBy
473
+ ...timestamps(), // createdAt, updatedAt
474
+ });
475
+
476
+ export type User = typeof users.$inferSelect;
477
+ export type NewUser = typeof users.$inferInsert;
478
+ ```
479
+
480
+ ```typescript
481
+ // src/server/repositories/user.repository.ts
482
+ import { BaseRepository } from '@spfn/core/db';
483
+ import { and, eq, desc, isNull } from 'drizzle-orm';
484
+ import { users, type User, type NewUser } from '../entities/users';
485
+
486
+ export class UserRepository extends BaseRepository
487
+ {
488
+ async findById(id: number): Promise<User | null>
489
+ {
490
+ return this._findOne(users, { id });
491
+ }
492
+
493
+ async createWithDedup(data: NewUser): Promise<User>
494
+ {
495
+ const existing = await this._findOne(users, { email: data.email });
496
+ if (existing)
497
+ {
498
+ throw new Error('Email already exists');
499
+ }
500
+ return this._create(users, data);
501
+ }
502
+
503
+ async softDelete(id: number, deletedBy: string): Promise<User | null>
504
+ {
505
+ return this._updateOne(users, { id }, { deletedAt: new Date(), deletedBy });
506
+ }
507
+
508
+ async findActiveAdmins(): Promise<User[]>
509
+ {
510
+ return this._findMany(users, {
511
+ where: and(eq(users.role, 'admin'), isNull(users.deletedAt)),
512
+ orderBy: desc(users.createdAt),
513
+ });
514
+ }
515
+
516
+ async paginate(page: number, limit: number)
517
+ {
518
+ const offset = (page - 1) * limit;
519
+ const [items, total] = await Promise.all([
520
+ this._findMany(users, { orderBy: desc(users.createdAt), limit, offset }),
521
+ this._count(users),
522
+ ]);
523
+ return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
524
+ }
525
+ }
526
+
527
+ export const userRepo = new UserRepository();
528
+ ```
529
+
530
+ ```typescript
531
+ // src/server/routes/users.ts — Transactional boundary owned by the route
532
+ import { Transactional, runInTransaction } from '@spfn/core/db';
533
+ import { userRepo } from '../repositories/user.repository';
534
+ import { profileRepo } from '../repositories/profile.repository';
535
+
536
+ export const middlewares = [Transactional()];
537
+
538
+ export async function POST(c: RouteContext)
539
+ {
540
+ // Single transaction (middleware) — both writes commit/rollback together
541
+ const user = await userRepo.createWithDedup(await c.req.json());
542
+ return c.json(user, 201);
543
+ }
544
+
545
+ // Or an explicit boundary outside a route:
546
+ export async function signup(data: NewUser, profile: NewProfile)
547
+ {
548
+ return runInTransaction(async () =>
549
+ {
550
+ const user = await userRepo.createWithDedup(data);
551
+ await profileRepo.create({ ...profile, userId: user.id });
552
+ return user;
553
+ });
554
+ }
555
+ ```
556
+
557
+ ---
558
+
559
+ ## Types reference
560
+
561
+ ```typescript
562
+ // Inferred per-table (Drizzle):
563
+ type User = typeof users.$inferSelect; // SELECT row shape
564
+ type NewUser = typeof users.$inferInsert; // INSERT shape
565
+
566
+ // BaseRepository generic:
567
+ abstract class BaseRepository<
568
+ TRelations extends AnyRelations = EmptyRelations,
569
+ TDatabase extends DrizzleDatabase = PostgresJsDatabase<TRelations>,
570
+ >
571
+
572
+ // RepositoryError fields:
573
+ class RepositoryError extends Error {
574
+ repository: string;
575
+ method?: string;
576
+ table?: string;
577
+ originalError?: Error;
578
+ }
579
+ ```
580
+
581
+ Connection, transaction, and schema types are documented in their sub-module READMEs.
582
+
583
+ ## Related
584
+
585
+ - [manager/README.md](./manager/README.md) — connection lifecycle, pooling, reconnect
586
+ - [transaction/README.md](./transaction/README.md) — transaction context & middleware
587
+ - [schema/README.md](./schema/README.md) — column helpers & schema isolation
588
+ - [@spfn/core/errors](../errors/README.md) — error classes returned by `fromPostgresError`
589
+ - [Drizzle ORM](https://orm.drizzle.team/) — `eq`/`and`/`sql`/`pgTable` and the query builder