feat(db): add projects and project links tables
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 23s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Failing after 3m31s
CI / build (pull_request) Successful in 15s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 28s
CI / security (push) Successful in 23s
CI / quality (push) Successful in 15s
CI / unit_tests (pull_request) Failing after 9m45s
CI / docker (pull_request) Has been skipped
CI / integration_tests (push) Failing after 3m30s
CI / build (push) Successful in 15s
CI / unit_tests (push) Failing after 9m42s
CI / docker (push) Has been skipped
CI / coverage (pull_request) Failing after 3m29s
CI / coverage (push) Failing after 3m28s

This commit was merged in pull request #75.
This commit is contained in:
Jeffrey Phillips Freeman
2026-02-15 06:28:45 +00:00
parent 5a7703473a
commit dbca60c98e
11 changed files with 1549 additions and 23 deletions
+157
View File
@@ -0,0 +1,157 @@
"""Add ns_projects and project_resource_links tables.
This migration creates the two project-related tables:
- ``ns_projects``: Spec-aligned project definitions identified solely by
their namespaced name (e.g., ``local/my-project``). Stores invariants,
automation profile, invariant actor, context policy, and tags as JSON
text fields.
- ``project_resource_links``: Many-to-many links between projects and
resources with optional alias and read-only override.
Revision ID: b0_001_projects
Revises: b1_001_resource_registry
Create Date: 2026-02-15 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b0_001_projects"
down_revision: str | Sequence[str] | None = "b1_001_resource_registry"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create ns_projects and project_resource_links tables."""
# --- ns_projects table ---
op.create_table(
"ns_projects",
# PK: namespaced name (e.g., "local/my-project")
sa.Column("namespaced_name", sa.String(255), nullable=False),
# Extracted namespace for filtering (e.g., "local")
sa.Column("namespace", sa.String(100), nullable=False),
# Human-readable description
sa.Column("description", sa.Text(), nullable=True),
# JSON list of invariant strings
sa.Column("invariants_json", sa.Text(), nullable=True),
# Default automation profile name
sa.Column("automation_profile", sa.String(255), nullable=True),
# Actor for invariant reconciliation
sa.Column("invariant_actor", sa.String(255), nullable=True),
# JSON context policy configuration
sa.Column("context_policy_json", sa.Text(), nullable=True),
# JSON array of tag strings
sa.Column(
"tags_json",
sa.Text(),
nullable=False,
server_default="[]",
),
# Creator identifier
sa.Column("created_by", sa.String(255), nullable=True),
# Timestamps (ISO-8601 strings)
sa.Column("created_at", sa.String(30), nullable=False),
sa.Column("updated_at", sa.String(30), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("namespaced_name"),
)
op.create_index(
"ix_ns_projects_namespace",
"ns_projects",
["namespace"],
unique=False,
)
# --- project_resource_links table ---
op.create_table(
"project_resource_links",
# PK: ULID (26-char string)
sa.Column("link_id", sa.String(26), nullable=False),
# FK to ns_projects
sa.Column(
"project_name",
sa.String(255),
sa.ForeignKey(
"ns_projects.namespaced_name",
ondelete="CASCADE",
name="fk_project_resource_links_project",
),
nullable=False,
),
# FK to resources
sa.Column(
"resource_id",
sa.String(26),
sa.ForeignKey(
"resources.resource_id",
ondelete="RESTRICT",
name="fk_project_resource_links_resource",
),
nullable=False,
),
# Optional short name for the resource within the project
sa.Column("alias", sa.String(255), nullable=True),
# Read-only override
sa.Column(
"read_only",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
# Timestamp (ISO-8601 string)
sa.Column("created_at", sa.String(30), nullable=False),
# Constraints
sa.PrimaryKeyConstraint("link_id"),
sa.UniqueConstraint(
"project_name",
"resource_id",
name="uq_project_resource_links_project_resource",
),
)
op.create_index(
"ix_project_resource_links_project_name",
"project_resource_links",
["project_name"],
unique=False,
)
op.create_index(
"ix_project_resource_links_resource_id",
"project_resource_links",
["resource_id"],
unique=False,
)
op.create_index(
"ix_project_resource_links_project_alias",
"project_resource_links",
["project_name", "alias"],
unique=False,
)
def downgrade() -> None:
"""Drop project tables."""
# Drop in reverse FK order: links -> ns_projects
op.drop_index(
"ix_project_resource_links_project_alias",
table_name="project_resource_links",
)
op.drop_index(
"ix_project_resource_links_resource_id",
table_name="project_resource_links",
)
op.drop_index(
"ix_project_resource_links_project_name",
table_name="project_resource_links",
)
op.drop_table("project_resource_links")
op.drop_index("ix_ns_projects_namespace", table_name="ns_projects")
op.drop_table("ns_projects")
+249
View File
@@ -0,0 +1,249 @@
"""ASV benchmarks for project migration and ORM operations.
Measures:
- NamespacedProjectModel insert throughput
- ProjectResourceLinkModel insert throughput
- Project-to-domain conversion performance
- Link query performance (by project name)
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ProjectResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
def _now_iso() -> str:
return datetime.now(tz=UTC).isoformat()
def _make_ulid(n: int) -> str:
return str(n).zfill(26)
def _set_sqlite_pragma(dbapi_conn: object, _connection_record: object) -> None:
cursor = dbapi_conn.cursor() # type: ignore[union-attr]
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
class ProjectInsert:
"""Benchmark inserting project records."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
event.listen(self.engine, "connect", _set_sqlite_pragma)
with self.engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
def teardown(self) -> None:
self.engine.dispose()
def time_insert_single_project(self) -> None:
session = self.session_factory()
proj = NamespacedProjectModel(
namespaced_name="bench/single-project",
namespace="bench",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
session.close()
def time_insert_100_projects(self) -> None:
session = self.session_factory()
for i in range(100):
proj = NamespacedProjectModel(
namespaced_name=f"bench/project-{i}",
namespace="bench",
description=f"Project {i}",
tags_json=json.dumps([f"tag-{i}"]),
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
session.close()
class ProjectLinkInsert:
"""Benchmark inserting project resource link records."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
event.listen(self.engine, "connect", _set_sqlite_pragma)
with self.engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
session = self.session_factory()
# Create resource type
rt = ResourceTypeModel()
rt.name = "bench/git-checkout"
rt.namespace = "bench"
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
# Create project
proj = NamespacedProjectModel(
namespaced_name="bench/link-project",
namespace="bench",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
# Create 50 resources
for i in range(50):
r = ResourceModel()
r.resource_id = _make_ulid(i)
r.type_name = "bench/git-checkout"
r.resource_kind = "physical"
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
session.close()
def teardown(self) -> None:
self.engine.dispose()
def time_insert_50_links(self) -> None:
session = self.session_factory()
for i in range(50):
link = ProjectResourceLinkModel(
link_id=_make_ulid(1000 + i),
project_name="bench/link-project",
resource_id=_make_ulid(i),
read_only=i % 2 == 0,
created_at=_now_iso(),
)
session.add(link)
session.commit()
session.close()
class ProjectDomainConversion:
"""Benchmark domain model conversion."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
event.listen(self.engine, "connect", _set_sqlite_pragma)
with self.engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
session = self.session_factory()
proj = NamespacedProjectModel(
namespaced_name="bench/domain-project",
namespace="bench",
description="Domain conversion bench",
invariants_json=json.dumps(["inv-1", "inv-2", "inv-3"]),
tags_json=json.dumps(["tag-a", "tag-b"]),
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
session.close()
def teardown(self) -> None:
self.engine.dispose()
def time_to_domain_conversion(self) -> None:
session = self.session_factory()
proj = session.get(NamespacedProjectModel, "bench/domain-project")
assert proj is not None
proj.to_domain()
session.close()
class ProjectLinkQuery:
"""Benchmark link queries by project name."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
event.listen(self.engine, "connect", _set_sqlite_pragma)
with self.engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
session = self.session_factory()
rt = ResourceTypeModel()
rt.name = "bench/git-checkout"
rt.namespace = "bench"
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
proj = NamespacedProjectModel(
namespaced_name="bench/query-project",
namespace="bench",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
for i in range(30):
r = ResourceModel()
r.resource_id = _make_ulid(i + 2000)
r.type_name = "bench/git-checkout"
r.resource_kind = "physical"
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
link = ProjectResourceLinkModel(
link_id=_make_ulid(i + 3000),
project_name="bench/query-project",
resource_id=_make_ulid(i + 2000),
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
session.close()
def teardown(self) -> None:
self.engine.dispose()
def time_query_30_links_by_project(self) -> None:
session = self.session_factory()
links = (
session.query(ProjectResourceLinkModel)
.filter(ProjectResourceLinkModel.project_name == "bench/query-project")
.all()
)
assert len(links) == 30
session.close()
+65
View File
@@ -401,8 +401,71 @@ resource_types (PK: name)
resources (PK: resource_id)
|-- N:M --> resource_edges (FK: parent_id/child_id, CASCADE)
|-- 1:N --> project_resource_links (FK: resource_id, RESTRICT)
ns_projects (PK: namespaced_name)
|-- 1:N --> project_resource_links (FK: project_name, CASCADE)
```
---
## Table: `ns_projects`
Spec-aligned project definitions identified solely by their namespaced name
(e.g., `local/my-project`). Projects store invariants, automation profile,
context policy, and tags as JSON text fields.
**Primary Key:** `namespaced_name`
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
| `namespaced_name` | String(255) | No | -- | PK. Full namespaced name (e.g. `local/my-project`) |
| `namespace` | String(100) | No | -- | Extracted namespace component |
| `description` | Text | Yes | NULL | Human-readable description |
| `invariants_json` | Text | Yes | NULL | JSON list of invariant strings |
| `automation_profile` | String(255) | Yes | NULL | Default automation profile name |
| `invariant_actor` | String(255) | Yes | NULL | Actor for invariant reconciliation |
| `context_policy_json` | Text | Yes | NULL | JSON context policy configuration |
| `tags_json` | Text | No | `'[]'` | JSON array of tag strings |
| `created_by` | String(255) | Yes | NULL | Creator identifier |
| `created_at` | String(30) | No | -- | ISO-8601 creation timestamp |
| `updated_at` | String(30) | No | -- | ISO-8601 last-update timestamp |
**Indexes:**
- `ix_ns_projects_namespace` on `namespace`
---
## Table: `project_resource_links`
Many-to-many links between projects and resources with optional alias
and read-only override. Each link has a ULID primary key.
**Primary Key:** `link_id` (ULID, String(26))
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
| `link_id` | String(26) | No | -- | ULID primary key |
| `project_name` | String(255) | No | -- | FK to `projects.namespaced_name` (CASCADE) |
| `resource_id` | String(26) | No | -- | FK to `resources.resource_id` (RESTRICT) |
| `alias` | String(255) | Yes | NULL | Optional short name within the project |
| `read_only` | Boolean | No | `0` | Read-only override |
| `created_at` | String(30) | No | -- | ISO-8601 timestamp |
**Foreign Keys:**
- `fk_project_resource_links_project`: `project_name` -> `ns_projects.namespaced_name` ON DELETE CASCADE
- `fk_project_resource_links_resource`: `resource_id` -> `resources.resource_id` ON DELETE RESTRICT
**Unique Constraints:**
- `uq_project_resource_links_project_resource` on `(project_name, resource_id)`
**Indexes:**
- `ix_project_resource_links_project_name` on `project_name`
- `ix_project_resource_links_resource_id` on `resource_id`
- `ix_project_resource_links_project_alias` on `(project_name, alias)`
---
## Migration Chain
| Revision | Description |
@@ -411,11 +474,13 @@ resources (PK: resource_id)
| `a5_004_spec_aligned_plans` | Creates `v3_plans`, `plan_projects`, `plan_arguments`, `plan_invariants` |
| `b1_001_resource_registry` | Creates `resource_types`, `resources`, `resource_edges` |
| `a5_005_rebaseline_plan_phases` | Rebaselines `v3_plans` phase/state CHECK constraints: adds `action` phase, removes `applied` phase, adds `applied`/`constrained` processing states, changes default phase to `action` |
| `b0_001_projects` | Creates spec-aligned `ns_projects`, `project_resource_links` |
## Source Location
- Migration (actions): `alembic/versions/a5_003_spec_aligned_actions.py`
- Migration (plans): `alembic/versions/a5_004_spec_aligned_plans.py`
- Migration (resources): `alembic/versions/b1_001_resource_registry_tables.py`
- Migration (projects): `alembic/versions/b0_001_projects.py`
- ORM models: `src/cleveragents/infrastructure/database/models.py`
- Repositories: `src/cleveragents/infrastructure/database/repositories.py`
+115
View File
@@ -0,0 +1,115 @@
Feature: Project database tables migration
As a developer working on project infrastructure
I need database tables for projects and project resource links
So that spec-aligned namespaced projects can be persisted and queried
Background:
Given a project tables in-memory database is initialized
# ---------------------------------------------------------------------------
# Table existence
# ---------------------------------------------------------------------------
Scenario: Projects table exists after migration
Then the project database should contain table "ns_projects"
Scenario: Project resource links table exists
Then the project database should contain table "project_resource_links"
# ---------------------------------------------------------------------------
# ProjectModel CRUD
# ---------------------------------------------------------------------------
Scenario: Can insert and retrieve a project
When I insert a project "local/my-project" with namespace "local"
Then I can retrieve project "local/my-project"
And the retrieved project namespace is "local"
And the retrieved project description is empty
And the retrieved project tags_json is "[]"
Scenario: Can insert a project with description
When I insert a described project "local/described-proj" with namespace "local" and description "A test project"
Then I can retrieve project "local/described-proj"
And the retrieved project description is "A test project"
Scenario: Can insert a project with JSON fields
When I insert a project "local/json-proj" with JSON fields
Then the project invariants_json round-trips correctly
And the project context_policy_json round-trips correctly
And the project tags_json round-trips as a list
Scenario: Project namespaced_name uniqueness is enforced
When I insert a project "local/unique-test" with namespace "local"
Then inserting a duplicate project "local/unique-test" raises an integrity error
# ---------------------------------------------------------------------------
# ProjectResourceLinkModel CRUD
# ---------------------------------------------------------------------------
Scenario: Can link a resource to a project
Given a resource type "builtin/git-checkout" exists in project db
And a resource "local/my-repo" of type "builtin/git-checkout" exists in project db
And a project "local/linked-proj" exists in project db
When I create a project resource link for project "local/linked-proj" to resource "local/my-repo"
Then the project resource link can be retrieved
And the project resource link project_name is "local/linked-proj"
And the project resource link read_only is false
Scenario: Can link a resource with alias and read_only
Given a resource type "builtin/git-checkout" exists in project db
And a resource "local/aliased-repo" of type "builtin/git-checkout" exists in project db
And a project "local/aliased-proj" exists in project db
When I create a project resource link with alias "my-alias" and read_only true
Then the project resource link alias is "my-alias"
And the project resource link read_only is true
Scenario: Duplicate project-resource link is rejected
Given a resource type "builtin/git-checkout" exists in project db
And a resource "local/dup-repo" of type "builtin/git-checkout" exists in project db
And a project "local/dup-proj" exists in project db
When I create a project resource link for project "local/dup-proj" to resource "local/dup-repo"
Then creating a duplicate link for project "local/dup-proj" to resource "local/dup-repo" raises an integrity error
Scenario: Deleting a project cascades to links
Given a resource type "builtin/git-checkout" exists in project db
And a resource "local/cascade-repo" of type "builtin/git-checkout" exists in project db
And a project "local/cascade-proj" exists in project db
And a project resource link exists for "local/cascade-proj" to "local/cascade-repo"
When I delete the project "local/cascade-proj"
Then the project resource link for "local/cascade-proj" should not exist
Scenario: Deleting a linked resource is restricted
Given a resource type "builtin/git-checkout" exists in project db
And a resource "local/restrict-repo" of type "builtin/git-checkout" exists in project db
And a project "local/restrict-proj" exists in project db
And a project resource link exists for "local/restrict-proj" to "local/restrict-repo"
Then deleting the linked resource "local/restrict-repo" raises an integrity error
# ---------------------------------------------------------------------------
# ORM Model domain conversion
# ---------------------------------------------------------------------------
Scenario: NamespacedProjectModel to_domain round-trips
When I insert a described project "local/domain-proj" with namespace "local" and description "Domain test"
Then converting the project to domain returns a NamespacedProject
And the domain project name is "domain-proj"
And the domain project namespace is "local"
And the domain project description is "Domain test"
Scenario: NamespacedProjectModel from_domain creates a valid model
When I create a NamespacedProject domain model
And I convert it to a NamespacedProjectModel via from_domain
Then the model namespaced_name is "local/from-domain-test"
And the model namespace is "local"
Scenario: Project resource link index on project_name exists
Then the project database should have an index "ix_project_resource_links_project_name" on table "project_resource_links"
Scenario: Project resource link index on resource_id exists
Then the project database should have an index "ix_project_resource_links_resource_id" on table "project_resource_links"
Scenario: Project resource link composite index on project_name and alias exists
Then the project database should have an index "ix_project_resource_links_project_alias" on table "project_resource_links"
Scenario: Project namespace index exists
Then the project database should have an index "ix_ns_projects_namespace" on table "ns_projects"
@@ -0,0 +1,514 @@
"""Step definitions for project database table migration tests.
Tests the NamespacedProjectModel and ProjectResourceLinkModel ORM models
and their corresponding database tables (projects, project_resource_links).
Uses an in-memory SQLite database with schema created via
``Base.metadata.create_all``.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ProjectResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
def _now_iso() -> str:
"""Return current UTC time as ISO-8601 string."""
return datetime.now(tz=UTC).isoformat()
def _make_ulid(suffix: str = "0") -> str:
"""Create a deterministic 26-char fake ULID for testing."""
return ("0" * (26 - len(suffix))) + suffix
def _set_sqlite_pragma_projects(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable FK enforcement on every new SQLite connection."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a project tables in-memory database is initialized")
def step_project_tables_init_db(context: Any) -> None:
"""Create an in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:")
event.listen(engine, "connect", _set_sqlite_pragma_projects)
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(engine)
context.pt_engine = engine
context.pt_session_factory = sessionmaker(bind=engine)
context.pt_session = context.pt_session_factory()
# Storage for cross-step references
context.pt_project = None
context.pt_link = None
context.pt_resource = None
context.pt_resource_id = None
context.pt_error = None
context.pt_domain_project = None
context.pt_model_from_domain = None
# ---------------------------------------------------------------------------
# Table existence
# ---------------------------------------------------------------------------
@then('the project database should contain table "{table_name}"')
def step_project_table_exists(context: Any, table_name: str) -> None:
inspector = inspect(context.pt_engine)
tables = inspector.get_table_names()
assert table_name in tables, f"Table {table_name} not found. Tables: {tables}"
# ---------------------------------------------------------------------------
# Index existence
# ---------------------------------------------------------------------------
@then(
'the project database should have an index "{index_name}" on table "{table_name}"'
)
def step_project_index_exists(context: Any, index_name: str, table_name: str) -> None:
inspector = inspect(context.pt_engine)
indexes = inspector.get_indexes(table_name)
index_names = [idx["name"] for idx in indexes]
assert index_name in index_names, (
f"Index {index_name} not found on {table_name}. Indexes: {index_names}"
)
# ---------------------------------------------------------------------------
# Project CRUD
# ---------------------------------------------------------------------------
@when('I insert a project "{name}" with namespace "{namespace}"')
def step_insert_project(context: Any, name: str, namespace: str) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@when(
'I insert a described project "{name}" with namespace "{namespace}"'
' and description "{desc}"'
)
def step_insert_project_with_desc(
context: Any, name: str, namespace: str, desc: str
) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
description=desc,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@when('I insert a project "{name}" with JSON fields')
def step_insert_project_with_json(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = NamespacedProjectModel(
namespaced_name=name,
namespace="local",
invariants_json=json.dumps(["invariant-1", "invariant-2"]),
context_policy_json=json.dumps({"max_file_size": 500000}),
tags_json=json.dumps(["tag-a", "tag-b"]),
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
context.pt_project = proj
@then('I can retrieve project "{name}"')
def step_retrieve_project(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = session.get(NamespacedProjectModel, name)
assert proj is not None, f"Project {name} not found"
context.pt_project = proj
@then('the retrieved project namespace is "{expected}"')
def step_project_namespace(context: Any, expected: str) -> None:
assert context.pt_project.namespace == expected
@then("the retrieved project description is empty")
def step_project_description_empty(context: Any) -> None:
assert context.pt_project.description is None
@then('the retrieved project description is "{expected}"')
def step_project_description(context: Any, expected: str) -> None:
assert context.pt_project.description == expected
@then('the retrieved project tags_json is "{expected}"')
def step_project_tags_json(context: Any, expected: str) -> None:
assert context.pt_project.tags_json == expected
@then("the project invariants_json round-trips correctly")
def step_project_invariants_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.invariants_json)
assert data == ["invariant-1", "invariant-2"]
@then("the project context_policy_json round-trips correctly")
def step_project_context_policy_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.context_policy_json)
assert data == {"max_file_size": 500000}
@then("the project tags_json round-trips as a list")
def step_project_tags_roundtrip(context: Any) -> None:
data = json.loads(context.pt_project.tags_json)
assert data == ["tag-a", "tag-b"]
@then('inserting a duplicate project "{name}" raises an integrity error')
def step_duplicate_project_error(context: Any, name: str) -> None:
session: Session = context.pt_session
dup = NamespacedProjectModel(
namespaced_name=name,
namespace="local",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(dup)
try:
session.commit()
raise AssertionError("Expected IntegrityError for duplicate project")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Resource + type setup helpers for link tests
# ---------------------------------------------------------------------------
@given('a resource type "{name}" exists in project db')
def step_resource_type_exists_project(context: Any, name: str) -> None:
session: Session = context.pt_session
existing = session.get(ResourceTypeModel, name)
if existing is not None:
return
namespace = name.split("/")[0]
rt = ResourceTypeModel()
rt.name = name
rt.namespace = namespace
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
@given('a resource "{name}" of type "{type_name}" exists in project db')
def step_resource_exists_project(context: Any, name: str, type_name: str) -> None:
session: Session = context.pt_session
# Generate a deterministic ULID from the resource name
ulid = _make_ulid(str(abs(hash(name)) % 10**20).zfill(20))
existing = session.get(ResourceModel, ulid)
if existing is not None:
context.pt_resource_id = ulid
return
r = ResourceModel()
r.resource_id = ulid
r.namespaced_name = name
r.namespace = name.split("/")[0]
r.type_name = type_name
r.resource_kind = "physical"
r.location = f"/tmp/{name}"
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
context.pt_resource_id = ulid
@given('a project "{name}" exists in project db')
def step_project_exists_project(context: Any, name: str) -> None:
session: Session = context.pt_session
existing = session.get(NamespacedProjectModel, name)
if existing is not None:
return
namespace = name.split("/")[0]
proj = NamespacedProjectModel(
namespaced_name=name,
namespace=namespace,
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
# ---------------------------------------------------------------------------
# Project resource links
# ---------------------------------------------------------------------------
@when(
'I create a project resource link for project "{project}" to resource "{resource}"'
)
def step_create_link(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
# Resolve resource_id from namespaced_name
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None, f"Resource {resource} not found"
link_id = _make_ulid(str(abs(hash(f"{project}:{resource}")) % 10**20).zfill(20))
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@when('I create a project resource link with alias "{alias}" and read_only {ro}')
def step_create_link_with_alias(context: Any, alias: str, ro: str) -> None:
session: Session = context.pt_session
# Use the most recently created resource and project
res = session.query(ResourceModel).first()
proj = session.query(NamespacedProjectModel).first()
assert res is not None and proj is not None
link_id = _make_ulid(str(abs(hash(f"alias:{alias}")) % 10**20).zfill(20))
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=proj.namespaced_name,
resource_id=res.resource_id,
alias=alias,
read_only=ro.lower() == "true",
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@then("the project resource link can be retrieved")
def step_link_retrievable(context: Any) -> None:
session: Session = context.pt_session
link = session.get(ProjectResourceLinkModel, context.pt_link.link_id)
assert link is not None, "Link not found"
context.pt_link = link
@then('the project resource link project_name is "{expected}"')
def step_link_project_name(context: Any, expected: str) -> None:
assert context.pt_link.project_name == expected
@then("the project resource link read_only is false")
def step_link_read_only_false(context: Any) -> None:
assert context.pt_link.read_only is False
@then("the project resource link read_only is true")
def step_link_read_only_true(context: Any) -> None:
assert context.pt_link.read_only is True
@then('the project resource link alias is "{expected}"')
def step_link_alias(context: Any, expected: str) -> None:
assert context.pt_link.alias == expected
@then(
'creating a duplicate link for project "{project}" to resource "{resource}"'
" raises an integrity error"
)
def step_duplicate_link_error(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
dup_link_id = _make_ulid("99999999999999999999999999"[:26])
dup = ProjectResourceLinkModel(
link_id=dup_link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(dup)
try:
session.commit()
raise AssertionError("Expected IntegrityError for duplicate link")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Cascade / restrict delete tests
# ---------------------------------------------------------------------------
@given('a project resource link exists for "{project}" to "{resource}"')
def step_link_exists(context: Any, project: str, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
link_id = _make_ulid(
str(abs(hash(f"exist:{project}:{resource}")) % 10**20).zfill(20)
)
link = ProjectResourceLinkModel(
link_id=link_id,
project_name=project,
resource_id=res.resource_id,
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
context.pt_link = link
@when('I delete the project "{name}"')
def step_delete_project(context: Any, name: str) -> None:
session: Session = context.pt_session
proj = session.get(NamespacedProjectModel, name)
assert proj is not None
session.delete(proj)
session.commit()
@then('the project resource link for "{project}" should not exist')
def step_link_should_not_exist(context: Any, project: str) -> None:
session: Session = context.pt_session
links = (
session.query(ProjectResourceLinkModel)
.filter(ProjectResourceLinkModel.project_name == project)
.all()
)
assert len(links) == 0, f"Expected 0 links, found {len(links)}"
@then('deleting the linked resource "{resource}" raises an integrity error')
def step_delete_linked_resource_restricted(context: Any, resource: str) -> None:
session: Session = context.pt_session
res = (
session.query(ResourceModel)
.filter(ResourceModel.namespaced_name == resource)
.first()
)
assert res is not None
session.delete(res)
try:
session.commit()
raise AssertionError("Expected IntegrityError for RESTRICT delete")
except IntegrityError:
session.rollback()
# ---------------------------------------------------------------------------
# Domain conversion
# ---------------------------------------------------------------------------
@then("converting the project to domain returns a NamespacedProject")
def step_to_domain(context: Any) -> None:
from cleveragents.domain.models.core.project import NamespacedProject
domain = context.pt_project.to_domain()
assert isinstance(domain, NamespacedProject)
context.pt_domain_project = domain
@then('the domain project name is "{expected}"')
def step_domain_project_name(context: Any, expected: str) -> None:
assert context.pt_domain_project.name == expected
@then('the domain project namespace is "{expected}"')
def step_domain_project_namespace(context: Any, expected: str) -> None:
assert context.pt_domain_project.namespace == expected
@then('the domain project description is "{expected}"')
def step_domain_project_description(context: Any, expected: str) -> None:
assert context.pt_domain_project.description == expected
@when("I create a NamespacedProject domain model")
def step_create_domain_project(context: Any) -> None:
from cleveragents.domain.models.core.project import NamespacedProject
context.pt_domain_project = NamespacedProject(
name="from-domain-test",
namespace="local",
description="Created from domain",
)
@when("I convert it to a NamespacedProjectModel via from_domain")
def step_convert_from_domain(context: Any) -> None:
model = NamespacedProjectModel.from_domain(context.pt_domain_project)
context.pt_model_from_domain = model
@then('the model namespaced_name is "{expected}"')
def step_model_namespaced_name(context: Any, expected: str) -> None:
assert context.pt_model_from_domain.namespaced_name == expected
@then('the model namespace is "{expected}"')
def step_model_namespace(context: Any, expected: str) -> None:
assert context.pt_model_from_domain.namespace == expected
+22 -22
View File
@@ -2115,28 +2115,28 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [X] Git [Jeff]: `git branch -d feature/m1-resource-builtins` Done: Day 7, February 15, 2026
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. Done: Day 7, February 15, 2026
- [ ] **COMMIT (Owner: Jeff | Group: B0.db.projects | Branch: feature/m1-project-db | Planned: Day 8 | Expected: Day 11) - Commit message: "feat(db): add projects and project links tables"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m1-project-db`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Add Alembic migration for `projects` and `project_resource_links` with explicit down_revision to latest head.
- [ ] Code [Jeff]: Define `projects` columns: `namespaced_name` PK, `namespace`, `description`, `invariants_json` (nullable), `automation_profile` (nullable), `invariant_actor` (nullable), `context_policy_json` (nullable), `tags_json`, `created_by`, timestamps.
- [ ] Code [Jeff]: Define `project_resource_links` columns: `link_id` ULID, `project_name` FK, `resource_id` FK, `alias`, `read_only`, `created_at`.
- [ ] Code [Jeff]: Add uniqueness constraint on (`project_name`, `resource_id`) and index on (`project_name`, `alias`).
- [ ] Code [Jeff]: Add indexes on `project_resource_links.project_name` and `project_resource_links.resource_id` for joins.
- [ ] Code [Jeff]: Add ORM models `ProjectModel` and `ProjectResourceLinkModel` with `to_domain()`/`from_domain()` mapping to `NamespacedProject` + link model and proper JSON field handling.
- [ ] Docs [Jeff]: Document project tables and link constraints in `docs/reference/database_schema.md`.
- [ ] Tests (Behave) [Jeff]: Add migration scenarios verifying project tables and link uniqueness.
- [ ] Tests (Robot) [Jeff]: Add Robot migration smoke test that inserts a project and link row.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/project_migration_bench.py` for migration baseline.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(db): add projects and project links tables"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-project-db` to `master` with description "Add projects + project_resource_links tables with constraints and migration tests.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m1-project-db`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Jeff | Group: B0.db.projects | Branch: feature/m1-project-db | Done: Day 7, February 15, 2026) - Commit message: "feat(db): add projects and project links tables"**
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git pull origin master` Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git checkout -b feature/m1-project-db` Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Add Alembic migration for `ns_projects` and `project_resource_links` with explicit down_revision to latest head. Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Define `ns_projects` columns: `namespaced_name` PK, `namespace`, `description`, `invariants_json` (nullable), `automation_profile` (nullable), `invariant_actor` (nullable), `context_policy_json` (nullable), `tags_json`, `created_by`, timestamps. Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Define `project_resource_links` columns: `link_id` ULID, `project_name` FK, `resource_id` FK, `alias`, `read_only`, `created_at`. Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Add uniqueness constraint on (`project_name`, `resource_id`) and index on (`project_name`, `alias`). Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Add indexes on `project_resource_links.project_name` and `project_resource_links.resource_id` for joins. Done: Day 7, February 15, 2026
- [X] Code [Jeff]: Add ORM models `NamespacedProjectModel` and `ProjectResourceLinkModel` with `to_domain()`/`from_domain()` mapping to `NamespacedProject` + link model and proper JSON field handling. Done: Day 7, February 15, 2026
- [X] Docs [Jeff]: Document project tables and link constraints in `docs/reference/database_schema.md`. Done: Day 7, February 15, 2026
- [X] Tests (Behave) [Jeff]: Add migration scenarios verifying project tables and link uniqueness. Done: Day 7, February 15, 2026
- [X] Tests (Robot) [Jeff]: Add Robot migration smoke test that inserts a project and link row. Done: Day 7, February 15, 2026
- [X] Tests (ASV) [Jeff]: Add `benchmarks/project_migration_bench.py` for migration baseline. Done: Day 7, February 15, 2026
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes. Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git add .` Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git commit -m "feat(db): add projects and project links tables"` Done: Day 7, February 15, 2026
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-project-db` to `master` with description "Add ns_projects + project_resource_links tables with constraints and migration tests.". Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
- [X] Git [Jeff]: `git branch -d feature/m1-project-db` Done: Day 7, February 15, 2026
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. Done: Day 7, February 15, 2026
**LEGACY (completed, superseded by rebaseline)**:
- [X] **COMMIT (Owner: Jeff | Group: B1.core | Branch: feature/m2-resource-core-db | Done: Day 5, February 13, 2026 23:30:15 +0000) - Commit message: "feat(db): add resource registry tables"**
- [X] Git [Jeff]: `git checkout master`
+1
View File
@@ -391,6 +391,7 @@ def integration_tests(session: nox.Session):
"""
session.install("-e", ".[tests]")
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
session.env["PYTHONPATH"] = "src:."
# Override PYTHONPATH so the editable install (src/) for this project
# is used instead of any inherited PYTHONPATH from the outer environment.
+180
View File
@@ -0,0 +1,180 @@
"""Helper utilities for project migration Robot integration tests.
Covers the integration between:
- NamespacedProjectModel <-> NamespacedProject domain model
- ProjectResourceLinkModel persistence
- Round-trip: domain -> SQLAlchemy -> domain
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ProjectResourceLinkModel,
ResourceModel,
ResourceTypeModel,
)
def _now_iso() -> str:
return datetime.now(tz=UTC).isoformat()
def _make_ulid(suffix: str = "0") -> str:
return ("0" * (26 - len(suffix))) + suffix
def _set_sqlite_pragma(dbapi_conn: object, _connection_record: object) -> None:
cursor = dbapi_conn.cursor() # type: ignore[union-attr]
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
def _create_db() -> tuple[Session, object]:
engine = create_engine("sqlite:///:memory:")
event.listen(engine, "connect", _set_sqlite_pragma)
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
session = factory()
return session, engine
def _setup_resource_type(session: Session) -> None:
rt = ResourceTypeModel()
rt.name = "builtin/git-checkout"
rt.namespace = "builtin"
rt.resource_kind = "physical"
rt.user_addable = True
rt.created_at = _now_iso()
rt.updated_at = _now_iso()
session.add(rt)
session.commit()
def _setup_resource(session: Session) -> str:
rid = _make_ulid("1")
r = ResourceModel()
r.resource_id = rid
r.namespaced_name = "local/test-repo"
r.namespace = "local"
r.type_name = "builtin/git-checkout"
r.resource_kind = "physical"
r.location = "/tmp/test-repo"
r.created_at = _now_iso()
r.updated_at = _now_iso()
session.add(r)
session.commit()
return rid
def _project_round_trip() -> None:
session, _ = _create_db()
proj = NamespacedProjectModel(
namespaced_name="local/round-trip-proj",
namespace="local",
description="Round trip test project",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
retrieved = session.get(NamespacedProjectModel, "local/round-trip-proj")
assert retrieved is not None
assert retrieved.namespace == "local"
assert retrieved.description == "Round trip test project"
print("project-round-trip-ok")
def _link_round_trip() -> None:
session, _ = _create_db()
_setup_resource_type(session)
rid = _setup_resource(session)
proj = NamespacedProjectModel(
namespaced_name="local/link-test-proj",
namespace="local",
tags_json="[]",
created_at=_now_iso(),
updated_at=_now_iso(),
)
session.add(proj)
session.commit()
link = ProjectResourceLinkModel(
link_id=_make_ulid("99"),
project_name="local/link-test-proj",
resource_id=rid,
read_only=False,
created_at=_now_iso(),
)
session.add(link)
session.commit()
retrieved = session.get(ProjectResourceLinkModel, _make_ulid("99"))
assert retrieved is not None
assert retrieved.project_name == "local/link-test-proj"
assert retrieved.resource_id == rid
print("link-round-trip-ok")
def _domain_conversion() -> None:
session, _ = _create_db()
# from_domain
domain_project = NamespacedProject(
name="domain-conv-proj",
namespace="local",
description="Domain conversion test",
)
model = NamespacedProjectModel.from_domain(domain_project)
assert model.namespaced_name == "local/domain-conv-proj"
assert model.namespace == "local"
session.add(model)
session.commit()
# to_domain
retrieved = session.get(NamespacedProjectModel, "local/domain-conv-proj")
assert retrieved is not None
domain_back = retrieved.to_domain()
assert domain_back.name == "domain-conv-proj"
assert domain_back.namespace == "local"
assert domain_back.description == "Domain conversion test"
print("domain-conversion-ok")
def main() -> None:
if len(sys.argv) < 2:
print("Usage: helper_project_migration.py <test-name>", file=sys.stderr)
sys.exit(1)
test_name = sys.argv[1]
dispatch: dict[str, object] = {
"project-round-trip": _project_round_trip,
"link-round-trip": _link_round_trip,
"domain-conversion": _domain_conversion,
}
handler = dispatch.get(test_name)
if handler is None:
print(f"Unknown test: {test_name}", file=sys.stderr)
sys.exit(1)
handler() # type: ignore[operator]
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
*** Settings ***
Documentation Integration tests for project migration: NamespacedProjectModel
... and ProjectResourceLinkModel round-trip persistence with SQLite.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_project_migration.py
*** Test Cases ***
Project Table Round Trip
[Documentation] Verify NamespacedProjectModel can insert and retrieve a project
[Tags] database project roundtrip
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} project-round-trip cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} project-round-trip-ok
Project Resource Link Round Trip
[Documentation] Verify ProjectResourceLinkModel can link a resource to a project
[Tags] database project link roundtrip
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} link-round-trip cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} link-round-trip-ok
Project Domain Conversion
[Documentation] Verify NamespacedProjectModel to_domain and from_domain work
[Tags] database project domain
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} domain-conversion cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} domain-conversion-ok
@@ -11,11 +11,13 @@ from .models import (
ContextModel,
LifecycleActionModel,
LifecyclePlanModel,
NamespacedProjectModel,
PlanArgumentModel,
PlanInvariantModel,
PlanModel,
PlanProjectModel,
ProjectModel,
ProjectResourceLinkModel,
ResourceEdgeModel,
ResourceModel,
ResourceTypeModel,
@@ -51,6 +53,7 @@ __all__ = [
"LifecycleActionModel",
"LifecyclePlanModel",
"LifecyclePlanRepository",
"NamespacedProjectModel",
"PlanArgumentModel",
"PlanInvariantModel",
"PlanModel",
@@ -59,6 +62,7 @@ __all__ = [
"PlanRepository",
"ProjectModel",
"ProjectRepository",
"ProjectResourceLinkModel",
"ResourceEdgeModel",
"ResourceModel",
"ResourceTypeModel",
@@ -24,6 +24,7 @@ from sqlalchemy import (
Integer,
String,
Text,
UniqueConstraint,
create_engine,
)
from sqlalchemy.ext.declarative import declarative_base
@@ -40,7 +41,13 @@ Base = declarative_base()
class ProjectModel(Base):
"""Database model for projects."""
"""Legacy database model for projects.
.. deprecated::
Superseded by ``NamespacedProjectModel`` (table ``ns_projects``)
which uses ``namespaced_name`` as PK instead of an integer id.
The legacy ``projects`` table is retained for backward compatibility.
"""
__tablename__ = "projects"
@@ -1020,6 +1027,209 @@ class PlanInvariantModel(Base): # type: ignore[misc]
)
# ---------------------------------------------------------------------------
# Project Models (Stage B0 - migration b0_001_projects)
# ---------------------------------------------------------------------------
class NamespacedProjectModel(Base): # type: ignore[misc]
"""Database model for spec-aligned namespaced projects.
Projects are identified solely by their namespaced name (e.g.,
``local/my-project``). They store invariants, automation profile,
context policy, and tags as JSON text fields.
Table: ``ns_projects``
"""
__allow_unmapped__ = True
__tablename__ = "ns_projects"
# PK: namespaced name (e.g., "local/my-project")
namespaced_name = Column(String(255), primary_key=True)
namespace = Column(String(100), nullable=False)
# Description
description = Column(Text, nullable=True)
# JSON list of invariant strings
invariants_json = Column(Text, nullable=True)
# Default automation profile name
automation_profile = Column(String(255), nullable=True)
# Actor for invariant reconciliation
invariant_actor = Column(String(255), nullable=True)
# JSON context policy configuration
context_policy_json = Column(Text, nullable=True)
# Tags (JSON array of strings)
tags_json = Column(Text, nullable=False, default="[]")
# Creator identifier
created_by = Column(String(255), nullable=True)
# Timestamps (ISO-8601 strings)
created_at = Column(String(30), nullable=False)
updated_at = Column(String(30), nullable=False)
# Relationships
resource_links = relationship(
"ProjectResourceLinkModel",
back_populates="project",
cascade="all, delete-orphan",
)
__table_args__ = (Index("ix_ns_projects_namespace", "namespace"),)
# -- Domain conversion helpers ------------------------------------------
def to_domain(self) -> Any:
"""Convert to ``NamespacedProject`` domain model.
Returns:
NamespacedProject domain object.
"""
from cleveragents.domain.models.core.project import (
ContextConfig,
LinkedResource,
NamespacedProject,
)
# Parse context policy from JSON
context_config: ContextConfig = ContextConfig()
raw_policy = cast("str | None", self.context_policy_json)
if raw_policy:
policy_dict: dict[str, Any] = json.loads(raw_policy)
context_config = ContextConfig(**policy_dict)
# Build linked resources from child relationship
linked_resources: list[LinkedResource] = []
for link in self.resource_links or []:
linked_resources.append(
LinkedResource(
resource_id=cast(str, link.resource_id),
project_read_only=cast(bool, link.read_only),
alias=cast("str | None", link.alias),
linked_at=datetime.fromisoformat(cast(str, link.created_at)),
)
)
# Extract name from namespaced_name (namespace/name format)
ns_name = cast(str, self.namespaced_name)
parts = ns_name.split("/", 1)
short_name = parts[1] if len(parts) > 1 else parts[0]
return NamespacedProject(
name=short_name,
namespace=cast(str, self.namespace),
description=cast("str | None", self.description),
linked_resources=linked_resources,
context_config=context_config,
created_at=datetime.fromisoformat(cast(str, self.created_at)),
updated_at=datetime.fromisoformat(cast(str, self.updated_at)),
)
@classmethod
def from_domain(cls, project: Any) -> NamespacedProjectModel:
"""Create from ``NamespacedProject`` domain model.
Args:
project: A ``NamespacedProject`` domain instance.
Returns:
A ``NamespacedProjectModel`` ready for persistence.
"""
# Serialize context_config to JSON
context_policy_json: str | None = None
if project.context_config is not None:
config_dict = project.context_config.model_dump(mode="json")
context_policy_json = json.dumps(config_dict)
tags_json_str = json.dumps(getattr(project, "tags", []) or [])
model = cls(
namespaced_name=project.namespaced_name,
namespace=project.namespace,
description=project.description,
invariants_json=json.dumps([]),
automation_profile=None,
invariant_actor=None,
context_policy_json=context_policy_json,
tags_json=tags_json_str,
created_by=None,
created_at=project.created_at.isoformat(),
updated_at=project.updated_at.isoformat(),
)
return model
class ProjectResourceLinkModel(Base): # type: ignore[misc]
"""Database model for project-resource links.
Links projects to resources from the Resource Registry with optional
alias and read-only override.
Table: ``project_resource_links``
"""
__allow_unmapped__ = True
__tablename__ = "project_resource_links"
# PK: ULID (26-char string)
link_id = Column(String(26), primary_key=True)
# FK to ns_projects
project_name = Column(
String(255),
ForeignKey("ns_projects.namespaced_name", ondelete="CASCADE"),
nullable=False,
)
# FK to resources
resource_id = Column(
String(26),
ForeignKey("resources.resource_id", ondelete="RESTRICT"),
nullable=False,
)
# Optional alias
alias = Column(String(255), nullable=True)
# Read-only override
read_only = Column(Boolean, nullable=False, default=False)
# Timestamp (ISO-8601 string)
created_at = Column(String(30), nullable=False)
# Relationships
project = relationship(
"NamespacedProjectModel",
back_populates="resource_links",
)
resource = relationship(
"ResourceModel",
foreign_keys=[resource_id],
)
__table_args__ = (
UniqueConstraint(
"project_name",
"resource_id",
name="uq_project_resource_links_project_resource",
),
Index("ix_project_resource_links_project_name", "project_name"),
Index("ix_project_resource_links_resource_id", "resource_id"),
Index(
"ix_project_resource_links_project_alias",
"project_name",
"alias",
),
)
# ---------------------------------------------------------------------------
# Resource Registry Models (Stage B1 - migration b1_001)
# ---------------------------------------------------------------------------