@martinffx/atelier 0.1.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/LICENSE +21 -0
- package/README.md +285 -0
- package/agents/architect.md +38 -0
- package/agents/oracle.md +40 -0
- package/agents/recon.md +39 -0
- package/dist/atelier.js +33082 -0
- package/package.json +44 -0
- package/skills/code-conventional-commit/SKILL.md +134 -0
- package/skills/code-conventional-commit/evals/evals.json +23 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-0/with_skill/outputs/commit-message.txt +4 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-0/with_skill/outputs/eval_metadata.json +6 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-1/with_skill/outputs/eval_metadata.json +6 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-1/with_skill/outputs/validation-result.txt +16 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-2/with_skill/outputs/commit-message.txt +8 -0
- package/skills/code-conventional-commit/workspace/iteration-1/eval-2/with_skill/outputs/eval_metadata.json +6 -0
- package/skills/code-conventional-commit/workspace/iteration-1/report.html +1325 -0
- package/skills/code-debug/SKILL.md +271 -0
- package/skills/code-debug/references/language-tools.md +139 -0
- package/skills/code-debug/references/techniques.md +117 -0
- package/skills/code-docs/SKILL.md +171 -0
- package/skills/code-docs/references/api-docs.md +116 -0
- package/skills/code-docs/references/changelog.md +64 -0
- package/skills/code-docs/references/readme-structure.md +94 -0
- package/skills/code-git-worktrees/SKILL.md +218 -0
- package/skills/code-perf/SKILL.md +163 -0
- package/skills/code-perf/references/common-bottlenecks.md +124 -0
- package/skills/code-perf/references/optimization-patterns.md +167 -0
- package/skills/code-perf/references/profiling-tools.md +126 -0
- package/skills/code-review/SKILL.md +78 -0
- package/skills/code-review/references/output.md +200 -0
- package/skills/code-review/references/reviewers.md +719 -0
- package/skills/code-review/references/rq.md +357 -0
- package/skills/code-review/references/rs.md +30 -0
- package/skills/code-security/SKILL.md +183 -0
- package/skills/code-security/references/owasp-top-10.md +129 -0
- package/skills/code-security/references/security-tools.md +102 -0
- package/skills/code-security/references/vulnerability-patterns.md +129 -0
- package/skills/code-stacked-commit/SKILL.md +155 -0
- package/skills/code-subagents/SKILL.md +184 -0
- package/skills/code-subagents/references/code-quality-reviewer-prompt.md +48 -0
- package/skills/code-subagents/references/implementor-prompt.md +79 -0
- package/skills/code-subagents/references/spec-reviewer-prompt.md +62 -0
- package/skills/oracle-architect/SKILL.md +246 -0
- package/skills/oracle-architect/references/api-design.md +345 -0
- package/skills/oracle-architect/references/data-modeling.md +318 -0
- package/skills/oracle-architect/references/ddd-patterns.md +247 -0
- package/skills/oracle-challenge/SKILL.md +126 -0
- package/skills/oracle-testing/SKILL.md +237 -0
- package/skills/oracle-testing/references/anti-patterns.md +618 -0
- package/skills/oracle-testing/references/boundaries.md +579 -0
- package/skills/oracle-testing/references/core-testing.md +691 -0
- package/skills/oracle-testing/references/edge-testing.md +794 -0
- package/skills/oracle-testing/references/stub-driven-tdd.md +322 -0
- package/skills/oracle-thinkdeep/SKILL.md +164 -0
- package/skills/python-architecture/SKILL.md +148 -0
- package/skills/python-architecture/references/data-modeling.md +558 -0
- package/skills/python-architecture/references/ddd.md +569 -0
- package/skills/python-architecture/references/functional-core.md +337 -0
- package/skills/python-build-tools/SKILL.md +153 -0
- package/skills/python-build-tools/references/basedpyright.md +321 -0
- package/skills/python-build-tools/references/ruff.md +225 -0
- package/skills/python-build-tools/references/uv.md +257 -0
- package/skills/python-fastapi/SKILL.md +184 -0
- package/skills/python-fastapi/references/api-design.md +563 -0
- package/skills/python-fastapi/references/dependencies.md +479 -0
- package/skills/python-fastapi/references/middleware.md +475 -0
- package/skills/python-fastapi/references/validation.md +497 -0
- package/skills/python-modern-python/SKILL.md +201 -0
- package/skills/python-modern-python/references/async.md +76 -0
- package/skills/python-modern-python/references/pattern-matching.md +89 -0
- package/skills/python-modern-python/references/typing.md +96 -0
- package/skills/python-monorepo/SKILL.md +146 -0
- package/skills/python-monorepo/references/docker.md +515 -0
- package/skills/python-monorepo/references/namespace-packages.md +426 -0
- package/skills/python-monorepo/references/workspace-config.md +341 -0
- package/skills/python-sqlalchemy/SKILL.md +119 -0
- package/skills/python-sqlalchemy/references/async.md +51 -0
- package/skills/python-sqlalchemy/references/models.md +67 -0
- package/skills/python-sqlalchemy/references/queries.md +57 -0
- package/skills/python-temporal/SKILL.md +128 -0
- package/skills/python-temporal/references/patterns.md +87 -0
- package/skills/python-temporal/references/testing.md +54 -0
- package/skills/python-testing/SKILL.md +161 -0
- package/skills/python-testing/references/boundaries.md +449 -0
- package/skills/python-testing/references/mocking.md +449 -0
- package/skills/python-testing/references/pytest.md +524 -0
- package/skills/spec-brainstorm/SKILL.md +325 -0
- package/skills/spec-finish/SKILL.md +159 -0
- package/skills/spec-implement/SKILL.md +296 -0
- package/skills/spec-orchestrator/SKILL.md +156 -0
- package/skills/spec-plan/SKILL.md +248 -0
- package/skills/typescript-api-design/SKILL.md +396 -0
- package/skills/typescript-api-design/references/error-responses.md +469 -0
- package/skills/typescript-build-tools/SKILL.md +447 -0
- package/skills/typescript-build-tools/references/biome.md +436 -0
- package/skills/typescript-build-tools/references/bun.md +432 -0
- package/skills/typescript-build-tools/references/turborepo.md +426 -0
- package/skills/typescript-build-tools/references/vitest.md +518 -0
- package/skills/typescript-drizzle-orm/SKILL.md +398 -0
- package/skills/typescript-drizzle-orm/references/cloudflare.md +392 -0
- package/skills/typescript-drizzle-orm/references/entity-pattern.md +412 -0
- package/skills/typescript-drizzle-orm/references/postgresql.md +191 -0
- package/skills/typescript-drizzle-orm/references/repository-pattern.md +479 -0
- package/skills/typescript-drizzle-orm/references/sqlite.md +291 -0
- package/skills/typescript-dynamodb-toolbox/SKILL.md +370 -0
- package/skills/typescript-dynamodb-toolbox/references/entity-layer.md +574 -0
- package/skills/typescript-dynamodb-toolbox/references/error-handling.md +495 -0
- package/skills/typescript-dynamodb-toolbox/references/modeling.md +332 -0
- package/skills/typescript-dynamodb-toolbox/references/repository-layer.md +631 -0
- package/skills/typescript-dynamodb-toolbox/references/testing.md +718 -0
- package/skills/typescript-dynamodb-toolbox/references/transactions.md +423 -0
- package/skills/typescript-effect-ts/SKILL.md +341 -0
- package/skills/typescript-effect-ts/references/core.md +412 -0
- package/skills/typescript-effect-ts/references/error-handling.md +468 -0
- package/skills/typescript-effect-ts/references/resources.md +476 -0
- package/skills/typescript-effect-ts/references/schema.md +536 -0
- package/skills/typescript-effect-ts/references/services.md +461 -0
- package/skills/typescript-fastify/SKILL.md +405 -0
- package/skills/typescript-fastify/references/plugins.md +329 -0
- package/skills/typescript-fastify/references/typeid.md +315 -0
- package/skills/typescript-functional-patterns/SKILL.md +402 -0
- package/skills/typescript-functional-patterns/references/adts.md +497 -0
- package/skills/typescript-functional-patterns/references/branded-types.md +542 -0
- package/skills/typescript-functional-patterns/references/migration-guide.md +695 -0
- package/skills/typescript-functional-patterns/references/option-result.md +585 -0
- package/skills/typescript-testing/SKILL.md +353 -0
- package/skills/typescript-testing/references/mocking.md +675 -0
- package/skills/typescript-testing/references/msw.md +852 -0
- package/skills/typescript-testing/references/snapshot-testing.md +774 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# Entity Layer Transformation Patterns
|
|
2
|
+
|
|
3
|
+
Domain entities manage all data transformations between layers, ensuring clean separation of concerns.
|
|
4
|
+
|
|
5
|
+
## The Four Transformation Methods
|
|
6
|
+
|
|
7
|
+
Every domain entity implements four methods for layer boundary crossing:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
class EntityName {
|
|
11
|
+
// 1. API Request → Domain Entity
|
|
12
|
+
static fromRequest(request: EntityCreateRequest): EntityName
|
|
13
|
+
|
|
14
|
+
// 2. DynamoDB Record → Domain Entity
|
|
15
|
+
static fromRecord(record: EntityFormatted): EntityName
|
|
16
|
+
|
|
17
|
+
// 3. Domain Entity → DynamoDB Record
|
|
18
|
+
toRecord(): EntityInput
|
|
19
|
+
|
|
20
|
+
// 4. Domain Entity → API Response
|
|
21
|
+
toResponse(): EntityResponse
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Data Flow Through Layers
|
|
26
|
+
|
|
27
|
+
### Create/Update Flow
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
HTTP Request
|
|
31
|
+
↓ fromRequest()
|
|
32
|
+
Domain Entity
|
|
33
|
+
↓ toRecord()
|
|
34
|
+
DynamoDB Record
|
|
35
|
+
↓ DynamoDB save
|
|
36
|
+
DynamoDB Record (with timestamps)
|
|
37
|
+
↓ fromRecord()
|
|
38
|
+
Domain Entity
|
|
39
|
+
↓ toResponse()
|
|
40
|
+
HTTP Response
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Read Flow
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
HTTP Request
|
|
47
|
+
↓ repository.get(id)
|
|
48
|
+
DynamoDB Record
|
|
49
|
+
↓ fromRecord()
|
|
50
|
+
Domain Entity
|
|
51
|
+
↓ toResponse()
|
|
52
|
+
HTTP Response
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Complete Entity Example
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { type InputItem, type FormattedItem } from 'dynamodb-toolbox/entity'
|
|
59
|
+
import { DateTime } from 'luxon'
|
|
60
|
+
|
|
61
|
+
// Type exports from schema
|
|
62
|
+
type IssueRecord = typeof IssueRecord
|
|
63
|
+
type IssueInput = InputItem<typeof IssueRecord>
|
|
64
|
+
type IssueFormatted = FormattedItem<typeof IssueRecord>
|
|
65
|
+
|
|
66
|
+
// API types
|
|
67
|
+
interface IssueCreateRequest {
|
|
68
|
+
owner: string
|
|
69
|
+
repo_name: string
|
|
70
|
+
title: string
|
|
71
|
+
body?: string
|
|
72
|
+
author: string
|
|
73
|
+
assignees?: string[]
|
|
74
|
+
labels?: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface IssueResponse {
|
|
78
|
+
owner: string
|
|
79
|
+
repo_name: string
|
|
80
|
+
issue_number: number
|
|
81
|
+
title: string
|
|
82
|
+
body?: string
|
|
83
|
+
status: "open" | "closed"
|
|
84
|
+
author: string
|
|
85
|
+
assignees: string[]
|
|
86
|
+
labels: string[]
|
|
87
|
+
created_at: string
|
|
88
|
+
updated_at: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Domain entity
|
|
92
|
+
class IssueEntity {
|
|
93
|
+
// Immutable properties
|
|
94
|
+
public readonly owner: string
|
|
95
|
+
public readonly repoName: string
|
|
96
|
+
public readonly issueNumber: number
|
|
97
|
+
public readonly title: string
|
|
98
|
+
public readonly body?: string
|
|
99
|
+
public readonly status: "open" | "closed"
|
|
100
|
+
public readonly author: string
|
|
101
|
+
public readonly assignees: string[]
|
|
102
|
+
public readonly labels: string[]
|
|
103
|
+
public readonly created: DateTime
|
|
104
|
+
public readonly modified: DateTime
|
|
105
|
+
|
|
106
|
+
constructor(props: IssueEntityOpts) {
|
|
107
|
+
this.owner = props.owner
|
|
108
|
+
this.repoName = props.repoName
|
|
109
|
+
this.issueNumber = props.issueNumber
|
|
110
|
+
this.title = props.title
|
|
111
|
+
this.body = props.body
|
|
112
|
+
this.status = props.status || "open"
|
|
113
|
+
this.author = props.author
|
|
114
|
+
this.assignees = props.assignees || []
|
|
115
|
+
this.labels = props.labels || []
|
|
116
|
+
this.created = props.created || DateTime.utc()
|
|
117
|
+
this.modified = props.modified || DateTime.utc()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. API Request → Entity
|
|
121
|
+
static fromRequest(data: IssueCreateRequest): IssueEntity {
|
|
122
|
+
// Validation
|
|
123
|
+
IssueEntity.validate(data)
|
|
124
|
+
|
|
125
|
+
return new IssueEntity({
|
|
126
|
+
owner: data.owner,
|
|
127
|
+
repoName: data.repo_name,
|
|
128
|
+
issueNumber: 0, // Set by repository after getting from counter
|
|
129
|
+
title: data.title,
|
|
130
|
+
body: data.body,
|
|
131
|
+
status: "open",
|
|
132
|
+
author: data.author,
|
|
133
|
+
assignees: data.assignees || [],
|
|
134
|
+
labels: data.labels || [],
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. DynamoDB Record → Entity
|
|
139
|
+
static fromRecord(record: IssueFormatted): IssueEntity {
|
|
140
|
+
return new IssueEntity({
|
|
141
|
+
owner: record.owner,
|
|
142
|
+
repoName: record.repo_name,
|
|
143
|
+
issueNumber: record.issue_number,
|
|
144
|
+
title: record.title,
|
|
145
|
+
body: record.body,
|
|
146
|
+
status: record.status,
|
|
147
|
+
author: record.author,
|
|
148
|
+
// DynamoDB Sets → Arrays
|
|
149
|
+
assignees: record.assignees ? Array.from(record.assignees) : [],
|
|
150
|
+
labels: record.labels ? Array.from(record.labels) : [],
|
|
151
|
+
// ISO strings → DateTime
|
|
152
|
+
created: DateTime.fromISO(record.created),
|
|
153
|
+
modified: DateTime.fromISO(record.modified),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. Entity → DynamoDB Record
|
|
158
|
+
toRecord(): IssueInput {
|
|
159
|
+
return {
|
|
160
|
+
owner: this.owner,
|
|
161
|
+
repo_name: this.repoName,
|
|
162
|
+
issue_number: this.issueNumber,
|
|
163
|
+
title: this.title,
|
|
164
|
+
body: this.body,
|
|
165
|
+
status: this.status,
|
|
166
|
+
author: this.author,
|
|
167
|
+
// Arrays → Sets (DynamoDB doesn't allow empty sets)
|
|
168
|
+
assignees: this.assignees.length > 0
|
|
169
|
+
? new Set(this.assignees)
|
|
170
|
+
: undefined,
|
|
171
|
+
labels: this.labels.length > 0
|
|
172
|
+
? new Set(this.labels)
|
|
173
|
+
: undefined,
|
|
174
|
+
// DateTime → ISO strings handled by DynamoDB Toolbox
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 4. Entity → API Response
|
|
179
|
+
toResponse(): IssueResponse {
|
|
180
|
+
return {
|
|
181
|
+
owner: this.owner,
|
|
182
|
+
repo_name: this.repoName,
|
|
183
|
+
issue_number: this.issueNumber,
|
|
184
|
+
title: this.title,
|
|
185
|
+
body: this.body,
|
|
186
|
+
status: this.status,
|
|
187
|
+
author: this.author,
|
|
188
|
+
assignees: this.assignees,
|
|
189
|
+
labels: this.labels,
|
|
190
|
+
created_at: this.created.toISO() ?? "",
|
|
191
|
+
updated_at: this.modified.toISO() ?? "",
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validation (called by fromRequest)
|
|
196
|
+
private static validate(data: IssueCreateRequest): void {
|
|
197
|
+
if (!data.title || data.title.trim().length === 0) {
|
|
198
|
+
throw new ValidationError("title", "Title is required")
|
|
199
|
+
}
|
|
200
|
+
if (data.title.length > 256) {
|
|
201
|
+
throw new ValidationError("title", "Title must be 256 characters or less")
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Immutable update (returns new instance)
|
|
206
|
+
updateIssue(opts: UpdateIssueEntityOpts): IssueEntity {
|
|
207
|
+
return new IssueEntity({
|
|
208
|
+
// Preserve immutable fields
|
|
209
|
+
owner: this.owner,
|
|
210
|
+
repoName: this.repoName,
|
|
211
|
+
issueNumber: this.issueNumber,
|
|
212
|
+
author: this.author,
|
|
213
|
+
created: this.created,
|
|
214
|
+
// Apply updates
|
|
215
|
+
title: opts.title ?? this.title,
|
|
216
|
+
body: opts.body ?? this.body,
|
|
217
|
+
status: opts.status ?? this.status,
|
|
218
|
+
assignees: opts.assignees ?? this.assignees,
|
|
219
|
+
labels: opts.labels ?? this.labels,
|
|
220
|
+
// Update timestamp
|
|
221
|
+
modified: DateTime.utc(),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Helper: Entity key for error messages
|
|
226
|
+
getEntityKey(): string {
|
|
227
|
+
return `ISSUE#${this.owner}#${this.repoName}#${this.issueNumber}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Helper: Parent entity key for transactions
|
|
231
|
+
getParentEntityKey(): string {
|
|
232
|
+
return `REPO#${this.owner}#${this.repoName}`
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Field Naming Conventions
|
|
238
|
+
|
|
239
|
+
### Entity Layer (TypeScript/camelCase)
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
class RepositoryEntity {
|
|
243
|
+
public readonly repoName: string
|
|
244
|
+
public readonly isPrivate: boolean
|
|
245
|
+
public readonly paymentPlanId?: string
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Database Layer (DynamoDB/snake_case)
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
schema: item({
|
|
253
|
+
repo_name: string().required(),
|
|
254
|
+
is_private: boolean().default(false),
|
|
255
|
+
payment_plan_id: string().optional(),
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Mapping in Transformations
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// fromRecord: snake_case → camelCase
|
|
263
|
+
static fromRecord(record: RepoFormatted): RepositoryEntity {
|
|
264
|
+
return new RepositoryEntity({
|
|
265
|
+
repoName: record.repo_name,
|
|
266
|
+
isPrivate: record.is_private,
|
|
267
|
+
paymentPlanId: record.payment_plan_id,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// toRecord: camelCase → snake_case
|
|
272
|
+
toRecord(): RepoInput {
|
|
273
|
+
return {
|
|
274
|
+
repo_name: this.repoName,
|
|
275
|
+
is_private: this.isPrivate,
|
|
276
|
+
payment_plan_id: this.paymentPlanId,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// toResponse: camelCase → snake_case (API convention)
|
|
281
|
+
toResponse(): RepoResponse {
|
|
282
|
+
return {
|
|
283
|
+
repo_name: this.repoName,
|
|
284
|
+
is_private: this.isPrivate,
|
|
285
|
+
payment_plan_id: this.paymentPlanId,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Why snake_case for API responses?**
|
|
291
|
+
Matches common REST API conventions and DynamoDB attribute names.
|
|
292
|
+
|
|
293
|
+
## DynamoDB Set Conversion
|
|
294
|
+
|
|
295
|
+
DynamoDB doesn't support empty Sets - convert to/from Arrays.
|
|
296
|
+
|
|
297
|
+
### Reading: Set → Array
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
static fromRecord(record: IssueFormatted): IssueEntity {
|
|
301
|
+
return new IssueEntity({
|
|
302
|
+
// DynamoDB Set (or undefined) → Array
|
|
303
|
+
assignees: record.assignees ? Array.from(record.assignees) : [],
|
|
304
|
+
labels: record.labels ? Array.from(record.labels) : [],
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Writing: Array → Set (or undefined)
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
toRecord(): IssueInput {
|
|
313
|
+
return {
|
|
314
|
+
// Array → Set (only if non-empty)
|
|
315
|
+
assignees: this.assignees.length > 0
|
|
316
|
+
? new Set(this.assignees)
|
|
317
|
+
: undefined,
|
|
318
|
+
labels: this.labels.length > 0
|
|
319
|
+
? new Set(this.labels)
|
|
320
|
+
: undefined,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Critical:** DynamoDB rejects empty Sets. Always check length before creating Set.
|
|
326
|
+
|
|
327
|
+
## Timestamp Handling
|
|
328
|
+
|
|
329
|
+
DynamoDB Toolbox auto-manages `created` and `modified` timestamps.
|
|
330
|
+
|
|
331
|
+
### Schema Configuration
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// Timestamps are added automatically as _ct and _md
|
|
335
|
+
// When querying through Entity, they're formatted as 'created' and 'modified'
|
|
336
|
+
const entity = new Entity({
|
|
337
|
+
name: "USER",
|
|
338
|
+
table: AppTable,
|
|
339
|
+
schema: item({
|
|
340
|
+
username: string().required().key(),
|
|
341
|
+
// created/modified added automatically
|
|
342
|
+
}),
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Reading Timestamps
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
static fromRecord(record: UserFormatted): UserEntity {
|
|
350
|
+
return new UserEntity({
|
|
351
|
+
username: record.username,
|
|
352
|
+
// DynamoDB Toolbox returns ISO strings
|
|
353
|
+
created: DateTime.fromISO(record.created),
|
|
354
|
+
modified: DateTime.fromISO(record.modified),
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Writing Timestamps
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
// Don't include created/modified in toRecord()
|
|
363
|
+
// DynamoDB Toolbox handles them automatically
|
|
364
|
+
toRecord(): UserInput {
|
|
365
|
+
return {
|
|
366
|
+
username: this.username,
|
|
367
|
+
email: this.email,
|
|
368
|
+
// created/modified omitted - auto-managed
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Immutable Update Pattern
|
|
374
|
+
|
|
375
|
+
Never mutate entity properties. Return new instance with updated values.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
class UserEntity {
|
|
379
|
+
public readonly username: string // Never changes
|
|
380
|
+
public readonly email: string
|
|
381
|
+
public readonly bio?: string
|
|
382
|
+
public readonly created: DateTime // Never changes
|
|
383
|
+
public readonly modified: DateTime
|
|
384
|
+
|
|
385
|
+
// Immutable update
|
|
386
|
+
updateUser(opts: UpdateUserEntityOpts): UserEntity {
|
|
387
|
+
return new UserEntity({
|
|
388
|
+
// Preserve identity
|
|
389
|
+
username: this.username,
|
|
390
|
+
created: this.created,
|
|
391
|
+
// Apply updates
|
|
392
|
+
email: opts.email ?? this.email,
|
|
393
|
+
bio: opts.bio ?? this.bio,
|
|
394
|
+
// Update timestamp
|
|
395
|
+
modified: DateTime.utc(),
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Usage:**
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// Repository layer
|
|
405
|
+
async update(user: UserEntity): Promise<UserEntity> {
|
|
406
|
+
// Create updated entity with new timestamp
|
|
407
|
+
const updated = user.updateUser({
|
|
408
|
+
email: "newemail@example.com",
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// Save to DynamoDB
|
|
412
|
+
const result = await this.entity
|
|
413
|
+
.build(PutItemCommand)
|
|
414
|
+
.item(updated.toRecord())
|
|
415
|
+
.send()
|
|
416
|
+
|
|
417
|
+
return UserEntity.fromRecord(result.ToolboxItem)
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Why immutable?**
|
|
422
|
+
- Predictable: No hidden state changes
|
|
423
|
+
- Testable: Pure functions
|
|
424
|
+
- Thread-safe: No race conditions
|
|
425
|
+
- Traceable: Clear audit trail
|
|
426
|
+
|
|
427
|
+
## Validation in fromRequest
|
|
428
|
+
|
|
429
|
+
Validate business rules when converting API request to entity.
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
static fromRequest(data: RepositoryCreateRequest): RepositoryEntity {
|
|
433
|
+
// Validate required fields
|
|
434
|
+
if (!data.owner || data.owner.trim().length === 0) {
|
|
435
|
+
throw new ValidationError("owner", "Owner is required")
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Validate format
|
|
439
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(data.owner)) {
|
|
440
|
+
throw new ValidationError(
|
|
441
|
+
"owner",
|
|
442
|
+
"Owner must contain only alphanumeric characters, hyphens, and underscores"
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Validate length
|
|
447
|
+
if (data.repo_name.length > 100) {
|
|
448
|
+
throw new ValidationError(
|
|
449
|
+
"repo_name",
|
|
450
|
+
"Repository name must be 100 characters or less"
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return new RepositoryEntity({
|
|
455
|
+
owner: data.owner,
|
|
456
|
+
repoName: data.repo_name,
|
|
457
|
+
description: data.description,
|
|
458
|
+
isPrivate: data.is_private ?? false,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Validation Layers:**
|
|
464
|
+
1. **Schema validation** (DynamoDB Toolbox) - Type and required fields
|
|
465
|
+
2. **Business validation** (Entity `fromRequest`) - Format, length, rules
|
|
466
|
+
3. **Existence validation** (Repository transactions) - Foreign key checks
|
|
467
|
+
|
|
468
|
+
## Helper Methods
|
|
469
|
+
|
|
470
|
+
### getEntityKey()
|
|
471
|
+
|
|
472
|
+
Returns entity identifier for error messages.
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
getEntityKey(): string {
|
|
476
|
+
return `ISSUE#${this.owner}#${this.repoName}#${this.issueNumber}`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Usage in error handling
|
|
480
|
+
throw new EntityNotFoundError("IssueEntity", issue.getEntityKey())
|
|
481
|
+
// → "IssueEntity 'ISSUE#alice#my-repo#00000001' not found"
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### getParentEntityKey()
|
|
485
|
+
|
|
486
|
+
Returns parent entity identifier for transaction error handling.
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
getParentEntityKey(): string {
|
|
490
|
+
return `REPO#${this.owner}#${this.repoName}`
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Usage in transaction error handling
|
|
494
|
+
handleTransactionError(error, {
|
|
495
|
+
entityType: "IssueEntity",
|
|
496
|
+
entityKey: issue.getEntityKey(),
|
|
497
|
+
parentEntityType: "RepositoryEntity",
|
|
498
|
+
parentEntityKey: issue.getParentEntityKey(),
|
|
499
|
+
})
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
## Testing Entity Transformations
|
|
503
|
+
|
|
504
|
+
Entity transformations are tested implicitly through repository tests, not in isolation.
|
|
505
|
+
|
|
506
|
+
**Don't:**
|
|
507
|
+
```typescript
|
|
508
|
+
// ❌ Don't test transformations in isolation
|
|
509
|
+
describe("IssueEntity", () => {
|
|
510
|
+
it("should convert to record", () => {
|
|
511
|
+
const issue = new IssueEntity(/* ... */)
|
|
512
|
+
const record = issue.toRecord()
|
|
513
|
+
expect(record.owner).toBe(issue.owner)
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Do:**
|
|
519
|
+
```typescript
|
|
520
|
+
// ✓ Test through repository layer
|
|
521
|
+
describe("IssueRepository", () => {
|
|
522
|
+
it("should create issue and retrieve with correct data", async () => {
|
|
523
|
+
const issue = IssueEntity.fromRequest({
|
|
524
|
+
owner: "alice",
|
|
525
|
+
repo_name: "my-repo",
|
|
526
|
+
title: "Test Issue",
|
|
527
|
+
author: "bob",
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const created = await issueRepo.create(issue)
|
|
531
|
+
|
|
532
|
+
// Transformations tested implicitly
|
|
533
|
+
expect(created.owner).toBe("alice")
|
|
534
|
+
expect(created.repoName).toBe("my-repo")
|
|
535
|
+
expect(created.title).toBe("Test Issue")
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Why?** Entity transformations are an implementation detail. Test the full flow (API → DynamoDB → API) through repository tests.
|
|
541
|
+
|
|
542
|
+
## Best Practices
|
|
543
|
+
|
|
544
|
+
### ✓ Do
|
|
545
|
+
|
|
546
|
+
- Implement all four transformation methods
|
|
547
|
+
- Use immutable entity properties
|
|
548
|
+
- Return new instances from update methods
|
|
549
|
+
- Convert Sets ↔ Arrays (handle empty sets)
|
|
550
|
+
- Parse timestamps to DateTime objects
|
|
551
|
+
- Validate in `fromRequest()`
|
|
552
|
+
- Use helper methods for entity keys
|
|
553
|
+
|
|
554
|
+
### ✗ Don't
|
|
555
|
+
|
|
556
|
+
- Don't mutate entity properties
|
|
557
|
+
- Don't include timestamps in `toRecord()` (auto-managed)
|
|
558
|
+
- Don't create empty Sets (DynamoDB rejects them)
|
|
559
|
+
- Don't test entity transformations in isolation
|
|
560
|
+
- Don't include PK/SK in entity (computed by schema)
|
|
561
|
+
|
|
562
|
+
## Entity Transformation Checklist
|
|
563
|
+
|
|
564
|
+
For each domain entity:
|
|
565
|
+
|
|
566
|
+
- [ ] **Four methods implemented** - fromRequest, fromRecord, toRecord, toResponse
|
|
567
|
+
- [ ] **Immutable properties** - All fields marked `readonly`
|
|
568
|
+
- [ ] **Immutable updates** - `updateEntity()` returns new instance
|
|
569
|
+
- [ ] **Field naming** - camelCase in entity, snake_case in DB
|
|
570
|
+
- [ ] **Set conversion** - Arrays ↔ Sets with empty check
|
|
571
|
+
- [ ] **Timestamp handling** - DateTime objects, not included in toRecord
|
|
572
|
+
- [ ] **Validation** - Business rules in fromRequest
|
|
573
|
+
- [ ] **Helper methods** - getEntityKey() and getParentEntityKey()
|
|
574
|
+
- [ ] **Type exports** - EntityInput and EntityFormatted from schema
|