@aws/nx-plugin-mcp 1.0.0-rc.13 → 1.0.0-rc.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ ---
2
+ title: py#dynamodb
3
+ description: Create a Python DynamoDB project
4
+ generator: py#dynamodb
5
+ ---
6
+
7
+ import { FileTree } from '@astrojs/starlight/components';
8
+ import Link from '@components/link.astro';
9
+ import RunGenerator from '@components/run-generator.astro';
10
+ import GeneratorParameters from '@components/generator-parameters.astro';
11
+ import Snippet from '@components/snippet.astro';
12
+
13
+ This generator creates a new Python project backed by [Amazon DynamoDB](https://aws.amazon.com/dynamodb/), using [PynamoDB](https://pynamodb.readthedocs.io/) for entity modelling. It generates the application code and infrastructure needed to provision and manage a DynamoDB table using AWS CDK or Terraform, with single-table design support and built-in local development via DynamoDB Local.
14
+
15
+ ## Usage
16
+
17
+ ### Generate a DynamoDB Project
18
+
19
+ <RunGenerator generator="py#dynamodb" />
20
+
21
+ ### Options
22
+
23
+ <GeneratorParameters generator="py#dynamodb" />
24
+
25
+ ## Generator Output
26
+
27
+ The generator creates the following project structure in the `<directory>/<name>` directory:
28
+
29
+ <FileTree>
30
+ - \<name>
31
+ - \_\_init\_\_.py Package exports
32
+ - client.py DynamoDB client and table name resolution
33
+ - entities
34
+ - base.py Base PynamoDB model with GSI declarations
35
+ - example.py Example entity definition
36
+ - \_\_init\_\_.py Entity exports
37
+ - config.json Table configuration including GSI definitions and local development settings
38
+ - project.json Project configuration and build targets
39
+ </FileTree>
40
+
41
+ The local development scripts are shared across all DynamoDB projects (both TypeScript and Python) and generated once into:
42
+
43
+ <FileTree>
44
+ - packages/common/scripts/src/dynamodb
45
+ - create-local-table.ts Creates the DynamoDB table in the local DynamoDB Local instance
46
+ - pull-image.ts Pulls the DynamoDB Local image
47
+ - start-container.ts Starts the DynamoDB Local container
48
+ </FileTree>
49
+
50
+ ### Infrastructure
51
+
52
+ <Snippet name="dynamodb/infrastructure" />
53
+
54
+ ## Local Development
55
+
56
+ ### Starting Local DynamoDB
57
+
58
+ <Snippet name="dynamodb/serve-local-start" />
59
+
60
+ ### Data Modelling
61
+
62
+ The generated project uses [PynamoDB](https://pynamodb.readthedocs.io/) for entity modelling. All entities **must** inherit from the generated `BaseModel` — it resolves the correct DynamoDB table name at runtime, reading from AWS AppConfig when deployed or from `config.json` when running locally via DynamoDB Local. Without this, PynamoDB will not know which table to use. `BaseModel` also uses [PynamoDB's polymorphism support](https://pynamodb.readthedocs.io/en/stable/polymorphism.html) to store multiple entity types in a single table, following [DynamoDB's single-table design](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html).
63
+
64
+ Add or update entity files under `<name>/entities/`, using the generated example entity as a starting point:
65
+
66
+ ```python title="packages/my_table/my_table/entities/example.py"
67
+ from collections.abc import Iterator
68
+ from datetime import UTC, datetime
69
+ from pynamodb.attributes import UnicodeAttribute
70
+ from .base import BaseModel
71
+
72
+
73
+ class ExampleModel(BaseModel, discriminator='ExampleModel'):
74
+ """
75
+ Key design:
76
+ pk=EXAMPLE#<id>, sk=EXAMPLE#<id>
77
+ gsi1pk=CATEGORY#<cat>, gsi1sk=EXAMPLE#<id> <- list items by category
78
+ gsi2pk=EXAMPLE, gsi2sk=<created_at> <- list all items by date
79
+ """
80
+
81
+ name = UnicodeAttribute()
82
+ category = UnicodeAttribute()
83
+ created_at = UnicodeAttribute()
84
+ updated_at = UnicodeAttribute()
85
+
86
+ @classmethod
87
+ def make_pk(cls, id: str) -> str:
88
+ return f'EXAMPLE#{id}'
89
+
90
+ @classmethod
91
+ def create(cls, id: str, name: str, category: str) -> 'ExampleModel':
92
+ now = datetime.now(UTC).isoformat()
93
+ item = cls(
94
+ pk=cls.make_pk(id),
95
+ sk=cls.make_pk(id),
96
+ gsi1pk=f'CATEGORY#{category}',
97
+ gsi1sk=cls.make_pk(id),
98
+ gsi2pk='EXAMPLE',
99
+ gsi2sk=now,
100
+ name=name,
101
+ category=category,
102
+ created_at=now,
103
+ updated_at=now,
104
+ )
105
+ item.save()
106
+ return item
107
+
108
+ # ── Primary index ─────────────────────────────────────────────────────────
109
+ @classmethod
110
+ def get_by_id(cls, id: str) -> 'ExampleModel':
111
+ return cls.get(cls.make_pk(id), cls.make_pk(id))
112
+
113
+ # ── gsi1_index: partition=category, sort=id ───────────────────────────────
114
+ @classmethod
115
+ def list_by_category(cls, category: str) -> Iterator['ExampleModel']:
116
+ return cls.gsi1_index.query(f'CATEGORY#{category}')
117
+
118
+ # ── gsi2_index: partition=type, sort=created_at ───────────────────────────
119
+ @classmethod
120
+ def list_created_between(cls, start: datetime, end: datetime) -> Iterator['ExampleModel']:
121
+ return cls.gsi2_index.query(
122
+ 'EXAMPLE',
123
+ range_key_condition=ExampleModel.gsi2sk.between(
124
+ start.isoformat(), end.isoformat(),
125
+ ),
126
+ scan_index_forward=False,
127
+ )
128
+ ```
129
+
130
+ For more details, see the [PynamoDB tutorial](https://pynamodb.readthedocs.io/en/stable/tutorial.html).
131
+
132
+ #### Designing Around Access Patterns
133
+
134
+ In DynamoDB, schema design starts with your queries, not your data shape. Before writing any model, list every access pattern your application needs, then design `pk`, `sk`, and GSI key values so each pattern is answered by a single table request — no JOINs, no sequential reads.
135
+
136
+ The generated `ExampleModel` demonstrates this for three patterns:
137
+
138
+ - **Get by ID** — primary index, `pk=EXAMPLE#<id>`, `sk=EXAMPLE#<id>`
139
+ - **List by category** — `gsi1`, `pk=CATEGORY#<category>`
140
+ - **List by creation date** — `gsi2`, `pk=EXAMPLE`, sort key between ISO timestamps
141
+
142
+ The **type prefix** convention (e.g. `EXAMPLE#`, `CATEGORY#`) is deliberate: it makes items self-describing when browsing the table, prevents accidental key collisions between entity types that share an index, and allows sort key prefix filtering using `begins_with`.
143
+
144
+ Before writing a new entity, define its key patterns upfront in a docstring. The `OrderModel` in the next section follows this convention:
145
+
146
+ ```python
147
+ class OrderModel(BaseModel, discriminator='OrderModel'):
148
+ """
149
+ Key design:
150
+ pk=ORDER#<order_id>, sk=ORDER#<order_id>
151
+ gsi1pk=USER#<user_id>, gsi1sk=ORDER#<order_id> <- list orders for a user
152
+ gsi2pk=ORDER, gsi2sk=<created_at> <- list all orders by date
153
+ """
154
+ ```
155
+
156
+ #### Storing Multiple Entity Types
157
+
158
+ PynamoDB's `DiscriminatorAttribute` stores a type label (`entity_type`) in every item. When querying via `BaseModel`, this label is used to instantiate each result as its correct subclass automatically — so a single query can return a mix of `UserModel`, `OrderModel`, and any other entity type registered in the same table.
159
+
160
+ :::caution[PynamoDB is not purpose-built for single-table design]
161
+ PynamoDB's `DiscriminatorAttribute` was designed for class hierarchy polymorphism within a single homogeneous table — not for storing structurally different entity types in one table. The pattern used here works, but keep these caveats in mind:
162
+
163
+ - **Key collision is your responsibility.** PynamoDB does not prevent two entity types from writing the same `pk`/`sk` pair. Always use unique type prefixes (e.g. `ORDER#`, `USER#`).
164
+ - **Cross-entity queries require `BaseModel`.** PynamoDB automatically adds a discriminator filter when querying a specific subclass, so items of other entity types at the same key will never be returned. To retrieve a mix of types from a shared GSI partition key, always query via `BaseModel`.
165
+ - **Shared table settings.** All entities share the `Meta` configuration (table name, region, credentials) defined in `BaseModel`.
166
+ :::
167
+
168
+ Below is a complete two-entity example — a `UserModel` with associated `OrderModel` records stored in the same table:
169
+
170
+ ```python title="packages/my_table/my_table/entities/user.py"
171
+ from collections.abc import Iterator
172
+ from datetime import UTC, datetime
173
+ from pynamodb.attributes import UnicodeAttribute
174
+ from .base import BaseModel
175
+
176
+
177
+ class UserModel(BaseModel, discriminator='UserModel'):
178
+ """
179
+ Key design:
180
+ pk=USER#<user_id>, sk=USER#<user_id>
181
+ gsi2pk=USER, gsi2sk=<created_at> <- list all users by date
182
+ """
183
+
184
+ username = UnicodeAttribute()
185
+ email = UnicodeAttribute()
186
+ created_at = UnicodeAttribute()
187
+
188
+ @classmethod
189
+ def make_pk(cls, user_id: str) -> str:
190
+ return f'USER#{user_id}'
191
+
192
+ @classmethod
193
+ def create(cls, user_id: str, username: str, email: str) -> 'UserModel':
194
+ now = datetime.now(UTC).isoformat()
195
+ item = cls(
196
+ pk=cls.make_pk(user_id),
197
+ sk=cls.make_pk(user_id),
198
+ gsi2pk='USER',
199
+ gsi2sk=now,
200
+ username=username,
201
+ email=email,
202
+ created_at=now,
203
+ )
204
+ item.save()
205
+ return item
206
+
207
+ @classmethod
208
+ def get_by_id(cls, user_id: str) -> 'UserModel':
209
+ return cls.get(cls.make_pk(user_id), cls.make_pk(user_id))
210
+
211
+ @classmethod
212
+ def list_recent(cls, limit: int | None = None) -> Iterator['UserModel']:
213
+ return cls.gsi2_index.query('USER', limit=limit, scan_index_forward=False)
214
+ ```
215
+
216
+ ```python title="packages/my_table/my_table/entities/order.py"
217
+ from collections.abc import Iterator
218
+ from datetime import UTC, datetime
219
+ from pynamodb.attributes import UnicodeAttribute
220
+ from .base import BaseModel
221
+
222
+
223
+ class OrderModel(BaseModel, discriminator='OrderModel'):
224
+ """
225
+ Key design:
226
+ pk=ORDER#<order_id>, sk=ORDER#<order_id>
227
+ gsi1pk=USER#<user_id>, gsi1sk=ORDER#<order_id> <- list orders by user
228
+ gsi2pk=ORDER, gsi2sk=<created_at> <- list all orders by date
229
+ """
230
+
231
+ user_id = UnicodeAttribute()
232
+ total = UnicodeAttribute()
233
+ created_at = UnicodeAttribute()
234
+
235
+ @classmethod
236
+ def make_pk(cls, order_id: str) -> str:
237
+ return f'ORDER#{order_id}'
238
+
239
+ @classmethod
240
+ def create(cls, order_id: str, user_id: str, total: str) -> 'OrderModel':
241
+ now = datetime.now(UTC).isoformat()
242
+ item = cls(
243
+ pk=cls.make_pk(order_id),
244
+ sk=cls.make_pk(order_id),
245
+ gsi1pk=f'USER#{user_id}',
246
+ gsi1sk=cls.make_pk(order_id),
247
+ gsi2pk='ORDER',
248
+ gsi2sk=now,
249
+ user_id=user_id,
250
+ total=total,
251
+ created_at=now,
252
+ )
253
+ item.save()
254
+ return item
255
+
256
+ @classmethod
257
+ def get_by_id(cls, order_id: str) -> 'OrderModel':
258
+ return cls.get(cls.make_pk(order_id), cls.make_pk(order_id))
259
+
260
+ # ── gsi1_index: partition=user, sort=order_id ────────────────────────────
261
+ @classmethod
262
+ def list_by_user(cls, user_id: str) -> Iterator['OrderModel']:
263
+ return cls.gsi1_index.query(f'USER#{user_id}')
264
+
265
+ # ── gsi2_index: partition=type, sort=created_at ───────────────────────────
266
+ @classmethod
267
+ def list_recent(cls, limit: int | None = None) -> Iterator['OrderModel']:
268
+ return cls.gsi2_index.query('ORDER', limit=limit, scan_index_forward=False)
269
+ ```
270
+
271
+ Export the new entities from `__init__.py`:
272
+
273
+ ```python title="packages/my_table/my_table/entities/__init__.py"
274
+ from .user import UserModel
275
+ from .order import OrderModel
276
+ from .example import ExampleModel
277
+ ```
278
+
279
+ #### GSI Overloading
280
+
281
+ `BaseModel` provides two shared GSIs (`gsi1_index`, `gsi2_index`). Both `UserModel` and `OrderModel` above write to `gsi2` — but with different `gsi2pk` values (`USER` vs `ORDER`). This is **GSI overloading**: reusing a single physical index to serve multiple independent access patterns without consuming extra GSI capacity.
282
+
283
+ - **`UserModel`** — `gsi2pk=USER`, `gsi2sk=<created_at>` → list all users by date
284
+ - **`OrderModel`** — `gsi2pk=ORDER`, `gsi2sk=<created_at>` → list all orders by date
285
+
286
+ `gsi1` can also be overloaded when multiple entity types share the same parent. If you later add a `ReviewModel` that also belongs to a user, you can assign it `gsi1pk=USER#<user_id>` with a `REVIEW#<id>` sort key — no additional GSI needed. Querying `gsi1` via `BaseModel` then returns both orders and reviews for that user in one request, with PynamoDB instantiating each item as its correct subclass:
287
+
288
+ ```python
289
+ from .base import BaseModel
290
+ from .order import OrderModel
291
+ from .review import ReviewModel
292
+
293
+ user_id = 'user-123'
294
+ items = list(BaseModel.gsi1_index.query(f'USER#{user_id}'))
295
+
296
+ orders = [i for i in items if isinstance(i, OrderModel)]
297
+ reviews = [i for i in items if isinstance(i, ReviewModel)]
298
+ ```
299
+
300
+ To retrieve only one entity type from an overloaded GSI, use a sort key prefix condition:
301
+
302
+ ```python
303
+ orders_only = list(BaseModel.gsi1_index.query(
304
+ f'USER#{user_id}',
305
+ range_key_condition=BaseModel.gsi1sk.startswith('ORDER#'),
306
+ ))
307
+ ```
308
+
309
+ #### One-to-Many Relationships
310
+
311
+ In a **one-to-many** relationship the child entity stores a reference to its parent in a GSI partition key, making the relationship traversable in both directions without duplicating data. The `UserModel` / `OrderModel` example above is exactly this pattern:
312
+
313
+ - **Get a single order by ID** — primary table: `pk=ORDER#<id>`, `sk=ORDER#<id>`
314
+ - **List all orders for a user** — `gsi1`: `pk=USER#<user_id>`
315
+
316
+ An alternative to GSI-based lookups is the **item collection** pattern: give child items the same `pk` as their parent and use the sort key to differentiate them. This lets you retrieve the parent and all its children in a single primary-table query, without a GSI:
317
+
318
+ ```python title="packages/my_table/my_table/entities/order.py (item collection variant)"
319
+ class OrderModel(BaseModel, discriminator='OrderModel'):
320
+ """
321
+ Key design (item collection):
322
+ pk=USER#<user_id>, sk=ORDER#<order_id> <- co-located under the parent user
323
+ """
324
+ ...
325
+ ```
326
+
327
+ ```python
328
+ # Retrieve the user and all their orders in one primary-table query
329
+ # BaseModel dispatches each item to its correct subclass via DiscriminatorAttribute
330
+ items = list(BaseModel.query(f'USER#{user_id}'))
331
+ user = next(i for i in items if isinstance(i, UserModel))
332
+ orders = [i for i in items if isinstance(i, OrderModel)]
333
+ ```
334
+
335
+ The tradeoff: item collections place all children under a single partition key, which is optimal for most workloads but can create a hot partition at extreme write throughput. The GSI approach (used in the examples above) keeps each entity in its own partition and is generally safer to start with.
336
+
337
+ #### Many-to-Many Relationships
338
+
339
+ Many-to-many relationships require a **junction entity** using the [adjacency list pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html): a dedicated item that records each link, with its GSI key inverting the direction so the relationship can be traversed both ways.
340
+
341
+ Consider `ArticleModel` and `TagModel`, where an article can have many tags and a tag can apply to many articles:
342
+
343
+ ```python title="packages/my_table/my_table/entities/article_tag.py"
344
+ from collections.abc import Iterator
345
+ from pynamodb.attributes import UnicodeAttribute
346
+ from .base import BaseModel
347
+
348
+
349
+ class ArticleTagModel(BaseModel, discriminator='ArticleTag'):
350
+ """
351
+ Junction entity for the Article ↔ Tag many-to-many relationship.
352
+
353
+ Key design:
354
+ pk=ARTICLE#<article_id>, sk=TAG#<tag_name> <- list tags for an article
355
+ gsi1pk=TAG#<tag_name>, gsi1sk=ARTICLE#<article_id> <- list articles for a tag
356
+ """
357
+
358
+ article_id = UnicodeAttribute()
359
+ tag_name = UnicodeAttribute()
360
+
361
+ @classmethod
362
+ def add(cls, article_id: str, tag_name: str) -> 'ArticleTagModel':
363
+ item = cls(
364
+ pk=f'ARTICLE#{article_id}',
365
+ sk=f'TAG#{tag_name}',
366
+ gsi1pk=f'TAG#{tag_name}',
367
+ gsi1sk=f'ARTICLE#{article_id}',
368
+ article_id=article_id,
369
+ tag_name=tag_name,
370
+ )
371
+ item.save()
372
+ return item
373
+
374
+ @classmethod
375
+ def remove(cls, article_id: str, tag_name: str) -> None:
376
+ cls.get(f'ARTICLE#{article_id}', f'TAG#{tag_name}').delete()
377
+
378
+ # ── Primary index: pk=article, sk=tag ─────────────────────────────────────
379
+ @classmethod
380
+ def list_tags_for_article(cls, article_id: str) -> Iterator['ArticleTagModel']:
381
+ return cls.query(f'ARTICLE#{article_id}')
382
+
383
+ # ── gsi1_index: pk=tag, sk=article ────────────────────────────────────────
384
+ @classmethod
385
+ def list_articles_for_tag(cls, tag_name: str) -> Iterator['ArticleTagModel']:
386
+ return cls.gsi1_index.query(f'TAG#{tag_name}')
387
+ ```
388
+
389
+ Because `ArticleTagModel` uses `pk=ARTICLE#<article_id>` — the same partition as the article itself — you can retrieve an article and all its tags in a single primary-table query:
390
+
391
+ ```python
392
+ from .base import BaseModel
393
+ from .article import ArticleModel
394
+ from .article_tag import ArticleTagModel
395
+
396
+ items = list(BaseModel.query('ARTICLE#article-123'))
397
+ article = next(i for i in items if isinstance(i, ArticleModel))
398
+ tags = [i.tag_name for i in items if isinstance(i, ArticleTagModel)]
399
+ ```
400
+
401
+ For further reading on DynamoDB data modelling, see the [DynamoDB data modelling guide](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling.html) and [Creating a single-table design with Amazon DynamoDB](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/).
402
+
403
+ ### Using the DynamoDB Client
404
+
405
+ The generated `client.py` exports two key utilities:
406
+
407
+ - `is_local()` — returns `True` when `SERVE_LOCAL=true`, used to switch between local and AWS behaviour.
408
+ - `get_table_name()` — returns the DynamoDB table name. When `SERVE_LOCAL=true`, reads the table name from `serveLocal.tableName` in `config.json`; otherwise fetches the name from AWS AppConfig using the `RUNTIME_CONFIG_APP_ID` environment variable and caches it for subsequent calls.
409
+
410
+ `BaseModel` in `entities/base.py` uses both to configure PynamoDB automatically:
411
+
412
+ - **Connection** — `BaseModel.Meta` sets `host` from `config.json` and hardcodes `region`, `aws_access_key_id`, and `aws_secret_access_key` when `is_local()` is `True`, pointing PynamoDB at the local DynamoDB instance. In AWS, these are left unset so PynamoDB uses the default credential chain.
413
+ - **Table name** — `BaseModel._get_connection()` calls `get_table_name()` before each operation, so the correct table is resolved at runtime without any manual configuration.
414
+
415
+ ### Stopping Local DynamoDB
416
+
417
+ <Snippet name="dynamodb/serve-local-windows" />
418
+
419
+ ## Adding/Removing Global Secondary Indexes
420
+
421
+ GSIs are defined in `config.json` at the project root under the `tableConfig.globalSecondaryIndexes` key. Add an entry for each GSI, then reflect the change in `BaseModel` by adding or removing the corresponding `GlobalSecondaryIndex` class and attributes in `<name>/entities/base.py`:
422
+
423
+ <Snippet name="dynamodb/gsi-config" parentHeading="Adding/Removing Global Secondary Indexes" />
424
+
425
+ ## Connecting to the Table
426
+
427
+ In any Python project, add the DynamoDB package as a workspace dependency and import entity classes directly:
428
+
429
+ ```python
430
+ from my_db_package.entities import ExampleModel
431
+
432
+ item = ExampleModel.get_by_id('123')
433
+ ```
434
+
435
+ :::note[Runtime config]
436
+ When running in AWS, `get_table_name()` fetches the table name from AWS AppConfig using the `RUNTIME_CONFIG_APP_ID` environment variable. Projects built with this plugin already have this variable configured automatically. For other Python projects, ensure `RUNTIME_CONFIG_APP_ID` is set in the runtime environment with the AppConfig application ID provisioned by your infrastructure. For more information, see the <Link path="guides/runtime-config">Runtime Configuration guide</Link>.
437
+ :::
438
+
439
+ ### Connection Generators
440
+
441
+ For specific project types, use the `connection` generator to automatically wire up local development dependencies so DynamoDB Local starts automatically alongside your project, and add the DynamoDB package as a workspace dependency:
442
+
443
+ - <Link path="guides/connection/py-fast-api-dynamodb">FastAPI → DynamoDB</Link>
444
+ - <Link path="guides/connection/py-agent-dynamodb">Python Agent → DynamoDB</Link>
445
+ - <Link path="guides/connection/py-mcp-server-dynamodb">Python MCP Server → DynamoDB</Link>
446
+
447
+ ## Deploying your Table
448
+
449
+ <Snippet name="dynamodb/deploying-table" parentHeading="Deploying your Table" />