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.
- package/README.md +9 -0
- package/dist/{chunk-J5A665UW.js → chunk-H5TUW2WR.js} +69 -153
- package/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/{chunk-7NPG5R7O.js → chunk-QBXLQNXY.js} +1 -1
- package/dist/{chunk-FMJIWFIS.js → chunk-W3GEJPPV.js} +258 -3
- package/dist/cli.js +314 -12
- package/dist/index.d.ts +99 -4
- package/dist/index.js +40 -8
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +3 -2
- package/dist/{types-B-F7jw9f.d.ts → types-m1Ect6hG.d.ts} +306 -114
- package/dist/typescript-ZUQEBJRV.js +210764 -0
- package/docs/cdc-projection.md +167 -0
- package/docs/design-patterns.md +18 -13
- package/docs/spec.md +9 -4
- package/package.json +4 -1
|
@@ -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
|
package/docs/design-patterns.md
CHANGED
|
@@ -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 (
|
|
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
|
|
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).
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
`
|
|
505
|
-
|
|
506
|
-
|
|
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).
|
|
510
|
-
typed-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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.
|
|
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
|
}
|