etlantic-sqlmodel 0.40.0__tar.gz → 0.41.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 (18) hide show
  1. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/PKG-INFO +6 -6
  2. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/README.md +4 -4
  3. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/pyproject.toml +2 -2
  4. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/__init__.py +1 -1
  5. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/__init__.py +10 -0
  6. etlantic_sqlmodel-0.41.0/src/etlantic_sqlmodel/control_plane/durable_stores.py +319 -0
  7. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/models.py +21 -1
  8. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_backup.py +103 -22
  9. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_stores.py +96 -67
  10. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/stores.py +15 -7
  11. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/__init__.py +1 -1
  12. etlantic_sqlmodel-0.41.0/src/etlantic_sqlmodel/migrations/versions/002_durable_cp3.py +33 -0
  13. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/.gitignore +0 -0
  14. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_search.py +0 -0
  15. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/session.py +0 -0
  16. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/versions/001_registry_cp2.py +0 -0
  17. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/versions/__init__.py +0 -0
  18. {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlantic-sqlmodel
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: ContractModel ↔ SQLModel bridge for ETLantic.
5
5
  Project-URL: Homepage, https://github.com/eddiethedean/etlantic
6
6
  Project-URL: Documentation, https://github.com/eddiethedean/etlantic/tree/main/docs
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.11
21
- Requires-Dist: etlantic<0.41,>=0.40.0
21
+ Requires-Dist: etlantic<0.42,>=0.41.0
22
22
  Requires-Dist: sqlmodel<1,>=0.0.22
23
23
  Description-Content-Type: text/markdown
24
24
 
@@ -28,13 +28,13 @@ Optional bridge between ETLantic `Data` contracts and
28
28
  [SQLModel](https://sqlmodel.tiangolo.com/) table models, plus optional CP1
29
29
  control-plane reference stores. Install when you need `contract_to_sqlmodel`
30
30
  helpers or SQLModel-backed definition/submission stores for local CP1 demos.
31
- Package version is **0.39.0** — pin with core.
31
+ Package version is **0.41.0** — pin with core.
32
32
 
33
33
  ## Install
34
34
 
35
35
  ```bash
36
- pip install 'etlantic-sqlmodel==0.40.0'
37
- # pip install 'etlantic==0.40.0'
36
+ pip install 'etlantic-sqlmodel==0.41.0'
37
+ # pip install 'etlantic==0.41.0'
38
38
  ```
39
39
 
40
40
  ## Schema bridge
@@ -93,6 +93,6 @@ by a durable database URL. They are not a production multi-tenant claim.
93
93
 
94
94
  ## Links
95
95
 
96
- [Optional packages](https://etlantic.readthedocs.io/en/v0.40.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
96
+ [Optional packages](https://etlantic.readthedocs.io/en/v0.41.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
97
97
  [Source](https://github.com/eddiethedean/etlantic/tree/main/packages/etlantic-sqlmodel) ·
98
98
  [Issues](https://github.com/eddiethedean/etlantic/issues)
@@ -4,13 +4,13 @@ Optional bridge between ETLantic `Data` contracts and
4
4
  [SQLModel](https://sqlmodel.tiangolo.com/) table models, plus optional CP1
5
5
  control-plane reference stores. Install when you need `contract_to_sqlmodel`
6
6
  helpers or SQLModel-backed definition/submission stores for local CP1 demos.
7
- Package version is **0.39.0** — pin with core.
7
+ Package version is **0.41.0** — pin with core.
8
8
 
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- pip install 'etlantic-sqlmodel==0.40.0'
13
- # pip install 'etlantic==0.40.0'
12
+ pip install 'etlantic-sqlmodel==0.41.0'
13
+ # pip install 'etlantic==0.41.0'
14
14
  ```
15
15
 
16
16
  ## Schema bridge
@@ -69,6 +69,6 @@ by a durable database URL. They are not a production multi-tenant claim.
69
69
 
70
70
  ## Links
71
71
 
72
- [Optional packages](https://etlantic.readthedocs.io/en/v0.40.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
72
+ [Optional packages](https://etlantic.readthedocs.io/en/v0.41.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
73
73
  [Source](https://github.com/eddiethedean/etlantic/tree/main/packages/etlantic-sqlmodel) ·
74
74
  [Issues](https://github.com/eddiethedean/etlantic/issues)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "etlantic-sqlmodel"
3
- version = "0.40.0"
3
+ version = "0.41.0"
4
4
  description = "ContractModel ↔ SQLModel bridge for ETLantic."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  ]
19
19
  dependencies = [
20
- "etlantic>=0.40.0,<0.41",
20
+ "etlantic>=0.41.0,<0.42",
21
21
  "sqlmodel>=0.0.22,<1",
22
22
  ]
23
23
 
@@ -52,7 +52,7 @@ from etlantic_sqlmodel.migrations import (
52
52
  )
53
53
  from sqlmodel import Field, SQLModel
54
54
 
55
- __version__ = "0.40.0"
55
+ __version__ = "0.41.0"
56
56
 
57
57
  __all__ = [
58
58
  "AliasRow",
@@ -9,9 +9,15 @@ tests/demos — production must apply versioned migrations under
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from etlantic_sqlmodel.control_plane.durable_stores import (
13
+ DURABLE_TABLES,
14
+ SQLModelDurableWorkStore,
15
+ create_durable_tables,
16
+ )
12
17
  from etlantic_sqlmodel.control_plane.models import (
13
18
  AliasRow,
14
19
  DefinitionRow,
20
+ DurableSnapshotRow,
15
21
  EnvironmentRow,
16
22
  EventRow,
17
23
  LogicalIdentityRow,
@@ -55,16 +61,19 @@ from etlantic_sqlmodel.control_plane.stores import (
55
61
 
56
62
  __all__ = [
57
63
  "BACKUP_SCHEMA",
64
+ "DURABLE_TABLES",
58
65
  "REGISTRY_TABLES",
59
66
  "AliasRow",
60
67
  "BackupTranscript",
61
68
  "DefinitionRow",
69
+ "DurableSnapshotRow",
62
70
  "EnvironmentRow",
63
71
  "EventRow",
64
72
  "LogicalIdentityRow",
65
73
  "PromotionRow",
66
74
  "RevisionRow",
67
75
  "SQLModelDefinitionRepository",
76
+ "SQLModelDurableWorkStore",
68
77
  "SQLModelSubmissionStore",
69
78
  "SecurityDomainRow",
70
79
  "SqlModelEventStore",
@@ -78,6 +87,7 @@ __all__ = [
78
87
  "backup_round_trip",
79
88
  "collect_revision_hits",
80
89
  "create_control_plane_tables",
90
+ "create_durable_tables",
81
91
  "create_registry_tables",
82
92
  "create_sqlite_engine",
83
93
  "dump_registry_sqlite",
@@ -0,0 +1,319 @@
1
+ """SQLModel-backed DurableWorkStore (CP3 / 041-P).
2
+
3
+ Reference provider: each mutating call loads the durable snapshot inside a
4
+ database transaction, applies MemoryDurableWorkStore semantics, and writes the
5
+ snapshot back with an optimistic ``payload_version`` check before commit.
6
+
7
+ Production note: apply versioned migrations — do not rely on
8
+ ``create_durable_tables`` as the sole schema path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import asdict
16
+ from datetime import UTC, datetime
17
+ from typing import Any, TypeVar
18
+
19
+ from sqlalchemy.engine import Engine
20
+
21
+ from etlantic.control_plane.durable_memory import MemoryDurableWorkStore
22
+ from etlantic.control_plane.durable_models import (
23
+ AttemptRecord,
24
+ BaselineAcknowledgement,
25
+ CheckpointRecord,
26
+ DiffRecord,
27
+ EffectRecord,
28
+ LeaseRecord,
29
+ OutboxRecord,
30
+ PreviewWorkspace,
31
+ ShadowRunRecord,
32
+ StateDiagnostic,
33
+ SubmissionRecord,
34
+ )
35
+ from etlantic.control_plane.errors import ControlPlaneError
36
+ from etlantic.control_plane.models import ControlPlaneContext
37
+ from etlantic_sqlmodel.control_plane.models import DurableSnapshotRow
38
+ from etlantic_sqlmodel.control_plane.session import session_scope
39
+ from sqlmodel import Session, SQLModel, select
40
+
41
+ T = TypeVar("T")
42
+
43
+ DURABLE_TABLES = (DurableSnapshotRow,)
44
+
45
+
46
+ def create_durable_tables(engine: Engine) -> None:
47
+ """Create CP3 durable tables (tests/demos only)."""
48
+ SQLModel.metadata.create_all(
49
+ engine,
50
+ tables=[cls.__table__ for cls in DURABLE_TABLES], # type: ignore[list-item]
51
+ )
52
+
53
+
54
+ def _utcnow_iso() -> str:
55
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
56
+
57
+
58
+ def _encode_key(parts: tuple[str, ...]) -> str:
59
+ return json.dumps(list(parts), separators=(",", ":"))
60
+
61
+
62
+ def _decode_key(text: str) -> tuple[str, ...]:
63
+ raw = json.loads(text)
64
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
65
+ raise ValueError("durable snapshot key must be a JSON string array")
66
+ return tuple(raw)
67
+
68
+
69
+ def _dump_store(store: MemoryDurableWorkStore) -> dict[str, Any]:
70
+ return {
71
+ "admission_limit": store.admission_limit,
72
+ "submissions": {
73
+ _encode_key(k): asdict(v) for k, v in store._submissions.items()
74
+ },
75
+ "idempotency": {_encode_key(k): v for k, v in store._idempotency.items()},
76
+ "outbox": {_encode_key(k): asdict(v) for k, v in store._outbox.items()},
77
+ "leases": {_encode_key(k): asdict(v) for k, v in store._leases.items()},
78
+ "attempts": {_encode_key(k): asdict(v) for k, v in store._attempts.items()},
79
+ "checkpoints": {
80
+ _encode_key(k): asdict(v) for k, v in store._checkpoints.items()
81
+ },
82
+ "effects": {_encode_key(k): asdict(v) for k, v in store._effects.items()},
83
+ "previews": {_encode_key(k): asdict(v) for k, v in store._previews.items()},
84
+ "diffs": {_encode_key(k): asdict(v) for k, v in store._diffs.items()},
85
+ "shadows": {_encode_key(k): asdict(v) for k, v in store._shadows.items()},
86
+ "baselines": {_encode_key(k): asdict(v) for k, v in store._baselines.items()},
87
+ "diagnostics": [asdict(d) for d in store._diagnostics],
88
+ }
89
+
90
+
91
+ def _load_store(payload: Mapping[str, Any]) -> MemoryDurableWorkStore:
92
+ store = MemoryDurableWorkStore(admission_limit=payload.get("admission_limit"))
93
+ store._submissions = {
94
+ _decode_key(k): SubmissionRecord(**v) # type: ignore[arg-type]
95
+ for k, v in dict(payload.get("submissions") or {}).items()
96
+ }
97
+ store._idempotency = {
98
+ _decode_key(k): v for k, v in dict(payload.get("idempotency") or {}).items()
99
+ }
100
+ store._outbox = {
101
+ _decode_key(k): OutboxRecord(**v) # type: ignore[arg-type]
102
+ for k, v in dict(payload.get("outbox") or {}).items()
103
+ }
104
+ store._leases = {
105
+ _decode_key(k): LeaseRecord(**v) # type: ignore[arg-type]
106
+ for k, v in dict(payload.get("leases") or {}).items()
107
+ }
108
+ store._attempts = {
109
+ _decode_key(k): AttemptRecord(**v) # type: ignore[arg-type]
110
+ for k, v in dict(payload.get("attempts") or {}).items()
111
+ }
112
+ store._checkpoints = {
113
+ _decode_key(k): CheckpointRecord(**v) # type: ignore[arg-type]
114
+ for k, v in dict(payload.get("checkpoints") or {}).items()
115
+ }
116
+ store._effects = {
117
+ _decode_key(k): EffectRecord(**v) # type: ignore[arg-type]
118
+ for k, v in dict(payload.get("effects") or {}).items()
119
+ }
120
+ store._previews = {
121
+ _decode_key(k): PreviewWorkspace(**v) # type: ignore[arg-type]
122
+ for k, v in dict(payload.get("previews") or {}).items()
123
+ }
124
+ store._diffs = {
125
+ _decode_key(k): DiffRecord(**v) # type: ignore[arg-type]
126
+ for k, v in dict(payload.get("diffs") or {}).items()
127
+ }
128
+ store._shadows = {
129
+ _decode_key(k): ShadowRunRecord(**v) # type: ignore[arg-type]
130
+ for k, v in dict(payload.get("shadows") or {}).items()
131
+ }
132
+ store._baselines = {
133
+ _decode_key(k): BaselineAcknowledgement(**v) # type: ignore[arg-type]
134
+ for k, v in dict(payload.get("baselines") or {}).items()
135
+ }
136
+ store._diagnostics = [
137
+ StateDiagnostic(**d) # type: ignore[arg-type]
138
+ for d in list(payload.get("diagnostics") or [])
139
+ ]
140
+ return store
141
+
142
+
143
+ class SQLModelDurableWorkStore:
144
+ """Transactional DurableWorkStore backed by a SQL snapshot row."""
145
+
146
+ def __init__(
147
+ self,
148
+ engine: Engine,
149
+ *,
150
+ admission_limit: int | None = None,
151
+ store_id: str = "default",
152
+ ) -> None:
153
+ self.engine = engine
154
+ self.admission_limit = admission_limit
155
+ self.store_id = store_id
156
+
157
+ def _txn(self, fn: Callable[[MemoryDurableWorkStore], T]) -> T:
158
+ with session_scope(self.engine) as session:
159
+ mem, version = self._read(session, for_update=True)
160
+ if self.admission_limit is not None:
161
+ mem.admission_limit = self.admission_limit
162
+ result = fn(mem)
163
+ self._write(session, mem, expected_version=version)
164
+ return result
165
+
166
+ def _read_only(self, fn: Callable[[MemoryDurableWorkStore], T]) -> T:
167
+ with session_scope(self.engine) as session:
168
+ mem, _version = self._read(session, for_update=False)
169
+ if self.admission_limit is not None:
170
+ mem.admission_limit = self.admission_limit
171
+ return fn(mem)
172
+
173
+ def _read(
174
+ self, session: Session, *, for_update: bool
175
+ ) -> tuple[MemoryDurableWorkStore, int]:
176
+ stmt = select(DurableSnapshotRow).where(
177
+ DurableSnapshotRow.store_id == self.store_id
178
+ )
179
+ if for_update:
180
+ stmt = stmt.with_for_update()
181
+ row = session.exec(stmt).first()
182
+ if row is None:
183
+ return MemoryDurableWorkStore(admission_limit=self.admission_limit), 0
184
+ return _load_store(json.loads(row.payload_json or "{}")), int(
185
+ row.payload_version or 0
186
+ )
187
+
188
+ def _write(
189
+ self,
190
+ session: Session,
191
+ store: MemoryDurableWorkStore,
192
+ *,
193
+ expected_version: int,
194
+ ) -> None:
195
+ payload = json.dumps(_dump_store(store), sort_keys=True)
196
+ row = session.exec(
197
+ select(DurableSnapshotRow)
198
+ .where(DurableSnapshotRow.store_id == self.store_id)
199
+ .with_for_update()
200
+ ).first()
201
+ if row is None:
202
+ if expected_version != 0:
203
+ raise ControlPlaneError.conflict(
204
+ "Durable snapshot version conflict (missing row)"
205
+ )
206
+ session.add(
207
+ DurableSnapshotRow(
208
+ store_id=self.store_id,
209
+ payload_json=payload,
210
+ payload_version=1,
211
+ updated_at=_utcnow_iso(),
212
+ )
213
+ )
214
+ return
215
+ current = int(row.payload_version or 0)
216
+ if current != expected_version:
217
+ raise ControlPlaneError.conflict(
218
+ "Durable snapshot version conflict (stale write)"
219
+ )
220
+ row.payload_json = payload
221
+ row.payload_version = current + 1
222
+ row.updated_at = _utcnow_iso()
223
+ session.add(row)
224
+
225
+ def accept(self, ctx: ControlPlaneContext, **kwargs: Any):
226
+ return self._txn(lambda m: m.accept(ctx, **kwargs))
227
+
228
+ def pending_outbox(self, ctx: ControlPlaneContext, *, limit: int = 100):
229
+ return self._read_only(lambda m: m.pending_outbox(ctx, limit=limit))
230
+
231
+ def mark_published(self, ctx: ControlPlaneContext, outbox_id: str):
232
+ return self._txn(lambda m: m.mark_published(ctx, outbox_id))
233
+
234
+ def cancel_submission(self, ctx: ControlPlaneContext, submission_id: str):
235
+ return self._txn(lambda m: m.cancel_submission(ctx, submission_id))
236
+
237
+ def acquire_lease(
238
+ self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
239
+ ):
240
+ return self._txn(lambda m: m.acquire_lease(ctx, submission_id, **kwargs))
241
+
242
+ def heartbeat(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
243
+ return self._txn(lambda m: m.heartbeat(ctx, submission_id, **kwargs))
244
+
245
+ def release_lease(
246
+ self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
247
+ ):
248
+ return self._txn(lambda m: m.release_lease(ctx, submission_id, **kwargs))
249
+
250
+ def start_attempt(
251
+ self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
252
+ ):
253
+ return self._txn(lambda m: m.start_attempt(ctx, submission_id, **kwargs))
254
+
255
+ def finish_attempt(self, ctx: ControlPlaneContext, attempt_id: str, **kwargs: Any):
256
+ return self._txn(lambda m: m.finish_attempt(ctx, attempt_id, **kwargs))
257
+
258
+ def compare_and_swap_checkpoint(
259
+ self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
260
+ ):
261
+ return self._txn(
262
+ lambda m: m.compare_and_swap_checkpoint(ctx, checkpoint_id, **kwargs)
263
+ )
264
+
265
+ def explain_transition(
266
+ self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
267
+ ):
268
+ return self._read_only(
269
+ lambda m: m.explain_transition(ctx, checkpoint_id, **kwargs)
270
+ )
271
+
272
+ def diagnose_checkpoint(
273
+ self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
274
+ ):
275
+ return self._txn(lambda m: m.diagnose_checkpoint(ctx, checkpoint_id, **kwargs))
276
+
277
+ def acknowledge_baseline(self, ctx: ControlPlaneContext, **kwargs: Any):
278
+ return self._txn(lambda m: m.acknowledge_baseline(ctx, **kwargs))
279
+
280
+ def record_effect(self, ctx: ControlPlaneContext, effect: EffectRecord):
281
+ return self._txn(lambda m: m.record_effect(ctx, effect))
282
+
283
+ def replay(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
284
+ return self._read_only(lambda m: m.replay(ctx, submission_id, **kwargs))
285
+
286
+ def plan_resume(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
287
+ return self._read_only(lambda m: m.plan_resume(ctx, submission_id, **kwargs))
288
+
289
+ def plan_repair(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
290
+ return self._read_only(lambda m: m.plan_repair(ctx, submission_id, **kwargs))
291
+
292
+ def plan_backfill(
293
+ self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
294
+ ):
295
+ return self._read_only(lambda m: m.plan_backfill(ctx, submission_id, **kwargs))
296
+
297
+ def create_preview(self, ctx: ControlPlaneContext, preview: PreviewWorkspace):
298
+ return self._txn(lambda m: m.create_preview(ctx, preview))
299
+
300
+ def mark_preview_stale(
301
+ self, ctx: ControlPlaneContext, preview_id: str, **kwargs: Any
302
+ ):
303
+ return self._txn(lambda m: m.mark_preview_stale(ctx, preview_id, **kwargs))
304
+
305
+ def record_preview_diff(self, ctx: ControlPlaneContext, diff: DiffRecord):
306
+ return self._txn(lambda m: m.record_preview_diff(ctx, diff))
307
+
308
+ def authorize_shadow_run(self, ctx: ControlPlaneContext, shadow: ShadowRunRecord):
309
+ return self._txn(lambda m: m.authorize_shadow_run(ctx, shadow))
310
+
311
+ def cleanup_expired_previews(self, ctx: ControlPlaneContext):
312
+ return self._txn(lambda m: m.cleanup_expired_previews(ctx))
313
+
314
+
315
+ __all__ = [
316
+ "DURABLE_TABLES",
317
+ "SQLModelDurableWorkStore",
318
+ "create_durable_tables",
319
+ ]
@@ -6,7 +6,7 @@ pipeline contracts.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from sqlalchemy import Column, String, UniqueConstraint
9
+ from sqlalchemy import Column, Integer, String, UniqueConstraint
10
10
 
11
11
  from sqlmodel import Field, SQLModel
12
12
 
@@ -266,9 +266,29 @@ class SecurityDomainRow(SQLModel, table=True):
266
266
  metadata_json: str = "{}"
267
267
 
268
268
 
269
+ class DurableSnapshotRow(SQLModel, table=True):
270
+ """Transactional CP3 durable-work snapshot (reference provider).
271
+
272
+ One logical store per engine; mutations load/save under a DB transaction so
273
+ accept+outbox and fencing CAS share commit boundaries.
274
+ """
275
+
276
+ __tablename__ = "cp_durable_snapshot"
277
+ __table_args__ = (UniqueConstraint("store_id", name="uq_cp_durable_snapshot"),)
278
+
279
+ id: int | None = Field(default=None, primary_key=True)
280
+ store_id: str = Field(
281
+ sa_column=Column(String(), index=True, nullable=False), default="default"
282
+ )
283
+ payload_json: str = "{}"
284
+ payload_version: int = Field(default=0, sa_column=Column(Integer(), nullable=False))
285
+ updated_at: str | None = None
286
+
287
+
269
288
  __all__ = [
270
289
  "AliasRow",
271
290
  "DefinitionRow",
291
+ "DurableSnapshotRow",
272
292
  "EnvironmentRow",
273
293
  "EventRow",
274
294
  "LogicalIdentityRow",
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  from collections.abc import Mapping
12
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, replace
13
13
  from datetime import UTC, datetime
14
14
  from pathlib import Path
15
15
  from typing import Any
@@ -21,6 +21,7 @@ from etlantic.control_plane import (
21
21
  ControlPlaneContext,
22
22
  EnvironmentRecord,
23
23
  EnvironmentRef,
24
+ LifecycleState,
24
25
  LogicalIdentity,
25
26
  Principal,
26
27
  PromotionRecord,
@@ -32,7 +33,9 @@ from etlantic.control_plane import (
32
33
  WorkspaceRecord,
33
34
  WorkspaceRef,
34
35
  content_fingerprint,
36
+ redact_control_plane_payload,
35
37
  )
38
+ from etlantic.control_plane.registry_memory import safe_registry_content
36
39
  from etlantic_sqlmodel.control_plane.models import (
37
40
  AliasRow,
38
41
  EnvironmentRow,
@@ -147,23 +150,26 @@ def dump_registry_sqlite(engine: Engine) -> BackupTranscript:
147
150
  }
148
151
  for row in session.exec(select(WorkspaceRow)).all()
149
152
  ]
150
- revisions = [
151
- {
152
- "tenant_id": row.tenant_id,
153
- "workspace_id": row.workspace_id,
154
- "logical_id": row.logical_id,
155
- "revision_id": row.revision_id,
156
- "content_fingerprint": row.content_fingerprint,
157
- "content": json.loads(row.content_json or "{}"),
158
- "created_at": row.created_at,
159
- "kind": row.kind,
160
- "signature_placeholder": row.signature_placeholder,
161
- "provenance": (
162
- json.loads(row.provenance_json) if row.provenance_json else {}
163
- ),
164
- }
165
- for row in session.exec(select(RevisionRow)).all()
166
- ]
153
+ revisions = []
154
+ for row in session.exec(select(RevisionRow)).all():
155
+ content = safe_registry_content(json.loads(row.content_json or "{}"))
156
+ provenance = redact_control_plane_payload(
157
+ json.loads(row.provenance_json) if row.provenance_json else {}
158
+ )
159
+ revisions.append(
160
+ {
161
+ "tenant_id": row.tenant_id,
162
+ "workspace_id": row.workspace_id,
163
+ "logical_id": row.logical_id,
164
+ "revision_id": row.revision_id,
165
+ "content_fingerprint": content_fingerprint(content),
166
+ "content": content,
167
+ "created_at": row.created_at,
168
+ "kind": row.kind,
169
+ "signature_placeholder": row.signature_placeholder,
170
+ "provenance": provenance if isinstance(provenance, dict) else {},
171
+ }
172
+ )
167
173
  logicals = [
168
174
  {
169
175
  "tenant_id": row.tenant_id,
@@ -286,7 +292,13 @@ def load_registry_sqlite(
286
292
  workspace_id="__backup__",
287
293
  domain_id=domain_id,
288
294
  )
289
- provider.tenants.put(ctx, TenantRecord.from_dict(tenant))
295
+ provider.tenants.put(
296
+ ctx,
297
+ replace(
298
+ TenantRecord.from_dict(tenant),
299
+ lifecycle=LifecycleState.ACTIVE,
300
+ ),
301
+ )
290
302
 
291
303
  for workspace in transcript.workspaces:
292
304
  tenant_id = str(workspace["tenant_id"])
@@ -304,7 +316,13 @@ def load_registry_sqlite(
304
316
  workspace_id=workspace_id,
305
317
  domain_id=domain_id,
306
318
  )
307
- provider.workspaces.put(ctx, WorkspaceRecord.from_dict(workspace))
319
+ provider.workspaces.put(
320
+ ctx,
321
+ replace(
322
+ WorkspaceRecord.from_dict(workspace),
323
+ lifecycle=LifecycleState.ACTIVE,
324
+ ),
325
+ )
308
326
 
309
327
  for environment in transcript.environments:
310
328
  tenant_id = str(environment["tenant_id"])
@@ -318,13 +336,14 @@ def load_registry_sqlite(
318
336
  ),
319
337
  "backup-domain",
320
338
  )
339
+ environment_record = EnvironmentRecord.from_dict(environment)
321
340
  provider.put_environment(
322
341
  _admin_ctx(
323
342
  tenant_id=tenant_id,
324
343
  workspace_id=workspace_id,
325
344
  domain_id=domain_id,
326
345
  ),
327
- EnvironmentRecord.from_dict(environment),
346
+ replace(environment_record, lifecycle=LifecycleState.ACTIVE),
328
347
  )
329
348
 
330
349
  for logical in transcript.logicals:
@@ -416,10 +435,72 @@ def load_registry_sqlite(
416
435
  from_environment=record.from_environment,
417
436
  to_environment=record.to_environment,
418
437
  created_at=record.created_at,
419
- metadata_json=json.dumps(dict(record.metadata), sort_keys=True),
438
+ metadata_json=json.dumps(
439
+ redact_control_plane_payload(dict(record.metadata)),
440
+ sort_keys=True,
441
+ ),
420
442
  )
421
443
  )
422
444
 
445
+ # Apply archived/suspended lifecycle states only after dependent rows exist.
446
+ for environment in transcript.environments:
447
+ record = EnvironmentRecord.from_dict(environment)
448
+ if record.lifecycle == LifecycleState.ACTIVE:
449
+ continue
450
+ workspace_id = record.workspace_id or "__backup__"
451
+ domain_id = next(
452
+ (
453
+ str(tenant["security_domain_id"])
454
+ for tenant in transcript.tenants
455
+ if tenant.get("tenant_id") == record.tenant_id
456
+ and tenant.get("security_domain_id")
457
+ ),
458
+ "backup-domain",
459
+ )
460
+ provider.put_environment(
461
+ _admin_ctx(
462
+ tenant_id=record.tenant_id,
463
+ workspace_id=workspace_id,
464
+ domain_id=domain_id,
465
+ ),
466
+ record,
467
+ )
468
+
469
+ for workspace in transcript.workspaces:
470
+ record = WorkspaceRecord.from_dict(workspace)
471
+ if record.lifecycle != LifecycleState.ACTIVE:
472
+ domain_id = next(
473
+ (
474
+ str(tenant["security_domain_id"])
475
+ for tenant in transcript.tenants
476
+ if tenant.get("tenant_id") == record.tenant_id
477
+ and tenant.get("security_domain_id")
478
+ ),
479
+ "backup-domain",
480
+ )
481
+ provider.workspaces.set_lifecycle(
482
+ _admin_ctx(
483
+ tenant_id=record.tenant_id,
484
+ workspace_id=record.workspace_id,
485
+ domain_id=domain_id,
486
+ ),
487
+ record.workspace_id,
488
+ record.lifecycle,
489
+ )
490
+
491
+ for tenant in transcript.tenants:
492
+ record = TenantRecord.from_dict(tenant)
493
+ if record.lifecycle != LifecycleState.ACTIVE:
494
+ provider.tenants.set_lifecycle(
495
+ _admin_ctx(
496
+ tenant_id=record.tenant_id,
497
+ workspace_id="__backup__",
498
+ domain_id=record.security_domain_id or "backup-domain",
499
+ ),
500
+ record.tenant_id,
501
+ record.lifecycle,
502
+ )
503
+
423
504
  return provider
424
505
 
425
506
 
@@ -32,6 +32,8 @@ from etlantic.control_plane import (
32
32
  content_fingerprint,
33
33
  redact_control_plane_payload,
34
34
  )
35
+ from etlantic.control_plane.redaction import redact_control_plane_text
36
+ from etlantic.control_plane.registry_memory import safe_registry_content
35
37
  from etlantic_sqlmodel.control_plane.models import (
36
38
  AliasRow,
37
39
  EnvironmentRow,
@@ -192,58 +194,33 @@ class SqlModelTenantDirectory:
192
194
  def get(self, ctx: ControlPlaneContext, tenant_id: str) -> TenantRecord:
193
195
  with session_scope(self.engine) as session:
194
196
  row = self._by_id(session, tenant_id)
195
- if row is None:
197
+ if row is None or ctx.tenant.tenant_id != tenant_id:
196
198
  raise ControlPlaneError.not_found(
197
199
  "Tenant not found",
198
200
  extensions={"tenant_id": tenant_id},
199
201
  )
200
202
  record = _tenant_from_row(row)
201
- in_scope = ctx.tenant.tenant_id == tenant_id
202
- admin_scope = (
203
- record.security_domain_id is not None
204
- and record.security_domain_id == ctx.security_domain.domain_id
205
- )
206
- if not in_scope and not admin_scope:
207
- raise ControlPlaneError.not_found(
208
- "Tenant not found",
209
- extensions={"tenant_id": tenant_id},
210
- )
211
203
  _require_active(record.lifecycle, resource=f"tenant:{tenant_id}")
212
204
  return deepcopy(record)
213
205
 
214
206
  def put(self, ctx: ControlPlaneContext, record: TenantRecord) -> None:
215
207
  with session_scope(self.engine) as session:
208
+ if ctx.tenant.tenant_id != record.tenant_id:
209
+ raise ControlPlaneError.not_found(
210
+ "Tenant not found",
211
+ extensions={"tenant_id": record.tenant_id},
212
+ )
216
213
  existing = self._by_id(session, record.tenant_id)
217
214
  if existing is not None:
218
215
  _require_active(
219
216
  LifecycleState(existing.lifecycle),
220
217
  resource=f"tenant:{record.tenant_id}",
221
218
  )
222
- existing_in_scope = ctx.tenant.tenant_id == record.tenant_id
223
- existing_admin_scope = (
224
- existing.security_domain_id is not None
225
- and existing.security_domain_id == ctx.security_domain.domain_id
226
- )
227
- if not existing_in_scope and not existing_admin_scope:
228
- raise ControlPlaneError.not_found(
229
- "Tenant not found",
230
- extensions={"tenant_id": record.tenant_id},
231
- )
232
219
  if existing.security_domain_id != record.security_domain_id:
233
220
  raise ControlPlaneError.conflict(
234
221
  "Tenant security domain is immutable through directory put",
235
222
  extensions={"tenant_id": record.tenant_id},
236
223
  )
237
- in_scope = ctx.tenant.tenant_id == record.tenant_id
238
- admin_scope = (
239
- record.security_domain_id is not None
240
- and record.security_domain_id == ctx.security_domain.domain_id
241
- )
242
- if not in_scope and not admin_scope:
243
- raise ControlPlaneError.not_found(
244
- "Tenant not found",
245
- extensions={"tenant_id": record.tenant_id},
246
- )
247
224
  now = _utcnow_iso()
248
225
  if existing is None:
249
226
  session.add(
@@ -267,16 +244,10 @@ class SqlModelTenantDirectory:
267
244
 
268
245
  def list(self, ctx: ControlPlaneContext) -> Sequence[TenantRecord]:
269
246
  with session_scope(self.engine) as session:
270
- rows = session.exec(select(TenantRow)).all()
271
- out: list[TenantRecord] = []
272
- for row in rows:
273
- record = _tenant_from_row(row)
274
- if record.tenant_id == ctx.tenant.tenant_id or (
275
- record.security_domain_id is not None
276
- and record.security_domain_id == ctx.security_domain.domain_id
277
- ):
278
- out.append(deepcopy(record))
279
- return sorted(out, key=lambda r: r.tenant_id)
247
+ row = self._by_id(session, ctx.tenant.tenant_id)
248
+ if row is None:
249
+ return ()
250
+ return (deepcopy(_tenant_from_row(row)),)
280
251
 
281
252
  def set_lifecycle(
282
253
  self,
@@ -286,10 +257,7 @@ class SqlModelTenantDirectory:
286
257
  ) -> TenantRecord:
287
258
  with session_scope(self.engine) as session:
288
259
  row = self._by_id(session, tenant_id)
289
- if row is None or (
290
- ctx.tenant.tenant_id != tenant_id
291
- and row.security_domain_id != ctx.security_domain.domain_id
292
- ):
260
+ if row is None or ctx.tenant.tenant_id != tenant_id:
293
261
  raise ControlPlaneError.not_found(
294
262
  "Tenant not found",
295
263
  extensions={"tenant_id": tenant_id},
@@ -327,6 +295,11 @@ class SqlModelWorkspaceDirectory:
327
295
  _require_active(tenant.lifecycle, resource=f"tenant:{tenant_id}")
328
296
 
329
297
  def get(self, ctx: ControlPlaneContext, workspace_id: str) -> WorkspaceRecord:
298
+ if workspace_id != ctx.workspace.workspace_id:
299
+ raise ControlPlaneError.not_found(
300
+ "Workspace not found",
301
+ extensions={"workspace_id": workspace_id},
302
+ )
330
303
  with session_scope(self.engine) as session:
331
304
  self._assert_tenant_active(ctx.tenant.tenant_id)
332
305
  row = self._by_key(session, ctx.tenant.tenant_id, workspace_id)
@@ -343,7 +316,10 @@ class SqlModelWorkspaceDirectory:
343
316
  return deepcopy(record)
344
317
 
345
318
  def put(self, ctx: ControlPlaneContext, record: WorkspaceRecord) -> None:
346
- if record.tenant_id != ctx.tenant.tenant_id:
319
+ if (
320
+ record.tenant_id != ctx.tenant.tenant_id
321
+ or record.workspace_id != ctx.workspace.workspace_id
322
+ ):
347
323
  raise ControlPlaneError.not_found(
348
324
  "Workspace not found",
349
325
  extensions={"workspace_id": record.workspace_id},
@@ -394,6 +370,11 @@ class SqlModelWorkspaceDirectory:
394
370
  workspace_id: str,
395
371
  state: LifecycleState,
396
372
  ) -> WorkspaceRecord:
373
+ if workspace_id != ctx.workspace.workspace_id:
374
+ raise ControlPlaneError.not_found(
375
+ "Workspace not found",
376
+ extensions={"workspace_id": workspace_id},
377
+ )
397
378
  with session_scope(self.engine) as session:
398
379
  self._assert_tenant_active(ctx.tenant.tenant_id)
399
380
  row = self._by_key(session, ctx.tenant.tenant_id, workspace_id)
@@ -549,6 +530,7 @@ class SqlModelRevisionRegistry:
549
530
  "recorded": revision.content_fingerprint,
550
531
  },
551
532
  )
533
+ safe_content = safe_registry_content(revision.content)
552
534
  try:
553
535
  with session_scope(self.engine) as session:
554
536
  self._assert_scope_active(ctx)
@@ -597,15 +579,17 @@ class SqlModelRevisionRegistry:
597
579
  workspace_id=revision.workspace_id,
598
580
  logical_id=revision.logical_id,
599
581
  revision_id=revision.revision_id,
600
- content_fingerprint=revision.content_fingerprint,
601
- content_json=json.dumps(dict(revision.content), sort_keys=True),
582
+ content_fingerprint=content_fingerprint(safe_content),
583
+ content_json=json.dumps(safe_content, sort_keys=True),
602
584
  created_at=revision.created_at or _utcnow_iso(),
603
585
  kind=revision.kind,
604
- signature_placeholder=revision.signature_placeholder,
586
+ signature_placeholder=(
587
+ redact_control_plane_text(revision.signature_placeholder)
588
+ if revision.signature_placeholder is not None
589
+ else None
590
+ ),
605
591
  provenance_json=(
606
- json.dumps(
607
- dict(revision.provenance_placeholder), sort_keys=True
608
- )
592
+ _meta(revision.provenance_placeholder)
609
593
  if revision.provenance_placeholder is not None
610
594
  else None
611
595
  ),
@@ -653,7 +637,7 @@ class SqlModelRevisionRegistry:
653
637
  key=lambda r: r.revision_id,
654
638
  )
655
639
 
656
- def put_alias(self, ctx: ControlPlaneContext, alias: AliasRecord) -> None:
640
+ def put_alias(self, ctx: ControlPlaneContext, alias: AliasRecord) -> AliasRecord:
657
641
  if (
658
642
  alias.tenant_id != ctx.tenant.tenant_id
659
643
  or alias.workspace_id != ctx.workspace.workspace_id
@@ -706,6 +690,12 @@ class SqlModelRevisionRegistry:
706
690
  existing.created_at = created
707
691
  existing.metadata_json = _meta(alias.metadata)
708
692
  session.add(existing)
693
+ session.flush()
694
+ row = self._alias_row(
695
+ session, ctx.tenant.tenant_id, ctx.workspace.workspace_id, alias.alias
696
+ )
697
+ assert row is not None
698
+ return deepcopy(_alias_from_row(row))
709
699
 
710
700
  def resolve_alias(self, ctx: ControlPlaneContext, alias: str) -> RegistryRevision:
711
701
  with session_scope(self.engine) as session:
@@ -757,11 +747,12 @@ class SqlModelRevisionRegistry:
757
747
  )
758
748
  source = _revision_from_row(source_row)
759
749
  source_snapshot = deepcopy(source)
760
- body = (
750
+ requested_body = (
761
751
  deepcopy(dict(content))
762
752
  if content is not None
763
753
  else deepcopy(dict(source.content))
764
754
  )
755
+ body = safe_registry_content(requested_body)
765
756
  new_revision_id = f"rev-{uuid.uuid4().hex[:16]}"
766
757
  created = _utcnow_iso()
767
758
  new_rev = RegistryRevision(
@@ -819,7 +810,7 @@ class SqlModelRevisionRegistry:
819
810
  from_environment=from_environment,
820
811
  to_environment=to_environment,
821
812
  created_at=created,
822
- metadata=dict(metadata or {}),
813
+ metadata=json.loads(_meta(metadata)),
823
814
  )
824
815
  session.add(
825
816
  PromotionRow(
@@ -948,6 +939,15 @@ class SqlModelRegistryProvider:
948
939
  _require_active(
949
940
  tenant.lifecycle, resource=f"tenant:{ctx.tenant.tenant_id}"
950
941
  )
942
+ assert self.workspaces is not None
943
+ workspace = self.workspaces.peek(
944
+ ctx.tenant.tenant_id, ctx.workspace.workspace_id
945
+ )
946
+ if workspace is not None:
947
+ _require_active(
948
+ workspace.lifecycle,
949
+ resource=f"workspace:{ctx.workspace.workspace_id}",
950
+ )
951
951
  now = _utcnow_iso()
952
952
  existing = session.exec(
953
953
  select(EnvironmentRow).where(
@@ -955,6 +955,20 @@ class SqlModelRegistryProvider:
955
955
  EnvironmentRow.environment_id == record.environment_id,
956
956
  )
957
957
  ).first()
958
+ if (
959
+ existing is not None
960
+ and existing.workspace_id is not None
961
+ and existing.workspace_id != ctx.workspace.workspace_id
962
+ ):
963
+ raise ControlPlaneError.not_found(
964
+ "Environment not found",
965
+ extensions={"environment_id": record.environment_id},
966
+ )
967
+ if existing is not None:
968
+ _require_active(
969
+ LifecycleState(existing.lifecycle),
970
+ resource=f"environment:{record.environment_id}",
971
+ )
958
972
  if existing is None:
959
973
  session.add(
960
974
  EnvironmentRow(
@@ -988,29 +1002,44 @@ class SqlModelRegistryProvider:
988
1002
  _require_active(
989
1003
  tenant.lifecycle, resource=f"tenant:{ctx.tenant.tenant_id}"
990
1004
  )
1005
+ assert self.workspaces is not None
1006
+ workspace = self.workspaces.peek(
1007
+ ctx.tenant.tenant_id, ctx.workspace.workspace_id
1008
+ )
1009
+ if workspace is not None:
1010
+ _require_active(
1011
+ workspace.lifecycle,
1012
+ resource=f"workspace:{ctx.workspace.workspace_id}",
1013
+ )
991
1014
  row = session.exec(
992
1015
  select(EnvironmentRow).where(
993
1016
  EnvironmentRow.tenant_id == ctx.tenant.tenant_id,
994
1017
  EnvironmentRow.environment_id == environment_id,
995
1018
  )
996
1019
  ).first()
997
- if row is None:
1020
+ if row is None or (
1021
+ row.workspace_id is not None
1022
+ and row.workspace_id != ctx.workspace.workspace_id
1023
+ ):
998
1024
  raise ControlPlaneError.not_found(
999
1025
  "Environment not found",
1000
1026
  extensions={"environment_id": environment_id},
1001
1027
  )
1002
- return deepcopy(
1003
- EnvironmentRecord(
1004
- tenant_id=row.tenant_id,
1005
- environment_id=row.environment_id,
1006
- name=row.name,
1007
- workspace_id=row.workspace_id,
1008
- lifecycle=LifecycleState(row.lifecycle),
1009
- created_at=row.created_at,
1010
- updated_at=row.updated_at,
1011
- metadata=_load_meta(row.metadata_json),
1012
- )
1028
+ record = EnvironmentRecord(
1029
+ tenant_id=row.tenant_id,
1030
+ environment_id=row.environment_id,
1031
+ name=row.name,
1032
+ workspace_id=row.workspace_id,
1033
+ lifecycle=LifecycleState(row.lifecycle),
1034
+ created_at=row.created_at,
1035
+ updated_at=row.updated_at,
1036
+ metadata=_load_meta(row.metadata_json),
1013
1037
  )
1038
+ _require_active(
1039
+ record.lifecycle,
1040
+ resource=f"environment:{environment_id}",
1041
+ )
1042
+ return deepcopy(record)
1014
1043
 
1015
1044
  def put_security_domain(
1016
1045
  self,
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import uuid
7
7
  from collections.abc import Callable, Mapping, Sequence
8
+ from copy import deepcopy
8
9
  from datetime import UTC, datetime
9
10
  from typing import Any
10
11
 
@@ -16,6 +17,7 @@ from etlantic.control_plane import (
16
17
  ControlPlaneContext,
17
18
  ControlPlaneError,
18
19
  ControlPlaneEvent,
20
+ redact_control_plane_payload,
19
21
  )
20
22
  from etlantic_sqlmodel.control_plane.models import (
21
23
  DefinitionRow,
@@ -130,6 +132,9 @@ class SQLModelSubmissionStore:
130
132
  ) -> AcceptResult:
131
133
  from sqlalchemy.exc import IntegrityError
132
134
 
135
+ safe_payload = redact_control_plane_payload(deepcopy(dict(payload)))
136
+ if not isinstance(safe_payload, dict):
137
+ safe_payload = {}
133
138
  try:
134
139
  with session_scope(self._engine) as session:
135
140
  existing = self._by_idem(
@@ -137,7 +142,7 @@ class SQLModelSubmissionStore:
137
142
  )
138
143
  if existing is not None:
139
144
  prior = json.loads(existing.payload_json)
140
- if prior != dict(payload):
145
+ if prior != safe_payload:
141
146
  raise ControlPlaneError.conflict(
142
147
  "Idempotency key reuse with a different payload",
143
148
  extensions={"idempotency_key": idempotency_key},
@@ -162,12 +167,12 @@ class SQLModelSubmissionStore:
162
167
  status="accepted",
163
168
  resource_type=resource_type,
164
169
  resource_id=run_id,
165
- payload_json=json.dumps(dict(payload), sort_keys=True),
170
+ payload_json=json.dumps(safe_payload, sort_keys=True),
166
171
  run_status="accepted",
167
172
  updated_at=created,
168
173
  definition_id=(
169
- str(payload["definition_id"])
170
- if payload.get("definition_id") is not None
174
+ str(safe_payload["definition_id"])
175
+ if safe_payload.get("definition_id") is not None
171
176
  else None
172
177
  ),
173
178
  )
@@ -185,7 +190,7 @@ class SQLModelSubmissionStore:
185
190
  extensions={"idempotency_key": idempotency_key},
186
191
  ) from exc
187
192
  prior = json.loads(winner.payload_json)
188
- if prior != dict(payload):
193
+ if prior != safe_payload:
189
194
  raise ControlPlaneError.conflict(
190
195
  "Idempotency key reuse with a different payload",
191
196
  extensions={"idempotency_key": idempotency_key},
@@ -311,6 +316,9 @@ class SqlModelEventStore:
311
316
  ) -> ControlPlaneEvent:
312
317
  import hashlib
313
318
 
319
+ safe_payload = redact_control_plane_payload(deepcopy(dict(payload or {})))
320
+ if not isinstance(safe_payload, dict):
321
+ safe_payload = {}
314
322
  with session_scope(self._engine) as session:
315
323
  statement = (
316
324
  select(EventRow)
@@ -339,7 +347,7 @@ class SqlModelEventStore:
339
347
  cursor=cursor,
340
348
  kind=kind,
341
349
  created_at=created,
342
- payload_json=json.dumps(dict(payload or {}), sort_keys=True),
350
+ payload_json=json.dumps(safe_payload, sort_keys=True),
343
351
  correlation_id=correlation_id,
344
352
  )
345
353
  session.add(row)
@@ -350,7 +358,7 @@ class SqlModelEventStore:
350
358
  cursor=cursor,
351
359
  kind=kind,
352
360
  created_at=created,
353
- payload=dict(payload or {}),
361
+ payload=safe_payload,
354
362
  correlation_id=correlation_id,
355
363
  scope={
356
364
  "tenant_id": ctx.tenant.tenant_id,
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from sqlalchemy import text
15
15
  from sqlalchemy.engine import Engine
16
16
 
17
- VERSIONS: Sequence[str] = ("001_registry_cp2",)
17
+ VERSIONS: Sequence[str] = ("001_registry_cp2", "002_durable_cp3")
18
18
 
19
19
 
20
20
  def _load(version: str) -> Any:
@@ -0,0 +1,33 @@
1
+ """002 — CP3 durable work snapshot tables.
2
+
3
+ Production note: apply via ``etlantic_sqlmodel.migrations.upgrade``.
4
+ ``create_all`` is for tests/demos only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sqlalchemy.engine import Engine
10
+
11
+ from etlantic_sqlmodel.control_plane.models import DurableSnapshotRow
12
+ from sqlmodel import SQLModel
13
+
14
+ TABLES = (DurableSnapshotRow,)
15
+
16
+
17
+ def upgrade(engine: Engine) -> None:
18
+ """Create CP3 durable tables."""
19
+ SQLModel.metadata.create_all(
20
+ engine,
21
+ tables=[cls.__table__ for cls in TABLES], # type: ignore[list-item]
22
+ )
23
+
24
+
25
+ def downgrade(engine: Engine) -> None:
26
+ """Drop CP3 durable tables."""
27
+ SQLModel.metadata.drop_all(
28
+ engine,
29
+ tables=[cls.__table__ for cls in reversed(TABLES)], # type: ignore[list-item]
30
+ )
31
+
32
+
33
+ __all__ = ["downgrade", "upgrade"]