The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""Store schema.
|
|
|
|
Tenant keying, from the first migration onward, per
|
|
`business-app-service-contract_v0.1` § 1.3. Every table holding owned data
|
|
carries `tenant`, and no query may assume it is the only tenant (§ 1.4). The
|
|
service is deployed single-tenant today; the key is here so a later
|
|
consolidation is a data copy rather than a rewrite.
|
|
|
|
The format's three distinct things stay distinct:
|
|
|
|
- a **package version** is immutable content addressed by `<id>@<version>`
|
|
(§ 17), scoped to a registry because identity is registry-scoped (§ 3.2);
|
|
- an **index entry** records how a version arrived in this store (§ 20.3);
|
|
- **files** are content, stored as blobs rather than parsed into rows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
LargeBinary,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from .db import Base
|
|
|
|
|
|
def utcnow() -> dt.datetime:
|
|
return dt.datetime.now(dt.timezone.utc)
|
|
|
|
|
|
class PackageVersion(Base):
|
|
"""One immutable `<registry>:<id>@<version>` within one tenant."""
|
|
|
|
__tablename__ = "package_versions"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant", "registry", "package_id", "version", name="uq_package_version"
|
|
),
|
|
Index("ix_package_versions_tenant_id", "tenant", "package_id"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
registry: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
package_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
version: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
# Indexed manifest fields, for discovery without opening every package.
|
|
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
|
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
package_type: Mapped[str] = mapped_column(String(32), nullable=False, default="template")
|
|
license: Mapped[str | None] = mapped_column(String(128))
|
|
tags: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
# The manifest as published, so a consumer sees exactly what was stored.
|
|
manifest: Mapped[str] = mapped_column(Text, nullable=False)
|
|
content_digest: Mapped[str] = mapped_column(String(71), nullable=False)
|
|
published_at: Mapped[dt.datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow
|
|
)
|
|
|
|
files: Mapped[list["PackageFile"]] = relationship(
|
|
back_populates="package_version", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class PackageFile(Base):
|
|
"""A file belonging to a package version. Content, not parsed rows."""
|
|
|
|
__tablename__ = "package_files"
|
|
__table_args__ = (
|
|
UniqueConstraint("package_version_id", "path", name="uq_package_file_path"),
|
|
Index("ix_package_files_tenant", "tenant"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
package_version_id: Mapped[int] = mapped_column(
|
|
ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
|
|
|
package_version: Mapped[PackageVersion] = relationship(back_populates="files")
|
|
|
|
|
|
class IndexEntry(Base):
|
|
"""How a package version arrived in this store (§ 20.3).
|
|
|
|
Store metadata, deliberately separate from the package: `included_at` is
|
|
first arrival and is never overwritten; a re-record updates `last_seen_at`.
|
|
"""
|
|
|
|
__tablename__ = "index_entries"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant", "registry", "package_id", "version", name="uq_index_entry"
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
registry: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
package_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
version: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
|
method: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
declared_author: Mapped[str | None] = mapped_column(String(256))
|
|
declared_source: Mapped[str | None] = mapped_column(Text)
|
|
license: Mapped[str | None] = mapped_column(String(128))
|
|
|
|
included_at: Mapped[dt.datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=utcnow
|
|
)
|
|
last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|