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 +136 -470
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -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 +3 -2
|
@@ -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.
|