roborean-storage-sqlalchemy 0.1.2__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.
@@ -0,0 +1,27 @@
1
+ venv/
2
+ venv-qt5/
3
+ venv-qt6/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .coverage
10
+ htmlcov/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ *.tsbuildinfo
15
+ node_modules/
16
+ .pnpm-store/
17
+ .DS_Store
18
+ .idea/
19
+ .vscode/*.local
20
+ playground/
21
+ .e2e-ai/
22
+ **/test-results/
23
+ **/playwright-report/
24
+ research/
25
+ .roborean/
26
+ .roborean-sql-artifacts/
27
+ *.db
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - SQLAlchemy ORM tables and repository adapters for projects and durable
8
+ runs, mapping to Pydantic domain models.
9
+
10
+ ### Changed
11
+
12
+ ### Fixed
13
+
14
+ - Lower inter-package dependency pins to `>=0.1.1` to match lockstep
15
+ release versions.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: roborean-storage-sqlalchemy
3
+ Version: 0.1.2
4
+ Summary: SQLAlchemy persistence adapter for Roborean
5
+ Project-URL: Homepage, https://github.com/TNick/roborean
6
+ Project-URL: Repository, https://github.com/TNick/roborean
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: roborean-spec>=0.1.1
10
+ Requires-Dist: roborean-storage-base>=0.1.1
11
+ Requires-Dist: sqlalchemy>=2.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: black>=24.0; extra == 'dev'
14
+ Requires-Dist: flake8>=7.0; extra == 'dev'
15
+ Requires-Dist: isort>=5.0; extra == 'dev'
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # roborean-storage-sqlalchemy
20
+
21
+ SQLAlchemy adapter for Roborean projects and durable runs. ORM rows map
22
+ to/from Pydantic domain models; the engine depends only on repository ports.
23
+
24
+ ```python
25
+ from roborean_storage_sqlalchemy import (
26
+ SqlAlchemyProjectRepository,
27
+ make_engine,
28
+ make_session_factory,
29
+ upgrade,
30
+ )
31
+
32
+ engine = make_engine("sqlite+pysqlite:///./roborean.db")
33
+ upgrade(engine)
34
+ factory = make_session_factory(engine)
35
+ projects = SqlAlchemyProjectRepository(factory)
36
+ ```
@@ -0,0 +1,18 @@
1
+ # roborean-storage-sqlalchemy
2
+
3
+ SQLAlchemy adapter for Roborean projects and durable runs. ORM rows map
4
+ to/from Pydantic domain models; the engine depends only on repository ports.
5
+
6
+ ```python
7
+ from roborean_storage_sqlalchemy import (
8
+ SqlAlchemyProjectRepository,
9
+ make_engine,
10
+ make_session_factory,
11
+ upgrade,
12
+ )
13
+
14
+ engine = make_engine("sqlite+pysqlite:///./roborean.db")
15
+ upgrade(engine)
16
+ factory = make_session_factory(engine)
17
+ projects = SqlAlchemyProjectRepository(factory)
18
+ ```
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "roborean-storage-sqlalchemy"
7
+ version = "0.1.2"
8
+ description = "SQLAlchemy persistence adapter for Roborean"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ dependencies = [
13
+ "SQLAlchemy>=2.0",
14
+ "roborean-spec>=0.1.1",
15
+ "roborean-storage-base>=0.1.1",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
20
+
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/TNick/roborean"
24
+ Repository = "https://github.com/TNick/roborean"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/roborean_storage_sqlalchemy"]
@@ -0,0 +1,18 @@
1
+ """SQLAlchemy persistence adapter for Roborean."""
2
+
3
+ from .migrate import SCHEMA_VERSION, upgrade
4
+ from .project_repo import SqlAlchemyProjectRepository
5
+ from .run_repo import SqlAlchemyRunRepository
6
+ from .session import create_all, make_engine, make_session_factory
7
+
8
+ __version__ = "0.2.0"
9
+
10
+ __all__ = [
11
+ "SCHEMA_VERSION",
12
+ "SqlAlchemyProjectRepository",
13
+ "SqlAlchemyRunRepository",
14
+ "create_all",
15
+ "make_engine",
16
+ "make_session_factory",
17
+ "upgrade",
18
+ ]
@@ -0,0 +1,157 @@
1
+ """Map between ORM rows and Pydantic domain models."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+
6
+ from roborean_spec import (
7
+ Project,
8
+ RunDiff,
9
+ RunError,
10
+ RunRecord,
11
+ RunRequest,
12
+ RunResults,
13
+ RunStatus,
14
+ project_from_dict,
15
+ project_to_dict,
16
+ )
17
+
18
+ from .models import ProjectRevisionRow, ProjectRow, RunRow
19
+
20
+
21
+ def _parse_dt(value: str | None) -> datetime | None:
22
+ """Parse an ISO timestamp into an aware datetime."""
23
+ if value is None:
24
+ return None
25
+ return datetime.fromisoformat(value)
26
+
27
+
28
+ def _fmt_dt(value: datetime | None) -> str | None:
29
+ """Format a datetime as ISO-8601."""
30
+ if value is None:
31
+ return None
32
+ if value.tzinfo is None:
33
+ value = value.replace(tzinfo=UTC)
34
+ return value.isoformat()
35
+
36
+
37
+ def project_to_row(project: Project, revision: str) -> ProjectRow:
38
+ """Build a current-project ORM row."""
39
+ now = datetime.now(UTC)
40
+ return ProjectRow(
41
+ id=project.id,
42
+ revision=revision,
43
+ name=project.name,
44
+ schema_version=project.schema_version,
45
+ body_json=json.dumps(project_to_dict(project), ensure_ascii=False),
46
+ created_at=now,
47
+ updated_at=now,
48
+ )
49
+
50
+
51
+ def row_to_project(row: ProjectRow) -> Project:
52
+ """Restore a project from a current-project row."""
53
+ return project_from_dict(json.loads(row.body_json))
54
+
55
+
56
+ def revision_to_row(
57
+ project: Project,
58
+ revision: str,
59
+ compiled_json: str | None = None,
60
+ ) -> ProjectRevisionRow:
61
+ """Build a revision snapshot row."""
62
+ return ProjectRevisionRow(
63
+ project_id=project.id,
64
+ revision=revision,
65
+ body_json=json.dumps(project_to_dict(project), ensure_ascii=False),
66
+ compiled_json=compiled_json,
67
+ created_at=datetime.now(UTC),
68
+ )
69
+
70
+
71
+ def run_to_row(record: RunRecord) -> RunRow:
72
+ """Build a run ORM row from a domain record."""
73
+ return RunRow(
74
+ run_id=record.run_id,
75
+ project_id=record.project_id,
76
+ project_revision=record.project_revision,
77
+ idempotency_key=record.idempotency_key,
78
+ status=record.status.value,
79
+ request_json=json.dumps(
80
+ record.request.model_dump(
81
+ mode="json", by_alias=True, exclude_none=True
82
+ ),
83
+ ensure_ascii=False,
84
+ ),
85
+ results_json=(
86
+ None
87
+ if record.results is None
88
+ else json.dumps(
89
+ record.results.model_dump(
90
+ mode="json", by_alias=True, exclude_none=True
91
+ ),
92
+ ensure_ascii=False,
93
+ )
94
+ ),
95
+ diff_json=(
96
+ None
97
+ if record.diff is None
98
+ else json.dumps(
99
+ record.diff.model_dump(
100
+ mode="json", by_alias=True, exclude_none=True
101
+ ),
102
+ ensure_ascii=False,
103
+ )
104
+ ),
105
+ attempt=record.attempt,
106
+ engine_version=record.engine_version,
107
+ plugin_versions_json=json.dumps(record.plugin_versions),
108
+ compiled_digest=record.compiled_digest,
109
+ error_json=(
110
+ None
111
+ if record.error is None
112
+ else json.dumps(
113
+ record.error.model_dump(
114
+ mode="json", by_alias=True, exclude_none=True
115
+ ),
116
+ ensure_ascii=False,
117
+ )
118
+ ),
119
+ request_digest=record.request_digest,
120
+ retry_policy_json=json.dumps(record.retry_policy_snapshot),
121
+ created_at=_parse_dt(record.created_at) or datetime.now(UTC),
122
+ started_at=_parse_dt(record.started_at),
123
+ finished_at=_parse_dt(record.finished_at),
124
+ )
125
+
126
+
127
+ def row_to_run(row: RunRow) -> RunRecord:
128
+ """Restore a domain run record from an ORM row."""
129
+ results = None
130
+ if row.results_json:
131
+ results = RunResults.model_validate(json.loads(row.results_json))
132
+ diff = None
133
+ if row.diff_json:
134
+ diff = RunDiff.model_validate(json.loads(row.diff_json))
135
+ error = None
136
+ if row.error_json:
137
+ error = RunError.model_validate(json.loads(row.error_json))
138
+ return RunRecord(
139
+ runId=row.run_id,
140
+ idempotencyKey=row.idempotency_key,
141
+ projectId=row.project_id,
142
+ projectRevision=row.project_revision,
143
+ compiledDigest=row.compiled_digest,
144
+ status=RunStatus(row.status),
145
+ request=RunRequest.model_validate(json.loads(row.request_json)),
146
+ results=results,
147
+ diff=diff,
148
+ attempt=row.attempt,
149
+ retryPolicySnapshot=json.loads(row.retry_policy_json),
150
+ engineVersion=row.engine_version,
151
+ pluginVersions=json.loads(row.plugin_versions_json),
152
+ error=error,
153
+ createdAt=_fmt_dt(row.created_at) or "",
154
+ startedAt=_fmt_dt(row.started_at),
155
+ finishedAt=_fmt_dt(row.finished_at),
156
+ requestDigest=row.request_digest,
157
+ )
@@ -0,0 +1,29 @@
1
+ """Internal storage schema migrator."""
2
+
3
+ from sqlalchemy import Engine, select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from .models import SchemaMetaRow
7
+ from .session import create_all
8
+
9
+ SCHEMA_VERSION = 1
10
+
11
+
12
+ def current_version(engine: Engine) -> int:
13
+ """Return the installed schema version, or 0 when unset."""
14
+ create_all(engine)
15
+ with Session(engine) as session:
16
+ row = session.scalar(select(SchemaMetaRow).limit(1))
17
+ return 0 if row is None else row.version
18
+
19
+
20
+ def upgrade(engine: Engine) -> None:
21
+ """Create tables and stamp the schema version."""
22
+ create_all(engine)
23
+ with Session(engine) as session:
24
+ row = session.scalar(select(SchemaMetaRow).limit(1))
25
+ if row is None:
26
+ session.add(SchemaMetaRow(id=1, version=SCHEMA_VERSION))
27
+ else:
28
+ row.version = SCHEMA_VERSION
29
+ session.commit()
@@ -0,0 +1,81 @@
1
+ """ORM storage shapes (not domain models)."""
2
+
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ """Declarative base for Roborean storage tables."""
11
+
12
+
13
+ class SchemaMetaRow(Base):
14
+ """Tracks the storage schema version."""
15
+
16
+ __tablename__ = "schema_meta"
17
+
18
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
19
+ version: Mapped[int] = mapped_column(Integer, nullable=False)
20
+
21
+
22
+ class ProjectRow(Base):
23
+ """Current project document storage."""
24
+
25
+ __tablename__ = "projects"
26
+
27
+ id: Mapped[str] = mapped_column(String(255), primary_key=True)
28
+ revision: Mapped[str] = mapped_column(String(128), nullable=False)
29
+ name: Mapped[str] = mapped_column(String(512), nullable=False)
30
+ schema_version: Mapped[str] = mapped_column(String(32), nullable=False)
31
+ body_json: Mapped[str] = mapped_column(Text, nullable=False)
32
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
33
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
34
+
35
+
36
+ class ProjectRevisionRow(Base):
37
+ """Immutable project revision snapshots."""
38
+
39
+ __tablename__ = "project_revisions"
40
+
41
+ project_id: Mapped[str] = mapped_column(String(255), primary_key=True)
42
+ revision: Mapped[str] = mapped_column(String(128), primary_key=True)
43
+ body_json: Mapped[str] = mapped_column(Text, nullable=False)
44
+ compiled_json: Mapped[str | None] = mapped_column(Text, nullable=True)
45
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
46
+
47
+
48
+ class RunRow(Base):
49
+ """Durable run records."""
50
+
51
+ __tablename__ = "runs"
52
+ __table_args__ = (
53
+ UniqueConstraint(
54
+ "project_id",
55
+ "idempotency_key",
56
+ name="uq_runs_project_idempotency",
57
+ ),
58
+ )
59
+
60
+ run_id: Mapped[str] = mapped_column(String(128), primary_key=True)
61
+ project_id: Mapped[str] = mapped_column(String(255), nullable=False)
62
+ project_revision: Mapped[str] = mapped_column(String(128), nullable=False)
63
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
64
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
65
+ request_json: Mapped[str] = mapped_column(Text, nullable=False)
66
+ results_json: Mapped[str | None] = mapped_column(Text, nullable=True)
67
+ diff_json: Mapped[str | None] = mapped_column(Text, nullable=True)
68
+ attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
69
+ engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
70
+ plugin_versions_json: Mapped[str] = mapped_column(Text, nullable=False)
71
+ compiled_digest: Mapped[str] = mapped_column(String(128), nullable=False)
72
+ error_json: Mapped[str | None] = mapped_column(Text, nullable=True)
73
+ request_digest: Mapped[str] = mapped_column(String(128), nullable=False)
74
+ retry_policy_json: Mapped[str] = mapped_column(Text, nullable=False)
75
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
76
+ started_at: Mapped[datetime | None] = mapped_column(
77
+ DateTime(timezone=True), nullable=True
78
+ )
79
+ finished_at: Mapped[datetime | None] = mapped_column(
80
+ DateTime(timezone=True), nullable=True
81
+ )
@@ -0,0 +1,80 @@
1
+ """SQLAlchemy project repository."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+
6
+ from roborean_spec import Project, project_from_dict
7
+ from roborean_storage_base import NotFoundError
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ from .mappers import project_to_row, revision_to_row, row_to_project
12
+ from .models import ProjectRevisionRow, ProjectRow
13
+
14
+
15
+ class SqlAlchemyProjectRepository:
16
+ """Persist projects through SQLAlchemy sessions."""
17
+
18
+ def __init__(self, session_factory: sessionmaker) -> None:
19
+ """Bind to a session factory."""
20
+ self._session_factory = session_factory
21
+
22
+ def get(self, project_id: str) -> Project:
23
+ """Return the current project document."""
24
+ with self._session_factory() as session:
25
+ row = session.get(ProjectRow, project_id)
26
+ if row is None:
27
+ raise NotFoundError(project_id)
28
+ return row_to_project(row)
29
+
30
+ def get_revision(self, project_id: str, revision: str) -> Project:
31
+ """Return a pinned revision document."""
32
+ with self._session_factory() as session:
33
+ row = session.get(
34
+ ProjectRevisionRow,
35
+ {"project_id": project_id, "revision": revision},
36
+ )
37
+ if row is None:
38
+ raise NotFoundError(f"{project_id}@{revision}")
39
+ return project_from_dict(json.loads(row.body_json))
40
+
41
+ def save(self, project: Project, *, revision: str | None = None) -> str:
42
+ """Upsert the current project and write a revision snapshot."""
43
+ rev = revision or "1"
44
+ with self._session_factory() as session:
45
+ existing = session.get(ProjectRow, project.id)
46
+ if existing is None:
47
+ session.add(project_to_row(project, rev))
48
+ else:
49
+ existing.revision = rev
50
+ existing.name = project.name
51
+ existing.schema_version = project.schema_version
52
+ existing.body_json = project_to_row(project, rev).body_json
53
+ existing.updated_at = datetime.now(UTC)
54
+ session.merge(revision_to_row(project, rev))
55
+ session.commit()
56
+ return rev
57
+
58
+ def list_ids(self) -> list[str]:
59
+ """List stored project identifiers."""
60
+ with self._session_factory() as session:
61
+ rows = session.scalars(
62
+ select(ProjectRow.id).order_by(ProjectRow.id)
63
+ )
64
+ return list(rows)
65
+
66
+ def delete(self, project_id: str) -> None:
67
+ """Delete a project and its revisions."""
68
+ with self._session_factory() as session:
69
+ row = session.get(ProjectRow, project_id)
70
+ if row is None:
71
+ raise NotFoundError(project_id)
72
+ session.delete(row)
73
+ revisions = session.scalars(
74
+ select(ProjectRevisionRow).where(
75
+ ProjectRevisionRow.project_id == project_id
76
+ )
77
+ )
78
+ for revision in revisions:
79
+ session.delete(revision)
80
+ session.commit()
@@ -0,0 +1,103 @@
1
+ """SQLAlchemy run repository."""
2
+
3
+ import logging
4
+
5
+ from roborean_spec import RunRecord
6
+ from roborean_storage_base import ConflictError, NotFoundError
7
+ from sqlalchemy import select
8
+ from sqlalchemy.exc import IntegrityError as SAIntegrityError
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ from .mappers import row_to_run, run_to_row
12
+ from .models import RunRow
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SqlAlchemyRunRepository:
18
+ """Persist durable runs through SQLAlchemy sessions."""
19
+
20
+ def __init__(self, session_factory: sessionmaker) -> None:
21
+ """Bind to a session factory."""
22
+ self._session_factory = session_factory
23
+
24
+ def get(self, run_id: str) -> RunRecord:
25
+ """Return one run by identifier."""
26
+ with self._session_factory() as session:
27
+ row = session.get(RunRow, run_id)
28
+ if row is None:
29
+ raise NotFoundError(run_id)
30
+ return row_to_run(row)
31
+
32
+ def get_by_idempotency(
33
+ self, project_id: str, idempotency_key: str
34
+ ) -> RunRecord | None:
35
+ """Return an existing run for an idempotency key, if any."""
36
+ with self._session_factory() as session:
37
+ row = session.scalar(
38
+ select(RunRow).where(
39
+ RunRow.project_id == project_id,
40
+ RunRow.idempotency_key == idempotency_key,
41
+ )
42
+ )
43
+ return None if row is None else row_to_run(row)
44
+
45
+ def save(self, record: RunRecord) -> None:
46
+ """Insert a new run, mapping unique violations to conflicts."""
47
+ with self._session_factory() as session:
48
+ existing = session.scalar(
49
+ select(RunRow).where(
50
+ RunRow.project_id == record.project_id,
51
+ RunRow.idempotency_key == record.idempotency_key,
52
+ )
53
+ )
54
+ if existing is not None:
55
+ if existing.request_digest != record.request_digest:
56
+ raise ConflictError(
57
+ "idempotency key reused with a different request body"
58
+ )
59
+ raise ConflictError("idempotency key already exists")
60
+ session.add(run_to_row(record))
61
+ try:
62
+ session.commit()
63
+ except SAIntegrityError as error:
64
+ session.rollback()
65
+ logger.debug(
66
+ "Unique constraint on run insert for %s",
67
+ record.run_id,
68
+ exc_info=True,
69
+ )
70
+ raise ConflictError("idempotency key already exists") from error
71
+
72
+ def update(self, record: RunRecord) -> None:
73
+ """Replace an existing run row."""
74
+ with self._session_factory() as session:
75
+ row = session.get(RunRow, record.run_id)
76
+ if row is None:
77
+ raise NotFoundError(record.run_id)
78
+ mapped = run_to_row(record)
79
+ row.status = mapped.status
80
+ row.request_json = mapped.request_json
81
+ row.results_json = mapped.results_json
82
+ row.diff_json = mapped.diff_json
83
+ row.attempt = mapped.attempt
84
+ row.error_json = mapped.error_json
85
+ row.compiled_digest = mapped.compiled_digest
86
+ row.plugin_versions_json = mapped.plugin_versions_json
87
+ row.retry_policy_json = mapped.retry_policy_json
88
+ row.started_at = mapped.started_at
89
+ row.finished_at = mapped.finished_at
90
+ session.commit()
91
+
92
+ def list_for_project(
93
+ self, project_id: str, *, limit: int = 50
94
+ ) -> list[RunRecord]:
95
+ """List recent runs for a project, newest first."""
96
+ with self._session_factory() as session:
97
+ rows = session.scalars(
98
+ select(RunRow)
99
+ .where(RunRow.project_id == project_id)
100
+ .order_by(RunRow.created_at.desc())
101
+ .limit(limit)
102
+ )
103
+ return [row_to_run(row) for row in rows]
@@ -0,0 +1,21 @@
1
+ """Engine and session helpers."""
2
+
3
+ from sqlalchemy import Engine, create_engine
4
+ from sqlalchemy.orm import sessionmaker
5
+
6
+ from .models import Base
7
+
8
+
9
+ def make_engine(url: str) -> Engine:
10
+ """Create a SQLAlchemy engine for the given URL."""
11
+ return create_engine(url, future=True)
12
+
13
+
14
+ def make_session_factory(engine: Engine) -> sessionmaker:
15
+ """Create a session factory bound to ``engine``."""
16
+ return sessionmaker(bind=engine, expire_on_commit=False, future=True)
17
+
18
+
19
+ def create_all(engine: Engine) -> None:
20
+ """Create ORM tables if they do not exist."""
21
+ Base.metadata.create_all(engine)
@@ -0,0 +1,36 @@
1
+ """Shared fixtures for SQLAlchemy storage tests."""
2
+
3
+ import pytest
4
+ from roborean_spec import project_from_dict
5
+ from roborean_storage_sqlalchemy import (
6
+ make_engine,
7
+ make_session_factory,
8
+ upgrade,
9
+ )
10
+
11
+
12
+ @pytest.fixture
13
+ def session_factory(tmp_path):
14
+ """Return a session factory over a temporary SQLite database."""
15
+ url = f"sqlite+pysqlite:///{tmp_path / 'test.db'}"
16
+ engine = make_engine(url)
17
+ upgrade(engine)
18
+ return make_session_factory(engine)
19
+
20
+
21
+ @pytest.fixture
22
+ def minimal_project():
23
+ """Return a tiny valid project model."""
24
+ return project_from_dict(
25
+ {
26
+ "schemaVersion": "1.0.0",
27
+ "id": "example.minimal",
28
+ "name": "Minimal",
29
+ "pluginRequirements": [],
30
+ "workspace": {"variables": []},
31
+ "bits": [],
32
+ "documents": [],
33
+ "templates": [],
34
+ "metadata": {},
35
+ }
36
+ )
@@ -0,0 +1,53 @@
1
+ """Integration tests for SQLAlchemy repositories."""
2
+
3
+ import pytest
4
+ from roborean_spec import (
5
+ RunRecord,
6
+ RunRequest,
7
+ RunStatus,
8
+ RunTrigger,
9
+ )
10
+ from roborean_storage_base import ConflictError
11
+ from roborean_storage_sqlalchemy import (
12
+ SqlAlchemyProjectRepository,
13
+ SqlAlchemyRunRepository,
14
+ )
15
+
16
+
17
+ @pytest.mark.integration
18
+ class TestSqlAlchemyRepos:
19
+ """Project and run persistence through SQLAlchemy."""
20
+
21
+ def test_project_roundtrip(self, session_factory, minimal_project) -> None:
22
+ """Projects save and load."""
23
+ repo = SqlAlchemyProjectRepository(session_factory)
24
+ repo.save(minimal_project, revision="1")
25
+ assert repo.get(minimal_project.id).name == "Minimal"
26
+ loaded = repo.get_revision(minimal_project.id, "1")
27
+ assert loaded.id == minimal_project.id
28
+
29
+ def test_run_idempotency(self, session_factory) -> None:
30
+ """Duplicate idempotency keys conflict."""
31
+ repo = SqlAlchemyRunRepository(session_factory)
32
+ record = RunRecord(
33
+ runId="r1",
34
+ idempotencyKey="k1",
35
+ projectId="example.minimal",
36
+ projectRevision="1",
37
+ compiledDigest="d",
38
+ status=RunStatus.QUEUED,
39
+ request=RunRequest(
40
+ projectId="example.minimal",
41
+ idempotencyKey="k1",
42
+ trigger=RunTrigger.TEST,
43
+ ),
44
+ attempt=1,
45
+ engineVersion="0.2.0",
46
+ pluginVersions={},
47
+ createdAt="2026-01-01T00:00:00+00:00",
48
+ requestDigest="abc",
49
+ )
50
+ repo.save(record)
51
+ with pytest.raises(ConflictError):
52
+ repo.save(record.model_copy(update={"run_id": "r2"}))
53
+ assert repo.get_by_idempotency("example.minimal", "k1").run_id == "r1"