graphddb 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +339 -482
- package/dist/{chunk-GWFZVNAV.js → chunk-BROCT574.js} +1 -1
- package/dist/{chunk-72VZYOSG.js → chunk-CPTV3H2U.js} +29 -11
- package/dist/{chunk-PC54CW5P.js → chunk-G5RWWBAL.js} +41 -19
- package/dist/cli.js +51 -14
- package/dist/index.d.ts +11 -4
- package/dist/index.js +29 -12
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-CpCo8yHP.d.ts → types-BWOrWcbd.d.ts} +170 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,62 +1,32 @@
|
|
|
1
1
|
# GraphDDB
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**A type-safe Graph Query Runtime for DynamoDB**
|
|
4
4
|
|
|
5
|
-
GraphDDB
|
|
6
|
-
PK
|
|
5
|
+
GraphDDB maps DynamoDB's native access patterns onto a GraphQL-like query model.
|
|
6
|
+
PK / GSI / projection / relation / cursor / operation limits are all kept visible rather than hidden.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
TypeScript の型を使って DynamoDB のアクセスパターンを定義・検証・実行するための Runtime である。
|
|
8
|
+
It is not an ORM. It is a Runtime for **defining, validating, and executing** DynamoDB access patterns through TypeScript types.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
DynamoDB は高速でスケーラブルだが、誤った設計をしやすい。チームはしばしば DynamoDB を
|
|
14
|
-
RDBMS のように扱い —— エンティティごとに1テーブル、アプリケーションコード内での join、
|
|
15
|
-
ORM 抽象の裏に隠れたその場しのぎのアクセスパターン —— その結果、不安定なパフォーマンス、
|
|
16
|
-
不要な GSI、不明瞭なサービス境界に陥る。
|
|
17
|
-
|
|
18
|
-
GraphDDB は逆のアプローチを取る。DynamoDB のアクセスパターンを **コードに直接** 表現し、
|
|
19
|
-
PK/GSI、projection、relation、cursor、操作の上限を可視に保つ。アクセスパターンと relation が
|
|
20
|
-
明示的になり、クエリプランが検査可能になり、よく設計されたアクセスパターンが自然と
|
|
21
|
-
サービス境界に整合する。GraphDDB は DynamoDB の設計を教えるのではない —— 正しい設計を、
|
|
22
|
-
コードを書く上で最も簡単な道にする。
|
|
23
|
-
|
|
24
|
-
これは GraphQL のクエリモデルと DynamoDB のアクセスパターンの自然な対応関係を通じて実現される:
|
|
25
|
-
|
|
26
|
-
```text
|
|
27
|
-
GraphQL Query Model DynamoDB
|
|
28
|
-
arguments ----> PK / GSI lookup
|
|
29
|
-
selection ----> ProjectionExpression
|
|
30
|
-
relation ----> Query / Get / BatchGet
|
|
31
|
-
connection ----> Cursor pagination
|
|
32
|
-
complexity ----> Operation limits
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Partition Key は Graph のエントリポイントであり、relation は明示的な traversal パスであり、
|
|
36
|
-
projection は selection set である。GraphDDB はクエリ言語を発明しない —— よく設計された
|
|
37
|
-
アクセスパターンを直接実行し、plan・limit・relation 解決を通じて曖昧さを可視化する。
|
|
10
|
+
Reads center on `query` / `list`, writes on `DDBModel.mutate` (a unified envelope), while
|
|
11
|
+
`putItem` / `updateItem` / `deleteItem` are positioned secondarily as raw base operations named after the DynamoDB API.
|
|
38
12
|
|
|
39
13
|
## 📦 Install
|
|
40
14
|
|
|
41
15
|
```bash
|
|
42
16
|
npm install graphddb
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
AWS SDK v3 は peer dependency である —— 使用するクライアントをインストールすること:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
17
|
+
# AWS SDK v3 は peer dependency。使うクライアントを入れる:
|
|
48
18
|
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
|
49
19
|
```
|
|
50
20
|
|
|
51
|
-
`tsx`
|
|
52
|
-
TypeScript
|
|
53
|
-
|
|
21
|
+
`tsx` is an optional peer dependency, needed only when running the code-generation CLI
|
|
22
|
+
(`graphddb generate …`) directly against TypeScript definitions. Library consumers do not need it
|
|
23
|
+
(esbuild keeps it out of the runtime bundle):
|
|
54
24
|
|
|
55
25
|
```bash
|
|
56
26
|
npm install -D tsx
|
|
57
27
|
```
|
|
58
28
|
|
|
59
|
-
Node.js ≥ 22
|
|
29
|
+
Node.js ≥ 22 is required.
|
|
60
30
|
|
|
61
31
|
## 🚀 Quick Start
|
|
62
32
|
|
|
@@ -76,168 +46,40 @@ class UserModel extends DDBModel {
|
|
|
76
46
|
}
|
|
77
47
|
const User = UserModel.asModel();
|
|
78
48
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// 二重 retry(SDK × ライブラリ)を避けること。
|
|
49
|
+
// クライアントは一度だけ接続。GraphDDB が throttle / transient の retry を所有するため
|
|
50
|
+
// `maxAttempts: 1` にして二重 retry(SDK × ライブラリ)を避ける(後述 "Retry & throttling")。
|
|
82
51
|
DDBModel.setClient(new DynamoDBClient({ maxAttempts: 1 }));
|
|
83
52
|
|
|
84
|
-
//
|
|
53
|
+
// READ —— 結果型に現れるのは select したフィールドのみ。
|
|
85
54
|
const user = await User.query({ userId: 'alice' }, { name: true });
|
|
86
55
|
|
|
87
|
-
//
|
|
88
|
-
await User.putItem({ userId: 'alice', name: 'Alice' });
|
|
89
|
-
|
|
90
|
-
// 複数アイテムの atomic な書き込み —— 統一 envelope(デフォルト `mode: 'transaction'`)。
|
|
56
|
+
// WRITE —— 複数アイテムを atomic に書く統一 envelope(デフォルト mode: 'transaction')。
|
|
91
57
|
await DDBModel.mutate({
|
|
92
58
|
user: { update: User, key: { userId: 'alice' }, input: { name: 'Alice B.' } },
|
|
93
59
|
});
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
クロスサービス contract、Python bridge、複数 fragment の atomic mutation などについては、
|
|
97
|
-
以下の各セクションおよび [`docs/spec.md`](./docs/spec.md) を参照。
|
|
98
|
-
|
|
99
|
-
## 🔁 Retry & throttling
|
|
100
|
-
|
|
101
|
-
GraphDDB は **すべての** 単一アイテム操作(`GetItem` / `Query` / `PutItem` / `UpdateItem` /
|
|
102
|
-
`DeleteItem`)、relation read の fan-out、そして `TransactWriteItems` に対して、throttle /
|
|
103
|
-
transient エラーの retry を行う —— 設定不要で、すぐに使える。デフォルトのポリシーは
|
|
104
|
-
full jitter 付きの指数バックオフ(`min(1000, 50·2^(n-1))` ms)で、最大 10 回まで、
|
|
105
|
-
throttle / 5xx / transient エラーのみを retry する(`ValidationException` や
|
|
106
|
-
条件チェック失敗は決して retry しない)。キャンセルされた transaction は理由ごとに
|
|
107
|
-
検査される: `ConditionalCheckFailed` の理由は即座に throw し、純粋に throttle による
|
|
108
|
-
キャンセルは retry される。
|
|
109
|
-
|
|
110
|
-
> これはライブラリ自身の **error retry** であり、`batchGet` / `batchWrite` が既に行う
|
|
111
|
-
> 部分バッチの `UnprocessedKeys` / `UnprocessedItems` retry とは別物である。両者は同じ
|
|
112
|
-
> バックオフのランプを使うが、独立したメカニズムである。
|
|
113
|
-
|
|
114
|
-
**クライアントは `maxAttempts: 1` に設定すること。** ライブラリが retry を所有する以上、
|
|
115
|
-
AWS SDK をデフォルト(`maxAttempts: 3`)のままにすると、GraphDDB の下層で
|
|
116
|
-
*乗算的に* retry してしまう。ライブラリを単一の真実の源とすること:
|
|
117
|
-
|
|
118
|
-
```ts
|
|
119
|
-
DDBModel.setClient(new DynamoDBClient({ maxAttempts: 1 }));
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
GraphDDB は渡されたクライアントを決して変更しない —— この設定はあなたの責任である。
|
|
123
|
-
|
|
124
|
-
ポリシーはグローバルに、あるいは呼び出しごとに調整できる:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
import { DDBModel } from 'graphddb';
|
|
128
|
-
|
|
129
|
-
// グローバルな上書き(setClient / setTableMapping と同様)。`null` でデフォルトに戻る。
|
|
130
|
-
DDBModel.setRetryPolicy({
|
|
131
|
-
maxAttempts: 5,
|
|
132
|
-
jitter: 'full', // または 'none'
|
|
133
|
-
onRetry: ({ attempt, error, delayMs, operation }) => {
|
|
134
|
-
console.warn(`retry #${attempt} of ${operation} after ${delayMs}ms`, error);
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// 任意の操作の options での呼び出しごとの上書き(`RetryPolicy | false`)。`false` で retry 無効。
|
|
139
|
-
await User.putItem({ userId: 'alice', name: 'Alice' }, { retry: false });
|
|
140
|
-
await User.query({ userId: 'alice' }, { name: true }, { retry: { maxAttempts: 3 } });
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## 👥 User Permissions Example
|
|
144
|
-
|
|
145
|
-
[user-permissions](./examples/user-permissions/) の例は、ユーザー・グループ・メンバーシップ・
|
|
146
|
-
パーミッションを単一の DynamoDB テーブルでモデル化している。
|
|
147
|
-
ここで GraphDDB の設計が最も分かりやすく見えてくる: テーブルは物理的だが、クエリの表面は
|
|
148
|
-
Graph の形をしている。
|
|
149
|
-
|
|
150
|
-
```text
|
|
151
|
-
UserPermissions table
|
|
152
|
-
|
|
153
|
-
Entity PK SK GSI1PK GSI1SK
|
|
154
|
-
Group GROUP#<groupId> META
|
|
155
|
-
User USER#<userId> PROFILE EMAIL#<email> PROFILE
|
|
156
|
-
GroupMembership GROUP#<groupId> USER#<userId> USER#<userId> GROUP#<groupId>
|
|
157
|
-
Permission GROUP#<groupId> PERM#<resource>#<action>
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
`GroupMembership` は隣接リスト(adjacency list)パターンを使う:
|
|
161
60
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
同じ物理テーブルが、キーを隠すことなく両方向をサポートする。
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
const engGroup = await Group.query(
|
|
169
|
-
{ groupId: 'eng' },
|
|
170
|
-
{
|
|
171
|
-
groupId: true,
|
|
172
|
-
name: true,
|
|
173
|
-
members: {
|
|
174
|
-
select: { userId: true, role: true },
|
|
175
|
-
limit: 20,
|
|
176
|
-
},
|
|
177
|
-
permissions: {
|
|
178
|
-
select: { resource: true, action: true },
|
|
179
|
-
limit: 20,
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
// email(GSI)でユーザーを探し、所属グループを一覧し、各グループについてグループ本体と
|
|
185
|
-
// そのパーミッションを取得する。N 件の membership -> 親 Group の解決は単一の
|
|
186
|
-
// BatchGetItem に集約される(N+1 にならない)。
|
|
187
|
-
const alice = await User.query(
|
|
188
|
-
{ email: 'alice@example.com' },
|
|
189
|
-
{
|
|
190
|
-
userId: true,
|
|
191
|
-
name: true,
|
|
192
|
-
groups: {
|
|
193
|
-
select: {
|
|
194
|
-
groupId: true,
|
|
195
|
-
role: true,
|
|
196
|
-
group: { // membership -> Group (belongsTo, BatchGet)
|
|
197
|
-
select: {
|
|
198
|
-
name: true,
|
|
199
|
-
permissions: { // group -> permissions (hasMany)
|
|
200
|
-
select: { resource: true, action: true },
|
|
201
|
-
filter: { resource: { attributeExists: true } },
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
},
|
|
206
|
-
limit: 20,
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
{ maxDepth: 3 },
|
|
210
|
-
);
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
最初のクエリは、小さく検査可能な DynamoDB plan に展開される:
|
|
214
|
-
|
|
215
|
-
```mermaid
|
|
216
|
-
flowchart LR
|
|
217
|
-
Q(["Group.query<br/>groupId: eng"]) --> S1["1 Query<br/>PK=GROUP#eng, SK=META<br/>→ Group"]
|
|
218
|
-
Q --> S2["2 Query<br/>PK=GROUP#eng, SK begins_with USER#<br/>→ members"]
|
|
219
|
-
Q --> S3["3 Query<br/>PK=GROUP#eng, SK begins_with PERM#<br/>→ permissions"]
|
|
220
|
-
S2 -.並列ディスパッチ.- S3
|
|
61
|
+
// 単一アイテムの raw な base 操作(DynamoDB API 名に倣う)。
|
|
62
|
+
await User.putItem({ userId: 'alice', name: 'Alice' });
|
|
221
63
|
```
|
|
222
64
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
並列ディスパッチを示すタイムラインは [example](./examples/user-permissions/) を参照。
|
|
65
|
+
Cross-service contracts, the Python bridge, atomic mutation of multiple fragments, and more are
|
|
66
|
+
covered in the sections below and in [`docs/spec.md`](./docs/spec.md).
|
|
226
67
|
|
|
227
68
|
## 📖 Usage
|
|
228
69
|
|
|
229
|
-
|
|
70
|
+
The snippets below are condensed from the [user-permissions](./examples/user-permissions/) example.
|
|
230
71
|
|
|
231
72
|
### Entity Definition
|
|
232
73
|
|
|
233
|
-
TypeScript
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
236
78
|
|
|
237
79
|
```ts
|
|
238
80
|
const TABLE = 'UserPermissions';
|
|
239
81
|
|
|
240
|
-
@model({ table: TABLE, prefix: 'USER' })
|
|
82
|
+
@model({ table: TABLE, prefix: 'USER', description: 'アプリのユーザー' })
|
|
241
83
|
class UserModel extends DDBModel {
|
|
242
84
|
static readonly keys = key<{ userId: string }>((c) => ({
|
|
243
85
|
pk: k`USER#${c.userId}`,
|
|
@@ -282,18 +124,27 @@ export const User = UserModel.asModel();
|
|
|
282
124
|
export const GroupMembership = GroupMembershipModel.asModel();
|
|
283
125
|
```
|
|
284
126
|
|
|
285
|
-
|
|
286
|
-
partial key
|
|
287
|
-
[
|
|
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,
|
|
129
|
+
see [`docs/spec.md`](./docs/spec.md).
|
|
130
|
+
|
|
131
|
+
Field decorators define the DynamoDB attribute type and catch mismatches with the declared TS type at compile time:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
@string userId!: string; // DynamoDB S
|
|
135
|
+
@number amount!: number; // DynamoDB N
|
|
136
|
+
@datetime createdAt!: Date; // ISO 8601 文字列として保存
|
|
137
|
+
@boolean isActive!: boolean; // DynamoDB BOOL
|
|
138
|
+
```
|
|
288
139
|
|
|
289
|
-
###
|
|
140
|
+
### Read: `query` / `list`
|
|
290
141
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
294
145
|
|
|
295
146
|
```ts
|
|
296
|
-
// email
|
|
147
|
+
// email(unique GSI)でユーザーを取得し、group membership へ traverse する。
|
|
297
148
|
const alice = await User.query(
|
|
298
149
|
{ email: 'alice@example.com' },
|
|
299
150
|
{
|
|
@@ -305,24 +156,13 @@ const alice = await User.query(
|
|
|
305
156
|
},
|
|
306
157
|
},
|
|
307
158
|
);
|
|
159
|
+
// alice: { name: string; groups: { items: { groupId; role }[]; cursor: string | null } }
|
|
308
160
|
|
|
309
161
|
// 未定義のキーフィールド -> コンパイルエラー
|
|
310
162
|
User.query({ foo: 'bar' }, { name: true });
|
|
311
163
|
```
|
|
312
164
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
```ts
|
|
316
|
-
// alice: {
|
|
317
|
-
// name: string;
|
|
318
|
-
// groups: {
|
|
319
|
-
// items: { groupId: string; role: string }[];
|
|
320
|
-
// cursor: string | null;
|
|
321
|
-
// };
|
|
322
|
-
// }
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
### List Queries
|
|
165
|
+
`query` is for a **single** lookup of a PK or unique GSI only. For keys that may return multiple items, use `list` (with a cursor):
|
|
326
166
|
|
|
327
167
|
```ts
|
|
328
168
|
const result = await GroupMembership.list(
|
|
@@ -330,17 +170,15 @@ const result = await GroupMembership.list(
|
|
|
330
170
|
{ userId: true, role: true },
|
|
331
171
|
{ limit: 20, after: cursor },
|
|
332
172
|
);
|
|
333
|
-
// result.items: GroupMembership[]
|
|
334
|
-
// result.cursor: string | null
|
|
173
|
+
// result.items: GroupMembership[] ; result.cursor: string | null
|
|
335
174
|
```
|
|
336
175
|
|
|
337
|
-
### Filtering
|
|
176
|
+
### Filtering (server-side `filter`)
|
|
338
177
|
|
|
339
|
-
`filter`
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
属性も参照できる。
|
|
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`.
|
|
344
182
|
|
|
345
183
|
```ts
|
|
346
184
|
const result = await Order.list(
|
|
@@ -358,74 +196,57 @@ const result = await Order.list(
|
|
|
358
196
|
);
|
|
359
197
|
```
|
|
360
198
|
|
|
361
|
-
|
|
362
|
-
`
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
`between`、`in`、`beginsWith`、`contains`、`notContains`、`attributeExists`、
|
|
368
|
-
`attributeType`、`size`。operator は **フィールドの宣言型によって制約される** ——
|
|
369
|
-
例えば数値フィールドへの `beginsWith` / `contains`、あるいは数値への `gt: 'x'` は
|
|
370
|
-
コンパイルエラーになる。
|
|
371
|
-
|
|
372
|
-
operator オブジェクトでは表現できない稀な条件のために、`cond` という raw な escape hatch が
|
|
373
|
-
式 fragment を受け取る。カラム名は bare な文字列ではなく、リファクタ安全な
|
|
374
|
-
`Model.col.<field>` 参照を使わなければならない。値はパラメータ化される:
|
|
199
|
+
Available operators: `eq` / `ne` / `ge` / `le` / `gt` / `lt` / `between` / `in` / `beginsWith` /
|
|
200
|
+
`contains` / `notContains` / `attributeExists` / `attributeType` / `size`. Operators are
|
|
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:
|
|
375
205
|
|
|
376
206
|
```ts
|
|
377
207
|
filter: cond`${Order.col.amount} > ${100} AND attribute_exists(${Order.col.status})`
|
|
378
208
|
```
|
|
379
209
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
コンパイルされた式はインジェクション安全であり、`KeyConditionExpression` /
|
|
383
|
-
`ProjectionExpression` とは独立して付与される。
|
|
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).
|
|
384
212
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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.
|
|
388
217
|
|
|
389
|
-
|
|
390
|
-
> DynamoDB はまずマッチするキーを読み、その後に filter する。`limit` は filter の
|
|
391
|
-
> **前** に適用されるため、1ページが `limit` **未満** のアイテムを返すことがある
|
|
392
|
-
> (そしてページ全体が filter で除外された場合、null でない cursor を持つ空ページになる)。
|
|
393
|
-
> 効率的な絞り込みのためにキーを設計し、`filter` は正しさのために使うこと。
|
|
218
|
+
### Write: `DDBModel.mutate` (unified envelope)
|
|
394
219
|
|
|
395
|
-
|
|
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.
|
|
396
224
|
|
|
397
|
-
|
|
398
|
-
ライフサイクルのセマンティクスを持たないプリミティブな単一アイテム操作:
|
|
225
|
+
A write route descriptor is `{ create | update | remove: Model, key, input?, condition?, result? }`:
|
|
399
226
|
|
|
400
227
|
```ts
|
|
401
|
-
await
|
|
402
|
-
userId: '
|
|
403
|
-
name: '
|
|
404
|
-
|
|
405
|
-
status: 'active',
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
await GroupMembership.putItem({
|
|
409
|
-
groupId: 'eng',
|
|
410
|
-
userId: 'alice',
|
|
411
|
-
role: 'admin',
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
await User.updateItem({ userId: 'alice' }, { status: 'disabled' });
|
|
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' });
|
|
415
232
|
```
|
|
416
233
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
`
|
|
428
|
-
|
|
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):
|
|
429
250
|
|
|
430
251
|
```ts
|
|
431
252
|
const { user, members } = await DDBModel.query({
|
|
@@ -435,44 +256,60 @@ const { user, members } = await DDBModel.query({
|
|
|
435
256
|
// user: Item | null ; members: { items, cursor }
|
|
436
257
|
```
|
|
437
258
|
|
|
438
|
-
|
|
439
|
-
(`query` / `list` のいずれか一方)。`query` の `options` は
|
|
440
|
-
`{ consistentRead?, maxDepth? }`、`list` の場合は `{ limit?, after?, order?, filter? }` である。
|
|
259
|
+
### Raw base operations: `putItem` / `updateItem` / `deleteItem`
|
|
441
260
|
|
|
442
|
-
`
|
|
443
|
-
|
|
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`:
|
|
444
264
|
|
|
445
265
|
```ts
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}, { mode: 'transaction' });
|
|
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' });
|
|
450
269
|
```
|
|
451
270
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
271
|
+
If you need conditional gates, read-back, or referential side effects, use `DDBModel.mutate` rather than the raw primitives.
|
|
272
|
+
|
|
273
|
+
### Relation Traversal
|
|
274
|
+
|
|
275
|
+
A relation is a query bound to the value of the parent entity. Declare it with `@hasMany` / `@hasOne` /
|
|
276
|
+
`@belongsTo` and traverse it by nesting inside `select`. `hasMany` / `list` require a default / max limit.
|
|
277
|
+
Independent relation subqueries are dispatched in parallel, and N `belongsTo` resolutions are aggregated
|
|
278
|
+
into a single `BatchGetItem` (no N+1):
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
// email でユーザーを探し、所属グループ -> 各グループ本体 -> そのパーミッションへ traverse。
|
|
282
|
+
const alice = await User.query(
|
|
283
|
+
{ email: 'alice@example.com' },
|
|
284
|
+
{
|
|
285
|
+
userId: true,
|
|
286
|
+
name: true,
|
|
287
|
+
groups: {
|
|
288
|
+
select: {
|
|
289
|
+
groupId: true,
|
|
290
|
+
role: true,
|
|
291
|
+
group: { // membership -> Group (belongsTo, BatchGet)
|
|
292
|
+
select: {
|
|
293
|
+
name: true,
|
|
294
|
+
permissions: { // group -> permissions (hasMany)
|
|
295
|
+
select: { resource: true, action: true },
|
|
296
|
+
filter: { resource: { attributeExists: true } },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
limit: 20,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{ maxDepth: 3 }, // traversal はデフォルト depth=1。深い traversal は明示許可が必要。
|
|
305
|
+
);
|
|
306
|
+
```
|
|
471
307
|
|
|
472
308
|
### Inspect Execution Plans
|
|
473
309
|
|
|
474
|
-
`explain()`
|
|
475
|
-
`
|
|
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:
|
|
476
313
|
|
|
477
314
|
```ts
|
|
478
315
|
const plan = GroupMembership.explain(
|
|
@@ -484,115 +321,186 @@ const plan = GroupMembership.explain(
|
|
|
484
321
|
// type: "Query",
|
|
485
322
|
// tableName: "UserPermissions",
|
|
486
323
|
// keyCondition: { PK: "GROUP#eng" },
|
|
487
|
-
// rangeCondition: {
|
|
488
|
-
//
|
|
489
|
-
// key: "SK",
|
|
490
|
-
// value: "USER#"
|
|
491
|
-
// },
|
|
492
|
-
// limit: 10
|
|
324
|
+
// rangeCondition: { operator: "begins_with", key: "SK", value: "USER#" },
|
|
325
|
+
// limit: 10,
|
|
493
326
|
// }]
|
|
494
327
|
// }
|
|
495
328
|
```
|
|
496
329
|
|
|
497
|
-
partial
|
|
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#" }`).
|
|
498
332
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
);
|
|
504
|
-
// {
|
|
505
|
-
// operations: [{
|
|
506
|
-
// type: "Query",
|
|
507
|
-
// keyCondition: { GSI1PK: "USER#alice" },
|
|
508
|
-
// indexName: "GSI1",
|
|
509
|
-
// rangeCondition: {
|
|
510
|
-
// operator: "begins_with",
|
|
511
|
-
// key: "GSI1SK",
|
|
512
|
-
// value: "GROUP#"
|
|
513
|
-
// },
|
|
514
|
-
// ...
|
|
515
|
-
// }]
|
|
516
|
-
// }
|
|
517
|
-
```
|
|
333
|
+
### Maintained Access Paths (0.4.x / Epic #118)
|
|
334
|
+
|
|
335
|
+
Declaring a relation or scalar as a **maintained access path** keeps a *separate* row in sync with the
|
|
336
|
+
lifecycle of the source entity —— and all of it is composed into the same atomic transaction as the source write.
|
|
518
337
|
|
|
519
|
-
|
|
338
|
+
- **`pattern` on `@hasMany` / `@hasOne`** —— `embeddedSnapshot` (keeping a projected snapshot / bounded
|
|
339
|
+
collection synchronized onto the owner row), versioned, sparse, etc. Declare the read shape with `read`,
|
|
340
|
+
the cross-entity trigger with `write.maintainedOn`, and the attributes to capture with `projection`:
|
|
520
341
|
|
|
521
|
-
|
|
342
|
+
```ts
|
|
343
|
+
@hasMany(() => PostModel, { threadId: 'threadId' }, {
|
|
344
|
+
pattern: 'embeddedSnapshot',
|
|
345
|
+
read: { maxItems: 3, order: 'DESC' },
|
|
346
|
+
write: { maintainedOn: ['Post.created'] },
|
|
347
|
+
projection: { postId: 'postId', textPreview: preview('$.entity.body', 120) },
|
|
348
|
+
})
|
|
349
|
+
latestPosts!: PostModel[];
|
|
350
|
+
```
|
|
522
351
|
|
|
523
|
-
|
|
524
|
-
DynamoDB に対する、境界付けられたアクセス表面(bounded access surface)である。
|
|
352
|
+
- **`@aggregate` counter** —— keeps a scalar aggregate (`count()`, etc.) synchronized with an atomic `ADD ±1`:
|
|
525
353
|
|
|
526
|
-
|
|
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
|
+
```
|
|
527
362
|
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
- relation
|
|
531
|
-
- projection
|
|
532
|
-
- traversal ルール
|
|
363
|
+
- **`@model({ kind })` + `@maintainedFrom`** —— declare a view as *its own model* and bind each source
|
|
364
|
+
with a class-level `@maintainedFrom` (`materializedView` / `sparseView`).
|
|
533
365
|
|
|
534
|
-
|
|
535
|
-
|
|
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:
|
|
369
|
+
[embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) /
|
|
370
|
+
[aggregate-counter](./examples/aggregate-counter/).
|
|
536
371
|
|
|
537
|
-
###
|
|
372
|
+
### Middleware / Hooks (`DDBModel.use`)
|
|
538
373
|
|
|
539
|
-
|
|
540
|
-
|
|
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**:
|
|
541
378
|
|
|
542
379
|
```ts
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
);
|
|
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 等)
|
|
551
388
|
```
|
|
552
389
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
390
|
+
`before*` runs in registration order (FIFO), `after*` / `onError` in reverse order (LIFO), so the
|
|
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).
|
|
395
|
+
|
|
396
|
+
### Retry & throttling
|
|
556
397
|
|
|
557
|
-
|
|
398
|
+
GraphDDB retries throttle / transient errors for **all** single-item operations
|
|
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).
|
|
558
403
|
|
|
559
|
-
|
|
560
|
-
|
|
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:
|
|
561
410
|
|
|
562
411
|
```ts
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
})
|
|
566
|
-
groups!: GroupMembershipModel[];
|
|
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 } });
|
|
567
415
|
```
|
|
568
416
|
|
|
569
|
-
|
|
417
|
+
## 👥 Example: User Permissions
|
|
570
418
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
DynamoDB の型の不一致を検出する。
|
|
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:
|
|
574
421
|
|
|
575
|
-
```
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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>
|
|
580
430
|
```
|
|
581
431
|
|
|
582
|
-
|
|
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.
|
|
583
454
|
|
|
584
|
-
|
|
585
|
-
両方を制御する。
|
|
586
|
-
要求したフィールドだけが返り、結果の型にもそのフィールドだけが現れる。
|
|
455
|
+
At its core is the natural correspondence between GraphQL's query model and DynamoDB's access patterns:
|
|
587
456
|
|
|
588
|
-
|
|
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).
|
|
589
475
|
|
|
590
|
-
|
|
591
|
-
|
|
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.
|
|
592
479
|
|
|
593
|
-
|
|
480
|
+
### Design Rules
|
|
594
481
|
|
|
595
|
-
|
|
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:
|
|
596
504
|
|
|
597
505
|
```mermaid
|
|
598
506
|
flowchart TD
|
|
@@ -609,127 +517,76 @@ flowchart TD
|
|
|
609
517
|
L5 -->|Partial Entity インスタンス| OUT(["✅ 型付き結果"])
|
|
610
518
|
```
|
|
611
519
|
|
|
612
|
-
## 📐 Design Rules
|
|
613
|
-
|
|
614
|
-
| ルール | 内容 |
|
|
615
|
-
|------|-------------|
|
|
616
|
-
| Service Boundary First | 1テーブルは1エンティティではなく、1つのサービス境界を表す。 |
|
|
617
|
-
| Access Pattern First | テーブルや ORM 風のエンティティをモデル化する前に、DynamoDB のアクセスパターンをモデル化する。 |
|
|
618
|
-
| Natural Mapping | Query / Get / BatchGet / Projection / cursor を GraphQL ライクなクエリの形にマップする。 |
|
|
619
|
-
| Inspectable Plans | DynamoDB の操作と実行計画は、実行前に検査可能である。 |
|
|
620
|
-
| Single-item query | `query` は PK または unique GSI の lookup のみ。複数件返りうるキーには `list` を使う。 |
|
|
621
|
-
| Explicit limits | `hasMany` と `list` は default/max limit の指定が必須。 |
|
|
622
|
-
| Bounded depth | Relation traversal はデフォルトで depth=1。より深い traversal は `maxDepth` で明示的に許可する必要がある。 |
|
|
623
|
-
| Parallel relations | 独立した relation のサブクエリ、BatchGet のチャンク、ネストした解決は並列にディスパッチされる(上限あり)。DynamoDB は HTTP ベースのため、直列化すべきコネクションが存在しない。 |
|
|
624
|
-
| No Scan | Full table scan は禁止。linter が検出・ブロックする。 |
|
|
625
|
-
| Partial select | クエリ結果は partial 型。select したフィールドのみが結果の型に現れる。 |
|
|
626
|
-
| Structured keys | キーは `k` segment テンプレートから構築され、partial key は segment 境界で `begins_with` にコンパイルされる。 |
|
|
627
|
-
| Typed consistentRead | `consistentRead` は PK クエリでのみ利用可能。GSI クエリはコンパイル時に拒否される。 |
|
|
628
|
-
| Server-side filter | `filter` は宣言的な DynamoDB `FilterExpression`(AppSync `ModelFilterInput` 互換)で、エンティティ全体に対して型付けされ、projection されていない属性も参照できる。RCU は削減せず、`limit` はその前に適用される。 |
|
|
629
|
-
|
|
630
|
-
## 🚧 Architectural Boundaries
|
|
631
|
-
|
|
632
|
-
Relation traversal は、Entity 上に定義されたアクセスパスのみをたどる。
|
|
633
|
-
未定義の relation、欠けている GSI、limit のない list、許可された depth を超える traversal は、
|
|
634
|
-
実行前にチェックされる。
|
|
635
|
-
|
|
636
|
-
これにより、境界をまたぐクエリや DynamoDB に不向きなアクセスパターンが、コード上で見えやすくなる。
|
|
637
|
-
コードが人間によって書かれたものであれ、AI によって生成されたものであれ、同じアーキテクチャ上の
|
|
638
|
-
制約が適用される。
|
|
639
|
-
|
|
640
|
-
## 🔍 Linter
|
|
641
|
-
|
|
642
|
-
静的解析が、runtime の前に Entity 定義の問題を検出する:
|
|
643
|
-
|
|
644
|
-
- Scan の使用
|
|
645
|
-
- `hasMany` / `list` での limit 未指定
|
|
646
|
-
- 曖昧な GSI 解決
|
|
647
|
-
- unique GSI に対する query/list の境界違反
|
|
648
|
-
|
|
649
520
|
## 🧱 Beyond the Core
|
|
650
521
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
- **[CQRS contracts](./docs/cqrs-contract.md)** —— N+1
|
|
654
|
-
|
|
655
|
-
- **[Mutation → command derivation](./docs/mutation-command-derivation.md)** ——
|
|
656
|
-
Command IF
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
`
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
渡すと、read 結果をデフォルトの Typed Plain Object ではなく、ホスト言語のドメインオブジェクトに
|
|
668
|
-
ロードできる。ホスト専用で、bridge SSoT にシリアライズされることはない。Phase 1
|
|
669
|
-
(`query` のトップレベル)は実装済み。`list` / relation ごとの hydration は今後の予定。
|
|
670
|
-
- **[Multi-language (Python bridge)](./docs/python-bridge.md)** —— TypeScript が単一の真実の源
|
|
671
|
-
であり、同じ定義から Python クライアント + runtime を生成し、TS↔Python の conformance スイートで
|
|
672
|
-
歩調を合わせる。
|
|
673
|
-
- **[In-memory testing](./docs/testing.md)** —— `graphddb/testing` は model-mapping、query-plan、
|
|
674
|
-
relation-traversal、CDC のテストを **Docker なしの in-process** で実行する。
|
|
675
|
-
- **[CDC emulator](./docs/cdc-emulator.md)** —— ローカルでの差分集計を駆動・テストするための、
|
|
676
|
-
DynamoDB Streams 相当の変更イベント。
|
|
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.
|
|
677
538
|
|
|
678
539
|
## 📚 Documentation
|
|
679
540
|
|
|
680
|
-
|
|
|
541
|
+
| Document | Description |
|
|
681
542
|
|----------|-------------|
|
|
682
|
-
| [Specification](./docs/spec.md) |
|
|
683
|
-
| [Design patterns](./docs/design-patterns.md) |
|
|
684
|
-
| [
|
|
685
|
-
| [
|
|
686
|
-
| [
|
|
687
|
-
| [
|
|
688
|
-
| [
|
|
689
|
-
| [
|
|
690
|
-
| [
|
|
543
|
+
| [Specification](./docs/spec.md) | Core API: entities, structured keys/GSIs, query/filter, relations, batch/transaction, design rules, runtime behavior. |
|
|
544
|
+
| [Design patterns](./docs/design-patterns.md) | Mapping representative DynamoDB design patterns onto graphddb features (how to declare `pattern` / `@model({ kind })` + `@maintainedFrom` / `@aggregate`, read & write maintenance, per-phase coverage). |
|
|
545
|
+
| [Middleware / Hooks](./docs/middleware.md) | The `DDBModel.use` host-side middleware: hook points read R1–R5 / write W1–W5, `{ context }`, ordering. |
|
|
546
|
+
| [CQRS contract layer](./docs/cqrs-contract.md) | Public Query/Command contracts, cardinality matrix, N+1 safety, composition across contracts, context boundaries. |
|
|
547
|
+
| [Mutation → command derivation](./docs/mutation-command-derivation.md) | The internal write-plan composition DSL behind the Command IF (`entityWrites`, fragment composition, derivation of atomic `TransactWriteItems`). |
|
|
548
|
+
| [Class hydration](./docs/class-hydration.md) | Opt-in `options.hydrate` that loads read results into host objects (Phase 1: `query` top level). |
|
|
549
|
+
| [Multi-language / Python bridge](./docs/python-bridge.md) | Code generation with TS as the SSoT and a Python runtime; TS↔Python conformance. |
|
|
550
|
+
| [In-memory test adapter](./docs/testing.md) | Docker-free in-process testing with `graphddb/testing` and `MemoryInspector`. |
|
|
551
|
+
| [CDC emulator](./docs/cdc-emulator.md) | A change-event emulator for differential aggregation patterns (dev/test). |
|
|
552
|
+
| [Benchmark](./benchmark/RESULTS.md) | Library comparison against hand-written AWS SDK, ElectroDB, DynamoDB Toolbox, and OneTable. |
|
|
691
553
|
|
|
692
554
|
## 💡 Examples
|
|
693
555
|
|
|
694
|
-
| Example |
|
|
556
|
+
| Example | Description |
|
|
695
557
|
|---------|-------------|
|
|
696
|
-
| [user-permissions](./examples/user-permissions/) | Single Table Design
|
|
697
|
-
| [aggregate-tree-pattern](./examples/aggregate-tree-pattern/) | CDC emulator
|
|
698
|
-
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) |
|
|
699
|
-
| [aggregate-counter](./examples/aggregate-counter/) | RFC §5.2
|
|
558
|
+
| [user-permissions](./examples/user-permissions/) | Manages users, groups, and permissions with Single Table Design. Includes adjacency lists, inverted indexes, relation traversal, and `explain`. |
|
|
559
|
+
| [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 (Epic #118): `pattern: 'embeddedSnapshot'` keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
|
|
561
|
+
| [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. |
|
|
700
562
|
|
|
701
563
|
## ✨ Features
|
|
702
564
|
|
|
703
|
-
- Entity
|
|
704
|
-
- DDBModel
|
|
705
|
-
-
|
|
706
|
-
-
|
|
707
|
-
|
|
708
|
-
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
-
|
|
713
|
-
-
|
|
714
|
-
-
|
|
715
|
-
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
-
|
|
719
|
-
-
|
|
720
|
-
-
|
|
721
|
-
- Batch
|
|
722
|
-
- CQRS Query/Command contract
|
|
723
|
-
- Mutation → command derivation
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
-
|
|
727
|
-
|
|
728
|
-
—— [docs](./docs/class-hydration.md) を参照
|
|
729
|
-
- Multi-language: TS を SSoT とした Python コード生成器 + runtime、TS↔Python の conformance 付き
|
|
730
|
-
—— [docs](./docs/python-bridge.md) を参照
|
|
731
|
-
- In-memory test adapter(`graphddb/testing`): Docker 不要のユニットテスト —— [docs](./docs/testing.md) を参照
|
|
732
|
-
- CDC emulator: dev/test 向けの DynamoDB Streams 相当の変更イベント —— [docs](./docs/cdc-emulator.md) を参照
|
|
565
|
+
- Entity metadata / decorators (`@model({ description })` propagates into the manifest / Python docstrings)
|
|
566
|
+
- DDBModel base class / connection management
|
|
567
|
+
- Type system (SelectableOf, QueryResult, QueryKeyOf)
|
|
568
|
+
- Read: `query` (PK / unique GSI single lookup) / `list` (cursor pagination), planner / executor / hydrator
|
|
569
|
+
- Write: `DDBModel.mutate` unified envelope (transaction / parallel, atomic composition of multiple fragments,
|
|
570
|
+
key-array bulk, `result` read-back) as the primary path, with raw `putItem` / `updateItem` / `deleteItem`
|
|
571
|
+
as secondary base operations
|
|
572
|
+
- Type-safe filtering: declarative server-side `filter` (FilterExpression / AppSync `ModelFilterInput`
|
|
573
|
+
compatible), `Model.col` column references and the `cond` raw escape hatch (shared by read filter / write `condition`)
|
|
574
|
+
- Structured segment keys (`k` tag): a partial key compiles to `begins_with` at a segment boundary
|
|
575
|
+
- Explainable execution plans (`explain`)
|
|
576
|
+
- Relations (hasMany, belongsTo, hasOne, depth limits, parallel traversal, N+1-safe BatchGet)
|
|
577
|
+
- Maintained access paths (Epic #118): `pattern: 'embeddedSnapshot'` relations, `@aggregate` `count()` counters
|
|
578
|
+
(atomic `ADD ±1`), `@model({ kind })` + `@maintainedFrom`. Synchronized maintenance (`updateMode: 'mutation'`)
|
|
579
|
+
works in both TS / Python (conformance-verified). stream / `max()` / `materializedView` / `sparseView` are later
|
|
580
|
+
- Middleware / Hooks (`DDBModel.use`): host-side hooks read R1–R5 / write W1–W5, `{ context }` —— non-serialized
|
|
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)
|
|
733
590
|
|
|
734
591
|
## 📄 License
|
|
735
592
|
|