graphddb 0.4.1 → 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.
@@ -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.4.1",
3
+ "version": "0.4.3",
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
  ".": {