@martinffx/atelier 1.4.0 → 3.0.0
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/CHANGELOG.md +38 -0
- package/README.md +95 -220
- package/agents/architect.md +6 -6
- package/agents/oracle.md +1 -1
- package/agents/{recon.md → sentinel.md} +3 -3
- package/dist/atelier.js +167 -26
- package/package.json +2 -1
- package/skills/atelier-orchestrator/SKILL.md +180 -0
- package/skills/atelier-setup/SKILL.md +80 -0
- package/skills/code-review/SKILL.md +2 -2
- package/skills/code-review/references/reviewers.md +3 -3
- package/skills/code-review/references/rq.md +9 -9
- package/skills/code-subagents/SKILL.md +44 -75
- package/skills/code-subagents/references/batch-reviewer-prompt.md +44 -0
- package/skills/code-subagents/references/implementor-prompt.md +10 -11
- package/skills/spec-brainstorm/SKILL.md +21 -16
- package/skills/spec-finish/SKILL.md +14 -14
- package/skills/spec-implement/SKILL.md +42 -42
- package/skills/spec-plan/SKILL.md +72 -26
- package/skills/spec-plan/references/plan_template.md +77 -0
- package/skills/code-subagents/references/code-quality-reviewer-prompt.md +0 -48
- package/skills/code-subagents/references/spec-reviewer-prompt.md +0 -62
- package/skills/python-architecture/SKILL.md +0 -148
- package/skills/python-architecture/references/data-modeling.md +0 -558
- package/skills/python-architecture/references/ddd.md +0 -569
- package/skills/python-architecture/references/functional-core.md +0 -337
- package/skills/python-build-tools/SKILL.md +0 -153
- package/skills/python-build-tools/references/basedpyright.md +0 -321
- package/skills/python-build-tools/references/ruff.md +0 -225
- package/skills/python-build-tools/references/uv.md +0 -257
- package/skills/python-fastapi/SKILL.md +0 -184
- package/skills/python-fastapi/references/api-design.md +0 -563
- package/skills/python-fastapi/references/dependencies.md +0 -479
- package/skills/python-fastapi/references/middleware.md +0 -475
- package/skills/python-fastapi/references/validation.md +0 -497
- package/skills/python-modern-python/SKILL.md +0 -201
- package/skills/python-modern-python/references/async.md +0 -76
- package/skills/python-modern-python/references/pattern-matching.md +0 -89
- package/skills/python-modern-python/references/typing.md +0 -96
- package/skills/python-monorepo/SKILL.md +0 -146
- package/skills/python-monorepo/references/docker.md +0 -515
- package/skills/python-monorepo/references/namespace-packages.md +0 -426
- package/skills/python-monorepo/references/workspace-config.md +0 -341
- package/skills/python-sqlalchemy/SKILL.md +0 -119
- package/skills/python-sqlalchemy/references/async.md +0 -51
- package/skills/python-sqlalchemy/references/models.md +0 -67
- package/skills/python-sqlalchemy/references/queries.md +0 -57
- package/skills/python-temporal/SKILL.md +0 -128
- package/skills/python-temporal/references/patterns.md +0 -87
- package/skills/python-temporal/references/testing.md +0 -54
- package/skills/python-testing/SKILL.md +0 -161
- package/skills/python-testing/references/boundaries.md +0 -449
- package/skills/python-testing/references/mocking.md +0 -449
- package/skills/python-testing/references/pytest.md +0 -524
- package/skills/spec-orchestrator/SKILL.md +0 -145
- package/skills/typescript-api-design/SKILL.md +0 -396
- package/skills/typescript-api-design/references/error-responses.md +0 -469
- package/skills/typescript-build-tools/SKILL.md +0 -447
- package/skills/typescript-build-tools/references/biome.md +0 -436
- package/skills/typescript-build-tools/references/bun.md +0 -432
- package/skills/typescript-build-tools/references/turborepo.md +0 -426
- package/skills/typescript-build-tools/references/vitest.md +0 -518
- package/skills/typescript-drizzle-orm/SKILL.md +0 -398
- package/skills/typescript-drizzle-orm/references/cloudflare.md +0 -392
- package/skills/typescript-drizzle-orm/references/entity-pattern.md +0 -412
- package/skills/typescript-drizzle-orm/references/postgresql.md +0 -191
- package/skills/typescript-drizzle-orm/references/repository-pattern.md +0 -479
- package/skills/typescript-drizzle-orm/references/sqlite.md +0 -291
- package/skills/typescript-dynamodb-toolbox/SKILL.md +0 -370
- package/skills/typescript-dynamodb-toolbox/references/entity-layer.md +0 -574
- package/skills/typescript-dynamodb-toolbox/references/error-handling.md +0 -495
- package/skills/typescript-dynamodb-toolbox/references/modeling.md +0 -332
- package/skills/typescript-dynamodb-toolbox/references/repository-layer.md +0 -631
- package/skills/typescript-dynamodb-toolbox/references/testing.md +0 -718
- package/skills/typescript-dynamodb-toolbox/references/transactions.md +0 -423
- package/skills/typescript-effect-ts/SKILL.md +0 -341
- package/skills/typescript-effect-ts/references/core.md +0 -412
- package/skills/typescript-effect-ts/references/error-handling.md +0 -468
- package/skills/typescript-effect-ts/references/resources.md +0 -476
- package/skills/typescript-effect-ts/references/schema.md +0 -536
- package/skills/typescript-effect-ts/references/services.md +0 -461
- package/skills/typescript-fastify/SKILL.md +0 -405
- package/skills/typescript-fastify/references/plugins.md +0 -329
- package/skills/typescript-fastify/references/typeid.md +0 -315
- package/skills/typescript-functional-patterns/SKILL.md +0 -402
- package/skills/typescript-functional-patterns/references/adts.md +0 -497
- package/skills/typescript-functional-patterns/references/branded-types.md +0 -542
- package/skills/typescript-functional-patterns/references/migration-guide.md +0 -695
- package/skills/typescript-functional-patterns/references/option-result.md +0 -585
- package/skills/typescript-testing/SKILL.md +0 -353
- package/skills/typescript-testing/references/mocking.md +0 -675
- package/skills/typescript-testing/references/msw.md +0 -852
- package/skills/typescript-testing/references/snapshot-testing.md +0 -774
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
# Entity Pattern
|
|
2
|
-
|
|
3
|
-
Domain entities encapsulate business logic and data transformations between API, domain, and database layers.
|
|
4
|
-
|
|
5
|
-
## Core Concept
|
|
6
|
-
|
|
7
|
-
Entities have **four transformation methods**:
|
|
8
|
-
|
|
9
|
-
1. `fromRequest(rq)` - API request → Entity
|
|
10
|
-
2. `fromRecord(record)` - Database record → Entity
|
|
11
|
-
3. `toRecord()` - Entity → Database insert/update
|
|
12
|
-
4. `toResponse()` - Entity → API response
|
|
13
|
-
|
|
14
|
-
This creates clear boundaries between layers and centralizes data transformation logic.
|
|
15
|
-
|
|
16
|
-
## Basic Entity
|
|
17
|
-
|
|
18
|
-
```typescript
|
|
19
|
-
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
|
20
|
-
import type { users } from '../schema'
|
|
21
|
-
|
|
22
|
-
// Infer types from schema
|
|
23
|
-
type UserRecord = InferSelectModel<typeof users>
|
|
24
|
-
type UserInsert = InferInsertModel<typeof users>
|
|
25
|
-
|
|
26
|
-
interface UserEntityData {
|
|
27
|
-
id: string
|
|
28
|
-
name: string
|
|
29
|
-
email: string
|
|
30
|
-
createdAt: Date
|
|
31
|
-
updatedAt: Date
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class UserEntity {
|
|
35
|
-
public readonly id: string
|
|
36
|
-
public readonly name: string
|
|
37
|
-
public readonly email: string
|
|
38
|
-
public readonly createdAt: Date
|
|
39
|
-
public readonly updatedAt: Date
|
|
40
|
-
|
|
41
|
-
// Private constructor enforces factory methods
|
|
42
|
-
private constructor(data: UserEntityData) {
|
|
43
|
-
this.id = data.id
|
|
44
|
-
this.name = data.name
|
|
45
|
-
this.email = data.email
|
|
46
|
-
this.createdAt = data.createdAt
|
|
47
|
-
this.updatedAt = data.updatedAt
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 1. API request → Entity
|
|
51
|
-
static fromRequest(rq: CreateUserRequest, id?: string): UserEntity {
|
|
52
|
-
const now = new Date()
|
|
53
|
-
return new UserEntity({
|
|
54
|
-
id: id ?? crypto.randomUUID(),
|
|
55
|
-
name: rq.name,
|
|
56
|
-
email: rq.email,
|
|
57
|
-
createdAt: now,
|
|
58
|
-
updatedAt: now,
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 2. DB record → Entity
|
|
63
|
-
static fromRecord(record: UserRecord): UserEntity {
|
|
64
|
-
return new UserEntity({
|
|
65
|
-
id: record.id,
|
|
66
|
-
name: record.name,
|
|
67
|
-
email: record.email,
|
|
68
|
-
createdAt: record.createdAt,
|
|
69
|
-
updatedAt: record.updatedAt,
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 3. Entity → DB record
|
|
74
|
-
toRecord(): UserInsert {
|
|
75
|
-
return {
|
|
76
|
-
id: this.id,
|
|
77
|
-
name: this.name,
|
|
78
|
-
email: this.email,
|
|
79
|
-
createdAt: this.createdAt,
|
|
80
|
-
updatedAt: this.updatedAt,
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 4. Entity → API response
|
|
85
|
-
toResponse(): UserResponse {
|
|
86
|
-
return {
|
|
87
|
-
id: this.id,
|
|
88
|
-
name: this.name,
|
|
89
|
-
email: this.email,
|
|
90
|
-
createdAt: this.createdAt.toISOString(),
|
|
91
|
-
updatedAt: this.updatedAt.toISOString(),
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
## Entity with TypeID
|
|
98
|
-
|
|
99
|
-
Using TypeID for type-safe prefixed identifiers:
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
import { TypeID } from 'typeid-js'
|
|
103
|
-
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
|
104
|
-
import type { users } from '../schema'
|
|
105
|
-
|
|
106
|
-
type UserID = TypeID<'usr'>
|
|
107
|
-
type UserRecord = InferSelectModel<typeof users>
|
|
108
|
-
type UserInsert = InferInsertModel<typeof users>
|
|
109
|
-
|
|
110
|
-
export class UserEntity {
|
|
111
|
-
public readonly id: UserID
|
|
112
|
-
public readonly name: string
|
|
113
|
-
public readonly email: string
|
|
114
|
-
|
|
115
|
-
private constructor(data: {
|
|
116
|
-
id: UserID
|
|
117
|
-
name: string
|
|
118
|
-
email: string
|
|
119
|
-
}) {
|
|
120
|
-
this.id = data.id
|
|
121
|
-
this.name = data.name
|
|
122
|
-
this.email = data.email
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
static fromRequest(rq: CreateUserRequest, id?: string): UserEntity {
|
|
126
|
-
return new UserEntity({
|
|
127
|
-
id: id ? TypeID.fromString<'usr'>(id) : new TypeID('usr'),
|
|
128
|
-
name: rq.name,
|
|
129
|
-
email: rq.email,
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
static fromRecord(record: UserRecord): UserEntity {
|
|
134
|
-
return new UserEntity({
|
|
135
|
-
id: TypeID.fromString<'usr'>(record.id),
|
|
136
|
-
name: record.name,
|
|
137
|
-
email: record.email,
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
toRecord(): UserInsert {
|
|
142
|
-
return {
|
|
143
|
-
id: this.id.toString(), // TypeID → string for DB
|
|
144
|
-
name: this.name,
|
|
145
|
-
email: this.email,
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
toResponse(): UserResponse {
|
|
150
|
-
return {
|
|
151
|
-
id: this.id.toString(), // TypeID → string for API
|
|
152
|
-
name: this.name,
|
|
153
|
-
email: this.email,
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
## Entity with JSON Fields
|
|
160
|
-
|
|
161
|
-
Handle JSON serialization/deserialization:
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
|
165
|
-
import type { users } from '../schema'
|
|
166
|
-
|
|
167
|
-
type UserMetadata = {
|
|
168
|
-
theme: 'light' | 'dark'
|
|
169
|
-
notifications: boolean
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
type UserRecord = InferSelectModel<typeof users>
|
|
173
|
-
type UserInsert = InferInsertModel<typeof users>
|
|
174
|
-
|
|
175
|
-
export class UserEntity {
|
|
176
|
-
public readonly id: string
|
|
177
|
-
public readonly name: string
|
|
178
|
-
public readonly metadata?: UserMetadata
|
|
179
|
-
|
|
180
|
-
private constructor(data: {
|
|
181
|
-
id: string
|
|
182
|
-
name: string
|
|
183
|
-
metadata?: UserMetadata
|
|
184
|
-
}) {
|
|
185
|
-
this.id = data.id
|
|
186
|
-
this.name = data.name
|
|
187
|
-
this.metadata = data.metadata
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
static fromRequest(rq: CreateUserRequest, id?: string): UserEntity {
|
|
191
|
-
return new UserEntity({
|
|
192
|
-
id: id ?? crypto.randomUUID(),
|
|
193
|
-
name: rq.name,
|
|
194
|
-
metadata: rq.metadata, // Already typed from API schema
|
|
195
|
-
})
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
static fromRecord(record: UserRecord): UserEntity {
|
|
199
|
-
// Parse JSON string from TEXT column
|
|
200
|
-
let metadata: UserMetadata | undefined
|
|
201
|
-
if (record.metadata) {
|
|
202
|
-
metadata = JSON.parse(record.metadata)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return new UserEntity({
|
|
206
|
-
id: record.id,
|
|
207
|
-
name: record.name,
|
|
208
|
-
metadata,
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
toRecord(): UserInsert {
|
|
213
|
-
return {
|
|
214
|
-
id: this.id,
|
|
215
|
-
name: this.name,
|
|
216
|
-
// Serialize to JSON string for TEXT column
|
|
217
|
-
metadata: this.metadata ? JSON.stringify(this.metadata) : undefined,
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
toResponse(): UserResponse {
|
|
222
|
-
return {
|
|
223
|
-
id: this.id,
|
|
224
|
-
name: this.name,
|
|
225
|
-
metadata: this.metadata, // Send as object
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
## Entity with Business Logic
|
|
232
|
-
|
|
233
|
-
Entities can contain domain logic that operates on their data:
|
|
234
|
-
|
|
235
|
-
```typescript
|
|
236
|
-
import { TypeID } from 'typeid-js'
|
|
237
|
-
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
|
238
|
-
import type { ledgerAccounts } from '../schema'
|
|
239
|
-
|
|
240
|
-
type LedgerAccountID = TypeID<'lat'>
|
|
241
|
-
type LedgerAccountRecord = InferSelectModel<typeof ledgerAccounts>
|
|
242
|
-
type LedgerAccountInsert = InferInsertModel<typeof ledgerAccounts>
|
|
243
|
-
|
|
244
|
-
export class LedgerAccountEntity {
|
|
245
|
-
public readonly id: LedgerAccountID
|
|
246
|
-
public readonly name: string
|
|
247
|
-
public readonly normalBalance: 'debit' | 'credit'
|
|
248
|
-
public readonly postedAmount: number
|
|
249
|
-
public readonly lockVersion: number
|
|
250
|
-
public readonly updated: Date
|
|
251
|
-
|
|
252
|
-
private constructor(data: {
|
|
253
|
-
id: LedgerAccountID
|
|
254
|
-
name: string
|
|
255
|
-
normalBalance: 'debit' | 'credit'
|
|
256
|
-
postedAmount: number
|
|
257
|
-
lockVersion: number
|
|
258
|
-
updated: Date
|
|
259
|
-
}) {
|
|
260
|
-
Object.assign(this, data)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
static fromRecord(record: LedgerAccountRecord): LedgerAccountEntity {
|
|
264
|
-
return new LedgerAccountEntity({
|
|
265
|
-
id: TypeID.fromString<'lat'>(record.id),
|
|
266
|
-
name: record.name,
|
|
267
|
-
normalBalance: record.normalBalance as 'debit' | 'credit',
|
|
268
|
-
postedAmount: record.postedAmount,
|
|
269
|
-
lockVersion: record.lockVersion,
|
|
270
|
-
updated: record.updated,
|
|
271
|
-
})
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Apply a transaction entry and return a new immutable entity.
|
|
276
|
-
* Uses double-entry accounting rules based on the account's normal balance.
|
|
277
|
-
*/
|
|
278
|
-
applyEntry(entry: {
|
|
279
|
-
direction: 'debit' | 'credit'
|
|
280
|
-
amount: number
|
|
281
|
-
}): LedgerAccountEntity {
|
|
282
|
-
let newPostedAmount = this.postedAmount
|
|
283
|
-
|
|
284
|
-
if (this.normalBalance === 'debit') {
|
|
285
|
-
if (entry.direction === 'debit') {
|
|
286
|
-
newPostedAmount += entry.amount // Debit increases debit accounts
|
|
287
|
-
} else {
|
|
288
|
-
newPostedAmount -= entry.amount // Credit decreases debit accounts
|
|
289
|
-
}
|
|
290
|
-
} else {
|
|
291
|
-
// credit normal balance
|
|
292
|
-
if (entry.direction === 'credit') {
|
|
293
|
-
newPostedAmount += entry.amount // Credit increases credit accounts
|
|
294
|
-
} else {
|
|
295
|
-
newPostedAmount -= entry.amount // Debit decreases credit accounts
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Return new immutable instance
|
|
300
|
-
return new LedgerAccountEntity({
|
|
301
|
-
...this,
|
|
302
|
-
postedAmount: newPostedAmount,
|
|
303
|
-
updated: new Date(),
|
|
304
|
-
})
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
toRecord(): LedgerAccountInsert {
|
|
308
|
-
return {
|
|
309
|
-
id: this.id.toString(),
|
|
310
|
-
name: this.name,
|
|
311
|
-
normalBalance: this.normalBalance,
|
|
312
|
-
postedAmount: this.postedAmount,
|
|
313
|
-
lockVersion: this.lockVersion,
|
|
314
|
-
updated: this.updated,
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
toResponse(): LedgerAccountResponse {
|
|
319
|
-
return {
|
|
320
|
-
id: this.id.toString(),
|
|
321
|
-
name: this.name,
|
|
322
|
-
normalBalance: this.normalBalance,
|
|
323
|
-
postedAmount: this.postedAmount,
|
|
324
|
-
updated: this.updated.toISOString(),
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
## Entity with Defaults
|
|
331
|
-
|
|
332
|
-
Handle default values consistently:
|
|
333
|
-
|
|
334
|
-
```typescript
|
|
335
|
-
export class LedgerEntity {
|
|
336
|
-
public readonly id: LedgerID
|
|
337
|
-
public readonly organizationId: OrgID
|
|
338
|
-
public readonly name: string
|
|
339
|
-
public readonly currency: string
|
|
340
|
-
public readonly currencyExponent: number
|
|
341
|
-
public readonly metadata?: Record<string, unknown>
|
|
342
|
-
|
|
343
|
-
private constructor(data: LedgerEntityData) {
|
|
344
|
-
Object.assign(this, data)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
static fromRequest(
|
|
348
|
-
rq: LedgerRequest,
|
|
349
|
-
organizationId: OrgID,
|
|
350
|
-
id?: string
|
|
351
|
-
): LedgerEntity {
|
|
352
|
-
return new LedgerEntity({
|
|
353
|
-
id: id ? TypeID.fromString<'lgr'>(id) : new TypeID('lgr'),
|
|
354
|
-
organizationId,
|
|
355
|
-
name: rq.name,
|
|
356
|
-
currency: rq.currency ?? 'USD', // Apply default
|
|
357
|
-
currencyExponent: rq.currencyExponent ?? 2, // Apply default
|
|
358
|
-
metadata: rq.metadata,
|
|
359
|
-
})
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
static fromRecord(record: LedgerRecord): LedgerEntity {
|
|
363
|
-
let metadata: Record<string, unknown> | undefined
|
|
364
|
-
if (record.metadata) {
|
|
365
|
-
metadata = JSON.parse(record.metadata)
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return new LedgerEntity({
|
|
369
|
-
id: TypeID.fromString<'lgr'>(record.id),
|
|
370
|
-
organizationId: TypeID.fromString<'org'>(record.organizationId),
|
|
371
|
-
name: record.name,
|
|
372
|
-
currency: record.currency,
|
|
373
|
-
currencyExponent: record.currencyExponent,
|
|
374
|
-
metadata,
|
|
375
|
-
})
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
toRecord(): LedgerInsert {
|
|
379
|
-
return {
|
|
380
|
-
id: this.id.toString(),
|
|
381
|
-
organizationId: this.organizationId.toString(),
|
|
382
|
-
name: this.name,
|
|
383
|
-
currency: this.currency,
|
|
384
|
-
currencyExponent: this.currencyExponent,
|
|
385
|
-
metadata: this.metadata ? JSON.stringify(this.metadata) : undefined,
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
toResponse(): LedgerResponse {
|
|
390
|
-
return {
|
|
391
|
-
id: this.id.toString(),
|
|
392
|
-
name: this.name,
|
|
393
|
-
currency: this.currency,
|
|
394
|
-
currencyExponent: this.currencyExponent,
|
|
395
|
-
metadata: this.metadata,
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
## Guidelines
|
|
402
|
-
|
|
403
|
-
1. **Use immutable readonly properties** - Prevents accidental mutation
|
|
404
|
-
2. **Private constructor** - Forces use of factory methods (fromRequest, fromRecord)
|
|
405
|
-
3. **Four transformation methods** - Clear contracts for each layer boundary
|
|
406
|
-
4. **Business logic in entities** - Domain rules belong in entities, not repos or services
|
|
407
|
-
5. **Return new instances** - When applying changes, return new entity (immutability)
|
|
408
|
-
6. **Parse JSON in fromRecord** - Handle TEXT → object deserialization
|
|
409
|
-
7. **Stringify JSON in toRecord** - Handle object → TEXT serialization
|
|
410
|
-
8. **Format dates in toResponse** - Convert Date → ISO string for API
|
|
411
|
-
9. **Apply defaults in fromRequest** - Ensure defaults are set when creating from API
|
|
412
|
-
10. **Use TypeID for type safety** - Prevents ID mixups across entity types
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# PostgreSQL Patterns with Drizzle ORM
|
|
2
|
-
|
|
3
|
-
PostgreSQL-specific patterns, connection setup, and error handling.
|
|
4
|
-
|
|
5
|
-
## Schema Definition
|
|
6
|
-
|
|
7
|
-
```typescript
|
|
8
|
-
import {
|
|
9
|
-
pgTable,
|
|
10
|
-
serial,
|
|
11
|
-
text,
|
|
12
|
-
integer,
|
|
13
|
-
timestamp,
|
|
14
|
-
boolean,
|
|
15
|
-
varchar,
|
|
16
|
-
uuid,
|
|
17
|
-
primaryKey,
|
|
18
|
-
unique,
|
|
19
|
-
index
|
|
20
|
-
} from 'drizzle-orm/pg-core'
|
|
21
|
-
|
|
22
|
-
export const users = pgTable('users', {
|
|
23
|
-
id: serial('id').primaryKey(),
|
|
24
|
-
name: text('name').notNull(),
|
|
25
|
-
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
26
|
-
age: integer('age'),
|
|
27
|
-
isActive: boolean('is_active').default(true),
|
|
28
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
29
|
-
updatedAt: timestamp('updated_at').$onUpdate(() => new Date()),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
export const posts = pgTable('posts', {
|
|
33
|
-
id: serial('id').primaryKey(),
|
|
34
|
-
title: text('title').notNull(),
|
|
35
|
-
content: text('content'),
|
|
36
|
-
authorId: integer('author_id')
|
|
37
|
-
.notNull()
|
|
38
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
39
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
40
|
-
})
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Database Connection
|
|
44
|
-
|
|
45
|
-
```typescript
|
|
46
|
-
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
47
|
-
import { Pool } from 'pg'
|
|
48
|
-
import * as schema from './schema'
|
|
49
|
-
|
|
50
|
-
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
51
|
-
export const db = drizzle(pool, { schema })
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Migrations (drizzle-kit)
|
|
55
|
-
|
|
56
|
-
```typescript
|
|
57
|
-
// drizzle.config.ts
|
|
58
|
-
import { defineConfig } from 'drizzle-kit'
|
|
59
|
-
|
|
60
|
-
export default defineConfig({
|
|
61
|
-
schema: './src/db/schema.ts',
|
|
62
|
-
out: './drizzle',
|
|
63
|
-
dialect: 'postgresql',
|
|
64
|
-
dbCredentials: {
|
|
65
|
-
url: process.env.DATABASE_URL!,
|
|
66
|
-
},
|
|
67
|
-
})
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
# Generate migration
|
|
72
|
-
npx drizzle-kit generate
|
|
73
|
-
|
|
74
|
-
# Apply migrations
|
|
75
|
-
npx drizzle-kit migrate
|
|
76
|
-
|
|
77
|
-
# Open Drizzle Studio
|
|
78
|
-
npx drizzle-kit studio
|
|
79
|
-
|
|
80
|
-
# Pull schema from existing DB
|
|
81
|
-
npx drizzle-kit pull
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## Column Helpers
|
|
85
|
-
|
|
86
|
-
```typescript
|
|
87
|
-
// UUID with auto-generation
|
|
88
|
-
id: uuid('id').defaultRandom().primaryKey()
|
|
89
|
-
|
|
90
|
-
// Timestamps
|
|
91
|
-
createdAt: timestamp('created_at').defaultNow().notNull()
|
|
92
|
-
updatedAt: timestamp('updated_at').$onUpdate(() => new Date())
|
|
93
|
-
|
|
94
|
-
// Enum
|
|
95
|
-
import { pgEnum } from 'drizzle-orm/pg-core'
|
|
96
|
-
export const statusEnum = pgEnum('status', ['pending', 'active', 'archived'])
|
|
97
|
-
// usage: status: statusEnum('status').default('pending')
|
|
98
|
-
|
|
99
|
-
// JSON
|
|
100
|
-
metadata: jsonb('metadata').$type<{ key: string }>()
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
## Composite Keys & Indexes
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
export const postTags = pgTable('post_tags', {
|
|
107
|
-
postId: integer('post_id').references(() => posts.id),
|
|
108
|
-
tagId: integer('tag_id').references(() => tags.id),
|
|
109
|
-
}, (t) => [
|
|
110
|
-
primaryKey({ columns: [t.postId, t.tagId] }),
|
|
111
|
-
])
|
|
112
|
-
|
|
113
|
-
export const users = pgTable('users', {
|
|
114
|
-
// columns...
|
|
115
|
-
}, (t) => [
|
|
116
|
-
unique('unique_email').on(t.email),
|
|
117
|
-
index('name_idx').on(t.name),
|
|
118
|
-
])
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
## PostgreSQL Error Codes
|
|
122
|
-
|
|
123
|
-
Map database error codes to domain errors:
|
|
124
|
-
|
|
125
|
-
```typescript
|
|
126
|
-
type ErrorContext = {
|
|
127
|
-
userId?: string
|
|
128
|
-
resourceId?: string
|
|
129
|
-
[key: string]: unknown
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function handleDBError(error: unknown, context: ErrorContext = {}): never {
|
|
133
|
-
const code = (error as { code?: string }).code
|
|
134
|
-
|
|
135
|
-
switch (code) {
|
|
136
|
-
case '23505': // unique_violation
|
|
137
|
-
throw new ConflictError('Resource already exists', context)
|
|
138
|
-
case '23503': // foreign_key_violation
|
|
139
|
-
throw new NotFoundError('Referenced resource not found', context)
|
|
140
|
-
case '40001': // serialization_failure
|
|
141
|
-
throw new ServiceUnavailableError('Transaction conflict - please retry', {
|
|
142
|
-
retryable: true,
|
|
143
|
-
...context,
|
|
144
|
-
})
|
|
145
|
-
case 'OC000': // AWS DSQL occ_conflict
|
|
146
|
-
throw new ServiceUnavailableError('Optimistic concurrency conflict', {
|
|
147
|
-
retryable: true,
|
|
148
|
-
...context,
|
|
149
|
-
})
|
|
150
|
-
default:
|
|
151
|
-
throw error
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
## Optimistic Locking
|
|
157
|
-
|
|
158
|
-
Prevent concurrent modification issues with version-based locking:
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
// Add lockVersion column to schema
|
|
162
|
-
export const users = pgTable('users', {
|
|
163
|
-
id: uuid('id').defaultRandom().primaryKey(),
|
|
164
|
-
name: text('name').notNull(),
|
|
165
|
-
lockVersion: integer('lock_version').default(0).notNull(),
|
|
166
|
-
updatedAt: timestamp('updated_at').$onUpdate(() => new Date()),
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// Update with version check
|
|
170
|
-
async update(entity: UserEntity): Promise<UserEntity> {
|
|
171
|
-
const result = await this.db
|
|
172
|
-
.update(users)
|
|
173
|
-
.set({
|
|
174
|
-
...entity.toRecord(),
|
|
175
|
-
lockVersion: sql`${users.lockVersion} + 1`,
|
|
176
|
-
})
|
|
177
|
-
.where(and(
|
|
178
|
-
eq(users.id, entity.id),
|
|
179
|
-
eq(users.lockVersion, entity.lockVersion)
|
|
180
|
-
))
|
|
181
|
-
.returning()
|
|
182
|
-
|
|
183
|
-
if (result.length === 0) {
|
|
184
|
-
throw new ConflictError({
|
|
185
|
-
message: 'Resource was modified by another transaction',
|
|
186
|
-
retryable: true,
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
return UserEntity.fromRecord(result[0])
|
|
190
|
-
}
|
|
191
|
-
```
|