Document: docs/notes/anti-patterns.md

# Anti-Patterns from Frappe & Odoo

> **Purpose**: This document catalogs critical mistakes from Frappe and Odoo that Framework M **must not repeat**.
>
> **Structure**:
> - ✅ **Tackled** = Already designed into Framework M (kept for reference)
> - ⚠ **Remaining** = Still needs explicit implementation

---

# ✅ TACKLED ISSUES

> These issues have design decisions in place. Listed for reference only.

| # | Issue | Solution | Where |
|---|-------|----------|-------|
| 1 | `name` as Primary Key | `id` (UUID) as PK, `name` as unique index | Phase 01, 02 |
| 2 | Global State (`frappe.session.user`) | Explicit `user_context` parameter | Phase 02 |
| 3 | Metadata in Database | Code-first Pydantic models | Phase 01, 02 |
| 4 | Synchronous/Blocking | Async native (Litestar + SQLAlchemy Async) | Phase 01 |
| 5 | Monkey Patching | Entrypoint-based overrides | Phase 01 |
| 6 | No Dependency Injection | `dependency-injector` library | Phase 01 |
| 7 | Weak/No Type Hints | 100% type hints, `mypy --strict` | Phase 01 |
| 8 | Database-Specific Features | Portable SQLAlchemy types only | Phase 02 |
| 9 | Implicit Transactions | Explicit transaction context managers | Phase 02 |
| 10 | No Event Sourcing / Audit Trail | Every state change emits event | Phase 02, 04 |
| 11 | Hard-coded Hooks | Event bus with multiple subscribers | Phase 04 |
| 12 | Permission Checks in Business Logic | Repository-level enforcement | Phase 03 |
| 13 | Session-Only Auth | Stateless JWT/header-based auth | Phase 03 |
| 14 | eval()/exec() in User Input | JMESPath/SimpleEval only | Phase 10 |
| 15 | Hard Delete Only | Soft delete with `deleted_at` | Phase 02 |
| 16 | Opt-out API Exposure | Opt-in `api_resource = True` | Phase 03 |
| 17 | Timezone-Naive Datetimes | `DateTime(timezone=True)` in SchemaMapper | Phase 02 |
| 18 | N+1 Query Problem | `load_children_for_parents()` (select_in_loading) | Phase 02 |
| 19 | Unbounded Queries | `DEFAULT_LIMIT=20`, `MAX_LIMIT=1000` enforced | Phase 02 |
| 22 | Unrestricted File Uploads | `File` DocType with extension whitelist, MIME validation | Phase 06 |
| 25 | No Circuit Breaker (Email) | `EmailQueue` with async processing, not blocking | Phase 06 |
| 26 | No API Versioning | URL-based versioning `/api/v1/` prefix | Phase 03 |
| 29 | Health Check Endpoints Missing | `/studio/api/health` endpoint implemented | Phase 07 |
| 30 | Caching Layer Missing | `RedisCacheAdapter` + `GenericRepository` integration | Phase 02, 04 |
| 32 | Over-Customization Upgrade Trap | Override mechanism + event-based extension | Phase 08 |

---

# ⚠ REMAINING ISSUES TO TACKLE

> These issues need explicit implementation. Each includes problem, impact, and required solution.

## 20. Missing Rate Limiting

### ❌ The Problem (Frappe)
```python
# No built-in rate limiting
# Attackers brute-force login, spam API, exhaust resources
```

### 🔥 Why It's Bad
- **Brute Force Attacks**: Unlimited login attempts
- **Resource Exhaustion**: API spam causes denial of service
- **Cost Explosion**: Cloud bills skyrocket from abuse
- **Real-world**: Security advisories list this as vulnerability

### ✅ Required Solution
```python
# Multi-level rate limiting
@rate_limit(max_requests=5, window_seconds=60, key="ip")  # Login
async def login(credentials: LoginRequest) -> Token:
    ...

@rate_limit(max_requests=100, window_seconds=60, key="user_id")  # API
async def create_document(data: CreateRequest) -> Document:
    ...
```

**Phase**: 03/10 (Middleware)
**Priority**: HIGH

---

## 21. Secrets in Logs

### ❌ The Problem (Frappe)
```python
frappe.log(f"Login attempt: {username}, {password}")  # Oops!
```

### 🔥 Why It's Bad
- **Credential Leak**: Passwords visible in log files
- **Compliance Violation**: PII in logs breaks GDPR
- **Breach Amplification**: Attacker reads logs, gets more access

### ✅ Required Solution
```python
import structlog

REDACTED_FIELDS = {"password", "token", "secret", "api_key", "credit_card"}

def redact_processor(logger, method_name, event_dict):
    for key in list(event_dict.keys()):
        if key.lower() in REDACTED_FIELDS:
            event_dict[key] = "[REDACTED]"
    return event_dict

logger = structlog.wrap_logger(
    structlog.get_logger(),
    processors=[redact_processor, ...]
)
```

**Phase**: 10 (Logging)
**Priority**: HIGH

---

## 23. Missing Request ID Tracing

### ❌ The Problem (Frappe)
```python
# "Error in Job" - which request triggered it? Unknown.
# Can't correlate logs across services
```

### 🔥 Why It's Bad
- **Debugging Nightmare**: Can't trace request → error
- **Distributed Tracing**: Microservices can't correlate
- **Support Tickets**: User says "it failed" - can't find the log

### ✅ Required Solution
```python
class RequestIdMiddleware:
    async def __call__(self, request: Request, call_next):
        request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())

        # Add to logging context
        structlog.contextvars.bind_contextvars(request_id=request_id)

        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id

        return response

# Every log includes request_id automatically
logger.info("Processing invoice", invoice_id=inv.id)
# {"request_id": "abc-123", "message": "Processing invoice", ...}
```

**Phase**: 03 (Middleware)
**Priority**: MEDIUM

---

## 24. No Idempotency Keys for Mutations

### ❌ The Problem (Frappe)
```python
# Network timeout → client retries → two invoices created!
```

### 🔥 Why It's Bad
- **Duplicate Records**: Retries create multiple copies
- **Double Charges**: Payment processed twice
- **Data Corruption**: Inconsistent state

### ✅ Required Solution
```python
@post("/api/v1/{doctype}")
async def create_document(
    data: CreateRequest,
    idempotency_key: str = Header(alias="Idempotency-Key", default=None),
    cache: CacheProtocol = Depends(),
) -> Document:
    if idempotency_key:
        cached = await cache.get(f"idempotency:{idempotency_key}")
        if cached:
            return cached  # Return same response

    result = await repo.save(data)

    if idempotency_key:
        await cache.set(f"idempotency:{idempotency_key}", result, ttl=86400)

    return result
```

**Phase**: 03 (API Layer)
**Priority**: MEDIUM

---

## 27. Unsafe Deserialization

### ❌ The Problem (Frappe/Odoo)
```python
# Odoo uses pickle for sessions (RCE!)
import pickle
data = pickle.loads(user_controlled_bytes)  # Arbitrary code execution

# YAML !!python/object tag executes code
yaml.load(user_input, Loader=yaml.FullLoader)  # Unsafe!
```

### 🔥 Why It's Bad
- **Remote Code Execution**: Pickle can execute arbitrary Python
- **Complete Compromise**: Attacker owns the server
- **Real-world**: CVE-2022-XXXX (Odoo)

### ✅ Required Solution
```python
# ✅ Use JSON only for user data
import json
data = json.loads(user_input)  # Safe

# ✅ If YAML needed, use safe_load
import yaml
data = yaml.safe_load(user_input)  # No code execution

# ❌ NEVER in production code:
pickle.loads(...)
yaml.load(..., Loader=yaml.FullLoader)
```

**Phase**: ALL (Code Audit)
**Priority**: HIGH

---

## 28. Missing CSRF Protection

### ❌ The Problem (Frappe)
```python
# CSRF tokens inconsistently applied
# Attacker page can POST to /api/ on behalf of logged-in user
```

### 🔥 Why It's Bad
- **Account Takeover**: Attacker changes user's password
- **Data Theft**: Attacker exports user's data
- **Financial Fraud**: Attacker initiates transfers
- **Real-world**: Security advisory for Frappe

### ✅ Required Solution
```python
# 1. SameSite cookies (primary defense)
response.set_cookie(
    key="session",
    value=token,
    httponly=True,
    secure=True,
    samesite="lax",  # Or "strict"
)

# 2. Verify Origin header for mutations
async def csrf_check(request: Request):
    if request.method in ("POST", "PUT", "DELETE"):
        origin = request.headers.get("Origin")
        if origin and origin not in ALLOWED_ORIGINS:
            raise CSRFError("Origin not allowed")
```

**Phase**: 03 (Middleware)
**Priority**: HIGH

---

## 31. MariaDB-Specific Issues (From Production)

### ❌ The Problem (Frappe)
```python
# Frappe is tightly coupled to MariaDB
# Many production issues stem from MariaDB-specific problems:
# - InnoDB buffer pool misconfiguration
# - Slow queries due to missing indexes
# - Connection pool exhaustion
```

### 🔥 Why It's Bad
- **Real-world Incidents**: Frappe Cloud downtime traced to MariaDB bugs
- **Scaling Limit**: Vertical scaling only for single-node MariaDB
- **Recovery Time**: Database issues require manual intervention

### ✅ Required Solution
```python
# Framework M: Database agnostic + proper pool config
# 1. SQLAlchemy handles dialect differences
# 2. Proper connection pooling
engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=10,
    pool_timeout=30,
    pool_recycle=3600,
    pool_pre_ping=True,  # Health check connections
)

# 3. Read replicas for scaling
# Configured via Phase 10 multi-bind support
```

**Phase**: 02, 10 (Database)
**Priority**: MEDIUM

---

## 32. Post-Submission Editing (allow_on_submit)

### ❌ The Problem (Frappe)
```python
# Frappe's "Allow on Submit" feature
# Developers mark fields as editable even when the document is "locked"
```

### 🔥 Why It's Bad
- **Breaks Auditability**: The database row representing a "finalized" contractual or financial state changes over time without a formal trail.
- **Hidden Side Effects**: Editing seemingly harmless fields (like Transporter details) might trigger secondary workflows that were already processed.
- **State Machine Corruption**: Once an entity has entered a terminal or locked state, mutating it goes against event sourcing and immutable ledger principles.

### ✅ Required Solution
```python
# Framework M enforces absolute immutability on SUBMITTED documents.

# 1. Provide an Amend flow for corrections:
# doc.cancel() -> new_doc = doc.amend(changes) -> new_doc.submit()

# 2. Or, use a separate Volatile Sibling Entity for metadata:
class DeliveryDispatchInfo(DocType):
    delivery_note_id: str  # Links to the immutable parent
    vehicle_number: str    # This entity can be kept in DRAFT/edited freely
```

**Phase**: 02 (Core Models)
**Priority**: HIGH

---

# Summary: Remaining Work

| Priority | Issues |
|----------|--------|
| **HIGH** | ~~#18 N+1~~, ~~#19 Limits~~, #20 Rate Limit, #21 Log Redaction, ~~#22 File Uploads~~, #27 Deserialization, #28 CSRF |
| **MEDIUM** | #23 Request ID, #24 Idempotency, ~~#25 Circuit Breaker~~, ~~#26 API Versioning~~, ~~#29 Health Checks~~, ~~#30 Caching~~, #31 DB Config, ~~#32 Override Strategy~~ |

---

# Checklist for Every Feature

Before implementing any feature, verify:

- [ ] Datetimes are timezone-aware (UTC)?
- [ ] Queries are paginated with max limit?
- [ ] No N+1 queries (use eager loading)?
- [ ] Rate limiting applied (if user-facing)?
- [ ] Secrets redacted from logs?
- [ ] File uploads validated (extension + magic bytes)?
- [ ] Request ID propagated through logs?
- [ ] No pickle/unsafe YAML?
- [ ] CSRF protection on mutations?
- [ ] Circuit breaker on external calls?


Document: docs/notes/migration-from-frappe.md

# Migration Guide: Frappe → Framework M

A practical guide for Frappe developers transitioning to Framework M. This covers common patterns and their Framework M equivalents.

> **Tone**: Both frameworks are excellent. Frappe's simplicity enabled rapid development. Framework M builds on those foundations with explicit patterns for scale.

> [!IMPORTANT]
> **Documentation Status**: This guide shows planned patterns and API designs. Examples will be revisited in **Phase 09B** with real, working code including actual container initialization, DI wiring, and tested implementations.

---

## Quick Reference

| Frappe Pattern | Framework M Pattern |
|----------------|---------------------|
| `frappe.db.get_value()` | `repo.get()` via DI |
| `frappe.session.user` | `AuthContext` via DI |
| `doc.before_save()` | `Controller.before_save()` |
| `frappe.get_doc()` | `repo.get(session, id)` |
| Implicit transaction | Explicit `UnitOfWork` |
| Global state | Dependency Injection |

---

## 1. Database Access

### Frappe (Global State)

```python
# Anywhere in code
value = frappe.db.get_value("Item", item_code, "stock_qty")
frappe.db.set_value("Item", item_code, "stock_qty", new_qty)

# Get document
doc = frappe.get_doc("Item", item_code)
doc.stock_qty = 100
doc.save()
```

### Framework M (Explicit Session)

```python
# Repository injected, session explicit
class ItemService:
    def __init__(self, repo: GenericRepository[Item], uow_factory: UoWFactory):
        self.repo = repo
        self.uow_factory = uow_factory

    async def update_stock(self, item_code: str, new_qty: int):
        async with self.uow_factory() as uow:
            item = await self.repo.get_by_name(uow.session, item_code)
            item.stock_qty = new_qty
            await self.repo.save(uow.session, item)
            await uow.commit()
```

**Why?** Explicit session enables:
- Multiple independent transactions per request
- Easy testing (mock the session)
- Clear transaction boundaries

---

## 2. Getting Current User

### Frappe

```python
current_user = frappe.session.user
if frappe.session.user == "Administrator":
    # admin logic
```

### Framework M

```python
# Injected via Litestar DI
from litestar import Controller, get
from framework_m.core.interfaces import AuthContext

class ItemController(Controller):
    @get("/items")
    async def list_items(self, auth: AuthContext) -> list[Item]:
        # auth.user_id, auth.roles available
        if "Administrator" in auth.roles:
            # admin logic
        return await self.service.list_for_user(auth.user_id)
```

---

## 3. Document Lifecycle Hooks

### Frappe (on DocType)

```python
class Item(Document):
    def before_save(self):
        self.validate_qty()

    def after_insert(self):
        self.create_stock_entry()
```

### Framework M (on Controller)

```python
# doctype.py - schema only
class Item(BaseDocType):
    item_code: str
    stock_qty: int

# controller.py - business logic
class ItemController(BaseController[Item]):
    async def validate(self, context=None):
        if self.doc.stock_qty < 0:
            raise ValueError("Stock cannot be negative")

    async def after_insert(self, context=None):
        await self.create_stock_entry()
```

**Hook Order (Insert):**
```
validate → before_insert → before_save → [DB INSERT] → after_save → after_insert
```

---

## 4. Child Tables

### Frappe (Loop in Template)

```html
{% for item in doc.items %}
  <tr>
    <td>{{ item.item_code }}</td>
    <td>{{ item.qty }}</td>
  </tr>
{% endfor %}
```

```python
# Backend
for item in doc.items:
    item.amount = item.qty * item.rate
```

### Framework M + Refine.dev

```typescript
// Frontend - Ant Design Table
<Table
  dataSource={invoice.items}
  columns={[
    { title: "Item", dataIndex: "item_code" },
    { title: "Qty", dataIndex: "qty" },
    { title: "Amount", dataIndex: "amount" },
  ]}
/>
```

```python
# Backend - Pydantic child table
class InvoiceItem(BaseDocType):
    class Meta:
        is_child_table = True

    item_code: str
    qty: Decimal
    rate: Decimal
    amount: Decimal = Decimal("0")

class Invoice(BaseDocType):
    items: list[InvoiceItem] = Field(default_factory=list)

# Controller - calculate amounts
class InvoiceController(BaseController[Invoice]):
    async def before_save(self, context=None):
        for item in self.doc.items:
            item.amount = item.qty * item.rate
        self.doc.total = sum(i.amount for i in self.doc.items)
```

---

## 5. Avoiding N+1 Queries

### Frappe (Common N+1)

```python
# N+1 problem: 1 query for invoices + N queries for customers
invoices = frappe.get_all("Invoice", fields=["name", "customer"])
for inv in invoices:
    customer = frappe.get_doc("Customer", inv.customer)  # N queries!
    inv.customer_name = customer.customer_name
```

### Framework M (Eager Loading)

```python
# Single query with JOIN
async def list_invoices_with_customers(self, session: AsyncSession):
    stmt = (
        select(Invoice)
        .options(joinedload(Invoice.customer))  # Eager load
        .limit(100)
    )
    result = await session.execute(stmt)
    return result.scalars().all()
```

Or use repository with includes:

```python
# Repository pattern with includes
invoices = await repo.list(
    session,
    filters={"status": "Draft"},
    includes=["customer", "items"],  # Eager load relations
)
```

---

## 6. Transactions

### Frappe (Implicit)

```python
# Transaction per request, auto-commit on success
def create_order():
    order = frappe.get_doc({"doctype": "Order", ...})
    order.insert()  # Auto-commits at request end

    items = create_items(order.name)  # Same transaction
```

### Framework M (Explicit UoW)

```python
async def create_order(self, data: OrderCreate):
    async with self.uow_factory() as uow:
        # All operations in same transaction
        order = Order(**data.model_dump())
        await self.order_repo.save(uow.session, order)

        for item_data in data.items:
            item = OrderItem(order_id=order.id, **item_data)
            await self.item_repo.save(uow.session, item)

        await uow.commit()  # Explicit commit
    # Auto-rollback if exception before commit
```

---

## 7. Background Jobs

### Frappe

```python
frappe.enqueue(
    "myapp.tasks.send_email",
    queue="short",
    email=email,
    subject=subject,
)
```

### Framework M (JobQueueProtocol)

Framework M uses a `JobQueueProtocol` interface, allowing you to plug in any background job system (Celery, Arq, BullMQ). The default adapter uses **Taskiq**.

```python
from framework_m.core.interfaces import JobQueueProtocol

# Injected dependency
class EmailService:
    def __init__(self, queue: JobQueueProtocol):
        self.queue = queue

    async def send_welcome(self, email: str):
        # Enqueue task (implementation agnostic)
        await self.queue.enqueue(
            "send_email_task",
            email=email,
            subject="Welcome"
        )

# Defining the task (adapter specific - e.g., Taskiq)
from framework_m.adapters.jobs import task

@task
async def send_email_task(email: str, subject: str):
    await smtp.send(email, subject)
```

---

## 8. Events / Hooks

### Frappe (doc_events)

```python
# hooks.py
doc_events = {
    "Invoice": {
        "on_submit": "myapp.invoice.on_submit",
    }
}
```

### Framework M (Event Bus)

```python
from framework_m.core.interfaces import EventBusProtocol, Event

# Publish event
event = Event(type="invoice.submitted", data={"id": str(invoice.id)})
await event_bus.publish("invoice.submitted", event)

# Subscribe (in another service)
async def handle_invoice_submitted(event: Event) -> None:
    await update_ledger(event.data["id"])

# Register handler on startup
await event_bus.subscribe("invoice.submitted", handle_invoice_submitted)
```

---

## 9. Permissions

### Frappe

```python
if frappe.has_permission("Invoice", "write", doc.name):
    # allowed
```

### Framework M

```python
# Via PermissionProtocol (injected)
from framework_m.core.interfaces import PermissionProtocol, PolicyEvaluateRequest, PermissionAction

class InvoiceService:
    def __init__(self, permissions: PermissionProtocol):
        self.permissions = permissions

    async def update(self, auth: AuthContext, invoice_id: str, data: dict):
        result = await self.permissions.evaluate(PolicyEvaluateRequest(
            principal=auth.user_id,
            action=PermissionAction.WRITE,
            resource="Invoice",
            resource_id=invoice_id,
            principal_attributes={"roles": auth.roles},
        ))
        if not result.authorized:
            raise PermissionDenied(result.reason)
```

Or declarative in Meta:

```python
class Invoice(BaseDocType):
    class Meta:
        permissions = {
            "read": ["Employee", "Manager"],
            "write": ["Manager"],
            "submit": ["Manager"],
        }
```

---

## 10. API Endpoints

### Frappe (whitelisted)

```python
@frappe.whitelist()
def get_stock_balance(item_code):
    return frappe.db.get_value("Item", item_code, "stock_qty")
```

### Framework M (Litestar routes)

```python
from litestar import Controller, get

class StockController(Controller):
    path = "/api/stock"

    @get("/{item_code:str}/balance")
    async def get_balance(self, item_code: str, repo: ItemRepository) -> dict:
        item = await repo.get_by_code(item_code)
        return {"balance": item.stock_qty}
```

Or use RPC-style for Frappe compatibility:

```python
# In my_app/api.py
from framework_m.core.decorators import rpc

@rpc()  # Registers as "my_app.api.get_stock_balance"
async def get_stock_balance(item_code: str, repo: ItemRepository) -> dict:
    item = await repo.get_by_code(item_code)
    return {"balance": item.stock_qty}

# Exposed as: POST /api/v1/rpc/fn/my_app.api.get_stock_balance
# Body: {"item_code": "ITEM-001"}
```

Two RPC patterns available:
- **Controller methods**: `POST /api/v1/rpc/{DocType}/{method}` - uses `@whitelist` on controller
- **Standalone functions**: `POST /api/v1/rpc/fn/{dotted.path}` - uses `@rpc` decorator

---

## 11. Queries and Aggregations

### Frappe

```python
# Get all with filters
items = frappe.get_all("Item", filters={"item_group": "Electronics"}, fields=["name", "price"])

# Query Builder
from frappe.query_builder import DocType
Item = DocType("Item")
query = frappe.qb.from_(Item).select(Item.name, Item.price).where(Item.stock_qty > 0)

# Raw SQL
frappe.db.sql("SELECT name, price FROM tabItem WHERE stock_qty > %s", (0,), as_dict=True)
```

### Framework M

```python
from framework_m.core.interfaces import FilterSpec, FilterOperator

# Repository list with filters
items = await repo.list(
    session,
    filters=[FilterSpec(field="item_group", operator=FilterOperator.EQ, value="Electronics")],
    limit=100,
)

# SQLAlchemy Core (for complex queries)
from sqlalchemy import select, func
stmt = select(Item.name, Item.price).where(Item.stock_qty > 0)
result = await session.execute(stmt)

# Aggregations
stmt = select(func.sum(Item.stock_qty)).where(Item.item_group == "Electronics")
total = (await session.execute(stmt)).scalar()
```

---

## 12. Print Formats

### Frappe

```python
# Jinja template in DocType
frappe.get_print("Invoice", invoice_name, print_format="Standard")
```

### Framework M

```python
# PrintProtocol for document rendering
from framework_m.core.interfaces import PrintProtocol, PrintFormat

class InvoiceService:
    def __init__(self, printer: PrintProtocol, repo: RepositoryProtocol[Invoice]):
        self.printer = printer
        self.repo = repo

    async def generate_pdf(self, invoice_id: str) -> bytes:
        invoice = await self.repo.get(UUID(invoice_id))
        return await self.printer.render(
            doc=invoice,
            template="standard",
            format=PrintFormat.PDF,
        )

    async def list_templates(self) -> list[str]:
        return await self.printer.get_templates("Invoice")

# Adapters: JinjaPrintAdapter (HTML), GotenbergAdapter (PDF via Gotenberg)
```

---

## 13. Workflow & Document States

> [!NOTE]
> **Planned Feature (Phase 08)**: Framework M will provide a pluggable `WorkflowProtocol` with internal and Temporal adapters. Workflows are defined as DocTypes, not hardcoded in controllers.

### Frappe

```python
# Workflow defined in UI, transitions via API
frappe.get_doc("Invoice", name).set_workflow_action("Approve")
```

### Framework M (WorkflowProtocol)

```python
# Workflow defined as DocType
# Workflow: "InvoiceApproval"
#   - States: Draft, Pending Approval, Approved, Rejected
#   - Transitions: Submit (Draft → Pending), Approve (Pending → Approved), etc.
#   - allowed_roles per transition

# DocType references the workflow
class Invoice(BaseDocType):
    class Meta:
        workflow = "InvoiceApproval"

# Transitions via injected WorkflowProtocol
class InvoiceService:
    def __init__(self, workflow: WorkflowProtocol):
        self.workflow = workflow

    async def approve(self, invoice_id: str, auth: AuthContext):
        # Checks permissions, validates transition, updates state, emits event
        await self.workflow.transition(invoice_id, "Approve", auth)

# Available adapters:
# - InternalWorkflowAdapter: Simple state machine, stores state in DB
# - TemporalWorkflowAdapter: Distributed, long-running workflows via Temporal.io
```

**Why Protocol-based?**
- Swap implementations without changing business logic
- Internal adapter for simple use cases
- Temporal for complex, distributed workflows
- Workflow definitions are data, not code

---

## 14. Naming Series

> [!NOTE]
> **Planned Feature (Phase 08)**: Declarative naming patterns via `Meta.name_pattern`. Uses optimistic generation with retry on conflict—no row-level locking.

### Frappe

```python
# In DocType, autoname = "naming_series:"
doc.naming_series = "INV-.YYYY.-"
```

### Framework M (Declarative Naming)

```python
class Invoice(BaseDocType):
    class Meta:
        # Declarative pattern - framework handles generation
        name_pattern = "INV-.YYYY.-.####"  # → INV-2026-0001

# Pattern tokens:
#   .YYYY  → Year (2026)
#   .MM    → Month (01-12)
#   .DD    → Day (01-31)
#   .####  → Sequential number (optimistic, retry on conflict)
#   {field} → Field value (e.g., {customer_code})

# For high-volume DocTypes, use database-native sequences:
class HighVolumeInvoice(BaseDocType):
    class Meta:
        name_pattern = "sequence:invoice_seq"  # Uses DB sequence

# Framework detects database and uses appropriate mechanism:
#   PostgreSQL: CREATE SEQUENCE + nextval()
#   MySQL/MariaDB: AUTO_INCREMENT helper table
#   SQLite: sqlite_sequence table
#   Oracle: CREATE SEQUENCE
#   MSSQL: CREATE SEQUENCE (2012+) or IDENTITY
```

**Why this approach?**
- `id` (UUID) is the primary key—zero contention on insert
- `name` is optional human-readable identifier
- No `SELECT ... FOR UPDATE` lock on counters (Frappe anti-pattern)
- Optimistic generation: insert → generate name → retry if conflict

---

## 15. Fixtures & Data Import

### Frappe

```json
// fixtures in hooks.py
fixtures = ["Custom Field", {"dt": "Role", "filters": [["name", "in", ["Manager"]]]}]
```

### Framework M

```python
# CLI command for fixtures
@app.command
async def load_fixtures(path: str = "fixtures/"):
    """Load JSON/YAML fixtures into database."""
    async with uow_factory() as uow:
        for file in Path(path).glob("*.json"):
            data = json.loads(file.read_text())
            for record in data:
                await repo.save(uow.session, Model(**record))
        await uow.commit()
```

---

## 16. Scheduled Jobs

### Frappe

```python
# hooks.py
scheduler_events = {
    "daily": ["myapp.tasks.daily_cleanup"],
    "cron": {"0 9 * * *": ["myapp.tasks.morning_report"]},
}
```

### Framework M (JobQueueProtocol Scheduling)

Schedules are managed via the `JobQueueProtocol` or adapter-specific decorators.

```python
# Programmatic scheduling via Protocol
await queue.schedule("morning_report", cron="0 9 * * *")

# Or Decorator-based (Taskiq Adapter)
from taskiq import cron

@broker.task
@cron("0 9 * * *")
async def morning_report():
    await report_service.generate_daily()

@broker.task
@cron("0 0 * * *")
async def daily_cleanup():
    await cleanup_service.run()
```

---

## 17. Cache

### Frappe

```python
# Get/set cache with TTL
frappe.cache().set_value("key", value, expires_in_sec=3600)
value = frappe.cache().get_value("key")

# Delete pattern
frappe.cache().delete_keys("user:*")
```

### Framework M

```python
from framework_m.core.interfaces import CacheProtocol

class ItemService:
    def __init__(self, cache: CacheProtocol, repo: RepositoryProtocol[Item]):
        self.cache = cache
        self.repo = repo

    async def get_item(self, item_id: str) -> Item:
        # Try cache first
        cached = await self.cache.get(f"item:{item_id}")
        if cached:
            return Item.model_validate(cached)

        # Fetch from DB and cache
        item = await self.repo.get(UUID(item_id))
        await self.cache.set(f"item:{item_id}", item.model_dump(), ttl=3600)
        return item

    async def invalidate_item(self, item_id: str):
        await self.cache.delete(f"item:{item_id}")

    async def invalidate_all_items(self):
        deleted = await self.cache.delete_pattern("item:*")
        return deleted

# Adapters: RedisCacheAdapter, MemoryCacheAdapter (dev)
```

---

## 18. Audit Trail / Version History

### Frappe

```python
# Auto-tracks changes in Version doctype
doc.get_doc_before_save()  # Compare changes

# Add comment
doc.add_comment("Updated stock levels")

# frappe.log_error for exceptions
frappe.log_error("Something went wrong")
```

### Framework M

```python
from framework_m.core.interfaces import AuditLogProtocol, AuditEntry

class InvoiceService:
    def __init__(self, audit: AuditLogProtocol, repo: RepositoryProtocol[Invoice]):
        self.audit = audit
        self.repo = repo

    async def update_invoice(self, invoice_id: str, data: dict, auth: AuthContext):
        old = await self.repo.get(UUID(invoice_id))
        # ... update logic ...
        new = await self.repo.save(updated_invoice)

        # Log the change with diff
        await self.audit.log(
            user_id=auth.user_id,
            action="update",
            doctype="Invoice",
            document_id=invoice_id,
            changes={"status": {"old": old.status, "new": new.status}},
            metadata={"ip": auth.ip_address},
        )

    async def get_history(self, invoice_id: str) -> list[AuditEntry]:
        return await self.audit.query(
            filters={"doctype": "Invoice", "document_id": invoice_id},
            limit=50,
        )

# Adapters:
# - DatabaseAuditAdapter: Writes to ActivityLog table (Indie)
# - FileAuditAdapter: JSONL file for Splunk/Filebeat
# - ElasticAuditAdapter: Elasticsearch (Enterprise)
```

---

## 19. Search

> [!NOTE]
> **Planned Feature (Phase 08)**: The `SearchProtocol` interface is defined, but the Meilisearch/Elasticsearch adapters are planned for Phase 08.

### Frappe

```python
# Link field search
frappe.get_list("Customer", filters={"name": ["like", "%search%"]})

# Global search
frappe.search_link("Customer", "john")
```

### Framework M

```python
from framework_m.core.interfaces import SearchProtocol, SearchResult

class CustomerService:
    def __init__(self, search: SearchProtocol, repo: RepositoryProtocol[Customer]):
        self.search = search
        self.repo = repo

    async def search_customers(self, query: str) -> SearchResult:
        return await self.search.search(
            doctype="Customer",
            query=query,
            limit=20,
        )

    async def index_customer(self, customer: Customer):
        # Called after save to keep search index updated
        await self.search.index(
            doctype="Customer",
            doc_id=str(customer.id),
            doc=customer.model_dump(),
        )

# Adapters: MeilisearchAdapter, ElasticsearchAdapter, WhooshAdapter (dev)
```

---

## 20. Real-time Notifications

> [!NOTE]
> **Planned Feature (Phase 08)**: The `NotificationProtocol` interface is defined, but WebSocket and Email adapters are planned for Phase 08.

### Frappe

```python
# Push to browser
frappe.publish_realtime("invoice_updated", {"id": doc.name}, user=user)

# Create notification
frappe.get_doc({
    "doctype": "Notification Log",
    "for_user": user,
    "subject": "Invoice approved",
}).insert()
```

### Framework M

```python
from framework_m.core.interfaces import NotificationProtocol

class InvoiceService:
    def __init__(self, notifications: NotificationProtocol):
        self.notifications = notifications

    async def approve_invoice(self, invoice_id: str, auth: AuthContext):
        # ... approval logic ...

        # In-app notification (stored in DB, shown in UI)
        await self.notifications.send_in_app(
            user_id=invoice.owner,
            title="Invoice Approved",
            body=f"Invoice {invoice.name} has been approved",
            link=f"/app/Invoice/{invoice_id}",
        )

        # Or email notification
        await self.notifications.send_email(
            to=[invoice.owner_email],
            subject="Invoice Approved",
            body=f"Your invoice {invoice.name} has been approved.",
            template="invoice_approved",
            context={"invoice": invoice.model_dump()},
        )

# WebSocket endpoint: WS /api/v1/notifications/stream
# Real-time push to connected clients via SocketProtocol
```

---

## 21. Virtual DocTypes (External Data Sources)

### Frappe

```python
# Virtual DocType - data from external API, not database
class ExternalProduct(Document):
    def db_insert(self):
        # Custom insert logic
        api.create_product(self.as_dict())

    def load_from_db(self):
        data = api.get_product(self.name)
        self.update(data)
```

### Framework M

```python
# Implement RepositoryProtocol for any data source
class ExternalProductRepository(RepositoryProtocol[Product]):
    def __init__(self, api_client: APIClient):
        self.api = api_client

    async def get(self, session, id: UUID) -> Product | None:
        data = await self.api.get_product(str(id))
        return Product(**data) if data else None

    async def save(self, session, entity: Product) -> Product:
        await self.api.upsert_product(entity.model_dump())
        return entity

# Register override in container
container.repository_factory.register_override("Product", ExternalProductRepository)
```

---

## 22. Custom Fields & Dynamic Meta

### Frappe

```python
# Get doctype meta with custom fields
meta = frappe.get_meta("Invoice")
for field in meta.fields:
    print(field.fieldname, field.fieldtype)

# Check if field exists
if meta.has_field("custom_discount"):
    pass
```

### Framework M

```python
# Meta endpoint returns full schema including custom fields
# GET /api/v1/meta/Invoice

# In code - MetaRegistry
from framework_m.core.registry import MetaRegistry

registry = MetaRegistry.get_instance()
meta = registry.get_doctype("Invoice")

for field in meta.fields:
    print(field.name, field.type)

# Custom fields via DocType overrides (Phase 08)
class ExtendedInvoice(Invoice):
    custom_discount: Decimal = Field(default=Decimal("0"))

registry.register_override("Invoice", ExtendedInvoice)
```

---

## 23. Currency & Number Formatting

### Frappe

```python
frappe.format_value(1234567.89, {"fieldtype": "Currency"})  # ₹12,34,567.89
frappe.format_value(0.15, {"fieldtype": "Percent"})  # 15%
```

### Framework M

```python
# Formatting via Python's babel library (locale-aware)
from babel.numbers import format_currency, format_percent

class InvoiceService:
    def format_total(self, amount: Decimal, locale: str = "en_IN") -> str:
        return format_currency(amount, "INR", locale=locale)
        # → ₹12,34,567.89

    def format_rate(self, rate: Decimal, locale: str = "en_IN") -> str:
        return format_percent(rate, locale=locale)
        # → 15%

# Frontend: use Intl API
# new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(1234567.89)
```

> [!NOTE]
> **I18nProtocol** covers text translation only. Number/currency formatting uses Python's `babel` library directly or a separate `FormatterProtocol` (planned).

---

## 24. File Attachments

### Frappe

```python
# Attach file to document
file_doc = frappe.get_doc({
    "doctype": "File",
    "file_url": "/files/invoice.pdf",
    "attached_to_doctype": "Invoice",
    "attached_to_name": "INV-001",
})
file_doc.insert()

# Get attachments
files = frappe.get_all("File", filters={"attached_to_name": "INV-001"})
```

### Framework M

```python
# StorageProtocol for file operations
from framework_m.core.interfaces import StorageProtocol

class InvoiceService:
    def __init__(self, storage: StorageProtocol, repo: RepositoryProtocol[Attachment]):
        self.storage = storage
        self.repo = repo

    async def attach_file(self, invoice_id: str, file: UploadFile) -> str:
        # Upload to S3/local/etc.
        content = await file.read()
        path = await self.storage.save_file(
            path=f"invoices/{invoice_id}/{file.filename}",
            content=content,
            content_type=file.content_type,
        )

        # Store attachment reference
        attachment = Attachment(
            doctype="Invoice",
            doc_id=invoice_id,
            file_url=path,
            filename=file.filename,
        )
        await self.repo.save(attachment)
        return path
```

---

## 25. Email

### Frappe

```python
frappe.sendmail(
    recipients=["user@example.com"],
    subject="Invoice Ready",
    template="invoice_notification",
    args={"invoice": invoice.as_dict()},
)
```

### Framework M

```python
# EmailSenderProtocol for sending emails
from framework_m.core.interfaces import EmailSenderProtocol, EmailMessage
from jinja2 import Environment

class InvoiceService:
    def __init__(self, email_sender: EmailSenderProtocol, jinja: Environment):
        self.email_sender = email_sender
        self.jinja = jinja

    async def send_notification(self, invoice: Invoice):
        template = self.jinja.get_template("invoice_notification.html")
        html = template.render(invoice=invoice.model_dump())

        message = EmailMessage(
            to=["user@example.com"],
            subject="Invoice Ready",
            html_body=html,
        )
        result = await self.email_sender.send(message)
        return result.success

# Adapters: SMTPEmailSender, LogEmailSender (dev), etc.
```

---

## 26. Linked Documents & Dynamic Links

### Frappe

```python
# Get all linked documents
linked = frappe.get_all("Dynamic Link", filters={
    "link_doctype": "Customer",
    "link_name": "CUST-001",
})

# Dynamic Link field
class Contact(Document):
    links = []  # Child table with link_doctype, link_name
```

### Framework M

```python
# Link fields with eager loading
class Contact(BaseDocType):
    # Explicit links
    customer: Link["Customer"] | None = None
    supplier: Link["Supplier"] | None = None

    # Or polymorphic links via child table
    links: list[DynamicLink] = Field(default_factory=list)

class DynamicLink(BaseDocType):
    class Meta:
        is_child_table = True

    link_doctype: str
    link_id: UUID

# Query linked documents
async def get_customer_contacts(self, customer_id: str):
    return await self.repo.list(
        session,
        filters=[FilterSpec("customer", FilterOperator.EQ, customer_id)],
        includes=["customer"],  # Eager load
    )
```

---

## 27. Reports (Query Reports / Script Reports)

### Frappe

```python
# Script Report
def execute(filters):
    columns = [{"fieldname": "name", "label": "Name", "fieldtype": "Data"}]
    data = frappe.db.sql("""SELECT name FROM tabItem WHERE ...""", as_dict=True)
    return columns, data
```

### Framework M

```python
# Report as a Service with typed response
class ItemReportService:
    def __init__(self, repo: GenericRepository[Item]):
        self.repo = repo

    async def execute(self, filters: ItemReportFilters) -> ReportResult:
        items = await self.repo.list(
            session,
            filters=filters.to_filter_specs(),
        )
        return ReportResult(
            columns=[
                Column(name="name", label="Name", type="string"),
                Column(name="stock_qty", label="Stock", type="number"),
            ],
            data=[item.model_dump() for item in items],
        )

# Endpoint
@get("/api/reports/item-stock")
async def item_stock_report(
    filters: ItemReportFilters,
    report_service: ItemReportService,
) -> ReportResult:
    return await report_service.execute(filters)
```

---

## 28. Single DocTypes (Settings)

### Frappe

```python
# Single DocType - only one record exists
settings = frappe.get_single("System Settings")
settings.enable_feature = True
settings.save()
```

### Framework M

```python
# Single DocType via Meta flag
class SystemSettings(BaseDocType):
    class Meta:
        is_single = True  # Only one record

    enable_feature: bool = False
    default_currency: str = "INR"

# Service for settings
class SettingsService:
    def __init__(self, repo: GenericRepository[SystemSettings]):
        self.repo = repo
        self._cache: SystemSettings | None = None

    async def get(self, session) -> SystemSettings:
        if self._cache is None:
            self._cache = await self.repo.get_single(session)
        return self._cache

    async def update(self, session, data: dict) -> SystemSettings:
        settings = await self.get(session)
        for key, value in data.items():
            setattr(settings, key, value)
        await self.repo.save(session, settings)
        self._cache = None  # Invalidate cache
        return settings
```

---

## 29. Multi-tenancy

### Frappe

```python
# Multi-site via bench
frappe.local.site  # Current site
# Each site = separate database

# Multi-company within site
filters = {"company": frappe.defaults.get_user_default("Company")}
```

### Framework M

```python
# TenantContext injected via middleware
class ItemService:
    async def list_items(self, tenant: TenantContext, session):
        # Automatic tenant filtering via RLS or explicit filter
        return await self.repo.list(
            session,
            filters=[FilterSpec("tenant_id", FilterOperator.EQ, tenant.id)],
        )

# Tenancy modes (configured via adapter):
# - ImplicitTenantAdapter: Single tenant (indie deployments)
# - HeaderTenantAdapter: Multi-tenant via X-Tenant-ID header
# - DatabasePerTenantAdapter: Separate DB per tenant

# Frontend: tenant from JWT or API response
# Backend: automatic RLS filtering when enabled
```

See Phase 06 checklist for TenantProtocol details.

## Part 2: Frontend Migration (Frappe UI → Refine)

### Quick Reference

| Frappe UI | Refine + React |
|-----------|----------------|
| `frappe.ui.form.Form` | `useForm()` + RJSF |
| `frappe.Listview` | `useTable()` + Table component |
| `frappe.call()` | `useCustom()` or `fetch()` |
| `cur_frm` | React state / props |
| `frappe.route_options` | URL params / React Router |
| `frappe.msgprint()` | Toast / notification component |
| `frappe.confirm()` | Modal / dialog component |
| `frappe.ui.Dialog` | `useModal()` |

---

## 30. Forms

### Frappe

```javascript
frappe.ui.form.on("Invoice", {
    customer: function(frm) {
        // Called when customer field changes
        frappe.call({
            method: "myapp.api.get_customer_address",
            args: { customer: frm.doc.customer },
            callback: (r) => frm.set_value("address", r.message)
        });
    },
    refresh: function(frm) {
        frm.add_custom_button("Send Email", () => { ... });
    }
});
```

### Refine + RJSF

```tsx
import { useForm } from "@refinedev/react-hook-form";
import Form from "@rjsf/core";

export const InvoiceEdit = () => {
    const { refineCore: { formLoading, onFinish }, watch, setValue } = useForm();
    const customer = watch("customer");

    // React to field changes
    useEffect(() => {
        if (customer) {
            fetch(`/api/customer/${customer}/address`)
                .then(r => r.json())
                .then(data => setValue("address", data.address));
        }
    }, [customer]);

    return (
        <Form
            schema={schema}  // From GET /api/meta/Invoice
            formData={formData}
            onSubmit={onFinish}
        />
    );
};
```

---

## 31. List Views

### Frappe

```javascript
frappe.listview_settings["Invoice"] = {
    add_fields: ["status", "grand_total"],
    get_indicator: (doc) => [doc.status, doc.status === "Paid" ? "green" : "orange"],
    onload: (listview) => {
        listview.page.add_action_item("Bulk Approve", () => { ... });
    }
};
```

### Refine + TanStack Table

```tsx
import { useTable } from "@refinedev/react-table";
import { Table, Tag, Button } from "antd";  // or shadcn

export const InvoiceList = () => {
    const { tableProps } = useTable({
        resource: "Invoice",
        columns: [
            { header: "Name", accessorKey: "name" },
            { header: "Status", accessorKey: "status",
              cell: ({ value }) => (
                <Tag color={value === "Paid" ? "green" : "orange"}>{value}</Tag>
              )
            },
            { header: "Total", accessorKey: "grand_total" },
        ],
    });

    return (
        <>
            <Button onClick={handleBulkApprove}>Bulk Approve</Button>
            <Table {...tableProps} />
        </>
    );
};
```

---

## 32. RPC Calls

### Frappe

```javascript
frappe.call({
    method: "myapp.api.get_stock_balance",
    args: { item_code: "ITEM-001" },
    callback: (r) => console.log(r.message)
});
```

### Refine

```tsx
import { useCustom } from "@refinedev/core";

const { data, isLoading } = useCustom({
    url: "/api/method/get_stock_balance",
    method: "post",
    config: { payload: { item_code: "ITEM-001" } },
});

// Or with plain fetch
const response = await fetch("/api/method/get_stock_balance", {
    method: "POST",
    body: JSON.stringify({ item_code: "ITEM-001" }),
});
```

---

## 33. Dialogs and Modals

### Frappe

```javascript
frappe.prompt([
    { fieldname: "reason", label: "Reason", fieldtype: "Small Text" }
], (values) => {
    frappe.call({ method: "cancel_invoice", args: { ...values } });
}, "Cancel Invoice");
```

### Refine + shadcn

```tsx
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { useForm } from "react-hook-form";

export const CancelDialog = ({ invoiceId }) => {
    const { register, handleSubmit } = useForm();

    const onSubmit = async (data) => {
        await fetch(`/api/invoice/${invoiceId}/cancel`, {
            method: "POST",
            body: JSON.stringify(data),
        });
    };

    return (
        <Dialog>
            <DialogTrigger>Cancel Invoice</DialogTrigger>
            <DialogContent>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <label>Reason</label>
                    <textarea {...register("reason")} />
                    <button type="submit">Confirm</button>
                </form>
            </DialogContent>
        </Dialog>
    );
};
```

---

## 34. Real-time Updates

### Frappe

```javascript
frappe.realtime.on("invoice_updated", (data) => {
    cur_frm.reload_doc();
});
```

### Refine (LiveProvider)

```tsx
import { useSubscription } from "@refinedev/core";

// Subscribe to resource updates
useSubscription({
    channel: "Invoice",
    onLiveEvent: (event) => {
        if (event.type === "updated") {
            queryClient.invalidateQueries(["Invoice", event.payload.id]);
        }
    },
});
```

---

## Minimal Patterns: Rapid Development in M

> [!WARNING]
> **Use Reasonably**: These patterns bypass some standard architectural layers (Services, structured UoW) for speed.
> Ideal for prototypes, scripts, and simple CRUD. For complex business logic, prefer the full Service/Controller/Repository layers.

> **Philosophy**: M's DI system works with function parameters, not just classes.
> You don't need service classes for simple cases, use DI directly.

### The Spectrum of Complexity

```
Frappe-Like (Quick)                              Enterprise (Structured)
│                                                           │
▼                                                           ▼
Minimal DocType ─► RPC Function ─► Simple Service ─► Full Service Layer
  (data only)       (with DI)        (single class)   (repos, UoW, events)
```

**Start minimal. Add structure when complexity demands it.**

---

### Pattern 1: Minimal DocType (No Controller)

Controllers are **optional**. For simple data storage, this is all you need:

```python
# feedback.py - entire file
from framework_m import DocType, Field

class Feedback(DocType):
    """User feedback - just storage, no business logic."""
    subject: str
    message: str
    rating: int = Field(ge=1, le=5, description="1-5 stars")
    email: str | None = None
```

**That's it.** You get:
- ✅ Database table created automatically
- ✅ REST API: `POST /api/v1/Feedback`, `GET /api/v1/Feedback`, etc.
- ✅ Validation (Pydantic enforces `rating` is 1-5)
- ✅ JSON Schema for frontend forms

**Frappe equivalent lines**: ~15 (with naming, mandatory setup)
**M lines**: 8

---

### Pattern 2: RPC Function with DI (Frappe-style APIs)

For custom endpoints, use `@rpc` with injected dependencies directly in function params:

```python
# stock_api.py
from decimal import Decimal
from dependency_injector.wiring import inject, Provide
from framework_m import rpc
from framework_m.core.container import Container
from framework_m.adapters.db import GenericRepository

@rpc("stock.get_balance")
@inject
async def get_stock_balance(
    item_code: str,
    warehouse: str,
    repo: GenericRepository = Provide[Container.stock_ledger_repo],
) -> dict:
    """Get stock balance for item at warehouse."""
    entries = await repo.list(
        filters=[
            ("item_code", "=", item_code),
            ("warehouse", "=", warehouse),
        ]
    )
    total = sum(e.qty for e in entries.items)
    return {"item_code": item_code, "warehouse": warehouse, "qty": total}
```

**Called as**: `POST /api/v1/rpc/fn/stock.get_balance`
```json
{"item_code": "ITEM-001", "warehouse": "Main"}
```

**No class needed.** The `@inject` decorator handles DI in the function.

---

### Pattern 3: Quick Validation (Controller Light)

Need just validation? Add a minimal controller:

```python
# feedback.py
from framework_m import DocType, Field, BaseController

class Feedback(DocType):
    subject: str
    message: str
    rating: int = Field(ge=1, le=5)
    email: str | None = None

class FeedbackController(BaseController[Feedback]):
    """Only validate - no other hooks needed."""

    async def validate(self, context=None) -> None:
        if len(self.doc.message) < 10:
            raise ValueError("Message must be at least 10 characters")
```

**Only implement what you need.** Empty hooks are inherited from `BaseController`.

---

### Pattern 4: Direct Database Access (The "Hack")

For quick scripts or migrations, you can use the session directly:

```python
from sqlalchemy import text
from framework_m import rpc
from dependency_injector.wiring import inject, Provide
from framework_m.core.container import Container
from framework_m.core.unit_of_work import UnitOfWork

@rpc("reports.sales_summary")
@inject
async def sales_summary(
    year: int,
    uow: UnitOfWork = Provide[Container.unit_of_work],
) -> list[dict]:
    """Raw SQL for complex reporting - this is the 'hack' path."""
    async with uow:
        result = await uow.session.execute(text("""
            SELECT
                customer,
                SUM(grand_total) as total,
                COUNT(*) as count
            FROM invoice
            WHERE EXTRACT(YEAR FROM posting_date) = :year
            AND docstatus = 1
            GROUP BY customer
            ORDER BY total DESC
            LIMIT 10
        """), {"year": year})
        return [dict(row._mapping) for row in result]
```

> [!WARNING]
> Raw SQL bypasses permissions, validation, and audit. Use for read-only reports or one-off scripts.
> For production business logic, use Repositories.

---

### Pattern 5: Multiple RPCs, One File

Group related functions without classes:

```python
# inventory_api.py
from framework_m import rpc
from dependency_injector.wiring import inject, Provide

@rpc("inventory.transfer")
@inject
async def transfer_stock(
    item: str,
    from_wh: str,
    to_wh: str,
    qty: float,
    stock_svc = Provide[Container.stock_service],
) -> dict:
    result = await stock_svc.transfer(item, from_wh, to_wh, qty)
    return {"success": True, "entry_id": str(result.id)}

@rpc("inventory.adjust")
@inject
async def adjust_stock(
    item: str,
    warehouse: str,
    qty: float,
    reason: str,
    stock_svc = Provide[Container.stock_service],
) -> dict:
    result = await stock_svc.adjust(item, warehouse, qty, reason)
    return {"success": True, "entry_id": str(result.id)}

@rpc("inventory.get_levels")
@inject
async def get_stock_levels(
    warehouse: str | None = None,
    repo = Provide[Container.item_repo],
) -> list[dict]:
    filters = [("warehouse", "=", warehouse)] if warehouse else []
    items = await repo.list(filters=filters, limit=1000)
    return [{"item": i.item_code, "qty": i.qty} for i in items.items]
```

**Three endpoints, no classes, full DI support.**

---

### Pattern 6: Background Job (Minimal)

```python
# jobs/cleanup.py
from framework_m.adapters.jobs import task
from dependency_injector.wiring import inject, Provide

@task
@inject
async def cleanup_old_logs(
    days: int = 30,
    repo = Provide[Container.log_repo],
    uow = Provide[Container.unit_of_work],
):
    """Delete logs older than N days."""
    async with uow:
        deleted = await repo.delete_where(
            filters=[("created", "<", f"now() - interval '{days} days'")]
        )
        await uow.commit()
    return {"deleted": deleted}

# Enqueue from anywhere:
# await cleanup_old_logs.kiq(days=7)
```

---

### When to Add Structure

| Situation | Pattern |
|-----------|---------|
| Simple data storage | Minimal DocType (no controller) |
| Custom API endpoint | `@rpc` function with DI |
| Simple validation | Controller with only `validate()` |
| Complex business logic | Service class |
| Multi-step transactions | Service + UnitOfWork |
| External integrations | Full Protocol/Adapter |

---

### Comparison: Lines of Code

| Task | Frappe | M (Minimal) | M (Full) |
|------|--------|-------------|----------|
| Simple DocType | 15 | 8 | 8 |
| DocType + validation | 20 | 15 | 15 |
| Custom API endpoint | 8 | 12 | 25 |
| Stock balance query | 5 | 10 | 20 |
| Background job | 10 | 10 | 15 |

**M is 30-50% more verbose for minimal cases, but the gap shrinks as complexity grows.**

---

## Summary: Key Mindset Shifts

| Frappe | Framework M | Why |
|--------|-------------|-----|
| Global `frappe.db` | Injected `repo` | Testability, no hidden state |
| Implicit transactions | Explicit `UoW` | Clear boundaries, multiple txns |
| Hooks on DocType | Hooks on Controller | Separation of data and logic |
| Request = transaction | You decide | Flexibility for complex flows |
| `frappe.call()` | Typed endpoints | IDE support, validation |
| `cur_frm` (global) | React state/props | Component isolation |
| Jinja templates | React components | Reusability, types |
| Server-rendered forms | Client-side RJSF | Better UX, offline support |

---

## Next Steps

1. Start with a simple DocType using `m new:doctype`
2. Implement a Controller with hooks
3. Use the Repository pattern for data access
4. Add Refine.dev frontend for UI
5. Connect forms via RJSF + metadata endpoint

See also:
- [Architecture Guide](../developer/architecture.md) — Full architecture details
- [ADR-0005](../adr/0005-refine-for-frontend.md) — Frontend stack decision
- [Protocol Reference](../developer/generated/index.md) — Hook implementations


Document: docs/notes/fundamentals.md

# Framework M Fundamentals

Core concepts for understanding and extending Framework M.

> [!IMPORTANT]
> **Documentation Status**: This guide shows planned patterns and API designs. Examples will be revisited in **Phase 09B** with real, working code including actual container initialization, DI wiring, and tested implementations.

---

## 0. Philosophy: Minimal Core, Maximum Flexibility

> **Only async Python and DI wiring are irreplaceable.**
> Everything else can be replaced, extended, or composed.

### The Irreplaceable Core

| Layer | What | Replaceable? |
|-------|------|-------------|
| **Language** | Python 3.12+ async | ❌ No |
| **DI Container** | Container + wiring | ❌ No |
| **Protocols** | Interface definitions | ❌ No (contracts) |
| **Entrypoints** | Plugin discovery | ❌ No (mechanism) |

### Blessed Defaults (Deeply Integrated)

These are tightly coupled to the framework but have escape hatches:

| Component | Default | Escape Hatch |
|-----------|---------|--------------|
| **HTTP Framework** | Litestar | Routes, middleware are Litestar-native (no abstraction) |
| **ORM** | SQLAlchemy | **Virtual DocTypes** with custom `RepositoryProtocol` implementations |

**Virtual DocTypes**: For non-SQL data sources (APIs, NoSQL, files), implement `RepositoryProtocol` and register via `RepositoryFactory.register_override()`:

```python
# Custom repository for any data source
class MongoRepository(RepositoryProtocol[MyDoc]):
    async def get(self, id: UUID) -> MyDoc | None: ...
    async def save(self, entity: MyDoc) -> MyDoc: ...

# Register the override
RepositoryFactory().register_override("MyDoc", MongoRepository)
```

### Everything Else is Replaceable

Components with Protocols can be swapped by implementing the interface and registering via entrypoints:

| Component | Default | Can Swap To | Protocol |
|-----------|---------|-------------|----------|
| Database | PostgreSQL | MySQL, SQLite, Oracle | (SQLAlchemy drivers) |
| Event Bus | NATS | Redis, Kafka, RabbitMQ | `EventBusProtocol` |
| Job Queue | Taskiq | Celery, ARQ | `JobQueueProtocol` |
| Cache | Redis | Memcached, Memory | `CacheProtocol` |
| Storage | S3 | Local, Memory | `StorageProtocol` |
| Auth | Local User | Keycloak, Auth0, custom | `IdentityProtocol` |
| Frontend | Refine.dev | Next.js, Vue, Flutter | (API-driven) |

### Open-Closed Principle

```
                    ┌─────────────────────────────┐
                    │   Your App / Custom Code    │
                    └─────────────┬───────────────┘
                                  │ uses
                    ┌─────────────▼───────────────┐
                    │        Protocols            │
                    │  (CacheProtocol, EventBus)  │
                    └─────────────┬───────────────┘
                                  │ implemented by
          ┌───────────────────────┼───────────────────────┐
          ▼                       ▼                       ▼
    ┌───────────┐           ┌───────────┐           ┌───────────┐
    │   Redis   │           │   NATS    │           │  Custom   │
    │  Adapter  │           │  Adapter  │           │  Adapter  │
    └───────────┘           └───────────┘           └───────────┘
```

**Closed for modification**: Protocols are stable contracts.
**Open for extension**: Add new adapters via entrypoints.

### Zero-Cliff Future

This design ensures:
- **No vendor lock-in** — swap any component
- **No rewrite cliffs** — extend, don't replace the whole framework
- **Progressive enhancement** — start simple, add capabilities as needed
- **Test isolation** — swap real adapters for mocks

---

## 1. Dependency Injection as Universal Composer

DI is not just for HTTP requests — it's the **universal composition mechanism** across:

| Context | How DI Works |
|---------|--------------|
| **Litestar (HTTP)** | Request-scoped via lifespan |
| **CLI commands** | App-scoped via entrypoints |
| **Background jobs** | Job-scoped via Taskiq |
| **Tests** | Overrides via fixtures |
| **Future (REPL, Notebooks)** | Same container pattern |

### 1.1 The Container Pattern

```python
# container.py - Central composition root
from framework_m.core.container import Container

def create_container(settings: Settings) -> Container:
    """Build the DI container with all dependencies."""
    container = Container()

    # Register adapters (implementations)
    container.register(DatabaseAdapter, SQLAlchemyAdapter(settings.DATABASE_URL))
    container.register(EventBus, NATSEventBus(settings.NATS_URL))
    container.register(JobQueue, TaskiqQueue(settings.NATS_URL))
    container.register(Cache, RedisCache(settings.REDIS_URL))

    # Register repositories
    container.register(ItemRepository, GenericRepository(Item, container.get(DatabaseAdapter)))

    # Register services
    container.register(ItemService, ItemService(container.get(ItemRepository)))

    return container
```

### 1.2 Entrypoint Registration

Framework M discovers adapters via **Python entrypoints** (not hardcoded imports):

```toml
# pyproject.toml
[project.entry-points."framework_m.adapters.eventbus"]
nats = "framework_m.adapters.eventbus.nats:NATSEventBus"
redis = "framework_m.adapters.eventbus.redis:RedisEventBus"

[project.entry-points."framework_m.adapters.cache"]
redis = "framework_m.adapters.cache.redis:RedisCache"
memory = "framework_m.adapters.cache.memory:MemoryCache"

[project.entry-points."framework_m.adapters.storage"]
s3 = "framework_m.adapters.storage.s3:S3Storage"
local = "framework_m.adapters.storage.local:LocalStorage"
```

### 1.3 Configuration-Driven Selection

```python
# settings.py
class Settings(BaseSettings):
    # Select adapter by name (from entrypoints)
    EVENT_BUS_ADAPTER: str = "nats"
    CACHE_ADAPTER: str = "redis"
    STORAGE_ADAPTER: str = "s3"

# bootstrap.py
def load_adapter(group: str, name: str):
    """Load adapter class from entrypoints."""
    from importlib.metadata import entry_points
    eps = entry_points(group=group)
    return eps[name].load()

# Usage
EventBusClass = load_adapter("framework_m.adapters.eventbus", settings.EVENT_BUS_ADAPTER)
container.register(EventBus, EventBusClass(settings))
```

---

## 2. Bootstrap Flow

### 2.1 Litestar (HTTP Server)

```python
# app.py
from litestar import Litestar
from litestar.di import Provide

def create_app() -> Litestar:
    settings = Settings()
    container = create_container(settings)

    return Litestar(
        route_handlers=[...],
        dependencies={
            "settings": Provide(lambda: settings),
            "container": Provide(lambda: container),
            "item_service": Provide(lambda: container.get(ItemService)),
        },
        lifespan=[container_lifespan],
    )

async def container_lifespan(app: Litestar):
    """Startup/shutdown lifecycle."""
    container = app.state.container
    await container.startup()  # Connect to DBs, queues
    yield
    await container.shutdown()  # Cleanup
```

### 2.2 CLI Commands

```python
# cli/commands.py
import cyclopts
from framework_m.core.bootstrap import create_container

app = cyclopts.App()

@app.command
def import_items(file: str):
    """CLI uses same DI container."""
    settings = Settings()
    container = create_container(settings)

    # Get service from container
    item_service = container.get(ItemService)

    # Use it
    items = parse_csv(file)
    for item in items:
        item_service.create(item)
```

### 2.3 Background Jobs (Taskiq)

```python
# jobs/tasks.py
from taskiq import TaskiqDepends
from framework_m.core.bootstrap import get_container

@broker.task
async def process_order(
    order_id: str,
    order_service: OrderService = TaskiqDepends(lambda: get_container().get(OrderService)),
):
    """Job gets dependencies from same container."""
    await order_service.process(order_id)
```

---

## 3. Repository + Controller + Service Composition

### 3.1 Layer Responsibilities

```
┌─────────────────────────────────────────────────────────────┐
│                     HTTP / CLI / Job                        │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                       Service Layer                         │
│  • Orchestrates business operations                         │
│  • Manages UnitOfWork (transactions)                        │
│  • Calls multiple repositories                              │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      Controller Layer                       │
│  • DocType-specific business logic                          │
│  • Lifecycle hooks (validate, before_save, after_save)      │
│  • Attached to Repository                                   │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     Repository Layer                        │
│  • CRUD operations                                          │
│  • Calls Controller hooks                                   │
│  • Receives session, doesn't own it                         │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      Database (SQLAlchemy)                  │
└─────────────────────────────────────────────────────────────┘
```

### 3.2 Composition Example

```python
# 1. DocType (schema only)
class Invoice(BaseDocType):
    customer: str
    total: Decimal
    items: list[InvoiceItem]

# 2. Controller (business logic + hooks)
class InvoiceController(BaseController[Invoice]):
    async def validate(self, context=None):
        if self.doc.total < 0:
            raise ValueError("Total cannot be negative")

    async def after_save(self, context=None):
        await self.update_customer_balance()

# 3. Repository (CRUD + hooks invocation)
invoice_repo = GenericRepository(
    model=Invoice,
    table=invoice_table,
    controller_class=InvoiceController,  # Wired here
)

# 4. Service (orchestration + transactions)
class InvoiceService:
    def __init__(self, repo: GenericRepository[Invoice], uow_factory: UoWFactory):
        self.repo = repo
        self.uow_factory = uow_factory

    async def create_invoice(self, data: InvoiceCreate) -> Invoice:
        async with self.uow_factory() as uow:
            invoice = Invoice(**data.model_dump())
            await self.repo.save(uow.session, invoice)  # Calls controller hooks
            await uow.commit()
        return invoice
```

---

## 4. Adding Custom Adapters

### 4.1 Implement the Protocol

```python
# my_app/adapters/custom_cache.py
from framework_m.core.interfaces import CacheProtocol

class MemcachedCache(CacheProtocol):
    def __init__(self, url: str):
        self.client = pymemcache.Client(url)

    async def get(self, key: str) -> Any:
        return self.client.get(key)

    async def set(self, key: str, value: Any, ttl: int = 300):
        self.client.set(key, value, expire=ttl)
```

### 4.2 Register via Entrypoint

```toml
# my_app/pyproject.toml
[project.entry-points."framework_m.adapters.cache"]
memcached = "my_app.adapters.custom_cache:MemcachedCache"
```

### 4.3 Select in Config

```bash
# .env
CACHE_ADAPTER=memcached
MEMCACHED_URL=memcached://localhost:11211
```

---

## 5. Testing with DI

### 5.1 Override Dependencies

```python
# tests/conftest.py
import pytest
from framework_m.core.container import Container

@pytest.fixture
def test_container():
    """Container with test adapters."""
    container = Container()
    container.register(Cache, MemoryCache())  # In-memory for tests
    container.register(EventBus, MockEventBus())  # Mock for tests
    return container

@pytest.fixture
def item_service(test_container):
    return test_container.get(ItemService)
```

### 5.2 Mock Specific Dependencies

```python
@pytest.mark.asyncio
async def test_create_invoice(item_service, mocker):
    # Mock specific method
    mocker.patch.object(item_service.repo, "save", return_value=mock_invoice)

    result = await item_service.create(data)
    assert result.id == mock_invoice.id
```

---

## Summary

| Concept | Key Point |
|---------|-----------|
| **DI Container** | Universal composer for all contexts |
| **Entrypoints** | Pluggable adapters without code changes |
| **Bootstrap** | Same container for HTTP, CLI, Jobs |
| **Composition** | Service → Repository → Controller → DocType |
| **Testing** | Override adapters via container |


Document: docs/tutorials/mongodb-mx-tutorial.md

# Tutorial: Building a MongoDB-Primary Framework-M Application

This tutorial demonstrates how to build a MongoDB-based application using Framework-M's MX (Modular eXtension) pattern. You'll learn how to use `framework-mx-mongo` to replace the default SQLAlchemy backend with MongoDB, without forking the framework.

## What is the MX Pattern?

The MX pattern enables enterprises to customize Framework-M by:

- **Pure package composition** - Install packages to change behavior
- **No forking required** - Maintain upgrade path to official releases
- **Entry point discovery** - Framework auto-discovers adapters at runtime
- **Protocol-based** - All adapters implement standard interfaces

## Prerequisites

- Python 3.12+
- MongoDB 4.4+ (local or remote instance)
- Basic understanding of Framework-M concepts

## Step 1: Install Framework-M with MongoDB Support

Instead of the standard `framework-m` package, install the core package with MongoDB adapters:

```bash
# Option 1: Core + MongoDB (no SQLAlchemy)
pip install framework-m-core framework-mx-mongo

# Option 2: With development tools
pip install framework-m-core framework-mx-mongo framework-m-studio
```

**What happens:**

- `framework-m-core` provides protocols and interfaces
- `framework-mx-mongo` provides MongoDB implementations
- Entry points automatically override defaults

## Step 2: Verify MongoDB Connection

Create a simple script to verify the setup:

```python
# verify_mongo.py
import asyncio
from framework_mx_mongo import MongoSessionFactory

async def main():
    factory = MongoSessionFactory(
        connection_url="mongodb://localhost:27017",
        database_name="myapp"
    )

    # Test connection
    if await factory.ping():
        print("✓ MongoDB connected successfully!")
    else:
        print("✗ MongoDB connection failed")

    await factory.close()

if __name__ == "__main__":
    asyncio.run(main())
```

Run it:

```bash
python verify_mongo.py
```

## Step 3: Create a DocType

DocTypes work the same with MongoDB as with SQLAlchemy:

```python
# models.py
from datetime import datetime
from framework_m_core.base_doctype import BaseDocTypeProtocol
from pydantic import BaseModel, Field
from uuid import uuid4

class User(BaseModel):
    """User DocType - stored in MongoDB."""

    id: str = Field(default_factory=lambda: str(uuid4()))
    name: str
    email: str
    age: int | None = None
    created_at: datetime = Field(default_factory=datetime.now)
    modified_at: datetime = Field(default_factory=datetime.now)

    class Config:
        # MongoDB-specific: collection name
        collection_name = "users"
```

## Step 4: Use the Repository

The repository API is identical - protocol-based design ensures compatibility:

```python
# app.py
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
from framework_mx_mongo import MongoGenericRepository
from models import User

async def main():
    # Connect to MongoDB
    client = AsyncIOMotorClient("mongodb://localhost:27017")
    db = client["myapp"]

    # Create repository
    user_repo = MongoGenericRepository(User, db)

    # Create a user
    new_user = User(name="Alice", email="alice@example.com", age=30)
    saved_user = await user_repo.save(new_user)
    print(f"Created user: {saved_user.id}")

    # Retrieve user
    from uuid import UUID
    found_user = await user_repo.get(UUID(saved_user.id))
    print(f"Found user: {found_user.name}")

    # List users with filtering
    from framework_m_core.interfaces.repository import FilterSpec, FilterOperator

    filters = [
        FilterSpec(field="age", operator=FilterOperator.GTE, value=25)
    ]
    result = await user_repo.list(filters=filters, limit=10)
    print(f"Found {result.total} users aged 25+")

    # Cleanup
    client.close()

if __name__ == "__main__":
    asyncio.run(main())
```

## Step 5: Configure Bootstrap (Optional)

For advanced applications, configure the bootstrap sequence:

```python
# config.py
from framework_m_core.bootstrap import BootstrapRunner
from framework_mx_mongo import MongoInit

async def initialize_app():
    """Initialize application with MongoDB."""

    # Create DI container (simplified)
    container = {}  # Use real DI container in production

    # Run bootstrap
    runner = BootstrapRunner()
    await runner.run(container)

    # Bootstrap discovers and runs:
    # 1. MongoInit (order=10) - from framework-mx-mongo
    # 2. Any other registered steps

    print("Application initialized!")

# In your main app
import asyncio
asyncio.run(initialize_app())
```

## Step 6: Schema Management

MongoDB schemas are defined via JSON Schema validators:

```python
# schema.py
from framework_mx_mongo import MongoSchemaMapper
from models import User

async def setup_schema():
    """Create MongoDB collection with validation."""
    from motor.motor_asyncio import AsyncIOMotorClient

    client = AsyncIOMotorClient("mongodb://localhost:27017")
    db = client["myapp"]

    # Generate schema
    mapper = MongoSchemaMapper()
    schema = mapper.create_table(User, None)

    # Create collection with validation
    await db.create_collection(
        schema["collection"],
        validator=schema["validator"]
    )

    # Create indexes
    collection = db[schema["collection"]]
    for index_spec in schema["indexes"]:
        await collection.create_index(
            index_spec["keys"],
            unique=index_spec.get("unique", False)
        )

    print(f"Collection '{schema['collection']}' created with schema validation")
    client.close()
```

## Step 7: Transactions (MongoDB 4.0+)

Use MongoDB sessions for ACID transactions:

```python
# transactions.py
import asyncio
from framework_mx_mongo import MongoSessionFactory
from motor.motor_asyncio import AsyncIOMotorClient

async def transfer_credits():
    """Example: Transfer credits between users atomically."""

    factory = MongoSessionFactory(
        "mongodb://localhost:27017",
        "myapp"
    )

    async with factory.session() as session:
        # Operations within this block use the same session
        db = factory.get_database()
        users = db["users"]

        # Deduct from user A
        await users.update_one(
            {"id": "user-a-id"},
            {"$inc": {"credits": -100}},
            session=session
        )

        # Add to user B
        await users.update_one(
            {"id": "user-b-id"},
            {"$inc": {"credits": 100}},
            session=session
        )

        # Auto-commits on success, rolls back on exception

    await factory.close()
    print("Transaction completed!")
```

## Step 8: Testing Your Application

Tests work the same - mock the MongoDB database:

```python
# test_users.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from framework_mx_mongo import MongoGenericRepository
from models import User

@pytest.fixture
def mock_db():
    """Mock MongoDB database."""
    mock = MagicMock()
    mock_collection = MagicMock()
    mock_collection.find_one = AsyncMock(return_value=None)
    mock_collection.insert_one = AsyncMock()
    mock.__getitem__ = lambda self, name: mock_collection
    return mock

@pytest.mark.asyncio
async def test_create_user(mock_db):
    """Test user creation with MongoDB repository."""
    repo = MongoGenericRepository(User, mock_db)

    user = User(name="Test User", email="test@example.com")
    saved_user = await repo.save(user)

    assert saved_user.name == "Test User"
    mock_db["user"].insert_one.assert_called_once()
```

## Migration from SQLAlchemy

If migrating an existing app from SQLAlchemy:

### 1. Update Dependencies

```toml
# Before
dependencies = ["framework-m"]

# After
dependencies = ["framework-m-core", "framework-mx-mongo"]
```

### 2. Update Imports

```python
# Before
from framework_m.adapters.db import GenericRepository

# After - imports are the same, but implementation changes!
from framework_m_core.interfaces.repository import RepositoryProtocol
# Repository is auto-discovered from framework-mx-mongo
```

### 3. Data Migration

Export from PostgreSQL/MySQL and import to MongoDB:

```python
# migrate_data.py
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from motor.motor_asyncio import AsyncIOMotorClient

async def migrate():
    # Read from SQLAlchemy
    sql_engine = create_async_engine("postgresql+asyncpg://...")

    # Write to MongoDB
    mongo_client = AsyncIOMotorClient("mongodb://localhost:27017")
    mongo_db = mongo_client["myapp"]

    # Migrate users table -> users collection
    async with sql_engine.connect() as conn:
        result = await conn.execute("SELECT * FROM users")
        users = result.fetchall()

        for user in users:
            await mongo_db.users.insert_one({
                "id": str(user.id),
                "name": user.name,
                "email": user.email,
                # ... other fields
            })

    print("Migration completed!")

asyncio.run(migrate())
```

## Best Practices

### 1. Use Indexes Wisely

```python
from pydantic import Field

class User(BaseModel):
    email: str = Field(
        ...,
        json_schema_extra={"index": True, "unique": True}
    )
```

### 2. Handle Connection Pooling

```python
# Singleton pattern for MongoDB client
from motor.motor_asyncio import AsyncIOMotorClient

class MongoConnection:
    _client = None

    @classmethod
    def get_client(cls):
        if cls._client is None:
            cls._client = AsyncIOMotorClient(
                "mongodb://localhost:27017",
                maxPoolSize=50
            )
        return cls._client
```

### 3. Use Pydantic Validation

MongoDB stores JSON documents - leverage Pydantic for validation:

```python
from pydantic import BaseModel, field_validator

class User(BaseModel):
    email: str

    @field_validator('email')
    @classmethod
    def validate_email(cls, v):
        if '@' not in v:
            raise ValueError('Invalid email')
        return v.lower()
```

## Comparison: Standard vs MX-Mongo

| Feature      | framework-m (Standard)        | framework-mx-mongo        |
| ------------ | ----------------------------- | ------------------------- |
| Database     | PostgreSQL/MySQL (SQLAlchemy) | MongoDB (Motor)           |
| Transactions | SQL transactions              | MongoDB sessions          |
| Schema       | Tables + columns              | Collections + documents   |
| Migrations   | Alembic                       | MongoDB schema versioning |
| Joins        | SQL joins                     | Embedded docs / $lookup   |
| Install size | Larger (SQLAlchemy)           | Smaller (Motor only)      |

## Advanced: Creating Your Own MX Package

Want to support Cassandra, DynamoDB, or another database? Follow the pattern:

```
framework-mx-cassandra/
├── pyproject.toml          # Register entry points
├── src/framework_mx_cassandra/
│   ├── repository.py       # Implement RepositoryProtocol
│   ├── schema_mapper.py    # Implement SchemaMapperProtocol
│   └── bootstrap.py        # Implement BootstrapProtocol
```

See the [MX Pattern ADR](../adr/0006-mx-pattern.md) for architectural details.

## Troubleshooting

### Connection Issues

```python
# Test MongoDB connection
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017", serverSelectionTimeoutMS=5000)
client.admin.command('ping')  # Should not raise exception
```

### Entry Point Not Found

Verify installation:

```bash
python -c "from importlib.metadata import entry_points; print(list(entry_points(group='framework_m.adapters.repository')))"
```

### Schema Validation Errors

Check validator syntax:

```python
schema = mapper.create_table(User, None)
print(schema["validator"])  # Inspect generated JSON Schema
```

## Next Steps

- Read [MX Pattern ADR](../adr/0006-mx-pattern.md)
- Explore [Protocol Reference](../developer/generated/index.md)
- Build your own MX package for your database!

## Summary

You've learned how to:

- ✅ Install Framework-M with MongoDB support
- ✅ Create DocTypes that work with MongoDB
- ✅ Use repositories with the same API
- ✅ Handle transactions with MongoDB sessions
- ✅ Migrate from SQLAlchemy to MongoDB
- ✅ Understand the MX pattern benefits

The MX pattern gives you database flexibility without vendor lock-in!


Document: docs/examples/README.md

# Example Applications

Complete example applications built with Framework M.

## Available Examples

| Example | Description | DocTypes |
|---------|-------------|----------|
| [TODO App](./todo/) | Simple task management | Task |
| [CRM](./crm/) | Customer relationship management | Contact, Deal |

## Using Examples

Each example includes:
- **DocType definitions** - Schema with fields
- **Controller code** - Business logic
- **API usage** - curl examples
- **Quick start** - Step-by-step setup

## Creating Your Own App

```bash
# 1. Create app
uv run m new:app myapp

# 2. Create DocTypes
cd myapp
uv run m new:doctype MyDocType

# 3. Edit files
# - src/doctypes/my_doctype/doctype.py
# - src/doctypes/my_doctype/controller.py

# 4. Run migration
uv run m migrate init
uv run m migrate create "Add MyDocType" --autogenerate
uv run m migrate

# 5. Start Studio
uv run m studio
```


Document: docs/examples/business-m-package.md

# Business-M Example Package

This is a complete example of a Framework M package with frontend components.

## Package Structure

```
business-m/
├── LICENSE
├── README.md
├── pyproject.toml
├── tests/
│   ├── __init__.py
│   ├── test_customer.py
│   └── test_invoice.py
└── src/
    └── business_m/
        ├── __init__.py
        ├── doctypes/
        │   ├── __init__.py
        │   ├── customer.py
        │   └── invoice.py
        └── frontend/
            ├── index.ts
            ├── package.json
            ├── tsconfig.json
            ├── components/
            │   ├── CustomerCard.tsx
            │   ├── CustomerCard.module.css
            │   ├── InvoiceForm.tsx
            │   └── InvoiceForm.module.css
            ├── pages/
            │   ├── CustomerList.tsx
            │   ├── CustomerDetail.tsx
            │   ├── InvoiceList.tsx
            │   └── InvoiceDetail.tsx
            ├── hooks/
            │   ├── useCustomer.ts
            │   └── useInvoice.ts
            └── types/
                └── index.ts
```

## pyproject.toml

```toml
[build-system]
requires = [\"hatchling\"]
build-backend = \"hatchling.build\"

[project]
name = \"business-m\"
version = \"1.0.0\"
description = \"Core business entities for Framework M\"
readme = \"README.md\"
requires-python = \">=3.12\"
license = \"MIT\"
authors = [
    { name = \"Framework M Team\", email = \"team@framework-m.dev\" }
]
classifiers = [
    \"Development Status :: 4 - Beta\",
    \"Framework :: AsyncIO\",
    \"Intended Audience :: Developers\",
    \"License :: OSI Approved :: Apache 2.0 License\",
    \"Programming Language :: Python :: 3.12\",
]
dependencies = [
    \"framework-m>=0.1.0\",
]

[project.optional-dependencies]
dev = [
    \"pytest>=7.0.0\",
    \"pytest-asyncio>=0.21.0\",
    \"mypy>=1.0.0\",
    \"ruff>=0.1.0\",
]

[project.entry-points.\"framework_m.apps\"]
business_m = \"business_m:app\"

[project.entry-points.\"framework_m.frontend\"]
business_m = \"business_m.frontend:plugin\"

[tool.hatch.build.targets.wheel]
packages = [\"src/business_m\"]

[tool.pytest.ini_options]
testpaths = [\"tests\"]
asyncio_mode = \"auto\"

[tool.mypy]
strict = true
warn_return_any = true
warn_unused_configs = true

[tool.ruff]
line-length = 88
target-version = \"py312\"
```

## Backend Code

### src/business_m/**init**.py

```python
\"\"\"Business-M: Core business entities for Framework M.\"\"\"

from framework_m import App

app = App(
    name=\"business_m\",
    doctypes_package=\"business_m.doctypes\",
)

__version__ = \"1.0.0\"
__all__ = [\"app\", \"__version__\"]
```

### src/business_m/doctypes/customer.py

```python
\"\"\"Customer DocType.\"\"\"

from typing import Optional
from framework_m import DocType, Field

class Customer(DocType):
    \"\"\"Represents a business customer.\"\"\"

    customer_name: str = Field(title=\"Customer Name\", req=True)
    email: Optional[str] = Field(title=\"Email\", unique=True)
    phone: Optional[str] = Field(title=\"Phone\")
    address: Optional[str] = Field(title=\"Address\")
    credit_limit: float = Field(default=0.0, title=\"Credit Limit\")
    outstanding_amount: float = Field(default=0.0, title=\"Outstanding Amount\")

    class Meta:
        doctype = \"Customer\"
        title_field = \"customer_name\"
        search_fields = [\"customer_name\", \"email\", \"phone\"]
        roles = {
            \"read\": [\"Sales User\", \"Sales Manager\"],
            \"write\": [\"Sales Manager\"],
            \"create\": [\"Sales Manager\"],
            \"delete\": [\"Sales Manager\"],
        }
```

### src/business_m/doctypes/invoice.py

```python
\"\"\"Invoice DocType.\"\"\"

from typing import Optional
from datetime import date
from framework_m import DocType, Field

class Invoice(DocType):
    \"\"\"Represents a sales invoice.\"\"\"

    invoice_number: str = Field(title=\"Invoice Number\", req=True, unique=True)
    customer_id: str = Field(title=\"Customer\", req=True, link=\"Customer\")
    invoice_date: date = Field(title=\"Invoice Date\", req=True)
    due_date: date = Field(title=\"Due Date\", req=True)
    total_amount: float = Field(default=0.0, title=\"Total Amount\")
    paid_amount: float = Field(default=0.0, title=\"Paid Amount\")
    status: str = Field(default=\"Draft\", title=\"Status\")

    class Meta:
        doctype = \"Invoice\"
        title_field = \"invoice_number\"
        search_fields = [\"invoice_number\", \"customer_id\"]
        submittable = True
        roles = {
            \"read\": [\"Sales User\", \"Sales Manager\"],
            \"write\": [\"Sales User\", \"Sales Manager\"],
            \"create\": [\"Sales User\", \"Sales Manager\"],
            \"submit\": [\"Sales Manager\"],
        }
```

## Frontend Code

### frontend/package.json

```json
{
  \"name\": \"@business-m/frontend\",
  \"version\": \"1.0.0\",
  \"private\": true,
  \"peerDependencies\": {
    \"react\": \"^18.0.0\",
    \"react-dom\": \"^18.0.0\",
    \"@framework-m/core\": \"^0.1.0\"
  },
  \"dependencies\": {
    \"date-fns\": \"^2.30.0\",
    \"react-hook-form\": \"^7.49.0\",
    \"zod\": \"^3.22.0\"
  },
  \"devDependencies\": {
    \"@types/react\": \"^18.2.0\",
    \"@types/react-dom\": \"^18.2.0\",
    \"typescript\": \"^5.3.0\"
  }
}
```

### frontend/index.ts

```typescript
import { registerPlugin } from \"@framework-m/core\";
import { CustomerCard } from \"./components/CustomerCard\";
import { InvoiceForm } from \"./components/InvoiceForm\";
import { CustomerListPage } from \"./pages/CustomerList\";
import { CustomerDetailPage } from \"./pages/CustomerDetail\";
import { InvoiceListPage } from \"./pages/InvoiceList\";
import { InvoiceDetailPage } from \"./pages/InvoiceDetail\";

export const plugin = {
  name: \"business_m\",
  version: \"1.0.0\",
};

registerPlugin({
  name: \"business_m\",
  version: \"1.0.0\",

  pages: [
    {
      path: \"/app/customer/list\",
      component: CustomerListPage,
    },
    {
      path: \"/app/customer/:id\",
      component: CustomerDetailPage,
    },
    {
      path: \"/app/invoice/list\",
      component: InvoiceListPage,
    },
    {
      path: \"/app/invoice/:id\",
      component: InvoiceDetailPage,
    },
  ],

  components: {
    CustomerCard,
    InvoiceForm,
  },
});

// Export public API for other packages
export { CustomerCard } from \"./components/CustomerCard\";
export { InvoiceForm } from \"./components/InvoiceForm\";
export { useCustomer } from \"./hooks/useCustomer\";
export { useInvoice } from \"./hooks/useInvoice\";
export type { CustomerCardProps, InvoiceFormProps } from \"./types\";
```

### frontend/components/CustomerCard.tsx

````typescript
import { Card, Badge } from \"@framework-m/core\";
import type { CustomerCardProps } from \"../types\";
import styles from \"./CustomerCard.module.css\";

export function CustomerCard({ customer, variant = \"detailed\" }: CustomerCardProps) {
  const isOverLimit = customer.outstanding_amount > customer.credit_limit;

  return (
    <Card className={styles.card}>
      <div className={styles.header}>
        <h3>{customer.customer_name}</h3>
        {isOverLimit && <Badge variant=\"danger\">Over Limit</Badge>}
      </div>

      {variant === \"detailed\" && (
        <div className={styles.details}>
          <div className={styles.row}>
            <span>Email:</span>
            <span>{customer.email || \"—\"}</span>
          </div>
          <div className={styles.row}>
            <span>Phone:</span>
            <span>{customer.phone || \"—\"}</span>
          </div>
          <div className={styles.row}>
            <span>Credit Limit:</span>
            <span>${customer.credit_limit.toFixed(2)}</span>
          </div>
          <div className={styles.row}>
            <span>Outstanding:</span>
            <span className={isOverLimit ? styles.danger : \"\"}>
              ${customer.outstanding_amount.toFixed(2)}
            </span>
          </div>
        </div>
      )}
    </Card>
  );
}\n```

### frontend/pages/CustomerList.tsx

```typescript
import { Page, DataTable, Button } from \"@framework-m/core\";
import { useNavigate } from \"react-router-dom\";
import { useCustomers } from \"../hooks/useCustomer\";

export function CustomerListPage() {
  const navigate = useNavigate();
  const { data: customers, isLoading } = useCustomers();

  return (
    <Page
      title=\"Customers\"
      actions={
        <Button onClick={() => navigate(\"/app/customer/new\")}>
          New Customer
        </Button>
      }
    >
      <DataTable
        data={customers}
        loading={isLoading}
        columns={[
          { key: \"customer_name\", label: \"Name\" },
          { key: \"email\", label: \"Email\" },
          { key: \"phone\", label: \"Phone\" },
          {
            key: \"outstanding_amount\",
            label: \"Outstanding\",
            render: (value) => `$${value.toFixed(2)}`,
          },
        ]}
        onRowClick={(customer) => navigate(`/app/customer/${customer.id}`)}
      />
    </Page>
  );
}
````

### frontend/hooks/useCustomer.ts

```typescript
import { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";
import { api } from \"@framework-m/core\";
import type { Customer } from \"../types\";

export function useCustomer(id: string) {
  return useQuery<Customer>({
    queryKey: [\"customer\", id],
    queryFn: () => api.get(`/api/customer/${id}`),
  });
}

export function useCustomers() {
  return useQuery<Customer[]>({
    queryKey: [\"customers\"],
    queryFn: () => api.get(\"/api/customer\"),
  });
}

export function useUpdateCustomer() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (customer: Customer) =>
      api.put(`/api/customer/${customer.id}`, customer),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [\"customers\"] });
    },
  });
}
```

### frontend/types/index.ts

```typescript
export interface Customer {
  id: string;
  customer_name: string;
  email?: string;
  phone?: string;
  address?: string;
  credit_limit: number;
  outstanding_amount: number;
}

export interface Invoice {
  id: string;
  invoice_number: string;
  customer_id: string;
  invoice_date: string;
  due_date: string;
  total_amount: number;
  paid_amount: number;
  status: \"Draft\" | \"Submitted\" | \"Paid\" | \"Cancelled\";
}

export interface CustomerCardProps {
  customer: Customer;
  variant?: \"compact\" | \"detailed\";
  onUpdate?: (customer: Customer) => void;
}

export interface InvoiceFormProps {
  invoice?: Invoice;
  onSave: (invoice: Invoice) => void;
  onCancel: () => void;
}
```

## Installation

### Development

```bash
# Clone repository
git clone https://github.com/framework-m/business-m.git
cd business-m

# Install in editable mode
pip install -e \".[dev]\"

# Run tests
pytest

# Type check
mypy src/business_m
```

### Production

```bash
pip install business-m
```

## Usage

### In Python

```python
from business_m.doctypes.customer import Customer
from business_m.doctypes.invoice import Invoice

# DocTypes are auto-discovered via entry point
```

### In Frontend

```typescript
import { CustomerCard, useCustomer } from \"@business-m/frontend\";

function MyComponent() {
  const { data: customer } = useCustomer(\"CUST-001\");

  return <CustomerCard customer={customer} variant=\"detailed\" />;
}
```

## Extending

Other packages can extend business-m:\n\n`typescript\n// In crm-app package\nimport { CustomerCard } from \"@business-m/frontend\";\nimport { LeadInfo } from \"./components/LeadInfo\";\n\nfunction CRMCustomerCard(props) {\n  return (\n    <div>\n      <CustomerCard {...props} />\n      <LeadInfo customerId={props.customer.id} />\n    </div>\n  );\n}\n`\n\n## License\n\nMIT\n


Document: docs/user/studio.md

# Studio User Guide

## Overview

Framework-M Studio is a visual development environment for creating and managing DocTypes.

## Starting Studio

```bash
# Start Studio server
uv run m studio

# Or with a specific port
uv run m studio --port 8000
```

Then open [http://localhost:8000/studio](http://localhost:8000/studio) in your browser.

## Creating a DocType

1. **Navigate to DocTypes** - Click "DocTypes" in the sidebar
2. **Click "Create"** - Opens the DocType editor
3. **Enter Name** - Use PascalCase (e.g., `SalesOrder`)
4. **Add Fields** - Click "Add Field" and configure each field
5. **Save** - Click "Save" to generate the Python file

## Adding Fields

Each field has:
- **Name** - Python identifier (snake_case)
- **Type** - Select from: Text, Integer, Float, Boolean, Date, etc.
- **Required** - Whether the field is mandatory
- **Default** - Optional default value
- **Validators** - Min/max length, pattern, min/max value

## Configuring Permissions

Permissions are defined in the DocType's Meta class:
- Enable in the Properties panel
- Set `is_submittable = True` for workflow-enabled docs

## Using Git Mode

**Viewing Status:**
1. Click the Git icon in the sidebar
2. See current branch, modified files, and commit history

**Committing Changes:**
1. Make changes to DocTypes
2. Open Git panel
3. Enter commit message
4. Click "Commit"

**Pushing to Remote:**
1. Commit your changes first
2. Click "Push" to upload to GitHub

## Sandbox Mode

Test DocTypes without saving:
1. Open a DocType
2. Switch to "Sandbox" tab
3. Use auto-generated mock data
4. Test CRUD operations
5. Verify validation rules

## Controller Hooks

Available lifecycle hooks:
- `validate` - Before save validation
- `before_save` - Pre-persistence logic
- `after_save` - Post-save side effects
- `before_delete` - Pre-delete cleanup

Edit in the "Controller" tab.