- 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>
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:
- Relations service — internal only, owns ContainerRelation
- Likes service — internal only, unified generic likes replacing ProductLike + PostLike
- PageConfig → blog — move to blog (which already owns pages)
- 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-childrenfromcart/bp/data/routes.py:175-198 - Remove
attach-child,detach-childfromcart/bp/actions/routes.py:112-153 - Remove
"shared.models.container_relation"and"container_relations"fromcart/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: paramsuser_id, target_type, target_slug/target_id→{"liked": bool}liked-slugs: paramsuser_id, target_type→["slug1", "slug2"]liked-ids: paramsuser_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: ReplaceProductLikesubquery withfetch_data("likes", "is-liked", ...). Annotateis_likedafter query.db_products_nocounts/db_products_counts: Fetchliked_slugsonce viafetch_data("likes", "liked-slugs", ...), filterProduct.slug.in_(liked_slugs)for?liked=true, annotateis_likedpost-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
ProductLikefromshared/models/market.py(lines 118-131) +Product.likesrelationship (lines 110-114) - Remove
PostLikefromshared/models/ghost_content.py+Post.likesrelationship - Remove
product_likesfrom market alembic TABLES - Remove
post_likesfrom 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 fromcart/bp/data/routes.py(lines 49-172) - Remove
update-page-configfromcart/bp/actions/routes.py(lines 50-110) - Remove
"shared.models.page_config"and"page_configs"fromcart/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— fromcart/bp/cart/services/checkout.py(move:create_order_from_cart,resolve_page_config,build_sumup_*,get_order_with_details. Keepfind_or_create_cart_itemin cart.)check_sumup_status.py— fromcart/bp/cart/services/check_sumup_status.py
clear_cart_for_order stays in cart as new action:
- Add
clear-cart-for-ordertocart/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/:
- Load local cart data (get_cart, calendar entries, tickets, totals)
- Serialize cart items to dicts
result = await call_action("orders", "create-order", payload={...})- 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.pyGET /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-265 → orders/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_ordersfromcart/app.py - Remove order templates from
cart/templates/ - Remove
"shared.models.order"and"orders", "order_items"fromcart/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)
container_relationsfromdb_cart→db_relationsproduct_likesfromdb_market+post_likesfromdb_blog→db_likes.likespage_configsfromdb_cart→db_blogorders+order_itemsfromdb_cart→db_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_itemsonly - 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
- Relations: Blog attach/detach marketplace to page; events attach/detach calendar
- Likes: Toggle product like on market page; toggle post like on blog;
?liked=truefilter - PageConfig: Blog admin page config update; cart checkout resolves page config from blog
- Orders: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com
- No remaining
call_action("cart", "attach-child|detach-child|update-page-config") - No remaining
fetch_data("cart", "page-config*|get-children") - Cart alembic only manages
cart_itemstable