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 CHANGED
@@ -1,26 +1,49 @@
1
1
  # GraphDDB
2
2
 
3
- **A type-safe Graph Query Runtime for DynamoDB**
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 model.
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
- It is not an ORM. It is a Runtime for **defining, validating, and executing** DynamoDB access patterns through TypeScript types.
10
+ ## Features
9
11
 
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.
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 peer dependency。使うクライアントを入れる:
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. Library consumers do not need it
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
- // クライアントは一度だけ接続。GraphDDB throttle / transient retry を所有するため
50
- // `maxAttempts: 1` にして二重 retry(SDK × ライブラリ)を避ける(後述 "Retry & throttling")。
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 —— 結果型に現れるのは select したフィールドのみ。
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 —— 複数アイテムを atomic に書く統一 envelope(デフォルト mode: 'transaction')。
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
- // 単一アイテムの raw base 操作(DynamoDB API 名に倣う)。
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
- 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).
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
- @string userId!: string; // DynamoDB S
135
- @number amount!: number; // DynamoDB N
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
- `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):
166
-
167
- ```ts
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
- ### Filtering (server-side `filter`)
102
+ ## 🔎 Inspect Execution Plans (Explain)
177
103
 
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 result = await Order.list(
185
- { userId: 'u001' },
186
- { orderId: true }, // amount / status projection 不要
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
- 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:
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
- `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`:
127
+ ## 🧪 Testing (strongly recommended)
264
128
 
265
- ```ts
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
- ```
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
- ### Relation Traversal
134
+ ## 🔗 Relation Traversal
274
135
 
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):
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: { // 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
- },
148
+ group: { select: { name: true } }, // membership -> Group (belongsTo, BatchGet)
300
149
  },
301
150
  limit: 20,
302
151
  },
303
152
  },
304
- { maxDepth: 3 }, // traversal はデフォルト depth=1。深い traversal は明示許可が必要。
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
- 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#" }`).
157
+ `hasMany` / `list` require a default / max limit. For the complete relation model, see
158
+ [`docs/spec.md`](./docs/spec.md).
332
159
 
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 —— and all of it is composed into the same atomic transaction as the source write.
337
-
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`:
341
-
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
- ```
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
- ### Middleware / Hooks (`DDBModel.use`)
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
- `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).
180
+ The runtime's responsibilities are short. For details, see [`docs/spec.md`](./docs/spec.md):
395
181
 
396
- ### Retry & throttling
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
- 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).
189
+ ## 🛠 More Capabilities
403
190
 
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).
191
+ Each capability is summarized here only. For details, operator lists, and procedures, see each doc:
407
192
 
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.
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
- ## Features
236
+ ## 🏛 Architecture
237
+
238
+ A query travels from the TS Model through the Planner / Runtime to DynamoDB:
564
239
 
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)
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