Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ def run_migrations_online() -> None:
)

with connectable.connect() as connection:
if connection.dialect.name == "sqlite":
# SQLite refuses to toggle `PRAGMA foreign_keys` mid-transaction
# (a documented no-op once a transaction is open), and Alembic's
# own per-migration transaction is already open by the time a
# revision's upgrade() runs. A migration that uses batch mode to
# add a constraint to a table that another table already has a
# deferred foreign key pointing at (e.g. #299's cyclic
# repository_lineages <-> repositories relationship) drops and
# recreates that table; SQLite's deferred-FK bookkeeping does not
# correctly reconcile that recreation against the still-open
# transaction, and a fully self-consistent final state still
# fails at COMMIT with a generic "FOREIGN KEY constraint failed"
# (verified: `PRAGMA foreign_key_check` reports no violation
# immediately beforehand). Disabling enforcement here, before any
# transaction opens, avoids this without weakening runtime
# enforcement: every real application connection still gets
# `PRAGMA foreign_keys=ON` via app.core.database's own
# connect-event listener; this affects only the connection
# Alembic itself uses while migrating.
connection.exec_driver_sql("PRAGMA foreign_keys=OFF")
connection.commit()
context.configure(connection=connection, target_metadata=target_metadata)

with context.begin_transaction():
Expand Down
382 changes: 382 additions & 0 deletions apps/backend/alembic/versions/0013_lineage_expand.py

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions apps/backend/alembic/versions/0014_lineage_constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""close the repository-lineage cyclic integrity boundary (2 of 2)

Revision ID: 0014_lineage_constraints
Revises: 0013_lineage_expand
Create Date: 2026-08-27

Issue #299 (RFC-0002), part 2 of 2. Adds the one constraint that genuinely
needs both sides of the lineage/repository relationship to already exist:
``repository_lineages.latest_repository_id`` (together with ``id``) must
name a row in ``repositories`` whose own ``lineage_id`` points back at this
exact lineage -- a latest pointer can never name a repository in a
different lineage, or a different owner's repository, even if application
code is wrong.

Split from Revision A specifically so a failure here leaves A's state
additive, backward-compatible, and inspectable/fixable before rerunning
this one (plan §7). The new application must not start unless Alembic is at
this revision or later -- do not start code that assumes the latest-member
invariant against a database still only at Revision A.
"""

from alembic import op

revision = "0014_lineage_constraints"
down_revision = "0013_lineage_expand"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("repository_lineages") as batch:
batch.create_foreign_key(
"fk_repository_lineages_latest_member",
"repositories",
["latest_repository_id", "id"],
["id", "lineage_id"],
deferrable=True,
initially="DEFERRED",
)


def downgrade() -> None:
with op.batch_alter_table("repository_lineages") as batch:
batch.drop_constraint("fk_repository_lineages_latest_member", type_="foreignkey")
2 changes: 2 additions & 0 deletions apps/backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.models.invite_token import InviteToken
from app.models.refresh_token import RefreshToken
from app.models.repository import RepositoryRecord
from app.models.repository_lineage import RepositoryLineage
from app.models.snapshot import (
RiAssertion,
RiDerivation,
Expand All @@ -26,6 +27,7 @@
"InviteToken",
"RefreshToken",
"RepositoryRecord",
"RepositoryLineage",
"RiAssertion",
"RiDerivation",
"RiDiagnostic",
Expand Down
37 changes: 37 additions & 0 deletions apps/backend/app/models/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CheckConstraint,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
JSON,
Expand Down Expand Up @@ -44,6 +45,12 @@ class RepositoryRecord(Base):
revision_kind: Mapped[str | None] = mapped_column(String(16), nullable=True)
revision_value: Mapped[str | None] = mapped_column(String(80), nullable=True)
revision_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Durable logical grouping above revisions (#299, RFC-0002). Both remain
# permanently nullable: an upload or an unresolved-ref legacy GitHub row
# is a standalone import with no lineage, by design (RFC §4.3/§6), not a
# transitional state to be tightened later.
lineage_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
sequence: Mapped[int | None] = mapped_column(Integer, nullable=True)
local_path: Mapped[str] = mapped_column(Text)
size: Mapped[int] = mapped_column(BigInteger, default=0)
file_count: Mapped[int] = mapped_column(Integer, default=0)
Expand Down Expand Up @@ -94,6 +101,36 @@ class RepositoryRecord(Base):
name="ck_repositories_git_revision",
),
Index("ix_repositories_revision_value", "revision_value"),
CheckConstraint(
"(lineage_id IS NULL AND sequence IS NULL) OR "
"(lineage_id IS NOT NULL AND sequence IS NOT NULL AND sequence >= 1)",
name="ck_repositories_lineage_sequence_pair",
),
# Standalone rows (both null) never collide under a unique constraint:
# SQL uniqueness treats every NULL as distinct. Also serves ordered
# lineage reads.
UniqueConstraint("lineage_id", "sequence", name="uq_repositories_lineage_sequence"),
# Composite target proving a lineage's latest-member pointer names a
# repository that actually belongs to that exact lineage.
UniqueConstraint("id", "lineage_id", name="uq_repositories_id_lineage"),
# Cross-owner attachment is invalid at the database layer even if
# service code is wrong (#299 §9). Deferred: a lineage and its first
# repository are written in one transaction (RFC §5.2), so this must
# not be checked until commit. No automatic delete action -- deletion
# updates or clears the lineage's latest pointer explicitly first
# (RFC §8.3), it is never left to a database cascade/set-null here.
# This is one half of a cyclic FK pair with `repository_lineages`;
# see the known `create_all()`-on-SQLite enforcement limitation
# documented on `RepositoryLineage.fk_repository_lineages_latest_member`
# (app/models/repository_lineage.py) -- it applies equally to
# whichever of the two constraints ends up as the forward reference.
ForeignKeyConstraint(
["lineage_id", "owner_id"],
["repository_lineages.id", "repository_lineages.owner_id"],
name="fk_repositories_lineage_owner",
deferrable=True,
initially="DEFERRED",
),
)

@validates("revision_kind", "revision_value")
Expand Down
113 changes: 113 additions & 0 deletions apps/backend/app/models/repository_lineage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from datetime import UTC, datetime

from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import Base

_CANONICAL_PARTIAL_WHERE = text("canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL")


class RepositoryLineage(Base):
"""Durable, owner-scoped logical grouping above repository revisions (#299, RFC-0002).

A row here groups repeated imports of the same GitHub repository/branch
into one ordered history. Uploads and unresolved-ref GitHub rows never get
a row here -- they stay unlineaged standalone imports (RFC §4.3/§6) -- so
``canonical_source_key``/``canonical_branch`` and every ``repositories``
attachment are permanently optional, not a transitional NULL.
"""

__tablename__ = "repository_lineages"

id: Mapped[str] = mapped_column(String(36), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(36), index=True)
canonical_source_key: Mapped[str | None] = mapped_column(Text, nullable=True)
canonical_branch: Mapped[str | None] = mapped_column(Text, nullable=True)
display_name: Mapped[str] = mapped_column(Text)
# The current highest surviving sequence in this lineage, or null for an
# empty lineage. This FK is deferrable/initially-deferred and cyclic with
# `repositories`: a lineage must exist (with a null pointer) before its
# first repository can be inserted, and the pointer is only set afterward,
# in the same transaction (RFC §5.1/§5.2).
latest_repository_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
# Durable, never-reused, transactionally-allocated next ordinal. Starts at
# 1; deleting a repository never decrements it (RFC §4.3).
next_sequence: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))

__table_args__ = (
# Composite ownership FK target for `repositories.lineage_id`.
UniqueConstraint("id", "owner_id", name="uq_repository_lineages_id_owner"),
CheckConstraint(
"(canonical_source_key IS NULL AND canonical_branch IS NULL) OR "
"(canonical_source_key IS NOT NULL AND canonical_branch IS NOT NULL)",
name="ck_repository_lineages_canonical_pair",
),
CheckConstraint("next_sequence >= 1", name="ck_repository_lineages_next_sequence_positive"),
ForeignKeyConstraint(
["owner_id"],
["users.id"],
name="fk_repository_lineages_owner_id_users",
ondelete="CASCADE",
),
# The owner-scoped canonical lookup index, and the sole source of
# "does this lineage already exist" truth if two imports race to
# create the first one (RFC §5.2).
Index(
"uq_repository_lineages_owner_source_branch",
"owner_id",
"canonical_source_key",
"canonical_branch",
unique=True,
postgresql_where=_CANONICAL_PARTIAL_WHERE,
sqlite_where=_CANONICAL_PARTIAL_WHERE,
),
# The cyclic half of the lineage/repository integrity boundary (RFC
# §4.2): a latest-pointer can never name a repository outside this
# exact lineage, even if service code is wrong. Declared here (rather
# than only in the migration) so `create_all()` in development/test
# produces the identical *declared* shape a migrated database
# reaches (`PRAGMA foreign_key_list`/`inspector.get_foreign_keys()`
# show it either way, on both dialects).
#
# Known SQLite limitation (confirmed in CI, #299): `create_all()`
# cannot avoid embedding one of these two cyclic FKs (this one, or
# `repositories.fk_repositories_lineage_owner`) as an inline forward
# reference to a table that doesn't exist yet -- SQLite must create
# one of `repositories`/`repository_lineages` before the other, and
# there is no `ALTER TABLE ADD CONSTRAINT` to add the missing half
# afterward the way the migration does. Which of the two ends up as
# the forward reference depends on `create_all()`'s internal
# cyclic-dependency tie-break, not something this code controls. At
# least one real SQLite build does not enforce a deferred FK
# declared that way -- it still reports the constraint correctly,
# but a genuine violation neither raises at COMMIT nor shows up in
# `PRAGMA foreign_key_check`. The
# Alembic migration (0013/0014) never creates this forward
# reference -- it adds each cyclic FK in its own revision, after
# both tables already exist -- so a database built by migrating,
# including every real deployment and this repo's own CI rehearsal,
# is unaffected. This is a `create_all()`-only (development/test
# bootstrap) gap, not a production one; see
# tests/test_repository_lineage_migration.py's
# test_cross_owner_lineage_attachment_is_rejected_by_the_database_even_if_forced
# for the migration-backed, reliable version of this proof.
ForeignKeyConstraint(
["latest_repository_id", "id"],
["repositories.id", "repositories.lineage_id"],
name="fk_repository_lineages_latest_member",
deferrable=True,
initially="DEFERRED",
),
)
Loading
Loading