graphddb 0.4.2 → 0.5.0
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 +145 -476
- package/dist/{chunk-BROCT574.js → chunk-7NPG5R7O.js} +1 -1
- package/dist/{chunk-CPTV3H2U.js → chunk-FMJIWFIS.js} +156 -30
- package/dist/{chunk-G5RWWBAL.js → chunk-J5A665UW.js} +23 -3
- package/dist/cli.js +781 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +20 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B-F7jw9f.d.ts} +42 -0
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cloudformation.md +294 -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 +6 -3
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,151 @@ 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 } }
|
|
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 });
|
|
160
96
|
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
|
163
100
|
```
|
|
164
101
|
|
|
165
|
-
|
|
102
|
+
## 🔎 Inspect Execution Plans (Explain)
|
|
166
103
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
{ userId: true, role: true },
|
|
171
|
-
{ limit: 20, after: cursor },
|
|
172
|
-
);
|
|
173
|
-
// result.items: GroupMembership[] ; result.cursor: string | null
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Filtering (server-side `filter`)
|
|
177
|
-
|
|
178
|
-
`filter` is a declarative, type-safe condition that compiles to DynamoDB's **`FilterExpression`** and is
|
|
179
|
-
evaluated **server-side** (modeled on AWS AppSync's `ModelFilterInput`). A bare value is shorthand for
|
|
180
|
-
equality, an operator object is a comparison, and `and` / `or` / `not` form logical groups. Because it is
|
|
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`
|
|
260
|
-
|
|
261
|
-
`putItem` / `updateItem` / `deleteItem` are **raw single-item operations** named after the DynamoDB API,
|
|
262
|
-
primitives with no lifecycle semantics. They carry no atomic composition of multiple fragments and no
|
|
263
|
-
derived side effects, unlike `mutate`:
|
|
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).
|
|
264
126
|
|
|
265
|
-
|
|
266
|
-
await User.putItem({ userId: 'alice', name: 'Alice', email: 'alice@example.com', status: 'active' });
|
|
267
|
-
await User.updateItem({ userId: 'alice' }, { status: 'disabled' });
|
|
268
|
-
await User.deleteItem({ userId: 'alice' });
|
|
269
|
-
```
|
|
127
|
+
## 🧪 Testing (strongly recommended)
|
|
270
128
|
|
|
271
|
-
|
|
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:
|
|
153
|
+
{ maxDepth: 2 }, // traversal defaults to depth=1. Deeper traversal must be explicitly allowed.
|
|
305
154
|
);
|
|
306
155
|
```
|
|
307
156
|
|
|
308
|
-
|
|
157
|
+
`hasMany` / `list` require a default / max limit. For the complete relation model, see
|
|
158
|
+
[`docs/spec.md`](./docs/spec.md).
|
|
309
159
|
|
|
310
|
-
|
|
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 },
|
|
318
|
-
);
|
|
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
|
-
```
|
|
329
|
-
|
|
330
|
-
For a partial GSI key match, the SK condition is separated out as a `rangeCondition`
|
|
331
|
-
(`keyCondition: { GSI1PK: "USER#alice" }`, `indexName: "GSI1"`, `rangeCondition: { begins_with GSI1SK "GROUP#" }`).
|
|
332
|
-
|
|
333
|
-
### Maintained Access Paths (0.4.x / Epic #118)
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
`
|
|
401
|
-
|
|
402
|
-
(
|
|
403
|
-
|
|
404
|
-
> This is the library's own **error retry** and is distinct from the partial-batch `UnprocessedKeys` /
|
|
405
|
-
> `UnprocessedItems` retry that `batchGet` / `batchWrite` already perform (they use the same backoff
|
|
406
|
-
> ramp but are independent).
|
|
407
|
-
|
|
408
|
-
**Set the client to `maxAttempts: 1`.** Since the library owns retries, leaving the SDK at its default
|
|
409
|
-
(`maxAttempts: 3`) would *multiply* the retries. The policy can be tuned globally or per call:
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
DDBModel.setRetryPolicy({ maxAttempts: 5, jitter: 'full' }); // null でデフォルトに戻る
|
|
413
|
-
await User.putItem({ userId: 'alice', name: 'Alice' }, { retry: false });
|
|
414
|
-
await User.query({ userId: 'alice' }, { name: true }, { retry: { maxAttempts: 3 } });
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
## 👥 Example: User Permissions
|
|
418
|
-
|
|
419
|
-
[user-permissions](./examples/user-permissions/) models users, groups, memberships, and permissions in a
|
|
420
|
-
single DynamoDB table. The table is physical, but the query surface has the shape of a graph:
|
|
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.
|
|
178
|
+
## ⚙️ Runtime
|
|
179
|
+
|
|
180
|
+
The runtime's responsibilities are short. For details, see [`docs/spec.md`](./docs/spec.md):
|
|
181
|
+
|
|
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.
|
|
188
|
+
|
|
189
|
+
## 🛠 More Capabilities
|
|
190
|
+
|
|
191
|
+
Each capability is summarized here only. For details, operator lists, and procedures, see each doc:
|
|
192
|
+
|
|
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).
|
|
207
|
+
- **CloudFormation generation** —— `graphddb generate cloudformation` emits a CloudFormation template for
|
|
208
|
+
the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / PROVISIONED / Auto
|
|
209
|
+
Scaling). [`docs/cloudformation.md`](./docs/cloudformation.md).
|
|
538
210
|
|
|
539
211
|
## 📚 Documentation
|
|
540
212
|
|
|
213
|
+
**The full documentation is bundled in the npm package** (under `docs/`). After installation you can browse
|
|
214
|
+
every doc at `node_modules/graphddb/docs/`, and if you add it as a `devDependency` you have all the
|
|
215
|
+
documentation available offline at hand. On GitHub, follow the links in the table below.
|
|
216
|
+
|
|
541
217
|
| Document | Description |
|
|
542
218
|
|----------|-------------|
|
|
543
219
|
| [Specification](./docs/spec.md) | Core API: entities, structured keys/GSIs, query/filter, relations, batch/transaction, design rules, runtime behavior. |
|
|
@@ -549,6 +225,7 @@ The query / relation / write APIs above are the core layer. GraphDDB additionall
|
|
|
549
225
|
| [Multi-language / Python bridge](./docs/python-bridge.md) | Code generation with TS as the SSoT and a Python runtime; TS↔Python conformance. |
|
|
550
226
|
| [In-memory test adapter](./docs/testing.md) | Docker-free in-process testing with `graphddb/testing` and `MemoryInspector`. |
|
|
551
227
|
| [CDC emulator](./docs/cdc-emulator.md) | A change-event emulator for differential aggregation patterns (dev/test). |
|
|
228
|
+
| [CloudFormation generation](./docs/cloudformation.md) | The `generate cloudformation` command: emit a CloudFormation template for the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / TableClass / SSE / PROVISIONED / Auto Scaling). All options, derivation rules, and scope boundary. |
|
|
552
229
|
| [Benchmark](./benchmark/RESULTS.md) | Library comparison against hand-written AWS SDK, ElectroDB, DynamoDB Toolbox, and OneTable. |
|
|
553
230
|
|
|
554
231
|
## 💡 Examples
|
|
@@ -557,36 +234,28 @@ The query / relation / write APIs above are the core layer. GraphDDB additionall
|
|
|
557
234
|
|---------|-------------|
|
|
558
235
|
| [user-permissions](./examples/user-permissions/) | Manages users, groups, and permissions with Single Table Design. Includes adjacency lists, inverted indexes, relation traversal, and `explain`. |
|
|
559
236
|
| [aggregate-tree-pattern](./examples/aggregate-tree-pattern/) | Differential tree aggregation (`siteScoreAverage`) driven by the CDC emulator: dirty propagation, throttled sweeps, recomputation. |
|
|
560
|
-
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | Treating a relation as a maintained access path
|
|
237
|
+
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | Treating a relation as a maintained access path: `pattern: 'embeddedSnapshot'` keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
|
|
561
238
|
| [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
239
|
|
|
563
|
-
##
|
|
240
|
+
## 🏛 Architecture
|
|
564
241
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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)
|
|
242
|
+
A query travels from the TS Model through the Planner / Runtime to DynamoDB:
|
|
243
|
+
|
|
244
|
+
```mermaid
|
|
245
|
+
flowchart LR
|
|
246
|
+
M["TS Model<br/>Entity / Key / GSI / Relation"] --> P["Planner<br/>access pattern resolution / projection"]
|
|
247
|
+
P --> R["Runtime<br/>execute / retry / hydrate"]
|
|
248
|
+
R --> D[("DynamoDB")]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
From the same TS Model (the SSoT), multiple targets are derived:
|
|
252
|
+
|
|
253
|
+
```mermaid
|
|
254
|
+
flowchart LR
|
|
255
|
+
M["TS Model (SSoT)"] --> RT["Runtime (TS)"]
|
|
256
|
+
M --> PY["Python client + runtime"]
|
|
257
|
+
M --> CQ["CQRS Contract"]
|
|
258
|
+
```
|
|
590
259
|
|
|
591
260
|
## 📄 License
|
|
592
261
|
|