# DX Improvements v2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Seven DX features: owner tracking + alias on FieldDescriptor, .asc()/.desc(), chainable order_by/limit on Query, to_dict, get_many, enum support, copy-pasteable repr, pagination.

**Architecture:** Owner tracking and alias on `FieldDescriptor` is the foundation — most other features depend on it. Task order matters: (1) owner+alias on FieldDescriptor, (2) .asc()/.desc() + updated Asc/Desc, (3) field() alias/enum_by params, (4) enum support in TypeRegistry, (5) to_dict with alias support, (6) alias-aware deserialization, (7) chainable order_by/limit on Query, (8) get_many, (9) copy-pasteable repr everywhere, (10) pagination, (11) exports + README.

**Tech Stack:** Python >= 3.13, pytest, pytest-bdd, mypy, ty, ruff

**Spec:** `docs/superpowers/specs/2026-03-21-dx-improvements-v2-design.md`

**Key context for implementers:**
- Read `CLAUDE.local.md` before starting — it has design principles and errors to avoid
- All `__repr__` must be copy-pasteable valid Python
- Prefer `City.population.asc()` over `Asc(City.population)` in repr
- `by_alias=False` by default for Python-facing APIs (`from_dict`, `to_dict`)
- `deserialize()` (internal, from Firestore) always uses aliases
- `AsyncCendry.select()` is a regular `def`, not `async def` — it returns `AsyncQuery`
- All fields are `kw_only`

---

## File Map

| File | Responsibility |
|------|---------------|
| `src/cendry/model.py` | `FieldDescriptor` gains `owner`, `alias`, `.asc()`, `.desc()`, `__repr__`. `FieldFilterResult` gains `owner`, `field_name` for repr. `field()` gains `alias`, `enum_by` params. |
| `src/cendry/filters.py` | `And`/`Or` repr updated to use filter repr. |
| `src/cendry/query.py` | `Asc`/`Desc` gain `owner`, `field_name`, `__repr__`. `Query`/`AsyncQuery` gain `.order_by()`, `.limit()`, `.paginate()`, `__repr__`. |
| `src/cendry/serialize.py` | `to_dict` added. `from_dict`, `deserialize` updated for alias support. |
| `src/cendry/context.py` | `get_many` / `async get_many` added. |
| `src/cendry/types.py` | `enum.Enum` accepted by registry. |
| `src/cendry/__init__.py` | Export `to_dict`. |
| `README.md` | Updated with new features. |

---

### Task 1: FieldDescriptor owner + alias + .asc()/.desc()

This is the foundation. `FieldDescriptor` gains `owner` (model class), `alias` (Firestore name), and ordering methods.

**Files:**
- Modify: `src/cendry/model.py`
- Modify: `tests/test_model.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_model.py
from cendry.model import FieldDescriptor


def test_field_descriptor_has_owner():
    class City(Model, collection="cities"):
        state: Field[str]

    assert City.state.owner is City


def test_field_descriptor_owner_with_inheritance():
    class Base(Map):
        name: Field[str]

    class City(Base, Model, collection="cities"):
        state: Field[str]

    # Both fields have City as owner, not Base
    assert City.name.owner is City
    assert City.state.owner is City


def test_field_descriptor_alias_default():
    class City(Model, collection="cities"):
        state: Field[str]

    assert City.state.alias == "state"


def test_field_descriptor_alias_custom():
    class City(Model, collection="cities"):
        name: Field[str] = field(alias="displayName")

    assert City.name.alias == "displayName"
    assert City.name.field_name == "name"


def test_field_descriptor_asc():
    class City(Model, collection="cities"):
        population: Field[int]

    from cendry.query import Asc
    result = City.population.asc()
    assert isinstance(result, Asc)


def test_field_descriptor_desc():
    class City(Model, collection="cities"):
        population: Field[int]

    from cendry.query import Desc
    result = City.population.desc()
    assert isinstance(result, Desc)


def test_field_descriptor_filter_uses_alias():
    class City(Model, collection="cities"):
        name: Field[str] = field(alias="displayName")

    result = City.name == "SF"
    assert result.field_name == "displayName"


def test_field_descriptor_repr():
    class City(Model, collection="cities"):
        state: Field[str]

    assert repr(City.state) == "City.state"
```

- [ ] **Step 2: Update FieldDescriptor**

Modify `FieldDescriptor.__init__` to accept `owner` and `alias`:

```python
def __init__(self, field_name: str, *, alias: str | None = None, owner: type | None = None) -> None:
    self.field_name = field_name
    self.alias = alias or field_name
    self.owner = owner

def __repr__(self) -> str:
    owner_name = self.owner.__name__ if self.owner else "?"
    return f"{owner_name}.{self.field_name}"
```

Update `_make_filter` to use alias:

```python
def _make_filter(self, op: str, value: Any) -> FieldFilterResult:
    return FieldFilterResult(self.alias, op, value, owner=self.owner, field_name=self.field_name)
```

Add `.asc()` and `.desc()`:

```python
def asc(self) -> "Asc":
    from .query import Asc
    return Asc(self)

def desc(self) -> "Desc":
    from .query import Desc
    return Desc(self)
```

- [ ] **Step 3: Update FieldFilterResult to carry owner**

```python
class FieldFilterResult(Filter):
    def __init__(self, field_name: str, op: str, value: Any, *, owner: type | None = None, field_name_python: str | None = None) -> None:
        self.field_name = field_name  # alias (for Firestore)
        self.op = op
        self.value = value
        self._owner = owner
        self._field_name_python = field_name_python or field_name
```

Note: `field_name` on `FieldFilterResult` is the alias (Firestore name) for filter generation. `_field_name_python` is the Python name for repr.

- [ ] **Step 4: Update field() to accept alias and enum_by**

```python
def field(
    *,
    default: Any = dataclasses.MISSING,
    default_factory: Any = dataclasses.MISSING,
    alias: str | None = None,
    enum_by: str = "value",
) -> Any:
    metadata = {}
    if alias is not None:
        metadata["cendry_alias"] = alias
    if enum_by != "value":
        metadata["cendry_enum_by"] = enum_by
    kwargs: dict[str, Any] = {}
    if default is not dataclasses.MISSING:
        kwargs["default"] = default
    if default_factory is not dataclasses.MISSING:
        kwargs["default_factory"] = default_factory
    if metadata:
        kwargs["metadata"] = metadata
    return dataclasses.field(**kwargs)
```

- [ ] **Step 5: Update _MapMeta to pass owner and alias**

In `_MapMeta.__new__`, change the FieldDescriptor setup:

```python
for f in dataclasses.fields(cls):
    if f.name != "id":
        alias = f.metadata.get("cendry_alias") if f.metadata else None
        setattr(cls, f.name, FieldDescriptor(f.name, alias=alias, owner=cls))
```

- [ ] **Step 6: Run tests, ruff, mypy, ty, commit**

```bash
uv run pytest -v && uv run ruff check src/ tests/ && uv run mypy src/cendry/ tests/ && uv run ty check src/cendry/ tests/
git commit -am "feat: add owner, alias, .asc()/.desc() to FieldDescriptor"
```

---

### Task 2: Update Asc/Desc with owner tracking + repr

**Files:**
- Modify: `src/cendry/query.py`
- Modify: `tests/test_query.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_query.py
def test_asc_repr():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.asc()
    assert repr(result) == "City.population.asc()"


def test_desc_repr():
    class City(Model, collection="cities"):
        population: Field[int]

    result = City.population.desc()
    assert repr(result) == "City.population.desc()"


def test_asc_uses_alias():
    class City(Model, collection="cities"):
        name: Field[str] = field(alias="displayName")

    result = City.name.asc()
    assert result.field == "displayName"


def test_asc_repr_with_alias():
    class City(Model, collection="cities"):
        name: Field[str] = field(alias="displayName")

    # repr shows Python name, not alias
    assert repr(City.name.asc()) == "City.name.asc()"
```

- [ ] **Step 2: Update _Order, Asc, Desc**

```python
class _Order:
    direction: str

    def __init__(self, field: str | FieldDescriptor) -> None:
        if isinstance(field, FieldDescriptor):
            self.field = field.alias  # use alias for Firestore
            self._owner = field.owner
            self._field_name_python = field.field_name
        else:
            self.field = field
            self._owner = None
            self._field_name_python = field

class Asc(_Order):
    direction = "ASCENDING"

    def __repr__(self) -> str:
        if self._owner:
            return f"{self._owner.__name__}.{self._field_name_python}.asc()"
        return f'Asc("{self.field}")'

class Desc(_Order):
    direction = "DESCENDING"

    def __repr__(self) -> str:
        if self._owner:
            return f"{self._owner.__name__}.{self._field_name_python}.desc()"
        return f'Desc("{self.field}")'
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_query.py tests/test_model.py -v
git commit -am "feat: add owner tracking and repr to Asc/Desc"
```

---

### Task 3: Enum support in TypeRegistry

**Files:**
- Modify: `src/cendry/types.py`
- Modify: `tests/test_types.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_types.py
import enum


def test_enum_is_valid(registry: TypeRegistry):
    class Status(enum.Enum):
        ACTIVE = "active"

    registry.validate("status", Status, "TestModel")


def test_int_enum_is_valid(registry: TypeRegistry):
    class Priority(enum.IntEnum):
        HIGH = 1

    registry.validate("priority", Priority, "TestModel")


def test_str_enum_is_valid(registry: TypeRegistry):
    class Color(enum.StrEnum):
        RED = "red"

    registry.validate("color", Color, "TestModel")
```

- [ ] **Step 2: Add enum check to _validate_hint**

In `TypeRegistry._validate_hint`, add before the structured types check:

```python
import enum

# Enum types
if isinstance(hint, type) and issubclass(hint, enum.Enum):
    return
```

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_types.py -v
git commit -am "feat: accept enum.Enum subclasses in TypeRegistry"
```

---

### Task 4: to_dict + alias-aware serialization

**Files:**
- Modify: `src/cendry/serialize.py`
- Modify: `tests/test_serialize.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_serialize.py
from cendry.serialize import to_dict


def test_to_dict_simple():
    city = from_dict(City, CITY_DATA)
    result = to_dict(city)
    assert result["name"] == "SF"
    assert "id" not in result


def test_to_dict_include_id():
    city = from_dict(City, CITY_DATA, doc_id="123")
    result = to_dict(city, include_id=True)
    assert result["id"] == "123"


def test_to_dict_nested_map():
    city = from_dict(City, {**CITY_DATA, "mayor": {"name": "Jane", "since": 2020}})
    result = to_dict(city)
    assert isinstance(result["mayor"], dict)
    assert result["mayor"]["name"] == "Jane"


def test_to_dict_by_alias():
    class AliasedModel(Model, collection="aliased"):
        name: Field[str] = cendry_field(alias="displayName")

    obj = AliasedModel(name="test")
    assert to_dict(obj)["name"] == "test"
    assert to_dict(obj, by_alias=True)["displayName"] == "test"


def test_from_dict_by_alias():
    class AliasedModel(Model, collection="aliased2"):
        name: Field[str] = cendry_field(alias="displayName")

    obj = from_dict(AliasedModel, {"displayName": "test"}, by_alias=True)
    assert obj.name == "test"
```

- [ ] **Step 2: Implement to_dict**

```python
def to_dict(
    instance: Model | Map,
    *,
    by_alias: bool = False,
    include_id: bool = False,
) -> dict[str, Any]:
    """Convert a model/map instance to a dict."""
    result: dict[str, Any] = {}
    for f in dataclasses.fields(instance):
        if f.name == "id":
            if include_id:
                result["id"] = getattr(instance, "id")
            continue
        value = getattr(instance, f.name)
        key = f.name
        if by_alias:
            key = f.metadata.get("cendry_alias", f.name) if f.metadata else f.name
        if isinstance(value, Map):
            value = _map_to_dict(value, by_alias=by_alias)
        result[key] = value
    return result


def _map_to_dict(instance: Map, *, by_alias: bool) -> dict[str, Any]:
    """Convert a Map instance to a dict recursively."""
    result: dict[str, Any] = {}
    for f in dataclasses.fields(instance):
        value = getattr(instance, f.name)
        key = f.name
        if by_alias:
            key = f.metadata.get("cendry_alias", f.name) if f.metadata else f.name
        if isinstance(value, Map):
            value = _map_to_dict(value, by_alias=by_alias)
        result[key] = value
    return result
```

- [ ] **Step 3: Update deserialize for alias support**

Update `deserialize` and `deserialize_map` to read from alias keys:

```python
def deserialize[T: Model](model_class: type[T], doc_id: str | None, data: dict[str, Any]) -> T:
    hints = get_type_hints(model_class, include_extras=True)
    converted: dict[str, Any] = {}
    for f in dataclasses.fields(model_class):
        if f.name == "id":
            continue
        # Read by alias (Firestore stores aliased keys)
        alias = f.metadata.get("cendry_alias", f.name) if f.metadata else f.name
        value = data.get(alias)
        if value is not None and isinstance(value, dict):
            inner = resolve_map_type(hints.get(f.name))
            if inner is not None:
                value = deserialize_map(inner, value)
        converted[f.name] = value
    return model_class(id=doc_id, **converted)
```

Same pattern for `deserialize_map`.

Update `from_dict` to accept `by_alias`:

```python
def from_dict[T: Model](
    model_class: type[T],
    data: dict[str, Any],
    *,
    doc_id: str | None = None,
    by_alias: bool = False,
) -> T:
    # ... validation uses appropriate keys based on by_alias
    # When by_alias=True, read from alias keys
    # When by_alias=False (default), read from Python field names
```

- [ ] **Step 4: Run tests, commit**

```bash
uv run pytest tests/test_serialize.py -v
git commit -am "feat: add to_dict, alias-aware serialization/deserialization"
```

---

### Task 5: Chainable order_by / limit on Query

**Files:**
- Modify: `src/cendry/query.py`
- Modify: `tests/test_query_object.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_query_object.py

def test_query_order_by_field_descriptor(mock_firestore_client: MagicMock):
    order_mock = _mock_stream([make_mock_document("SF", SF_DATA)])
    mock_firestore_client.collection.return_value.order_by.return_value = order_mock
    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.select(City).order_by(City.population).to_list()
    assert len(cities) == 1
    mock_firestore_client.collection.return_value.order_by.assert_called_once()


def test_query_order_by_desc(mock_firestore_client: MagicMock):
    order_mock = _mock_stream([make_mock_document("SF", SF_DATA)])
    mock_firestore_client.collection.return_value.order_by.return_value = order_mock
    ctx = Cendry(client=mock_firestore_client)
    ctx.select(City).order_by(City.population.desc()).to_list()
    mock_firestore_client.collection.return_value.order_by.assert_called_once_with(
        "population", direction="DESCENDING",
    )


def test_query_chainable_limit(mock_firestore_client: MagicMock):
    limit_mock = _mock_stream([make_mock_document("SF", SF_DATA)])
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.select(City).limit(5).to_list()
    assert len(cities) == 1
    mock_firestore_client.collection.return_value.limit.assert_called_once_with(5)


def test_query_order_by_returns_new_query(mock_firestore_client: MagicMock):
    mock_firestore_client.collection.return_value.stream.return_value = iter([])
    order_mock = MagicMock()
    order_mock.stream.return_value = iter([])
    mock_firestore_client.collection.return_value.order_by.return_value = order_mock
    ctx = Cendry(client=mock_firestore_client)
    q1 = ctx.select(City)
    q2 = q1.order_by(City.population)
    assert q1 is not q2
```

- [ ] **Step 2: Implement order_by and limit on Query/AsyncQuery**

Add to `Query`:

```python
def order_by(self, *orders: Any) -> "Query[T]":
    query = self._query
    for order in orders:
        if isinstance(order, FieldDescriptor):
            order = Asc(order)
        query = query.order_by(order.field, direction=order.direction)
    return Query(query, self._model_class, self._apply_filter)

def limit(self, n: int) -> "Query[T]":
    return Query(self._query.limit(n), self._model_class, self._apply_filter)
```

Same for `AsyncQuery`.

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_query_object.py -v
git commit -am "feat: add chainable order_by/limit to Query/AsyncQuery"
```

---

### Task 6: get_many

**Files:**
- Modify: `src/cendry/context.py`
- Modify: `tests/test_context.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_context.py

def test_get_many(mock_firestore_client: MagicMock):
    docs = [
        make_mock_document("SF", SF_DATA, exists=True),
        make_mock_document("LA", {**SF_DATA, "name": "Los Angeles"}, exists=True),
    ]
    doc_refs = [MagicMock() for _ in docs]
    mock_firestore_client.collection.return_value.document.side_effect = doc_refs
    mock_firestore_client.get_all.return_value = docs

    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.get_many(City, ["SF", "LA"])
    assert len(cities) == 2
    assert cities[0].name == "San Francisco"


def test_get_many_missing_raises(mock_firestore_client: MagicMock):
    doc_found = make_mock_document("SF", SF_DATA, exists=True)
    doc_missing = make_mock_document("NOPE", {}, exists=False)
    doc_refs = [MagicMock(), MagicMock()]
    mock_firestore_client.collection.return_value.document.side_effect = doc_refs
    mock_firestore_client.get_all.return_value = [doc_found, doc_missing]

    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        ctx.get_many(City, ["SF", "NOPE"])


@pytest.mark.anyio
async def test_async_get_many(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA, exists=True)]
    doc_refs = [MagicMock()]
    mock_firestore_client.collection.return_value.document.side_effect = doc_refs
    mock_firestore_client.get_all = AsyncMock(return_value=docs)

    ctx = AsyncCendry(client=mock_firestore_client)
    cities = await ctx.get_many(City, ["SF"])
    assert len(cities) == 1
```

- [ ] **Step 2: Implement get_many**

Add to `Cendry`:

```python
def get_many(
    self, model_class: type[T], document_ids: list[str], *, parent: Model | None = None
) -> list[T]:
    col_ref = self._get_collection_ref(model_class, parent)
    doc_refs = [col_ref.document(doc_id) for doc_id in document_ids]
    docs = list(self._client.get_all(doc_refs))
    missing = [doc.id for doc in docs if not doc.exists]
    if missing:
        raise DocumentNotFoundError(
            model_class.__collection__,
            ", ".join(missing),
        )
    return [self._deserialize(model_class, doc.id, doc.to_dict()) for doc in docs]
```

Add `async get_many` to `AsyncCendry` (same logic with `await`).

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_context.py -v -k "get_many"
git commit -am "feat: add get_many to Cendry and AsyncCendry"
```

---

### Task 7: Copy-pasteable repr everywhere

**Files:**
- Modify: `src/cendry/model.py`
- Modify: `src/cendry/filters.py`
- Modify: `src/cendry/query.py`
- Modify: `tests/test_filters.py`
- Modify: `tests/test_query_object.py`

- [ ] **Step 1: Write failing tests for FieldFilterResult repr**

```python
# in tests/test_filters.py — update existing repr tests

def test_field_filter_result_repr_dunder():
    class City(Model, collection="cities"):
        state: Field[str]

    result = City.state == "CA"
    assert repr(result) == "City.state == 'CA'"


def test_field_filter_result_repr_method():
    class City(Model, collection="cities"):
        regions: Field[list[str]]

    result = City.regions.array_contains("west")
    assert repr(result) == "City.regions.array_contains('west')"
```

- [ ] **Step 2: Update FieldFilterResult.__repr__**

Map operators to dunder or method form:

```python
_DUNDER_OPS = {"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<="}
_METHOD_OPS = {
    "array-contains": "array_contains",
    "array-contains-any": "array_contains_any",
    "in": "is_in",
    "not-in": "not_in",
}

def __repr__(self) -> str:
    owner = f"{self._owner.__name__}." if self._owner else ""
    field = self._field_name_python
    if self.op in _DUNDER_OPS:
        return f"{owner}{field} {_DUNDER_OPS[self.op]} {self.value!r}"
    if self.op in _METHOD_OPS:
        return f"{owner}{field}.{_METHOD_OPS[self.op]}({self.value!r})"
    return f'FieldFilter("{self.field_name}", "{self.op}", {self.value!r})'
```

- [ ] **Step 3: Write failing tests for Query repr**

```python
# in tests/test_query_object.py

def test_query_repr_empty(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)
    q = ctx.select(City)
    assert repr(q) == "Query(City)"


def test_query_repr_with_filter(mock_firestore_client: MagicMock):
    where_mock = MagicMock()
    mock_firestore_client.collection.return_value.where.return_value = where_mock
    ctx = Cendry(client=mock_firestore_client)
    q = ctx.select(City).filter(City.state == "CA")
    assert "City.state == 'CA'" in repr(q)
```

- [ ] **Step 4: Implement Query.__repr__**

`Query` needs to track its filters, order_by, and limit for repr. Add internal tracking:

```python
class Query[T: Model]:
    def __init__(self, firestore_query, model_class, apply_filter, *,
                 _filters=None, _order_by=None, _limit=None):
        self._query = firestore_query
        self._model_class = model_class
        self._apply_filter = apply_filter
        self._filters_repr = _filters or []
        self._order_by_repr = _order_by or []
        self._limit_repr = _limit

    def __repr__(self) -> str:
        parts = [self._model_class.__name__]
        parts.extend(repr(f) for f in self._filters_repr)
        if self._order_by_repr:
            parts.append(f"order_by=[{', '.join(repr(o) for o in self._order_by_repr)}]")
        if self._limit_repr is not None:
            parts.append(f"limit={self._limit_repr}")
        return f"Query({', '.join(parts)})"
```

Update `filter()`, `order_by()`, `limit()` to propagate repr state.

Same for `AsyncQuery` with `AsyncQuery(...)`.

- [ ] **Step 5: Run all tests, ruff, commit**

```bash
uv run pytest -v && uv run ruff check src/ tests/
git commit -am "feat: copy-pasteable repr for FieldFilterResult, Asc/Desc, Query"
```

---

### Task 8: Pagination

**Files:**
- Modify: `src/cendry/query.py`
- Modify: `tests/test_query_object.py`

- [ ] **Step 1: Write failing tests**

```python
# append to tests/test_query_object.py

def test_query_paginate(mock_firestore_client: MagicMock):
    page1_docs = [make_mock_document("1", SF_DATA), make_mock_document("2", SF_DATA)]
    page2_docs = [make_mock_document("3", SF_DATA)]  # partial page = last

    call_count = 0
    def mock_limit(n):
        nonlocal call_count
        call_count += 1
        mock = MagicMock()
        if call_count == 1:
            mock.stream.return_value = iter(page1_docs)
        else:
            mock.stream.return_value = iter(page2_docs)
        mock.start_after.return_value = mock
        mock.limit.return_value = mock
        return mock

    mock_firestore_client.collection.return_value.limit = mock_limit
    ctx = Cendry(client=mock_firestore_client)
    pages = list(ctx.select(City).paginate(page_size=2))
    assert len(pages) == 2
    assert len(pages[0]) == 2
    assert len(pages[1]) == 1
```

- [ ] **Step 2: Implement paginate on Query**

```python
def paginate(self, page_size: int) -> Iterator[list[T]]:
    query = self._query
    while True:
        page_query = Query(query.limit(page_size), self._model_class, self._apply_filter)
        page = page_query.to_list()
        if not page:
            break
        yield page
        if len(page) < page_size:
            break
        # Use last document as cursor — need the raw doc snapshot
        # This requires storing raw docs alongside deserialized models
        # Implementation detail: iterate raw docs + deserialize, keep last raw doc
        last_doc = None
        for doc in query.limit(page_size).stream():
            last_doc = doc
        if last_doc is None:
            break
        query = query.start_after(last_doc)
```

Note: The above has a double-fetch issue. Better approach — iterate once, collect both raw docs and models:

```python
def paginate(self, page_size: int) -> Iterator[list[T]]:
    query = self._query
    while True:
        items: list[T] = []
        last_doc = None
        for doc in query.limit(page_size).stream():
            last_doc = doc
            items.append(deserialize(self._model_class, doc.id, doc.to_dict()))
        if not items:
            break
        yield items
        if len(items) < page_size:
            break
        query = query.start_after(last_doc)
```

Same for `AsyncQuery` with `async for`.

- [ ] **Step 3: Run tests, commit**

```bash
uv run pytest tests/test_query_object.py -v -k paginate
git commit -am "feat: add paginate to Query/AsyncQuery"
```

---

### Task 9: Public API exports + README

**Files:**
- Modify: `src/cendry/__init__.py`
- Modify: `tests/test_public_api.py`
- Modify: `README.md`

- [ ] **Step 1: Update __init__.py**

Add `to_dict` to imports and `__all__`.

- [ ] **Step 2: Update test_public_api.py**

Add `"to_dict"` to expected list.

- [ ] **Step 3: Update README.md**

Add sections for:
- `.asc()` / `.desc()` on field descriptors
- Chainable `.order_by()` / `.limit()` on Query
- `to_dict()` usage
- `get_many()` usage
- Field aliases
- Enum support
- Pagination
- Copy-pasteable repr examples

- [ ] **Step 4: Run full verification**

```bash
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/cendry/ tests/
uv run ty check src/cendry/ tests/
uv run coverage run -m pytest && uv run coverage report --show-missing
```

- [ ] **Step 5: Commit**

```bash
git commit -am "feat: export to_dict, update README with DX v2 features"
```
