# Partial Updates — Design

## Overview

Add `update` (partial field updates) and `refresh` (re-fetch in-place) to Cendry's sync and async contexts.

## `update(instance, field_updates)` / `update(ModelClass, doc_id, field_updates)`

Partial update — only the specified fields are written. Maps to Firestore's `DocumentReference.update()`.

### Calling conventions

```python
# By instance
ctx.update(city, {"name": "New Name", "population": 1_000_000})

# By class + doc_id
ctx.update(City, "SF", {"name": "New Name", "mayor.name": "Jane"})

# With Firestore transforms
from cendry import DELETE_FIELD, SERVER_TIMESTAMP, Increment
ctx.update(city, {"updated_at": SERVER_TIMESTAMP, "views": Increment(1)})

# With parent (subcollection)
ctx.update(neighborhood, {"population": 65_000}, parent=city)

# Async
await ctx.update(city, {"name": "New Name"})
```

### Behavior

- Two overloads via `@overload`, mirroring `delete`'s pattern.
- Returns `None` — side-effecting, does **not** mutate the instance.
- `parent=` parameter for subcollections, same as other write ops.
- Raises `DocumentNotFoundError` if the document doesn't exist (wraps `google.api_core.exceptions.NotFound` with `__cause__`).
- Raises `CendryError` if instance has `id=None`.

### Value serialization

Values in `field_updates` are serialized before passing to Firestore:

- **Sentinel values** (`DELETE_FIELD`, `SERVER_TIMESTAMP`) and **transforms** (`Increment`, `ArrayUnion`, etc.) pass through unchanged.
- **Custom-typed values** are serialized via the type handler registry (e.g. `Celsius` → `float`).
- **Map instances** are serialized recursively via `_map_to_dict`.
- **Simple values** (str, int, bool, etc.) pass through unchanged.

A `serialize_update_value(value, registry)` standalone function in `serialize.py` handles this. It reuses the existing `_serialize_value` with a sentinel short-circuit at the top:

```python
def serialize_update_value(value: Any, *, registry: TypeRegistry) -> Any:
    """Serialize a value for Firestore update, passing sentinels/transforms through."""
    if _is_firestore_transform(value):
        return value
    return _serialize_value(value, type(value), by_alias=True, registry=registry)
```

**Sentinel/transform detection** uses the concrete public types:

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

def _is_firestore_transform(value: Any) -> bool:
    return isinstance(value, (Sentinel, _NumericValue, _ValueList))
```

This handles `DELETE_FIELD`, `SERVER_TIMESTAMP`, `Increment`, `ArrayUnion`, `ArrayRemove`, `Maximum`, `Minimum`. Container types (e.g. `list[Money]`), enums, and Map instances are all handled by `_serialize_value`'s existing recursion logic.

### Alias resolution

Top-level keys in `field_updates` that match a Python field name are converted to Firestore aliases. For dot-notation paths, the first segment is resolved. Keys that don't match any field name pass through unchanged (for raw Firestore paths).

A `resolve_field_path(model_class, path)` standalone function in `serialize.py` handles this by looking up the field's alias via `dataclasses.fields()` metadata.

### Implementation sketch

```python
# Cendry
@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:
    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
```

## `refresh(instance)`

Re-fetch a document from Firestore and mutate the instance in-place.

```python
ctx.refresh(city)          # re-fetches, mutates city in-place
await ctx.refresh(city)    # async variant
```

### Behavior

- Requires `instance.id` is not `None` — raises `CendryError` otherwise.
- Fetches the document via `_get_collection_ref` + `.document(instance.id).get()`.
- Deserializes into a fresh instance via `deserialize()`.
- Copies all field values from the fresh instance back onto the original via `dataclasses.fields()`.
- `parent=` parameter for subcollections.
- Raises `DocumentNotFoundError` if the document no longer exists.
- Returns `None` — the mutation is the side effect.

### Implementation sketch

```python
def refresh(self, instance: T, *, parent: Model | None = None) -> None:
    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))
```

## Re-exported Sentinels and Transforms

Re-export from `cendry.__init__`:

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

All added to `__all__`.

## Error Handling

- `update` on missing document: wraps `google.api_core.exceptions.NotFound` → `DocumentNotFoundError` with `__cause__`.
- `update`/`refresh` with `id=None`: raises `CendryError`.
- Import: `from google.api_core.exceptions import NotFound` in `context.py`.

## Testing

| Test case | What it verifies |
|-----------|-----------------|
| `update` by instance with simple fields | Correct dict passed to Firestore `.update()` |
| `update` by class + doc_id | Two-arg calling convention works |
| `update` with dot-notation nested path | Path passes through to Firestore |
| `update` with alias resolution | Python field names converted to Firestore aliases |
| `update` with custom type handler | Value serialized via handler |
| `update` with sentinel values | `DELETE_FIELD`, `SERVER_TIMESTAMP` pass through |
| `update` with transforms | `Increment`, `ArrayUnion` pass through |
| `update` instance with `id=None` raises | `CendryError` |
| `update` on missing doc raises | `DocumentNotFoundError` (wraps `NotFound`) |
| `update` with parent | Subcollection support |
| `refresh` by instance | Instance fields mutated in-place |
| `refresh` instance with `id=None` raises | `CendryError` |
| `refresh` on missing doc raises | `DocumentNotFoundError` |
| `refresh` with parent | Subcollection support |
| Async variants | Mirror all sync tests |
| Public API exports | Sentinel/transform re-exports in `__all__` |

## Files Changed

- `src/cendry/serialize.py` — add `serialize_update_value`, `resolve_field_path`, `_is_firestore_transform`
- `src/cendry/context.py` — add `update`, `refresh`
- `src/cendry/__init__.py` — re-export sentinels and transforms
- `tests/test_context.py` — new tests for update and refresh
- `tests/test_public_api.py` — add sentinels/transforms to expected exports
- `tests/features/write_operations.feature` — BDD scenarios for update/refresh
- `tests/test_bdd_write_operations.py` — BDD step definitions
