@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.
- package/README.md +132 -4
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
package/src/db/README.md
ADDED
|
@@ -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
|