graphddb 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -476
- package/dist/{chunk-BROCT574.js → chunk-7NPG5R7O.js} +1 -1
- package/dist/{chunk-CPTV3H2U.js → chunk-FMJIWFIS.js} +156 -30
- package/dist/{chunk-G5RWWBAL.js → chunk-J5A665UW.js} +23 -3
- package/dist/cli.js +781 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +20 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B-F7jw9f.d.ts} +42 -0
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cloudformation.md +294 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +6 -3
package/docs/testing.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# GraphDDB Testing — In-Memory Test Adapter (`graphddb/testing`)
|
|
2
|
+
|
|
3
|
+
GraphDDB ships an in-process test adapter that runs the full engine — planner,
|
|
4
|
+
key resolver, projection, hydrator, relation traversal, conditional/transactional
|
|
5
|
+
writes, and CDC — against an in-memory store. It requires **no DynamoDB and no
|
|
6
|
+
Docker**, so model logic, query plans, relation traversal, and change-data-capture
|
|
7
|
+
can be unit-tested in milliseconds.
|
|
8
|
+
|
|
9
|
+
This is the **recommended path for unit tests**. Integration tests that must
|
|
10
|
+
verify exact DynamoDB marshalling and engine-level behavior still run against
|
|
11
|
+
DynamoDB Local.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
unit test → graphddb/testing (in-memory) no Docker, fast
|
|
15
|
+
integration test → DynamoDB Local / dynalite
|
|
16
|
+
production verify → real DynamoDB
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Purpose and scope
|
|
20
|
+
|
|
21
|
+
The adapter is **not** a DynamoDB emulator. It is a verifier of GraphDDB's
|
|
22
|
+
*contract*: the access, projection, and change-capture operations the engine
|
|
23
|
+
emits. Concretely, it exercises:
|
|
24
|
+
|
|
25
|
+
- **Model mapping** — how each model is stored physically (`PK` / `SK` / GSI keys
|
|
26
|
+
and attributes).
|
|
27
|
+
- **Query plans** — `GetItem`, partition `Query` (PK eq + optional `SK` equality
|
|
28
|
+
or `begins_with`), GSI `Query`, and `BatchGetItem`.
|
|
29
|
+
- **Relation traversal** — `hasMany` / `belongsTo`, including nested and deep
|
|
30
|
+
selections.
|
|
31
|
+
- **Projection** — comma-separated projection expressions over dotted paths.
|
|
32
|
+
- **Conditional and transactional writes** — `ConditionExpression` evaluation and
|
|
33
|
+
all-or-nothing `TransactWrite`.
|
|
34
|
+
- **CDC** — `ChangeEvent`s (INSERT / MODIFY / REMOVE with old/new images) produced
|
|
35
|
+
by writes, when enabled.
|
|
36
|
+
|
|
37
|
+
The adapter implements exactly the DynamoDB subset GraphDDB uses — nothing more.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
GraphDDB funnels all read and write I/O through a single injectable seam, the
|
|
42
|
+
`Executor` interface (`src/executor/types.ts`). The default implementation,
|
|
43
|
+
`DynamoExecutor`, talks to real DynamoDB. Reads receive a structured
|
|
44
|
+
`DynamoDBOperation`; writes receive the structured inputs the operation layer
|
|
45
|
+
already builds (`PutInput` / `UpdateInput` / `DeleteInput`, plus batch and
|
|
46
|
+
transaction variants) — never an AWS SDK command, and never a key-condition
|
|
47
|
+
string. Because the seam sits at the *structured operation boundary*, a fake
|
|
48
|
+
needs no expression-string parser.
|
|
49
|
+
|
|
50
|
+
`createTestDB()` installs a `MemoryExecutor` (`src/memory/memory-executor.ts`)
|
|
51
|
+
over a `MemoryStore` (`src/memory/memory-store.ts`) on the `ClientManager` seam
|
|
52
|
+
via `ClientManager.setExecutor()`. Every `Model.putItem / query / list / updateItem /
|
|
53
|
+
deleteItem / batchGet` call, the planner, hydrator, and relation traversal then run
|
|
54
|
+
unchanged against the in-memory store. `db.testing.close()` (or
|
|
55
|
+
`ClientManager.resetExecutor()`) restores the default `DynamoExecutor`.
|
|
56
|
+
|
|
57
|
+
The store keeps items in their **physical** form — exactly the bytes GraphDDB
|
|
58
|
+
writes (`PK` / `SK` / `GSI*PK` / `GSI*SK` plus attributes), with no marshalling
|
|
59
|
+
and no hydration. Tables are `Map<PK, Map<SK, Item>>`; sort keys compare
|
|
60
|
+
lexicographically (byte order). GSIs are not stored separately: since GraphDDB
|
|
61
|
+
projects ALL onto its GSIs, a GSI query filters the base partition rows on their
|
|
62
|
+
`{index}PK` / `{index}SK` attributes — equivalent to a maintained ALL-projection
|
|
63
|
+
index that is trivially consistent on every write.
|
|
64
|
+
|
|
65
|
+
Only the `FilterExpression`, `UpdateExpression`, and `ConditionExpression`
|
|
66
|
+
compiled strings are interpreted (the one boundary where the engine hands the
|
|
67
|
+
executor a compiled string), by dedicated in-memory evaluators in
|
|
68
|
+
`src/memory/filter-eval.ts`, `apply-update.ts`, and `apply-condition.ts`. No
|
|
69
|
+
`ADD` / `DELETE` update clauses are interpreted because GraphDDB never emits
|
|
70
|
+
them.
|
|
71
|
+
|
|
72
|
+
## Using it
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createTestDB } from 'graphddb/testing';
|
|
76
|
+
import { User, Group, GroupMembership } from './models';
|
|
77
|
+
|
|
78
|
+
it('loads group permissions through relation traversal', async () => {
|
|
79
|
+
const db = createTestDB({ models: [User, Group, GroupMembership] });
|
|
80
|
+
try {
|
|
81
|
+
await Group.putItem({ groupId: 'eng', name: 'Engineering' });
|
|
82
|
+
await GroupMembership.putItem({ groupId: 'eng', userId: 'alice', role: 'admin', seats: 1 });
|
|
83
|
+
|
|
84
|
+
const group = await Group.query(
|
|
85
|
+
{ groupId: 'eng' },
|
|
86
|
+
{ name: true, permissions: { select: { permissionId: true, action: true } } },
|
|
87
|
+
);
|
|
88
|
+
expect(group?.name).toBe('Engineering');
|
|
89
|
+
} finally {
|
|
90
|
+
db.testing.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`createTestDB(options?)` returns a `TestDB`:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
interface TestDB {
|
|
99
|
+
testing: MemoryInspector; // inspection / fixtures / isolation
|
|
100
|
+
store: MemoryStore; // backing store (advanced use)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface CreateTestDBOptions {
|
|
104
|
+
// Models whose physical table name is addressable in the inspector by logical
|
|
105
|
+
// name, and (with cdc: true) are stream-enabled. Optional: the inspector also
|
|
106
|
+
// reflects any table the store has written to.
|
|
107
|
+
models?: (ModelStatic | Function)[];
|
|
108
|
+
// Enable the CDC emulator (record mode) over the configured models.
|
|
109
|
+
cdc?: boolean;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`createTestDB()` is the recommended entry point. For callers that wire the seam
|
|
114
|
+
manually, `graphddb/testing` also exports `MemoryExecutor` and `MemoryStore`
|
|
115
|
+
(install with `ClientManager.setExecutor(new MemoryExecutor(store))`).
|
|
116
|
+
|
|
117
|
+
Tests must call `db.testing.close()` when done (typically in `afterEach`) to
|
|
118
|
+
detach CDC and restore the default executor. `db.testing.reset()` clears the
|
|
119
|
+
store between tests.
|
|
120
|
+
|
|
121
|
+
## `MemoryInspector` API
|
|
122
|
+
|
|
123
|
+
`db.testing` is a `MemoryInspector` (`src/testing/index.ts`). It is a direct,
|
|
124
|
+
**hydration-free** view of the store for assertions, fixtures, and isolation.
|
|
125
|
+
`dump` / `dumpTable` / `scan` / `getRaw` return **physical items** — the keys and
|
|
126
|
+
attributes exactly as stored — so model mapping can be asserted without going
|
|
127
|
+
through the engine. The system-under-test still uses `Model.query` / `Model.list`.
|
|
128
|
+
|
|
129
|
+
Table arguments accept either a model's logical table name (resolved to its
|
|
130
|
+
physical name for configured models) or a physical name directly.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
interface MemoryInspector {
|
|
134
|
+
// Every physical item, keyed by physical table name.
|
|
135
|
+
dump(): Record<string, Item[]>;
|
|
136
|
+
// Every physical item in one table.
|
|
137
|
+
dumpTable(table: string): Item[];
|
|
138
|
+
// Raw physical rows, optionally filtered by PK / SK-prefix / GSI partition.
|
|
139
|
+
scan(table: string, filter?: ScanFilter): Item[];
|
|
140
|
+
// Item count for a table.
|
|
141
|
+
count(table: string): number;
|
|
142
|
+
// One physical item by base-table key, or null.
|
|
143
|
+
getRaw(table: string, key: { PK: string; SK: string }): Item | null;
|
|
144
|
+
// Insert physical items directly (fixtures).
|
|
145
|
+
seed(table: string, items: Item[]): void;
|
|
146
|
+
// Clear every table (and reset captured changes).
|
|
147
|
+
reset(): void;
|
|
148
|
+
// Structured-clone the whole store (save).
|
|
149
|
+
snapshot(): MemorySnapshot;
|
|
150
|
+
// Restore a snapshot (load).
|
|
151
|
+
restore(snapshot: MemorySnapshot): void;
|
|
152
|
+
// ChangeEvents captured so far (requires cdc: true; throws otherwise).
|
|
153
|
+
changes(): ChangeEvent[];
|
|
154
|
+
// Tear down: restore the default executor and detach CDC.
|
|
155
|
+
close(): void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ScanFilter {
|
|
159
|
+
pk?: string; // match the base-table PK exactly (or {index}PK with `index`)
|
|
160
|
+
skPrefix?: string; // match an SK prefix (or {index}SK prefix with `index`)
|
|
161
|
+
index?: string; // inspect a GSI partition instead of the base table, e.g. 'GSI1'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type Item = Record<string, unknown>;
|
|
165
|
+
type MemorySnapshot = Record<string, Item[]>;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Asserting physical layout
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await GroupMembership.putItem({ groupId: 'eng', userId: 'alice', role: 'admin', seats: 3 });
|
|
172
|
+
|
|
173
|
+
expect(db.testing.dumpTable('AppTable')).toContainEqual(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
PK: 'GROUP#eng',
|
|
176
|
+
SK: 'USER#alice',
|
|
177
|
+
GSI1PK: 'USER#alice',
|
|
178
|
+
GSI1SK: 'MEMBERSHIP',
|
|
179
|
+
role: 'admin',
|
|
180
|
+
seats: 3,
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Scan a base partition, an SK prefix, or a GSI partition.
|
|
185
|
+
db.testing.scan('AppTable', { pk: 'GROUP#eng' });
|
|
186
|
+
db.testing.scan('AppTable', { pk: 'GROUP#eng', skPrefix: 'USER#a' });
|
|
187
|
+
db.testing.scan('AppTable', { index: 'GSI1', pk: 'USER#alice' });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Fixtures and isolation
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// Seed physical items directly (bypasses the model API); they are queryable
|
|
194
|
+
// through the normal model API afterward.
|
|
195
|
+
db.testing.seed('AppTable', [
|
|
196
|
+
{ PK: 'GROUP#eng', SK: 'META', groupId: 'eng', name: 'Engineering' },
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// snapshot/restore is a deep (structured) clone — isolate state across mutations.
|
|
200
|
+
const base = db.testing.snapshot();
|
|
201
|
+
// …mutate…
|
|
202
|
+
db.testing.restore(base); // no pollution carried to the next test
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## `explain()` parity
|
|
206
|
+
|
|
207
|
+
The query planner produces an `ExecutionPlan` independently of which executor is
|
|
208
|
+
installed, so `Model.explain()` returns the **same plan** with or without the
|
|
209
|
+
memory adapter. Plans can therefore be asserted under the in-memory config:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const plan = User.explain(
|
|
213
|
+
{ userId: 'u1' },
|
|
214
|
+
{ select: { memberships: { select: { group: { select: { name: true } } } } } },
|
|
215
|
+
);
|
|
216
|
+
plan.operations.map((op) => op.type); // e.g. ['GetItem', 'Query', 'BatchGetItem', ...]
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## CDC in-memory
|
|
220
|
+
|
|
221
|
+
With `{ cdc: true }`, `createTestDB()` attaches a CDC emulator (record mode) over
|
|
222
|
+
the configured models. Writes flowing through the memory executor produce
|
|
223
|
+
`ChangeEvent`s readable via `db.testing.changes()`. The emulator derives
|
|
224
|
+
INSERT / MODIFY / REMOVE and the old/new images from each write's pre-image
|
|
225
|
+
(the executor returns `oldItem` when the caller requests `ReturnValues: ALL_OLD`).
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const db = createTestDB({ models: [Group], cdc: true });
|
|
229
|
+
try {
|
|
230
|
+
await Group.putItem({ groupId: 'eng', name: 'Engineering' });
|
|
231
|
+
|
|
232
|
+
const events = db.testing.changes();
|
|
233
|
+
expect(events[0]).toMatchObject({
|
|
234
|
+
eventName: 'INSERT',
|
|
235
|
+
keys: { pk: 'GROUP#eng', sk: 'META' },
|
|
236
|
+
});
|
|
237
|
+
expect(events[0].newImage).toMatchObject({ name: 'Engineering' });
|
|
238
|
+
} finally {
|
|
239
|
+
db.testing.close();
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`ChangeEvent` mirrors DynamoDB Streams `NEW_AND_OLD_IMAGES` (`eventName`, `table`,
|
|
244
|
+
`keys`, `oldImage`, `newImage`, `approximateCreationTime`, `sequenceNumber`,
|
|
245
|
+
`shardId`), so a consumer written against the emulator runs unchanged against
|
|
246
|
+
real Streams. Overwriting puts yield `MODIFY` with an `oldImage`; deletes yield
|
|
247
|
+
`REMOVE` with an `oldImage` and no `newImage`. Calling `changes()` without
|
|
248
|
+
`{ cdc: true }` throws.
|
|
249
|
+
|
|
250
|
+
## Fidelity non-goals
|
|
251
|
+
|
|
252
|
+
The adapter reproduces GraphDDB's contract, not DynamoDB's full behavior.
|
|
253
|
+
Anything that depends on exact DynamoDB semantics belongs in integration tests
|
|
254
|
+
(DynamoDB Local). The adapter does **not** reproduce:
|
|
255
|
+
|
|
256
|
+
- RCU/WCU, throttling, or partition splitting.
|
|
257
|
+
- TTL real-time expiry.
|
|
258
|
+
- Real DynamoDB Streams delivery (timing, shard mechanics, retry).
|
|
259
|
+
- IAM, the AWS SDK surface, or partition-size limits.
|
|
260
|
+
- Exact marshalling of floats, big numbers, and binary (the DocumentClient may
|
|
261
|
+
differ at the type boundary).
|
|
262
|
+
|
|
263
|
+
Sort keys compare as **byte-order lexicographic** strings; numeric key
|
|
264
|
+
zero-padding is the model's responsibility, matching real DynamoDB. `ADD` /
|
|
265
|
+
`DELETE` update clauses are not interpreted (GraphDDB never emits them).
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphddb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Graph data modeling on DynamoDB with adjacency list pattern",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"dist"
|
|
9
|
+
"dist",
|
|
10
|
+
"docs"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
".": {
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"test:py": "python3 -m pytest python/tests -m \"not integration\"",
|
|
37
38
|
"test:py:integration": "python3 -m pytest python/tests -m integration",
|
|
38
39
|
"test:conformance": "tsx conformance/run.ts",
|
|
40
|
+
"validate:cfn": "node scripts/validate-cfn.mjs",
|
|
39
41
|
"gen:cli": "cli-contracts generate",
|
|
40
42
|
"gen:cli:check": "cli-contracts generate --dry-run",
|
|
41
43
|
"clean": "rm -rf dist",
|
|
@@ -69,7 +71,8 @@
|
|
|
69
71
|
"tsup": "^8.0.0",
|
|
70
72
|
"tsx": "^4.22.4",
|
|
71
73
|
"typescript": "^5.5.0",
|
|
72
|
-
"vitest": "^3.0.0"
|
|
74
|
+
"vitest": "^3.0.0",
|
|
75
|
+
"yaml": "^2.9.0"
|
|
73
76
|
},
|
|
74
77
|
"engines": {
|
|
75
78
|
"node": ">=22"
|