graphddb 0.4.2 → 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 +136 -470
- 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
package/README.md
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
1
|
# GraphDDB
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Build GraphQL-like queries directly on DynamoDB —— while keeping every access pattern explicit.**
|
|
4
4
|
|
|
5
|
-
GraphDDB maps DynamoDB's native access patterns onto a GraphQL-like query
|
|
6
|
-
PK / GSI / projection / relation / cursor / operation limits are all kept visible rather than hidden.
|
|
5
|
+
GraphDDB is a type-safe Runtime that maps DynamoDB's native access patterns onto a GraphQL-like query
|
|
6
|
+
model. PK / GSI / projection / relation / cursor / operation limits are all kept visible rather than hidden.
|
|
7
|
+
It is not an ORM —— it is for **defining, validating, and executing** DynamoDB access patterns through
|
|
8
|
+
TypeScript types.
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
## ✨ Features
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
- ✅ **Type-safe models** —— the TS class is the single source of truth. Keys / GSIs / field types are checked at compile time.
|
|
13
|
+
- ✅ **Graph-style traversal** —— nest `@hasMany` / `@belongsTo` / `@hasOne` inside `select`.
|
|
14
|
+
- ✅ **Automatic access-pattern resolution** —— the runtime picks the PK / GSI from the fields you give.
|
|
15
|
+
- ✅ **Projection** —— only `select`ed fields are read, and only those appear in the result type.
|
|
16
|
+
- ✅ **Execution plan (Explain)** —— inspect the execution plan before touching DynamoDB.
|
|
17
|
+
- ✅ **CQRS contracts** —— public Query/Command contracts (N+1-safe, context boundaries).
|
|
18
|
+
- ✅ **Python generation** —— generate a Python client + runtime from TS as the SSoT (conformance-verified).
|
|
19
|
+
- ✅ **Testing adapter** —— unit-test planner / traversal / transaction / CDC with an in-memory executor.
|
|
20
|
+
- ✅ **Middleware** —— host-side hooks for reads / writes (logging, tenant, authorization).
|
|
21
|
+
- ✅ **CDC emulator** —— drive and test change events equivalent to DynamoDB Streams locally.
|
|
22
|
+
- ✅ **Maintained access paths** —— keep embedded snapshots / aggregate counters synchronized atomically.
|
|
23
|
+
|
|
24
|
+
## 🤔 Philosophy
|
|
25
|
+
|
|
26
|
+
- **Native DynamoDB access patterns** —— express access patterns directly in code instead of hiding them
|
|
27
|
+
behind an ORM abstraction. Because PK / GSI, projection, and limits stay visible, the correct design
|
|
28
|
+
becomes the easiest path.
|
|
29
|
+
- **Graph-style traversal** —— GraphQL's query model (arguments → key lookup, selection → projection,
|
|
30
|
+
relation → Query/Get/BatchGet) corresponds naturally to DynamoDB's access patterns.
|
|
31
|
+
- **Compile-time safety** —— undefined relations, missing GSIs, limit-less lists, and traversals exceeding
|
|
32
|
+
the allowed depth are rejected before execution at compile time / by the linter. The same constraints
|
|
33
|
+
apply equally to human-written and AI-generated code.
|
|
34
|
+
- **Runtime transparency** —— the runtime is not a black box. `explain()` shows the execution plan, and the
|
|
35
|
+
plan reveals the shape of RCU usage.
|
|
12
36
|
|
|
13
37
|
## 📦 Install
|
|
14
38
|
|
|
15
39
|
```bash
|
|
16
40
|
npm install graphddb
|
|
17
|
-
# AWS SDK v3
|
|
41
|
+
# AWS SDK v3 is a peer dependency. Install the clients you use:
|
|
18
42
|
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
|
19
43
|
```
|
|
20
44
|
|
|
21
45
|
`tsx` is an optional peer dependency, needed only when running the code-generation CLI
|
|
22
|
-
(`graphddb generate …`) directly against TypeScript definitions
|
|
23
|
-
(esbuild keeps it out of the runtime bundle):
|
|
46
|
+
(`graphddb generate …`) directly against TypeScript definitions (library consumers do not need it):
|
|
24
47
|
|
|
25
48
|
```bash
|
|
26
49
|
npm install -D tsx
|
|
@@ -46,498 +69,148 @@ class UserModel extends DDBModel {
|
|
|
46
69
|
}
|
|
47
70
|
const User = UserModel.asModel();
|
|
48
71
|
|
|
49
|
-
//
|
|
50
|
-
// `maxAttempts: 1`
|
|
72
|
+
// Connect the client once. GraphDDB owns throttle / transient retries, so set
|
|
73
|
+
// `maxAttempts: 1` to avoid double retries (SDK × library) — see "Retry & throttling" below.
|
|
51
74
|
DDBModel.setClient(new DynamoDBClient({ maxAttempts: 1 }));
|
|
52
75
|
|
|
53
|
-
// READ ——
|
|
76
|
+
// READ —— only the fields you select appear in the result type.
|
|
54
77
|
const user = await User.query({ userId: 'alice' }, { name: true });
|
|
55
78
|
|
|
56
|
-
// WRITE ——
|
|
79
|
+
// WRITE —— a unified envelope that writes multiple items atomically (default mode: 'transaction').
|
|
57
80
|
await DDBModel.mutate({
|
|
58
81
|
user: { update: User, key: { userId: 'alice' }, input: { name: 'Alice B.' } },
|
|
59
82
|
});
|
|
60
83
|
|
|
61
|
-
//
|
|
84
|
+
// Raw base operations on a single item (named after the DynamoDB API).
|
|
62
85
|
await User.putItem({ userId: 'alice', name: 'Alice' });
|
|
63
86
|
```
|
|
64
87
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
## 📖 Usage
|
|
69
|
-
|
|
70
|
-
The snippets below are condensed from the [user-permissions](./examples/user-permissions/) example.
|
|
71
|
-
|
|
72
|
-
### Entity Definition
|
|
73
|
-
|
|
74
|
-
The TypeScript class is the single source of truth. Keys / GSIs are built from the **segments** of the
|
|
75
|
-
`k` tagged template, and the input type of the key builder becomes the type of the query parameters.
|
|
76
|
-
`@model({ description })` is an optional human-readable description (#154) that is pure documentation
|
|
77
|
-
metadata, propagated into the manifest and the generated Python docstrings.
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
const TABLE = 'UserPermissions';
|
|
81
|
-
|
|
82
|
-
@model({ table: TABLE, prefix: 'USER', description: 'アプリのユーザー' })
|
|
83
|
-
class UserModel extends DDBModel {
|
|
84
|
-
static readonly keys = key<{ userId: string }>((c) => ({
|
|
85
|
-
pk: k`USER#${c.userId}`,
|
|
86
|
-
sk: k`PROFILE`,
|
|
87
|
-
}));
|
|
88
|
-
|
|
89
|
-
static readonly emailIndex = gsi<{ email: string }>(
|
|
90
|
-
'GSI1',
|
|
91
|
-
(c) => ({ pk: k`EMAIL#${c.email}`, sk: k`PROFILE` }),
|
|
92
|
-
{ unique: true },
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
@string userId!: string;
|
|
96
|
-
@string name!: string;
|
|
97
|
-
@string email!: string;
|
|
98
|
-
@string status!: string;
|
|
99
|
-
|
|
100
|
-
@hasMany(() => GroupMembershipModel, { userId: 'userId' }, {
|
|
101
|
-
limit: { default: 20, max: 100 },
|
|
102
|
-
})
|
|
103
|
-
groups!: GroupMembershipModel[];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
@model({ table: TABLE, prefix: 'GROUP' })
|
|
107
|
-
class GroupMembershipModel extends DDBModel {
|
|
108
|
-
static readonly keys = key<{ groupId: string; userId: string }>((c) => ({
|
|
109
|
-
pk: k`GROUP#${c.groupId}`,
|
|
110
|
-
sk: k`USER#${c.userId}`,
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
|
-
static readonly userGroupsIndex = gsi<{ userId: string; groupId: string }>(
|
|
114
|
-
'GSI1',
|
|
115
|
-
(c) => ({ pk: k`USER#${c.userId}`, sk: k`GROUP#${c.groupId}` }),
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
@string groupId!: string;
|
|
119
|
-
@string userId!: string;
|
|
120
|
-
@string role!: string;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export const User = UserModel.asModel();
|
|
124
|
-
export const GroupMembership = GroupMembershipModel.asModel();
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
A multi-segment sort key is an array of segments (e.g. `sk: [k\`PERM#${c.resource}\`, k\`${c.action}\`]`).
|
|
128
|
-
A partial key is compiled to `begins_with` at the matching segment boundary. For the complete key model,
|
|
88
|
+
Reads center on `query` (a single PK / unique GSI lookup) / `list` (multiple items, with a cursor).
|
|
89
|
+
Writes center on `DDBModel.mutate` (a unified envelope), while `putItem` / `updateItem` / `deleteItem` are
|
|
90
|
+
positioned secondarily as raw base operations named after the DynamoDB API. For the full API and options,
|
|
129
91
|
see [`docs/spec.md`](./docs/spec.md).
|
|
130
92
|
|
|
131
|
-
Field decorators define the DynamoDB attribute type and catch mismatches with the declared TS type at compile time:
|
|
132
|
-
|
|
133
93
|
```ts
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@datetime createdAt!: Date; // ISO 8601 文字列として保存
|
|
137
|
-
@boolean isActive!: boolean; // DynamoDB BOOL
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### Read: `query` / `list`
|
|
141
|
-
|
|
142
|
-
The first argument describes "how to find the data"; the second describes "what to return". The runtime
|
|
143
|
-
resolves which index (PK / GSI) to use from the given field names. The return type is inferred from
|
|
144
|
-
`select`, and only the requested fields appear in the result.
|
|
145
|
-
|
|
146
|
-
```ts
|
|
147
|
-
// email(unique GSI)でユーザーを取得し、group membership へ traverse する。
|
|
148
|
-
const alice = await User.query(
|
|
149
|
-
{ email: 'alice@example.com' },
|
|
150
|
-
{
|
|
151
|
-
userId: true,
|
|
152
|
-
name: true,
|
|
153
|
-
groups: {
|
|
154
|
-
select: { groupId: true, role: true },
|
|
155
|
-
limit: 20,
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
// alice: { name: string; groups: { items: { groupId; role }[]; cursor: string | null } }
|
|
160
|
-
|
|
161
|
-
// 未定義のキーフィールド -> コンパイルエラー
|
|
162
|
-
User.query({ foo: 'bar' }, { name: true });
|
|
163
|
-
```
|
|
94
|
+
// query: the 1st argument says "how to find it", the 2nd says "what to return". The return type is inferred from select.
|
|
95
|
+
const alice = await User.query({ userId: 'alice' }, { name: true });
|
|
164
96
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const result = await GroupMembership.list(
|
|
169
|
-
{ groupId: 'eng' },
|
|
170
|
-
{ userId: true, role: true },
|
|
171
|
-
{ limit: 20, after: cursor },
|
|
172
|
-
);
|
|
173
|
-
// result.items: GroupMembership[] ; result.cursor: string | null
|
|
97
|
+
// list: keys that return multiple items use a cursor-based list.
|
|
98
|
+
const page = await GroupMembership.list({ groupId: 'eng' }, { userId: true, role: true }, { limit: 20 });
|
|
99
|
+
// page.items / page.cursor
|
|
174
100
|
```
|
|
175
101
|
|
|
176
|
-
|
|
102
|
+
## 🔎 Inspect Execution Plans (Explain)
|
|
177
103
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
typed against the **entire entity**, it can reference attributes **not** included in `select`.
|
|
104
|
+
This is what sets GraphDDB apart. `explain()` returns the DynamoDB operations before they run. `select` is
|
|
105
|
+
translated into a `ProjectionExpression`, so only the requested attributes are read. The plan can be used
|
|
106
|
+
for testing, debugging, and RCU estimation:
|
|
182
107
|
|
|
183
108
|
```ts
|
|
184
|
-
const
|
|
185
|
-
{
|
|
186
|
-
{
|
|
187
|
-
{
|
|
188
|
-
filter: {
|
|
189
|
-
status: 'confirmed', // #status = :v (等価の省略記法)
|
|
190
|
-
amount: { gt: 100 }, // #amount > :v
|
|
191
|
-
title: { beginsWith: 'A' }, // begins_with(#title, :v)
|
|
192
|
-
shippedAt: { attributeExists: true },
|
|
193
|
-
or: [{ status: 'pending' }, { amount: { lt: 10 } }],
|
|
194
|
-
},
|
|
195
|
-
},
|
|
109
|
+
const plan = GroupMembership.explain(
|
|
110
|
+
{ groupId: 'eng' },
|
|
111
|
+
{ select: { userId: true, role: true }, limit: 10 },
|
|
196
112
|
);
|
|
113
|
+
// {
|
|
114
|
+
// operations: [{
|
|
115
|
+
// type: "Query",
|
|
116
|
+
// tableName: "UserPermissions",
|
|
117
|
+
// keyCondition: { PK: "GROUP#eng" },
|
|
118
|
+
// rangeCondition: { operator: "begins_with", key: "SK", value: "USER#" },
|
|
119
|
+
// limit: 10,
|
|
120
|
+
// }]
|
|
121
|
+
// }
|
|
197
122
|
```
|
|
198
123
|
|
|
199
|
-
|
|
200
|
-
`
|
|
201
|
-
**constrained by the declared type of the field** (`beginsWith` on a number, `gt: 'x'`, etc. are compile
|
|
202
|
-
errors). For the rare condition an operator cannot express, write it with the raw escape hatch `cond`
|
|
203
|
-
using `Model.col.<field>` (a refactor-safe column reference). All values are parameterized and column
|
|
204
|
-
names are aliased, so it is injection-safe:
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
filter: cond`${Order.col.amount} > ${100} AND attribute_exists(${Order.col.status})`
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
The same `cond` fragment works not only for read filters but also for write `condition`s (a `mutate`
|
|
211
|
-
route, `putItem` / `updateItem` / `deleteItem`, a transaction item, a public command).
|
|
212
|
-
|
|
213
|
-
> **RCU note:** `FilterExpression` does **not** reduce read capacity —— DynamoDB reads the keys first
|
|
214
|
-
> and filters afterward. `limit` is applied **before** the filter, so a single page may return fewer
|
|
215
|
-
> than `limit`, or an entire page may be excluded, yielding an empty page with a non-null cursor.
|
|
216
|
-
> Do efficient narrowing through key design and use `filter` for correctness.
|
|
217
|
-
|
|
218
|
-
### Write: `DDBModel.mutate` (unified envelope)
|
|
219
|
-
|
|
220
|
-
The primary write path is `DDBModel.mutate`. It is an **alias-map envelope** that runs multiple write
|
|
221
|
-
routes in a single call —— the in-process counterpart of a GraphQL operation. Each alias is a named
|
|
222
|
-
route, and the result is keyed by the same alias. Lifecycle-aware writes (conditional gates, read-back,
|
|
223
|
-
referential side effects) go through here.
|
|
224
|
-
|
|
225
|
-
A write route descriptor is `{ create | update | remove: Model, key, input?, condition?, result? }`:
|
|
226
|
-
|
|
227
|
-
```ts
|
|
228
|
-
const res = await DDBModel.mutate({
|
|
229
|
-
a: { create: GroupMembership, key: { groupId: 'eng', userId: 'u1' }, input: { role: 'admin' } },
|
|
230
|
-
b: { update: User, key: { userId: 'u1' }, input: { name: 'Ann' }, condition: { status: 'active' }, result: { select: { name: true } } },
|
|
231
|
-
}, { mode: 'transaction' });
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
- **`key`** is a single object, or an **array of objects** (key-array bulk).
|
|
235
|
-
- **`condition`** is the write gate of `WriteCondition` —— the same declarative operator subset as the
|
|
236
|
-
read filter (`eq` / `ne` / comparisons / `between` / `in` / string operations / `size` / `attributeType`
|
|
237
|
-
+ `and` / `or` / `not`), the existence primitives (`notExists` / `attributeExists` / `attributeNotExists`),
|
|
238
|
-
or a raw `cond` fragment.
|
|
239
|
-
- **`result: { select, options? }`** reads back the written item. Omit it for void.
|
|
240
|
-
- **`mode: 'transaction'`** (default) is a single atomic `TransactWriteItems`: all-or-nothing, throws on
|
|
241
|
-
failure, a map of more than 25 items is an error (it is not split). Equivalent to GraphQL's
|
|
242
|
-
all-or-nothing contract.
|
|
243
|
-
- **`mode: 'parallel'`** is a non-atomic `BatchWriteItem` (chunking + `UnprocessedItems` retry): each
|
|
244
|
-
alias reports partial success/failure as `{ ok }` | `{ error }`. Independent operations run in parallel;
|
|
245
|
-
dependencies are ordered via `$.alias.field` references.
|
|
246
|
-
|
|
247
|
-
The read side has a symmetric `DDBModel.query(map)`. Each read route
|
|
248
|
-
(`{ query | list: Model, key, select, options? }`) runs **independently and in parallel**, with no
|
|
249
|
-
consistency across routes (just like GraphQL's parallel field resolution):
|
|
250
|
-
|
|
251
|
-
```ts
|
|
252
|
-
const { user, members } = await DDBModel.query({
|
|
253
|
-
user: { query: User, key: { userId: 'u1' }, select: { name: true } },
|
|
254
|
-
members: { list: GroupMembership, key: { groupId: 'eng' }, select: { role: true }, options: { limit: 20 } },
|
|
255
|
-
});
|
|
256
|
-
// user: Item | null ; members: { items, cursor }
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
### Raw base operations: `putItem` / `updateItem` / `deleteItem`
|
|
124
|
+
For the complete shape of the plan (such as the `rangeCondition` separation on a GSI partial match), see
|
|
125
|
+
[`docs/spec.md`](./docs/spec.md).
|
|
260
126
|
|
|
261
|
-
|
|
262
|
-
primitives with no lifecycle semantics. They carry no atomic composition of multiple fragments and no
|
|
263
|
-
derived side effects, unlike `mutate`:
|
|
127
|
+
## 🧪 Testing (strongly recommended)
|
|
264
128
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
If you need conditional gates, read-back, or referential side effects, use `DDBModel.mutate` rather than the raw primitives.
|
|
129
|
+
The **in-memory executor** in `graphddb/testing` lets you fully unit-test planner / relation traversal /
|
|
130
|
+
transaction / CDC with **no Docker and no DynamoDB Local**. Because it never touches a real DynamoDB and
|
|
131
|
+
runs in-process, you can verify model-mapping, query-plans, relation resolution, and differential
|
|
132
|
+
aggregation while keeping CI fast and deterministic. See [`docs/testing.md`](./docs/testing.md).
|
|
272
133
|
|
|
273
|
-
|
|
134
|
+
## 🔗 Relation Traversal
|
|
274
135
|
|
|
275
|
-
A relation is a query bound to the value of the parent entity. Declare it with `@hasMany` / `@
|
|
276
|
-
`@
|
|
277
|
-
|
|
278
|
-
into a single `BatchGetItem` (no N+1):
|
|
136
|
+
A relation is a query bound to the value of the parent entity. Declare it with `@hasMany` / `@belongsTo` /
|
|
137
|
+
`@hasOne` and traverse it by nesting inside `select`. Independent relation subqueries are dispatched in
|
|
138
|
+
parallel, and N `belongsTo` resolutions are aggregated into a single `BatchGetItem` (no N+1):
|
|
279
139
|
|
|
280
140
|
```ts
|
|
281
|
-
// email でユーザーを探し、所属グループ -> 各グループ本体 -> そのパーミッションへ traverse。
|
|
282
141
|
const alice = await User.query(
|
|
283
142
|
{ email: 'alice@example.com' },
|
|
284
143
|
{
|
|
285
|
-
userId: true,
|
|
286
144
|
name: true,
|
|
287
145
|
groups: {
|
|
288
146
|
select: {
|
|
289
|
-
groupId: true,
|
|
290
147
|
role: true,
|
|
291
|
-
group: {
|
|
292
|
-
select: {
|
|
293
|
-
name: true,
|
|
294
|
-
permissions: { // group -> permissions (hasMany)
|
|
295
|
-
select: { resource: true, action: true },
|
|
296
|
-
filter: { resource: { attributeExists: true } },
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
},
|
|
148
|
+
group: { select: { name: true } }, // membership -> Group (belongsTo, BatchGet)
|
|
300
149
|
},
|
|
301
150
|
limit: 20,
|
|
302
151
|
},
|
|
303
152
|
},
|
|
304
|
-
{ maxDepth:
|
|
305
|
-
);
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
### Inspect Execution Plans
|
|
309
|
-
|
|
310
|
-
`explain()` returns the DynamoDB operations before they run. `select` is translated into a
|
|
311
|
-
`ProjectionExpression`, so only the requested attributes are read. The plan can be used for testing,
|
|
312
|
-
debugging, and RCU estimation:
|
|
313
|
-
|
|
314
|
-
```ts
|
|
315
|
-
const plan = GroupMembership.explain(
|
|
316
|
-
{ groupId: 'eng' },
|
|
317
|
-
{ select: { userId: true, role: true }, limit: 10 },
|
|
153
|
+
{ maxDepth: 2 }, // traversal defaults to depth=1. Deeper traversal must be explicitly allowed.
|
|
318
154
|
);
|
|
319
|
-
// {
|
|
320
|
-
// operations: [{
|
|
321
|
-
// type: "Query",
|
|
322
|
-
// tableName: "UserPermissions",
|
|
323
|
-
// keyCondition: { PK: "GROUP#eng" },
|
|
324
|
-
// rangeCondition: { operator: "begins_with", key: "SK", value: "USER#" },
|
|
325
|
-
// limit: 10,
|
|
326
|
-
// }]
|
|
327
|
-
// }
|
|
328
155
|
```
|
|
329
156
|
|
|
330
|
-
|
|
331
|
-
|
|
157
|
+
`hasMany` / `list` require a default / max limit. For the complete relation model, see
|
|
158
|
+
[`docs/spec.md`](./docs/spec.md).
|
|
332
159
|
|
|
333
|
-
|
|
160
|
+
## 🧩 Maintained Access Paths
|
|
334
161
|
|
|
335
162
|
Declaring a relation or scalar as a **maintained access path** keeps a *separate* row in sync with the
|
|
336
|
-
lifecycle of the source entity
|
|
337
|
-
|
|
338
|
-
-
|
|
339
|
-
collection synchronized onto the owner row
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
})
|
|
349
|
-
latestPosts!: PostModel[];
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
- **`@aggregate` counter** —— keeps a scalar aggregate (`count()`, etc.) synchronized with an atomic `ADD ±1`:
|
|
353
|
-
|
|
354
|
-
```ts
|
|
355
|
-
@aggregate(() => ThreadPostModel, { threadId: 'threadId' }, {
|
|
356
|
-
pattern: 'counter',
|
|
357
|
-
value: count(),
|
|
358
|
-
write: { maintainedOn: ['ThreadPost.created', 'ThreadPost.removed'] },
|
|
359
|
-
})
|
|
360
|
-
postCount!: number;
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
- **`@model({ kind })` + `@maintainedFrom`** —— declare a view as *its own model* and bind each source
|
|
364
|
-
with a class-level `@maintainedFrom` (`materializedView` / `sparseView`).
|
|
365
|
-
|
|
366
|
-
Synchronized maintenance (`updateMode: 'mutation'`) works in both TS and Python (conformance-verified).
|
|
367
|
-
`stream` / `max()` / `materializedView` / `sparseView` are later phases. For details and the pattern
|
|
368
|
-
mapping table, see [`docs/design-patterns.md`](./docs/design-patterns.md). Examples:
|
|
163
|
+
lifecycle of the source entity (all of it composed into the same atomic transaction as the source write):
|
|
164
|
+
|
|
165
|
+
- **embedded snapshot** —— `pattern: 'embeddedSnapshot'` on `@hasMany` / `@hasOne` keeps a projected
|
|
166
|
+
snapshot / bounded collection synchronized onto the owner row.
|
|
167
|
+
- **aggregate counter** —— `@aggregate` + `count()` keeps a scalar aggregate synchronized with an atomic `ADD ±1`.
|
|
168
|
+
- **materialized view / sparse view** —— `@model({ kind })` + `@maintainedFrom` declare a view as its own
|
|
169
|
+
model (later phases).
|
|
170
|
+
- **versioned history** —— maintenance of versioned history rows.
|
|
171
|
+
|
|
172
|
+
Synchronized maintenance (`updateMode: 'mutation'`) works in both TS and Python (conformance-verified). For
|
|
173
|
+
how to declare them, the pattern mapping table, and per-phase coverage, see
|
|
174
|
+
[`docs/design-patterns.md`](./docs/design-patterns.md). Examples:
|
|
369
175
|
[embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) /
|
|
370
176
|
[aggregate-counter](./examples/aggregate-counter/).
|
|
371
177
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
You can register **host-side middleware** that runs at fixed points in reads / writes. This handles
|
|
375
|
-
cross-cutting concerns —— logging, metrics, tracing, tenant / authorization scoping, soft-delete,
|
|
376
|
-
result post-processing —— without embedding them in queries or model definitions. It is host-only, never
|
|
377
|
-
touches the planner or the bridge SSoT, and is **not serialized**:
|
|
378
|
-
|
|
379
|
-
```ts
|
|
380
|
-
import { DDBModel } from 'graphddb';
|
|
381
|
-
|
|
382
|
-
DDBModel.use({
|
|
383
|
-
name: 'tenant-scope',
|
|
384
|
-
read: { before(ctx) { /* ctx を観測 / 変更、throw でキャンセル */ } },
|
|
385
|
-
write: { before(ctx) { /* ctx.input / ctx.kind を変更可 */ } },
|
|
386
|
-
});
|
|
387
|
-
DDBModel.clearMiddleware(); // 全解除(テスト teardown 等)
|
|
388
|
-
```
|
|
178
|
+
## ⚙️ Runtime
|
|
389
179
|
|
|
390
|
-
|
|
391
|
-
first-registered middleware is the outermost (onion nesting without `next()`). Request-scoped values
|
|
392
|
-
(tenant / actor / trace id) become visible from `ctx.context` when you pass `{ context }` to any
|
|
393
|
-
read / write. Hooks can observe, mutate `ctx`, cancel by throwing, and recover in `onError`. For the
|
|
394
|
-
full set of hook points (read R1–R5 / write W1–W5), see [`docs/middleware.md`](./docs/middleware.md).
|
|
180
|
+
The runtime's responsibilities are short. For details, see [`docs/spec.md`](./docs/spec.md):
|
|
395
181
|
|
|
396
|
-
|
|
182
|
+
- **access pattern resolution** —— pick the PK / GSI from the fields you give.
|
|
183
|
+
- **projection** —— compile `select` into a `ProjectionExpression`.
|
|
184
|
+
- **relation traversal** —— dispatch bound queries in parallel (N+1-safe BatchGet).
|
|
185
|
+
- **retry / throttling** —— retry throttle / transient errors with full-jitter exponential backoff, no
|
|
186
|
+
configuration required (set the client to `maxAttempts: 1`, since the library owns retries).
|
|
187
|
+
- **hydration** —— reconstruct raw items into typed partial entities.
|
|
397
188
|
|
|
398
|
-
|
|
399
|
-
(`GetItem` / `Query` / `PutItem` / `UpdateItem` / `DeleteItem`), the fan-out of relation reads, and
|
|
400
|
-
`TransactWriteItems`, with **no configuration required**. The default is exponential backoff with full
|
|
401
|
-
jitter (`min(1000, 50·2^(n-1))` ms), up to 10 attempts, for throttle / 5xx / transient only
|
|
402
|
-
(`ValidationException` and condition-check failures are never retried).
|
|
189
|
+
## 🛠 More Capabilities
|
|
403
190
|
|
|
404
|
-
|
|
405
|
-
> `UnprocessedItems` retry that `batchGet` / `batchWrite` already perform (they use the same backoff
|
|
406
|
-
> ramp but are independent).
|
|
191
|
+
Each capability is summarized here only. For details, operator lists, and procedures, see each doc:
|
|
407
192
|
|
|
408
|
-
**
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
[
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
```text
|
|
423
|
-
UserPermissions table
|
|
424
|
-
|
|
425
|
-
Entity PK SK GSI1PK GSI1SK
|
|
426
|
-
Group GROUP#<groupId> META
|
|
427
|
-
User USER#<userId> PROFILE EMAIL#<email> PROFILE
|
|
428
|
-
GroupMembership GROUP#<groupId> USER#<userId> USER#<userId> GROUP#<groupId>
|
|
429
|
-
Permission GROUP#<groupId> PERM#<resource>#<action>
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
`GroupMembership` is an adjacency-list pattern: `PK/SK` reads a group's members, and `GSI1PK/GSI1SK`
|
|
433
|
-
reads a user's groups. The same physical table supports both directions without hiding the keys.
|
|
434
|
-
Fetching a group, its members, and its permissions in a single `query` expands into a small, inspectable plan:
|
|
435
|
-
|
|
436
|
-
```mermaid
|
|
437
|
-
flowchart LR
|
|
438
|
-
Q(["Group.query<br/>groupId: eng"]) --> S1["1 Query<br/>PK=GROUP#eng, SK=META<br/>→ Group"]
|
|
439
|
-
Q --> S2["2 Query<br/>PK=GROUP#eng, SK begins_with USER#<br/>→ members"]
|
|
440
|
-
Q --> S3["3 Query<br/>PK=GROUP#eng, SK begins_with PERM#<br/>→ permissions"]
|
|
441
|
-
S2 -.並列ディスパッチ.- S3
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
The independent relation subqueries (`members` and `permissions`) are dispatched in parallel. For a
|
|
445
|
-
timeline illustrating the parallel dispatch, see the [example](./examples/user-permissions/).
|
|
446
|
-
|
|
447
|
-
## 🤔 Design Philosophy (in brief)
|
|
448
|
-
|
|
449
|
-
DynamoDB is fast but easy to misdesign. Teams often treat it like an RDBMS (one table per entity,
|
|
450
|
-
in-app joins, access patterns hidden behind an ORM abstraction) and end up with unstable performance,
|
|
451
|
-
unnecessary GSIs, and unclear service boundaries. GraphDDB does the opposite: it expresses access
|
|
452
|
-
patterns **directly in code** and keeps PK / GSI, projection, relation, cursor, and limits visible.
|
|
453
|
-
This makes the correct design the easiest path to take while writing code.
|
|
454
|
-
|
|
455
|
-
At its core is the natural correspondence between GraphQL's query model and DynamoDB's access patterns:
|
|
456
|
-
|
|
457
|
-
```text
|
|
458
|
-
GraphQL Query Model DynamoDB
|
|
459
|
-
arguments ----> PK / GSI lookup
|
|
460
|
-
selection ----> ProjectionExpression
|
|
461
|
-
relation ----> Query / Get / BatchGet
|
|
462
|
-
connection ----> Cursor pagination
|
|
463
|
-
complexity ----> Operation limits
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
- **Entity = access surface** —— not an ORM model, but the set of all supported ways to access data within
|
|
467
|
-
a service boundary (physical keys / GSIs / relations / projection / traversal rules).
|
|
468
|
-
- **Key / GSI = structured segments** —— built from `k` tag segments. The builder's input type doubles as
|
|
469
|
-
the query parameter type, and a partial key compiles to `begins_with` at a segment boundary.
|
|
470
|
-
- **Relation = bound query** —— a query bound to the parent's value. The same key/GSI resolution logic applies.
|
|
471
|
-
- **Select = projection + type inference** —— only the requested fields are returned, and only those
|
|
472
|
-
fields appear in the result type.
|
|
473
|
-
- **Planner = inspectable execution plan** —— a plan is produced before touching DynamoDB
|
|
474
|
-
(testing / debugging / RCU estimation).
|
|
475
|
-
|
|
476
|
-
Relation traversal follows only the access paths defined on the Entity. Undefined relations, missing GSIs,
|
|
477
|
-
limit-less lists, and traversals exceeding the allowed depth are checked before execution. The same
|
|
478
|
-
architectural constraints apply whether the code was written by a human or AI-generated.
|
|
479
|
-
|
|
480
|
-
### Design Rules
|
|
481
|
-
|
|
482
|
-
| Rule | Description |
|
|
483
|
-
|------|-------------|
|
|
484
|
-
| Service Boundary First | A single table represents one service boundary, not one entity. |
|
|
485
|
-
| Access Pattern First | Model the access patterns before tables or ORM-style entities. |
|
|
486
|
-
| Natural Mapping | Map Query / Get / BatchGet / Projection / cursor onto a GraphQL-like shape. |
|
|
487
|
-
| Inspectable Plans | DynamoDB operations and execution plans are inspectable before execution. |
|
|
488
|
-
| Single-item query | `query` is for PK or unique GSI lookups only. Use `list` for keys that return multiple items. |
|
|
489
|
-
| Explicit limits | `hasMany` and `list` require a default / max limit. |
|
|
490
|
-
| Bounded depth | Traversal defaults to depth=1. Deeper traversal must be explicitly allowed with `maxDepth`. |
|
|
491
|
-
| Parallel relations | Independent relation subqueries, BatchGet chunks, and nested resolutions are dispatched in parallel (bounded). |
|
|
492
|
-
| No Scan | Full table scans are forbidden. The linter detects and blocks them. |
|
|
493
|
-
| Partial select | Query results are partial types. Only the selected fields appear in the result type. |
|
|
494
|
-
| Structured keys | Keys are built from `k` segment templates. A partial key compiles to `begins_with` at a segment boundary. |
|
|
495
|
-
| Typed consistentRead | `consistentRead` is for PK queries only. GSI queries are rejected at compile time. |
|
|
496
|
-
| Server-side filter | `filter` is a declarative `FilterExpression` (compatible with AppSync `ModelFilterInput`). It does not reduce RCU, and `limit` is applied before it. |
|
|
497
|
-
|
|
498
|
-
The linter detects problems in Entity definitions before runtime (use of Scan, missing limits on
|
|
499
|
-
`hasMany` / `list`, ambiguous GSI resolution, query/list boundary violations against a unique GSI).
|
|
500
|
-
|
|
501
|
-
### Architecture
|
|
502
|
-
|
|
503
|
-
A query executes through 5 layers:
|
|
504
|
-
|
|
505
|
-
```mermaid
|
|
506
|
-
flowchart TD
|
|
507
|
-
L1["🧩 Layer 1: Schema Definition<br/>Entity / Field / Key / GSI / Relation / Embedded"]
|
|
508
|
-
L2["🔧 Layer 2: Query Builder<br/>型安全な DSL: key 解決 / select バリデーション"]
|
|
509
|
-
L3["🗺️ Layer 3: Query Planner<br/>Relation traversal / Get vs Query vs BatchGet 判定<br/>Projection 生成 / GSI 選択"]
|
|
510
|
-
L4["⚡ Layer 4: Executor<br/>DynamoDB DocumentClient ラッパー"]
|
|
511
|
-
L5["💧 Layer 5: Hydrator<br/>型変換 / Embedded 再構築 / メタデータ付与"]
|
|
512
|
-
|
|
513
|
-
L1 -->|EntityMetadata Registry| L2
|
|
514
|
-
L2 -->|ExecutionPlan| L3
|
|
515
|
-
L3 -->|"DynamoDBOperation[]"| L4
|
|
516
|
-
L4 -->|raw Items| L5
|
|
517
|
-
L5 -->|Partial Entity インスタンス| OUT(["✅ 型付き結果"])
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
## 🧱 Beyond the Core
|
|
521
|
-
|
|
522
|
-
The query / relation / write APIs above are the core layer. GraphDDB additionally provides:
|
|
523
|
-
|
|
524
|
-
- **[CQRS contracts](./docs/cqrs-contract.md)** —— a public Query/Command contract model with an N+1-safe
|
|
525
|
-
cardinality matrix, composition across contracts, and enforcement of context boundaries.
|
|
526
|
-
- **[Mutation → command derivation](./docs/mutation-command-derivation.md)** —— the internal write-plan
|
|
527
|
-
composition DSL behind the public Command IF. Declare a model's write semantics (`entityWrites`) once,
|
|
528
|
-
and `mutation` composes multiple fragments while the compiler merges them into a single atomic
|
|
529
|
-
`TransactWriteItems`.
|
|
530
|
-
- **[Class hydration](./docs/class-hydration.md)** —— load read results into host-language domain objects
|
|
531
|
-
via the `options.hydrate` factory (host-only, non-serialized; Phase 1 = `query` top level implemented).
|
|
532
|
-
- **[Multi-language / Python bridge](./docs/python-bridge.md)** —— generate a Python client + runtime with
|
|
533
|
-
TS as the SSoT, kept in step by a TS↔Python conformance suite.
|
|
534
|
-
- **[In-memory testing](./docs/testing.md)** —— `graphddb/testing` runs model-mapping / query-plan /
|
|
535
|
-
relation-traversal / CDC tests in-process without Docker.
|
|
536
|
-
- **[CDC emulator](./docs/cdc-emulator.md)** —— change events equivalent to DynamoDB Streams, to drive and
|
|
537
|
-
test differential aggregation locally.
|
|
193
|
+
- **Filtering** —— a declarative, type-safe server-side `filter` (compatible with `FilterExpression` /
|
|
194
|
+
AppSync `ModelFilterInput`). `Model.col` + the `cond` raw escape hatch are shared by read filters / write
|
|
195
|
+
`condition`s. For the operator list, see [`docs/spec.md`](./docs/spec.md).
|
|
196
|
+
- **Middleware / Hooks** —— `DDBModel.use` for host-side hooks on reads / writes (logging, metrics, tenant /
|
|
197
|
+
authorization scoping). Host-only and non-serialized. For hook points, see [`docs/middleware.md`](./docs/middleware.md).
|
|
198
|
+
- **Class hydration** —— `options.hydrate` loads read results into host-language domain objects (opt-in,
|
|
199
|
+
Phase 1: `query` top level). [`docs/class-hydration.md`](./docs/class-hydration.md).
|
|
200
|
+
- **Python bridge** —— generate a Python client + runtime with TS as the SSoT, kept in step by a TS↔Python
|
|
201
|
+
conformance suite. [`docs/python-bridge.md`](./docs/python-bridge.md).
|
|
202
|
+
- **CQRS contracts** —— public Query/Command contracts (cardinality matrix, N+1 safety, context boundaries,
|
|
203
|
+
composition across contracts). [`docs/cqrs-contract.md`](./docs/cqrs-contract.md) /
|
|
204
|
+
[`docs/mutation-command-derivation.md`](./docs/mutation-command-derivation.md).
|
|
205
|
+
- **CDC emulator** —— change events equivalent to DynamoDB Streams, to drive and test differential
|
|
206
|
+
aggregation. [`docs/cdc-emulator.md`](./docs/cdc-emulator.md).
|
|
538
207
|
|
|
539
208
|
## 📚 Documentation
|
|
540
209
|
|
|
210
|
+
**The full documentation is bundled in the npm package** (under `docs/`). After installation you can browse
|
|
211
|
+
every doc at `node_modules/graphddb/docs/`, and if you add it as a `devDependency` you have all the
|
|
212
|
+
documentation available offline at hand. On GitHub, follow the links in the table below.
|
|
213
|
+
|
|
541
214
|
| Document | Description |
|
|
542
215
|
|----------|-------------|
|
|
543
216
|
| [Specification](./docs/spec.md) | Core API: entities, structured keys/GSIs, query/filter, relations, batch/transaction, design rules, runtime behavior. |
|
|
@@ -560,33 +233,26 @@ The query / relation / write APIs above are the core layer. GraphDDB additionall
|
|
|
560
233
|
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | Treating a relation as a maintained access path (Epic #118): `pattern: 'embeddedSnapshot'` keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
|
|
561
234
|
| [aggregate-counter](./examples/aggregate-counter/) | The `@aggregate` counter from RFC §5.2: the `ThreadPost.created` / `removed` lifecycle keeps `ThreadCounter.postCount` synchronized via an atomic `ADD ±1` within the same `TransactWriteItems` as the source write. |
|
|
562
235
|
|
|
563
|
-
##
|
|
236
|
+
## 🏛 Architecture
|
|
237
|
+
|
|
238
|
+
A query travels from the TS Model through the Planner / Runtime to DynamoDB:
|
|
564
239
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
- Retry / throttling (single op / fan-out / transaction, full-jitter exponential backoff, global / per-call tuning)
|
|
582
|
-
- Linter (no-scan, require-limit, query-boundary, GSI ambiguity)
|
|
583
|
-
- Typed constraints on ConsistentRead, the Transaction DSL, Batch operations
|
|
584
|
-
- CQRS Query/Command contract layer —— [docs](./docs/cqrs-contract.md)
|
|
585
|
-
- Mutation → command derivation (`entityWrites` + `mutation`) —— [docs](./docs/mutation-command-derivation.md)
|
|
586
|
-
- Opt-in class hydration (`options.hydrate`, Phase 1 implemented) —— [docs](./docs/class-hydration.md)
|
|
587
|
-
- Multi-language: a Python code generator + runtime with TS as the SSoT, TS↔Python conformance —— [docs](./docs/python-bridge.md)
|
|
588
|
-
- In-memory test adapter (`graphddb/testing`) —— [docs](./docs/testing.md)
|
|
589
|
-
- CDC emulator —— [docs](./docs/cdc-emulator.md)
|
|
240
|
+
```mermaid
|
|
241
|
+
flowchart LR
|
|
242
|
+
M["TS Model<br/>Entity / Key / GSI / Relation"] --> P["Planner<br/>access pattern resolution / projection"]
|
|
243
|
+
P --> R["Runtime<br/>execute / retry / hydrate"]
|
|
244
|
+
R --> D[("DynamoDB")]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
From the same TS Model (the SSoT), multiple targets are derived:
|
|
248
|
+
|
|
249
|
+
```mermaid
|
|
250
|
+
flowchart LR
|
|
251
|
+
M["TS Model (SSoT)"] --> RT["Runtime (TS)"]
|
|
252
|
+
M --> PY["Python client + runtime"]
|
|
253
|
+
M --> OA["OpenAPI"]
|
|
254
|
+
M --> CQ["CQRS Contract"]
|
|
255
|
+
```
|
|
590
256
|
|
|
591
257
|
## 📄 License
|
|
592
258
|
|