graphddb 0.4.1 → 0.4.3
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 +166 -643
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +3 -2
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# GraphDDB — DynamoDB Design Patterns
|
|
2
|
+
|
|
3
|
+
DynamoDB modeling is the art of choosing, per access pattern, a **read shape** and
|
|
4
|
+
the **write maintenance** that keeps it true. GraphDDB's thesis (Epic #118) is that
|
|
5
|
+
a relation is not navigation — it is a *maintained access path*: a read strategy
|
|
6
|
+
plus a write-maintenance effect, declared as one contract and compiled into the
|
|
7
|
+
read plan, the write plan, the repair/rebuild plan, and the validators.
|
|
8
|
+
|
|
9
|
+
This page maps the **ten canonical DynamoDB designs** (RFC #118 §1) to the concrete
|
|
10
|
+
graphddb feature that implements each. For every pattern you get:
|
|
11
|
+
|
|
12
|
+
- **(a) What it is** — the use case and the shape it takes on a DynamoDB table.
|
|
13
|
+
- **(b) How to declare it in graphddb** — the feature / preset / option, with a
|
|
14
|
+
minimal code example taken from the real API.
|
|
15
|
+
- **(c) Read & write behavior** — how the read is served and how the write keeps it
|
|
16
|
+
in sync.
|
|
17
|
+
- **(d) Constraints & phase status** — limits and which Epic #118 phase ships it.
|
|
18
|
+
|
|
19
|
+
> Every API name, option, and helper below is exercised by the library's tests and
|
|
20
|
+
> `examples/`. The maintained-relation vocabulary (`pattern` / `read` / `write` /
|
|
21
|
+
> `projection`, the `maintainedOn` triggers, `updateMode`) is specified in
|
|
22
|
+
> [`spec.md` §7](./spec.md#7-relations); this page is the pattern-oriented companion.
|
|
23
|
+
|
|
24
|
+
## The maintenance pipeline in one paragraph
|
|
25
|
+
|
|
26
|
+
A maintained shape is fed by **cross-entity triggers**: `write.maintainedOn:
|
|
27
|
+
['<Entity>.<event>']`, where `<Entity>` is the source's *logical* name (the class
|
|
28
|
+
name minus a `Model` suffix, or the `@model({ prefix })` value) and `<event>` is
|
|
29
|
+
`created | updated | removed`. A synchronous (`updateMode: 'mutation'`) maintainer
|
|
30
|
+
folds its owner-row write into the **same `TransactWriteItems`** as the source write
|
|
31
|
+
(atomic with it). A `stream` maintainer instead emits a maintenance-outbox row that
|
|
32
|
+
a CDC drain applies asynchronously — this is how operations a single transaction
|
|
33
|
+
cannot express (collection trim/splice, a running `max`, sparse put/delete) are
|
|
34
|
+
realized. All presets lower to the **same maintenance IR**, so the maintenance
|
|
35
|
+
graph, the stream drain, and the rebuild planner consume every pattern uniformly.
|
|
36
|
+
|
|
37
|
+
## Pattern index
|
|
38
|
+
|
|
39
|
+
| # | Pattern | graphddb feature | Status |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| 1 | [Same Partition / Containment](#1-same-partition--containment) | segmented SK + `hasMany`, `pattern: 'samePartition'` | ✅ Phase 1 |
|
|
42
|
+
| 2 | [Embedded Refs](#2-embedded-refs) | `embeddedSnapshot` collection projecting id fields only | ✅ Phase 1 (sync append) / stream for trim |
|
|
43
|
+
| 3 | [Embedded Snapshot](#3-embedded-snapshot) | `pattern: 'embeddedSnapshot'` (snapshot / collection) | ✅ Phase 1 |
|
|
44
|
+
| 4 | [Edge / Adjacency](#4-edge--adjacency) | `edgeWrites` `putEdge` / `deleteEdge` / `updateKeyChangeEdge` | ✅ Phase 1 |
|
|
45
|
+
| 5 | [Materialized View](#5-materialized-view) | `@model({ kind: 'materializedView' })` + `@maintainedFrom` | ✅ Phase 2 (stream) |
|
|
46
|
+
| 6 | [Sparse View](#6-sparse-view) | `@model({ kind: 'sparseView' })` + `@maintainedFrom` + `whenMember` | ✅ Phase 3 (stream-only) |
|
|
47
|
+
| 7 | [Aggregate Counter](#7-aggregate-counter) | `@aggregate(..., { pattern: 'counter', value: count() })` | ✅ Phase 1 (`count()`) / stream (`max()`) |
|
|
48
|
+
| 8 | [Versioned / Latest Pointer](#8-versioned--latest-pointer) | `@hasOne(... versionedLatest)` + `@hasMany(... versionedHistory)` | ✅ Phase 3 |
|
|
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 |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 1. Same Partition / Containment
|
|
55
|
+
|
|
56
|
+
**(a) What it is.** Child items that belong to a parent are stored in the **same
|
|
57
|
+
partition** as the parent and read with a single `Query` (`begins_with` on a
|
|
58
|
+
segmented sort key). The classic example: a `Suite` and all its `TestCase`s share
|
|
59
|
+
`PROJECT#{projectId}` as their partition key, with the cases under a segmented SK
|
|
60
|
+
`SUITE#{suiteId}#CASE#…`. One partition read returns the parent and its children
|
|
61
|
+
together — no join, no fan-out.
|
|
62
|
+
|
|
63
|
+
**(b) How to declare it.** A segmented `key` on the child, plus a `@hasMany` whose
|
|
64
|
+
`keyBinding` is a contiguous prefix of the child's primary key. The
|
|
65
|
+
`pattern: 'samePartition'` preset names that shape explicitly (and a linter rule
|
|
66
|
+
verifies the declaration really lowers to a same-partition `begins_with`).
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
@model({ table: 'App', prefix: 'PROJECT' })
|
|
70
|
+
class TestCaseModel extends DDBModel {
|
|
71
|
+
static readonly keys = key<{ projectId: string; suiteId: string; caseId: string }>((c) => ({
|
|
72
|
+
pk: k`PROJECT#${c.projectId}`,
|
|
73
|
+
sk: [k`SUITE#${c.suiteId}`, k`CASE#${c.caseId}`],
|
|
74
|
+
}));
|
|
75
|
+
@string projectId!: string;
|
|
76
|
+
@string suiteId!: string;
|
|
77
|
+
@string caseId!: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@model({ table: 'App', prefix: 'PROJECT' })
|
|
81
|
+
class SuiteModel extends DDBModel {
|
|
82
|
+
static readonly keys = key<{ projectId: string; suiteId: string }>((c) => ({
|
|
83
|
+
pk: k`PROJECT#${c.projectId}`,
|
|
84
|
+
sk: k`SUITE#${c.suiteId}`,
|
|
85
|
+
}));
|
|
86
|
+
@string projectId!: string;
|
|
87
|
+
@string suiteId!: string;
|
|
88
|
+
|
|
89
|
+
@hasMany(() => TestCaseModel, { projectId: 'projectId', suiteId: 'suiteId' }, {
|
|
90
|
+
pattern: 'samePartition',
|
|
91
|
+
limit: { default: 50, max: 200 },
|
|
92
|
+
})
|
|
93
|
+
cases!: TestCaseModel[];
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**(c) Read & write behavior.** Read is a single bounded `Query` against the shared
|
|
98
|
+
partition with a `begins_with` SK condition — the traversal planner derives it from
|
|
99
|
+
the `keyBinding`. There is **no write maintenance**: `samePartition` is pure read
|
|
100
|
+
sugar. The children are kept consistent simply by being written under the parent's
|
|
101
|
+
partition; nothing is denormalized.
|
|
102
|
+
|
|
103
|
+
**(d) Constraints & phase status.** ✅ Phase 1. `hasMany` only (a parent→children
|
|
104
|
+
collection — `belongsTo`/`hasOne` cannot lower to a `begins_with`). The `keyBinding`
|
|
105
|
+
must bind every PK field and be a proper SK *prefix* (a GSI hop is a different
|
|
106
|
+
partition; a full PK match is a point read — both are rejected). Because there is no
|
|
107
|
+
materialized data, `read` / `write` / `projection` options on a `samePartition`
|
|
108
|
+
relation are a declaration error.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 2. Embedded Refs
|
|
113
|
+
|
|
114
|
+
**(a) What it is.** A parent row carries a small **list of references** (just the
|
|
115
|
+
ids / keys) to related children, so the parent read returns the set of child ids
|
|
116
|
+
without a query; the child bodies are fetched on demand (e.g. a follow-up
|
|
117
|
+
`BatchGet`). On the table this is an attribute holding a list of id strings/maps on
|
|
118
|
+
the parent item.
|
|
119
|
+
|
|
120
|
+
**(b) How to declare it.** There is no dedicated `refs` preset; embedded refs are an
|
|
121
|
+
**`embeddedSnapshot` collection that projects only the id field(s)** — the snapshot
|
|
122
|
+
machinery, narrowed to keys. (The RFC marks this pattern ⚠️ precisely because it is
|
|
123
|
+
not its own primitive but a thin projection of the snapshot collection.)
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
@hasMany(() => PostModel, { threadId: 'threadId' }, {
|
|
127
|
+
pattern: 'embeddedSnapshot',
|
|
128
|
+
read: { maxItems: 20, order: 'DESC' },
|
|
129
|
+
write: { maintainedOn: ['Post.created'], updateMode: 'stream' },
|
|
130
|
+
projection: { postId: 'postId' }, // ids only — the "refs" shape
|
|
131
|
+
})
|
|
132
|
+
postRefs!: PostModel[];
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**(c) Read & write behavior.** The ref list is read **inline** off the parent row
|
|
136
|
+
(it *is* the maintained access path). On `Post.created` the maintainer appends the
|
|
137
|
+
projected `{ postId }` ref; resolving the bodies is a separate `BatchGet` against
|
|
138
|
+
the children's keys.
|
|
139
|
+
|
|
140
|
+
**(d) Constraints & phase status.** ✅ The synchronous append is Phase 1; bounding
|
|
141
|
+
the list (`maxItems` trim) or removing a ref on `Post.removed` requires the
|
|
142
|
+
read-modify-write **stream** path (declare `updateMode: 'stream'`), since a
|
|
143
|
+
synchronous collection is append-only. Same payload-only projection rule as
|
|
144
|
+
[Embedded Snapshot](#3-embedded-snapshot).
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 3. Embedded Snapshot
|
|
149
|
+
|
|
150
|
+
**(a) What it is.** Denormalize a **copy** of related data onto the row that reads
|
|
151
|
+
it, so the read needs no join. Two shapes: a **single-row mirror** (a `ProfileCard`
|
|
152
|
+
holding a copy of its `Profile`'s display fields) and a **bounded collection** (a
|
|
153
|
+
`ThreadSummary` holding the latest N posts' previews). On the table the snapshot is
|
|
154
|
+
a map (or list of maps) attribute on the owner item.
|
|
155
|
+
|
|
156
|
+
**(b) How to declare it.** `pattern: 'embeddedSnapshot'` on a relation, with
|
|
157
|
+
`write.maintainedOn` (the trigger), `projection` (what to capture, with `preview` /
|
|
158
|
+
identity-shorthand transforms), and — for a collection — `read.maxItems` / `order`.
|
|
159
|
+
A `@belongsTo` makes a single-row snapshot; a `@hasMany` makes a collection. (From
|
|
160
|
+
`examples/embedded-snapshot-pattern/`.)
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// Collection snapshot: the latest posts on a thread summary row.
|
|
164
|
+
@hasMany(() => PostModel, { threadId: 'threadId' }, {
|
|
165
|
+
pattern: 'embeddedSnapshot',
|
|
166
|
+
read: { maxItems: 3, order: 'DESC' },
|
|
167
|
+
write: { maintainedOn: ['Post.created'] },
|
|
168
|
+
projection: { postId: 'postId', textPreview: preview('$.entity.body', 120) },
|
|
169
|
+
})
|
|
170
|
+
latestPosts!: PostModel[];
|
|
171
|
+
|
|
172
|
+
// Single-row snapshot: mirror a profile into a card, re-projected on each update.
|
|
173
|
+
@belongsTo(() => ProfileModel, { userId: 'userId' }, {
|
|
174
|
+
pattern: 'embeddedSnapshot',
|
|
175
|
+
write: { maintainedOn: ['Profile.updated'] },
|
|
176
|
+
projection: { displayName: 'displayName', bioPreview: preview('$.entity.bio', 40) },
|
|
177
|
+
})
|
|
178
|
+
source!: ProfileModel;
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**(c) Read & write behavior.** The snapshot is read **inline** off the owner row.
|
|
182
|
+
Synchronously (`updateMode: 'mutation'`, the default), the maintainer folds into the
|
|
183
|
+
**same transaction** as the source write: a `belongsTo` snapshot re-projects the
|
|
184
|
+
single mirrored row in place; a `hasMany` collection appends a projected item
|
|
185
|
+
(`list_append`). On the stream path, the collection additionally trims to `maxItems`
|
|
186
|
+
and splices on `removed`.
|
|
187
|
+
|
|
188
|
+
**(d) Constraints & phase status.** ✅ Phase 1 for the synchronous path. A
|
|
189
|
+
synchronous collection is **append-only** — `maxItems` trim and `removed`-driven
|
|
190
|
+
splice are the **stream** path ([§7.2](./spec.md#72-stream-maintenance--outbox--cdc-drain-updatemode-stream-130)).
|
|
191
|
+
Projection sources are restricted to the written source payload (`$.entity.*` /
|
|
192
|
+
`$.input.*`) — no write-time fetch of a third entity. Two maintainers targeting the
|
|
193
|
+
same owner row in one mutation is a loud reject ("1 mutation × 1 target row = 1
|
|
194
|
+
effect"). Mind the DynamoDB 400 KB item limit when bounding a collection.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 4. Edge / Adjacency
|
|
199
|
+
|
|
200
|
+
**(a) What it is.** A many-to-many relationship materialized as **edge items** in an
|
|
201
|
+
adjacency list: an edge row whose PK/SK encode one direction of the relationship
|
|
202
|
+
(e.g. `GROUP#{g} / USER#{u}` for "user u is in group g"). Reading a group's members
|
|
203
|
+
is a `Query` on the group partition; the edge row itself can carry edge attributes
|
|
204
|
+
(role, joinedAt).
|
|
205
|
+
|
|
206
|
+
**(b) How to declare it.** The adjacency entity declares its **edge write side** with
|
|
207
|
+
`edgeWrites`, deriving the lifecycle-distinguished `Put`/`Delete` of the edge from
|
|
208
|
+
the entity's own create/delete. The read side is the ordinary `@hasMany` /
|
|
209
|
+
`@belongsTo` on the edge's key binding.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
@model({ table: 'App', prefix: 'GROUP' })
|
|
213
|
+
class GroupMembershipModel extends DDBModel {
|
|
214
|
+
static readonly keys = key<{ groupId: string; userId: string }>((c) => ({
|
|
215
|
+
pk: k`GROUP#${c.groupId}`,
|
|
216
|
+
sk: k`USER#${c.userId}`,
|
|
217
|
+
}));
|
|
218
|
+
@string groupId!: string;
|
|
219
|
+
@string userId!: string;
|
|
220
|
+
@string role!: string;
|
|
221
|
+
|
|
222
|
+
@belongsTo(() => GroupModel, { groupId: 'groupId' })
|
|
223
|
+
group!: GroupModel | null;
|
|
224
|
+
|
|
225
|
+
// The adjacency row IS the Group↔User edge read by Group.members.
|
|
226
|
+
static readonly writes = edgeWrites((w) => [
|
|
227
|
+
w.putEdge(() => GroupModel, 'members'),
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**(c) Read & write behavior.** Reading is a partition `Query` over the edge rows.
|
|
233
|
+
`edgeWrites` derives the write maintenance: creating the membership emits the edge
|
|
234
|
+
`Put`, deleting it emits the `Delete`, and a foreign-key change emits
|
|
235
|
+
`Delete(old) + Put(new)` (`w.updateKeyChangeEdge`). A build-time round-trip guard
|
|
236
|
+
checks the declared edge actually round-trips to the relation it claims to feed.
|
|
237
|
+
|
|
238
|
+
**(d) Constraints & phase status.** ✅ Phase 1 (`putEdge` / `deleteEdge` /
|
|
239
|
+
`updateKeyChangeEdge`). A *bidirectional* edge maintained as two physical rows is
|
|
240
|
+
the `dualEdge` extension — see [Reverse Lookup](#9-reverse-lookup).
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 5. Materialized View
|
|
245
|
+
|
|
246
|
+
**(a) What it is.** A read-optimized **view item on its own entity**, assembled from
|
|
247
|
+
one or more sources, that serves a query no single source partition can. Example: a
|
|
248
|
+
`UserThreadList` row per user, holding that user's recent posts (a bounded
|
|
249
|
+
collection) plus the current thread title (a snapshot from a *different* source).
|
|
250
|
+
The view row exists only to be read directly.
|
|
251
|
+
|
|
252
|
+
**(b) How to declare it.** The view is its OWN model — `@model({ kind:
|
|
253
|
+
'materializedView' })` — and each source is a **class-level** `@maintainedFrom(source,
|
|
254
|
+
(self, source) => options)` decorator, stacked once per source. The `(self, source) =>`
|
|
255
|
+
callback is symbolic: `source.<field>` is a typed reference to a source field (it lowers
|
|
256
|
+
to the same payload-rooted value the IR reads), `self.<field>` names a view field (the
|
|
257
|
+
`collection.field` position), and the object KEYS (`keyBind` / `project` left-hand side)
|
|
258
|
+
are typed `keyof Self` names. `on` is the lifecycle-event list (`created`/`updated`/
|
|
259
|
+
`removed`) — the source is the decorator's first argument, so there is no `'Post.created'`
|
|
260
|
+
string to resolve. **Declaration order carries no meaning**: two sources writing the same
|
|
261
|
+
projection target, the same `collection.field`, or a different view-key field SET are a
|
|
262
|
+
loud build error, not a silent last-wins. The view model registers itself like any
|
|
263
|
+
`@model`, so the maintenance graph discovers it (no separate registry).
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
@model({ table: 'AppTable', prefix: 'UTL', kind: 'materializedView' })
|
|
267
|
+
@maintainedFrom(() => PostModel, (self, source) => ({
|
|
268
|
+
keyBind: { userId: source.userId },
|
|
269
|
+
on: ['created', 'removed'],
|
|
270
|
+
project: { postId: source.postId, textPreview: preview(source.body, 120) },
|
|
271
|
+
collection: { field: self.recentPosts, maxItems: 3, order: 'DESC', orderBy: source.createdAt },
|
|
272
|
+
updateMode: 'stream',
|
|
273
|
+
}))
|
|
274
|
+
@maintainedFrom(() => ThreadModel, (_self, source) => ({ // a snapshot slice from a different source
|
|
275
|
+
keyBind: { userId: source.userId },
|
|
276
|
+
on: ['updated'],
|
|
277
|
+
project: { threadTitle: source.title },
|
|
278
|
+
updateMode: 'stream',
|
|
279
|
+
}))
|
|
280
|
+
class UserThreadListModel extends DDBModel {
|
|
281
|
+
static readonly keys = key<{ userId: string }>((c) => ({ pk: k`USER#${c.userId}`, sk: k`VIEW` }));
|
|
282
|
+
@string userId!: string;
|
|
283
|
+
@string threadTitle!: string;
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**(c) Read & write behavior.** The view row is read directly by its key. Each source
|
|
288
|
+
event is drained (asynchronously) into the view row: collection slices order + trim,
|
|
289
|
+
snapshot slices `SET` their projected attributes. Multiple sources converge on the
|
|
290
|
+
same view row, each maintaining its own slice.
|
|
291
|
+
|
|
292
|
+
**(d) Constraints & phase status.** ✅ Phase 2. A view fed by a bounded ordered
|
|
293
|
+
collection or `removed` events is the **stream** path (`updateMode: 'stream'`) — it
|
|
294
|
+
needs read-modify-write. Drive it with `createMaintenanceDrain({ models })` over the CDC
|
|
295
|
+
stream — the drain derives the view IR from the same declarative models
|
|
296
|
+
([spec §7.2](./spec.md#72-stream-maintenance--outbox--cdc-drain-updatemode-stream-130)).
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 6. Sparse View
|
|
301
|
+
|
|
302
|
+
**(a) What it is.** A view that contains a row **only while a predicate holds** — an
|
|
303
|
+
index of "active tasks", "flagged accounts", etc. Membership flips with the source's
|
|
304
|
+
state, so the view row is *put* when the predicate becomes true and *deleted* when it
|
|
305
|
+
becomes false. (Distinct from a DynamoDB *sparse GSI*, which omits items missing the
|
|
306
|
+
index attribute; here membership is an explicit put/delete maintained by a predicate.)
|
|
307
|
+
|
|
308
|
+
**(b) How to declare it.** `@model({ kind: 'sparseView' })` with a `@maintainedFrom`
|
|
309
|
+
carrying a `when` membership predicate, built with `whenMember(source.field, op,
|
|
310
|
+
value?)`. `op` is one of `truthy | falsy | exists | notExists | eq | ne` (`eq`/`ne`
|
|
311
|
+
require a comparand). Every `@maintainedFrom` on a `sparseView` MUST carry a `when`
|
|
312
|
+
(and `when` is mutually exclusive with `collection`).
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
@model({ table: 'AppTable', prefix: 'ACT', kind: 'sparseView' })
|
|
316
|
+
@maintainedFrom(() => TaskModel, (_self, source) => ({
|
|
317
|
+
keyBind: { ownerId: source.assigneeId, taskId: source.taskId },
|
|
318
|
+
on: ['created', 'updated', 'removed'],
|
|
319
|
+
when: whenMember(source.status, 'eq', 'active'), // true → PUT / false or removed → DELETE
|
|
320
|
+
project: { taskId: source.taskId, title: source.title },
|
|
321
|
+
updateMode: 'stream',
|
|
322
|
+
}))
|
|
323
|
+
class ActiveTaskModel extends DDBModel { /* keys, fields */ }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**(c) Read & write behavior.** The view is read directly (a `Query` over the
|
|
327
|
+
membership partition). On each source event the drain evaluates the predicate: if it
|
|
328
|
+
holds, the view row is `PUT` (with the projected `fields`); if it flips false (or the
|
|
329
|
+
source is `removed`), the row is `DELETE`d.
|
|
330
|
+
|
|
331
|
+
**(d) Constraints & phase status.** ✅ Phase 3, **stream-only**. A single atomic
|
|
332
|
+
transaction cannot branch an item between `Put` and `Delete` on a runtime predicate,
|
|
333
|
+
so declaring a sparse view on the synchronous `mutation` path is a loud reject —
|
|
334
|
+
`write.updateMode: 'stream'` is required. A membership predicate is mutually
|
|
335
|
+
exclusive with a `collection` slice. The rebuild planner does **not** reconstruct
|
|
336
|
+
membership rows (re-run the source events through the drain instead).
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 7. Aggregate Counter
|
|
341
|
+
|
|
342
|
+
**(a) What it is.** A scalar **derived aggregate** kept on a counter row — a post
|
|
343
|
+
count per thread, a running max timestamp — so the aggregate is read in O(1) without
|
|
344
|
+
scanning the source rows. On the table it is a number/value attribute on a counter
|
|
345
|
+
item.
|
|
346
|
+
|
|
347
|
+
**(b) How to declare it.** An `@aggregate` field bound to a source, with
|
|
348
|
+
`pattern: 'counter'` and a `value` aggregate (`count()` or `max(field)`). (From
|
|
349
|
+
`examples/aggregate-counter/`.)
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
@model({ table: 'App' })
|
|
353
|
+
class ThreadCounterModel extends DDBModel {
|
|
354
|
+
static readonly keys = key<{ threadId: string }>((c) => ({
|
|
355
|
+
pk: k`THREADCOUNT#${c.threadId}`,
|
|
356
|
+
sk: k`COUNT`,
|
|
357
|
+
}));
|
|
358
|
+
@string threadId!: string;
|
|
359
|
+
|
|
360
|
+
@aggregate(() => ThreadPostModel, { threadId: 'threadId' }, {
|
|
361
|
+
pattern: 'counter',
|
|
362
|
+
value: count(),
|
|
363
|
+
write: { maintainedOn: ['ThreadPost.created', 'ThreadPost.removed'] },
|
|
364
|
+
})
|
|
365
|
+
postCount!: number;
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**(c) Read & write behavior.** The counter row is read directly. `ThreadPost.created`
|
|
370
|
+
applies `postCount += 1`, `ThreadPost.removed` applies `-= 1` — realized as an
|
|
371
|
+
atomic, concurrency-safe `ADD ±1` composed into the **same `TransactWriteItems`** as
|
|
372
|
+
the source write (no prior read, never clobbers a concurrent increment; rolls back
|
|
373
|
+
with the source if the source write fails).
|
|
374
|
+
|
|
375
|
+
**(d) Constraints & phase status.** ✅ Phase 1 for `count()` (synchronous, derive
|
|
376
|
+
increment, issue #85/#141). A `max(field)` running aggregate needs a conditional
|
|
377
|
+
`SET` whose failed guard must not roll back the legitimate source write — so it is
|
|
378
|
+
the **stream** path: declare `value: max('createdAt')` with
|
|
379
|
+
`write: { updateMode: 'stream' }`. `max()` on the synchronous `mutation` path is a
|
|
380
|
+
loud reject pointing you at `count()` or `stream`.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 8. Versioned / Latest Pointer
|
|
385
|
+
|
|
386
|
+
**(a) What it is.** Keep an append-only **history** of every revision plus a
|
|
387
|
+
**latest pointer** that always resolves the current version in one read. On the table
|
|
388
|
+
this is a history row per version (`DOC#{id} / V#{version}`) and a single latest row
|
|
389
|
+
(`DOC#{id} / LATEST`) overwritten each revision.
|
|
390
|
+
|
|
391
|
+
**(b) How to declare it.** On the SOURCE (revision) model, ride the `@hasOne` /
|
|
392
|
+
`@hasMany` `pattern` discriminated union (issue #152). `@hasOne(() => LatestModel,
|
|
393
|
+
(self, source) => ({ pattern: 'versionedLatest', on, project }))` is the single
|
|
394
|
+
overwritten pointer; `@hasMany(() => HistoryModel, (self, source) => ({ pattern:
|
|
395
|
+
'versionedHistory', on, project }))` is the append-only history. Cardinality is
|
|
396
|
+
type-enforced: `versionedLatest` is only valid on `@hasOne`, `versionedHistory` only on
|
|
397
|
+
`@hasMany`. The latest/history KEY binding is derived from each target model's own `key`
|
|
398
|
+
fields (latest keys omit the version; history keys include it) — both lower to the same
|
|
399
|
+
composed snapshot×2 IR.
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
class DocModel extends DDBModel {
|
|
403
|
+
static readonly keys = key<{ docId: string; version: number }>((c) => ({
|
|
404
|
+
pk: k`DOC#${c.docId}`, sk: k`V#${c.version}`,
|
|
405
|
+
}));
|
|
406
|
+
@string docId!: string; @number version!: number; @string body!: string;
|
|
407
|
+
|
|
408
|
+
@hasOne(() => DocLatestModel, (_self, source) => ({ // latest = single overwritten row
|
|
409
|
+
pattern: 'versionedLatest',
|
|
410
|
+
on: ['created', 'updated'],
|
|
411
|
+
project: { docId: source.docId, version: source.version, body: source.body },
|
|
412
|
+
}))
|
|
413
|
+
latest!: DocLatestModel;
|
|
414
|
+
|
|
415
|
+
@hasMany(() => DocHistoryModel, (_self, source) => ({ // history = append per revision
|
|
416
|
+
pattern: 'versionedHistory',
|
|
417
|
+
on: ['created', 'updated'],
|
|
418
|
+
project: { docId: source.docId, version: source.version, body: source.body },
|
|
419
|
+
}))
|
|
420
|
+
history!: DocHistoryModel[];
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**(c) Read & write behavior.** Read the latest pointer by id for the current version;
|
|
425
|
+
`Query` the history partition for the full revision list. Each maintained revision
|
|
426
|
+
**overwrites** the latest row and **appends** a new history row — both projected from
|
|
427
|
+
the same source payload, composed from the two declarations.
|
|
428
|
+
|
|
429
|
+
**(d) Constraints & phase status.** ✅ Phase 3. The history target model's key must
|
|
430
|
+
include a version discriminator the latest target's key omits (so history appends
|
|
431
|
+
instead of overwriting), and the latest and history models must be **distinct**. Both
|
|
432
|
+
rows are pure snapshots (no collection / membership slices). A self-co-located form
|
|
433
|
+
(latest + history in the source's own key space) is a deferred follow-up — the primary
|
|
434
|
+
form is the two-model shape above.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## 9. Reverse Lookup
|
|
439
|
+
|
|
440
|
+
**(a) What it is.** Read a relationship from **both directions** — "groups of a user"
|
|
441
|
+
*and* "users of a group", "who I follow" *and* "who follows me". Two complementary
|
|
442
|
+
strategies on the table: one item indexed by a **GSI** that flips PK/SK, or **two
|
|
443
|
+
physical rows** (a forward edge and an inverse edge on its own partition).
|
|
444
|
+
|
|
445
|
+
**(b) How to declare it.** *GSI form* — declare a `gsi` on the edge that swaps the key
|
|
446
|
+
so the reverse `@hasMany` resolves against it (no extra write; DynamoDB maintains the
|
|
447
|
+
GSI). *Dual-edge form* — `edgeWrites` with `w.dualEdge(forward, inverse)` to keep a
|
|
448
|
+
**two-row** bidirectional edge in sync without an inverse GSI.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
// GSI form: one row, reverse direction served by a GSI.
|
|
452
|
+
@model({ table: 'App', prefix: 'GROUP' })
|
|
453
|
+
class GroupMembershipModel extends DDBModel {
|
|
454
|
+
static readonly keys = key<{ groupId: string; userId: string }>((c) => ({
|
|
455
|
+
pk: k`GROUP#${c.groupId}`, sk: k`USER#${c.userId}`,
|
|
456
|
+
}));
|
|
457
|
+
static readonly userGroupsIndex = gsi<{ userId: string; groupId: string }>(
|
|
458
|
+
'GSI1', (c) => ({ pk: k`USER#${c.userId}`, sk: k`GROUP#${c.groupId}` }),
|
|
459
|
+
);
|
|
460
|
+
@string groupId!: string;
|
|
461
|
+
@string userId!: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Dual-edge form: two physical rows kept in sync (no inverse GSI).
|
|
465
|
+
@model({ table: 'App', prefix: 'FOLLOW' })
|
|
466
|
+
class FollowEdgeModel extends DDBModel {
|
|
467
|
+
// … key: FOLLOWER#{a} / FOLLOWING#{b}
|
|
468
|
+
static readonly writes = edgeWrites((w) => [
|
|
469
|
+
w.dualEdge(
|
|
470
|
+
{ target: () => UserModel, relationProperty: 'following' },
|
|
471
|
+
{ adjacency: () => FollowedByEdgeModel, target: () => UserModel, relationProperty: 'followers' },
|
|
472
|
+
),
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**(c) Read & write behavior.** *GSI form*: the reverse relation queries the GSI;
|
|
478
|
+
DynamoDB keeps the GSI eventually consistent with the base item — no application
|
|
479
|
+
write effect. *Dual-edge form*: the single `dualEdge` declaration writes / deletes /
|
|
480
|
+
moves **both** the forward row and the inverse row on the second adjacency model
|
|
481
|
+
together, so the two directions stay consistent without a GSI.
|
|
482
|
+
|
|
483
|
+
**(d) Constraints & phase status.** ✅ Phase 1 for the GSI form (the existing
|
|
484
|
+
bidirectional edge: base row + inverse GSI). The two-row `dualEdge` synchronization
|
|
485
|
+
is Phase 3 (#133). A dual edge is *exactly* two rows — a round-trip guard validates
|
|
486
|
+
the inverse half (and rejects pointing both directions at the same adjacency model).
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## 10. External Projection
|
|
491
|
+
|
|
492
|
+
**(a) What it is.** Project a source's changes to an **external sink** — BigQuery,
|
|
493
|
+
OpenSearch, S3, Neptune — for analytics or search, decoupled from the table. The
|
|
494
|
+
projection is asynchronous and must tolerate at-least-once delivery (a redelivered
|
|
495
|
+
event must not double-write).
|
|
496
|
+
|
|
497
|
+
**(b) How it is being redesigned (🚧 #153).** The previous `defineProjection` builder +
|
|
498
|
+
`ProjectionSinkDrain` / `InMemoryProjectionSink` runtime baked the sink-delivery
|
|
499
|
+
semantics (upsert, idempotency, dedup) into the library — a boundary overreach: the
|
|
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.
|
|
507
|
+
|
|
508
|
+
**(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.
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## See also
|
|
518
|
+
|
|
519
|
+
- [`spec.md` §7 — Relations & maintained access paths](./spec.md#7-relations)
|
|
520
|
+
- [`cdc-emulator.md`](./cdc-emulator.md) — the CDC emulator that drives the stream drains locally
|
|
521
|
+
- Runnable examples: [`embedded-snapshot-pattern`](../examples/embedded-snapshot-pattern/), [`aggregate-counter`](../examples/aggregate-counter/), [`user-permissions`](../examples/user-permissions/)
|