canned-prompts/service/migrations/versions/0001_tenant_keyed_package_store.py
tegwick 5f47da8036 CANP-WP-0006 T03: read API
Search, versions, manifests, archives and the index, over HTTP.

The route shape is the decision worth recording. Package ids contain `/`, so
the obvious /packages/{id}/{version} is ambiguous under a greedy path
parameter. Rather than invent an HTTP-specific identifier, the routes speak the
format's own <registry>:<id>@<version> syntax and parse it — `:` and `@` are
both legal in a path segment, and each route keeps a distinct prefix so
greediness cannot swallow a neighbouring one. The API therefore exercises
section 3.2's reference notation instead of working around it.

A bare id present in more than one registry returns 409 with the candidates,
never a guess. 409 rather than 300 because the request is answerable once the
caller says which registry they meant. Omitting a version applies section
17.1's selector rules, so a prerelease is never chosen implicitly.

Validation is delegated to reference/, installed into the service environment
rather than reimplemented. One validator means the service and the CLI cannot
disagree about what a valid package is; a service accepting something the CLI
rejects would be the divergence this project exists to prevent. Importing it is
not changing it — reference/ stays the dependency-light conformance witness.

Storage keeps the format's distinctions: an immutable package version, its
files as content rather than parsed rows, and an index entry recording arrival.
Only reserved paths and manifest-referenced files are stored (section 2), and a
re-publish of identical content is accepted while different content under the
same id@version is a conflict (section 17).

Handles a real test-vs-production difference: SQLite autoincrements INTEGER
PRIMARY KEY only, never BIGINT, so the SQLite-backed tests could not insert a
row. BigInteger().with_variant(Integer, "sqlite") keeps BIGINT on PostgreSQL
while letting the tests exercise the same models and migration.

Verified live against a seeded store holding this repo's examples and four
helix-forge prompt packages. Service tests 11 -> 22.

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
2026-09-06 20:23:25 +02:00

81 lines
3.8 KiB
Python

"""tenant-keyed package store
Every table holding owned data carries `tenant` from this, the first migration,
per business-app-service-contract_v0.1 section 1.3 — so a later consolidation is
a data copy rather than a rewrite, and no business logic may assume it is the
only tenant (section 1.4).
Revision ID: 0001
Revises:
"""
from alembic import op
import sqlalchemy as sa
# Matches models.BigIntPK: BIGINT on PostgreSQL, INTEGER on SQLite so the
# test database autoincrements the same way production does.
BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite")
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('index_entries',
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), nullable=False),
sa.Column('version', sa.String(length=64), nullable=False),
sa.Column('source', sa.Text(), nullable=False),
sa.Column('method', sa.String(length=32), nullable=False),
sa.Column('declared_author', sa.String(length=256), nullable=True),
sa.Column('declared_source', sa.Text(), nullable=True),
sa.Column('license', sa.String(length=128), nullable=True),
sa.Column('included_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_index_entry')
)
op.create_table('package_versions',
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('registry', sa.String(length=128), nullable=False),
sa.Column('package_id', sa.String(length=512), nullable=False),
sa.Column('version', sa.String(length=64), nullable=False),
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('package_type', sa.String(length=32), nullable=False),
sa.Column('license', sa.String(length=128), nullable=True),
sa.Column('tags', sa.Text(), nullable=False),
sa.Column('manifest', sa.Text(), nullable=False),
sa.Column('content_digest', sa.String(length=71), nullable=False),
sa.Column('published_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_package_version')
)
op.create_index('ix_package_versions_tenant_id', 'package_versions', ['tenant', 'package_id'], unique=False)
op.create_table('package_files',
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
sa.Column('tenant', sa.String(length=64), nullable=False),
sa.Column('package_version_id', sa.BigInteger(), nullable=False),
sa.Column('path', sa.String(length=1024), nullable=False),
sa.Column('content', sa.LargeBinary(), nullable=False),
sa.ForeignKeyConstraint(['package_version_id'], ['package_versions.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('package_version_id', 'path', name='uq_package_file_path')
)
op.create_index('ix_package_files_tenant', 'package_files', ['tenant'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_package_files_tenant', table_name='package_files')
op.drop_table('package_files')
op.drop_index('ix_package_versions_tenant_id', table_name='package_versions')
op.drop_table('package_versions')
op.drop_table('index_entries')
# ### end Alembic commands ###