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,611 @@
1
+ # GraphDDB Python Bridge — Multi-Language Code Generation and Runtime
2
+
3
+ This document is the normative specification of GraphDDB's TypeScript-to-Python
4
+ bridge as implemented. TypeScript is the Single Source of Truth (SSoT): queries
5
+ and commands are defined and validated in TypeScript, a static parameterized
6
+ planner emits language-neutral JSON specifications, and `graphddb generate
7
+ python` produces typed Python repositories that a thin runtime executes against
8
+ DynamoDB via boto3. Python never re-implements the GraphDDB DSL; it executes the
9
+ access patterns that TypeScript has already defined and validated.
10
+
11
+ ## 1. Scope and Principles
12
+
13
+ - **TypeScript is the SSoT.** Entity, key, GSI, and relation models, together
14
+ with the set of permitted queries and commands, are defined only in
15
+ TypeScript. Python does not define entities, build ad-hoc queries, design
16
+ access patterns, or resolve relations dynamically.
17
+ - **Declarative subset only.** Only declarative, serializable definitions cross
18
+ the bridge: parameterized keys and items, the strict select projection,
19
+ declarative server-side filters, relation traversal, and the supported write
20
+ conditions. Arbitrary post-load TypeScript logic is out of scope — the select
21
+ API is fully declarative, and any application-side post-processing is the
22
+ caller's responsibility (`result.items.filter(...)` on the typed result),
23
+ never bridged.
24
+ - **Security boundary.** A Python caller can invoke only the queries and
25
+ commands defined in TypeScript. Undefined relation traversals, Scans,
26
+ unbounded lists, and unintended access patterns cannot be expressed.
27
+ - **No drift by construction.** A single bridge bundle (`manifest.json` +
28
+ `operations.json`) is the contract shared by every runtime, and TypeScript and
29
+ Python execution semantics are pinned together by a golden conformance suite.
30
+
31
+ ## 2. Architecture
32
+
33
+ ```text
34
+ TypeScript (design-time / build-time SSoT)
35
+ ├─ Entity / Key / GSI / Relation models
36
+ ├─ Parameterized query / command definitions (param + define*)
37
+ ├─ Static parameterized planner (symbolic key evaluation)
38
+ ├─ Bridge guard (reject non-declarative / non-serializable specs)
39
+ └─ Bridge bundle: manifest.json + operations.json
40
+ │ graphddb generate python
41
+
42
+ Python (generated bindings + runtime)
43
+ ├─ types.py (TypedDict / @dataclass result types)
44
+ ├─ repositories.py (per-entity repository classes)
45
+ ├─ __init__.py (re-exports)
46
+ └─ graphddb_runtime (hand-written thin executor)
47
+
48
+ boto3 DynamoDB client
49
+ ```
50
+
51
+ | Layer | Responsibility |
52
+ |---|---|
53
+ | TypeScript model | Entity / key / GSI / relation definitions |
54
+ | Definition DSL (`param`, `define*`) | The permitted, parameterized queries and commands |
55
+ | Static planner (`src/spec/*`) | Symbolic key evaluation → `manifest.json` / `operations.json` |
56
+ | Generator (`src/codegen/python.ts`, `src/cli/*`) | Python repositories, result types, package exports |
57
+ | Python repository | Stable application-facing interface |
58
+ | Python runtime (`graphddb_runtime`) | Interprets the JSON specs and executes via boto3 |
59
+
60
+ ## 3. TypeScript Definition DSL
61
+
62
+ ### 3.1 Parameters
63
+
64
+ A parameter is a branded placeholder standing in for a value supplied at
65
+ execution time. Placeholders are created with `param.string()`,
66
+ `param.number()`, `param.literal(...)`, and `param.array({...})`. Each carries a
67
+ TypeScript value type (preserved through the inferred definition types) and a
68
+ runtime descriptor (`kind`, optional `literals`, optional element shape) that
69
+ the planner and generator read. `param.literal('active', 'disabled')` has value
70
+ type `'active' | 'disabled'`; the literal set is preserved in the spec.
71
+
72
+ Parameters are legal only inside the structures passed to the `define*` entry
73
+ points — never to the live `Model.query` / `Model.putItem` runtime API, whose types
74
+ remain free of parameter placeholders.
75
+
76
+ ### 3.2 Definition Entry Points
77
+
78
+ Definitions are built with dedicated entry points, each accepting `param`
79
+ placeholders at scalar leaves while checking field names, value types, the key
80
+ boundary, and the strict select against the model:
81
+
82
+ - `defineQuery(Model, key, select)` — single-item read (unique key).
83
+ - `defineList(Model, key, select)` — partition read (partial / partition key).
84
+ - `definePut(Model, item, options?)` — put.
85
+ - `defineUpdate(Model, key, changes, options?)` — update.
86
+ - `defineDelete(Model, key, options?)` — delete.
87
+
88
+ Write entry points accept an optional `{ condition }` (see §6.2). Each entry
89
+ point collects its placeholders into a `name → ParamDescriptor` map; a parameter
90
+ name used twice with conflicting types is rejected at definition time.
91
+
92
+ > Design note: the entry points are dedicated functions (not `Model.query(...)`
93
+ > overloads) so that the live runtime model API is never widened to accept
94
+ > parameter placeholders.
95
+
96
+ ### 3.3 Grouping
97
+
98
+ `defineQueries({ ... })` groups read definitions (`defineQuery` / `defineList`);
99
+ `defineCommands({ ... })` groups write definitions (`definePut` / `defineUpdate`
100
+ / `defineDelete`). Each validates that every entry is the correct operation kind
101
+ and returns the typed record that the planner consumes. `defineTransactions({
102
+ ... })` groups declarative transactions (see §6.3).
103
+
104
+ ```ts
105
+ export const queries = defineQueries({
106
+ getUserByEmail: defineQuery(
107
+ User,
108
+ { email: param.string() },
109
+ { userId: true, name: true, email: true, status: true },
110
+ ),
111
+ listGroupMembers: defineList(
112
+ GroupMembership,
113
+ { groupId: param.string() },
114
+ { userId: true, role: true, joinedAt: true },
115
+ ),
116
+ });
117
+
118
+ export const commands = defineCommands({
119
+ addGroupMember: definePut(GroupMembership, {
120
+ groupId: param.string(),
121
+ userId: param.string(),
122
+ role: param.string(),
123
+ }),
124
+ disableUser: defineUpdate(
125
+ User,
126
+ { userId: param.string() },
127
+ { status: param.literal('disabled') },
128
+ ),
129
+ deleteGroupMember: defineDelete(GroupMembership, {
130
+ groupId: param.string(),
131
+ userId: param.string(),
132
+ }),
133
+ });
134
+ ```
135
+
136
+ ## 4. The Bridge Bundle
137
+
138
+ `buildBridgeBundle(queries?, commands?, registry?, transactions?, contracts?)`
139
+ produces a deterministic, JSON-serializable `BridgeBundle` of two documents:
140
+
141
+ ```ts
142
+ interface BridgeBundle {
143
+ readonly manifest: Manifest; // manifest.json
144
+ readonly operations: OperationsDocument; // operations.json
145
+ }
146
+ ```
147
+
148
+ Building runs `buildManifest`, then `buildOperations`, then asserts the whole
149
+ bundle is serializable (§6.1). All collections are emitted in name-sorted order
150
+ so the output is stable across runs.
151
+
152
+ ### 4.1 `manifest.json` — Schema Metadata
153
+
154
+ ```jsonc
155
+ {
156
+ "version": "1.0",
157
+ "tables": { "UserPermissions": { "physicalName": "UserPermissions" } },
158
+ "entities": {
159
+ "UserModel": {
160
+ "table": "UserPermissions",
161
+ "physicalName": "UserPermissions",
162
+ "prefix": "USER#",
163
+ "fields": {
164
+ "userId": { "type": "string" },
165
+ "email": { "type": "string" },
166
+ "status": { "type": "string" },
167
+ "createdAt": { "type": "string", "format": "datetime" }
168
+ },
169
+ "key": {
170
+ "inputFields": ["userId"],
171
+ "pkTemplate": "USER#{userId}",
172
+ "skTemplate": "PROFILE"
173
+ },
174
+ "gsis": [
175
+ {
176
+ "indexName": "GSI1",
177
+ "unique": true,
178
+ "inputFields": ["email"],
179
+ "pkTemplate": "EMAIL#{email}",
180
+ "skTemplate": "PROFILE"
181
+ }
182
+ ],
183
+ "relations": {
184
+ "groups": {
185
+ "type": "hasMany",
186
+ "target": "GroupMembershipModel",
187
+ "keyBinding": { "userId": "userId" }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ - `fields[*].type` is one of `string | number | boolean | binary | stringSet |
196
+ numberSet | list | map`. A string field that holds a temporal value carries
197
+ `format: "datetime"` or `format: "date"`, used by the runtime to restore
198
+ Python `datetime` values during hydration.
199
+ - `key` / `gsis[*]` carry `inputFields` and `pkTemplate` / `skTemplate` where the
200
+ templates use `{field}` placeholders (e.g. `USER#{userId}`, `EMAIL#{email}`).
201
+ `skTemplate` is `null` when the access pattern has no sort key.
202
+ - `relations[*]` is one of `hasMany | hasOne | belongsTo` with a `target` entity
203
+ and a `keyBinding` (target field → source field).
204
+
205
+ ### 4.2 `operations.json` — Execution Specs
206
+
207
+ ```ts
208
+ interface OperationsDocument {
209
+ readonly version: '1.0';
210
+ readonly queries: Record<string, QuerySpec>;
211
+ readonly commands: Record<string, CommandSpec>;
212
+ readonly transactions?: Record<string, TransactionSpec>;
213
+ readonly contracts?: Record<string, ContractSpec>;
214
+ readonly contexts?: Record<string, ContextSpec>;
215
+ }
216
+ ```
217
+
218
+ #### Parameter specs
219
+
220
+ Every operation carries a `params` map of `ParamSpec`:
221
+
222
+ ```jsonc
223
+ { "type": "string", "required": true }
224
+ { "type": "number", "required": true }
225
+ { "type": "literal", "required": true, "literals": ["disabled"] }
226
+ { "type": "array", "required": true,
227
+ "element": { "userId": { "type": "string", "required": true } } }
228
+ ```
229
+
230
+ #### Queries
231
+
232
+ A `QuerySpec` has `params`, an ordered `operations` array, and a `cardinality`
233
+ (`"one"` or `"many"`). Each `OperationSpec`:
234
+
235
+ ```jsonc
236
+ {
237
+ "type": "Query", // "Query" | "GetItem" | "BatchGetItem"
238
+ "tableName": "UserPermissions",
239
+ "indexName": "GSI1", // omitted for base-table access
240
+ "keyCondition": { "GSI1PK": "EMAIL#{email}", "GSI1SK": "PROFILE" },
241
+ "rangeCondition": { "operator": "begins_with", "key": "SK", "value": "USER#" },
242
+ "projection": ["email", "name", "status", "userId"],
243
+ "limit": 20,
244
+ "filter": { /* declarative FilterExpression */ },
245
+ "resultPath": "$", // "$" or e.g. "$.groups.items"
246
+ "sourceField": "userId" // present on relation child operations
247
+ }
248
+ ```
249
+
250
+ The operation type is chosen by the planner from the key shape: a full PK+SK is
251
+ a `GetItem`; a partial key with a contiguous sort-key prefix is a `Query` with a
252
+ `begins_with` `rangeCondition`; a partition-key-only access is a `Query`. The
253
+ `projection` is the set of scalar fields selected `true`.
254
+
255
+ #### Template placeholders
256
+
257
+ Key, item, and change templates use two placeholder forms plus literals:
258
+
259
+ - `{paramName}` — bound from caller-supplied parameters.
260
+ - `{result.sourceField}` — bound from a prior operation's result item(s), used to
261
+ chain relation operations.
262
+ - Literal text (e.g. `PROFILE`, `USER#`) is emitted verbatim.
263
+
264
+ The planner derives every template by **symbolic evaluation** of the model's key
265
+ mapping (it evaluates the key function over symbolic field markers rather than
266
+ concrete values), producing `USER#{userId}`-style templates directly from the
267
+ metadata.
268
+
269
+ #### Relation chaining and execution plan
270
+
271
+ A relation traversal is expressed as additional `OperationSpec` nodes in the same
272
+ query's `operations` array. Each child references its parent through
273
+ `{result.sourceField}` in its `keyCondition`, and its `resultPath` nests the
274
+ result under the parent (`$.groups.items` for a `hasMany`, `$.account` for a
275
+ `belongsTo` / `hasOne`). `belongsTo` / `hasOne` children are emitted as
276
+ `BatchGetItem`; `hasMany` children as `Query`.
277
+
278
+ A query may carry an `executionPlan`:
279
+
280
+ ```jsonc
281
+ { "groups": [[1, 2], [3]], "concurrency": 16 }
282
+ ```
283
+
284
+ `groups` is an ordered list of stages of operation indices (the root, index 0,
285
+ is implicit and runs first). Operations within a stage are mutually independent
286
+ and may run concurrently; later stages may read `{result.*}` produced by earlier
287
+ ones. The same `deriveExecutionPlan` routine is used by the generator and by the
288
+ TypeScript runtime, so the staging cannot drift between them.
289
+
290
+ #### Commands
291
+
292
+ ```jsonc
293
+ {
294
+ "type": "UpdateItem", // "PutItem" | "UpdateItem" | "DeleteItem"
295
+ "tableName": "UserPermissions",
296
+ "entity": "UserModel",
297
+ "params": { "userId": {"type":"string","required":true},
298
+ "status": {"type":"literal","required":true,"literals":["disabled"]} },
299
+ "keyCondition": { "PK": "USER#{userId}", "SK": "PROFILE" },
300
+ "changes": { "status": "{status}" },
301
+ "condition": { "kind": "notExists" } // or "equals" / "expr" / "raw" / attributeExists… (§6.2)
302
+ }
303
+ ```
304
+
305
+ A `put` carries an `item` template; an `update` carries `changes`; both `update`
306
+ and `delete` carry a `keyCondition`. Writes target the base table — a
307
+ GSI-keyed write is rejected by the planner.
308
+
309
+ ## 5. The `graphddb generate python` CLI
310
+
311
+ ```bash
312
+ graphddb generate python \
313
+ --entry src/models/index.ts \
314
+ --queries src/models/queries.ts \
315
+ --commands src/models/commands.ts \
316
+ --out generated/graphddb
317
+ ```
318
+
319
+ | Flag | Required | Default | Meaning |
320
+ |---|---|---|---|
321
+ | `--entry`, `-e` | yes | — | Module that registers entity models (imported for side effects); may also export the definition maps. |
322
+ | `--out`, `-o` | yes | — | Output directory (created if absent). |
323
+ | `--queries`, `-q` | no | `--entry` | Module exporting `queries`. |
324
+ | `--commands`, `-c` | no | `--entry` | Module exporting `commands`. |
325
+ | `--transactions`, `-t` | no | `--commands` then `--entry` | Module exporting `transactions`. |
326
+ | `--contracts` | no | `--entry` | Module exporting CQRS `contracts`. |
327
+ | `--contexts` | no | `--contracts` then `--entry` | Module exporting context ownership. |
328
+ | `--dataclass` | no | `false` | Emit `@dataclass` result types instead of `TypedDict`. |
329
+
330
+ TypeScript definition modules are transpiled on the fly (via tsx/esbuild — the
331
+ same transform the build uses), so you do **not** need to wrap the CLI in `tsx`
332
+ or enable Node type-stripping; run `graphddb generate` under plain `node`.
333
+
334
+ `tsx` is an *optional* peer dependency: it is only needed to load `.ts`
335
+ definition modules, so install it as a devDependency in projects that run
336
+ codegen (`npm i -D tsx`). The library half of the package never needs it, so
337
+ runtime consumers do not pull esbuild. Already-compiled `.js` / `.mjs` modules
338
+ load without tsx.
339
+
340
+ The entry module is imported first (registering the models), after which the
341
+ definition maps are read from their named exports. The command builds the bridge
342
+ bundle and writes exactly five files:
343
+
344
+ ```text
345
+ generated/graphddb/
346
+ manifest.json # schema metadata (2-space JSON, trailing newline)
347
+ operations.json # execution specs
348
+ types.py # result / element types
349
+ repositories.py # repository classes
350
+ __init__.py # package re-exports
351
+ ```
352
+
353
+ `generate python` is the only `generate` subcommand.
354
+
355
+ ## 5.1 Generated Python
356
+
357
+ Every generated file begins with `# DO NOT EDIT. Generated by GraphDDB.` and
358
+ `from __future__ import annotations`. Application-specific logic wraps the
359
+ generated repositories rather than editing them.
360
+
361
+ ### Result types (`types.py`)
362
+
363
+ Result types are named `<PascalCaseQueryName>Result` (e.g. `GetUserByEmailResult`)
364
+ with name-sorted fields. By default each is a `TypedDict`; with `--dataclass`
365
+ each is an `@dataclass` (optional fields default to `None` and follow required
366
+ fields). Manifest field types map to Python: `string`/`stringSet` → `str`,
367
+ `number`/`numberSet` → `float`, `boolean` → `bool`, `binary` → `bytes`, `list`
368
+ → `list`, `map` → `dict`; literal params render as `Literal[...]`. A `hasMany`
369
+ relation renders as a `<Item>Connection` (`items: list[...]`, `cursor: str |
370
+ None`); a `belongsTo` / `hasOne` renders as `<Item> | None`. Transaction array
371
+ parameters yield `<Transaction><Param>Item` element types.
372
+
373
+ ### Repositories (`repositories.py`)
374
+
375
+ One `class <Entity>Repository:` per owning entity, constructed with a
376
+ `GraphDDBRuntime`. Methods are named in `snake_case`; arguments are `snake_case`
377
+ while the dict passed to the runtime keys parameters by their original
378
+ definition names. Query methods return `<Result> | None`; command methods return
379
+ `None`. If transactions exist, a `TransactionsRepository` exposes them via
380
+ `execute_transaction`.
381
+
382
+ ```python
383
+ class UserRepository:
384
+ def __init__(self, runtime: GraphDDBRuntime) -> None:
385
+ self._runtime = runtime
386
+
387
+ def get_user_by_email(self, email: str) -> GetUserByEmailResult | None:
388
+ return self._runtime.execute_query(
389
+ query_id="getUserByEmail",
390
+ params={"email": email},
391
+ )
392
+
393
+ def disable_user(self, status: Literal["disabled"], user_id: str) -> None:
394
+ self._runtime.execute_command(
395
+ command_id="disableUser",
396
+ params={"status": status, "userId": user_id},
397
+ )
398
+ ```
399
+
400
+ `__init__.py` re-exports the result types and repository classes with a
401
+ name-sorted `__all__`.
402
+
403
+ ## 6. Build-Time Guarantees
404
+
405
+ ### 6.1 Serializability
406
+
407
+ The bridge guard asserts that every value in the bundle survives `JSON.stringify`
408
+ round-tripping: only strings, finite numbers, booleans, `null`, arrays, and plain
409
+ objects are allowed. Functions, symbols, `undefined`, `Date`, `Map`, `Set`,
410
+ class instances, and non-finite numbers are rejected.
411
+
412
+ ### 6.2 Supported Write Conditions
413
+
414
+ A write `condition` (`WriteCondition`, issue #114) serializes to one of these
415
+ `ConditionSpec` kinds:
416
+
417
+ - `{ notExists: true }` → `{ "kind": "notExists" }` (`attribute_not_exists` on the
418
+ partition key);
419
+ - `{ attributeExists: '<field>' }` → `{ "kind": "attributeExists", "field" }`;
420
+ `{ attributeNotExists: '<field>' }` → `{ "kind": "attributeNotExists", "field" }`;
421
+ - a field-equality map → `{ "kind": "equals", "fields": { ... } }` (legacy,
422
+ golden-stable shape);
423
+ - the **declarative operator subset** (the same operators as a read filter —
424
+ `gt`/`ge`/`lt`/`le`/`ne`/`between`/`in`/`beginsWith`/`contains`/`notContains`/
425
+ `size`/`attributeType` + `and`/`or`/`not`) → `{ "kind": "expr", "declarative": … }`,
426
+ which both runtimes re-render through the shared filter compiler;
427
+ - a raw `cond\`…\`` fragment → `{ "kind": "raw", "expression", "names", "values" }`,
428
+ pre-compiled to a finished `ConditionExpression` with stable `#cr_<field>` /
429
+ `:crN` aliases. A value may be a native literal or a `{ "$param": "<name>" }`
430
+ leaf bound from a caller param at execution time.
431
+
432
+ A raw `cond` is serializable only as a **whole** condition; a `cond` nested inside
433
+ a declarative `and` / `or` / `not` group is rejected at build time (no raw node in
434
+ the serialized `expr` tree). (Per-item conditions are not available on
435
+ `BatchWriteItem`; conditional batch writes must use a transaction.)
436
+
437
+ ### 6.3 Transactions (declarative subset)
438
+
439
+ `defineTransaction` records a declarative transaction whose body may only call
440
+ `tx.put` / `tx.update` / `tx.delete` with field references or literals at scalar
441
+ leaves, iterate an array parameter with `tx.forEach(p.<arrayParam>, ...)`, and
442
+ attach a declarative `when` comparison (`eq` / `ne`) and/or a supported
443
+ `condition`. The planner emits a `TransactionSpec` whose `items` carry rendered
444
+ templates (`{param}`, `{item.field}` for `forEach` elements), optional
445
+ `condition`, optional `when`, and optional `forEach: { source }`. A transaction
446
+ with no `forEach` is rejected at build time if it exceeds 25 items; with
447
+ `forEach`, the 25-item limit is enforced by the runtime after expansion.
448
+
449
+ ## 7. The Python Runtime (`graphddb_runtime`)
450
+
451
+ `graphddb_runtime` is the hand-written package the generated bindings import. It
452
+ interprets `manifest.json` / `operations.json` and executes the validated access
453
+ patterns through a boto3 DynamoDB client. The runtime core is synchronous (boto3
454
+ is a synchronous SDK).
455
+
456
+ ### 7.1 Construction and API
457
+
458
+ ```python
459
+ GraphDDBRuntime(
460
+ dynamodb_client,
461
+ manifest_path: str,
462
+ operations_path: str,
463
+ table_mapping: Mapping[str, str] | None = None,
464
+ limits: RuntimeLimits | None = None,
465
+ )
466
+ ```
467
+
468
+ `table_mapping` maps logical table names to deployed physical names. The
469
+ specs are loaded and cached at construction.
470
+
471
+ | Method | Behavior |
472
+ |---|---|
473
+ | `execute_query(query_id, params, options=None) -> dict \| None` | Run a single- or multi-operation query; returns the hydrated result, or `None` when the root matches nothing. `options` may carry a pagination `cursor`. |
474
+ | `execute_command(command_id, params, options=None) -> None` | Run a single write (`PutItem` / `UpdateItem` / `DeleteItem`). |
475
+ | `execute_transaction(transaction_id, params) -> None` | Expand and run a declarative transaction as `TransactWriteItems`. |
476
+ | `explain(query_id, params) -> dict` | Resolve the operation list without executing — substitutes `{param}` and leaves `{result.*}` intact. |
477
+
478
+ `AsyncGraphDDBRuntime(sync_runtime)` provides an `await`-able surface for the
479
+ same methods by running each blocking call via `asyncio.to_thread`; behavior
480
+ (params, specs, results, error types) is identical. The wrapped runtime is
481
+ available as `.sync`. No `aioboto3` is required.
482
+
483
+ ### 7.2 Parameter Validation and Template Resolution
484
+
485
+ Parameters are validated against each operation's `params` spec **before** any
486
+ DynamoDB call: required parameters must be present, unknown ones are rejected,
487
+ and scalar types and literal sets are checked. Template resolution substitutes
488
+ `{name}` placeholders; `{result.sourceField}` placeholders are bound from a prior
489
+ operation's results when chaining relations. An unbound placeholder raises
490
+ `ParameterValidationError`.
491
+
492
+ ### 7.3 boto3 Conversion
493
+
494
+ Python values are converted to DynamoDB AttributeValues with
495
+ `boto3.dynamodb.types.TypeSerializer` and back with `TypeDeserializer`. Numbers
496
+ deserialize to `Decimal`.
497
+
498
+ ### 7.4 Hydration
499
+
500
+ For each returned item the runtime keeps only the fields selected `true` in the
501
+ projection, strips internal key attributes (`PK`, `SK`, `GSI*PK`, `GSI*SK`), and
502
+ restores temporal fields: a `format: "datetime"` string parses to a
503
+ timezone-aware UTC `datetime`, and a `format: "date"` string parses to midnight
504
+ UTC. A malformed temporal string raises `HydrationError`.
505
+
506
+ ### 7.5 Cursors
507
+
508
+ A single-operation cursor is the DynamoDB `LastEvaluatedKey` serialized as
509
+ compact JSON (with `Decimal` rendered as int/float and binary base64-encoded)
510
+ and encoded as unpadded base64url. `decode_cursor` reverses this. The cursor's
511
+ contents are opaque to the application. Batched range contract methods use a
512
+ per-key cursor envelope (`{ key, inner }`) keyed by the owning contract key, so
513
+ resuming with a cursor for the wrong key is rejected.
514
+
515
+ ### 7.6 Relations, Batch, and Concurrency
516
+
517
+ Relation children are resolved against the parents produced by earlier
518
+ operations and merged into the result at their `resultPath`:
519
+
520
+ - **`belongsTo` / `hasOne`** — source values from all parents are deduplicated
521
+ and fetched in a single `BatchGetItem`, then matched back to each parent (a
522
+ miss yields `None`). This avoids the N+1 pattern.
523
+ - **`hasMany`** — one `Query` per parent (with the `begins_with` range and any
524
+ declarative filter), producing an `{ items, cursor }` connection.
525
+
526
+ `BatchGetItem` requests are chunked at 100 keys, deduplicated, and retried on
527
+ `UnprocessedKeys` with capped exponential backoff (up to 10 attempts).
528
+ `BatchWriteItem` is chunked at 25 with the same `UnprocessedItems` retry. Stages
529
+ of mutually independent operations (from `executionPlan`) run concurrently on a
530
+ bounded thread pool (default 16 in flight); boto3 releases the GIL during
531
+ network I/O, so the overlap is real. Result order is preserved.
532
+
533
+ ### 7.7 Transactions
534
+
535
+ `execute_transaction` expands a `TransactionSpec` into boto3 transact items:
536
+ `forEach` items iterate their array source, `when` guards filter items, and
537
+ templates resolve `{param}` and `{item.field}`. Conditions are any serialized
538
+ `ConditionSpec` kind (§6.2) — existence primitives, AND-joined equality, the
539
+ declarative operator subset (`expr`), or a whole `cond` raw fragment. The expanded
540
+ set is limited to 25 items.
541
+
542
+ ### 7.8 Runtime Limits
543
+
544
+ ```python
545
+ @dataclass(frozen=True)
546
+ class RuntimeLimits:
547
+ max_operations: int = 20
548
+ max_items: int = 100
549
+ max_depth: int = 1
550
+ max_batch_get_items: int = 100
551
+ ```
552
+
553
+ These bound the number of read operations per query, the result cardinality
554
+ (items per Query, keys per BatchGet, relation fan-out), the relation nesting
555
+ depth, and the BatchGet chunk size. A violation raises `LimitExceededError`
556
+ before any DynamoDB call.
557
+
558
+ ### 7.9 Errors
559
+
560
+ All runtime errors derive from `GraphDDBError`:
561
+
562
+ `QueryNotFoundError`, `CommandNotFoundError`, `TransactionNotFoundError`,
563
+ `ContractNotFoundError`, `ContractArityError`, `ParameterValidationError`,
564
+ `LimitExceededError`, `OperationExecutionError` (wraps a boto3 `ClientError` /
565
+ `BotoCoreError`, preserving the cause as `.original`), `HydrationError`, and
566
+ `MultiOperationNotSupportedError`.
567
+
568
+ ### 7.10 AWS Lambda
569
+
570
+ The runtime loads the JSON specs from disk and constructs a boto3 client — both
571
+ cold-start costs. Construct the runtime and repositories in module scope so they
572
+ are reused across warm invocations and captured by SnapStart; never construct
573
+ them inside the handler. The deployment artifact needs the runtime package, the
574
+ generated bindings, and the two JSON specs (boto3/botocore are provided by the
575
+ Lambda runtime).
576
+
577
+ ## 8. TS ↔ Python Conformance
578
+
579
+ The TypeScript live executor and the Python runtime are two independent
580
+ implementations of the same semantics; a golden conformance suite
581
+ (`conformance/`) proves they agree. Against a shared DynamoDB Local table seeded
582
+ with deterministic fixed-date data:
583
+
584
+ - The **TypeScript path** substitutes concrete parameters into each definition's
585
+ IR and dispatches to the live executor (`executeQuery` / `executeList` /
586
+ `executePut` / `executeUpdate` / `executeDelete`), relations included.
587
+ - The **Python path** runs the **generated** `operations.json` / `manifest.json`
588
+ through `GraphDDBRuntime` against the same seeded table.
589
+ - Both results are normalized to canonical JSON and deep-compared; the run exits
590
+ non-zero on any divergence.
591
+
592
+ Normalization absorbs only representation differences — `Date` ↔ `datetime`
593
+ rendered to the same ISO-8601 instant, an absent connection `cursor` defaulting
594
+ to `null`, and the cardinality-one root shape — applied symmetrically to both
595
+ sides. Real semantics are preserved and must match exactly: all field values,
596
+ the set of projected fields, item ordering within connections (DynamoDB
597
+ sort-key order), relation nesting, filter results, BatchGet dedup/order, and
598
+ persisted command items. The TypeScript and Python sides write under private key
599
+ suffixes (`-ts` / `-py`) so they share the table without colliding.
600
+
601
+ Coverage spans single `GetItem`, unique-GSI `Query`, partition `Query`/list,
602
+ relation queries (`belongsTo` BatchGet + nested `hasMany` + server filter +
603
+ cursor), put / update / delete, conditional writes (existence primitives, equality, the declarative operator subset, and `cond` raw fragments),
604
+ and declarative transactions (including `forEach` + `when`). A
605
+ `CONFORMANCE_MUTATE` switch injects a deliberate divergence (corrupting a
606
+ hydrated date, neutralizing the filter compiler, or corrupting a persisted item)
607
+ to prove the suite detects drift rather than passing vacuously.
608
+
609
+ > Note: the static build-time N+1 safety check on CQRS contracts is
610
+ > TypeScript-only — there is no Python build step — so it is a TypeScript-side
611
+ > boundary rather than a cross-runtime parity claim.