Files
mono/.claude/plans/hazy-sniffing-sphinx.md
giles 22802bd36b Send all responses as sexp wire format with client-side rendering
- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:45:07 +00:00

14 KiB

Split Cart into Microservices

Context

The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces:

  1. Relations service — internal only, owns ContainerRelation
  2. Likes service — internal only, unified generic likes replacing ProductLike + PostLike
  3. PageConfig → blog — move to blog (which already owns pages)
  4. Orders service — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout

After the split, cart becomes a thin CartItem CRUD + inbox service.


Phase 1: Relations Service (internal only)

1.1 Scaffold relations/

Create minimal internal-only app (no templates, no context_fn):

File Notes
relations/__init__.py Empty
relations/path_setup.py Copy from cart
relations/app.py create_base_app("relations"), register data + actions BPs only
relations/services/__init__.py Empty register_domain_services()
relations/models/__init__.py from shared.models.container_relation import ContainerRelation
relations/bp/__init__.py Export register_data, register_actions
relations/bp/data/routes.py Move get-children handler from cart/bp/data/routes.py:175-198
relations/bp/actions/routes.py Move attach-child + detach-child from cart/bp/actions/routes.py:112-153
relations/alembic.ini Copy from cart, adjust path
relations/alembic/env.py MODELS=["shared.models.container_relation"], TABLES={"container_relations"}
relations/alembic/versions/0001_initial.py Create container_relations table
relations/Dockerfile Follow cart pattern, COPY relations/ ./
relations/entrypoint.sh Standard pattern, db=db_relations

1.2 Retarget callers ("cart""relations")

File Lines Change
events/bp/calendars/services/calendars.py 74, 111, 121 call_action("cart", ...)call_action("relations", ...)
blog/bp/menu_items/services/menu_items.py 83, 137, 141, 157 Same
shared/services/market_impl.py 96, 109, 133 Same

1.3 Clean up cart

  • Remove get-children from cart/bp/data/routes.py:175-198
  • Remove attach-child, detach-child from cart/bp/actions/routes.py:112-153
  • Remove "shared.models.container_relation" and "container_relations" from cart/alembic/env.py

Phase 2: Likes Service (internal only)

2.1 New unified model

Single likes table in db_likes:

class Like(Base):
    __tablename__ = "likes"
    id: Mapped[int] (pk)
    user_id: Mapped[int] (not null, indexed)
    target_type: Mapped[str] (String 32, not null)  # "product" or "post"
    target_slug: Mapped[str | None] (String 255)     # for products
    target_id: Mapped[int | None] (Integer)           # for posts
    created_at, updated_at, deleted_at

    UniqueConstraint("user_id", "target_type", "target_slug")
    UniqueConstraint("user_id", "target_type", "target_id")
    Index("ix_likes_target", "target_type", "target_slug")

Products use target_type="product", target_slug=slug. Posts use target_type="post", target_id=post.id.

2.2 Scaffold likes/

File Notes
likes/__init__.py Empty
likes/path_setup.py Standard
likes/app.py Internal-only, create_base_app("likes"), data + actions BPs
likes/services/__init__.py Empty register_domain_services()
likes/models/__init__.py Import Like
likes/models/like.py Generic Like model (above)
likes/bp/__init__.py Export register functions
likes/bp/data/routes.py is-liked, liked-slugs, liked-ids
likes/bp/actions/routes.py toggle action
likes/alembic.ini Standard
likes/alembic/env.py MODELS=["likes.models.like"], TABLES={"likes"}
likes/alembic/versions/0001_initial.py Create likes table
likes/Dockerfile Standard pattern
likes/entrypoint.sh Standard, db=db_likes

2.3 Data endpoints (likes/bp/data/routes.py)

  • is-liked: params user_id, target_type, target_slug/target_id{"liked": bool}
  • liked-slugs: params user_id, target_type["slug1", "slug2"]
  • liked-ids: params user_id, target_type[1, 2, 3]

2.4 Action endpoints (likes/bp/actions/routes.py)

  • toggle: payload {user_id, target_type, target_slug?, target_id?}{"liked": bool}

2.5 Retarget market app

market/bp/product/routes.py (like_toggle, ~line 119): Replace toggle_product_like(g.s, user_id, product_slug) with:

result = await call_action("likes", "toggle", payload={
    "user_id": user_id, "target_type": "product", "target_slug": product_slug
})
liked = result["liked"]

market/bp/browse/services/db_backend.py (most complex):

  • db_product_full / db_product_full_id: Replace ProductLike subquery with fetch_data("likes", "is-liked", ...). Annotate is_liked after query.
  • db_products_nocounts / db_products_counts: Fetch liked_slugs once via fetch_data("likes", "liked-slugs", ...), filter Product.slug.in_(liked_slugs) for ?liked=true, annotate is_liked post-query.

Delete: toggle_product_like from market/bp/product/services/product_operations.py

2.6 Retarget blog app

blog/bp/post/routes.py (like_toggle): Replace toggle_post_like(g.s, user_id, post_id) with call_action("likes", "toggle", payload={...}).

Delete: toggle_post_like from blog/bp/post/services/post_operations.py

2.7 Remove old like models

  • Remove ProductLike from shared/models/market.py (lines 118-131) + Product.likes relationship (lines 110-114)
  • Remove PostLike from shared/models/ghost_content.py + Post.likes relationship
  • Remove product_likes from market alembic TABLES
  • Remove post_likes from blog alembic TABLES

Phase 3: PageConfig → Blog

3.1 Replace blog proxy endpoints with direct DB queries

blog/bp/data/routes.py (lines 77-102): Replace the 3 proxy handlers that currently call fetch_data("cart", ...) with direct DB queries. Copy logic from cart/bp/data/routes.py:

  • page-config (cart lines 114-134)
  • page-config-by-id (cart lines 136-149)
  • page-configs-batch (cart lines 151-172)
  • page-config-ensure (cart lines 49-81) — add new

Also add the _page_config_dict helper (cart lines 203-213).

3.2 Move action to blog

blog/bp/actions/routes.py (~line 40): Replace call_action("cart", "update-page-config", ...) proxy with direct handler. Copy logic from cart/bp/actions/routes.py:51-110.

3.3 Blog callers become local

File Current After
blog/bp/post/admin/routes.py:34 fetch_data("cart", "page-config", ...) Direct DB query (blog now owns table)
blog/bp/post/admin/routes.py:87,132 call_action("cart", "update-page-config", ...) Direct call to local handler
blog/bp/post/services/markets.py:44 fetch_data("cart", "page-config", ...) Direct DB query
blog/bp/blog/ghost_db.py:295 fetch_data("cart", "page-configs-batch", ...) Direct DB query

3.4 Retarget cross-service callers ("cart""blog")

File Change
cart/bp/cart/services/page_cart.py:181 fetch_data("cart", "page-configs-batch", ...)fetch_data("blog", "page-configs-batch", ...)
cart/bp/cart/global_routes.py:274 fetch_data("cart", "page-config-by-id", ...)fetch_data("blog", "page-config-by-id", ...)

(Note: checkout.py:117 and cart/app.py:177 already target "blog")

3.5 Update blog alembic

blog/alembic/env.py: Add "shared.models.page_config" to MODELS and "page_configs" to TABLES.

3.6 Clean up cart

  • Remove all page-config* handlers from cart/bp/data/routes.py (lines 49-172)
  • Remove update-page-config from cart/bp/actions/routes.py (lines 50-110)
  • Remove "shared.models.page_config" and "page_configs" from cart/alembic/env.py

Phase 4: Orders Service (public, orders.rose-ash.com)

4.1 Scaffold orders/

File Notes
orders/__init__.py Empty
orders/path_setup.py Standard
orders/app.py Public app with context_fn, templates, fragments, page slug hydration
orders/services/__init__.py register_domain_services()
orders/models/__init__.py from shared.models.order import Order, OrderItem
orders/bp/__init__.py Export all BPs
orders/bp/order/ Move from cart/bp/order/ (single order: detail, pay, recheck)
orders/bp/orders/ Move from cart/bp/orders/ (order list + pagination)
orders/bp/checkout/routes.py Webhook + return routes from cart/bp/cart/global_routes.py
orders/bp/data/routes.py Minimal
orders/bp/actions/routes.py create-order action (called by cart during checkout)
orders/bp/fragments/routes.py account-nav-item fragment (orders link)
orders/templates/ Move _types/order/, _types/orders/, checkout templates from cart
orders/alembic.ini Standard
orders/alembic/env.py MODELS=["shared.models.order"], TABLES={"orders", "order_items"}
orders/alembic/versions/0001_initial.py Create orders + order_items tables
orders/Dockerfile Standard, public-facing
orders/entrypoint.sh Standard, db=db_orders

4.2 Move checkout services to orders

Move to orders/services/:

  • checkout.py — from cart/bp/cart/services/checkout.py (move: create_order_from_cart, resolve_page_config, build_sumup_*, get_order_with_details. Keep find_or_create_cart_item in cart.)
  • check_sumup_status.py — from cart/bp/cart/services/check_sumup_status.py

clear_cart_for_order stays in cart as new action:

  • Add clear-cart-for-order to cart/bp/actions/routes.py
  • Orders calls call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})

4.3 create-order action endpoint (orders/bp/actions/routes.py)

Cart's POST /checkout/ calls this:

Payload: {cart_items: [{product_id, product_title, product_slug, product_image,
           product_special_price, product_regular_price, product_price_currency,
           quantity, market_place_container_id}],
          calendar_entries, tickets, user_id, session_id,
          product_total, calendar_total, ticket_total,
          page_post_id, redirect_url, webhook_base_url}
Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description}

4.4 Refactor cart's checkout route

cart/bp/cart/global_routes.py POST /checkout/:

  1. Load local cart data (get_cart, calendar entries, tickets, totals)
  2. Serialize cart items to dicts
  3. result = await call_action("orders", "create-order", payload={...})
  4. Redirect to result["sumup_hosted_url"]

Same for page-scoped checkout in cart/bp/cart/page_routes.py.

4.5 Move webhook + return routes to orders

  • POST /checkout/webhook/<order_id>/orders/bp/checkout/routes.py
  • GET /checkout/return/<order_id>/orders/bp/checkout/routes.py
  • SumUp redirect/webhook URLs must now point to orders.rose-ash.com

4.6 Move order list/detail routes

  • cart/bp/order/orders/bp/order/
  • cart/bp/orders/orders/bp/orders/

4.7 Move startup reconciliation

_reconcile_pending_orders from cart/app.py:209-265orders/app.py

4.8 Clean up cart

  • Remove cart/bp/order/, cart/bp/orders/
  • Remove checkout webhook/return from cart/bp/cart/global_routes.py
  • Remove _reconcile_pending_orders from cart/app.py
  • Remove order templates from cart/templates/
  • Remove "shared.models.order" and "orders", "order_items" from cart/alembic/env.py

Phase 5: Infrastructure (applies to all new services)

5.1 docker-compose.yml

Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9).

Add to x-app-env:

INTERNAL_URL_RELATIONS: http://relations:8000
INTERNAL_URL_LIKES: http://likes:8000
INTERNAL_URL_ORDERS: http://orders:8000
APP_URL_ORDERS: https://orders.rose-ash.com

5.2 docker-compose.dev.yml

Add all 3 services with dev volumes (ports 8008, 8009, 8010). Add to x-sibling-models for all 3 new services.

5.3 deploy.sh

Add relations likes orders to APPS list.

5.4 Caddyfile (/root/caddy/Caddyfile)

Add only orders (public):

orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 }

5.5 shared/infrastructure/factory.py

Add to model import loop: "relations.models", "likes.models", "orders.models"

5.6 shared/infrastructure/urls.py

Add orders_url(path) helper.

5.7 All existing Dockerfiles

Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account).

5.8 CLAUDE.md

Update project structure and add notes about the new services.


Data Migration (one-time, run before code switch)

  1. container_relations from db_cartdb_relations
  2. product_likes from db_market + post_likes from db_blogdb_likes.likes
  3. page_configs from db_cartdb_blog
  4. orders + order_items from db_cartdb_orders

Use pg_dump/pg_restore or direct SQL for migration.


Post-Split Cart State

After all 4 phases, cart owns only:

  • Model: CartItem (table in db_cart)
  • Alembic: cart_items only
  • Data endpoints: cart-summary, cart-items
  • Action endpoints: adopt-cart-for-user, clear-cart-for-order (new)
  • Inbox handlers: Add/Remove/Update rose:CartItem
  • Public routes: cart overview, page cart, add-to-cart, quantity, delete
  • Fragments: cart-mini
  • Checkout: POST /checkout/ (creates order via call_action("orders", "create-order"), redirects to SumUp)

Verification

  1. Relations: Blog attach/detach marketplace to page; events attach/detach calendar
  2. Likes: Toggle product like on market page; toggle post like on blog; ?liked=true filter
  3. PageConfig: Blog admin page config update; cart checkout resolves page config from blog
  4. Orders: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com
  5. No remaining call_action("cart", "attach-child|detach-child|update-page-config")
  6. No remaining fetch_data("cart", "page-config*|get-children")
  7. Cart alembic only manages cart_items table