# DX Improvements 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 independent DX improvements: filter repr, dunder shortcuts, context manager, FieldDescriptor export, Query object, from_dict, and better errors.

**Architecture:** Each improvement is self-contained. The largest change is the Query object (replaces bare iterators from `select()`). Deserialization logic is extracted from `_BaseCendry` into `serialize.py` for reuse by `from_dict`. All existing tests must continue to pass after each task.

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

---

## File Map

| File | Responsibility |
|------|---------------|
| `src/cendry/model.py` | `FieldFilterResult.__repr__`, `FieldDescriptor` dunders |
| `src/cendry/filters.py` | `And.__repr__`, `Or.__repr__` |
| `src/cendry/serialize.py` | **New** — `deserialize`, `deserialize_map`, `resolve_map_type`, `from_dict` |
| `src/cendry/query.py` | `Query[T]`, `AsyncQuery[T]` added alongside `Asc`/`Desc` |
| `src/cendry/context.py` | Context manager, use `serialize.py`, return `Query`/`AsyncQuery` |
| `src/cendry/__init__.py` | Export new public API symbols |
| `README.md` | Updated usage examples |
| `tests/test_filters.py` | Repr tests |
| `tests/test_model.py` | Dunder shortcut tests |
| `tests/test_context.py` | Context manager tests, updated for Query return type |
| `tests/test_serialize.py` | **New** — `from_dict` tests |
| `tests/test_query_object.py` | **New** — `Query`/`AsyncQuery` tests |

---

### Task 1: Filter repr

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

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

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

def test_field_filter_result_repr():
    from cendry.model import FieldFilterResult

    f = FieldFilterResult("state", "==", "CA")
    assert repr(f) == 'FieldFilter("state", "==", "CA")'


def test_field_filter_result_repr_int():
    from cendry.model import FieldFilterResult

    f = FieldFilterResult("population", ">", 1000000)
    assert repr(f) == 'FieldFilter("population", ">", 1000000)'


def test_and_repr():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("pop", ">", 100)
    result = And(f1, f2)
    assert "And(" in repr(result)


def test_or_repr():
    f1 = FieldFilter("state", "==", "CA")
    f2 = FieldFilter("state", "==", "NY")
    result = Or(f1, f2)
    assert "Or(" in repr(result)
```

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

In `src/cendry/model.py`, add to `FieldFilterResult`:

```python
def __repr__(self) -> str:
    return f'FieldFilter("{self.field_name}", "{self.op}", {self.value!r})'
```

In `src/cendry/filters.py`, add to `And`:

```python
def __repr__(self) -> str:
    return f"And({', '.join(repr(f) for f in self.filters)})"
```

Same for `Or`.

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

```bash
uv run pytest tests/test_filters.py -v
git commit -am "feat: add __repr__ to FieldFilterResult, And, Or"
```

---

### Task 2: Dunder filter shortcuts

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

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

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

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

    result = City.state == "CA"
    assert isinstance(result, Filter)
    assert result.op == "=="


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

    result = City.state != "CA"
    assert isinstance(result, Filter)
    assert result.op == "!="


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

    result = City.population > 100
    assert isinstance(result, Filter)
    assert result.op == ">"


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

    result = City.population >= 100
    assert isinstance(result, Filter)
    assert result.op == ">="


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

    result = City.population < 100
    assert isinstance(result, Filter)
    assert result.op == "<"


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

    result = City.population <= 100
    assert isinstance(result, Filter)
    assert result.op == "<="


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

    with pytest.raises(TypeError):
        hash(City.state)
```

- [ ] **Step 2: Implement dunders on FieldDescriptor**

Add to `FieldDescriptor` in `src/cendry/model.py`:

```python
__hash__ = None  # type: ignore[assignment]

def __eq__(self, other: Any) -> FieldFilterResult:  # type: ignore[override]
    return self._make_filter("==", other)

def __ne__(self, other: Any) -> FieldFilterResult:  # type: ignore[override]
    return self._make_filter("!=", other)

def __gt__(self, other: Any) -> FieldFilterResult:
    return self._make_filter(">", other)

def __ge__(self, other: Any) -> FieldFilterResult:
    return self._make_filter(">=", other)

def __lt__(self, other: Any) -> FieldFilterResult:
    return self._make_filter("<", other)

def __le__(self, other: Any) -> FieldFilterResult:
    return self._make_filter("<=", other)
```

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

```bash
uv run pytest tests/test_model.py -v
git commit -am "feat: add dunder filter shortcuts on FieldDescriptor"
```

---

### Task 3: Context manager

**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_cendry_context_manager(mock_firestore_client: MagicMock):
    with Cendry(client=mock_firestore_client) as ctx:
        assert isinstance(ctx, Cendry)
    mock_firestore_client.close.assert_called_once()


@pytest.mark.anyio
async def test_async_cendry_context_manager(mock_firestore_client: MagicMock):
    mock_firestore_client.close = AsyncMock()
    async with AsyncCendry(client=mock_firestore_client) as ctx:
        assert isinstance(ctx, AsyncCendry)
    mock_firestore_client.close.assert_called_once()
```

- [ ] **Step 2: Implement context manager**

Add to `Cendry`:

```python
def __enter__(self) -> "Cendry":
    return self

def __exit__(self, *args: Any) -> None:
    self._client.close()
```

Add to `AsyncCendry`:

```python
async def __aenter__(self) -> "AsyncCendry":
    return self

async def __aexit__(self, *args: Any) -> None:
    await self._client.close()
```

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

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

---

### Task 4: Extract deserialization into serialize.py + from_dict

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

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

```python
# tests/test_serialize.py
import pytest

from cendry import Field, Map, Model, from_dict
from cendry import field as cendry_field


class Mayor(Map):
    name: Field[str]
    since: Field[int]


class City(Model, collection="cities_ser"):
    name: Field[str]
    state: Field[str]
    country: Field[str]
    capital: Field[bool]
    population: Field[int]
    regions: Field[list[str]]
    nickname: Field[str | None] = cendry_field(default=None)
    mayor: Field[Mayor | None] = cendry_field(default=None)


def test_from_dict_simple():
    city = from_dict(City, {
        "name": "SF", "state": "CA", "country": "USA",
        "capital": False, "population": 870_000, "regions": ["west"],
    })
    assert city.name == "SF"
    assert city.id is None


def test_from_dict_with_id():
    city = from_dict(City, {
        "name": "SF", "state": "CA", "country": "USA",
        "capital": False, "population": 870_000, "regions": ["west"],
    }, id="123")
    assert city.id == "123"


def test_from_dict_nested_map():
    city = from_dict(City, {
        "name": "SF", "state": "CA", "country": "USA",
        "capital": False, "population": 870_000, "regions": ["west"],
        "mayor": {"name": "London Breed", "since": 2018},
    })
    assert isinstance(city.mayor, Mayor)
    assert city.mayor.name == "London Breed"


def test_from_dict_missing_fields():
    with pytest.raises(TypeError, match="missing required fields"):
        from_dict(City, {"name": "SF"})
```

- [ ] **Step 2: Create serialize.py**

Extract `_deserialize`, `_deserialize_map`, `_resolve_map_type` from `_BaseCendry` into standalone functions in `src/cendry/serialize.py`. Add `from_dict` that calls `deserialize` with missing-field validation.

```python
# src/cendry/serialize.py
import dataclasses
import types
from typing import Any, TypeVar, get_args, get_type_hints

from .model import Map, Model

T = TypeVar("T", bound=Model)


def resolve_map_type(hint: Any) -> type | None:
    """Resolve a type hint to a concrete Map subclass if applicable."""
    if hint is None or isinstance(hint, str):
        return None
    if isinstance(hint, types.UnionType):
        non_none = [a for a in get_args(hint) if a is not type(None)]
        if len(non_none) == 1:
            hint = non_none[0]
    if isinstance(hint, type) and issubclass(hint, Map):
        return hint
    return None


def deserialize_map(map_class: type, data: dict[str, Any]) -> Any:
    """Recursively deserialize a Map from a dict."""
    hints = get_type_hints(map_class, include_extras=True)
    converted: dict[str, Any] = {}
    for f in dataclasses.fields(map_class):
        value = data.get(f.name)
        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 map_class(**converted)


def deserialize(model_class: type[T], doc_id: str | None, data: dict[str, Any]) -> T:
    """Convert a dict to a model instance with nested Map deserialization."""
    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
        value = data.get(f.name)
        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)


def from_dict(model_class: type[T], data: dict[str, Any], *, id: str | None = None) -> T:
    """Construct a model instance from a dict.

    Raises TypeError if required fields are missing.
    """
    required = []
    for f in dataclasses.fields(model_class):
        if f.name == "id":
            continue
        if (
            f.default is dataclasses.MISSING
            and f.default_factory is dataclasses.MISSING
            and f.name not in data
        ):
            required.append(f.name)
    if required:
        missing = ", ".join(required)
        raise TypeError(
            f"from_dict({model_class.__name__}): missing required fields: {missing}"
        )
    return deserialize(model_class, id, data)
```

- [ ] **Step 3: Update _BaseCendry to use serialize.py**

Replace `_deserialize`, `_deserialize_map`, `_resolve_map_type` in `context.py` with calls to `serialize.deserialize`. The `_BaseCendry` class becomes:

```python
def _deserialize(self, model_class: type[T], doc_id: str, data: dict[str, Any]) -> T:
    from .serialize import deserialize
    return deserialize(model_class, doc_id, data)
```

Remove `_resolve_map_type` and `_deserialize_map` from `_BaseCendry`. Remove the now-unused imports (`types`, `get_args`, `get_type_hints`).

- [ ] **Step 4: Run full test suite**

```bash
uv run pytest -v
```

All existing context tests must still pass (same behavior, different code path).

- [ ] **Step 5: Commit**

```bash
git commit -am "feat: extract deserialization into serialize.py, add from_dict"
```

---

### Task 5: Query and AsyncQuery

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

This is the largest task. `Query[T]` wraps a Firestore query + model class + the `_apply_filter` logic from `_BaseCendry`.

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

```python
# tests/test_query_object.py
from unittest.mock import AsyncMock, MagicMock

import pytest

from cendry import AsyncCendry, Cendry, CendryError, DocumentNotFoundError, Field, Model
from cendry import field as cendry_field
from tests.conftest import City, Neighborhood, make_mock_document

SF_DATA = {
    "name": "San Francisco", "state": "CA", "country": "USA",
    "capital": False, "population": 870_000, "regions": ["west_coast"],
}


def _mock_stream(docs):
    """Create a mock that returns docs from .stream()."""
    mock = MagicMock()
    mock.stream.return_value = iter(docs)
    return mock


def test_select_returns_query(mock_firestore_client: MagicMock):
    from cendry.query import Query

    mock_firestore_client.collection.return_value.stream.return_value = iter([])
    ctx = Cendry(client=mock_firestore_client)
    query = ctx.select(City)
    assert isinstance(query, Query)


def test_query_iter(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    mock_firestore_client.collection.return_value.stream.return_value = iter(docs)
    ctx = Cendry(client=mock_firestore_client)
    cities = list(ctx.select(City))
    assert len(cities) == 1
    assert cities[0].name == "San Francisco"


def test_query_to_list(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    mock_firestore_client.collection.return_value.stream.return_value = iter(docs)
    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.select(City).to_list()
    assert len(cities) == 1


def test_query_first(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    limit_mock = _mock_stream(docs)
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    city = ctx.select(City).first()
    assert city is not None
    assert city.name == "San Francisco"


def test_query_first_empty(mock_firestore_client: MagicMock):
    limit_mock = _mock_stream([])
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    assert ctx.select(City).first() is None


def test_query_one(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    limit_mock = _mock_stream(docs)
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    city = ctx.select(City).one()
    assert city.name == "San Francisco"


def test_query_one_empty(mock_firestore_client: MagicMock):
    limit_mock = _mock_stream([])
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        ctx.select(City).one()


def test_query_one_multiple(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA), make_mock_document("LA", SF_DATA)]
    limit_mock = _mock_stream(docs)
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="more than one"):
        ctx.select(City).one()


def test_query_exists_true(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    limit_mock = _mock_stream(docs)
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    assert ctx.select(City).exists() is True


def test_query_exists_false(mock_firestore_client: MagicMock):
    limit_mock = _mock_stream([])
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = Cendry(client=mock_firestore_client)
    assert ctx.select(City).exists() is False


def test_query_count(mock_firestore_client: MagicMock):
    count_mock = MagicMock()
    count_mock.get.return_value = [[MagicMock(value=42)]]
    mock_firestore_client.collection.return_value.count.return_value = count_mock
    ctx = Cendry(client=mock_firestore_client)
    assert ctx.select(City).count() == 42


def test_query_filter(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    where_mock = _mock_stream(docs)
    mock_firestore_client.collection.return_value.where.return_value = where_mock
    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.select(City).filter(City.state.eq("CA")).to_list()
    assert len(cities) == 1


def test_query_filter_list(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]
    where_mock = MagicMock()
    where_mock.where.return_value = _mock_stream(docs)
    mock_firestore_client.collection.return_value.where.return_value = where_mock
    ctx = Cendry(client=mock_firestore_client)
    cities = ctx.select(City).filter([City.state.eq("CA"), City.population.gt(100)]).to_list()
    assert len(cities) == 1


def test_query_filter_immutable(mock_firestore_client: MagicMock):
    mock_firestore_client.collection.return_value.stream.return_value = iter([])
    where_mock = MagicMock()
    where_mock.stream.return_value = iter([])
    mock_firestore_client.collection.return_value.where.return_value = where_mock
    ctx = Cendry(client=mock_firestore_client)
    q1 = ctx.select(City)
    q2 = q1.filter(City.state.eq("CA"))
    assert q1 is not q2


# --- Async ---


@pytest.mark.anyio
async def test_async_query_to_list(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]

    async def mock_stream():
        for d in docs:
            yield d

    mock_firestore_client.collection.return_value.stream = mock_stream
    ctx = AsyncCendry(client=mock_firestore_client)
    cities = await ctx.select(City).to_list()
    assert len(cities) == 1


@pytest.mark.anyio
async def test_async_query_first(mock_firestore_client: MagicMock):
    docs = [make_mock_document("SF", SF_DATA)]

    async def mock_stream():
        for d in docs:
            yield d

    limit_mock = MagicMock()
    limit_mock.stream = mock_stream
    mock_firestore_client.collection.return_value.limit.return_value = limit_mock
    ctx = AsyncCendry(client=mock_firestore_client)
    city = await ctx.select(City).first()
    assert city is not None
```

- [ ] **Step 2: Implement Query and AsyncQuery**

Add to `src/cendry/query.py`:

```python
from collections.abc import AsyncIterator, Iterator
from typing import Any, TypeVar

from .exceptions import CendryError, DocumentNotFoundError
from .serialize import deserialize

T = TypeVar("T")


class Query[T]:
    """Synchronous query result with convenience methods."""

    def __init__(self, firestore_query: Any, model_class: type[T], apply_filter: Any) -> None:
        self._query = firestore_query
        self._model_class = model_class
        self._apply_filter = apply_filter

    def __iter__(self) -> Iterator[T]:
        for doc in self._query.stream():
            yield deserialize(self._model_class, doc.id, doc.to_dict())

    def filter(self, *filters: Any) -> "Query[T]":
        query = self._query
        for f in filters:
            if isinstance(f, list):
                for sub in f:
                    query = self._apply_filter(query, sub)
            else:
                query = self._apply_filter(query, f)
        return Query(query, self._model_class, self._apply_filter)

    def to_list(self) -> list[T]:
        return list(self)

    def first(self) -> T | None:
        for item in Query(self._query.limit(1), self._model_class, self._apply_filter):
            return item
        return None

    def one(self) -> T:
        items = Query(self._query.limit(2), self._model_class, self._apply_filter).to_list()
        if not items:
            raise DocumentNotFoundError(self._model_class.__collection__, "<query>")
        if len(items) > 1:
            raise CendryError(
                f"Expected exactly one {self._model_class.__name__}, got more than one"
            )
        return items[0]

    def exists(self) -> bool:
        return self.first() is not None

    def count(self) -> int:
        result = self._query.count().get()
        return result[0][0].value


class AsyncQuery[T]:
    """Asynchronous query result with convenience methods."""

    def __init__(self, firestore_query: Any, model_class: type[T], apply_filter: Any) -> None:
        self._query = firestore_query
        self._model_class = model_class
        self._apply_filter = apply_filter

    async def __aiter__(self) -> AsyncIterator[T]:
        async for doc in self._query.stream():
            yield deserialize(self._model_class, doc.id, doc.to_dict())

    def filter(self, *filters: Any) -> "AsyncQuery[T]":
        query = self._query
        for f in filters:
            if isinstance(f, list):
                for sub in f:
                    query = self._apply_filter(query, sub)
            else:
                query = self._apply_filter(query, f)
        return AsyncQuery(query, self._model_class, self._apply_filter)

    async def to_list(self) -> list[T]:
        return [item async for item in self]

    async def first(self) -> T | None:
        async for item in AsyncQuery(
            self._query.limit(1), self._model_class, self._apply_filter
        ):
            return item
        return None

    async def one(self) -> T:
        items = await AsyncQuery(
            self._query.limit(2), self._model_class, self._apply_filter
        ).to_list()
        if not items:
            raise DocumentNotFoundError(self._model_class.__collection__, "<query>")
        if len(items) > 1:
            raise CendryError(
                f"Expected exactly one {self._model_class.__name__}, got more than one"
            )
        return items[0]

    async def exists(self) -> bool:
        return (await self.first()) is not None

    async def count(self) -> int:
        result = await self._query.count().get()
        return result[0][0].value
```

- [ ] **Step 3: Update context.py select/select_group to return Query/AsyncQuery**

`select()` and `select_group()` return `Query`/`AsyncQuery` instead of yielding directly:

```python
def select(self, model_class, *filters, **kwargs) -> Query[T]:
    query = self._build_query(model_class, filters, **kwargs)
    return Query(query, model_class, self._apply_filter)

def select_group(self, model_class, *filters, **kwargs) -> Query[T]:
    query = self._build_query(model_class, filters, collection_group=True, **kwargs)
    return Query(query, model_class, self._apply_filter)
```

**Important:** `AsyncCendry.select()` and `AsyncCendry.select_group()` must be changed from `async def` (generator with `yield`) to regular `def` methods that return `AsyncQuery`. Remove the `async` keyword — they no longer need it since they just build a query and return the wrapper.

- [ ] **Step 4: Run full test suite**

```bash
uv run pytest -v
```

Existing tests that do `list(ctx.select(...))` and `for city in ctx.select(...)` should still work since `Query` implements `__iter__`.

- [ ] **Step 5: Commit**

```bash
git commit -am "feat: add Query and AsyncQuery with filter, first, one, exists, count, to_list"
```

---

### Task 6: Better error messages

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

- [ ] **Step 1: Update error message in __init_subclass__**

Change `Model.__init_subclass__` error from:

```python
f"Model '{cls.__name__}' must specify collection: class {cls.__name__}(Model, collection='...')"
```

to:

```python
f"class {cls.__name__}(Model) requires a collection name.\n  Example: class {cls.__name__}(Model, collection=\"{cls.__name__.lower()}s\")"
```

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

Update `test_model_requires_collection` to match the new message. Also verify the `from_dict` missing fields error message (already tested in Task 4).

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

```bash
uv run pytest tests/test_model.py -v
git commit -am "feat: improve error messages for missing collection and from_dict"
```

---

### Task 7: 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 imports and `__all__`:
- `FieldDescriptor` from `.model`
- `from_dict` from `.serialize`
- `Query`, `AsyncQuery` from `.query`

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

Add `"AsyncQuery"`, `"FieldDescriptor"`, `"Query"`, `"from_dict"` to the expected list.

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

Add sections for:
- Dunder filter shortcuts (`City.state == "CA"`)
- Context manager (`with Cendry() as ctx:`)
- Query methods (`first()`, `one()`, `count()`, `exists()`, `to_list()`, `filter()`)
- `from_dict()` usage
- `register_type()` usage

- [ ] **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 new public API, update README with DX improvements"
```
