# Partial Updates 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:** Add `update` (partial field updates), `refresh` (re-fetch in-place), and re-export Firestore sentinels/transforms.

**Architecture:** `serialize_update_value` and `resolve_field_path` live in `serialize.py` as standalone functions. `update` and `refresh` are methods on `Cendry`/`AsyncCendry`. Firestore sentinels/transforms re-exported from `cendry.__init__`.

**Tech Stack:** Python 3.13+, google-cloud-firestore, pytest, pytest-bdd, anyio

**Spec:** `docs/superpowers/specs/2026-03-22-partial-updates-design.md`

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `src/cendry/serialize.py` | Modify | Add `_is_firestore_transform`, `serialize_update_value`, `resolve_field_path` |
| `src/cendry/context.py` | Modify | Add `update`, `refresh` to `Cendry` and `AsyncCendry` |
| `src/cendry/__init__.py` | Modify | Re-export sentinels and transforms |
| `tests/test_serialize.py` | Modify | Tests for new serialize functions |
| `tests/test_context.py` | Modify | Tests for `update` and `refresh` |
| `tests/test_public_api.py` | Modify | Add sentinels/transforms to expected exports |
| `tests/features/write_operations.feature` | Modify | BDD scenarios for update/refresh |
| `tests/test_bdd_write_operations.py` | Modify | BDD step definitions |

---

### Task 1: Serialize helpers (`_is_firestore_transform`, `serialize_update_value`, `resolve_field_path`)

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

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

Add to `tests/test_serialize.py`:

```python
from cendry.serialize import resolve_field_path, serialize_update_value
from cendry import field as cendry_field


class AliasedCity(Model, collection="aliased_cities"):
    name: Field[str]
    city_name: Field[str] = cendry_field(alias="cityName")


def test_serialize_update_value_simple():
    """Simple values pass through unchanged."""
    custom = TypeRegistry()
    assert serialize_update_value("hello", registry=custom) == "hello"
    assert serialize_update_value(42, registry=custom) == 42
    assert serialize_update_value(True, registry=custom) is True


def test_serialize_update_value_with_handler():
    """Custom-typed values are serialized via handler."""
    custom = TypeRegistry()
    custom.register(Celsius, handler=CelsiusHandler())
    result = serialize_update_value(Celsius(20.5), registry=custom)
    assert result == 20.5


def test_serialize_update_value_sentinel():
    """Firestore sentinels pass through unchanged."""
    from google.cloud.firestore import DELETE_FIELD, SERVER_TIMESTAMP

    custom = TypeRegistry()
    assert serialize_update_value(DELETE_FIELD, registry=custom) is DELETE_FIELD
    assert serialize_update_value(SERVER_TIMESTAMP, registry=custom) is SERVER_TIMESTAMP


def test_serialize_update_value_transform():
    """Firestore transforms pass through unchanged."""
    from google.cloud.firestore import ArrayUnion, Increment

    custom = TypeRegistry()
    inc = Increment(1)
    assert serialize_update_value(inc, registry=custom) is inc
    au = ArrayUnion(["tag"])
    assert serialize_update_value(au, registry=custom) is au


def test_resolve_field_path_simple():
    """Simple field names pass through."""
    assert resolve_field_path(AliasedCity, "name") == "name"


def test_resolve_field_path_alias():
    """Python field names are converted to Firestore aliases."""
    assert resolve_field_path(AliasedCity, "city_name") == "cityName"


def test_resolve_field_path_dot_notation():
    """First segment of dot-notation path is alias-resolved."""
    assert resolve_field_path(AliasedCity, "city_name.sub") == "cityName.sub"


def test_resolve_field_path_unknown():
    """Unknown field names pass through unchanged."""
    assert resolve_field_path(AliasedCity, "unknown_field") == "unknown_field"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_serialize.py::test_serialize_update_value_simple -v`
Expected: FAIL with `ImportError` (function doesn't exist yet)

- [ ] **Step 3: Implement the functions in `serialize.py`**

Add imports at the top of `src/cendry/serialize.py`:

```python
from google.cloud.firestore_v1.transforms import Sentinel, _NumericValue, _ValueList
```

Add at the end of `src/cendry/serialize.py` (before `to_dict`):

```python
def _is_firestore_transform(value: Any) -> bool:
    """Check if a value is a Firestore sentinel or transform."""
    return isinstance(value, (Sentinel, _NumericValue, _ValueList))


def serialize_update_value(value: Any, *, registry: TypeRegistry | None = None) -> Any:
    """Serialize a value for Firestore update, passing sentinels/transforms through.

    Args:
        value: The value to serialize.
        registry: Optional TypeRegistry. Uses default if not provided.

    Returns:
        The serialized value, or the original if it's a sentinel/transform.
    """
    if _is_firestore_transform(value):
        return value
    registry = registry or default_registry
    return _serialize_value(value, type(value), by_alias=True, registry=registry)


def resolve_field_path(model_class: type[Model], path: str) -> str:
    """Resolve a field path, converting Python names to Firestore aliases.

    For dot-notation paths, only the first segment is resolved.
    Unknown field names pass through unchanged.

    Args:
        model_class: The Model class to look up aliases on.
        path: Field path (e.g. "name", "city_name", "mayor.name").

    Returns:
        The resolved path with aliases applied.
    """
    segments = path.split(".", 1)
    first = segments[0]
    for f in dataclasses.fields(model_class):
        if f.name == first:
            alias = _get_alias(f)
            if len(segments) > 1:
                return f"{alias}.{segments[1]}"
            return alias
    return path
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_serialize.py -v`
Expected: All pass

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

Run: `uv run pytest -q`
Expected: All pass

- [ ] **Step 6: Commit**

```bash
git add src/cendry/serialize.py tests/test_serialize.py
git commit -m "feat: add serialize_update_value and resolve_field_path"
```

---

### Task 2: Re-export sentinels and transforms

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

- [ ] **Step 1: Write the failing test**

Update `tests/test_public_api.py` — add these to the `expected` list (in alphabetical order):

```python
"ArrayRemove",
"ArrayUnion",
"DELETE_FIELD",
"Increment",
"Maximum",
"Minimum",
"SERVER_TIMESTAMP",
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_public_api.py::test_public_api_exports -v`
Expected: FAIL with assertion error (missing exports)

- [ ] **Step 3: Add exports to `__init__.py`**

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

```python
from google.cloud.firestore import (
    DELETE_FIELD,
    SERVER_TIMESTAMP,
    ArrayRemove,
    ArrayUnion,
    Increment,
    Maximum,
    Minimum,
)
```

Add to `__all__` (in alphabetical position):
- `"ArrayRemove"` (after `"And"`)
- `"ArrayUnion"` (after `"ArrayRemove"`)
- `"DELETE_FIELD"` (after `"CendryError"`)
- `"Increment"` (after `"FieldFilter"`)
- `"Maximum"` (after `"Map"`)
- `"Minimum"` (after `"Maximum"`)
- `"SERVER_TIMESTAMP"` (after `"Query"`)

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_public_api.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/cendry/__init__.py tests/test_public_api.py
git commit -m "feat: re-export Firestore sentinels and transforms"
```

---

### Task 3: Sync `update` method

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

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

Add to `tests/test_context.py`. First add import at top:

```python
from google.api_core.exceptions import NotFound
```

Then add tests:

```python
# --- update ---


def test_update_by_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    ctx.update(city, {"name": "New Name", "population": 1_000_000})

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.update.assert_called_once()
    call_data = doc_ref.return_value.update.call_args[0][0]
    assert call_data["name"] == "New Name"
    assert call_data["population"] == 1_000_000


def test_update_by_class_and_id(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)

    ctx.update(City, "SF", {"name": "New Name"})

    doc_ref = mock_firestore_client.collection.return_value.document
    doc_ref.assert_called_with("SF")
    doc_ref.return_value.update.assert_called_once()


def test_update_dot_notation(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    ctx.update(city, {"mayor.name": "Jane"})

    call_data = (
        mock_firestore_client.collection.return_value.document.return_value.update.call_args[0][0]
    )
    assert "mayor.name" in call_data


def test_update_alias_resolution(mock_firestore_client: MagicMock):
    from cendry import field as cendry_field

    class AliasCity(Model, collection="alias_cities"):
        display_name: Field[str] = cendry_field(alias="displayName")

    city = AliasCity(display_name="SF", id="SF")
    ctx = Cendry(client=mock_firestore_client)

    ctx.update(city, {"display_name": "San Francisco"})

    call_data = (
        mock_firestore_client.collection.return_value.document.return_value.update.call_args[0][0]
    )
    assert "displayName" in call_data
    assert "display_name" not in call_data


def test_update_with_sentinel(mock_firestore_client: MagicMock):
    from google.cloud.firestore import DELETE_FIELD, SERVER_TIMESTAMP

    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)

    ctx.update(city, {"nickname": DELETE_FIELD, "name": SERVER_TIMESTAMP})

    call_data = (
        mock_firestore_client.collection.return_value.document.return_value.update.call_args[0][0]
    )
    assert call_data["nickname"] is DELETE_FIELD
    assert call_data["name"] is SERVER_TIMESTAMP


def test_update_with_transform(mock_firestore_client: MagicMock):
    from google.cloud.firestore import Increment

    city = City(**SF_DATA, id="SF")
    ctx = Cendry(client=mock_firestore_client)
    inc = Increment(1)

    ctx.update(city, {"population": inc})

    call_data = (
        mock_firestore_client.collection.return_value.document.return_value.update.call_args[0][0]
    )
    assert call_data["population"] is inc


def test_update_instance_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    ctx = Cendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot update a model instance with id=None"):
        ctx.update(city, {"name": "New"})


def test_update_missing_doc_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.update.side_effect = (
        NotFound("not found")
    )

    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError) as exc_info:
        ctx.update(city, {"name": "New"})

    assert isinstance(exc_info.value.__cause__, NotFound)


def test_update_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value

    ctx = Cendry(client=mock_firestore_client)
    ctx.update(Neighborhood, "MISSION", {"population": 65_000}, parent=parent)

    sub_doc_ref.update.assert_called_once()


def test_update_with_custom_type_handler(mock_firestore_client: MagicMock):
    custom = TypeRegistry()
    custom.register(Celsius, handler=CelsiusHandler())
    weather = Weather(city="SF", temp=Celsius(20.5), id="w1")

    ctx = Cendry(client=mock_firestore_client, type_registry=custom)
    ctx.update(weather, {"temp": Celsius(25.0)})

    call_data = (
        mock_firestore_client.collection.return_value.document.return_value.update.call_args[0][0]
    )
    assert call_data["temp"] == 25.0
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py::test_update_by_instance -v`
Expected: FAIL with `AttributeError` (`Cendry` has no `update`)

- [ ] **Step 3: Implement `Cendry.update`**

Add import to `src/cendry/context.py`:

```python
from google.api_core.exceptions import NotFound
```

Add import from serialize:

```python
from .serialize import deserialize, resolve_field_path, serialize_update_value, to_dict
```

Add to `Cendry` class (after `delete`):

```python
@overload
def update(
    self, instance: Model, field_updates: dict[str, Any], *, parent: Model | None = None
) -> None: ...
@overload
def update(
    self,
    model_class: type[T],
    doc_id: str,
    field_updates: dict[str, Any],
    *,
    parent: Model | None = None,
) -> None: ...

def update(  # type: ignore[misc]
    self,
    instance_or_class: Model | type[T],
    field_updates_or_doc_id: dict[str, Any] | str,
    field_updates_or_none: dict[str, Any] | None = None,
    *,
    parent: Model | None = None,
) -> None:
    """Partially update a document's fields.

    Args:
        instance_or_class: A Model instance, or a Model class.
        field_updates_or_doc_id: Field updates dict (instance form) or doc ID (class form).
        field_updates_or_none: Field updates dict (class form only).
        parent: Parent document for subcollection updates.

    Raises:
        DocumentNotFoundError: If the document does not exist.
    """
    if isinstance(instance_or_class, Model):
        if instance_or_class.id is None:
            raise CendryError("Cannot update a model instance with id=None")
        model_class = type(instance_or_class)
        doc_id = instance_or_class.id
        field_updates = field_updates_or_doc_id
    else:
        model_class = instance_or_class
        doc_id = field_updates_or_doc_id
        field_updates = field_updates_or_none
        assert field_updates is not None

    resolved = {
        resolve_field_path(model_class, k): serialize_update_value(
            v, registry=self.type_registry
        )
        for k, v in field_updates.items()
    }
    col_ref = self._get_collection_ref(model_class, parent)
    try:
        col_ref.document(doc_id).update(resolved)
    except NotFound as e:
        raise DocumentNotFoundError(model_class.__collection__, doc_id) from e
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py -k "test_update" -v`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add Cendry.update() method"
```

---

### Task 4: Sync `refresh` method

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

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

```python
# --- refresh ---


def test_refresh_mutates_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    updated_data = {**SF_DATA, "name": "San Fran", "population": 900_000}
    doc = make_mock_document("SF", updated_data)
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    ctx.refresh(city)

    assert city.name == "San Fran"
    assert city.population == 900_000
    assert city.id == "SF"


def test_refresh_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    ctx = Cendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot refresh a model instance with id=None"):
        ctx.refresh(city)


def test_refresh_missing_doc_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    doc = make_mock_document("SF", {}, exists=False)
    mock_firestore_client.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        ctx.refresh(city)


def test_refresh_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhood = Neighborhood(name="Mission", population=60_000, id="MISSION")
    updated_data = {"name": "Mission District", "population": 65_000}
    doc = make_mock_document("MISSION", updated_data)
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    parent_doc.collection.return_value.document.return_value.get.return_value = doc

    ctx = Cendry(client=mock_firestore_client)
    ctx.refresh(neighborhood, parent=parent)

    assert neighborhood.name == "Mission District"
    assert neighborhood.population == 65_000
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py::test_refresh_mutates_instance -v`
Expected: FAIL with `AttributeError` (`Cendry` has no `refresh`)

- [ ] **Step 3: Implement `Cendry.refresh`**

Add to `Cendry` class (after `update`):

```python
def refresh(self, instance: T, *, parent: Model | None = None) -> None:
    """Re-fetch a document from Firestore and update the instance in-place.

    Args:
        instance: Model instance to refresh.
        parent: Parent document for subcollection queries.

    Raises:
        DocumentNotFoundError: If the document does not exist.
    """
    if instance.id is None:
        raise CendryError("Cannot refresh a model instance with id=None")
    col_ref = self._get_collection_ref(type(instance), parent)
    doc = col_ref.document(instance.id).get()
    if not doc.exists:
        raise DocumentNotFoundError(type(instance).__collection__, instance.id)
    fresh = deserialize(type(instance), doc.id, doc.to_dict(), registry=self.type_registry)
    for f in dataclasses.fields(instance):
        setattr(instance, f.name, getattr(fresh, f.name))
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py -k "test_refresh" -v`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add Cendry.refresh() method"
```

---

### Task 5: Async `update` and `refresh`

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

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

```python
# --- async update ---


@pytest.mark.anyio
async def test_async_update_by_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.update = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.update(city, {"name": "New Name"})

    mock_firestore_client.collection.return_value.document.return_value.update.assert_called_once()


@pytest.mark.anyio
async def test_async_update_by_class_and_id(mock_firestore_client: MagicMock):
    mock_firestore_client.collection.return_value.document.return_value.update = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.update(City, "SF", {"name": "New Name"})

    mock_firestore_client.collection.return_value.document.return_value.update.assert_called_once()


@pytest.mark.anyio
async def test_async_update_instance_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)  # id=None
    ctx = AsyncCendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot update a model instance with id=None"):
        await ctx.update(city, {"name": "New"})


@pytest.mark.anyio
async def test_async_update_missing_doc_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    mock_firestore_client.collection.return_value.document.return_value.update = AsyncMock(
        side_effect=NotFound("not found")
    )

    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError) as exc_info:
        await ctx.update(city, {"name": "New"})

    assert isinstance(exc_info.value.__cause__, NotFound)


@pytest.mark.anyio
async def test_async_update_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    parent_doc = mock_firestore_client.collection.return_value.document.return_value
    sub_doc_ref = parent_doc.collection.return_value.document.return_value
    sub_doc_ref.update = AsyncMock()

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.update(Neighborhood, "MISSION", {"population": 65_000}, parent=parent)

    sub_doc_ref.update.assert_called_once()


# --- async refresh ---


@pytest.mark.anyio
async def test_async_refresh_mutates_instance(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    updated_data = {**SF_DATA, "name": "San Fran", "population": 900_000}
    doc = make_mock_document("SF", updated_data)
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(
        return_value=doc,
    )

    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.refresh(city)

    assert city.name == "San Fran"
    assert city.population == 900_000


@pytest.mark.anyio
async def test_async_refresh_no_id_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA)
    ctx = AsyncCendry(client=mock_firestore_client)

    with pytest.raises(CendryError, match="Cannot refresh a model instance with id=None"):
        await ctx.refresh(city)


@pytest.mark.anyio
async def test_async_refresh_missing_doc_raises(mock_firestore_client: MagicMock):
    city = City(**SF_DATA, id="SF")
    doc = make_mock_document("SF", {}, exists=False)
    mock_firestore_client.collection.return_value.document.return_value.get = AsyncMock(
        return_value=doc,
    )

    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(DocumentNotFoundError):
        await ctx.refresh(city)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_context.py -k "test_async_update or test_async_refresh" -v`
Expected: FAIL with `AttributeError`

- [ ] **Step 3: Implement async methods on `AsyncCendry`**

Add to `AsyncCendry` class (after `delete`):

```python
@overload
async def update(
    self, instance: Model, field_updates: dict[str, Any], *, parent: Model | None = None
) -> None: ...
@overload
async def update(
    self,
    model_class: type[T],
    doc_id: str,
    field_updates: dict[str, Any],
    *,
    parent: Model | None = None,
) -> None: ...

async def update(  # type: ignore[misc]
    self,
    instance_or_class: Model | type[T],
    field_updates_or_doc_id: dict[str, Any] | str,
    field_updates_or_none: dict[str, Any] | None = None,
    *,
    parent: Model | None = None,
) -> None:
    """Partially update a document's fields."""
    if isinstance(instance_or_class, Model):
        if instance_or_class.id is None:
            raise CendryError("Cannot update a model instance with id=None")
        model_class = type(instance_or_class)
        doc_id = instance_or_class.id
        field_updates = field_updates_or_doc_id
    else:
        model_class = instance_or_class
        doc_id = field_updates_or_doc_id
        field_updates = field_updates_or_none
        assert field_updates is not None

    resolved = {
        resolve_field_path(model_class, k): serialize_update_value(
            v, registry=self.type_registry
        )
        for k, v in field_updates.items()
    }
    col_ref = self._get_collection_ref(model_class, parent)
    try:
        await col_ref.document(doc_id).update(resolved)
    except NotFound as e:
        raise DocumentNotFoundError(model_class.__collection__, doc_id) from e

async def refresh(self, instance: T, *, parent: Model | None = None) -> None:
    """Re-fetch a document from Firestore and update the instance in-place."""
    if instance.id is None:
        raise CendryError("Cannot refresh a model instance with id=None")
    col_ref = self._get_collection_ref(type(instance), parent)
    doc = await col_ref.document(instance.id).get()
    if not doc.exists:
        raise DocumentNotFoundError(type(instance).__collection__, instance.id)
    fresh = deserialize(type(instance), doc.id, doc.to_dict(), registry=self.type_registry)
    for f in dataclasses.fields(instance):
        setattr(instance, f.name, getattr(fresh, f.name))
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_context.py -k "test_async_update or test_async_refresh" -v`
Expected: All pass

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

Run: `uv run pytest -q`
Expected: All pass

- [ ] **Step 6: Commit**

```bash
git add src/cendry/context.py tests/test_context.py
git commit -m "feat: add async update() and refresh() to AsyncCendry"
```

---

### Task 6: BDD scenarios

**Files:**
- Modify: `tests/features/write_operations.feature`
- Modify: `tests/test_bdd_write_operations.py`

- [ ] **Step 1: Add BDD scenarios to feature file**

Append to `tests/features/write_operations.feature`:

```gherkin
    Scenario: Update a document by instance
        Given a City instance with id "SF"
        When I update the instance with {"name": "San Fran"}
        Then the document is updated in Firestore

    Scenario: Update a document with id None raises error
        Given a City instance without an id
        When I update the instance with {"name": "San Fran"}
        Then a CendryError is raised with message "Cannot update a model instance with id=None"

    Scenario: Refresh a document by instance
        Given a City instance with id "SF"
        And the document exists in Firestore with updated data
        When I refresh the instance
        Then the instance fields are updated
```

- [ ] **Step 2: Add step definitions**

Add to `tests/test_bdd_write_operations.py`:

```python
@scenario(f"{FEATURES}/write_operations.feature", "Update a document by instance")
def test_bdd_update_by_instance():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Update a document with id None raises error")
def test_bdd_update_no_id():
    pass


@scenario(f"{FEATURES}/write_operations.feature", "Refresh a document by instance")
def test_bdd_refresh():
    pass


@given("the document exists in Firestore with updated data")
def document_with_updated_data(ctx_and_instance):
    _, instance, client = ctx_and_instance
    updated_data = {**SF_DATA, "name": "San Fran", "population": 900_000}
    doc = make_mock_document(instance.id, updated_data)
    client.collection.return_value.document.return_value.get.return_value = doc


@when(
    parsers.parse('I update the instance with {{"name": "{value}"}}'),
    target_fixture="result",
)
def update_instance(ctx_and_instance, value: str):
    ctx, instance, _ = ctx_and_instance
    try:
        ctx.update(instance, {"name": value})
        return None
    except CendryError as e:
        return e


@when("I refresh the instance", target_fixture="result")
def refresh_instance(ctx_and_instance):
    ctx, instance, _ = ctx_and_instance
    ctx.refresh(instance)
    return None


@then("the document is updated in Firestore")
def check_update_called(ctx_and_instance):
    _, _, client = ctx_and_instance
    client.collection.return_value.document.return_value.update.assert_called_once()


@then("the instance fields are updated")
def check_refresh_fields(ctx_and_instance):
    _, instance, _ = ctx_and_instance
    assert instance.name == "San Fran"
    assert instance.population == 900_000
```

- [ ] **Step 3: Run BDD tests**

Run: `uv run pytest tests/test_bdd_write_operations.py -v`
Expected: All pass

- [ ] **Step 4: Commit**

```bash
git add tests/features/write_operations.feature tests/test_bdd_write_operations.py
git commit -m "test: add BDD scenarios for update and refresh"
```

---

### Task 7: Type checking, linting, and final verification

**Files:** All modified files

- [ ] **Step 1: Run ruff**

Run: `uv run ruff check src/ tests/ && uv run ruff format src/ tests/ && uv run ruff format --check src/ tests/`
Expected: Clean. Fix if needed.

- [ ] **Step 2: Run mypy**

Run: `uv run mypy src/cendry/ tests/`
Expected: Clean.

- [ ] **Step 3: Run ty**

Run: `uv run ty check src/cendry/ tests/`
Expected: Clean.

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

Run: `uv run coverage run -m pytest && uv run coverage report --show-missing`
Expected: All pass, 100% coverage.

- [ ] **Step 5: Fix any issues and commit**

```bash
git add -u
git commit -m "fix: resolve type-checking and lint issues for partial updates"
```

(Skip if no fixes needed.)
