graphddb-runtime 0.1.0__py3-none-any.whl

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,58 @@
1
+ """GraphDDB Python runtime (issue #44, single-operation core).
2
+
3
+ Public surface consumed by generated ``repositories.py`` and applications:
4
+
5
+ - :class:`GraphDDBRuntime` — the executor.
6
+ - :class:`RuntimeLimits` — execution-time bounds.
7
+ - the :class:`GraphDDBError` family.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .cursor import decode_cursor, encode_cursor
13
+ from .errors import (
14
+ CommandNotFoundError,
15
+ ContractArityError,
16
+ ContractNotFoundError,
17
+ GraphDDBError,
18
+ HydrationError,
19
+ LimitExceededError,
20
+ MultiOperationNotSupportedError,
21
+ OperationExecutionError,
22
+ ParameterValidationError,
23
+ QueryNotFoundError,
24
+ TransactionNotFoundError,
25
+ )
26
+ from .async_runtime import AsyncGraphDDBRuntime
27
+ from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
28
+ from .limits import RuntimeLimits
29
+ from .per_key_cursor import (
30
+ decode_per_key_cursor,
31
+ encode_per_key_cursor,
32
+ serialize_contract_key,
33
+ )
34
+ from .runtime import GraphDDBRuntime
35
+
36
+ __all__ = [
37
+ "GraphDDBRuntime",
38
+ "AsyncGraphDDBRuntime",
39
+ "RuntimeLimits",
40
+ "RELATION_TRAVERSAL_CONCURRENCY",
41
+ "map_with_concurrency",
42
+ "GraphDDBError",
43
+ "QueryNotFoundError",
44
+ "CommandNotFoundError",
45
+ "TransactionNotFoundError",
46
+ "ContractNotFoundError",
47
+ "ContractArityError",
48
+ "ParameterValidationError",
49
+ "LimitExceededError",
50
+ "OperationExecutionError",
51
+ "HydrationError",
52
+ "MultiOperationNotSupportedError",
53
+ "encode_cursor",
54
+ "decode_cursor",
55
+ "serialize_contract_key",
56
+ "encode_per_key_cursor",
57
+ "decode_per_key_cursor",
58
+ ]
@@ -0,0 +1,110 @@
1
+ """Async adapter over the sync :class:`GraphDDBRuntime` (issue #46, Phase 4).
2
+
3
+ boto3 is a synchronous SDK, so the runtime core is synchronous. This module
4
+ provides a **thin** async wrapper that runs each blocking call in a worker
5
+ thread via :func:`asyncio.to_thread`, giving an ``await``-able surface with
6
+ **behavior equivalent** to the sync runtime (same params, same specs, same
7
+ results, same error types) without duplicating the executor.
8
+
9
+ It does not require ``aioboto3``; the wrapped sync runtime keeps using boto3.
10
+ For a project that wants a fully non-blocking DynamoDB client, ``aioboto3`` can
11
+ be adopted later behind the same interface — but for the common Lambda / request
12
+ handler case, ``asyncio.to_thread`` is sufficient and keeps a single executor
13
+ implementation (no TS/Python or sync/async divergence to test twice).
14
+
15
+ Usage::
16
+
17
+ import boto3
18
+ from graphddb_runtime import GraphDDBRuntime, AsyncGraphDDBRuntime
19
+
20
+ sync = GraphDDBRuntime(boto3.client("dynamodb"), manifest, operations)
21
+ runtime = AsyncGraphDDBRuntime(sync)
22
+
23
+ user = await runtime.execute_query("getUser", {"userId": "alice"})
24
+ await runtime.execute_transaction("addManyMembers", {...})
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ from typing import Any, Mapping, Optional
31
+
32
+ from .runtime import GraphDDBRuntime
33
+
34
+
35
+ class AsyncGraphDDBRuntime:
36
+ """An ``await``-able adapter delegating to a sync :class:`GraphDDBRuntime`.
37
+
38
+ Every method runs its synchronous counterpart in a thread, so a slow boto3
39
+ round trip does not block the event loop. The wrapped runtime is exposed as
40
+ :attr:`sync` for callers that need the blocking API directly.
41
+ """
42
+
43
+ def __init__(self, runtime: GraphDDBRuntime) -> None:
44
+ self.sync = runtime
45
+
46
+ async def execute_query(
47
+ self,
48
+ query_id: str,
49
+ params: Mapping[str, Any],
50
+ options: Optional[Mapping[str, Any]] = None,
51
+ ) -> Optional[dict]:
52
+ return await asyncio.to_thread(
53
+ self.sync.execute_query, query_id, params, options
54
+ )
55
+
56
+ async def execute_query_method(
57
+ self,
58
+ contract_name: str,
59
+ method_name: str,
60
+ key_or_keys: Any,
61
+ params: Optional[Mapping[str, Any]] = None,
62
+ ) -> Any:
63
+ """Async wrapper over :meth:`GraphDDBRuntime.execute_query_method` (#62)."""
64
+ return await asyncio.to_thread(
65
+ self.sync.execute_query_method,
66
+ contract_name,
67
+ method_name,
68
+ key_or_keys,
69
+ params,
70
+ )
71
+
72
+ async def execute_command(
73
+ self,
74
+ command_id: str,
75
+ params: Mapping[str, Any],
76
+ options: Optional[Mapping[str, Any]] = None,
77
+ ) -> None:
78
+ await asyncio.to_thread(
79
+ self.sync.execute_command, command_id, params, options
80
+ )
81
+
82
+ async def execute_command_method(
83
+ self,
84
+ contract_name: str,
85
+ method_name: str,
86
+ key_or_keys: Any,
87
+ params: Optional[Mapping[str, Any]] = None,
88
+ ) -> None:
89
+ """Async wrapper over :meth:`GraphDDBRuntime.execute_command_method` (#64)."""
90
+ await asyncio.to_thread(
91
+ self.sync.execute_command_method,
92
+ contract_name,
93
+ method_name,
94
+ key_or_keys,
95
+ params,
96
+ )
97
+
98
+ async def execute_transaction(
99
+ self,
100
+ transaction_id: str,
101
+ params: Mapping[str, Any],
102
+ ) -> None:
103
+ await asyncio.to_thread(
104
+ self.sync.execute_transaction, transaction_id, params
105
+ )
106
+
107
+ async def explain(self, query_id: str, params: Mapping[str, Any]) -> dict:
108
+ # `explain` does not touch DynamoDB, but keeping it on the async surface
109
+ # lets call sites use one runtime object uniformly.
110
+ return await asyncio.to_thread(self.sync.explain, query_id, params)
@@ -0,0 +1,218 @@
1
+ """BatchGetItem chunking + UnprocessedKeys retry (issue #45).
2
+
3
+ Port of the TypeScript batch semantics (``src/operations/batch-retry.ts`` /
4
+ ``src/executor/batch-executor.ts``):
5
+
6
+ - keys are **deduped** before the request (``planBatchGetForQueryKeys`` /
7
+ ``dedupeDynamoKeys``);
8
+ - split into chunks of at most :data:`BATCH_GET_MAX_KEYS` (100) keys;
9
+ - each chunk retries ``UnprocessedKeys`` with exponential backoff
10
+ (``50 * 2^(attempt-1)`` ms, capped at 1000ms) up to
11
+ :data:`BATCH_MAX_RETRY_ATTEMPTS` (10) attempts, then raises.
12
+
13
+ The sleep is injected so unit tests can observe the backoff schedule without
14
+ real delays.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from typing import Any, Callable, Dict, List, Mapping, Optional
21
+
22
+ # Mirrors src/operations/batch-retry.ts.
23
+ BATCH_GET_MAX_KEYS = 100
24
+ BATCH_WRITE_MAX_ITEMS = 25
25
+ BATCH_MAX_RETRY_ATTEMPTS = 10
26
+
27
+
28
+ def compute_backoff_delay(attempt: int) -> float:
29
+ """Backoff for a 1-based attempt: ``50 * 2^(attempt-1)`` ms, capped at 1000ms."""
30
+ return min(1000.0, 50.0 * (2 ** (attempt - 1))) / 1000.0
31
+
32
+
33
+ def chunk(items: List[Any], size: int) -> List[List[Any]]:
34
+ return [items[i : i + size] for i in range(0, len(items), size)]
35
+
36
+
37
+ def serialize_key(key: Mapping[str, Any]) -> str:
38
+ """Stable string form of a (plain) key for dedup / parent matching.
39
+
40
+ Mirrors the TS ``dedupeDynamoKeys`` / ``serializeQueryKey`` ordering
41
+ (attributes sorted by name).
42
+ """
43
+ import json
44
+
45
+ return json.dumps(sorted(key.items()), separators=(",", ":"), default=str)
46
+
47
+
48
+ class BatchGetExecutor:
49
+ """Executes a deduped, chunked, retrying BatchGetItem against a boto3 client.
50
+
51
+ The client is the low-level ``boto3.client("dynamodb")`` (AttributeValue
52
+ shapes). ``request_extra`` carries the optional ``ProjectionExpression`` /
53
+ ``ExpressionAttributeNames``. Returns the flat list of raw (AttributeValue)
54
+ items across all chunks/retries.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ client: Any,
60
+ *,
61
+ boto_errors: tuple = (),
62
+ on_request: Optional[Callable[[int], None]] = None,
63
+ sleep: Callable[[float], None] = time.sleep,
64
+ max_batch_get_items: int = BATCH_GET_MAX_KEYS,
65
+ ) -> None:
66
+ self._client = client
67
+ self._boto_errors = boto_errors
68
+ self._on_request = on_request
69
+ self._sleep = sleep
70
+ self._max_batch_get_items = max_batch_get_items
71
+
72
+ def get(
73
+ self,
74
+ physical_table: str,
75
+ serialized_keys: List[Dict[str, Any]],
76
+ request_extra: Optional[Dict[str, Any]] = None,
77
+ ) -> List[Dict[str, Any]]:
78
+ if not serialized_keys:
79
+ return []
80
+
81
+ items: List[Dict[str, Any]] = []
82
+ for batch in chunk(serialized_keys, self._max_batch_get_items):
83
+ items.extend(
84
+ self._get_chunk(physical_table, batch, request_extra or {})
85
+ )
86
+ return items
87
+
88
+ def _get_chunk(
89
+ self,
90
+ physical_table: str,
91
+ keys: List[Dict[str, Any]],
92
+ request_extra: Dict[str, Any],
93
+ ) -> List[Dict[str, Any]]:
94
+ pending = keys
95
+ attempt = 0
96
+ out: List[Dict[str, Any]] = []
97
+
98
+ while pending:
99
+ table_request = {"Keys": pending, **request_extra}
100
+ if self._on_request is not None:
101
+ self._on_request(len(pending))
102
+ try:
103
+ resp = self._client.batch_get_item(
104
+ RequestItems={physical_table: table_request}
105
+ )
106
+ except self._boto_errors as exc: # type: ignore[misc]
107
+ from .errors import OperationExecutionError
108
+
109
+ raise OperationExecutionError(
110
+ f"BatchGetItem failed for table {physical_table!r}: {exc}",
111
+ original=exc,
112
+ ) from exc
113
+
114
+ out.extend(resp.get("Responses", {}).get(physical_table, []))
115
+
116
+ unprocessed = (
117
+ resp.get("UnprocessedKeys", {})
118
+ .get(physical_table, {})
119
+ .get("Keys", [])
120
+ )
121
+ if not unprocessed:
122
+ break
123
+
124
+ if attempt >= BATCH_MAX_RETRY_ATTEMPTS:
125
+ from .errors import OperationExecutionError
126
+
127
+ raise OperationExecutionError(
128
+ f"BatchGet exceeded the maximum of {BATCH_MAX_RETRY_ATTEMPTS} "
129
+ f"retry attempts with {len(unprocessed)} key(s) still "
130
+ f"unprocessed for table {physical_table!r} (likely sustained "
131
+ f"throttling)."
132
+ )
133
+
134
+ pending = unprocessed
135
+ attempt += 1
136
+ self._sleep(compute_backoff_delay(attempt))
137
+
138
+ return out
139
+
140
+
141
+ class BatchWriteExecutor:
142
+ """Executes a chunked, retrying ``BatchWriteItem`` against a boto3 client.
143
+
144
+ Port of the TypeScript batch-write semantics
145
+ (``src/operations/batch-retry.ts`` ``batchWriteChunkWithRetry`` +
146
+ ``src/operations/batch.ts`` ``executeBatchWrite``): the per-table write
147
+ requests (``{"PutRequest": {"Item": …}}`` / ``{"DeleteRequest": {"Key": …}}``,
148
+ AttributeValue shapes) are split into chunks of at most
149
+ :data:`BATCH_WRITE_MAX_ITEMS` (25); each chunk retries ``UnprocessedItems``
150
+ with the same exponential backoff as :class:`BatchGetExecutor` up to
151
+ :data:`BATCH_MAX_RETRY_ATTEMPTS` attempts, then raises.
152
+
153
+ ``BatchWriteItem`` carries **no conditions** (DynamoDB has no per-request
154
+ ``ConditionExpression`` for it) and is **not atomic** — both are properties of
155
+ the command-contract ``'batchWrite'`` mode (issue #64). The sleep is injected
156
+ so unit tests can observe the backoff schedule without real delays.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ client: Any,
162
+ *,
163
+ boto_errors: tuple = (),
164
+ on_request: Optional[Callable[[int], None]] = None,
165
+ sleep: Callable[[float], None] = time.sleep,
166
+ max_batch_write_items: int = BATCH_WRITE_MAX_ITEMS,
167
+ ) -> None:
168
+ self._client = client
169
+ self._boto_errors = boto_errors
170
+ self._on_request = on_request
171
+ self._sleep = sleep
172
+ self._max_batch_write_items = max_batch_write_items
173
+
174
+ def write(self, physical_table: str, requests: List[Dict[str, Any]]) -> None:
175
+ """Apply every write request to ``physical_table``, chunked + retried."""
176
+ if not requests:
177
+ return
178
+ for batch in chunk(requests, self._max_batch_write_items):
179
+ self._write_chunk(physical_table, batch)
180
+
181
+ def _write_chunk(
182
+ self, physical_table: str, requests: List[Dict[str, Any]]
183
+ ) -> None:
184
+ pending = requests
185
+ attempt = 0
186
+
187
+ while pending:
188
+ if self._on_request is not None:
189
+ self._on_request(len(pending))
190
+ try:
191
+ resp = self._client.batch_write_item(
192
+ RequestItems={physical_table: pending}
193
+ )
194
+ except self._boto_errors as exc: # type: ignore[misc]
195
+ from .errors import OperationExecutionError
196
+
197
+ raise OperationExecutionError(
198
+ f"BatchWriteItem failed for table {physical_table!r}: {exc}",
199
+ original=exc,
200
+ ) from exc
201
+
202
+ unprocessed = resp.get("UnprocessedItems", {}).get(physical_table, [])
203
+ if not unprocessed:
204
+ break
205
+
206
+ if attempt >= BATCH_MAX_RETRY_ATTEMPTS:
207
+ from .errors import OperationExecutionError
208
+
209
+ raise OperationExecutionError(
210
+ f"BatchWrite exceeded the maximum of {BATCH_MAX_RETRY_ATTEMPTS} "
211
+ f"retry attempts with {len(unprocessed)} item(s) still "
212
+ f"unprocessed for table {physical_table!r} (likely sustained "
213
+ f"throttling)."
214
+ )
215
+
216
+ pending = unprocessed
217
+ attempt += 1
218
+ self._sleep(compute_backoff_delay(attempt))
@@ -0,0 +1,87 @@
1
+ """Bounded-concurrency staged execution (issue #70b).
2
+
3
+ The static planner records an **execution plan** in the SSoT (issue #70a): a
4
+ query's :class:`OperationSpec`\\ s are partitioned into ordered *stages*, where the
5
+ operations **within** a stage are mutually independent (none reads a
6
+ ``{result.*}`` value another produces) and so may be issued concurrently, while
7
+ the stages run in order. A contract method mirrors this for its External Query
8
+ compositions. This module is the Python runtime's executor for that plan: it runs
9
+ the independent work of one stage with a bounded number of in-flight workers, the
10
+ faithful counterpart of the TypeScript runtime's ``mapWithConcurrency``
11
+ (``src/relation/concurrency.ts``).
12
+
13
+ ## Why threads (and not asyncio)
14
+
15
+ The runtime core is **synchronous** — it drives ``boto3``, a synchronous SDK
16
+ (see ``async_runtime.py``: the async surface is a thin ``asyncio.to_thread``
17
+ wrapper over this same sync core). A DynamoDB request is network I/O, during
18
+ which ``boto3``/``botocore`` release the GIL, so a :class:`ThreadPoolExecutor`
19
+ gives **real wall-clock overlap** for the independent requests of a stage while
20
+ keeping a single executor implementation (no sync/async fork). The default bound
21
+ is the same declared value the TS runtime uses (16), carried in the serialized
22
+ plan as ``concurrency``.
23
+
24
+ ## Ordering guarantee
25
+
26
+ :func:`map_with_concurrency` returns results in **input order**
27
+ (``output[i]`` is ``worker(items[i])``) regardless of completion order, exactly
28
+ like the TS helper. Stage consumers that mutate a shared result tree must still
29
+ ensure each worker writes a **disjoint** slot (its own parent / property), which
30
+ the relation-assembly and composition call sites do — completion order is then
31
+ irrelevant to the merged output.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from concurrent.futures import ThreadPoolExecutor
37
+ from typing import Callable, List, Sequence, TypeVar
38
+
39
+ T = TypeVar("T")
40
+ R = TypeVar("R")
41
+
42
+ #: The default declared in-flight bound, mirroring the TypeScript
43
+ #: ``RELATION_TRAVERSAL_CONCURRENCY`` (``src/relation/concurrency.ts``). A spec's
44
+ #: ``executionPlan.concurrency`` overrides it; this is the fallback when a plan is
45
+ #: absent or omits the field.
46
+ RELATION_TRAVERSAL_CONCURRENCY = 16
47
+
48
+
49
+ def map_with_concurrency(
50
+ items: Sequence[T],
51
+ limit: int,
52
+ worker: Callable[[T, int], R],
53
+ ) -> List[R]:
54
+ """Map ``items`` to results by invoking ``worker(item, index)`` with at most
55
+ ``limit`` workers in flight, preserving input order in the output.
56
+
57
+ Behaves like ``[worker(x, i) for i, x in enumerate(items)]`` but never runs
58
+ more than ``limit`` workers concurrently — the faithful counterpart of the
59
+ TypeScript ``mapWithConcurrency``. Independent boto3 round trips therefore
60
+ overlap (the SDK releases the GIL during I/O) under the declared bound.
61
+
62
+ - An empty input returns ``[]`` without spawning a pool.
63
+ - A single item (or ``limit <= 1``) runs **inline** on the calling thread, so
64
+ a non-staged / one-op-per-stage execution is byte-for-byte the pre-#70b
65
+ sequential path (no pool, no thread hand-off) — the backward-compatible
66
+ fallback.
67
+ - The first worker exception propagates (the pool is shut down); this mirrors
68
+ ``Promise.all`` rejecting on the first failure.
69
+ """
70
+ n = len(items)
71
+ if n == 0:
72
+ return []
73
+
74
+ effective = max(1, min(limit, n))
75
+ # Inline fast path: nothing to parallelize → run on the calling thread, no
76
+ # pool. Keeps a single-op-per-stage plan identical to sequential execution.
77
+ if effective == 1 or n == 1:
78
+ return [worker(item, i) for i, item in enumerate(items)]
79
+
80
+ results: List[R] = [None] * n # type: ignore[list-item]
81
+ with ThreadPoolExecutor(max_workers=effective) as pool:
82
+ futures = {
83
+ pool.submit(worker, item, i): i for i, item in enumerate(items)
84
+ }
85
+ for future, i in futures.items():
86
+ results[i] = future.result()
87
+ return results
@@ -0,0 +1,49 @@
1
+ """Pagination cursor encode/decode (issue #44).
2
+
3
+ A cursor is the DynamoDB ``LastEvaluatedKey`` encoded as base64url-without-pad
4
+ JSON, matching the TypeScript ``encodeCursor`` / ``decodeCursor``
5
+ (``src/pagination/cursor.ts``): the JSON is the key object only — no wrapping
6
+ ``{table, index, ...}`` envelope — so the byte shape is identical for a given
7
+ key.
8
+
9
+ The runtime works with the **deserialized** (plain Python) key produced by
10
+ ``TypeDeserializer`` so the encoded JSON matches the un-marshalled shape the TS
11
+ runtime (lib-dynamodb) encodes. ``encode_cursor`` accepts whatever JSON-safe
12
+ mapping it is given; numbers from a deserialized key arrive as ``Decimal`` and
13
+ are rendered as plain JSON numbers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ from decimal import Decimal
21
+ from typing import Any, Mapping
22
+
23
+
24
+ def _default(value: Any) -> Any:
25
+ if isinstance(value, Decimal):
26
+ # Render as int when integral, else float — mirrors JSON.stringify(Number).
27
+ return int(value) if value == value.to_integral_value() else float(value)
28
+ if isinstance(value, (bytes, bytearray)):
29
+ return base64.b64encode(bytes(value)).decode("ascii")
30
+ raise TypeError(f"Cannot encode {type(value).__name__} into a cursor")
31
+
32
+
33
+ def encode_cursor(last_evaluated_key: Mapping[str, Any]) -> str:
34
+ """Encode a (deserialized) ``LastEvaluatedKey`` into a base64url cursor."""
35
+ payload = json.dumps(
36
+ last_evaluated_key,
37
+ separators=(",", ":"),
38
+ ensure_ascii=False,
39
+ default=_default,
40
+ )
41
+ raw = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
42
+ return raw.rstrip("=")
43
+
44
+
45
+ def decode_cursor(cursor: str) -> dict:
46
+ """Decode a base64url cursor back into a plain ``ExclusiveStartKey`` dict."""
47
+ padding = "=" * (-len(cursor) % 4)
48
+ payload = base64.urlsafe_b64decode((cursor + padding).encode("ascii"))
49
+ return json.loads(payload.decode("utf-8"))
@@ -0,0 +1,80 @@
1
+ """Error hierarchy for the GraphDDB Python runtime (issue #44).
2
+
3
+ Mirrors the error taxonomy sketched in ``docs/python-bridge.md``.
4
+ Every runtime-raised error derives from :class:`GraphDDBError` so callers can
5
+ catch the whole family with a single ``except``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class GraphDDBError(Exception):
12
+ """Base class for every error raised by the runtime."""
13
+
14
+
15
+ class QueryNotFoundError(GraphDDBError):
16
+ """Raised when an ``execute_query`` is given an unknown ``query_id``."""
17
+
18
+
19
+ class CommandNotFoundError(GraphDDBError):
20
+ """Raised when an ``execute_command`` is given an unknown ``command_id``."""
21
+
22
+
23
+ class TransactionNotFoundError(GraphDDBError):
24
+ """Raised when an ``execute_transaction`` is given an unknown id (issue #46)."""
25
+
26
+
27
+ class ContractNotFoundError(GraphDDBError):
28
+ """Raised when an ``execute_query_method`` names an unknown contract / method.
29
+
30
+ Covers an unknown contract name, an unknown method on a known contract, or a
31
+ method whose ``kind`` does not match the call (e.g. a command method invoked
32
+ through the query path). Issue #62 (single-service contract Runtime).
33
+ """
34
+
35
+
36
+ class ContractArityError(GraphDDBError):
37
+ """Raised when a contract method is called with the wrong input arity.
38
+
39
+ Specifically, an **array** of keys fed into a ``range`` method (whose
40
+ ``inputArity`` is ``'single'``): a range resolution is one partition ``Query``
41
+ per key, so an array would be an N+1 fan-out forbidden by the contract's N+1
42
+ rule. The caller must loop in application code if N independent range reads
43
+ are genuinely needed. Issue #62.
44
+ """
45
+
46
+
47
+ class ParameterValidationError(GraphDDBError):
48
+ """Raised before any DynamoDB call when params fail validation.
49
+
50
+ Covers a missing required param, an unknown param, a wrong scalar type, or a
51
+ ``literal`` param whose value is not in the allowed set.
52
+ """
53
+
54
+
55
+ class LimitExceededError(GraphDDBError):
56
+ """Raised before any DynamoDB call when a runtime limit would be exceeded."""
57
+
58
+
59
+ class OperationExecutionError(GraphDDBError):
60
+ """Wraps a boto3 ``ClientError`` (or any boto3 failure) from a DynamoDB call.
61
+
62
+ The original exception is preserved as ``__cause__`` and on ``original``.
63
+ """
64
+
65
+ def __init__(self, message: str, original: BaseException | None = None) -> None:
66
+ super().__init__(message)
67
+ self.original = original
68
+
69
+
70
+ class HydrationError(GraphDDBError):
71
+ """Raised when a raw DynamoDB item cannot be hydrated into a result."""
72
+
73
+
74
+ class MultiOperationNotSupportedError(GraphDDBError):
75
+ """Raised when a query/command spec needs more than the single-op core.
76
+
77
+ Relation chains (operations whose length is > 1, or ops carrying a
78
+ ``sourceField``) are out of scope for issue #44 and are handled by the
79
+ relation runtime in issue #45.
80
+ """