graphddb-runtime 0.7.8__tar.gz → 0.7.9__tar.gz

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.
Files changed (49) hide show
  1. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/PKG-INFO +2 -1
  2. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/per_key_cursor.py +18 -6
  3. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/runtime.py +29 -12
  4. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/templates.py +58 -8
  5. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/PKG-INFO +2 -1
  6. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
  7. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/requires.txt +1 -0
  8. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/pyproject.toml +7 -2
  9. graphddb_runtime-0.7.9/tests/test_integration_binding_contract.py +425 -0
  10. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/README.md +0 -0
  11. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/__init__.py +0 -0
  12. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/async_runtime.py +0 -0
  13. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/batch.py +0 -0
  14. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/concurrency.py +0 -0
  15. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/cursor.py +0 -0
  16. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/errors.py +0 -0
  17. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/filters.py +0 -0
  18. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/hydration.py +0 -0
  19. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/limits.py +0 -0
  20. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/middleware.py +0 -0
  21. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/relations.py +0 -0
  22. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/transactions.py +0 -0
  23. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  24. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/top_level.txt +0 -0
  25. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/setup.cfg +0 -0
  26. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_concurrency.py +0 -0
  27. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_contract_runtime.py +0 -0
  28. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_crosslang_python_cells.py +0 -0
  29. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description.py +0 -0
  30. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description_followups.py +0 -0
  31. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description_jsdoc.py +0 -0
  32. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration.py +0 -0
  33. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_command.py +0 -0
  34. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_compose.py +0 -0
  35. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_contract.py +0 -0
  36. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_edge_derive.py +0 -0
  37. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_edge_write.py +0 -0
  38. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_events.py +0 -0
  39. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_maintain.py +0 -0
  40. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_middleware.py +0 -0
  41. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_referential.py +0 -0
  42. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_relations.py +0 -0
  43. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_unique.py +0 -0
  44. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_maintain.py +0 -0
  45. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_middleware.py +0 -0
  46. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_relations.py +0 -0
  47. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_spec_version_guard.py +0 -0
  48. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_ttl.py +0 -0
  49. {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_unit.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.7.8
3
+ Version: 0.7.9
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
+ Requires-Dist: behavior-contracts==0.1.2
9
10
  Provides-Extra: test
10
11
  Requires-Dist: pytest>=7.0; extra == "test"
11
12
 
@@ -29,9 +29,10 @@ envelope shape does not branch on arity).
29
29
 
30
30
  from __future__ import annotations
31
31
 
32
- import json
33
32
  from typing import Any, Mapping, Optional
34
33
 
34
+ from behavior_contracts import canonical_value as _canonical_value
35
+
35
36
  from .cursor import decode_cursor, encode_cursor
36
37
 
37
38
 
@@ -39,12 +40,23 @@ def serialize_contract_key(key: Mapping[str, Any]) -> str:
39
40
  """Canonical, cross-runtime-stable string identity of a contract key.
40
41
 
41
42
  Object fields are sorted by name so ``{a, b}`` and ``{b, a}`` serialize
42
- identically; values are emitted via ``json.dumps`` with compact separators.
43
- Matches the TS ``serializeContractKey`` (``JSON.stringify`` over a
44
- field-sorted object), e.g. ``{"categoryId":"tech"}``.
43
+ identically; values are emitted as compact JSON. Matches the TS
44
+ ``serializeContractKey`` (``JSON.stringify`` over a field-sorted object),
45
+ e.g. ``{"categoryId":"tech"}``.
46
+
47
+ **WS4 adapter (behavior-contracts)**: this is the shared COMMON
48
+ ``canonicalValue`` primitive (runtime-boundary.md §2.1 →
49
+ canonical-serialization.md §2: key identity = top-level key sort + compact
50
+ JSON). graphddb now delegates to the extracted shared library instead of
51
+ re-implementing the field-sorted ``json.dumps``. Verified byte-for-byte
52
+ identical for every value shape a contract key carries (string / int / bool /
53
+ null / non-ASCII / nested-dict-insertion-order / array); both the shared and
54
+ the former graphddb impl reject non-JSON-scalar leaves such as ``Decimal``,
55
+ so the boundary is exact.
45
56
  """
46
- ordered = {field: key[field] for field in sorted(key.keys())}
47
- return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
57
+ # ``canonical_value`` performs the top-level key sort + compact JSON itself,
58
+ # so pass the key mapping (as a plain dict) straight through.
59
+ return _canonical_value(dict(key))
48
60
 
49
61
 
50
62
  def encode_per_key_cursor(
@@ -19,6 +19,9 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple
19
19
 
20
20
  from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
21
21
 
22
+ from behavior_contracts import EnvelopeFailure as _EnvelopeFailure
23
+ from behavior_contracts import validate_envelope as _validate_envelope
24
+
22
25
  from .batch import BatchGetExecutor, BatchWriteExecutor, serialize_key
23
26
  from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
24
27
  from .cursor import decode_cursor, encode_cursor
@@ -83,26 +86,40 @@ def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
83
86
  (forward-rejected);
84
87
  - a missing / malformed version is rejected (an unversioned document
85
88
  cannot be trusted to match any vocabulary).
89
+
90
+ **WS4 adapter (behavior-contracts)**: the ``<major>.<minor>`` fail-closed
91
+ comparison mechanism is the shared COMMON ``validate_envelope`` primitive
92
+ (runtime-boundary.md §2.1 — ``validateEnvelope``: DynamoDB-agnostic version
93
+ check). graphddb now delegates the parse + major-equal / minor-<= rule to the
94
+ shared library (which reads the ``version`` field here) and re-raises the
95
+ shared ``EnvelopeFailure`` as graphddb's public ``GraphDDBError`` with the
96
+ same actionable wording (test contract preserved).
86
97
  """
87
- version = document.get("version")
88
- if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+", version):
89
- raise GraphDDBError(
90
- f"{label}: missing or malformed spec version {version!r} — expected "
91
- f"'<major>.<minor>' (this runtime supports {SPEC_VERSION_SUPPORTED}). "
92
- f"Regenerate the document with a matching graphddb generator."
98
+ try:
99
+ _validate_envelope(
100
+ document,
101
+ SPEC_VERSION_SUPPORTED,
102
+ field="version",
103
+ label=label,
104
+ )
105
+ except _EnvelopeFailure as exc:
106
+ version = document.get("version") if isinstance(document, Mapping) else None
107
+ if exc.code in ("MISSING_VERSION", "MALFORMED_VERSION"):
108
+ raise GraphDDBError(
109
+ f"{label}: missing or malformed spec version {version!r} — expected "
110
+ f"'<major>.<minor>' (this runtime supports {SPEC_VERSION_SUPPORTED}). "
111
+ f"Regenerate the document with a matching graphddb generator."
112
+ ) from exc
113
+ supported_major, supported_minor = (
114
+ int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
93
115
  )
94
- major, minor = (int(part) for part in version.split("."))
95
- supported_major, supported_minor = (
96
- int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
97
- )
98
- if major != supported_major or minor > supported_minor:
99
116
  raise GraphDDBError(
100
117
  f"{label}: document spec version '{version}' is not supported by this "
101
118
  f"runtime (supports major {supported_major}, minor <= {supported_minor}). "
102
119
  f"A newer document may carry binding forms this runtime would silently "
103
120
  f"mis-execute; upgrade graphddb_runtime or regenerate the document with "
104
121
  f"a matching generator version."
105
- )
122
+ ) from exc
106
123
 
107
124
  try: # pragma: no cover - exercised indirectly
108
125
  from botocore.exceptions import BotoCoreError, ClientError
@@ -16,12 +16,31 @@ from __future__ import annotations
16
16
  import re
17
17
  from typing import Any, Mapping
18
18
 
19
+ from behavior_contracts import TemplateFailure as _TemplateFailure
20
+ from behavior_contracts import render_template as _render_template
21
+
19
22
  from .errors import ParameterValidationError
20
23
 
21
24
  # A placeholder is `{` + name (no dot) + `}`. `{result.*}` is matched but flagged
22
25
  # separately by the executor so it can raise the multi-op error.
23
26
  _PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
24
27
 
28
+ # Names resolved by a *different* layer than the core `{param}` primitive:
29
+ # `{result.*}` (relation-chain binding, resolved by the runPlan/relation layer)
30
+ # and `{item.*}` (Map/forEach expansion). See runtime-boundary.md §4.1: the
31
+ # shared `render_template` deliberately rejects these scopes (RESULT_SCOPE_IN_CORE)
32
+ # because they belong to graphddb's relation execution, not to the DSL-agnostic
33
+ # core. graphddb's `resolve_template` is overloaded to serve BOTH `{param}` key
34
+ # rendering AND `{result.*}` binding, so the adapter only delegates the former.
35
+ _NON_CORE_SCOPES = ("result.", "item.")
36
+
37
+
38
+ def _has_non_core_scope(template: str) -> bool:
39
+ return any(
40
+ m.group(1).startswith(_NON_CORE_SCOPES)
41
+ for m in _PLACEHOLDER.finditer(template)
42
+ )
43
+
25
44
 
26
45
  def validate_params(
27
46
  params: Mapping[str, Any],
@@ -118,14 +137,45 @@ def resolve_template(template: str, params: Mapping[str, Any]) -> str:
118
137
  the single-table layout). Missing params should have been caught by
119
138
  :func:`validate_params`; a stray placeholder here raises
120
139
  :class:`ParameterValidationError`.
140
+
141
+ **WS4 adapter (behavior-contracts)** — this function is *overloaded* in
142
+ graphddb: it renders both the DSL-agnostic ``{param}`` primitive AND the
143
+ graphddb-specific ``{result.*}`` relation-chain binding (the relation layer
144
+ passes ``{"result.<field>": value}`` params with ``{result.<field>}``
145
+ templates, see ``runtime.py`` ``_relation_key_marker`` / belongsTo fan-out).
146
+ Per runtime-boundary.md §4.1 only the ``{param}`` case is COMMON, so:
147
+
148
+ - **pure ``{param}`` templates** → delegate to the shared COMMON
149
+ ``render_template`` (single source of truth for the `{param}` grammar,
150
+ strict missing-key, and Python-parity stringify). The shared
151
+ ``TemplateFailure(UNBOUND_PARAM)`` is translated to graphddb's
152
+ ``ParameterValidationError`` to preserve graphddb's public error contract.
153
+ - **templates carrying ``{result.*}`` / ``{item.*}``** → keep graphddb's own
154
+ substitution (the shared core deliberately rejects these scopes; they are
155
+ resolved by graphddb's relation execution layer, not by the DSL core).
156
+
157
+ This is the WS4 boundary correction: graphddb's ``resolve_template`` does
158
+ *more* than the shared ``render_template``, so it cannot be swapped wholesale
159
+ — the shared core is wired in for the primitive it genuinely owns.
121
160
  """
161
+ if _has_non_core_scope(template):
162
+ # CONSUMER-SPECIFIC relation-layer binding: substitute every `{name}`
163
+ # (including `{result.*}` / `{item.*}`) with its param value. This is the
164
+ # part the shared core does not own (runtime-boundary.md §4.1).
165
+ def repl(match: "re.Match[str]") -> str:
166
+ name = match.group(1)
167
+ if name not in params or params[name] is None:
168
+ raise ParameterValidationError(
169
+ f"template '{template}' references unbound parameter '{name}'"
170
+ )
171
+ return str(params[name])
122
172
 
123
- def repl(match: "re.Match[str]") -> str:
124
- name = match.group(1)
125
- if name not in params or params[name] is None:
126
- raise ParameterValidationError(
127
- f"template '{template}' references unbound parameter '{name}'"
128
- )
129
- return str(params[name])
173
+ return _PLACEHOLDER.sub(repl, template)
130
174
 
131
- return _PLACEHOLDER.sub(repl, template)
175
+ # COMMON `{param}` primitive → shared behavior-contracts.
176
+ try:
177
+ return _render_template(template, params)
178
+ except _TemplateFailure as exc:
179
+ # Preserve graphddb's public error contract (ParameterValidationError)
180
+ # while routing the mechanism through the shared library.
181
+ raise ParameterValidationError(str(exc)) from exc
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.7.8
3
+ Version: 0.7.9
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
+ Requires-Dist: behavior-contracts==0.1.2
9
10
  Provides-Extra: test
10
11
  Requires-Dist: pytest>=7.0; extra == "test"
11
12
 
@@ -27,6 +27,7 @@ tests/test_description.py
27
27
  tests/test_description_followups.py
28
28
  tests/test_description_jsdoc.py
29
29
  tests/test_integration.py
30
+ tests/test_integration_binding_contract.py
30
31
  tests/test_integration_command.py
31
32
  tests/test_integration_compose.py
32
33
  tests/test_integration_contract.py
@@ -1,4 +1,5 @@
1
1
  boto3>=1.26
2
+ behavior-contracts==0.1.2
2
3
 
3
4
  [test]
4
5
  pytest>=7.0
@@ -4,12 +4,17 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.7.8"
7
+ version = "0.7.9"
8
8
  description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = { text = "MIT" }
12
- dependencies = ["boto3>=1.26"]
12
+ # behavior-contracts (formerly dsl-contracts) epic (WS8): the COMMON runtime
13
+ # primitives (spec-version validation, {param} template rendering, contract-key
14
+ # canonicalisation) are delegated to the extracted shared package
15
+ # ``behavior-contracts`` (import name ``behavior_contracts``). The runtime
16
+ # imports it unconditionally, so it is a HARD dependency.
17
+ dependencies = ["boto3>=1.26", "behavior-contracts==0.1.2"]
13
18
 
14
19
  [project.optional-dependencies]
15
20
  test = ["pytest>=7.0"]
@@ -0,0 +1,425 @@
1
+ """Integration tests for the CONTRACT-typed Python bindings (issue #250 step 5).
2
+
3
+ `test_integration_contract.py` proves the raw contract entry point
4
+ (``execute_query_method`` / ``execute_command_method``) executes the serialized
5
+ cardinality matrix correctly. THIS suite proves the #250 GENERATED layer on top:
6
+ the contract-derived typed repository methods (``group_by_id__get``,
7
+ ``members_by_group__list``, ``member_access__get``, ``user_commands__set_tier``,
8
+ …) call those entry points with the right key/params split and return results
9
+ IDENTICAL to a raw entry-point call — for every #250 conformance dimension:
10
+
11
+ - point read (``GroupById.get`` → ``Item | None``, miss → ``None``)
12
+ - unique-GSI point read (``UserByEmail.get`` → GSI Query, ``inputArity 'single'``)
13
+ - range read (``MembersByGroup.list`` → ``{items, cursor}`` connection)
14
+ - range read WITH a live cursor (a >1MB partition page-breaks with NO retrieval
15
+ opts — v1 typed bindings expose none, #250 defers limit/after/order — so the
16
+ connection's ``cursor`` is genuinely non-None through the typed method)
17
+ - External Query composition (``MemberAccess.get`` → composed ``group`` via the
18
+ ``GroupById`` contract; absent parent composes ``None``)
19
+ - non-promoted single writes (``UserCommands.setTier`` update and
20
+ ``MembershipCommands.add`` create: ``single.mode == 'op'`` in the serialized
21
+ contract — a plain UpdateItem/PutItem, no transaction promotion)
22
+
23
+ Cross-language note: the Go / Rust / PHP bindings-IT suites
24
+ (``go/graphddb_bindings_it/contract_integration_test.go``,
25
+ ``rust/graphddb_bindings_it/tests/contract_integration.rs``,
26
+ ``php/tests/Integration/ContractBindingsIntegrationTest.php``) seed the SAME
27
+ data (group ``g1``/Engineering, members alice=admin / bob=member, joinedAt
28
+ 2021-01-01/-02) and assert the SAME expected values, so the four runtimes are
29
+ pinned to identical observable results for the typed contract surface.
30
+
31
+ Requires DynamoDB Local on port 8000:
32
+ docker compose -f docker-compose.test.yml up -d
33
+
34
+ Marked ``integration`` so it is skipped by ``-m "not integration"``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import importlib.util
40
+ import json
41
+ import os
42
+ import sys
43
+
44
+ import boto3
45
+ import pytest
46
+ from botocore.config import Config
47
+
48
+ import conftest
49
+ from graphddb_runtime import GraphDDBRuntime
50
+
51
+ pytestmark = pytest.mark.integration
52
+
53
+ ENDPOINT = os.environ.get("DDB_LOCAL_ENDPOINT", "http://localhost:8000")
54
+ TABLE = "UserPermissions"
55
+
56
+ # ── the cross-language shared seed (mirrored in the Go / Rust / PHP suites) ────
57
+ GROUP_ID = "g1"
58
+ GROUP_NAME = "Engineering"
59
+ GROUP_DESCRIPTION = "seeded"
60
+ GROUP_CREATED_AT = "2020-01-01T00:00:00.000Z"
61
+ MEMBERS = [
62
+ ("alice", "admin", "2021-01-01T00:00:00.000Z"),
63
+ ("bob", "member", "2021-01-02T00:00:00.000Z"),
64
+ ]
65
+ USERS = [
66
+ ("alice", "alice@example.com", "Alice"),
67
+ ("bob", "bob@example.com", "Bob"),
68
+ ]
69
+
70
+
71
+ def _load_generated_package():
72
+ """Import the generated bridge dir as the ``generated_bridge`` package."""
73
+ gen_dir = conftest.FIXTURES_DIR
74
+ pkg_name = "generated_bridge"
75
+ if pkg_name in sys.modules:
76
+ return sys.modules[pkg_name]
77
+ init = os.path.join(gen_dir, "__init__.py")
78
+ spec = importlib.util.spec_from_file_location(
79
+ pkg_name, init, submodule_search_locations=[gen_dir]
80
+ )
81
+ module = importlib.util.module_from_spec(spec)
82
+ sys.modules[pkg_name] = module
83
+ spec.loader.exec_module(module)
84
+ return module
85
+
86
+
87
+ @pytest.fixture(scope="module")
88
+ def client():
89
+ cfg = Config(retries={"max_attempts": 2}, connect_timeout=2, read_timeout=10)
90
+ c = boto3.client(
91
+ "dynamodb",
92
+ endpoint_url=ENDPOINT,
93
+ region_name="local",
94
+ aws_access_key_id="fake",
95
+ aws_secret_access_key="fake",
96
+ config=cfg,
97
+ )
98
+ try:
99
+ c.list_tables()
100
+ except Exception as exc: # pragma: no cover
101
+ pytest.skip(f"DynamoDB Local not reachable at {ENDPOINT}: {exc}")
102
+ yield c
103
+
104
+
105
+ @pytest.fixture(scope="module")
106
+ def table(client):
107
+ try:
108
+ client.delete_table(TableName=TABLE)
109
+ client.get_waiter("table_not_exists").wait(TableName=TABLE)
110
+ except Exception:
111
+ pass
112
+ client.create_table(
113
+ TableName=TABLE,
114
+ KeySchema=[
115
+ {"AttributeName": "PK", "KeyType": "HASH"},
116
+ {"AttributeName": "SK", "KeyType": "RANGE"},
117
+ ],
118
+ AttributeDefinitions=[
119
+ {"AttributeName": "PK", "AttributeType": "S"},
120
+ {"AttributeName": "SK", "AttributeType": "S"},
121
+ {"AttributeName": "GSI1PK", "AttributeType": "S"},
122
+ {"AttributeName": "GSI1SK", "AttributeType": "S"},
123
+ ],
124
+ GlobalSecondaryIndexes=[
125
+ {
126
+ "IndexName": "GSI1",
127
+ "KeySchema": [
128
+ {"AttributeName": "GSI1PK", "KeyType": "HASH"},
129
+ {"AttributeName": "GSI1SK", "KeyType": "RANGE"},
130
+ ],
131
+ "Projection": {"ProjectionType": "ALL"},
132
+ }
133
+ ],
134
+ BillingMode="PAY_PER_REQUEST",
135
+ )
136
+ client.get_waiter("table_exists").wait(TableName=TABLE)
137
+ yield TABLE
138
+ client.delete_table(TableName=TABLE)
139
+
140
+
141
+ @pytest.fixture(scope="module")
142
+ def env(client, table):
143
+ """Runtime + typed repositories over the shared cross-language seed."""
144
+ bridge = _load_generated_package()
145
+ runtime = GraphDDBRuntime(
146
+ dynamodb_client=client,
147
+ manifest_path=conftest.MANIFEST_PATH,
148
+ operations_path=conftest.OPERATIONS_PATH,
149
+ )
150
+ group_repo = bridge.GroupRepository(runtime)
151
+ membership_repo = bridge.GroupMembershipRepository(runtime)
152
+ user_repo = bridge.UserRepository(runtime)
153
+
154
+ # Group META row (the GroupById.get point target / MemberAccess compose child).
155
+ client.put_item(
156
+ TableName=TABLE,
157
+ Item={
158
+ "PK": {"S": f"GROUP#{GROUP_ID}"},
159
+ "SK": {"S": "META"},
160
+ "groupId": {"S": GROUP_ID},
161
+ "name": {"S": GROUP_NAME},
162
+ "description": {"S": GROUP_DESCRIPTION},
163
+ "createdAt": {"S": GROUP_CREATED_AT},
164
+ },
165
+ )
166
+ # User PROFILE rows with the unique-GSI email projection (UserByEmail.get).
167
+ for uid, email, name in USERS:
168
+ client.put_item(
169
+ TableName=TABLE,
170
+ Item={
171
+ "PK": {"S": f"USER#{uid}"},
172
+ "SK": {"S": "PROFILE"},
173
+ "GSI1PK": {"S": f"EMAIL#{email}"},
174
+ "GSI1SK": {"S": "PROFILE"},
175
+ "userId": {"S": uid},
176
+ "name": {"S": name},
177
+ "email": {"S": email},
178
+ "status": {"S": "active"},
179
+ "createdAt": {"S": GROUP_CREATED_AT},
180
+ },
181
+ )
182
+ # Memberships written THROUGH the typed contract command (a non-promoted
183
+ # parallel-mode single create — `single.mode == 'op'`, one plain PutItem).
184
+ for uid, role, joined_at in MEMBERS:
185
+ membership_repo.membership_commands__add(
186
+ group_id=GROUP_ID, user_id=uid, role=role, joined_at=joined_at
187
+ )
188
+ return {
189
+ "runtime": runtime,
190
+ "group": group_repo,
191
+ "membership": membership_repo,
192
+ "user": user_repo,
193
+ "client": client,
194
+ }
195
+
196
+
197
+ # ── point read (GroupById.get → Item | None) ───────────────────────────────────
198
+
199
+
200
+ def test_typed_point_read_matches_raw_entry_point(env):
201
+ typed = env["group"].group_by_id__get(group_id=GROUP_ID)
202
+ raw = env["runtime"].execute_query_method("GroupById", "get", {"groupId": GROUP_ID})
203
+ assert typed is not None
204
+ assert typed == raw
205
+ assert typed["groupId"] == GROUP_ID
206
+ assert typed["name"] == GROUP_NAME
207
+ assert typed["description"] == GROUP_DESCRIPTION
208
+
209
+
210
+ def test_typed_point_read_miss_returns_none(env):
211
+ assert env["group"].group_by_id__get(group_id="does-not-exist") is None
212
+ assert (
213
+ env["runtime"].execute_query_method("GroupById", "get", {"groupId": "does-not-exist"})
214
+ is None
215
+ )
216
+
217
+
218
+ def test_typed_unique_gsi_point_read_matches_raw_entry_point(env):
219
+ typed = env["user"].user_by_email__get(email="alice@example.com")
220
+ raw = env["runtime"].execute_query_method(
221
+ "UserByEmail", "get", {"email": "alice@example.com"}
222
+ )
223
+ assert typed is not None
224
+ assert typed == raw
225
+ assert typed["userId"] == "alice"
226
+ assert typed["email"] == "alice@example.com"
227
+ assert typed["status"] == "active"
228
+
229
+
230
+ # ── range read (MembersByGroup.list → {items, cursor} connection) ──────────────
231
+
232
+
233
+ def test_typed_range_read_matches_raw_entry_point(env):
234
+ typed = env["membership"].members_by_group__list(group_id=GROUP_ID)
235
+ raw = env["runtime"].execute_query_method(
236
+ "MembersByGroup", "list", {"groupId": GROUP_ID}
237
+ )
238
+ assert typed == raw
239
+ # Single page → the connection cursor is None.
240
+ assert typed["cursor"] is None
241
+ by_user = {m["userId"]: m for m in typed["items"]}
242
+ assert set(by_user) == {"alice", "bob"}
243
+ assert by_user["alice"]["role"] == "admin"
244
+ assert by_user["bob"]["role"] == "member"
245
+ assert all(m["groupId"] == GROUP_ID for m in typed["items"])
246
+
247
+
248
+ def test_typed_range_read_carries_live_cursor_on_page_break(env):
249
+ """The v1 typed range method exposes NO retrieval opts (#250 defers
250
+ limit/after/order), so a non-None cursor must come from a REAL page break:
251
+ 4 members whose role is ~380KB each overflow the 1MB Query page, and the
252
+ typed connection surfaces the per-key cursor. The raw entry point (which
253
+ DOES take opts) continues from that cursor to the remaining rows."""
254
+ client = env["client"]
255
+ big_group = "gbig"
256
+ big_role = "x" * 380_000
257
+ member_ids = [f"m{i}" for i in range(1, 5)]
258
+ for uid in member_ids:
259
+ client.put_item(
260
+ TableName=TABLE,
261
+ Item={
262
+ "PK": {"S": f"GROUP#{big_group}"},
263
+ "SK": {"S": f"USER#{uid}"},
264
+ "groupId": {"S": big_group},
265
+ "userId": {"S": uid},
266
+ "role": {"S": big_role},
267
+ "joinedAt": {"S": "2021-01-01T00:00:00.000Z"},
268
+ },
269
+ )
270
+
271
+ typed = env["membership"].members_by_group__list(group_id=big_group)
272
+ raw = env["runtime"].execute_query_method(
273
+ "MembersByGroup", "list", {"groupId": big_group}
274
+ )
275
+ assert typed == raw
276
+ # The 1MB page break yields a genuine cursor through the TYPED method.
277
+ assert typed["cursor"] is not None
278
+ first_page = [m["userId"] for m in typed["items"]]
279
+ assert 0 < len(first_page) < len(member_ids)
280
+
281
+ # The typed cursor is the SAME per-key envelope the raw entry point resumes
282
+ # from: the continuation yields exactly the remaining members, no overlap.
283
+ rest = env["runtime"].execute_query_method(
284
+ "MembersByGroup", "list", {"groupId": big_group}, {"after": typed["cursor"]}
285
+ )
286
+ rest_ids = [m["userId"] for m in rest["items"]]
287
+ assert set(first_page).isdisjoint(rest_ids)
288
+ assert sorted(first_page + rest_ids) == member_ids
289
+
290
+ # Cleanup: keep the module-scoped seed minimal for the other tests.
291
+ for uid in member_ids:
292
+ client.delete_item(
293
+ TableName=TABLE,
294
+ Key={"PK": {"S": f"GROUP#{big_group}"}, "SK": {"S": f"USER#{uid}"}},
295
+ )
296
+
297
+
298
+ # ── External Query composition (MemberAccess.get → composed group) ─────────────
299
+
300
+
301
+ def test_typed_compose_matches_raw_entry_point(env):
302
+ typed = env["membership"].member_access__get(group_id=GROUP_ID, user_id="alice")
303
+ raw = env["runtime"].execute_query_method(
304
+ "MemberAccess", "get", {"groupId": GROUP_ID, "userId": "alice"}
305
+ )
306
+ assert typed is not None
307
+ assert typed == raw
308
+ assert typed["role"] == "admin"
309
+ # The composed parent group (via the GroupById contract), fully projected.
310
+ assert typed["group"] is not None
311
+ assert typed["group"]["groupId"] == GROUP_ID
312
+ assert typed["group"]["name"] == GROUP_NAME
313
+ assert typed["group"]["description"] == GROUP_DESCRIPTION
314
+
315
+
316
+ def test_typed_compose_absent_parent_is_none(env):
317
+ client = env["client"]
318
+ # A membership whose group META does not exist → the composition is None.
319
+ client.put_item(
320
+ TableName=TABLE,
321
+ Item={
322
+ "PK": {"S": "GROUP#ghost"},
323
+ "SK": {"S": "USER#alice"},
324
+ "groupId": {"S": "ghost"},
325
+ "userId": {"S": "alice"},
326
+ "role": {"S": "member"},
327
+ "joinedAt": {"S": "2021-01-01T00:00:00.000Z"},
328
+ },
329
+ )
330
+ typed = env["membership"].member_access__get(group_id="ghost", user_id="alice")
331
+ raw = env["runtime"].execute_query_method(
332
+ "MemberAccess", "get", {"groupId": "ghost", "userId": "alice"}
333
+ )
334
+ assert typed is not None
335
+ assert typed == raw
336
+ assert typed["group"] is None
337
+ client.delete_item(
338
+ TableName=TABLE,
339
+ Key={"PK": {"S": "GROUP#ghost"}, "SK": {"S": "USER#alice"}},
340
+ )
341
+
342
+
343
+ # ── non-promoted single writes (single.mode == 'op') ───────────────────────────
344
+
345
+
346
+ def test_fixture_writes_are_serialized_non_promoted():
347
+ """Document the 'non-promoted' fact this suite covers: both write methods'
348
+ single-key resolution target is a PLAIN op (`mode: 'op'` → one
349
+ UpdateItem/PutItem from operations.commands), not a promoted transaction."""
350
+ with open(conftest.OPERATIONS_PATH, encoding="utf-8") as fh:
351
+ operations = json.load(fh)
352
+ set_tier = operations["contracts"]["UserCommands"]["methods"]["setTier"]
353
+ add = operations["contracts"]["MembershipCommands"]["methods"]["add"]
354
+ assert set_tier["single"] == {"mode": "op", "operation": "UserCommands__setTier"}
355
+ assert add["single"] == {"mode": "op", "operation": "MembershipCommands__add"}
356
+
357
+
358
+ def test_typed_nonpromoted_update_matches_raw_write(env):
359
+ """`user_commands__set_tier` (typed, alice) and the raw entry point (bob)
360
+ must persist IDENTICAL rows (modulo the identity fields)."""
361
+ client = env["client"]
362
+ env["user"].user_commands__set_tier(user_id="alice", tier="pro")
363
+ env["runtime"].execute_command_method(
364
+ "UserCommands", "setTier", {"userId": "bob"}, {"tier": "pro"}
365
+ )
366
+
367
+ def row(uid):
368
+ item = client.get_item(
369
+ TableName=TABLE,
370
+ Key={"PK": {"S": f"USER#{uid}"}, "SK": {"S": "PROFILE"}},
371
+ ConsistentRead=True,
372
+ )["Item"]
373
+ # Strip the per-user identity attributes; everything else must match.
374
+ for k in ("PK", "GSI1PK", "userId", "name", "email"):
375
+ item.pop(k, None)
376
+ return item
377
+
378
+ alice, bob = row("alice"), row("bob")
379
+ assert alice["tier"] == {"S": "pro"}
380
+ assert alice == bob
381
+
382
+
383
+ def test_typed_nonpromoted_create_matches_raw_write(env):
384
+ """`membership_commands__add` (typed) and the raw entry point must persist
385
+ IDENTICAL membership rows (modulo the member id)."""
386
+ client = env["client"]
387
+ env["membership"].membership_commands__add(
388
+ group_id=GROUP_ID, user_id="typed-w", role="viewer", joined_at="2022-03-04T05:06:07.000Z"
389
+ )
390
+ env["runtime"].execute_command_method(
391
+ "MembershipCommands",
392
+ "add",
393
+ {"groupId": GROUP_ID, "userId": "raw-w"},
394
+ {"role": "viewer", "joinedAt": "2022-03-04T05:06:07.000Z"},
395
+ )
396
+
397
+ def row(uid):
398
+ item = client.get_item(
399
+ TableName=TABLE,
400
+ Key={"PK": {"S": f"GROUP#{GROUP_ID}"}, "SK": {"S": f"USER#{uid}"}},
401
+ ConsistentRead=True,
402
+ )["Item"]
403
+ # Strip the per-member identity attributes (the SK and the user-side
404
+ # GSI1 projection both embed the userId); everything else must match.
405
+ for k in ("SK", "GSI1PK", "userId"):
406
+ item.pop(k, None)
407
+ return item
408
+
409
+ typed_row, raw_row = row("typed-w"), row("raw-w")
410
+ assert typed_row["role"] == {"S": "viewer"}
411
+ assert typed_row == raw_row
412
+
413
+ # …and the typed range read sees both rows exactly as the raw read does.
414
+ typed = env["membership"].members_by_group__list(group_id=GROUP_ID)
415
+ raw = env["runtime"].execute_query_method(
416
+ "MembersByGroup", "list", {"groupId": GROUP_ID}
417
+ )
418
+ assert typed == raw
419
+ assert {"typed-w", "raw-w"} <= {m["userId"] for m in typed["items"]}
420
+
421
+ for uid in ("typed-w", "raw-w"):
422
+ client.delete_item(
423
+ TableName=TABLE,
424
+ Key={"PK": {"S": f"GROUP#{GROUP_ID}"}, "SK": {"S": f"USER#{uid}"}},
425
+ )