relation plan
This commit is contained in:
473
docs/relation-system-plan.md
Normal file
473
docs/relation-system-plan.md
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
# Flexible Entity Relationship System
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The rose-ash platform has 9 microservices with decoupled entities (posts, pages, markets, calendars, entries, products, etc.) connected via a generic `ContainerRelation` table. The current system is limited:
|
||||||
|
|
||||||
|
- **Parent-child only** — no cardinality, no relation types, no many-to-many
|
||||||
|
- **Feature flags gate everything** — `PageConfig.features["market"]=true` must be set before creating a market on a page
|
||||||
|
- **Navigation is disconnected** — `rebuild_navigation()` is a no-op; MenuNode is manually managed
|
||||||
|
- **Per-domain boilerplate** — each service has its own container-nav fragment handler, its own create-and-attach flow
|
||||||
|
- **No relation semantics** — you can't distinguish *how* two entities are connected, only *that* they are
|
||||||
|
|
||||||
|
The s-expression engine (phases 1-7 complete) gives us a declarative language to define relations and auto-generate UI. Building this now means the remaining sexp work gets designed around the relation model.
|
||||||
|
|
||||||
|
**Goal**: A registry-driven system where relation types are declared in s-expressions, cardinality is enforced, navigation is auto-generated, and attach/detach is generic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
1. **Relations is a graph store, not an orchestrator.** Domain services create their own entities, then call `relate` on the relations service. Relations validates cardinality and stores the link. It never calls out to domain services to create entities.
|
||||||
|
|
||||||
|
2. **All entity connections live in ContainerRelation.** Including many-to-many (e.g. post↔calendar_entry). Single source of truth. Domain-specific junction tables (like `calendar_entry_posts`) migrate here.
|
||||||
|
|
||||||
|
## Architecture: Three Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ RELATION REGISTRY (shared/sexp/relations.py) │
|
||||||
|
│ defrelation declarations — loaded at startup by all │
|
||||||
|
│ services from shared code. Pure data, no DB. │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ RELATION STORAGE (relations service, db_relations) │
|
||||||
|
│ container_relations table + relation_type column. │
|
||||||
|
│ Graph store. Validates against registry. │
|
||||||
|
│ Domain services create entities, then call relate. │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ RELATION CONSUMERS (all services) │
|
||||||
|
│ Nav auto-generation, UI components, fragment rendering │
|
||||||
|
│ Read registry to know how to display/manage relations │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A: Relation Registry
|
||||||
|
|
||||||
|
### A.1 — `defrelation` s-expression form
|
||||||
|
|
||||||
|
New special form for the evaluator, alongside `defcomp`:
|
||||||
|
|
||||||
|
```scheme
|
||||||
|
(defrelation :page->market
|
||||||
|
:from "page"
|
||||||
|
:to "market"
|
||||||
|
:cardinality :one-to-many
|
||||||
|
:inverse :market->page
|
||||||
|
:nav :submenu
|
||||||
|
:nav-icon "fa fa-shopping-bag"
|
||||||
|
:nav-label "markets")
|
||||||
|
|
||||||
|
(defrelation :page->calendar
|
||||||
|
:from "page"
|
||||||
|
:to "calendar"
|
||||||
|
:cardinality :one-to-many
|
||||||
|
:inverse :calendar->page
|
||||||
|
:nav :submenu
|
||||||
|
:nav-icon "fa fa-calendar"
|
||||||
|
:nav-label "calendars")
|
||||||
|
|
||||||
|
(defrelation :post->calendar_entry
|
||||||
|
:from "post"
|
||||||
|
:to "calendar_entry"
|
||||||
|
:cardinality :many-to-many
|
||||||
|
:inverse :calendar_entry->post
|
||||||
|
:nav :inline
|
||||||
|
:nav-icon "fa fa-file-alt"
|
||||||
|
:nav-label "events")
|
||||||
|
|
||||||
|
(defrelation :page->menu_node
|
||||||
|
:from "page"
|
||||||
|
:to "menu_node"
|
||||||
|
:cardinality :one-to-one
|
||||||
|
:nav :hidden)
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.2 — RelationDef type
|
||||||
|
|
||||||
|
**File: `shared/sexp/types.py`** — add alongside `Component`, `Lambda`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RelationDef:
|
||||||
|
name: str # "page->market"
|
||||||
|
from_type: str # "page"
|
||||||
|
to_type: str # "market"
|
||||||
|
cardinality: str # "one-to-one" | "one-to-many" | "many-to-many"
|
||||||
|
inverse: str | None # "market->page"
|
||||||
|
nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden"
|
||||||
|
nav_icon: str | None # "fa fa-shopping-bag"
|
||||||
|
nav_label: str | None # "markets" — display label for nav sections
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.3 — Registry module
|
||||||
|
|
||||||
|
**New file: `shared/sexp/relations.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
_RELATION_REGISTRY: dict[str, RelationDef] = {}
|
||||||
|
|
||||||
|
def load_relation_registry() -> None:
|
||||||
|
"""Parse defrelation s-expressions, populate registry."""
|
||||||
|
for source in [_PAGE_MARKET, _PAGE_CALENDAR, _POST_ENTRY, _PAGE_MENU_NODE]:
|
||||||
|
register_relations(source)
|
||||||
|
|
||||||
|
def get_relation(name: str) -> RelationDef | None: ...
|
||||||
|
def relations_from(entity_type: str) -> list[RelationDef]: ...
|
||||||
|
def relations_to(entity_type: str) -> list[RelationDef]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Called from `create_base_app()` at startup alongside `load_shared_components()`.
|
||||||
|
|
||||||
|
### A.4 — `defrelation` in the evaluator
|
||||||
|
|
||||||
|
**File: `shared/sexp/evaluator.py`** — add to `_SPECIAL_FORMS`:
|
||||||
|
|
||||||
|
Parses keyword args from the s-expression, creates a `RelationDef`, stores it in `_RELATION_REGISTRY` and the current env.
|
||||||
|
|
||||||
|
### A.5 — Replaces PageConfig feature flags
|
||||||
|
|
||||||
|
- The existence of `defrelation :page->market` means pages *can* have markets. No feature flag needed.
|
||||||
|
- Admin UI queries `relations_from("page")` to show create buttons.
|
||||||
|
- `PageConfig` survives only for SumUp payment credentials. The `features` JSON column is deprecated.
|
||||||
|
|
||||||
|
### A.6 — Tests
|
||||||
|
|
||||||
|
**New file: `shared/sexp/tests/test_relations.py`**
|
||||||
|
- Parse `defrelation`, verify `RelationDef` fields
|
||||||
|
- Registry lookup: `get_relation()`, `relations_from()`, `relations_to()`
|
||||||
|
- Cardinality values validated
|
||||||
|
- Inverse relation lookup
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `shared/sexp/types.py` — add `RelationDef`
|
||||||
|
- `shared/sexp/evaluator.py` — add `defrelation` special form
|
||||||
|
- `shared/sexp/relations.py` — NEW: registry + definitions
|
||||||
|
- `shared/sexp/tests/test_relations.py` — NEW: registry tests
|
||||||
|
- `shared/infrastructure/factory.py` — call `load_relation_registry()` at startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B: Schema Evolution
|
||||||
|
|
||||||
|
### B.1 — Add `relation_type` column
|
||||||
|
|
||||||
|
**File: `shared/models/container_relation.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
relation_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
New composite index: `(relation_type, parent_type, parent_id)` for filtered child queries.
|
||||||
|
|
||||||
|
### B.2 — Alembic migration
|
||||||
|
|
||||||
|
**File: `relations/alembic/versions/xxx_add_relation_type.py`**
|
||||||
|
|
||||||
|
1. Add nullable `relation_type` column
|
||||||
|
2. Add indexes
|
||||||
|
3. Backfill existing rows:
|
||||||
|
- `(page, *, market, *)` → `relation_type = "page->market"`
|
||||||
|
- `(page, *, calendar, *)` → `relation_type = "page->calendar"`
|
||||||
|
- `(page, *, menu_node, *)` → `relation_type = "page->menu_node"`
|
||||||
|
|
||||||
|
### B.3 — Add `get_parents()` query
|
||||||
|
|
||||||
|
**File: `shared/services/relationships.py`**
|
||||||
|
|
||||||
|
Inverse of `get_children()` — query by `(child_type, child_id)` with optional `relation_type` filter. Uses existing `ix_container_relations_child` index.
|
||||||
|
|
||||||
|
### B.4 — Update `attach_child()` / `detach_child()`
|
||||||
|
|
||||||
|
Add optional `relation_type` parameter. When provided:
|
||||||
|
- Stored in the relation row
|
||||||
|
- Included in emitted activity `object_data`
|
||||||
|
- Used for cardinality enforcement (Phase C)
|
||||||
|
|
||||||
|
Existing callers without `relation_type` continue to work (backward compatible).
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `shared/models/container_relation.py` — add column + index
|
||||||
|
- `shared/services/relationships.py` — add `relation_type` param, add `get_parents()`
|
||||||
|
- `relations/alembic/versions/` — NEW migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C: Generic Relate/Unrelate API
|
||||||
|
|
||||||
|
### C.1 — New action endpoints on relations service
|
||||||
|
|
||||||
|
**File: `relations/bp/actions/routes.py`**
|
||||||
|
|
||||||
|
**`relate`** — validates against registry, enforces cardinality, delegates to `attach_child()`:
|
||||||
|
```json
|
||||||
|
POST /internal/actions/relate
|
||||||
|
{
|
||||||
|
"relation_type": "page->market",
|
||||||
|
"from_id": 42,
|
||||||
|
"to_id": 7,
|
||||||
|
"label": "Farm Shop",
|
||||||
|
"metadata": {"slug": "farm-shop"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`unrelate`** — validates, delegates to `detach_child()`:
|
||||||
|
```json
|
||||||
|
POST /internal/actions/unrelate
|
||||||
|
{
|
||||||
|
"relation_type": "page->market",
|
||||||
|
"from_id": 42,
|
||||||
|
"to_id": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`can-relate`** — pre-flight check (cardinality, registry validation) without creating anything:
|
||||||
|
```json
|
||||||
|
POST /internal/actions/can-relate
|
||||||
|
{
|
||||||
|
"relation_type": "page->market",
|
||||||
|
"from_id": 42
|
||||||
|
}
|
||||||
|
→ {"allowed": true} or {"allowed": false, "reason": "one-to-one already exists"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Domain services call `can-relate` *before* creating the entity to avoid orphans on cardinality failure.
|
||||||
|
|
||||||
|
### C.2 — Typical domain flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Domain service receives "create market on page 42" request
|
||||||
|
2. call_action("relations", "can-relate", {relation_type: "page->market", from_id: 42})
|
||||||
|
3. If not allowed → return error to user (no entity created)
|
||||||
|
4. Create the entity locally (market service creates MarketPlace row)
|
||||||
|
5. call_action("relations", "relate", {relation_type: "page->market", from_id: 42, to_id: 7, ...})
|
||||||
|
```
|
||||||
|
|
||||||
|
### C.3 — Cardinality enforcement
|
||||||
|
|
||||||
|
In the `relate` and `can-relate` handlers:
|
||||||
|
|
||||||
|
- **one-to-one**: Check no active relation of this type exists for `from_id`. Reject if found.
|
||||||
|
- **one-to-many**: No limit (current behavior).
|
||||||
|
- **many-to-many**: No limit in either direction.
|
||||||
|
|
||||||
|
### C.4 — Enhanced activity emission
|
||||||
|
|
||||||
|
Activities now include `relation_type` in `object_data`:
|
||||||
|
```python
|
||||||
|
object_data={
|
||||||
|
"relation_type": "page->market",
|
||||||
|
"parent_type": "page",
|
||||||
|
"parent_id": 42,
|
||||||
|
"child_type": "market",
|
||||||
|
"child_id": 7,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C.5 — Existing endpoints stay as aliases
|
||||||
|
|
||||||
|
`attach-child` and `detach-child` remain during transition. They infer `relation_type` from `(parent_type, child_type)` when not provided.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `relations/bp/actions/routes.py` — add `relate`, `unrelate`, `can-relate`
|
||||||
|
- `shared/services/relationships.py` — cardinality check in `attach_child()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D: Navigation Auto-Generation
|
||||||
|
|
||||||
|
### D.1 — Generic container-nav fragment
|
||||||
|
|
||||||
|
**New handler: `relations/bp/fragments/routes.py`**
|
||||||
|
|
||||||
|
The relations service becomes a fragment provider. A single generic handler replaces per-service container-nav handlers in market and events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _container_nav_handler():
|
||||||
|
container_type = request.args["container_type"]
|
||||||
|
container_id = int(request.args["container_id"])
|
||||||
|
post_slug = request.args.get("post_slug", "")
|
||||||
|
|
||||||
|
nav_defs = [d for d in relations_from(container_type) if d.nav != "hidden"]
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for defn in nav_defs:
|
||||||
|
children = await get_children(
|
||||||
|
g.s, container_type, container_id,
|
||||||
|
child_type=defn.to_type,
|
||||||
|
relation_type=defn.name,
|
||||||
|
)
|
||||||
|
for child in children:
|
||||||
|
parts.append(render_sexp(
|
||||||
|
'(~relation-nav :href href :name name :icon icon :nav-class nc)',
|
||||||
|
href=_build_href(defn, child, post_slug, ctx),
|
||||||
|
name=child.label or "",
|
||||||
|
icon=defn.nav_icon or "",
|
||||||
|
nc=nav_class,
|
||||||
|
))
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### D.2 — Nav hints
|
||||||
|
|
||||||
|
| `nav` value | Behavior |
|
||||||
|
|-------------|----------|
|
||||||
|
| `submenu` | Clickable item in container-nav header/sidebar |
|
||||||
|
| `tab` | Tab in a tabbed interface |
|
||||||
|
| `badge` | Count badge on parent |
|
||||||
|
| `inline` | Inline within parent content |
|
||||||
|
| `hidden` | Not shown in navigation |
|
||||||
|
|
||||||
|
### D.3 — `rebuild_navigation()` becomes real
|
||||||
|
|
||||||
|
**File: `shared/services/navigation.py`**
|
||||||
|
|
||||||
|
For now: invalidate nav caches when relations change. The top-level MenuNode entries (depth=0, main nav bar) remain manually managed. Child navigation (markets, calendars under a page) is generated dynamically from the relation registry via the generic container-nav fragment.
|
||||||
|
|
||||||
|
Future: `rebuild_navigation()` could materialize the full nav tree from the relation graph for performance.
|
||||||
|
|
||||||
|
### D.4 — Href computation
|
||||||
|
|
||||||
|
The `ContainerRelation.label` column stores the display name. Add a `metadata` JSON column for additional denormalized data (slug, icon) needed to compute hrefs without fetching from the owner service:
|
||||||
|
|
||||||
|
```python
|
||||||
|
metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# e.g., {"slug": "farm-shop", "icon": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `relate` action populates this from the caller's payload.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `relations/bp/fragments/routes.py` — NEW: generic container-nav
|
||||||
|
- `shared/services/navigation.py` — cache invalidation
|
||||||
|
- `shared/models/container_relation.py` — add `metadata` JSON column
|
||||||
|
- `relations/alembic/versions/` — migration for metadata column
|
||||||
|
- Blog, market, events fragment routes — deprecate per-service container-nav
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E: S-Expression UI Components
|
||||||
|
|
||||||
|
### E.1 — `~relation-nav` component
|
||||||
|
|
||||||
|
**File: `shared/sexp/components.py`** — replaces `~market-link-nav`, `~calendar-link-nav`:
|
||||||
|
|
||||||
|
```scheme
|
||||||
|
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
|
||||||
|
(a :href href :class (or nav-class "...")
|
||||||
|
(when icon (i :class icon :aria-hidden "true"))
|
||||||
|
(div name)))
|
||||||
|
```
|
||||||
|
|
||||||
|
### E.2 — `~relation-attach` component
|
||||||
|
|
||||||
|
Generic "add child" button driven by registry:
|
||||||
|
|
||||||
|
```scheme
|
||||||
|
(defcomp ~relation-attach (&key create-url label icon)
|
||||||
|
(a :href create-url
|
||||||
|
:hx-get create-url
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-push-url "true"
|
||||||
|
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100"
|
||||||
|
(when icon (i :class icon))
|
||||||
|
(span (or label "Add"))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### E.3 — `~relation-detach` component
|
||||||
|
|
||||||
|
Confirm-and-remove button:
|
||||||
|
|
||||||
|
```scheme
|
||||||
|
(defcomp ~relation-detach (&key detach-url name)
|
||||||
|
(button :hx-delete detach-url
|
||||||
|
:hx-confirm (str "Remove " name "?")
|
||||||
|
:class "text-red-500 hover:text-red-700 text-sm"
|
||||||
|
(i :class "fa fa-times")))
|
||||||
|
```
|
||||||
|
|
||||||
|
### E.4 — Keep specializations where needed
|
||||||
|
|
||||||
|
`~calendar-entry-nav` has date display logic — keep it as a specialization. The generic handler can delegate to specific components based on `to_type` when richer rendering is needed.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `shared/sexp/components.py` — add `~relation-nav`, `~relation-attach`, `~relation-detach`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F: Migration of Existing Relations
|
||||||
|
|
||||||
|
### F.1 — Migration order
|
||||||
|
|
||||||
|
1. Add `relation_type` column + backfill (Phase B)
|
||||||
|
2. Deploy registry + new endpoints (Phases A, C)
|
||||||
|
3. Switch callers one-by-one to use `can-relate` + `relate`:
|
||||||
|
- `shared/services/market_impl.py` — market creation: check can-relate, create, relate
|
||||||
|
- `events/bp/calendars/services/calendars.py` — calendar creation
|
||||||
|
- `blog/bp/menu_items/services/menu_items.py` — menu node creation
|
||||||
|
4. Deploy generic container-nav fragment (Phase D)
|
||||||
|
5. Switch fragment consumers from per-service to generic
|
||||||
|
6. Deprecate per-service container-nav handlers
|
||||||
|
7. Deprecate `PageConfig.features` column
|
||||||
|
|
||||||
|
### F.2 — CalendarEntryPost migration (many-to-many)
|
||||||
|
|
||||||
|
The `calendar_entry_posts` junction table in db_events is a domain-specific many-to-many. Strategy:
|
||||||
|
|
||||||
|
- Add `defrelation :post->calendar_entry` with `:cardinality :many-to-many`
|
||||||
|
- Migrate existing rows to `ContainerRelation` with `relation_type="post->calendar_entry"`
|
||||||
|
- Update `shared/services/entry_associations.py` to use `relate`/`unrelate`
|
||||||
|
- Eventually drop `calendar_entry_posts` table
|
||||||
|
|
||||||
|
### F.3 — PageConfig simplification
|
||||||
|
|
||||||
|
- Remove `features` JSON column (after all feature-flag checks are replaced by registry lookups)
|
||||||
|
- Keep `PageConfig` for SumUp credentials only
|
||||||
|
- Consider renaming to `PaymentConfig`
|
||||||
|
|
||||||
|
### Files touched (incremental)
|
||||||
|
- `shared/services/market_impl.py` — use `relate`
|
||||||
|
- `events/bp/calendars/services/calendars.py` — use `relate`
|
||||||
|
- `blog/bp/menu_items/services/menu_items.py` — use `relate`
|
||||||
|
- `shared/services/entry_associations.py` — use `relate`/`unrelate`
|
||||||
|
- `blog/bp/post/services/markets.py` — simplify (no feature flag check)
|
||||||
|
- `market/bp/fragments/routes.py` — deprecate container-nav
|
||||||
|
- `events/bp/fragments/routes.py` — deprecate container-nav
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
- `shared/sexp/tests/test_relations.py` — registry parsing, lookup, cardinality rules
|
||||||
|
- `shared/sexp/tests/test_evaluator.py` — `defrelation` form evaluation
|
||||||
|
- `shared/sexp/tests/test_components.py` — `~relation-nav`, `~relation-attach` rendering
|
||||||
|
|
||||||
|
### Integration tests
|
||||||
|
- Relations service: `relate` with valid/invalid types, cardinality enforcement
|
||||||
|
- `can-relate` pre-flight: returns allowed/denied correctly
|
||||||
|
- `unrelate`: soft-deletes, emits Remove activity
|
||||||
|
- Generic container-nav fragment: returns correct HTML for various relation types
|
||||||
|
|
||||||
|
### Manual testing
|
||||||
|
- Browse site with Playwright after each phase
|
||||||
|
- Verify nav renders correctly
|
||||||
|
- Test create/delete flows for markets and calendars
|
||||||
|
- Check activity bus still fires on relation changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Order & Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase A (Registry) ──────┐
|
||||||
|
├──▶ Phase C (API) ──▶ Phase D (Nav) ──▶ Phase F (Migration)
|
||||||
|
Phase B (Schema) ───────┘ ▲
|
||||||
|
│
|
||||||
|
Phase E (UI Components)
|
||||||
|
```
|
||||||
|
|
||||||
|
A and B can run in parallel. C needs both. D and E need C. F is incremental, runs alongside D and E.
|
||||||
Reference in New Issue
Block a user