graphddb 0.4.3 → 0.5.1

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,294 @@
1
+ # GraphDDB — CloudFormation generation
2
+
3
+ `graphddb generate cloudformation` emits an AWS CloudFormation template
4
+ describing the **DynamoDB table(s)** a GraphDDB single-table design maps onto.
5
+ GraphDDB is single-table by design: every entity that shares a `table` name
6
+ contributes to the SAME `AWS::DynamoDB::Table`, and their Global Secondary
7
+ Indexes are **unioned** by index name.
8
+
9
+ The command consumes the same serializable manifest the Python bridge uses (it
10
+ loads your entry model, builds the manifest from the shared metadata registry,
11
+ and emits the template). Output is **deterministic** — the same model set always
12
+ produces byte-identical output — and defaults to YAML (`--format json` for JSON).
13
+
14
+ > This document describes the shipped `generate cloudformation` command. It is
15
+ > the reference for the command's options, the resources it generates, the
16
+ > derivation rules that map a model set onto a table, and its scope boundary.
17
+
18
+ ### Related specifications
19
+
20
+ - [`spec.md`](./spec.md) — the core API: entities, structured keys / GSIs, the
21
+ physical key attributes (`PK` / `SK` / `<index>PK` / `<index>SK`).
22
+ - [`design-patterns.md`](./design-patterns.md) — maintained access paths and the
23
+ `updateMode: 'stream'` signal that drives `--stream auto`.
24
+ - [`python-bridge.md`](./python-bridge.md) — the other consumer of the same
25
+ manifest / SSoT.
26
+
27
+ ## Overview
28
+
29
+ ```bash
30
+ graphddb generate cloudformation --entry models.ts --out table.yaml
31
+ ```
32
+
33
+ - **`--entry`** loads the TypeScript (or pre-compiled `.js`/`.mjs`) module that
34
+ registers your entity models as a side effect of import. TypeScript modules are
35
+ transpiled on demand via `tsx` (an optional peer dependency — install it with
36
+ `npm i -D tsx`).
37
+ - Without `--out`, the template is written verbatim to **stdout** (pipe-friendly).
38
+ With `--out`, it is written to that file and a JSON result
39
+ (`{"status":"ok","outDir":…,"files":[…]}`) is printed to stdout.
40
+
41
+ ## Options
42
+
43
+ Every option below matches the shipped CLI contract (`cli-contract.yaml`) and the
44
+ handler validation exactly.
45
+
46
+ | Option | Alias | Type | Default | Notes / validation |
47
+ |--------|-------|------|---------|--------------------|
48
+ | `--entry` | `-e` | file | — (required) | Entry module that registers the entity models. Must exist / be readable; missing → exit `2`. |
49
+ | `--out` | `-o` | file | stdout | Output file. Parent directories are created. Omitted → template to stdout. |
50
+ | `--format` | `-f` | `yaml` \| `json` | `yaml` | Serialization format. Any other value → exit `2`. |
51
+ | `--gsi-projection` | | `ALL` \| `KEYS_ONLY` | `ALL` | GSI `ProjectionType`. `ALL` projects every attribute; `KEYS_ONLY` projects only the key attributes. Other value → exit `2`. |
52
+ | `--stream` | | `auto` \| `on` \| `off` | `auto` | DynamoDB Streams (`StreamSpecification` `NEW_AND_OLD_IMAGES`). `auto` emits a stream only when the model set uses stream-based maintenance (`updateMode: 'stream'`); `on` always emits; `off` never. When enabled, a `StreamArn` Output is added. Other value → exit `2`. |
53
+ | `--billing-mode` | | `PAY_PER_REQUEST` \| `PROVISIONED` | `PAY_PER_REQUEST` | `PAY_PER_REQUEST` is on-demand (no provisioned throughput). `PROVISIONED` emits `BillingMode: PROVISIONED` with `ProvisionedThroughput` on the table **and every GSI**, and unlocks Auto Scaling. Other value → exit `2`. |
54
+ | `--rcu` | | integer ≥ 1 | `5` | Provisioned `ReadCapacityUnits` on the table and every GSI. **Only valid with `--billing-mode PROVISIONED`** — supplying it with `PAY_PER_REQUEST` is a loud reject (exit `2`). Non-integer / `< 1` → exit `2`. |
55
+ | `--wcu` | | integer ≥ 1 | `5` | Provisioned `WriteCapacityUnits`. Same rules as `--rcu`. |
56
+ | `--autoscale` | | boolean flag | off | Enable Application Auto Scaling (a `ScalableTarget` + `ScalingPolicy` pair for the table read/write and each GSI read/write). **Only valid with `--billing-mode PROVISIONED`** — with `PAY_PER_REQUEST` it is a loud reject (exit `2`). |
57
+ | `--autoscale-min` | | integer ≥ 1 | `5` | Auto Scaling `MinCapacity` for every scalable target. Must be `<= --autoscale-max` (else exit `2`). Meaningful only with `--autoscale`. |
58
+ | `--autoscale-max` | | integer ≥ 1 | `100` | Auto Scaling `MaxCapacity` for every scalable target. Must be `>= --autoscale-min`. |
59
+ | `--autoscale-target` | | integer 1–100 | `70` | Target utilization % (`TargetValue` of the target-tracking policy). `> 100` → exit `2`. |
60
+ | `--pitr` | | boolean flag | off | Point-in-time recovery (`PointInTimeRecoverySpecification` `PointInTimeRecoveryEnabled: true`). Omitted entirely when off (never emitted as `false`). |
61
+ | `--table-class` | | `STANDARD` \| `STANDARD_INFREQUENT_ACCESS` | omitted | Emits `TableClass: <value>`. Omitted by default (AWS defaults to `STANDARD`). Other value → exit `2`. |
62
+ | `--sse` | | boolean flag | off | Server-side encryption with the AWS-owned/managed key (`SSESpecification` `SSEEnabled: true` — no KMS key ARN / SSEType). Omitted entirely when off. |
63
+
64
+ ### Exit codes
65
+
66
+ - `0` — generation succeeded.
67
+ - `1` — unexpected error (e.g. the entry module threw). JSON error on stderr.
68
+ - `2` — invalid arguments (missing / unreadable `--entry`, an invalid enum value,
69
+ a bad integer, or a loud reject below). JSON error on stderr
70
+ (`{"code":"INVALID_ARGS","message":…}`).
71
+
72
+ ## Generated resources
73
+
74
+ For each **physical table** the model set maps onto, the template emits:
75
+
76
+ - **`AWS::DynamoDB::Table`** with:
77
+ - `TableName` — a `!Ref` to a `TableName` parameter whose default is the
78
+ physical table name. (Multi-table designs get one parameter per table, keyed
79
+ by logical id.)
80
+ - `BillingMode` — `PAY_PER_REQUEST` (default) or `PROVISIONED`.
81
+ - `AttributeDefinitions` — **key attributes only**, every one `AttributeType: S`
82
+ (see derivation rules).
83
+ - `KeySchema` — `PK` (HASH) plus `SK` (RANGE) when the base key has a sort key.
84
+ - `GlobalSecondaryIndexes` — one per unioned GSI (omitted when there are none).
85
+ - `ProvisionedThroughput` — on the table **and every GSI**, only under
86
+ `PROVISIONED`.
87
+ - `StreamSpecification` — `NEW_AND_OLD_IMAGES`, only when streams are enabled.
88
+ - `TimeToLiveSpecification` — only when a `@ttl` field is declared (see TTL).
89
+ - `PointInTimeRecoverySpecification` / `TableClass` / `SSESpecification` — only
90
+ when the corresponding `--pitr` / `--table-class` / `--sse` option is given.
91
+ - **`AWS::ApplicationAutoScaling::ScalableTarget` + `::ScalingPolicy`** — separate
92
+ top-level resources (not table properties), only under `--autoscale`.
93
+ - **Parameters** — a `TableName` (String, default = physical name).
94
+ - **Outputs** — the table name (`Ref`) and ARN (`Fn::GetAtt … Arn`), plus a
95
+ `StreamArn` output when streams are enabled.
96
+
97
+ All resources, attribute definitions, and GSIs are emitted in a stable (sorted)
98
+ order, so the output is byte-deterministic.
99
+
100
+ ## Derivation rules
101
+
102
+ How a GraphDDB model set is mapped onto the physical DynamoDB shape:
103
+
104
+ 1. **One physical table per logical `table` name.** Entities that share a `table`
105
+ are single-table siblings and contribute to the same `AWS::DynamoDB::Table`.
106
+ The CFn logical resource id is `<AlphanumericLogicalName>Table` (e.g.
107
+ `UserPermissions` → `UserPermissionsTable`).
108
+ 2. **Physical key attribute names match the runtime exactly.** The base partition
109
+ key is always `PK`; the base sort key (when present) is `SK`. Each GSI uses
110
+ `<indexName>PK` and `<indexName>SK` (e.g. `GSI1PK` / `GSI1SK`).
111
+ 3. **`AttributeDefinitions` are key attributes only, all `AttributeType: S`.** It
112
+ contains `PK`, `SK` (when the table has a sort key), and each GSI's `<index>PK`
113
+ plus `<index>SK` (only for GSIs that have a sort key). **Non-key attributes are
114
+ never added** — in particular the TTL attribute is deliberately kept out
115
+ (CloudFormation rejects a non-key attribute there; `TimeToLiveSpecification`
116
+ references it legitimately).
117
+ 4. **The base `KeySchema` is `PK` (HASH) + `SK` (RANGE).** `SK` is included when
118
+ **any** entity on the table declares a sort key; a PK-only design omits `SK`
119
+ and its attribute definition.
120
+ 5. **GSIs are unioned by index name across all entities on the table.** The same
121
+ `IndexName` declared by several entities collapses to one GSI. Each GSI's
122
+ `KeySchema` is `<index>PK` (HASH) plus `<index>SK` (RANGE) — HASH-only when the
123
+ GSI has no sort key. `Projection.ProjectionType` is `ALL` (default) or
124
+ `KEYS_ONLY`. (Full GSI-union consistency — same sort-key presence across
125
+ entities, ≤ 20 GSIs — is enforced statically at model registration by the
126
+ `cfn-schema-consistency` linter rule; the emitter keeps a backstop that throws
127
+ on a surviving inconsistency.)
128
+ 6. **Streams (`--stream auto`) are derived from maintenance metadata.** `auto`
129
+ emits a stream when any registered entity uses stream-based maintenance
130
+ (`updateMode: 'stream'` — on a relation `write`, a versioned relation preset,
131
+ an `@aggregate` `write`, or a view's `@maintainedFrom`). This signal comes from
132
+ the metadata registry, not the manifest. The single view type is
133
+ `NEW_AND_OLD_IMAGES` (stream-based maintenance needs both images).
134
+ 7. **TTL is derived from a `@ttl` field.** When an entity on the table declares a
135
+ `@ttl` field, its physical attribute name is carried in the manifest and
136
+ rendered as `TimeToLiveSpecification { AttributeName, Enabled: true }`. DynamoDB
137
+ allows exactly one TTL attribute per table; the manifest build enforces this,
138
+ and the emitter backstops it by throwing if two entities on a table disagree.
139
+ 8. **Provisioned throughput + Auto Scaling are pure deploy-time settings** (from
140
+ CLI options, not derived from the model). Under `PROVISIONED`, the table and
141
+ every GSI carry `ProvisionedThroughput` (`--rcu` / `--wcu`, default 5/5).
142
+ Under `--autoscale`, each of the table read/write and every GSI read/write gets
143
+ a `ScalableTarget` (`ServiceNamespace: dynamodb`, the matching
144
+ `ScalableDimension`, an `Fn::Sub` `ResourceId` tying to the table's `Ref`,
145
+ `MinCapacity`/`MaxCapacity`, and the DynamoDB service-linked role ARN) plus a
146
+ `TargetTrackingScaling` `ScalingPolicy` (predefined
147
+ `DynamoDBRead/WriteCapacityUtilization` metric, `TargetValue`). So **N GSIs →
148
+ `2×(1+N)` scalable targets + `2×(1+N)` scaling policies.** GSI targets
149
+ additionally `DependsOn` the table.
150
+ 9. **PITR / TableClass / SSE are pure deploy-time settings**, each defaulting to
151
+ OFF/omitted — the emitter omits the whole property rather than emitting a
152
+ `false`/default value.
153
+
154
+ ## Scope boundary
155
+
156
+ This command generates **only the GraphDDB-managed DynamoDB resources** — the
157
+ table(s), their GSIs, and the DynamoDB-adjacent settings above (Streams, TTL,
158
+ PITR, TableClass, SSE, provisioned throughput, and the Application Auto Scaling
159
+ resources for that table). It does **not** generate IAM roles/policies for your
160
+ application, Lambda functions, API Gateway, stream consumers, DynamoDB Streams
161
+ event-source mappings, or any other application infrastructure. The one IAM
162
+ reference it emits is the AWS **service-linked** role ARN that Application Auto
163
+ Scaling uses (built with `Fn::Sub` so `${AWS::AccountId}` resolves at deploy
164
+ time) — it does not create an IAM role. Compose the generated template into your
165
+ own stack (nested stack / module) alongside those app resources.
166
+
167
+ ## Loud-reject / validation behaviors
168
+
169
+ The CLI validates arguments before generation and **rejects loudly** (exit `2`,
170
+ JSON `INVALID_ARGS` on stderr) rather than silently ignoring a nonsensical
171
+ combination. The pure emitter keeps the same guards as an authoritative backstop
172
+ (it throws). The loud rejects are:
173
+
174
+ - **`--rcu` / `--wcu` with `PAY_PER_REQUEST`** — on-demand tables have no
175
+ provisioned capacity, so accepting the value would mislead you.
176
+ - **`--autoscale` with `PAY_PER_REQUEST`** — Application Auto Scaling on an
177
+ on-demand table is invalid in AWS.
178
+ - **`--autoscale-min` > `--autoscale-max`** — an invalid scalable-target range.
179
+ - **`--autoscale-target` > 100** — it is a utilization percentage (1–100).
180
+ - **A non-integer or `< 1` value for `--rcu` / `--wcu` / `--autoscale-*`.**
181
+ - **An invalid enum value** for `--format` / `--gsi-projection` / `--stream` /
182
+ `--billing-mode` / `--table-class`.
183
+ - **A missing `--entry`** (or one that cannot be imported).
184
+
185
+ Inconsistent single-table declarations (a GSI with conflicting sort-key presence,
186
+ or two `@ttl` attributes on one table) are caught statically by the
187
+ `cfn-schema-consistency` linter at model registration; the emitter throws as a
188
+ backstop if one survives to generation time.
189
+
190
+ ## Worked example
191
+
192
+ The [`user-permissions`](../examples/user-permissions/) example is a single
193
+ `UserPermissions` table whose entities (users, groups, memberships, permissions)
194
+ share one physical table, with a `GSI1` **unioned** across `UserModel` (email
195
+ lookup) and `GroupMembershipModel` (the inverted membership index). It exercises
196
+ the base `PK`/`SK` key and a GSI union — it does **not** use streams, TTL, PITR,
197
+ provisioned throughput, or auto scaling (those are opt-in flags / a `@ttl`
198
+ decorator this model does not trigger).
199
+
200
+ Generate it (the committed sample
201
+ [`examples/user-permissions/cloudformation.yaml`](../examples/user-permissions/cloudformation.yaml)
202
+ is produced by exactly this command, and a drift-guard test keeps it in sync):
203
+
204
+ ```bash
205
+ graphddb generate cloudformation \
206
+ --entry examples/user-permissions/models.ts \
207
+ --out examples/user-permissions/cloudformation.yaml
208
+ ```
209
+
210
+ The generated template (default YAML):
211
+
212
+ ```yaml
213
+ AWSTemplateFormatVersion: "2010-09-09"
214
+ Description: DynamoDB table(s) for a GraphDDB single-table design (generated by graphddb generate cloudformation).
215
+ Parameters:
216
+ TableName:
217
+ Type: String
218
+ Default: UserPermissions
219
+ Description: Name of the UserPermissions DynamoDB table.
220
+ Resources:
221
+ UserPermissionsTable:
222
+ Type: "AWS::DynamoDB::Table"
223
+ Properties:
224
+ TableName:
225
+ Ref: TableName
226
+ BillingMode: PAY_PER_REQUEST
227
+ AttributeDefinitions:
228
+ - AttributeName: PK
229
+ AttributeType: S
230
+ - AttributeName: SK
231
+ AttributeType: S
232
+ - AttributeName: GSI1PK
233
+ AttributeType: S
234
+ - AttributeName: GSI1SK
235
+ AttributeType: S
236
+ KeySchema:
237
+ - AttributeName: PK
238
+ KeyType: HASH
239
+ - AttributeName: SK
240
+ KeyType: RANGE
241
+ GlobalSecondaryIndexes:
242
+ - IndexName: GSI1
243
+ KeySchema:
244
+ - AttributeName: GSI1PK
245
+ KeyType: HASH
246
+ - AttributeName: GSI1SK
247
+ KeyType: RANGE
248
+ Projection:
249
+ ProjectionType: ALL
250
+ Outputs:
251
+ TableName:
252
+ Description: Name of the UserPermissions DynamoDB table.
253
+ Value:
254
+ Ref: UserPermissionsTable
255
+ TableArn:
256
+ Description: ARN of the UserPermissions DynamoDB table.
257
+ Value:
258
+ "Fn::GetAtt":
259
+ - UserPermissionsTable
260
+ - Arn
261
+ ```
262
+
263
+ ### More option combinations
264
+
265
+ ```bash
266
+ # JSON output.
267
+ graphddb generate cloudformation --entry models.ts --format json
268
+
269
+ # Key-only GSI projection.
270
+ graphddb generate cloudformation --entry models.ts --gsi-projection KEYS_ONLY --out table.yaml
271
+
272
+ # Force a DynamoDB stream (NEW_AND_OLD_IMAGES) on.
273
+ graphddb generate cloudformation --entry models.ts --stream on --out table.yaml
274
+
275
+ # Deploy-time hardening: PITR + SSE + Infrequent-Access table class.
276
+ graphddb generate cloudformation --entry models.ts --pitr --sse --table-class STANDARD_INFREQUENT_ACCESS --out table.yaml
277
+
278
+ # Provisioned billing with fixed throughput.
279
+ graphddb generate cloudformation --entry models.ts --billing-mode PROVISIONED --rcu 10 --wcu 5 --out table.yaml
280
+
281
+ # Provisioned billing + Application Auto Scaling.
282
+ graphddb generate cloudformation --entry models.ts \
283
+ --billing-mode PROVISIONED --autoscale --autoscale-min 5 --autoscale-max 100 --autoscale-target 70 --out table.yaml
284
+ ```
285
+
286
+ ## Validating the output
287
+
288
+ Real `aws cloudformation validate-template` requires AWS credentials and the
289
+ `cloudformation:ValidateTemplate` IAM permission. Where those are unavailable
290
+ (this project's CI is IAM-blocked, for example), an equivalent **structural**
291
+ check is enough for a syntax gate: generate the template in both formats, parse
292
+ each (YAML + JSON), assert the two agree, and assert the table structure. The
293
+ repository wires this as `npm run validate:cfn` (see `scripts/validate-cfn.mjs`),
294
+ which the CI runs as its "Validate generated CloudFormation (structural)" step.
@@ -47,7 +47,7 @@ graph, the stream drain, and the rebuild planner consume every pattern uniformly
47
47
  | 7 | [Aggregate Counter](#7-aggregate-counter) | `@aggregate(..., { pattern: 'counter', value: count() })` | ✅ Phase 1 (`count()`) / stream (`max()`) |
48
48
  | 8 | [Versioned / Latest Pointer](#8-versioned--latest-pointer) | `@hasOne(... versionedLatest)` + `@hasMany(... versionedHistory)` | ✅ Phase 3 |
49
49
  | 9 | [Reverse Lookup](#9-reverse-lookup) | GSI (both directions) or `dualEdge` two-row sync | ✅ Phase 1 (GSI) / Phase 3 (`dualEdge`) |
50
- | 10 | [External Projection](#10-external-projection) | typed-consumer-IF contract (redesign see #153) | 🚧 redesign |
50
+ | 10 | [External Projection](#10-external-projection) | typed-consumer-IF contract (`@cdcProjected` + `fromChange` / `subscribe`) | |
51
51
 
52
52
  ---
53
53
 
@@ -494,23 +494,28 @@ OpenSearch, S3, Neptune — for analytics or search, decoupled from the table. T
494
494
  projection is asynchronous and must tolerate at-least-once delivery (a redelivered
495
495
  event must not double-write).
496
496
 
497
- **(b) How it is being redesigned (🚧 #153).** The previous `defineProjection` builder +
497
+ **(b) How to declare it in graphddb.** The previous `defineProjection` builder +
498
498
  `ProjectionSinkDrain` / `InMemoryProjectionSink` runtime baked the sink-delivery
499
499
  semantics (upsert, idempotency, dedup) into the library — a boundary overreach: the
500
500
  actual sink write happens inside the consumer's CDC infrastructure, outside graphddb.
501
- That runtime was **removed in 0.4.0** (issue #152). The redesign (issue #153) reframes
502
- external projection as a **typed-consumer-IF contract**: from a declared source +
503
- projection fields + idempotency-key extraction, graphddb emits a typed
504
- `(changeImage) => TypedProjectionRecord` mapper for the consumer to call inside its own
505
- sink delivery graphddb owns the *parse typed record* contract, not the delivery.
506
- External projection is neither a Model nor a relation, so it does not ride a decorator.
501
+ That runtime was **removed in 0.4.0** (issue #152). External projection is now a
502
+ **typed-consumer-IF contract** (issue #153): mark the source model with
503
+ `@cdcProjected()`, then parse a CDC change event into typed instances — `Model.fromChange(event)`
504
+ returns `[oldRecord, newRecord]`, and `DDBModel.subscribe(handlers)` (the GraphQL-style
505
+ sibling of `query` / `mutate`) returns a `ChangeHandler` the consumer mounts on its own
506
+ stream. graphddb owns the *parse typed record* contract; subscription, sink delivery,
507
+ and idempotency stay with the consumer. See [`cdc-projection.md`](./cdc-projection.md)
508
+ for the full design.
507
509
 
508
510
  **(c) Read & write behavior.** Out of scope for this library (the data is read from the
509
- sink; delivery + idempotency live in the consumer's CDC code). See #153 for the
510
- typed-consumer-IF contract design.
511
-
512
- **(d) Constraints & phase status.** 🚧 Redesign (#153). The sink runtime is gone in
513
- 0.4.0; the typed-consumer-IF replacement lands separately.
511
+ sink; delivery + idempotency live in the consumer's CDC code). graphddb only parses the
512
+ change event into a typed record; see [`cdc-projection.md`](./cdc-projection.md) for the
513
+ typed-consumer-IF contract.
514
+
515
+ **(d) Constraints & phase status.** ✅ Shipped (issue #153). The sink runtime was removed
516
+ in 0.4.0; the typed-consumer-IF replacement (`@cdcProjected` + `fromChange` + `subscribe`
517
+ + the generated `CdcModelRegistry`) is implemented and tested (unit + emulator-driven
518
+ integration).
514
519
 
515
520
  ---
516
521
 
package/docs/spec.md CHANGED
@@ -546,9 +546,10 @@ Shared vocabulary (confirmed across the epic):
546
546
  view model** — `materializedView` / `sparseView` — are declared with `@model({ kind })`
547
547
  + `@maintainedFrom` (§7.1 below). The **versioned** presets ride the `@hasOne` /
548
548
  `@hasMany` `pattern` discriminated union (`versionedLatest` / `versionedHistory`).
549
- External projection is being redesigned as a typed-consumer-IF contract (#153) and
550
- has no decorator. Unknown values on a relation are accepted by the open-ended union
551
- but not lowered.
549
+ External projection is a typed-consumer-IF contract (#153); it is not a relation
550
+ `pattern` the source model is marked with `@cdcProjected()` instead (see
551
+ [`cdc-projection.md`](./cdc-projection.md)). Unknown values on a relation are
552
+ accepted by the open-ended union but not lowered.
552
553
  - **`write.maintainedOn`** — cross-entity triggers `"<Entity>.<event>"`. The
553
554
  Entity segment is the **logical** entity name (`PostModel → 'Post'`; or the
554
555
  `@model({ prefix })` value sans `#`), and the event is one of
@@ -634,7 +635,11 @@ The latest/history key binding is derived from each target model's own `key` fie
634
635
  composed snapshot×2 IR. (A self-co-located form is a deferred follow-up.)
635
636
 
636
637
  External projection's old `defineProjection` / `ProjectionSinkDrain` runtime was
637
- **removed in 0.4.0** and is being redesigned as a typed-consumer-IF contract (#153).
638
+ **removed in 0.4.0** and is now a typed-consumer-IF contract (#153):
639
+ the source model is marked `@cdcProjected()`, `Model.fromChange(event)` parses a CDC
640
+ change event into `[oldRecord, newRecord]`, and `DDBModel.subscribe(handlers)` returns a
641
+ `ChangeHandler` the consumer mounts on its own stream (subscription / sink delivery /
642
+ idempotency stay with the consumer). See [`cdc-projection.md`](./cdc-projection.md).
638
643
 
639
644
  ### 7.2 Stream maintenance — outbox → CDC drain (`updateMode: 'stream'`, #130)
640
645
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphddb",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Graph data modeling on DynamoDB with adjacency list pattern",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,6 +37,7 @@
37
37
  "test:py": "python3 -m pytest python/tests -m \"not integration\"",
38
38
  "test:py:integration": "python3 -m pytest python/tests -m integration",
39
39
  "test:conformance": "tsx conformance/run.ts",
40
+ "validate:cfn": "node scripts/validate-cfn.mjs",
40
41
  "gen:cli": "cli-contracts generate",
41
42
  "gen:cli:check": "cli-contracts generate --dry-run",
42
43
  "clean": "rm -rf dist",
@@ -70,7 +71,8 @@
70
71
  "tsup": "^8.0.0",
71
72
  "tsx": "^4.22.4",
72
73
  "typescript": "^5.5.0",
73
- "vitest": "^3.0.0"
74
+ "vitest": "^3.0.0",
75
+ "yaml": "^2.9.0"
74
76
  },
75
77
  "engines": {
76
78
  "node": ">=22"