graphddb 0.5.0 → 0.5.2

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,167 @@
1
+ # GraphDDB — CDC Projection (typed-consumer-IF)
2
+
3
+ CDC projection lets you turn a model's change stream into **typed records** you can
4
+ push to an **external sink** — BigQuery, OpenSearch, S3, Neptune — for analytics or
5
+ search, decoupled from the table. Delivery is asynchronous over CDC (DynamoDB Streams)
6
+ and must tolerate at-least-once semantics (a redelivered event must not double-write).
7
+
8
+ graphddb's job here is narrow and deliberate: **parse a change event into a typed
9
+ record**. It does *not* subscribe to the stream, write to the sink, or de-duplicate —
10
+ those belong to your infrastructure. This keeps the library out of your delivery
11
+ semantics (upsert, idempotency, dedup) while still giving you fully typed access to
12
+ what changed.
13
+
14
+ ## The boundary
15
+
16
+ graphddb owns the **parse → typed record** contract, and nothing downstream of it.
17
+
18
+ | graphddb (the contract) | you (the runtime) |
19
+ | --- | --- |
20
+ | Enable CDC parse on a source model (`@cdcProjected`) | Subscribe to the CDC stream (emulator / DynamoDB Streams) |
21
+ | Parse `oldImage` / `newImage` into typed instances | Write to the sink (upsert / delete) |
22
+ | Route an event to its owning model | Idempotency store (dedup / at-least-once) |
23
+ | Produce the `(event) => [old, new]` typed mapper | Actual delivery and retry decisions |
24
+
25
+ What graphddb hands you is a pure function (and a batch handler built on it) — never a
26
+ subscription, a sink write, or a dedup store.
27
+
28
+ ## API
29
+
30
+ ### 1. `@cdcProjected()` — mark a source model
31
+
32
+ Enables CDC parse on the model. `fromChange` and `subscribe` typing only apply to a
33
+ `@cdcProjected` model (calling `fromChange` on an un-annotated model is an error). The
34
+ decorator has no runtime effect — it only marks the model as CDC-parseable.
35
+
36
+ ```ts
37
+ import { model, cdcProjected, DDBModel, string, number, datetime, key, k } from 'graphddb';
38
+
39
+ @model({ table: 'Orders', prefix: 'ORDER' })
40
+ @cdcProjected()
41
+ class OrderModel extends DDBModel {
42
+ static readonly keys = key<{ orderId: string }>((c) => ({
43
+ pk: k`ORDER#${c.orderId}`, sk: k`META`,
44
+ }));
45
+
46
+ @string orderId!: string;
47
+ @string status!: string;
48
+ @number amountTotal!: number;
49
+ @datetime updatedAt!: Date;
50
+ }
51
+ ```
52
+
53
+ ### 2. `Model.fromChange(event)` — procedural, single event
54
+
55
+ Takes a `ChangeEvent` and returns the `[oldRecord, newRecord]` tuple, parsing
56
+ `oldImage` / `newImage` into typed model instances (the same hydration used for reads —
57
+ ISO strings become `Date`, embedded objects are reconstructed). It returns **all
58
+ fields** (there is no field projection — see [Design notes](#design-notes)).
59
+
60
+ ```ts
61
+ const [oldRecord, newRecord] = OrderModel.fromChange(event);
62
+ // ^ OrderModel | null ^ OrderModel | null
63
+
64
+ // One side is null depending on the event kind:
65
+ // INSERT → [null, newRecord]
66
+ // MODIFY → [oldRecord, newRecord]
67
+ // REMOVE → [oldRecord, null ]
68
+ // (event for a different model) → [null, null] ← self-routing
69
+
70
+ if (oldRecord || newRecord) {
71
+ if (!newRecord) {
72
+ // REMOVE — delete; derive the sink key from oldRecord
73
+ } else {
74
+ // INSERT | MODIFY — upsert; diff against oldRecord if you need change detection
75
+ }
76
+ }
77
+ ```
78
+
79
+ A valid event for this model always has at least one non-null side, so `[null, null]`
80
+ is an unambiguous "this event is not for this model" signal. You don't need a separate
81
+ guard — `fromChange` does routing **and** parse in one call.
82
+
83
+ ### 3. `DDBModel.subscribe(handlers)` — declarative, batch
84
+
85
+ The third sibling of `query` / `mutate` (GraphQL's query / mutation / **subscription**).
86
+ It takes a handler map keyed by model name. Unlike `query` / `mutate`, which hit
87
+ DynamoDB, **`subscribe` does not subscribe**: it *returns* a `ChangeHandler`
88
+ (`(batch) => Promise<BatchResult>`) that you mount on your own stream source.
89
+
90
+ ```ts
91
+ // graphddb side: build a batch handler that parses + routes + dispatches (no sink, no dedup)
92
+ const handler = DDBModel.subscribe({
93
+ OrderModel: (old, neu) => { // old, neu: OrderModel | null (typed via the generated registry)
94
+ if (!neu) return opensearch.delete({ id: old!.orderId });
95
+ return opensearch.index({ id: neu.orderId, body: { status: neu.status } });
96
+ },
97
+ UserModel: (old, neu) => { /* old, neu: UserModel | null */ },
98
+ });
99
+
100
+ // your side: the real subscription lives here (delivery is yours)
101
+ emulator.subscribe(handler); // local (CDC emulator)
102
+ // export const lambdaHandler = handler; // production (DynamoDB Streams → Lambda)
103
+ ```
104
+
105
+ The returned handler:
106
+
107
+ - loops the batch and routes each event to its owning model;
108
+ - typed-parses via that model's `fromChange`, then calls the matching handler;
109
+ - if a handler throws, records that record's `sequenceNumber` into `batchItemFailures`
110
+ (the Streams / Lambda partial-batch-failure protocol, so only the failed records are
111
+ redelivered);
112
+ - **never writes a sink and never dedups** — the `opensearch.*` calls above are your code.
113
+
114
+ Recording `batchItemFailures` is the delivery *protocol* shape (which records to
115
+ redeliver), not delivery *semantics* (upsert / dedup), so it stays inside the boundary.
116
+
117
+ ### 4. `CdcModelRegistry` — generated type registry
118
+
119
+ The `subscribe` handlers are keyed by model name (a string), so type inference needs a
120
+ type-level "model name → type" map. graphddb's code generation emits it for you as a
121
+ module augmentation:
122
+
123
+ ```ts
124
+ // generated by `graphddb generate` (you do not write this)
125
+ declare module 'graphddb' {
126
+ interface CdcModelRegistry {
127
+ OrderModel: OrderModel;
128
+ UserModel: UserModel;
129
+ }
130
+ }
131
+ ```
132
+
133
+ With it in your build, `subscribe`'s keys are constrained to your registered models
134
+ (typos and unknown model names are compile errors) and each handler's `old` / `neu` is
135
+ inferred from the key.
136
+
137
+ ## The GraphQL trio
138
+
139
+ | GraphQL | DDBModel | Executed by | Returns |
140
+ | --- | --- | --- | --- |
141
+ | query | `DDBModel.query(envelope)` | graphddb (hits DynamoDB) | result object |
142
+ | mutation | `DDBModel.mutate(envelope)` | graphddb (hits DynamoDB) | result object |
143
+ | subscription | `DDBModel.subscribe(handlers)` | **you** (mount it) | `ChangeHandler` |
144
+
145
+ Only subscription is "executed by you, returns a handler rather than a result" —
146
+ because CDC delivery lives outside the boundary.
147
+
148
+ ## Design notes
149
+
150
+ - **No field projection (`fromChange` returns all fields).** Narrowing the fields does
151
+ not reduce network cost (the image is already on the stream), so there is no `select`.
152
+ Shape the record however you like in plain TS inside your handler.
153
+ - **`[null, null]` for routing.** A valid event always has a non-null side, so both
154
+ being null is an unambiguous "not for this model" signal — `fromChange` handles
155
+ routing and parse without an extra guard.
156
+ - **`subscribe` returns a handler.** So that graphddb owns neither the subscription nor
157
+ delivery. The return type being a `ChangeHandler` (not a result) is the visible
158
+ expression of the boundary.
159
+ - **Naming.** `subscribe` completes the GraphQL query / mutation / subscription trio
160
+ (distinct from `emulator.subscribe(handler)`, which is the actual subscription).
161
+
162
+ ## See also
163
+
164
+ - [`spec.md` §7 — Relations & maintained access paths](./spec.md#7-relations)
165
+ - [`design-patterns.md` §10 — External Projection](./design-patterns.md#10-external-projection)
166
+ - [`cdc-emulator.md`](./cdc-emulator.md) — the CDC emulator that drives stream handlers locally
167
+ - [`class-hydration.md`](./class-hydration.md) — how raw items become typed instances
@@ -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.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Graph data modeling on DynamoDB with adjacency list pattern",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -80,5 +80,8 @@
80
80
  "license": "MIT",
81
81
  "dependencies": {
82
82
  "commander": "^15.0.0"
83
+ },
84
+ "optionalDependencies": {
85
+ "typescript": "^5.5.0"
83
86
  }
84
87
  }