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.
@@ -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/)