graphddb 0.1.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 +553 -0
- package/dist/chunk-347U24SB.js +1818 -0
- package/dist/chunk-6LEHSX45.js +4276 -0
- package/dist/chunk-UNRQ5YJT.js +461 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +890 -0
- package/dist/index.d.ts +5309 -0
- package/dist/index.js +2887 -0
- package/dist/testing/index.d.ts +182 -0
- package/dist/testing/index.js +898 -0
- package/dist/types-CDrWiPxp.d.ts +1203 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# GraphDDB
|
|
2
|
+
|
|
3
|
+
**Type-safe Graph Query Runtime for DynamoDB**
|
|
4
|
+
|
|
5
|
+
GraphDDB maps DynamoDB's native access patterns to a GraphQL-like query model:
|
|
6
|
+
PK, GSI, Projection, Relation, Cursor, and operation limits all remain visible.
|
|
7
|
+
|
|
8
|
+
GraphDDB is not an ORM.
|
|
9
|
+
It is a runtime for defining, validating, and executing DynamoDB access patterns with TypeScript types.
|
|
10
|
+
|
|
11
|
+
## Why GraphDDB?
|
|
12
|
+
|
|
13
|
+
DynamoDB is fast and scalable, but easy to design incorrectly. Teams often treat it like an
|
|
14
|
+
RDBMS — one table per entity, joins in application code, ad-hoc access patterns hidden behind
|
|
15
|
+
ORM abstractions — and end up with unstable performance, unnecessary GSIs, and unclear service
|
|
16
|
+
boundaries.
|
|
17
|
+
|
|
18
|
+
GraphDDB takes the opposite approach: it represents DynamoDB access patterns **directly in
|
|
19
|
+
code**, keeping PK/GSI, projections, relations, cursors, and operation limits visible. Access
|
|
20
|
+
patterns and relations become explicit, query plans become inspectable, and well-designed
|
|
21
|
+
access patterns naturally align with service boundaries. GraphDDB does not teach DynamoDB
|
|
22
|
+
design — it makes correct design the easiest way to write code.
|
|
23
|
+
|
|
24
|
+
It does this through a natural correspondence between GraphQL's query model and DynamoDB's
|
|
25
|
+
access patterns:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
GraphQL Query Model DynamoDB
|
|
29
|
+
arguments ----> PK / GSI lookup
|
|
30
|
+
selection ----> ProjectionExpression
|
|
31
|
+
relation ----> Query / Get / BatchGet
|
|
32
|
+
connection ----> Cursor pagination
|
|
33
|
+
complexity ----> Operation limits
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A partition key is a graph entry point, a relation an explicit traversal path, a projection a
|
|
37
|
+
selection set. GraphDDB does not invent a query language — it executes well-designed access
|
|
38
|
+
patterns directly and makes ambiguity visible through plans, limits, and relation resolution.
|
|
39
|
+
|
|
40
|
+
## User Permissions Example
|
|
41
|
+
|
|
42
|
+
The [user-permissions](./examples/user-permissions/) example models users, groups, memberships, and permissions in a single DynamoDB table.
|
|
43
|
+
This is where GraphDDB's design becomes easiest to see: the table is physical, while the query surface is graph-shaped.
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
UserPermissions table
|
|
47
|
+
|
|
48
|
+
Entity PK SK GSI1PK GSI1SK
|
|
49
|
+
Group GROUP#<groupId> META
|
|
50
|
+
User USER#<userId> PROFILE EMAIL#<email> PROFILE
|
|
51
|
+
GroupMembership GROUP#<groupId> USER#<userId> USER#<userId> GROUP#<groupId>
|
|
52
|
+
Permission GROUP#<groupId> PERM#<resource>#<action>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`GroupMembership` uses the adjacency list pattern:
|
|
56
|
+
|
|
57
|
+
- `PK/SK` reads members of a group
|
|
58
|
+
- `GSI1PK/GSI1SK` reads groups for a user
|
|
59
|
+
|
|
60
|
+
The same physical table supports both directions without hiding the keys.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const engGroup = await Group.query(
|
|
64
|
+
{ groupId: 'eng' },
|
|
65
|
+
{
|
|
66
|
+
groupId: true,
|
|
67
|
+
name: true,
|
|
68
|
+
members: {
|
|
69
|
+
select: { userId: true, role: true },
|
|
70
|
+
limit: 20,
|
|
71
|
+
},
|
|
72
|
+
permissions: {
|
|
73
|
+
select: { resource: true, action: true },
|
|
74
|
+
limit: 20,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Find a user by email (GSI), list their groups, and for each group fetch the
|
|
80
|
+
// group and its permissions. The N memberships -> parent Group resolution
|
|
81
|
+
// collapses into a single BatchGetItem (no N+1).
|
|
82
|
+
const alice = await User.query(
|
|
83
|
+
{ email: 'alice@example.com' },
|
|
84
|
+
{
|
|
85
|
+
userId: true,
|
|
86
|
+
name: true,
|
|
87
|
+
groups: {
|
|
88
|
+
select: {
|
|
89
|
+
groupId: true,
|
|
90
|
+
role: true,
|
|
91
|
+
group: { // membership -> Group (belongsTo, BatchGet)
|
|
92
|
+
select: {
|
|
93
|
+
name: true,
|
|
94
|
+
permissions: { // group -> permissions (hasMany)
|
|
95
|
+
select: { resource: true, action: true },
|
|
96
|
+
filter: { resource: { attributeExists: true } },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
limit: 20,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{ maxDepth: 3 },
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The first query expands into a small, inspectable DynamoDB plan:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
1. Query PK = GROUP#eng, SK = META -> Group
|
|
112
|
+
2. Query PK = GROUP#eng, SK begins_with USER# -> members ┐ dispatched
|
|
113
|
+
3. Query PK = GROUP#eng, SK begins_with PERM# -> permissions ┘ in parallel
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Independent relation sub-queries (here `members` and `permissions`) are dispatched concurrently; the `belongsTo` resolution in the second query batches N lookups into one `BatchGetItem`. See the [example](./examples/user-permissions/) for a timeline that proves the parallel dispatch.
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
The snippets below are compressed from the [user-permissions](./examples/user-permissions/) example.
|
|
121
|
+
|
|
122
|
+
### Entity Definition
|
|
123
|
+
|
|
124
|
+
A TypeScript class is the single source of truth. Keys and GSIs are built from `k` tagged-template
|
|
125
|
+
**segments**, and the key builder's input type becomes the query parameter type.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const TABLE = 'UserPermissions';
|
|
129
|
+
|
|
130
|
+
@model({ table: TABLE, prefix: 'USER' })
|
|
131
|
+
class UserModel extends DDBModel {
|
|
132
|
+
static readonly keys = key<{ userId: string }>((c) => ({
|
|
133
|
+
pk: k`USER#${c.userId}`,
|
|
134
|
+
sk: k`PROFILE`,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
static readonly emailIndex = gsi<{ email: string }>(
|
|
138
|
+
'GSI1',
|
|
139
|
+
(c) => ({ pk: k`EMAIL#${c.email}`, sk: k`PROFILE` }),
|
|
140
|
+
{ unique: true },
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
@string userId!: string;
|
|
144
|
+
@string name!: string;
|
|
145
|
+
@string email!: string;
|
|
146
|
+
@string status!: string;
|
|
147
|
+
|
|
148
|
+
@hasMany(() => GroupMembershipModel, { userId: 'userId' }, {
|
|
149
|
+
limit: { default: 20, max: 100 },
|
|
150
|
+
})
|
|
151
|
+
groups!: GroupMembershipModel[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@model({ table: TABLE, prefix: 'GROUP' })
|
|
155
|
+
class GroupMembershipModel extends DDBModel {
|
|
156
|
+
static readonly keys = key<{ groupId: string; userId: string }>((c) => ({
|
|
157
|
+
pk: k`GROUP#${c.groupId}`,
|
|
158
|
+
sk: k`USER#${c.userId}`,
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
static readonly userGroupsIndex = gsi<{ userId: string; groupId: string }>(
|
|
162
|
+
'GSI1',
|
|
163
|
+
(c) => ({ pk: k`USER#${c.userId}`, sk: k`GROUP#${c.groupId}` }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
@string groupId!: string;
|
|
167
|
+
@string userId!: string;
|
|
168
|
+
@string role!: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const User = UserModel.asModel();
|
|
172
|
+
export const GroupMembership = GroupMembershipModel.asModel();
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A multi-segment sort key is an array of segments (e.g. `sk: [k\`PERM#${c.resource}\`, k\`${c.action}\`]`);
|
|
176
|
+
a partial key compiles to `begins_with` at the matching segment boundary. See the
|
|
177
|
+
[specification](./docs/spec.md) for the full key model.
|
|
178
|
+
|
|
179
|
+
### GraphQL-like Queries
|
|
180
|
+
|
|
181
|
+
The first argument describes how to find data.
|
|
182
|
+
The second argument describes what to return.
|
|
183
|
+
The runtime resolves which index to use from the provided field names.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Fetch a user by email and traverse to group memberships.
|
|
187
|
+
const alice = await User.query(
|
|
188
|
+
{ email: 'alice@example.com' },
|
|
189
|
+
{
|
|
190
|
+
userId: true,
|
|
191
|
+
name: true,
|
|
192
|
+
groups: {
|
|
193
|
+
select: { groupId: true, role: true },
|
|
194
|
+
limit: 20,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Unknown key field -> compile-time error
|
|
200
|
+
User.query({ foo: 'bar' }, { name: true });
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The return type is inferred from `select`:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// alice: {
|
|
207
|
+
// name: string;
|
|
208
|
+
// groups: {
|
|
209
|
+
// items: { groupId: string; role: string }[];
|
|
210
|
+
// cursor: string | null;
|
|
211
|
+
// };
|
|
212
|
+
// }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### List Queries
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
const result = await GroupMembership.list(
|
|
219
|
+
{ groupId: 'eng' },
|
|
220
|
+
{
|
|
221
|
+
select: { userId: true, role: true },
|
|
222
|
+
limit: 20,
|
|
223
|
+
after: cursor,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
// result.items: GroupMembership[]
|
|
227
|
+
// result.cursor: string | null
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Filtering (server-side `filter`)
|
|
231
|
+
|
|
232
|
+
`filter` is a declarative, type-safe condition compiled to a DynamoDB **`FilterExpression`** and
|
|
233
|
+
evaluated **server-side**. Modeled on AWS AppSync's `ModelFilterInput`: a bare value is an
|
|
234
|
+
equality shorthand, an operator object expresses comparisons, and `and` / `or` / `not` build
|
|
235
|
+
logical groups. It is typed against the **full entity**, so it can reference attributes that are
|
|
236
|
+
**not** in `select`.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const result = await Order.list(
|
|
240
|
+
{ userId: 'u001' },
|
|
241
|
+
{
|
|
242
|
+
select: { orderId: true }, // amount / status need not be projected
|
|
243
|
+
filter: {
|
|
244
|
+
status: 'confirmed', // #status = :v (equality shorthand)
|
|
245
|
+
amount: { gt: 100 }, // #amount > :v
|
|
246
|
+
title: { beginsWith: 'A' }, // begins_with(#title, :v)
|
|
247
|
+
shippedAt: { attributeExists: true },
|
|
248
|
+
or: [{ status: 'pending' }, { amount: { lt: 10 } }],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Relations take the same `filter` via `Model.relation(...)` of the target model. For logic a
|
|
255
|
+
server-side `FilterExpression` cannot express, call `result.items.filter(...)` on the returned
|
|
256
|
+
projection — it keeps each selected field's declared scalar type, so `o.amount > 100` needs no
|
|
257
|
+
cast (there is no built-in post-load predicate).
|
|
258
|
+
|
|
259
|
+
Available operators (per field type): `eq`, `ne`, `ge`, `le`, `gt`, `lt`,
|
|
260
|
+
`between`, `in`, `beginsWith`, `contains`, `notContains`, `attributeExists`,
|
|
261
|
+
`attributeType`, `size`. Operators are **constrained by the field's declared
|
|
262
|
+
type** — e.g. `beginsWith` / `contains` on a numeric field, or `gt: 'x'` on a
|
|
263
|
+
number, are compile errors.
|
|
264
|
+
|
|
265
|
+
For rare conditions the operator objects cannot express, the `cond` raw escape
|
|
266
|
+
hatch accepts an expression fragment. Column names must use refactor-safe
|
|
267
|
+
`Model.col.<field>` references (never bare strings); values are parameterized:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
filter: cond`${Order.col.amount} > ${100} AND attribute_exists(${Order.col.status})`
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
All values are parameterized (`ExpressionAttributeValues`) and all column names
|
|
274
|
+
are aliased (`ExpressionAttributeNames`) — there is no literal interpolation, so
|
|
275
|
+
the compiled expression is injection-free, and it is attached independently of
|
|
276
|
+
the `KeyConditionExpression` / `ProjectionExpression`.
|
|
277
|
+
|
|
278
|
+
> **RCU note:** A `FilterExpression` does **not** reduce read capacity —
|
|
279
|
+
> DynamoDB reads matching keys first, then filters. `limit` is applied
|
|
280
|
+
> **before** the filter, so a single page may return **fewer** than `limit`
|
|
281
|
+
> items (and, if the whole page is filtered out, an empty page with a non-null
|
|
282
|
+
> cursor). Design keys for efficient narrowing; use `filter` for correctness.
|
|
283
|
+
|
|
284
|
+
### Writes
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
await User.put({
|
|
288
|
+
userId: 'alice',
|
|
289
|
+
name: 'Alice',
|
|
290
|
+
email: 'alice@example.com',
|
|
291
|
+
status: 'active',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await GroupMembership.put({
|
|
295
|
+
groupId: 'eng',
|
|
296
|
+
userId: 'alice',
|
|
297
|
+
role: 'admin',
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await User.update(alice, { status: 'disabled' });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Inspect Execution Plans
|
|
304
|
+
|
|
305
|
+
`explain()` shows the DynamoDB operations before they are executed.
|
|
306
|
+
`select` is converted into a `ProjectionExpression`, so only requested attributes are read.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const plan = GroupMembership.explain(
|
|
310
|
+
{ groupId: 'eng' },
|
|
311
|
+
{ select: { userId: true, role: true }, limit: 10 },
|
|
312
|
+
);
|
|
313
|
+
// {
|
|
314
|
+
// operations: [{
|
|
315
|
+
// type: "Query",
|
|
316
|
+
// tableName: "UserPermissions",
|
|
317
|
+
// keyCondition: { PK: "GROUP#eng" },
|
|
318
|
+
// rangeCondition: {
|
|
319
|
+
// operator: "begins_with",
|
|
320
|
+
// key: "SK",
|
|
321
|
+
// value: "USER#"
|
|
322
|
+
// },
|
|
323
|
+
// limit: 10
|
|
324
|
+
// }]
|
|
325
|
+
// }
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
For partial GSI key matches, the SK condition is separated as a `rangeCondition`:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
const gsiPlan = GroupMembership.explain(
|
|
332
|
+
{ userId: 'alice' },
|
|
333
|
+
{ select: { groupId: true, role: true }, limit: 10 },
|
|
334
|
+
);
|
|
335
|
+
// {
|
|
336
|
+
// operations: [{
|
|
337
|
+
// type: "Query",
|
|
338
|
+
// keyCondition: { GSI1PK: "USER#alice" },
|
|
339
|
+
// indexName: "GSI1",
|
|
340
|
+
// rangeCondition: {
|
|
341
|
+
// operator: "begins_with",
|
|
342
|
+
// key: "GSI1SK",
|
|
343
|
+
// value: "GROUP#"
|
|
344
|
+
// },
|
|
345
|
+
// ...
|
|
346
|
+
// }]
|
|
347
|
+
// }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Core Concepts
|
|
351
|
+
|
|
352
|
+
### Entity = Access Pattern
|
|
353
|
+
|
|
354
|
+
An Entity is not an ORM model.
|
|
355
|
+
It is a bounded access surface for DynamoDB.
|
|
356
|
+
|
|
357
|
+
An Entity defines every supported way to access data within a service boundary:
|
|
358
|
+
|
|
359
|
+
- physical keys
|
|
360
|
+
- GSIs
|
|
361
|
+
- relations
|
|
362
|
+
- projections
|
|
363
|
+
- traversal rules
|
|
364
|
+
|
|
365
|
+
Physically, an Entity maps to a DynamoDB item.
|
|
366
|
+
Architecturally, it defines the allowed access surface for that part of the service.
|
|
367
|
+
|
|
368
|
+
### Key / GSI = Structured Segments
|
|
369
|
+
|
|
370
|
+
`key()` and `gsi()` build keys from `k` tagged-template **segments**. The builder's input type
|
|
371
|
+
defines the fields required to resolve the key, and also becomes the query parameter type.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// One definition serves two roles:
|
|
375
|
+
// 1. Write path: generate PK/SK when saving an Entity
|
|
376
|
+
// 2. Query path: define the parameter type for User.query({ email: ... })
|
|
377
|
+
static readonly emailIndex = gsi<{ email: string }>(
|
|
378
|
+
'GSI1',
|
|
379
|
+
(c) => ({ pk: k`EMAIL#${c.email}`, sk: k`PROFILE` }),
|
|
380
|
+
{ unique: true },
|
|
381
|
+
);
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Because keys are structured segments rather than opaque strings, a partial key — one supplying
|
|
385
|
+
only a leading prefix of the sort-key segments — compiles to `begins_with` at that segment
|
|
386
|
+
boundary. This is how relations and prefix queries resolve.
|
|
387
|
+
|
|
388
|
+
### Relation = Bound Query
|
|
389
|
+
|
|
390
|
+
A Relation is a query bound to values from the parent Entity.
|
|
391
|
+
The same resolution logic applies: the runtime matches field names against Key / GSI definitions.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
@hasMany(() => GroupMembershipModel, { userId: 'userId' }, {
|
|
395
|
+
limit: { default: 20, max: 100 },
|
|
396
|
+
})
|
|
397
|
+
groups!: GroupMembershipModel[];
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Field Decorators
|
|
401
|
+
|
|
402
|
+
Type-safe semantic decorators define DynamoDB attribute types.
|
|
403
|
+
Compile-time checks based on `ClassFieldDecoratorContext` detect mismatches between declared TypeScript types and DynamoDB types.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
@string userId!: string; // DynamoDB S
|
|
407
|
+
@number amount!: number; // DynamoDB N
|
|
408
|
+
@datetime createdAt!: Date; // stored as an ISO 8601 string
|
|
409
|
+
@boolean isActive!: boolean; // DynamoDB BOOL
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Select = Projection + Type Inference
|
|
413
|
+
|
|
414
|
+
The `select` object controls both DynamoDB `ProjectionExpression` generation and TypeScript return type inference.
|
|
415
|
+
Only requested fields are returned, and only those fields appear in the result type.
|
|
416
|
+
|
|
417
|
+
### Planner = Inspectable Execution Plan
|
|
418
|
+
|
|
419
|
+
Before DynamoDB is accessed, the runtime generates an execution plan.
|
|
420
|
+
Plans can be used for tests, debugging, and RCU estimation.
|
|
421
|
+
|
|
422
|
+
## Architecture
|
|
423
|
+
|
|
424
|
+
```text
|
|
425
|
+
Layer 1: Schema Definition
|
|
426
|
+
Entity / Field / Key / GSI / Relation / Embedded
|
|
427
|
+
-> EntityMetadata Registry
|
|
428
|
+
|
|
429
|
+
Layer 2: Query Builder
|
|
430
|
+
Type-safe DSL: key resolution, select validation
|
|
431
|
+
-> ExecutionPlan
|
|
432
|
+
|
|
433
|
+
Layer 3: Query Planner
|
|
434
|
+
Relation traversal, Get vs Query vs BatchGet decisions
|
|
435
|
+
Projection generation, GSI selection
|
|
436
|
+
-> DynamoDBOperation[]
|
|
437
|
+
|
|
438
|
+
Layer 4: Executor
|
|
439
|
+
DynamoDB DocumentClient wrapper
|
|
440
|
+
-> raw Items
|
|
441
|
+
|
|
442
|
+
Layer 5: Hydrator
|
|
443
|
+
raw Items -> Partial Entity instances
|
|
444
|
+
type conversion, Embedded reconstruction, metadata attachment
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
## Design Rules
|
|
448
|
+
|
|
449
|
+
| Rule | Description |
|
|
450
|
+
|------|-------------|
|
|
451
|
+
| Service Boundary First | One table represents one service boundary, not one entity. |
|
|
452
|
+
| Access Pattern First | Model DynamoDB access patterns before modeling tables or ORM-style entities. |
|
|
453
|
+
| Natural Mapping | Map Query / Get / BatchGet / Projection / cursor to a GraphQL-like query shape. |
|
|
454
|
+
| Inspectable Plans | DynamoDB operations and execution plans are inspectable before execution. |
|
|
455
|
+
| Single-item query | `query` is only for PK or unique GSI lookups. Use `list` for keys that can return multiple items. |
|
|
456
|
+
| Explicit limits | `hasMany` and `list` require default/max limits. |
|
|
457
|
+
| Bounded depth | Relation traversal defaults to depth=1. Deeper traversal must be explicitly allowed with `maxDepth`. |
|
|
458
|
+
| Parallel relations | Independent relation sub-queries, BatchGet chunks, and nested resolution are dispatched concurrently (bounded). DynamoDB is HTTP-based, so there is no connection to serialize on. |
|
|
459
|
+
| No Scan | Full table scans are forbidden. The linter detects and blocks them. |
|
|
460
|
+
| Partial select | Query results are partial types. Only selected fields appear in the result type. |
|
|
461
|
+
| Structured keys | Keys are built from `k` segment templates; partial keys compile to `begins_with` at a segment boundary. |
|
|
462
|
+
| Typed consistentRead | `consistentRead` is only available for PK queries. GSI queries are rejected at compile time. |
|
|
463
|
+
| Server-side filter | `filter` is a declarative DynamoDB `FilterExpression` (AppSync `ModelFilterInput`-compatible), typed against the full entity and able to reference unprojected attributes. It does not reduce RCU, and `limit` is applied before it. |
|
|
464
|
+
| Post-load filtering | No built-in post-load predicate; use `result.items.filter(...)` on the typed projection. Efficient narrowing belongs in key design. |
|
|
465
|
+
|
|
466
|
+
## Architectural Boundaries
|
|
467
|
+
|
|
468
|
+
Relation traversal only follows access paths defined on Entities.
|
|
469
|
+
Undefined relations, missing GSIs, lists without limits, and traversal that exceeds allowed depth are checked before execution.
|
|
470
|
+
|
|
471
|
+
This makes cross-boundary queries and DynamoDB-unfriendly access patterns easier to see in code.
|
|
472
|
+
The same architectural constraints apply regardless of whether code is written by humans or generated by AI.
|
|
473
|
+
|
|
474
|
+
## Linter
|
|
475
|
+
|
|
476
|
+
Static analysis detects problems in Entity definitions before runtime:
|
|
477
|
+
|
|
478
|
+
- Scan usage
|
|
479
|
+
- missing limits on `hasMany` / `list`
|
|
480
|
+
- ambiguous GSI resolution
|
|
481
|
+
- query/list boundary violations for unique GSIs
|
|
482
|
+
|
|
483
|
+
## Beyond the Core
|
|
484
|
+
|
|
485
|
+
The query / relation / write API above is the core layer. GraphDDB also ships:
|
|
486
|
+
|
|
487
|
+
- **[CQRS contracts](./docs/cqrs-contract.md)** — public Query/Command contract models with an
|
|
488
|
+
N+1-safe cardinality matrix, cross-contract composition, and context-boundary enforcement.
|
|
489
|
+
- **[Mutation → command derivation](./docs/mutation-command-derivation.md)** — an internal
|
|
490
|
+
write-plan composition DSL behind the public Command IF. Declare a model's write semantics
|
|
491
|
+
(`entityWrites`: referential integrity, uniqueness, edge effects, derived counters, outbox
|
|
492
|
+
events, idempotency) once; a `mutation` composes one or more write fragments that the compiler
|
|
493
|
+
merges into a single atomic `TransactWriteItems`. All write paths route through one shared
|
|
494
|
+
`commitTransaction` orchestration.
|
|
495
|
+
- **[Opt-in class hydration](./docs/class-hydration.md)** — pass an `options.hydrate` factory to
|
|
496
|
+
load a read result onto a host-language domain object instead of the default Typed Plain Object.
|
|
497
|
+
Host-only and never serialized into the bridge SSoT. Phase 1 (`query` top-level) is implemented;
|
|
498
|
+
`list` / per-relation hydration are planned future phases.
|
|
499
|
+
- **[Multi-language (Python bridge)](./docs/python-bridge.md)** — TypeScript is the single source
|
|
500
|
+
of truth; generate a Python client + runtime from the same definitions, kept in lockstep by a
|
|
501
|
+
TS↔Python conformance suite.
|
|
502
|
+
- **[In-memory testing](./docs/testing.md)** — `graphddb/testing` runs model-mapping, query-plan,
|
|
503
|
+
relation-traversal, and CDC tests **in-process, without Docker**.
|
|
504
|
+
- **[CDC emulator](./docs/cdc-emulator.md)** — DynamoDB-Streams-equivalent change events for
|
|
505
|
+
driving and testing incremental aggregation locally.
|
|
506
|
+
|
|
507
|
+
## Documentation
|
|
508
|
+
|
|
509
|
+
| Document | Description |
|
|
510
|
+
|----------|-------------|
|
|
511
|
+
| [Specification](./docs/spec.md) | Core API: entities, structured keys/GSIs, query/filter, relations, batch/transactions, design rules, runtime behavior. |
|
|
512
|
+
| [CQRS contract layer](./docs/cqrs-contract.md) | Public Query/Command contracts, cardinality matrix, N+1 safety, cross-contract composition, context boundaries. |
|
|
513
|
+
| [Mutation → command derivation](./docs/mutation-command-derivation.md) | The internal write-plan composition DSL behind the Command IF: model write-semantics (`entityWrites`), fragment composition, and atomic `TransactWriteItems` derivation. |
|
|
514
|
+
| [Class hydration](./docs/class-hydration.md) | Opt-in `options.hydrate` factory that loads a read result onto a host-language object (Phase 1: `query` top-level; Phases 2–3 future). |
|
|
515
|
+
| [Multi-language / Python bridge](./docs/python-bridge.md) | TS-as-SSoT code generation and the Python runtime; TS↔Python conformance. |
|
|
516
|
+
| [In-memory test adapter](./docs/testing.md) | Docker-free, in-process testing via `graphddb/testing` and `MemoryInspector`. |
|
|
517
|
+
| [CDC emulator](./docs/cdc-emulator.md) | Change-event emulator (dev/test) for incremental aggregation patterns. |
|
|
518
|
+
| [Benchmark](./benchmark/RESULTS.md) | Library comparison vs. hand-written AWS SDK, ElectroDB, DynamoDB Toolbox, and OneTable. |
|
|
519
|
+
|
|
520
|
+
## Examples
|
|
521
|
+
|
|
522
|
+
| Example | Description |
|
|
523
|
+
|---------|-------------|
|
|
524
|
+
| [user-permissions](./examples/user-permissions/) | Manage users, groups, and permissions with Single Table Design. Includes adjacency lists, inverted indexes, relation traversal, and `explain`. |
|
|
525
|
+
| [aggregate-tree-pattern](./examples/aggregate-tree-pattern/) | Incremental tree aggregation (`siteScoreAverage`) driven by the CDC emulator: dirty propagation, throttled sweep, and recompute. |
|
|
526
|
+
|
|
527
|
+
## Features
|
|
528
|
+
|
|
529
|
+
- Entity metadata / decorators
|
|
530
|
+
- DDBModel base class / connection management
|
|
531
|
+
- Type system (SelectableOf, QueryResult, QueryKeyOf)
|
|
532
|
+
- CRUD (put, update, delete, conditional writes)
|
|
533
|
+
- Query / List (planner, executor, hydrator, cursor pagination)
|
|
534
|
+
- Type-safe filtering: declarative server-side `filter` (FilterExpression / AppSync `ModelFilterInput`-compatible), plus `Model.col` column refs and the `cond` raw escape hatch
|
|
535
|
+
- Structured segment keys (`k` tag): partial keys compile to `begins_with` at a segment boundary
|
|
536
|
+
- Explainable execution plans
|
|
537
|
+
- Relations (hasMany, belongsTo, hasOne, depth limits, traversal)
|
|
538
|
+
- BatchGet optimization
|
|
539
|
+
- Parallel relation execution (independent sub-queries, BatchGet chunks, and nested resolution are dispatched concurrently with bounded concurrency)
|
|
540
|
+
- Linter (no-scan, require-limit, query-boundary, GSI ambiguity, expanded design rules)
|
|
541
|
+
- ConsistentRead type constraints
|
|
542
|
+
- Transaction DSL
|
|
543
|
+
- Batch operations
|
|
544
|
+
- CQRS Query/Command contract layer (public IF, N+1-safe composition, context boundaries) — see [docs](./docs/cqrs-contract.md)
|
|
545
|
+
- Mutation → command derivation: internal write-plan composition DSL (`entityWrites` + `mutation`) behind the Command IF, multi-fragment atomic composition into one `TransactWriteItems` — see [docs](./docs/mutation-command-derivation.md)
|
|
546
|
+
- Opt-in class hydration (`options.hydrate`): load a read result onto a host object; host-only, never serialized. Phase 1 (`query` top-level) implemented — see [docs](./docs/class-hydration.md)
|
|
547
|
+
- Multi-language: TS-as-SSoT Python code generator + runtime, with TS↔Python conformance — see [docs](./docs/python-bridge.md)
|
|
548
|
+
- In-memory test adapter (`graphddb/testing`): Docker-free unit testing — see [docs](./docs/testing.md)
|
|
549
|
+
- CDC emulator: DynamoDB-Streams-equivalent change events for dev/test — see [docs](./docs/cdc-emulator.md)
|
|
550
|
+
|
|
551
|
+
## License
|
|
552
|
+
|
|
553
|
+
MIT
|