@martinffx/atelier 2.0.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 +14 -0
- package/README.md +94 -253
- package/package.json +1 -1
- package/skills/{spec-orchestrator → atelier-orchestrator}/SKILL.md +3 -3
- package/skills/atelier-setup/SKILL.md +80 -0
- package/skills/code-review/references/reviewers.md +3 -3
- package/skills/code-review/references/rq.md +6 -6
- package/skills/spec-brainstorm/SKILL.md +5 -5
- package/skills/spec-implement/SKILL.md +2 -3
- package/skills/spec-plan/SKILL.md +21 -29
- package/skills/spec-plan/references/plan_template.md +77 -0
- 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/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,631 +0,0 @@
|
|
|
1
|
-
# Repository Layer Patterns
|
|
2
|
-
|
|
3
|
-
The repository layer encapsulates all DynamoDB operations, providing a clean interface between domain entities and the database.
|
|
4
|
-
|
|
5
|
-
## Repository Responsibilities
|
|
6
|
-
|
|
7
|
-
**What Repositories Do:**
|
|
8
|
-
- Execute DynamoDB commands (Get, Put, Update, Delete, Query, Scan)
|
|
9
|
-
- Enforce business constraints via conditional writes
|
|
10
|
-
- Handle pagination and filtering
|
|
11
|
-
- Convert between DynamoDB records and domain entities
|
|
12
|
-
- Map DynamoDB errors to domain errors
|
|
13
|
-
|
|
14
|
-
**What Repositories Don't Do:**
|
|
15
|
-
- Business logic (belongs in Service layer)
|
|
16
|
-
- Entity transformations (handled by Entity classes)
|
|
17
|
-
- HTTP request/response handling (belongs in Router layer)
|
|
18
|
-
- Direct attribute manipulation (use Entity methods)
|
|
19
|
-
|
|
20
|
-
## Constructor Patterns
|
|
21
|
-
|
|
22
|
-
### Single Entity Repository
|
|
23
|
-
|
|
24
|
-
For simple CRUD operations on one entity type.
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
import { type UserRecord } from './schema'
|
|
28
|
-
|
|
29
|
-
class UserRepository {
|
|
30
|
-
constructor(private readonly entity: UserRecord) {}
|
|
31
|
-
|
|
32
|
-
async get(username: string): Promise<UserEntity | undefined> {
|
|
33
|
-
const result = await this.entity
|
|
34
|
-
.build(GetItemCommand)
|
|
35
|
-
.key({ username })
|
|
36
|
-
.send()
|
|
37
|
-
|
|
38
|
-
return result.Item ? UserEntity.fromRecord(result.Item) : undefined
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
**When to use:**
|
|
44
|
-
- Entity has no dependencies on other entities
|
|
45
|
-
- All operations are simple CRUD
|
|
46
|
-
- No transaction validation needed
|
|
47
|
-
|
|
48
|
-
### Multi-Entity Repository
|
|
49
|
-
|
|
50
|
-
For operations requiring validation or queries across entities.
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
import { type Table } from 'dynamodb-toolbox/table'
|
|
54
|
-
import { type IssueRecord, type RepoRecord, type CounterRecord } from './schema'
|
|
55
|
-
|
|
56
|
-
class IssueRepository {
|
|
57
|
-
constructor(
|
|
58
|
-
private readonly table: Table,
|
|
59
|
-
private readonly issueRecord: IssueRecord,
|
|
60
|
-
private readonly counterRecord: CounterRecord,
|
|
61
|
-
private readonly repoRecord: RepoRecord
|
|
62
|
-
) {}
|
|
63
|
-
|
|
64
|
-
async create(issue: IssueEntity): Promise<IssueEntity> {
|
|
65
|
-
// Uses counterRecord to generate issue number
|
|
66
|
-
// Uses repoRecord to validate parent exists
|
|
67
|
-
// Uses table to execute cross-entity queries
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
**When to use:**
|
|
73
|
-
- Need to validate foreign keys (transactions)
|
|
74
|
-
- Need to query related entities
|
|
75
|
-
- Need atomic counters for sequential IDs
|
|
76
|
-
- Need to query GSIs with multiple entity types
|
|
77
|
-
|
|
78
|
-
**Key Point:** Pass `Table` instance for cross-entity queries, individual `Entity` instances for specific operations.
|
|
79
|
-
|
|
80
|
-
## CRUD Operations
|
|
81
|
-
|
|
82
|
-
### Create with Duplicate Prevention
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
import { PutItemCommand } from 'dynamodb-toolbox'
|
|
86
|
-
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'
|
|
87
|
-
|
|
88
|
-
async create(user: UserEntity): Promise<UserEntity> {
|
|
89
|
-
try {
|
|
90
|
-
const result = await this.entity
|
|
91
|
-
.build(PutItemCommand)
|
|
92
|
-
.item(user.toRecord())
|
|
93
|
-
.options({
|
|
94
|
-
condition: { attr: "PK", exists: false } // Prevent duplicates
|
|
95
|
-
})
|
|
96
|
-
.send()
|
|
97
|
-
|
|
98
|
-
return UserEntity.fromRecord(result.ToolboxItem)
|
|
99
|
-
} catch (error: unknown) {
|
|
100
|
-
if (error instanceof ConditionalCheckFailedException) {
|
|
101
|
-
throw new DuplicateEntityError("UserEntity", user.username)
|
|
102
|
-
}
|
|
103
|
-
throw error
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
**Key Points:**
|
|
109
|
-
- Use `PutItemCommand` (replaces entire item)
|
|
110
|
-
- Condition: `{ attr: "PK", exists: false }`
|
|
111
|
-
- Return entity from `result.ToolboxItem`
|
|
112
|
-
- Map `ConditionalCheckFailedException` to `DuplicateEntityError`
|
|
113
|
-
|
|
114
|
-
### Read by Primary Key
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
import { GetItemCommand } from 'dynamodb-toolbox'
|
|
118
|
-
|
|
119
|
-
async get(username: string): Promise<UserEntity | undefined> {
|
|
120
|
-
const result = await this.entity
|
|
121
|
-
.build(GetItemCommand)
|
|
122
|
-
.key({ username }) // Provide key attributes only
|
|
123
|
-
.send()
|
|
124
|
-
|
|
125
|
-
return result.Item ? UserEntity.fromRecord(result.Item) : undefined
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
**Key Points:**
|
|
130
|
-
- Use `GetItemCommand`
|
|
131
|
-
- Pass only key attributes to `.key()`
|
|
132
|
-
- Return `undefined` if not found (don't throw)
|
|
133
|
-
- Always convert via `Entity.fromRecord()`
|
|
134
|
-
|
|
135
|
-
### Update with Existence Check
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
async update(user: UserEntity): Promise<UserEntity> {
|
|
139
|
-
try {
|
|
140
|
-
const result = await this.entity
|
|
141
|
-
.build(PutItemCommand)
|
|
142
|
-
.item(user.toRecord())
|
|
143
|
-
.options({
|
|
144
|
-
condition: { attr: "PK", exists: true } // Must exist
|
|
145
|
-
})
|
|
146
|
-
.send()
|
|
147
|
-
|
|
148
|
-
return UserEntity.fromRecord(result.ToolboxItem)
|
|
149
|
-
} catch (error: unknown) {
|
|
150
|
-
if (error instanceof ConditionalCheckFailedException) {
|
|
151
|
-
throw new EntityNotFoundError("UserEntity", user.username)
|
|
152
|
-
}
|
|
153
|
-
throw error
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
**Key Points:**
|
|
159
|
-
- Use `PutItemCommand` (replaces entire item)
|
|
160
|
-
- Condition: `{ attr: "PK", exists: true }`
|
|
161
|
-
- Map `ConditionalCheckFailedException` to `EntityNotFoundError`
|
|
162
|
-
|
|
163
|
-
### Delete
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
|
-
import { DeleteItemCommand } from 'dynamodb-toolbox'
|
|
167
|
-
|
|
168
|
-
async delete(username: string): Promise<void> {
|
|
169
|
-
await this.entity
|
|
170
|
-
.build(DeleteItemCommand)
|
|
171
|
-
.key({ username })
|
|
172
|
-
.send()
|
|
173
|
-
|
|
174
|
-
// No return value - succeeds silently even if item doesn't exist
|
|
175
|
-
}
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
**Key Points:**
|
|
179
|
-
- Use `DeleteItemCommand`
|
|
180
|
-
- No condition needed (idempotent by default)
|
|
181
|
-
- Add condition if you need to verify existence
|
|
182
|
-
|
|
183
|
-
## UpdateItemCommand vs PutItemCommand
|
|
184
|
-
|
|
185
|
-
### Use PutItemCommand (Replace Entire Item)
|
|
186
|
-
|
|
187
|
-
```typescript
|
|
188
|
-
// Replace entire item with entity state
|
|
189
|
-
await this.entity
|
|
190
|
-
.build(PutItemCommand)
|
|
191
|
-
.item(user.toRecord()) // Full entity
|
|
192
|
-
.options({ condition: { attr: "PK", exists: true } })
|
|
193
|
-
.send()
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
**When to use:**
|
|
197
|
-
- Creating entities (with `exists: false`)
|
|
198
|
-
- Updating entities (with `exists: true`)
|
|
199
|
-
- Working with Entity objects (full state)
|
|
200
|
-
- Need to replace entire item
|
|
201
|
-
|
|
202
|
-
**Pros:**
|
|
203
|
-
- Works with Entity pattern (full state)
|
|
204
|
-
- Simple and predictable
|
|
205
|
-
- Matches domain model
|
|
206
|
-
|
|
207
|
-
**Cons:**
|
|
208
|
-
- Replaces entire item (not partial update)
|
|
209
|
-
|
|
210
|
-
### Use UpdateItemCommand (Partial Update)
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
import { UpdateItemCommand, $add } from 'dynamodb-toolbox'
|
|
214
|
-
|
|
215
|
-
// Atomic counter increment
|
|
216
|
-
await this.entity
|
|
217
|
-
.build(UpdateItemCommand)
|
|
218
|
-
.item({
|
|
219
|
-
org_id: orgId,
|
|
220
|
-
repo_id: repoId,
|
|
221
|
-
current_value: $add(1), // Only update this field
|
|
222
|
-
})
|
|
223
|
-
.options({ returnValues: "ALL_NEW" })
|
|
224
|
-
.send()
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
**When to use:**
|
|
228
|
-
- Atomic operations (`$add`, `$subtract`, `$append`)
|
|
229
|
-
- Partial updates without loading full entity
|
|
230
|
-
- Updating single attributes
|
|
231
|
-
- Counter increments
|
|
232
|
-
|
|
233
|
-
**Atomic Update Operators:**
|
|
234
|
-
- `$add(n)` - Add to number (creates with n if doesn't exist)
|
|
235
|
-
- `$subtract(n)` - Subtract from number
|
|
236
|
-
- `$append([items])` - Append to list
|
|
237
|
-
- `$remove()` - Remove attribute
|
|
238
|
-
|
|
239
|
-
**Pros:**
|
|
240
|
-
- Atomic operations (no race conditions)
|
|
241
|
-
- Efficient (don't need to load entity first)
|
|
242
|
-
- Can return updated values
|
|
243
|
-
|
|
244
|
-
**Cons:**
|
|
245
|
-
- Doesn't work with full Entity objects
|
|
246
|
-
- More complex API
|
|
247
|
-
- Bypasses entity validation
|
|
248
|
-
|
|
249
|
-
**Best Practice:** Use `PutItemCommand` for entity-based operations, `UpdateItemCommand` for atomic operations.
|
|
250
|
-
|
|
251
|
-
## Query Patterns
|
|
252
|
-
|
|
253
|
-
### Query Main Table by Partition Key
|
|
254
|
-
|
|
255
|
-
```typescript
|
|
256
|
-
async listByPartition(username: string): Promise<Star[]> {
|
|
257
|
-
const result = await this.table
|
|
258
|
-
.build(QueryCommand)
|
|
259
|
-
.entities(this.starRecord) // Type-safe formatting
|
|
260
|
-
.query({
|
|
261
|
-
partition: `ACCOUNT#${username}`,
|
|
262
|
-
})
|
|
263
|
-
.send()
|
|
264
|
-
|
|
265
|
-
return result.Items?.map(item => StarEntity.fromRecord(item)) || []
|
|
266
|
-
}
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
**Key Points:**
|
|
270
|
-
- Use `Table.build(QueryCommand)` for queries
|
|
271
|
-
- Use `.entities(this.record)` for type-safe attribute mapping
|
|
272
|
-
- Specify partition key value
|
|
273
|
-
- All items with that partition key returned
|
|
274
|
-
|
|
275
|
-
### Query GSI
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
|
-
async listByRepo(owner: string, repoName: string): Promise<Issue[]> {
|
|
279
|
-
const result = await this.table
|
|
280
|
-
.build(QueryCommand)
|
|
281
|
-
.entities(this.issueRecord)
|
|
282
|
-
.query({
|
|
283
|
-
partition: `ISSUE#${owner}#${repoName}`,
|
|
284
|
-
index: "GSI1", // Specify GSI name
|
|
285
|
-
})
|
|
286
|
-
.send()
|
|
287
|
-
|
|
288
|
-
return result.Items?.map(item => IssueEntity.fromRecord(item)) || []
|
|
289
|
-
}
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
**Key Points:**
|
|
293
|
-
- Specify `index: "GSI1"`
|
|
294
|
-
- Query GSI partition key (GSI1PK)
|
|
295
|
-
- Returns all items in that partition
|
|
296
|
-
|
|
297
|
-
### Query with Range Key Filter
|
|
298
|
-
|
|
299
|
-
```typescript
|
|
300
|
-
async listComments(
|
|
301
|
-
owner: string,
|
|
302
|
-
repoName: string,
|
|
303
|
-
issueNumber: number
|
|
304
|
-
): Promise<Comment[]> {
|
|
305
|
-
const paddedNumber = String(issueNumber).padStart(8, "0")
|
|
306
|
-
|
|
307
|
-
const result = await this.table
|
|
308
|
-
.build(QueryCommand)
|
|
309
|
-
.entities(this.commentRecord)
|
|
310
|
-
.query({
|
|
311
|
-
partition: `REPO#${owner}#${repoName}`,
|
|
312
|
-
range: {
|
|
313
|
-
beginsWith: `ISSUE#${paddedNumber}#COMMENT#`
|
|
314
|
-
}
|
|
315
|
-
})
|
|
316
|
-
.send()
|
|
317
|
-
|
|
318
|
-
return result.Items?.map(item => CommentEntity.fromRecord(item)) || []
|
|
319
|
-
}
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
**Range Key Operators:**
|
|
323
|
-
- `beginsWith: "prefix"` - SK starts with prefix
|
|
324
|
-
- `between: ["start", "end"]` - SK between values
|
|
325
|
-
- `gt: "value"` - SK greater than
|
|
326
|
-
- `gte: "value"` - SK greater than or equal
|
|
327
|
-
- `lt: "value"` - SK less than
|
|
328
|
-
- `lte: "value"` - SK less than or equal
|
|
329
|
-
|
|
330
|
-
### Query with Status Filter (Smart SK Design)
|
|
331
|
-
|
|
332
|
-
```typescript
|
|
333
|
-
async listOpenIssues(owner: string, repoName: string): Promise<Issue[]> {
|
|
334
|
-
const result = await this.table
|
|
335
|
-
.build(QueryCommand)
|
|
336
|
-
.entities(this.issueRecord)
|
|
337
|
-
.query({
|
|
338
|
-
partition: `ISSUE#${owner}#${repoName}`,
|
|
339
|
-
index: "GSI4",
|
|
340
|
-
range: {
|
|
341
|
-
beginsWith: "ISSUE#OPEN#" // Filters to open issues only
|
|
342
|
-
}
|
|
343
|
-
})
|
|
344
|
-
.send()
|
|
345
|
-
|
|
346
|
-
return result.Items?.map(item => IssueEntity.fromRecord(item)) || []
|
|
347
|
-
}
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
**Pattern:** Encode status in GSI sort key for efficient filtering.
|
|
351
|
-
|
|
352
|
-
## Pagination
|
|
353
|
-
|
|
354
|
-
### Pagination Helper Functions
|
|
355
|
-
|
|
356
|
-
```typescript
|
|
357
|
-
// Encode LastEvaluatedKey as opaque token
|
|
358
|
-
function encodePageToken(
|
|
359
|
-
lastEvaluated?: Record<string, unknown>
|
|
360
|
-
): string | undefined {
|
|
361
|
-
return lastEvaluated
|
|
362
|
-
? Buffer.from(JSON.stringify(lastEvaluated)).toString("base64")
|
|
363
|
-
: undefined
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Decode token back to LastEvaluatedKey
|
|
367
|
-
function decodePageToken(
|
|
368
|
-
token?: string
|
|
369
|
-
): Record<string, unknown> | undefined {
|
|
370
|
-
return token
|
|
371
|
-
? JSON.parse(Buffer.from(token, "base64").toString())
|
|
372
|
-
: undefined
|
|
373
|
-
}
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
### Paginated Query
|
|
377
|
-
|
|
378
|
-
```typescript
|
|
379
|
-
interface PaginatedResponse<T> {
|
|
380
|
-
items: T[]
|
|
381
|
-
nextOffset?: string
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async listReposByOwner(
|
|
385
|
-
owner: string,
|
|
386
|
-
limit = 50,
|
|
387
|
-
offset?: string
|
|
388
|
-
): Promise<PaginatedResponse<RepositoryEntity>> {
|
|
389
|
-
const result = await this.table
|
|
390
|
-
.build(QueryCommand)
|
|
391
|
-
.entities(this.repoRecord)
|
|
392
|
-
.query({
|
|
393
|
-
partition: `ACCOUNT#${owner}`,
|
|
394
|
-
index: "GSI3",
|
|
395
|
-
range: { lt: "ACCOUNT#" }, // Filter repos only
|
|
396
|
-
})
|
|
397
|
-
.options({
|
|
398
|
-
reverse: true, // Newest first
|
|
399
|
-
exclusiveStartKey: decodePageToken(offset), // Continue from cursor
|
|
400
|
-
limit, // Page size
|
|
401
|
-
})
|
|
402
|
-
.send()
|
|
403
|
-
|
|
404
|
-
return {
|
|
405
|
-
items: result.Items?.map(item =>
|
|
406
|
-
RepositoryEntity.fromRecord(item)
|
|
407
|
-
) || [],
|
|
408
|
-
nextOffset: encodePageToken(result.LastEvaluatedKey),
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
**Key Points:**
|
|
414
|
-
- Use `exclusiveStartKey` to continue from previous page
|
|
415
|
-
- Use `limit` to control page size
|
|
416
|
-
- Encode `LastEvaluatedKey` as opaque token for clients
|
|
417
|
-
- Use `reverse: true` for descending order
|
|
418
|
-
|
|
419
|
-
**Client Usage:**
|
|
420
|
-
```typescript
|
|
421
|
-
// First page
|
|
422
|
-
const page1 = await repo.listReposByOwner("alice", 10)
|
|
423
|
-
|
|
424
|
-
// Next page
|
|
425
|
-
const page2 = await repo.listReposByOwner("alice", 10, page1.nextOffset)
|
|
426
|
-
|
|
427
|
-
// Continue until nextOffset is undefined
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
## Table.build vs Entity.build
|
|
431
|
-
|
|
432
|
-
### Entity.build (Single Entity Operations)
|
|
433
|
-
|
|
434
|
-
```typescript
|
|
435
|
-
// Use Entity.build for operations on single entity type
|
|
436
|
-
const result = await this.entity
|
|
437
|
-
.build(GetItemCommand)
|
|
438
|
-
.key({ username })
|
|
439
|
-
.send()
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
**Commands:**
|
|
443
|
-
- `GetItemCommand` - Read single item
|
|
444
|
-
- `PutItemCommand` - Create/update item
|
|
445
|
-
- `UpdateItemCommand` - Partial update
|
|
446
|
-
- `DeleteItemCommand` - Delete item
|
|
447
|
-
- `PutTransaction` - Transaction put
|
|
448
|
-
- `ConditionCheck` - Transaction condition
|
|
449
|
-
|
|
450
|
-
### Table.build (Multi-Entity Queries)
|
|
451
|
-
|
|
452
|
-
```typescript
|
|
453
|
-
// Use Table.build for queries across multiple entity types
|
|
454
|
-
const result = await this.table
|
|
455
|
-
.build(QueryCommand)
|
|
456
|
-
.entities(this.issueRecord) // Specify which entity type
|
|
457
|
-
.query({ partition: "ISSUE#..." })
|
|
458
|
-
.send()
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
**Commands:**
|
|
462
|
-
- `QueryCommand` - Query partition
|
|
463
|
-
- `ScanCommand` - Full table scan (avoid in production)
|
|
464
|
-
|
|
465
|
-
**Critical:** Always use `.entities(this.record)` when querying table to ensure proper attribute mapping from raw DynamoDB names (`_ct`, `_md`) to entity names (`created`, `modified`).
|
|
466
|
-
|
|
467
|
-
## Type-Safe Queries with .entities()
|
|
468
|
-
|
|
469
|
-
### Without .entities() (Raw DynamoDB Attributes)
|
|
470
|
-
|
|
471
|
-
```typescript
|
|
472
|
-
// ❌ WRONG - Returns raw DynamoDB attributes
|
|
473
|
-
const result = await this.table
|
|
474
|
-
.build(QueryCommand)
|
|
475
|
-
.query({ partition: "ACCOUNT#alice" })
|
|
476
|
-
.send()
|
|
477
|
-
|
|
478
|
-
// result.Items has _ct and _md instead of created and modified
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
### With .entities() (Formatted Attributes)
|
|
482
|
-
|
|
483
|
-
```typescript
|
|
484
|
-
// ✓ RIGHT - Returns formatted attributes
|
|
485
|
-
const result = await this.table
|
|
486
|
-
.build(QueryCommand)
|
|
487
|
-
.entities(this.userRecord) // Formats through entity schema
|
|
488
|
-
.query({ partition: "ACCOUNT#alice" })
|
|
489
|
-
.send()
|
|
490
|
-
|
|
491
|
-
// result.Items has created and modified (properly formatted)
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
**Rule:** Always use `.entities()` when querying Table to get type-safe, formatted results.
|
|
495
|
-
|
|
496
|
-
## Common Patterns from gh-ddb
|
|
497
|
-
|
|
498
|
-
### Pattern: List Collection by Parent
|
|
499
|
-
|
|
500
|
-
```typescript
|
|
501
|
-
// List all issues for a repository
|
|
502
|
-
async list(owner: string, repoName: string): Promise<IssueEntity[]> {
|
|
503
|
-
const result = await this.table
|
|
504
|
-
.build(QueryCommand)
|
|
505
|
-
.entities(this.issueRecord)
|
|
506
|
-
.query({
|
|
507
|
-
partition: `ISSUE#${owner}#${repoName}`,
|
|
508
|
-
index: "GSI1",
|
|
509
|
-
})
|
|
510
|
-
.send()
|
|
511
|
-
|
|
512
|
-
return result.Items?.map(item => IssueEntity.fromRecord(item)) || []
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### Pattern: Query with Temporal Sorting
|
|
517
|
-
|
|
518
|
-
```typescript
|
|
519
|
-
// List repos by owner, newest first
|
|
520
|
-
async listByOwner(
|
|
521
|
-
owner: string,
|
|
522
|
-
options: ListOptions = {}
|
|
523
|
-
): Promise<PaginatedResponse<RepositoryEntity>> {
|
|
524
|
-
const { limit = 50, offset } = options
|
|
525
|
-
|
|
526
|
-
const result = await this.table
|
|
527
|
-
.build(QueryCommand)
|
|
528
|
-
.entities(this.repoRecord)
|
|
529
|
-
.query({
|
|
530
|
-
partition: `ACCOUNT#${owner}`,
|
|
531
|
-
index: "GSI3",
|
|
532
|
-
range: { lt: "ACCOUNT#" }, // Exclude account record itself
|
|
533
|
-
})
|
|
534
|
-
.options({
|
|
535
|
-
reverse: true, // Newest first (GSI3SK has #timestamp)
|
|
536
|
-
exclusiveStartKey: decodePageToken(offset),
|
|
537
|
-
limit,
|
|
538
|
-
})
|
|
539
|
-
.send()
|
|
540
|
-
|
|
541
|
-
return {
|
|
542
|
-
items: result.Items?.map(item =>
|
|
543
|
-
RepositoryEntity.fromRecord(item)
|
|
544
|
-
) || [],
|
|
545
|
-
offset: encodePageToken(result.LastEvaluatedKey),
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
### Pattern: Adjacency List Query (Both Directions)
|
|
551
|
-
|
|
552
|
-
```typescript
|
|
553
|
-
// User's starred repos (main table)
|
|
554
|
-
async getStarsByUser(username: string): Promise<StarEntity[]> {
|
|
555
|
-
const result = await this.table
|
|
556
|
-
.build(QueryCommand)
|
|
557
|
-
.entities(this.starRecord)
|
|
558
|
-
.query({
|
|
559
|
-
partition: `ACCOUNT#${username}`,
|
|
560
|
-
range: { beginsWith: "STAR#" }
|
|
561
|
-
})
|
|
562
|
-
.send()
|
|
563
|
-
|
|
564
|
-
return result.Items?.map(item => StarEntity.fromRecord(item)) || []
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Repo's stargazers (GSI1 - inverted)
|
|
568
|
-
async getStarsByRepo(
|
|
569
|
-
owner: string,
|
|
570
|
-
repoName: string
|
|
571
|
-
): Promise<StarEntity[]> {
|
|
572
|
-
const result = await this.table
|
|
573
|
-
.build(QueryCommand)
|
|
574
|
-
.entities(this.starRecord)
|
|
575
|
-
.query({
|
|
576
|
-
partition: `REPO#${owner}#${repoName}`,
|
|
577
|
-
index: "GSI1",
|
|
578
|
-
range: { beginsWith: "STAR#" }
|
|
579
|
-
})
|
|
580
|
-
.send()
|
|
581
|
-
|
|
582
|
-
return result.Items?.map(item => StarEntity.fromRecord(item)) || []
|
|
583
|
-
}
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
## Repository Best Practices
|
|
587
|
-
|
|
588
|
-
### ✓ Do
|
|
589
|
-
|
|
590
|
-
- Keep repositories focused on their primary entity
|
|
591
|
-
- Use conditional writes for create/update
|
|
592
|
-
- Always convert via `Entity.fromRecord()`
|
|
593
|
-
- Return `undefined` for not found (don't throw on Get)
|
|
594
|
-
- Map DynamoDB errors to domain errors
|
|
595
|
-
- Use `.entities()` for type-safe queries
|
|
596
|
-
- Encode pagination cursors as opaque tokens
|
|
597
|
-
- Pass `Table` instance for cross-entity operations
|
|
598
|
-
|
|
599
|
-
### ✗ Don't
|
|
600
|
-
|
|
601
|
-
- Don't include business logic (use Service layer)
|
|
602
|
-
- Don't return raw DynamoDB records
|
|
603
|
-
- Don't query without `.entities()` (loses type safety)
|
|
604
|
-
- Don't use Scan in production (performance cost)
|
|
605
|
-
- Don't retry blindly on errors (may have partial state)
|
|
606
|
-
- Don't expose DynamoDB error details to clients
|
|
607
|
-
- Don't create empty Sets (DynamoDB rejects them)
|
|
608
|
-
|
|
609
|
-
## Repository Testing
|
|
610
|
-
|
|
611
|
-
See [testing.md](testing.md) for comprehensive testing patterns including:
|
|
612
|
-
|
|
613
|
-
- DynamoDB Local setup
|
|
614
|
-
- CRUD operation tests
|
|
615
|
-
- Query and pagination tests
|
|
616
|
-
- Concurrency tests
|
|
617
|
-
- Error handling tests
|
|
618
|
-
|
|
619
|
-
## Repository Checklist
|
|
620
|
-
|
|
621
|
-
For each repository:
|
|
622
|
-
|
|
623
|
-
- [ ] **Constructor** - Single entity or multi-entity pattern
|
|
624
|
-
- [ ] **Create** - PutItemCommand with `exists: false` condition
|
|
625
|
-
- [ ] **Get** - GetItemCommand returning `undefined` if not found
|
|
626
|
-
- [ ] **Update** - PutItemCommand with `exists: true` condition
|
|
627
|
-
- [ ] **Delete** - DeleteItemCommand (idempotent)
|
|
628
|
-
- [ ] **Queries** - Use `.entities()` for type-safe results
|
|
629
|
-
- [ ] **Pagination** - Encode/decode cursors, return nextOffset
|
|
630
|
-
- [ ] **Error Handling** - Map to domain errors
|
|
631
|
-
- [ ] **Testing** - Comprehensive tests for all operations
|