Decouple per-service Alembic migrations and fix cross-DB queries
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s

Each service (blog, market, cart, events, federation, account) now owns
its own database schema with independent Alembic migrations. Removes the
monolithic shared/alembic/ that ran all migrations against a single DB.

- Add per-service alembic.ini, env.py, and 0001_initial.py migrations
- Add shared/db/alembic_env.py helper with table-name filtering
- Fix cross-DB FK in blog/models/snippet.py (users lives in db_account)
- Fix cart_impl.py cross-DB queries: fetch products and market_places
  via internal data endpoints instead of direct SQL joins
- Fix blog ghost_sync to fetch page_configs from cart via data endpoint
- Add products-by-ids and page-config-ensure data endpoints
- Update all entrypoint.sh to create own DB and run own migrations
- Cart now uses db_cart instead of db_market
- Add docker-compose.dev.yml, dev.sh for local development
- CI deploys both rose-ash swarm stack and rose-ash-dev compose stack
- Fix Quart namespace package crash (root_path in factory.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 12:07:24 +00:00
parent bde2fd73b8
commit e65bd41ebe
77 changed files with 2405 additions and 2335 deletions

35
blog/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

20
blog/alembic/env.py Normal file
View File

@@ -0,0 +1,20 @@
from alembic import context
from shared.db.alembic_env import run_alembic
MODELS = [
"shared.models.ghost_content",
"shared.models.kv",
"shared.models.menu_item",
"shared.models.menu_node",
"shared.models.container_relation",
"blog.models.snippet",
"blog.models.tag_group",
]
TABLES = frozenset({
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes",
"snippets", "tag_groups", "tag_group_tags",
"menu_items", "menu_nodes", "kv", "container_relations",
})
run_alembic(context.config, MODELS, TABLES)

View File

@@ -0,0 +1,268 @@
"""Initial blog tables
Revision ID: blog_0001
Revises: -
Create Date: 2026-02-26
"""
import sqlalchemy as sa
from alembic import op
revision = "blog_0001"
down_revision = None
branch_labels = None
depends_on = None
def _table_exists(conn, name):
result = conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
), {"t": name})
return result.scalar() is not None
def upgrade():
if _table_exists(op.get_bind(), "posts"):
return
op.create_table(
"tags",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ghost_id", sa.String(64), nullable=False),
sa.Column("slug", sa.String(191), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("visibility", sa.String(32), nullable=False),
sa.Column("feature_image", sa.Text(), nullable=True),
sa.Column("meta_title", sa.String(300), nullable=True),
sa.Column("meta_description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("ghost_id"),
)
op.create_index("ix_tags_ghost_id", "tags", ["ghost_id"])
op.create_index("ix_tags_slug", "tags", ["slug"])
op.create_table(
"authors",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ghost_id", sa.String(64), nullable=False),
sa.Column("slug", sa.String(191), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("email", sa.String(255), nullable=True),
sa.Column("profile_image", sa.Text(), nullable=True),
sa.Column("cover_image", sa.Text(), nullable=True),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("website", sa.Text(), nullable=True),
sa.Column("location", sa.Text(), nullable=True),
sa.Column("facebook", sa.Text(), nullable=True),
sa.Column("twitter", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("ghost_id"),
)
op.create_index("ix_authors_ghost_id", "authors", ["ghost_id"])
op.create_index("ix_authors_slug", "authors", ["slug"])
op.create_table(
"posts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ghost_id", sa.String(64), nullable=False),
sa.Column("uuid", sa.String(64), nullable=False),
sa.Column("slug", sa.String(191), nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("html", sa.Text(), nullable=True),
sa.Column("plaintext", sa.Text(), nullable=True),
sa.Column("mobiledoc", sa.Text(), nullable=True),
sa.Column("lexical", sa.Text(), nullable=True),
sa.Column("feature_image", sa.Text(), nullable=True),
sa.Column("feature_image_alt", sa.Text(), nullable=True),
sa.Column("feature_image_caption", sa.Text(), nullable=True),
sa.Column("excerpt", sa.Text(), nullable=True),
sa.Column("custom_excerpt", sa.Text(), nullable=True),
sa.Column("visibility", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("featured", sa.Boolean(), nullable=False),
sa.Column("is_page", sa.Boolean(), nullable=False),
sa.Column("email_only", sa.Boolean(), nullable=False),
sa.Column("canonical_url", sa.Text(), nullable=True),
sa.Column("meta_title", sa.String(500), nullable=True),
sa.Column("meta_description", sa.Text(), nullable=True),
sa.Column("og_image", sa.Text(), nullable=True),
sa.Column("og_title", sa.String(500), nullable=True),
sa.Column("og_description", sa.Text(), nullable=True),
sa.Column("twitter_image", sa.Text(), nullable=True),
sa.Column("twitter_title", sa.String(500), nullable=True),
sa.Column("twitter_description", sa.Text(), nullable=True),
sa.Column("custom_template", sa.String(191), nullable=True),
sa.Column("reading_time", sa.Integer(), nullable=True),
sa.Column("comment_id", sa.String(191), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("publish_requested", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("primary_author_id", sa.Integer(), nullable=True),
sa.Column("primary_tag_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("ghost_id"),
sa.UniqueConstraint("uuid"),
sa.ForeignKeyConstraint(["primary_author_id"], ["authors.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["primary_tag_id"], ["tags.id"], ondelete="SET NULL"),
)
op.create_index("ix_posts_ghost_id", "posts", ["ghost_id"])
op.create_index("ix_posts_slug", "posts", ["slug"])
op.create_index("ix_posts_user_id", "posts", ["user_id"])
op.create_table(
"post_authors",
sa.Column("post_id", sa.Integer(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("post_id", "author_id"),
sa.ForeignKeyConstraint(["post_id"], ["posts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["author_id"], ["authors.id"], ondelete="CASCADE"),
)
op.create_table(
"post_tags",
sa.Column("post_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("post_id", "tag_id"),
sa.ForeignKeyConstraint(["post_id"], ["posts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
)
op.create_table(
"post_likes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("post_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["post_id"], ["posts.id"], ondelete="CASCADE"),
)
op.create_table(
"snippets",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("visibility", sa.String(20), nullable=False, server_default="private"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
)
op.create_index("ix_snippets_user_id", "snippets", ["user_id"])
op.create_index("ix_snippets_visibility", "snippets", ["visibility"])
op.create_table(
"tag_groups",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("slug", sa.String(191), nullable=False),
sa.Column("feature_image", sa.Text(), nullable=True),
sa.Column("colour", sa.String(32), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_table(
"tag_group_tags",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tag_group_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tag_group_id"], ["tag_groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
sa.UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
op.create_table(
"menu_items",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("post_id", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_menu_items_post_id", "menu_items", ["post_id"])
op.create_index("ix_menu_items_sort_order", "menu_items", ["sort_order"])
op.create_table(
"menu_nodes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("container_type", sa.String(32), nullable=False),
sa.Column("container_id", sa.Integer(), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("depth", sa.Integer(), nullable=False),
sa.Column("label", sa.String(255), nullable=False),
sa.Column("slug", sa.String(255), nullable=True),
sa.Column("href", sa.String(1024), nullable=True),
sa.Column("icon", sa.String(64), nullable=True),
sa.Column("feature_image", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["parent_id"], ["menu_nodes.id"], ondelete="SET NULL"),
)
op.create_index("ix_menu_nodes_container", "menu_nodes", ["container_type", "container_id"])
op.create_index("ix_menu_nodes_parent_id", "menu_nodes", ["parent_id"])
op.create_table(
"kv",
sa.Column("key", sa.String(120), nullable=False),
sa.Column("value", sa.Text(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("key"),
)
op.create_table(
"container_relations",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("parent_type", sa.String(32), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=False),
sa.Column("child_type", sa.String(32), nullable=False),
sa.Column("child_id", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("parent_type", "parent_id", "child_type", "child_id", name="uq_container_relations_parent_child"),
)
op.create_index("ix_container_relations_parent", "container_relations", ["parent_type", "parent_id"])
op.create_index("ix_container_relations_child", "container_relations", ["child_type", "child_id"])
def downgrade():
op.drop_table("container_relations")
op.drop_table("kv")
op.drop_table("menu_nodes")
op.drop_table("menu_items")
op.drop_table("tag_group_tags")
op.drop_table("tag_groups")
op.drop_table("snippets")
op.drop_table("post_likes")
op.drop_table("post_tags")
op.drop_table("post_authors")
op.drop_table("posts")
op.drop_table("authors")
op.drop_table("tags")

View File

@@ -21,8 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import (
Post, Author, Tag, PostAuthor, PostTag
)
from shared.models.page_config import PageConfig
from shared.infrastructure.data_client import fetch_data
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
@@ -218,14 +217,13 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
finally:
sess.autoflush = old_autoflush
# Auto-create PageConfig for pages
# Auto-create PageConfig for pages (lives in db_cart, accessed via internal API)
if obj.is_page:
existing_pc = (await sess.execute(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == obj.id)
)).scalar_one_or_none()
if existing_pc is None:
sess.add(PageConfig(container_type="page", container_id=obj.id, features={}))
await sess.flush()
await fetch_data(
"cart", "page-config-ensure",
params={"container_type": "page", "container_id": obj.id},
required=False,
)
return obj, old_status

41
blog/entrypoint.sh Normal file → Executable file
View File

@@ -10,10 +10,33 @@ if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
done
fi
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
echo "Running Alembic migrations..."
(cd shared && alembic upgrade head)
# Create own database + run own migrations
if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then
python3 -c "
import os, re
url = os.environ['ALEMBIC_DATABASE_URL']
m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url)
if not m:
print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation')
exit(0)
user, password, host, port, dbname = m.groups()
import psycopg
conn = psycopg.connect(
f'postgresql://{user}:{password}@{host}:{port}/postgres',
autocommit=True,
)
cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,))
if not cur.fetchone():
conn.execute(f'CREATE DATABASE {dbname}')
print(f'Created database {dbname}')
else:
print(f'Database {dbname} already exists')
conn.close()
" || echo "DB creation failed (non-fatal), continuing..."
echo "Running blog Alembic migrations..."
(cd blog && alembic upgrade head)
fi
# Clear Redis page cache on deploy
@@ -28,5 +51,11 @@ print('Redis cache cleared.')
fi
# Start the app
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
fi
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG}

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy import Integer, String, Text, DateTime, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
@@ -16,9 +16,7 @@ class Snippet(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(