graphddb-runtime 0.2.5__tar.gz → 0.3.0__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 (42) hide show
  1. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/middleware.py +3 -3
  3. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/transactions.py +133 -0
  4. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  5. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/SOURCES.txt +2 -0
  6. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/pyproject.toml +1 -1
  7. graphddb_runtime-0.3.0/tests/test_integration_maintain.py +337 -0
  8. graphddb_runtime-0.3.0/tests/test_maintain.py +280 -0
  9. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/README.md +0 -0
  10. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/__init__.py +0 -0
  11. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/async_runtime.py +0 -0
  12. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/batch.py +0 -0
  13. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/concurrency.py +0 -0
  14. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/cursor.py +0 -0
  15. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/errors.py +0 -0
  16. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/filters.py +0 -0
  17. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/hydration.py +0 -0
  18. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/limits.py +0 -0
  19. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/per_key_cursor.py +0 -0
  20. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/relations.py +0 -0
  21. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/runtime.py +0 -0
  22. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/templates.py +0 -0
  23. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  24. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/requires.txt +0 -0
  25. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
  26. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/setup.cfg +0 -0
  27. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_concurrency.py +0 -0
  28. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_contract_runtime.py +0 -0
  29. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration.py +0 -0
  30. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_command.py +0 -0
  31. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_compose.py +0 -0
  32. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_contract.py +0 -0
  33. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_edge_derive.py +0 -0
  34. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_edge_write.py +0 -0
  35. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_events.py +0 -0
  36. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_middleware.py +0 -0
  37. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_referential.py +0 -0
  38. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_relations.py +0 -0
  39. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_unique.py +0 -0
  40. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_middleware.py +0 -0
  41. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_relations.py +0 -0
  42. {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.5
3
+ Version: 0.3.0
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
@@ -25,14 +25,14 @@ Idiomatic Python differences from the TS reference (behavior is identical):
25
25
  {...}}}``) OR a small object with the matching attributes — a ``Middleware``
26
26
  is anything :func:`_hook` can read a callable off. This mirrors the TS object
27
27
  literal a host registers, and keeps registration as light as ``runtime.use({...})``.
28
- - Context objects are mutable dataclasses; the mutable fields the proposal marks
28
+ - Context objects are mutable dataclasses; the mutable fields docs/middleware.md marks
29
29
  (``params`` in R1, ``operation`` in R2, ``kind`` / ``input`` in W1, ``items``
30
30
  in W3) are plain attributes a hook reassigns or mutates in place.
31
31
 
32
32
  A read or write issued with no ``context`` sees an empty ``{}`` — exactly as the
33
33
  TS runtime threads ``context ?? {}``.
34
34
 
35
- @see docs/proposals/read-middleware.md
35
+ @see docs/middleware.md
36
36
  """
37
37
 
38
38
  from __future__ import annotations
@@ -51,7 +51,7 @@ from typing import (
51
51
 
52
52
  # A registered middleware is any object/mapping carrying the hook groups. The
53
53
  # library never validates its shape beyond reading callables off the known
54
- # paths, mirroring the TS "hooks are unrestricted" stance (proposal appendix A).
54
+ # paths, mirroring the TS "hooks are unrestricted" stance (see docs/middleware.md).
55
55
  Middleware = Any
56
56
  RequestContext = Dict[str, Any]
57
57
  Item = Dict[str, Any]
@@ -152,6 +152,53 @@ def _when_holds(
152
152
  return left == right if when["op"] == "eq" else left != right
153
153
 
154
154
 
155
+ def _apply_maintain_transform(op: str, args: List[Any], value: Any) -> Any:
156
+ """Apply one maintenance projection transform to a resolved source value (#129,
157
+ Epic #118) — the branch-for-branch mirror of the TS in-process
158
+ ``applyProjectionTransform`` (``command-runtime.ts``) and the declarative
159
+ ``applyMaintainTransform`` (``declarative-transaction.ts``):
160
+
161
+ - ``identity`` — copy the source value through unchanged;
162
+ - ``preview`` — keep the first ``n`` characters of the string form of the source
163
+ value (``args[0]`` is the positive-integer bound). ``None`` passes through.
164
+
165
+ Both runtimes apply this IDENTICALLY so the maintained row is byte-consistent.
166
+ """
167
+ if op == "identity":
168
+ return value
169
+ if op == "preview":
170
+ n = args[0] if args else None
171
+ if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
172
+ raise ValueError(
173
+ f"a maintenance `preview` projection has a non-positive-integer length "
174
+ f"bound ({n!r})."
175
+ )
176
+ if value is None:
177
+ return value
178
+ return str(value)[:n]
179
+ raise ValueError(
180
+ f"unknown maintenance projection op '{op}' (expected 'identity' / 'preview')."
181
+ )
182
+
183
+
184
+ def _build_maintain_projection(
185
+ maintain: Mapping[str, Any],
186
+ params: Mapping[str, Any],
187
+ element: Optional[Mapping[str, Any]],
188
+ ) -> Dict[str, Any]:
189
+ """Build the projected record a maintenance write mirrors onto the owner row: each
190
+ target attribute resolved from its source mutation-input field and run through its
191
+ transform. Mirrors the TS ``buildMaintainProjection``.
192
+ """
193
+ out: Dict[str, Any] = {}
194
+ for attr, transform in maintain["projection"].items():
195
+ value = _resolve_value("{" + transform["inputField"] + "}", params, element)
196
+ out[attr] = _apply_maintain_transform(
197
+ transform["op"], transform.get("args", []), value
198
+ )
199
+ return out
200
+
201
+
155
202
  def _attr_str(value: Any) -> str:
156
203
  """The string form of a serialized DynamoDB attribute value (PK/SK are strings)."""
157
204
  if isinstance(value, Mapping):
@@ -472,6 +519,18 @@ class TransactionExpander:
472
519
  self._apply_condition(condition, check, params, element)
473
520
  return {"ConditionCheck": check}
474
521
 
522
+ # #129 (Epic #118): a relation-side maintenance write — a projected `Update`
523
+ # on a SEPARATE owner row (a snapshot SET / a bounded-collection list_append),
524
+ # the branch-for-branch mirror of the TS in-process `renderMaintainWriteItem`
525
+ # and the declarative `buildMaintainUpdateItem`. The owner key was already
526
+ # resolved into `key`; the projection transform IR is applied here so the
527
+ # maintained row is byte-consistent across the runtimes.
528
+ maintain = spec.get("maintain")
529
+ if maintain is not None:
530
+ return self._build_maintain_update(
531
+ spec, maintain, table, key, params, element
532
+ )
533
+
475
534
  # Update. Two distinct DynamoDB actions, both supported (issue #85):
476
535
  # - `changes` → `SET #f = :v` (overwrite a named field);
477
536
  # - `add` → `ADD #f :delta` (atomic numeric increment, concurrency-safe;
@@ -511,6 +570,80 @@ class TransactionExpander:
511
570
  self._apply_condition(spec.get("condition"), update, params, element)
512
571
  return {"Update": update}
513
572
 
573
+ def _build_maintain_update(
574
+ self,
575
+ spec: Mapping[str, Any],
576
+ maintain: Mapping[str, Any],
577
+ table: str,
578
+ key: Dict[str, Any],
579
+ params: Mapping[str, Any],
580
+ element: Optional[Mapping[str, Any]],
581
+ ) -> Dict[str, Any]:
582
+ """Build a relation-side maintenance ``Update`` (#129/#141, Epic #118): a
583
+ ``counter`` applies an atomic scalar ``ADD #attr :delta``; a ``snapshot`` SETs
584
+ each projected attribute onto the owner row; a ``collection`` appends the
585
+ projected item into the bounded list
586
+ (``SET #c = list_append(if_not_exists(#c, :empty), :item)``). **Phase 1 is
587
+ append-only** — ``maxItems`` / ``orderBy`` are not trimmed synchronously
588
+ (#130). The branch-for-branch mirror of the TS ``renderMaintainWriteItem`` /
589
+ ``buildMaintainUpdateItem``: the owner key is already resolved (``key``), and
590
+ the projection transform IR is applied so the maintained row is
591
+ byte-consistent across the runtimes.
592
+ """
593
+ update: Dict[str, Any] = {"TableName": table, "Key": key}
594
+ kind = maintain["kind"]
595
+ # #141: a counter is a scalar atomic `ADD #attr :delta` (no projection) — the
596
+ # SAME `ADD #a0 :a0` shape the self-lifecycle derived counter (#85) builds, so a
597
+ # same-row counter ADD merges identically across runtimes.
598
+ if kind == "counter":
599
+ counter = maintain.get("counter")
600
+ if counter is None:
601
+ raise ValueError(
602
+ f"a 'counter' maintenance write on '{spec['entity']}' has no "
603
+ f"`counter` payload."
604
+ )
605
+ delta = counter["delta"]
606
+ delta = int(delta) if isinstance(delta, str) else delta
607
+ update["UpdateExpression"] = "ADD #a0 :a0"
608
+ update["ExpressionAttributeNames"] = {"#a0": counter["attribute"]}
609
+ update["ExpressionAttributeValues"] = {":a0": self._serializer.serialize(delta)}
610
+ return {"Update": update}
611
+ projection = _build_maintain_projection(maintain, params, element)
612
+ if kind == "collection":
613
+ collection = maintain.get("collection") or {}
614
+ field = collection.get("field")
615
+ if field is None:
616
+ raise ValueError(
617
+ f"a 'collection' maintenance write on '{spec['entity']}' has no "
618
+ f"`collection.field`."
619
+ )
620
+ update["UpdateExpression"] = (
621
+ "SET #c = list_append(if_not_exists(#c, :empty), :item)"
622
+ )
623
+ update["ExpressionAttributeNames"] = {"#c": field}
624
+ update["ExpressionAttributeValues"] = {
625
+ ":empty": self._serializer.serialize([]),
626
+ ":item": self._serializer.serialize([projection]),
627
+ }
628
+ else:
629
+ names: Dict[str, str] = {}
630
+ values: Dict[str, Any] = {}
631
+ sets: List[str] = []
632
+ for i, (attr, value) in enumerate(projection.items()):
633
+ names[f"#m{i}"] = attr
634
+ values[f":m{i}"] = self._serializer.serialize(value)
635
+ sets.append(f"#m{i} = :m{i}")
636
+ if not sets:
637
+ raise ValueError(
638
+ f"a 'snapshot' maintenance write on '{spec['entity']}' has an empty "
639
+ f"projection."
640
+ )
641
+ update["UpdateExpression"] = "SET " + ", ".join(sets)
642
+ update["ExpressionAttributeNames"] = names
643
+ update["ExpressionAttributeValues"] = values
644
+ # A maintenance write carries no DynamoDB write condition (it upserts the row).
645
+ return {"Update": update}
646
+
514
647
  def expand(
515
648
  self, spec: Mapping[str, Any], params: Mapping[str, Any]
516
649
  ) -> List[Dict[str, Any]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.5
3
+ Version: 0.3.0
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
@@ -29,10 +29,12 @@ tests/test_integration_contract.py
29
29
  tests/test_integration_edge_derive.py
30
30
  tests/test_integration_edge_write.py
31
31
  tests/test_integration_events.py
32
+ tests/test_integration_maintain.py
32
33
  tests/test_integration_middleware.py
33
34
  tests/test_integration_referential.py
34
35
  tests/test_integration_relations.py
35
36
  tests/test_integration_unique.py
37
+ tests/test_maintain.py
36
38
  tests/test_middleware.py
37
39
  tests/test_relations.py
38
40
  tests/test_unit.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.2.5"
7
+ version = "0.3.0"
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"
@@ -0,0 +1,337 @@
1
+ """Integration tests for #129 relation-side MAINTENANCE WRITES against DynamoDB Local.
2
+
3
+ Exercises ``GraphDDBRuntime.execute_command_method`` on the maintain contracts
4
+ generated from the same SSoT the TS in-process runtime (#127) executes:
5
+
6
+ - COLLECTION (embeddedSnapshot / ``@hasMany``): ``ThreadPostCommands.create`` fires
7
+ ``ThreadPost.created`` → ``ThreadSummary.latestPosts``. The create composes the
8
+ ThreadPost Put AND a projected ``list_append`` onto the THREADSUMMARY#<threadId>
9
+ owner row in ONE atomic ``TransactWriteItems`` — the owner row is UPSERTED and the
10
+ projected item (``postId`` identity, ``textPreview = preview(body, 120)``) appended.
11
+ - SNAPSHOT (``@belongsTo``): ``ProfileCommands.update`` fires ``Profile.updated`` →
12
+ ``ProfileCard``. The update composes the Profile Put AND a projected ``SET``
13
+ (``displayName`` identity, ``bioPreview = preview(bio, 40)``) onto the
14
+ PROFILECARD#<userId> owner row, atomically.
15
+
16
+ ACs proven on real DynamoDB (mirroring __tests__/integration/mutation-maintain.test.ts
17
+ over the SAME SSoT, so the two runtimes produce identical effects / identical rollback):
18
+
19
+ - 1 atomic tx: after ONE create, BOTH the source row and the maintained append are
20
+ present; after ONE update, BOTH the source row and the snapshot mirror are present.
21
+ - append-only (Phase 1): a second create appends a SECOND item (the first preserved).
22
+ - atomic rollback: re-creating the SAME post fails the create guard and does NOT
23
+ append a duplicate maintained item (the maintenance write rolled back WITH the
24
+ source — it is not a separate, independently-applied write).
25
+ - read-back invariant: the mirrored values match the source payload through the
26
+ projection transform (identity / preview), byte-for-byte the TS runtime's output.
27
+
28
+ Marked ``integration`` so it is skipped by ``-m "not integration"``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+
35
+ import boto3
36
+ import pytest
37
+ from botocore.config import Config
38
+
39
+ import conftest
40
+ from graphddb_runtime import GraphDDBRuntime
41
+
42
+ pytestmark = pytest.mark.integration
43
+
44
+ ENDPOINT = os.environ.get("DDB_LOCAL_ENDPOINT", "http://localhost:8000")
45
+ TABLE = "UserPermissions"
46
+
47
+
48
+ @pytest.fixture(scope="module")
49
+ def client():
50
+ cfg = Config(retries={"max_attempts": 2}, connect_timeout=2, read_timeout=5)
51
+ c = boto3.client(
52
+ "dynamodb",
53
+ endpoint_url=ENDPOINT,
54
+ region_name="local",
55
+ aws_access_key_id="fake",
56
+ aws_secret_access_key="fake",
57
+ config=cfg,
58
+ )
59
+ try:
60
+ c.list_tables()
61
+ except Exception as exc: # pragma: no cover
62
+ pytest.skip(f"DynamoDB Local not reachable at {ENDPOINT}: {exc}")
63
+ yield c
64
+
65
+
66
+ @pytest.fixture(scope="module")
67
+ def table(client):
68
+ try:
69
+ client.delete_table(TableName=TABLE)
70
+ client.get_waiter("table_not_exists").wait(TableName=TABLE)
71
+ except Exception:
72
+ pass
73
+ client.create_table(
74
+ TableName=TABLE,
75
+ KeySchema=[
76
+ {"AttributeName": "PK", "KeyType": "HASH"},
77
+ {"AttributeName": "SK", "KeyType": "RANGE"},
78
+ ],
79
+ AttributeDefinitions=[
80
+ {"AttributeName": "PK", "AttributeType": "S"},
81
+ {"AttributeName": "SK", "AttributeType": "S"},
82
+ {"AttributeName": "GSI1PK", "AttributeType": "S"},
83
+ {"AttributeName": "GSI1SK", "AttributeType": "S"},
84
+ ],
85
+ GlobalSecondaryIndexes=[
86
+ {
87
+ "IndexName": "GSI1",
88
+ "KeySchema": [
89
+ {"AttributeName": "GSI1PK", "KeyType": "HASH"},
90
+ {"AttributeName": "GSI1SK", "KeyType": "RANGE"},
91
+ ],
92
+ "Projection": {"ProjectionType": "ALL"},
93
+ }
94
+ ],
95
+ BillingMode="PAY_PER_REQUEST",
96
+ )
97
+ client.get_waiter("table_exists").wait(TableName=TABLE)
98
+ yield TABLE
99
+ client.delete_table(TableName=TABLE)
100
+
101
+
102
+ @pytest.fixture()
103
+ def rt(client, table):
104
+ return GraphDDBRuntime(
105
+ dynamodb_client=client,
106
+ manifest_path=conftest.MANIFEST_PATH,
107
+ operations_path=conftest.OPERATIONS_PATH,
108
+ )
109
+
110
+
111
+ def _row(client, pk, sk):
112
+ res = client.get_item(TableName=TABLE, Key={"PK": {"S": pk}, "SK": {"S": sk}})
113
+ return res.get("Item")
114
+
115
+
116
+ # ── COLLECTION maintainer: ThreadPost.created → ThreadSummary.latestPosts ─────────
117
+
118
+
119
+ def test_collection_create_writes_post_and_appends_maintained_item_atomically(rt, client):
120
+ result = rt.execute_command_method(
121
+ "ThreadPostCommands",
122
+ "create",
123
+ {"threadId": "it1", "postId": "p1"},
124
+ {
125
+ "threadId": "it1",
126
+ "postId": "p1",
127
+ "body": "hello world",
128
+ "createdAt": "2026-06-01T00:00:00.000Z",
129
+ },
130
+ )
131
+ # The read-back projection of the source write.
132
+ assert result == {"threadId": "it1", "postId": "p1", "body": "hello world"}
133
+
134
+ # The ThreadPost row persisted (the base write).
135
+ post = _row(client, "THREAD#it1", "POST#p1")
136
+ assert post is not None and post["body"]["S"] == "hello world"
137
+
138
+ # The ThreadSummary owner row was UPSERTED and the maintained collection item
139
+ # appended — in the SAME transaction as the post Put.
140
+ summary = _row(client, "THREADSUMMARY#it1", "SUMMARY")
141
+ assert summary is not None
142
+ assert summary["latestPosts"] == {
143
+ "L": [{"M": {"postId": {"S": "p1"}, "textPreview": {"S": "hello world"}}}]
144
+ }
145
+
146
+ # #141: the SAME ThreadPost.created ALSO incremented the ThreadCounter.postCount
147
+ # counter (+1, on a DIFFERENT owner row) — upserted from absent in the same tx.
148
+ counter = _row(client, "THREADCOUNT#it1", "COUNT")
149
+ assert counter is not None and counter["postCount"]["N"] == "1"
150
+
151
+
152
+ def test_collection_second_create_appends_second_projected_item(rt, client):
153
+ long_body = "x" * 300
154
+ rt.execute_command_method(
155
+ "ThreadPostCommands",
156
+ "create",
157
+ {"threadId": "it1", "postId": "p2"},
158
+ {
159
+ "threadId": "it1",
160
+ "postId": "p2",
161
+ "body": long_body,
162
+ "createdAt": "2026-06-02T00:00:00.000Z",
163
+ },
164
+ )
165
+ summary = _row(client, "THREADSUMMARY#it1", "SUMMARY")
166
+ items = summary["latestPosts"]["L"]
167
+ assert len(items) == 2
168
+ # The first item is preserved (append, not overwrite).
169
+ assert items[0] == {"M": {"postId": {"S": "p1"}, "textPreview": {"S": "hello world"}}}
170
+ # The second item's textPreview is preview(body, 120) — exactly 120 chars.
171
+ assert items[1]["M"]["postId"]["S"] == "p2"
172
+ assert len(items[1]["M"]["textPreview"]["S"]) == 120
173
+ assert items[1]["M"]["textPreview"]["S"] == "x" * 120
174
+
175
+
176
+ def test_collection_atomic_rollback_no_duplicate_append(rt, client):
177
+ # Before: latestPosts has 2 items.
178
+ before = _row(client, "THREADSUMMARY#it1", "SUMMARY")
179
+ assert len(before["latestPosts"]["L"]) == 2
180
+
181
+ # Re-create p1 — the entity Put's attribute_not_exists(PK) guard fails, cancelling
182
+ # the WHOLE TransactWriteItems (the source Put + the maintenance append).
183
+ with pytest.raises(Exception):
184
+ rt.execute_command_method(
185
+ "ThreadPostCommands",
186
+ "create",
187
+ {"threadId": "it1", "postId": "p1"},
188
+ {
189
+ "threadId": "it1",
190
+ "postId": "p1",
191
+ "body": "duplicate",
192
+ "createdAt": "2026-06-03T00:00:00.000Z",
193
+ },
194
+ )
195
+ # Proof of atomic rollback: the maintained collection was NOT appended a third time
196
+ # — still exactly the 2 prior items (the maintenance write rolled back WITH the
197
+ # failed source write; it is not a separate, independently-applied write).
198
+ after = _row(client, "THREADSUMMARY#it1", "SUMMARY")
199
+ assert len(after["latestPosts"]["L"]) == 2
200
+
201
+
202
+ # ── SNAPSHOT maintainer: Profile.updated → ProfileCard mirror ─────────────────────
203
+
204
+
205
+ def test_snapshot_update_mirrors_projected_row_into_owner_atomically(rt, client):
206
+ # Seed the Profile so the `update` has a row to write (and the snapshot has a source).
207
+ client.put_item(
208
+ TableName=TABLE,
209
+ Item={
210
+ "PK": {"S": "PROFILE#u1"},
211
+ "SK": {"S": "META"},
212
+ "userId": {"S": "u1"},
213
+ "displayName": {"S": "old name"},
214
+ "bio": {"S": "old bio"},
215
+ },
216
+ )
217
+ bio = (
218
+ "mathematician and the first to recognise that the analytical engine "
219
+ "had applications beyond pure calculation"
220
+ )
221
+ result = rt.execute_command_method(
222
+ "ProfileCommands",
223
+ "update",
224
+ {"userId": "u1"},
225
+ {"userId": "u1", "displayName": "Ada Lovelace", "bio": bio},
226
+ )
227
+ assert result == {"userId": "u1", "displayName": "Ada Lovelace"}
228
+
229
+ # The Profile row updated (base write).
230
+ profile = _row(client, "PROFILE#u1", "META")
231
+ assert profile["displayName"]["S"] == "Ada Lovelace"
232
+
233
+ # The ProfileCard owner row was UPSERTED with the projected snapshot — same tx.
234
+ card = _row(client, "PROFILECARD#u1", "CARD")
235
+ assert card is not None
236
+ assert card["displayName"]["S"] == "Ada Lovelace"
237
+ # bioPreview = preview(bio, 40) — the first 40 characters.
238
+ assert card["bioPreview"]["S"] == bio[:40]
239
+ assert len(card["bioPreview"]["S"]) == 40
240
+
241
+
242
+ # ── #141 COUNTER maintainer: ThreadPost.created/removed → ThreadCounter.postCount ──
243
+
244
+
245
+ def test_counter_create_increments_postcount_plus_one_atomically(rt, client):
246
+ """`ThreadPost.created` → ThreadCounter.postCount += 1: the create composes the
247
+ ThreadPost Put AND an atomic `ADD postCount 1` onto the THREADCOUNT#<threadId> owner
248
+ row in ONE atomic transact_write_items (upserted from absent)."""
249
+ rt.execute_command_method(
250
+ "ThreadPostCommands",
251
+ "create",
252
+ {"threadId": "ctC", "postId": "cp1"},
253
+ {
254
+ "threadId": "ctC",
255
+ "postId": "cp1",
256
+ "body": "first",
257
+ "createdAt": "2026-06-01T00:00:00.000Z",
258
+ },
259
+ )
260
+ assert _row(client, "THREAD#ctC", "POST#cp1") is not None
261
+ counter = _row(client, "THREADCOUNT#ctC", "COUNT")
262
+ assert counter is not None and counter["postCount"]["N"] == "1"
263
+
264
+ # A second create increments to 2 (concurrency-safe ADD, no read-modify-write).
265
+ rt.execute_command_method(
266
+ "ThreadPostCommands",
267
+ "create",
268
+ {"threadId": "ctC", "postId": "cp2"},
269
+ {
270
+ "threadId": "ctC",
271
+ "postId": "cp2",
272
+ "body": "second",
273
+ "createdAt": "2026-06-02T00:00:00.000Z",
274
+ },
275
+ )
276
+ counter = _row(client, "THREADCOUNT#ctC", "COUNT")
277
+ assert counter["postCount"]["N"] == "2"
278
+
279
+
280
+ def test_counter_remove_decrements_postcount_minus_one_atomically(rt, client):
281
+ """`ThreadPost.removed` → ThreadCounter.postCount -= 1: the remove composes the
282
+ ThreadPost Delete AND an atomic `ADD postCount -1` in ONE atomic tx."""
283
+ # Seed a counter at 2 and a ThreadPost row to remove.
284
+ client.put_item(
285
+ TableName=TABLE,
286
+ Item={
287
+ "PK": {"S": "THREADCOUNT#ctR"},
288
+ "SK": {"S": "COUNT"},
289
+ "threadId": {"S": "ctR"},
290
+ "postCount": {"N": "2"},
291
+ },
292
+ )
293
+ client.put_item(
294
+ TableName=TABLE,
295
+ Item={
296
+ "PK": {"S": "THREAD#ctR"},
297
+ "SK": {"S": "POST#cpR"},
298
+ "threadId": {"S": "ctR"},
299
+ "postId": {"S": "cpR"},
300
+ "body": {"S": "to remove"},
301
+ "createdAt": {"S": "2026-06-02T00:00:00.000Z"},
302
+ },
303
+ )
304
+ rt.execute_command_method(
305
+ "ThreadPostCommands",
306
+ "remove",
307
+ {"threadId": "ctR", "postId": "cpR"},
308
+ {"threadId": "ctR", "postId": "cpR"},
309
+ )
310
+ # The base post row removed.
311
+ assert _row(client, "THREAD#ctR", "POST#cpR") is None
312
+ # The counter decremented (2 → 1) by the atomic ADD(-1) in the same tx.
313
+ counter = _row(client, "THREADCOUNT#ctR", "COUNT")
314
+ assert counter["postCount"]["N"] == "1"
315
+
316
+
317
+ def test_counter_atomic_rollback_no_double_increment(rt, client):
318
+ """Re-creating the same post fails the create guard and the WHOLE tx rolls back —
319
+ the counter is NOT double-incremented (the ADD rode the failed source Put)."""
320
+ rt.execute_command_method(
321
+ "ThreadPostCommands",
322
+ "create",
323
+ {"threadId": "ctX", "postId": "cpX"},
324
+ {"threadId": "ctX", "postId": "cpX", "body": "one", "createdAt": "2026-06-01T00:00:00.000Z"},
325
+ )
326
+ before = _row(client, "THREADCOUNT#ctX", "COUNT")
327
+ assert before["postCount"]["N"] == "1"
328
+
329
+ with pytest.raises(Exception):
330
+ rt.execute_command_method(
331
+ "ThreadPostCommands",
332
+ "create",
333
+ {"threadId": "ctX", "postId": "cpX"},
334
+ {"threadId": "ctX", "postId": "cpX", "body": "dup", "createdAt": "2026-06-02T00:00:00.000Z"},
335
+ )
336
+ after = _row(client, "THREADCOUNT#ctX", "COUNT")
337
+ assert after["postCount"]["N"] == "1"
@@ -0,0 +1,280 @@
1
+ """Unit tests for relation-side MAINTENANCE WRITES (#129, Epic #118).
2
+
3
+ The Python mirror of the TS in-process `renderMaintainWriteItem`
4
+ (`src/runtime/command-runtime.ts`) and the declarative `buildMaintainUpdateItem`
5
+ (`src/operations/declarative-transaction.ts`). These prove the Python runtime
6
+ realizes the serialized maintenance shape — the projection transform IR
7
+ (`identity` / `preview`) + the bounded-collection append — IDENTICALLY, the same
8
+ way the conformance golden proves it end-to-end against DynamoDB Local.
9
+
10
+ No network: the maintain command is driven through `execute_command_method`
11
+ against a recording fake client that captures the `transact_write_items` request,
12
+ so the projected `Update` item (the snapshot `SET` / the collection `list_append`)
13
+ is asserted directly.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+ from boto3.dynamodb.types import TypeDeserializer
20
+
21
+ from graphddb_runtime import GraphDDBRuntime
22
+ from graphddb_runtime.transactions import (
23
+ _apply_maintain_transform,
24
+ _build_maintain_projection,
25
+ )
26
+
27
+ import conftest
28
+
29
+ _DESER = TypeDeserializer()
30
+
31
+
32
+ def make_runtime(client, **kw) -> GraphDDBRuntime:
33
+ return GraphDDBRuntime(
34
+ dynamodb_client=client,
35
+ manifest_path=conftest.MANIFEST_PATH,
36
+ operations_path=conftest.OPERATIONS_PATH,
37
+ **kw,
38
+ )
39
+
40
+
41
+ def plain(av_map):
42
+ return {k: _DESER.deserialize(v) for k, v in av_map.items()}
43
+
44
+
45
+ class FakeClient:
46
+ def __init__(self):
47
+ self.calls = []
48
+
49
+ def transact_write_items(self, **kwargs):
50
+ self.calls.append(("transact_write_items", kwargs))
51
+ return {}
52
+
53
+ # The maintain commands declare a `result.select`, so a consistent read-back
54
+ # GetItem follows the write; return an empty item (the read-back parity is
55
+ # covered by conformance, not this unit test).
56
+ def get_item(self, **kwargs):
57
+ self.calls.append(("get_item", kwargs))
58
+ return {}
59
+
60
+
61
+ # ── the transform IR mirror (applyProjectionTransform parity) ────────────────────
62
+
63
+
64
+ def test_identity_transform_copies_value_through():
65
+ assert _apply_maintain_transform("identity", [], "hello") == "hello"
66
+ assert _apply_maintain_transform("identity", [], 42) == 42
67
+ assert _apply_maintain_transform("identity", [], None) is None
68
+
69
+
70
+ def test_preview_transform_keeps_first_n_chars():
71
+ # Exactly the TS `applyProjectionTransform('preview', [n], value)` semantics:
72
+ # the first n characters of the string form; a short value is unchanged.
73
+ assert _apply_maintain_transform("preview", [5], "hello world") == "hello"
74
+ assert _apply_maintain_transform("preview", [120], "short") == "short"
75
+ # A non-string is stringified first (matching `String(value).slice(0, n)`).
76
+ assert _apply_maintain_transform("preview", [2], 12345) == "12"
77
+ # None passes through (a missing source value is not previewed).
78
+ assert _apply_maintain_transform("preview", [10], None) is None
79
+
80
+
81
+ def test_preview_transform_rejects_non_positive_bound():
82
+ for bad in (0, -1, 1.5, "x", None):
83
+ with pytest.raises(ValueError):
84
+ _apply_maintain_transform("preview", [bad], "value")
85
+
86
+
87
+ def test_unknown_transform_op_rejects_loudly():
88
+ with pytest.raises(ValueError):
89
+ _apply_maintain_transform("uppercase", [], "value")
90
+
91
+
92
+ def test_build_maintain_projection_applies_each_transform():
93
+ maintain = {
94
+ "projection": {
95
+ "postId": {"op": "identity", "args": [], "inputField": "postId"},
96
+ "textPreview": {"op": "preview", "args": [5], "inputField": "body"},
97
+ }
98
+ }
99
+ out = _build_maintain_projection(
100
+ maintain, {"postId": "p1", "body": "hello world"}, None
101
+ )
102
+ assert out == {"postId": "p1", "textPreview": "hello"}
103
+
104
+
105
+ # ── the collection maintainer (embeddedSnapshot / hasMany) ───────────────────────
106
+
107
+
108
+ def test_collection_maintainer_appends_projected_item_via_list_append():
109
+ """`ThreadPost.created` → ThreadSummary.latestPosts: the create composes the
110
+ ThreadPost Put AND a `list_append` onto the THREADSUMMARY#<threadId> owner row in
111
+ ONE atomic transact_write_items — the Python mirror of the TS in-process append."""
112
+ client = FakeClient()
113
+ rt = make_runtime(client)
114
+ rt.execute_command_method(
115
+ "ThreadPostCommands",
116
+ "create",
117
+ {"threadId": "t1", "postId": "p1"},
118
+ {
119
+ "threadId": "t1",
120
+ "postId": "p1",
121
+ "body": "hello world",
122
+ "createdAt": "2026-06-01T00:00:00.000Z",
123
+ },
124
+ )
125
+ write_calls = [c for c in client.calls if c[0] == "transact_write_items"]
126
+ assert len(write_calls) == 1
127
+ items = write_calls[0][1]["TransactItems"]
128
+ # The base ThreadPost Put + the collection maintenance Update on the THREADSUMMARY
129
+ # owner row + (#141) the counter ADD on the THREADCOUNT owner row.
130
+ puts = [it["Put"] for it in items if "Put" in it]
131
+ updates = [it["Update"] for it in items if "Update" in it]
132
+ assert len(puts) == 1
133
+ assert plain(puts[0]["Item"])["PK"] == "THREAD#t1"
134
+
135
+ # Select the collection maintainer by its owner-row key (the counter Update targets
136
+ # a DIFFERENT row, THREADCOUNT#t1, so the two never collide).
137
+ upd = next(u for u in updates if plain(u["Key"])["PK"] == "THREADSUMMARY#t1")
138
+ assert plain(upd["Key"]) == {"PK": "THREADSUMMARY#t1", "SK": "SUMMARY"}
139
+ # Append-only: list_append(if_not_exists(#c, :empty), :item) onto `latestPosts`.
140
+ assert (
141
+ upd["UpdateExpression"]
142
+ == "SET #c = list_append(if_not_exists(#c, :empty), :item)"
143
+ )
144
+ assert upd["ExpressionAttributeNames"] == {"#c": "latestPosts"}
145
+ vals = upd["ExpressionAttributeValues"]
146
+ assert _DESER.deserialize(vals[":empty"]) == []
147
+ # The appended item: postId identity, textPreview = preview(body, 120).
148
+ appended = _DESER.deserialize(vals[":item"])
149
+ assert appended == [{"postId": "p1", "textPreview": "hello world"}]
150
+
151
+
152
+ def test_collection_maintainer_preview_truncates_long_body_to_120():
153
+ client = FakeClient()
154
+ rt = make_runtime(client)
155
+ long_body = "x" * 300
156
+ rt.execute_command_method(
157
+ "ThreadPostCommands",
158
+ "create",
159
+ {"threadId": "t2", "postId": "p2"},
160
+ {
161
+ "threadId": "t2",
162
+ "postId": "p2",
163
+ "body": long_body,
164
+ "createdAt": "2026-06-02T00:00:00.000Z",
165
+ },
166
+ )
167
+ items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
168
+ "TransactItems"
169
+ ]
170
+ # Select the collection maintainer (THREADSUMMARY) — the #141 counter Update targets
171
+ # THREADCOUNT and carries no `:item`.
172
+ updates = [it["Update"] for it in items if "Update" in it]
173
+ upd = next(u for u in updates if plain(u["Key"])["PK"] == "THREADSUMMARY#t2")
174
+ appended = _DESER.deserialize(upd["ExpressionAttributeValues"][":item"])
175
+ assert len(appended[0]["textPreview"]) == 120
176
+ assert appended[0]["textPreview"] == "x" * 120
177
+
178
+
179
+ # ── the snapshot maintainer (belongsTo) ──────────────────────────────────────────
180
+
181
+
182
+ def test_snapshot_maintainer_sets_projected_attributes_onto_owner_row():
183
+ """`Profile.updated` → ProfileCard snapshot: the update composes the Profile Put
184
+ AND a `SET` of each projected attribute onto the PROFILECARD#<userId> owner row in
185
+ ONE atomic transact_write_items — the Python mirror of the TS in-process snapshot."""
186
+ client = FakeClient()
187
+ rt = make_runtime(client)
188
+ bio = (
189
+ "mathematician and the first to recognise that the analytical engine "
190
+ "had applications beyond pure calculation"
191
+ )
192
+ rt.execute_command_method(
193
+ "ProfileCommands",
194
+ "update",
195
+ {"userId": "u1"},
196
+ {"userId": "u1", "displayName": "Ada Lovelace", "bio": bio},
197
+ )
198
+ items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
199
+ "TransactItems"
200
+ ]
201
+ updates = [it["Update"] for it in items if "Update" in it]
202
+ # The base Profile Update (the `update` write) + the maintenance Update on the card.
203
+ card = next(
204
+ u for u in updates if plain(u["Key"])["PK"] == "PROFILECARD#u1"
205
+ )
206
+ assert plain(card["Key"]) == {"PK": "PROFILECARD#u1", "SK": "CARD"}
207
+ assert card["UpdateExpression"].startswith("SET ")
208
+ names = card["ExpressionAttributeNames"]
209
+ values = {k: _DESER.deserialize(v) for k, v in card["ExpressionAttributeValues"].items()}
210
+ # Reconstruct attr -> value from the #mN / :mN aliases.
211
+ projected = {}
212
+ for name_alias, attr in names.items():
213
+ value_alias = ":" + name_alias[1:]
214
+ projected[attr] = values[value_alias]
215
+ assert projected["displayName"] == "Ada Lovelace"
216
+ # bioPreview = preview(bio, 40) — exactly the first 40 chars.
217
+ assert projected["bioPreview"] == bio[:40]
218
+ assert len(projected["bioPreview"]) == 40
219
+
220
+
221
+ # ── the counter maintainer (@aggregate / count(), #141) ──────────────────────────
222
+
223
+
224
+ def _counter_update(items):
225
+ """The THREADCOUNT (counter) Update among a create/remove's transact items."""
226
+ updates = [it["Update"] for it in items if "Update" in it]
227
+ return next(u for u in updates if plain(u["Key"])["PK"].startswith("THREADCOUNT#"))
228
+
229
+
230
+ def test_counter_maintainer_adds_plus_one_on_create():
231
+ """`ThreadPost.created` → ThreadCounter.postCount += 1: the create composes the
232
+ ThreadPost Put AND an atomic `ADD postCount 1` onto the THREADCOUNT#<threadId> owner
233
+ row in ONE atomic transact_write_items — the Python mirror of the TS in-process
234
+ counter render. The counter row is a DIFFERENT owner from the collection
235
+ (THREADSUMMARY), so they never collide."""
236
+ client = FakeClient()
237
+ rt = make_runtime(client)
238
+ rt.execute_command_method(
239
+ "ThreadPostCommands",
240
+ "create",
241
+ {"threadId": "t1", "postId": "p1"},
242
+ {
243
+ "threadId": "t1",
244
+ "postId": "p1",
245
+ "body": "hello world",
246
+ "createdAt": "2026-06-01T00:00:00.000Z",
247
+ },
248
+ )
249
+ items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
250
+ "TransactItems"
251
+ ]
252
+ upd = _counter_update(items)
253
+ assert plain(upd["Key"]) == {"PK": "THREADCOUNT#t1", "SK": "COUNT"}
254
+ # An atomic `ADD #a0 :a0` of +1 (concurrency-safe, no prior read).
255
+ assert upd["UpdateExpression"] == "ADD #a0 :a0"
256
+ assert upd["ExpressionAttributeNames"] == {"#a0": "postCount"}
257
+ assert _DESER.deserialize(upd["ExpressionAttributeValues"][":a0"]) == 1
258
+
259
+
260
+ def test_counter_maintainer_adds_minus_one_on_remove():
261
+ """`ThreadPost.removed` → ThreadCounter.postCount -= 1: the remove composes the
262
+ ThreadPost Delete AND an atomic `ADD postCount -1` onto the THREADCOUNT owner row."""
263
+ client = FakeClient()
264
+ rt = make_runtime(client)
265
+ rt.execute_command_method(
266
+ "ThreadPostCommands",
267
+ "remove",
268
+ {"threadId": "t9", "postId": "p9"},
269
+ {"threadId": "t9", "postId": "p9"},
270
+ )
271
+ items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
272
+ "TransactItems"
273
+ ]
274
+ # The base ThreadPost Delete + the counter ADD(-1).
275
+ deletes = [it["Delete"] for it in items if "Delete" in it]
276
+ assert any(plain(d["Key"])["PK"] == "THREAD#t9" for d in deletes)
277
+ upd = _counter_update(items)
278
+ assert upd["UpdateExpression"] == "ADD #a0 :a0"
279
+ assert upd["ExpressionAttributeNames"] == {"#a0": "postCount"}
280
+ assert _DESER.deserialize(upd["ExpressionAttributeValues"][":a0"]) == -1