# Batch Writes 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 batch write support: `batch()` context manager, `Batch`/`AsyncBatch` wrappers, `save_many`/`delete_many` convenience methods.

**Architecture:** Extract `validate_required_fields` to `serialize.py`. New `batch.py` with `Batch`/`AsyncBatch` wrapping Firestore's `WriteBatch`. Context methods `batch()`, `save_many`, `delete_many` on both `Cendry`/`AsyncCendry`.

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

**Spec:** `docs/superpowers/specs/2026-03-22-batch-writes-design.md`

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `src/cendry/serialize.py` | Modify | Extract `validate_required_fields` as standalone function |
| `src/cendry/context.py` | Modify | Refactor to use extracted function; add `batch()`, `save_many`, `delete_many` |
| `src/cendry/batch.py` | Create | `Batch` and `AsyncBatch` wrapper classes |
| `src/cendry/__init__.py` | Modify | Export `Batch`, `AsyncBatch` |
| `tests/test_serialize.py` | Modify | Test `validate_required_fields` standalone |
| `tests/test_batch.py` | Create | Tests for Batch/AsyncBatch wrapper methods |
| `tests/test_context.py` | Modify | Tests for `save_many`, `delete_many`, `batch()` |
| `tests/test_public_api.py` | Modify | Add exports |

---

### Task 1: Extract `validate_required_fields` to `serialize.py`

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

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

Add to `tests/test_serialize.py`:

```python
from cendry.serialize import validate_required_fields


def test_validate_required_fields_raises():
    city = City(
        name=None,
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )
    with pytest.raises(Exception, match="Required fields are None: name"):
        validate_required_fields(city)


def test_validate_required_fields_passes():
    city = City(**CITY_DATA)
    validate_required_fields(city)  # should not raise
```

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

Run: `uv run pytest tests/test_serialize.py::test_validate_required_fields_raises -v`
Expected: FAIL with `ImportError`

- [ ] **Step 3: Implement**

Add to `src/cendry/serialize.py` (after `resolve_field_path`, before `to_dict`):

```python
def validate_required_fields(instance: Model) -> None:
    """Raise CendryError if any required fields are None.

    Args:
        instance: Model instance to validate.

    Raises:
        CendryError: If required fields are None.
    """
    from .exceptions import CendryError

    missing = []
    for f in dataclasses.fields(instance):
        if f.name == "id":
            continue
        has_default = (
            f.default is not dataclasses.MISSING
            or f.default_factory is not dataclasses.MISSING
        )
        if not has_default and getattr(instance, f.name) is None:
            missing.append(f.name)
    if missing:
        fields = ", ".join(missing)
        raise CendryError(f"Required fields are None: {fields}")
```

Then refactor `_BaseCendry._validate_required_fields` in `src/cendry/context.py` to delegate:

```python
def _validate_required_fields(self, instance: Model) -> None:
    """Raise CendryError if any required fields are None."""
    validate_required_fields(instance)
```

And add import: `from .serialize import ..., validate_required_fields`

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

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

- [ ] **Step 5: Commit**

```bash
git add src/cendry/serialize.py src/cendry/context.py tests/test_serialize.py
git commit -m "refactor: extract validate_required_fields to serialize.py"
```

---

### Task 2: `Batch` and `AsyncBatch` classes

**Files:**
- Create: `src/cendry/batch.py`
- Create: `tests/test_batch.py`

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

Create `tests/test_batch.py`:

```python
from unittest.mock import AsyncMock, MagicMock

import pytest

from cendry import AsyncCendry, Cendry, CendryError, Field, Model
from cendry import field as cendry_field
from cendry.batch import AsyncBatch, Batch
from cendry.types import TypeRegistry, default_registry
from tests.conftest import SF_DATA, City, Neighborhood, make_mock_document


def make_batch_and_client():
    """Create a mock client and Batch for testing."""
    client = MagicMock()
    fs_batch = MagicMock()
    client.batch.return_value = fs_batch

    def get_collection_ref(model_class, parent=None):
        if parent is not None:
            parent_ref = client.collection(parent.__collection__).document(parent.id)
            return parent_ref.collection(model_class.__collection__)
        return client.collection(model_class.__collection__)

    return Batch(fs_batch, get_collection_ref, default_registry), fs_batch, client


# --- Batch.save ---


def test_batch_save_explicit_id():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.save(city)

    fs_batch.set.assert_called_once()


def test_batch_save_auto_id():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA)  # id=None

    doc_ref = client.collection.return_value.document.return_value
    doc_ref.id = "auto-123"

    batch.save(city)

    assert city.id == "auto-123"
    fs_batch.set.assert_called_once()


def test_batch_save_validates_required_fields():
    batch, fs_batch, client = make_batch_and_client()
    city = City(
        name=None,
        state="CA",
        country="USA",
        capital=False,
        population=870_000,
        regions=[],
    )

    with pytest.raises(CendryError, match="Required fields are None"):
        batch.save(city)


# --- Batch.create ---


def test_batch_create():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.create(city)

    fs_batch.create.assert_called_once()


def test_batch_create_auto_id():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA)  # id=None

    doc_ref = client.collection.return_value.document.return_value
    doc_ref.id = "auto-789"

    batch.create(city)

    assert city.id == "auto-789"


# --- Batch.update ---


def test_batch_update_by_instance():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.update(city, {"name": "New Name"})

    fs_batch.update.assert_called_once()


def test_batch_update_by_class_and_id():
    batch, fs_batch, client = make_batch_and_client()

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

    fs_batch.update.assert_called_once()


def test_batch_update_no_id_raises():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA)  # id=None

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


# --- Batch.delete ---


def test_batch_delete_by_instance():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.delete(city)

    fs_batch.delete.assert_called_once()


def test_batch_delete_by_class_and_id():
    batch, fs_batch, client = make_batch_and_client()

    batch.delete(City, "SF")

    fs_batch.delete.assert_called_once()


def test_batch_delete_no_id_raises():
    batch, fs_batch, client = make_batch_and_client()
    city = City(**SF_DATA)  # id=None

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


# --- Context manager ---


def test_batch_commits_on_exit():
    batch, fs_batch, _ = make_batch_and_client()

    with batch:
        batch.save(City(**SF_DATA, id="SF"))

    fs_batch.commit.assert_called_once()


def test_batch_no_commit_on_exception():
    batch, fs_batch, _ = make_batch_and_client()

    with pytest.raises(ValueError):
        with batch:
            raise ValueError("boom")

    fs_batch.commit.assert_not_called()


# --- AsyncBatch ---


def make_async_batch_and_client():
    client = MagicMock()
    fs_batch = MagicMock()
    fs_batch.commit = AsyncMock()
    client.batch.return_value = fs_batch

    def get_collection_ref(model_class, parent=None):
        if parent is not None:
            parent_ref = client.collection(parent.__collection__).document(parent.id)
            return parent_ref.collection(model_class.__collection__)
        return client.collection(model_class.__collection__)

    return AsyncBatch(fs_batch, get_collection_ref, default_registry), fs_batch, client


@pytest.mark.anyio
async def test_async_batch_save():
    batch, fs_batch, _ = make_async_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.save(city)

    fs_batch.set.assert_called_once()


@pytest.mark.anyio
async def test_async_batch_commits_on_exit():
    batch, fs_batch, _ = make_async_batch_and_client()

    async with batch:
        batch.save(City(**SF_DATA, id="SF"))

    fs_batch.commit.assert_called_once()


@pytest.mark.anyio
async def test_async_batch_no_commit_on_exception():
    batch, fs_batch, _ = make_async_batch_and_client()

    with pytest.raises(ValueError):
        async with batch:
            raise ValueError("boom")

    fs_batch.commit.assert_not_called()


@pytest.mark.anyio
async def test_async_batch_delete_by_instance():
    batch, fs_batch, _ = make_async_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.delete(city)

    fs_batch.delete.assert_called_once()


@pytest.mark.anyio
async def test_async_batch_update_by_instance():
    batch, fs_batch, _ = make_async_batch_and_client()
    city = City(**SF_DATA, id="SF")

    batch.update(city, {"name": "New"})

    fs_batch.update.assert_called_once()
```

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

Run: `uv run pytest tests/test_batch.py::test_batch_save_explicit_id -v`
Expected: FAIL with `ImportError` (module doesn't exist)

- [ ] **Step 3: Implement `batch.py`**

Create `src/cendry/batch.py`:

```python
import dataclasses
from collections.abc import Callable
from typing import Any, TypeVar, overload

from .exceptions import CendryError
from .model import Model
from .serialize import (
    resolve_field_path,
    serialize_update_value,
    to_dict,
    validate_required_fields,
)
from .types import TypeRegistry

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


class Batch:
    """Synchronous batch writer with model-aware methods.

    Use as a context manager — auto-commits on successful exit.
    """

    def __init__(
        self,
        firestore_batch: Any,
        get_collection_ref: Callable[..., Any],
        registry: TypeRegistry,
    ) -> None:
        self._batch = firestore_batch
        self._get_collection_ref = get_collection_ref
        self._registry = registry

    def __enter__(self) -> "Batch":
        return self

    def __exit__(self, exc_type: type | None, *args: object) -> None:
        if exc_type is None:
            self._batch.commit()

    def save(self, instance: T, *, parent: Model | None = None) -> None:
        """Queue a save (upsert). Mutates instance.id if None."""
        validate_required_fields(instance)
        col_ref = self._get_collection_ref(type(instance), parent)
        if instance.id is None:
            doc_ref = col_ref.document()
        else:
            doc_ref = col_ref.document(instance.id)
        self._batch.set(doc_ref, to_dict(instance, by_alias=True, registry=self._registry))
        if instance.id is None:
            instance.id = doc_ref.id

    def create(self, instance: T, *, parent: Model | None = None) -> None:
        """Queue a create (insert only). Mutates instance.id if None."""
        validate_required_fields(instance)
        col_ref = self._get_collection_ref(type(instance), parent)
        if instance.id is None:
            doc_ref = col_ref.document()
        else:
            doc_ref = col_ref.document(instance.id)
        self._batch.create(doc_ref, to_dict(instance, by_alias=True, registry=self._registry))
        if instance.id is None:
            instance.id = doc_ref.id

    @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:
        """Queue a partial update."""
        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: dict[str, Any] = field_updates_or_doc_id  # type: ignore[assignment]
        else:
            model_class = instance_or_class
            assert isinstance(field_updates_or_doc_id, str)
            doc_id = field_updates_or_doc_id
            field_updates = field_updates_or_none  # type: ignore[assignment]
            assert field_updates is not None

        resolved = {
            resolve_field_path(model_class, k): serialize_update_value(
                v, registry=self._registry
            )
            for k, v in field_updates.items()
        }
        col_ref = self._get_collection_ref(model_class, parent)
        self._batch.update(col_ref.document(doc_id), resolved)

    @overload
    def delete(self, instance: Model, *, parent: Model | None = None) -> None: ...
    @overload
    def delete(
        self, model_class: type[T], doc_id: str, *, parent: Model | None = None
    ) -> None: ...

    def delete(  # type: ignore[misc]
        self,
        instance_or_class: Model | type[T],
        doc_id: str | None = None,
        *,
        parent: Model | None = None,
    ) -> None:
        """Queue a delete."""
        if isinstance(instance_or_class, Model):
            if instance_or_class.id is None:
                raise CendryError("Cannot delete a model instance with id=None")
            col_ref = self._get_collection_ref(type(instance_or_class), parent)
            self._batch.delete(col_ref.document(instance_or_class.id))
        else:
            assert doc_id is not None
            col_ref = self._get_collection_ref(instance_or_class, parent)
            self._batch.delete(col_ref.document(doc_id))


class AsyncBatch:
    """Asynchronous batch writer with model-aware methods.

    Use as an async context manager — auto-commits on successful exit.
    """

    def __init__(
        self,
        firestore_batch: Any,
        get_collection_ref: Callable[..., Any],
        registry: TypeRegistry,
    ) -> None:
        self._batch = firestore_batch
        self._get_collection_ref = get_collection_ref
        self._registry = registry

    async def __aenter__(self) -> "AsyncBatch":
        return self

    async def __aexit__(self, exc_type: type | None, *args: object) -> None:
        if exc_type is None:
            await self._batch.commit()

    def save(self, instance: T, *, parent: Model | None = None) -> None:
        """Queue a save (upsert). Mutates instance.id if None."""
        validate_required_fields(instance)
        col_ref = self._get_collection_ref(type(instance), parent)
        if instance.id is None:
            doc_ref = col_ref.document()
        else:
            doc_ref = col_ref.document(instance.id)
        self._batch.set(doc_ref, to_dict(instance, by_alias=True, registry=self._registry))
        if instance.id is None:
            instance.id = doc_ref.id

    def create(self, instance: T, *, parent: Model | None = None) -> None:
        """Queue a create (insert only). Mutates instance.id if None."""
        validate_required_fields(instance)
        col_ref = self._get_collection_ref(type(instance), parent)
        if instance.id is None:
            doc_ref = col_ref.document()
        else:
            doc_ref = col_ref.document(instance.id)
        self._batch.create(doc_ref, to_dict(instance, by_alias=True, registry=self._registry))
        if instance.id is None:
            instance.id = doc_ref.id

    @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:
        """Queue a partial update."""
        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: dict[str, Any] = field_updates_or_doc_id  # type: ignore[assignment]
        else:
            model_class = instance_or_class
            assert isinstance(field_updates_or_doc_id, str)
            doc_id = field_updates_or_doc_id
            field_updates = field_updates_or_none  # type: ignore[assignment]
            assert field_updates is not None

        resolved = {
            resolve_field_path(model_class, k): serialize_update_value(
                v, registry=self._registry
            )
            for k, v in field_updates.items()
        }
        col_ref = self._get_collection_ref(model_class, parent)
        self._batch.update(col_ref.document(doc_id), resolved)

    @overload
    def delete(self, instance: Model, *, parent: Model | None = None) -> None: ...
    @overload
    def delete(
        self, model_class: type[T], doc_id: str, *, parent: Model | None = None
    ) -> None: ...

    def delete(  # type: ignore[misc]
        self,
        instance_or_class: Model | type[T],
        doc_id: str | None = None,
        *,
        parent: Model | None = None,
    ) -> None:
        """Queue a delete."""
        if isinstance(instance_or_class, Model):
            if instance_or_class.id is None:
                raise CendryError("Cannot delete a model instance with id=None")
            col_ref = self._get_collection_ref(type(instance_or_class), parent)
            self._batch.delete(col_ref.document(instance_or_class.id))
        else:
            assert doc_id is not None
            col_ref = self._get_collection_ref(instance_or_class, parent)
            self._batch.delete(col_ref.document(doc_id))
```

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

Run: `uv run pytest tests/test_batch.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/batch.py tests/test_batch.py
git commit -m "feat: add Batch and AsyncBatch wrapper classes"
```

---

### Task 3: Context methods (`batch`, `save_many`, `delete_many`) and exports

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

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

Add to `tests/test_context.py`:

```python
from cendry.batch import Batch


# --- batch() ---


def test_batch_returns_batch(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)
    b = ctx.batch()
    assert isinstance(b, Batch)


def test_batch_context_manager_commits(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)
    with ctx.batch() as batch:
        batch.save(City(**SF_DATA, id="SF"))
    mock_firestore_client.batch.return_value.commit.assert_called_once()


# --- save_many ---


def test_save_many(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(3)]
    ctx = Cendry(client=mock_firestore_client)
    ctx.save_many(cities)
    assert mock_firestore_client.batch.return_value.set.call_count == 3
    mock_firestore_client.batch.return_value.commit.assert_called_once()


def test_save_many_over_500_raises(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(501)]
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Batch limit exceeded: 501 items, maximum is 500"):
        ctx.save_many(cities)


def test_save_many_with_parent(mock_firestore_client: MagicMock):
    parent = City(**SF_DATA, id="SF")
    neighborhoods = [
        Neighborhood(name=f"nb-{i}", population=1000, id=f"nb-{i}") for i in range(2)
    ]
    ctx = Cendry(client=mock_firestore_client)
    ctx.save_many(neighborhoods, parent=parent)
    assert mock_firestore_client.batch.return_value.set.call_count == 2


def test_save_many_validates_required_fields(mock_firestore_client: MagicMock):
    cities = [
        City(**SF_DATA, id="ok"),
        City(name=None, state="CA", country="USA", capital=False, population=0, regions=[]),
    ]
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Required fields are None"):
        ctx.save_many(cities)


# --- delete_many ---


def test_delete_many_by_instances(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(3)]
    ctx = Cendry(client=mock_firestore_client)
    ctx.delete_many(cities)
    assert mock_firestore_client.batch.return_value.delete.call_count == 3
    mock_firestore_client.batch.return_value.commit.assert_called_once()


def test_delete_many_by_class_and_ids(mock_firestore_client: MagicMock):
    ctx = Cendry(client=mock_firestore_client)
    ctx.delete_many(City, ["SF", "LA", "NYC"])
    assert mock_firestore_client.batch.return_value.delete.call_count == 3


def test_delete_many_over_500_raises(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(501)]
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Batch limit exceeded"):
        ctx.delete_many(cities)


def test_delete_many_instance_no_id_raises(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id="ok"), City(**SF_DATA)]
    ctx = Cendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Cannot delete a model instance with id=None"):
        ctx.delete_many(cities)


# --- async batch ---


@pytest.mark.anyio
async def test_async_batch_context_manager_commits(mock_firestore_client: MagicMock):
    mock_firestore_client.batch.return_value.commit = AsyncMock()
    ctx = AsyncCendry(client=mock_firestore_client)
    async with ctx.batch() as batch:
        batch.save(City(**SF_DATA, id="SF"))
    mock_firestore_client.batch.return_value.commit.assert_called_once()


@pytest.mark.anyio
async def test_async_save_many(mock_firestore_client: MagicMock):
    mock_firestore_client.batch.return_value.commit = AsyncMock()
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(3)]
    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.save_many(cities)
    assert mock_firestore_client.batch.return_value.set.call_count == 3


@pytest.mark.anyio
async def test_async_save_many_over_500_raises(mock_firestore_client: MagicMock):
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(501)]
    ctx = AsyncCendry(client=mock_firestore_client)
    with pytest.raises(CendryError, match="Batch limit exceeded"):
        await ctx.save_many(cities)


@pytest.mark.anyio
async def test_async_delete_many_by_instances(mock_firestore_client: MagicMock):
    mock_firestore_client.batch.return_value.commit = AsyncMock()
    cities = [City(**SF_DATA, id=f"city-{i}") for i in range(3)]
    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete_many(cities)
    assert mock_firestore_client.batch.return_value.delete.call_count == 3


@pytest.mark.anyio
async def test_async_delete_many_by_class_and_ids(mock_firestore_client: MagicMock):
    mock_firestore_client.batch.return_value.commit = AsyncMock()
    ctx = AsyncCendry(client=mock_firestore_client)
    await ctx.delete_many(City, ["SF", "LA", "NYC"])
    assert mock_firestore_client.batch.return_value.delete.call_count == 3
```

Also update `tests/test_public_api.py` — add `"AsyncBatch"` and `"Batch"` to the expected list.

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

Run: `uv run pytest tests/test_context.py::test_batch_returns_batch -v`
Expected: FAIL with `AttributeError`

- [ ] **Step 3: Implement context methods**

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

```python
from .batch import AsyncBatch, Batch
```

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

```python
def batch(self) -> Batch:
    """Create a batch writer for atomic multi-document operations."""
    return Batch(self._client.batch(), self._get_collection_ref, self.type_registry)

def save_many(self, instances: list[T], *, parent: Model | None = None) -> None:
    """Save multiple documents atomically. Max 500 items.

    Args:
        instances: List of Model instances to save.
        parent: Parent document for subcollection writes.

    Raises:
        CendryError: If more than 500 items or validation fails.
    """
    if len(instances) > 500:
        raise CendryError(
            f"Batch limit exceeded: {len(instances)} items, maximum is 500"
        )
    with self.batch() as b:
        for instance in instances:
            b.save(instance, parent=parent)

@overload
def delete_many(
    self, instances: list[Model], *, parent: Model | None = None
) -> None: ...
@overload
def delete_many(
    self,
    model_class: type[T],
    doc_ids: list[str],
    *,
    parent: Model | None = None,
) -> None: ...

def delete_many(  # type: ignore[misc]
    self,
    instances_or_class: list[Model] | type[T],
    doc_ids: list[str] | None = None,
    *,
    parent: Model | None = None,
) -> None:
    """Delete multiple documents atomically. Max 500 items."""
    if isinstance(instances_or_class, list):
        if len(instances_or_class) > 500:
            raise CendryError(
                f"Batch limit exceeded: {len(instances_or_class)} items, maximum is 500"
            )
        with self.batch() as b:
            for instance in instances_or_class:
                b.delete(instance, parent=parent)
    else:
        assert doc_ids is not None
        if len(doc_ids) > 500:
            raise CendryError(
                f"Batch limit exceeded: {len(doc_ids)} items, maximum is 500"
            )
        with self.batch() as b:
            for doc_id in doc_ids:
                b.delete(instances_or_class, doc_id, parent=parent)
```

Add the same to `AsyncCendry` (after `refresh`):

```python
def batch(self) -> AsyncBatch:
    """Create an async batch writer for atomic multi-document operations."""
    return AsyncBatch(self._client.batch(), self._get_collection_ref, self.type_registry)

async def save_many(self, instances: list[T], *, parent: Model | None = None) -> None:
    """Save multiple documents atomically. Max 500 items."""
    if len(instances) > 500:
        raise CendryError(
            f"Batch limit exceeded: {len(instances)} items, maximum is 500"
        )
    async with self.batch() as b:
        for instance in instances:
            b.save(instance, parent=parent)

@overload
async def delete_many(
    self, instances: list[Model], *, parent: Model | None = None
) -> None: ...
@overload
async def delete_many(
    self,
    model_class: type[T],
    doc_ids: list[str],
    *,
    parent: Model | None = None,
) -> None: ...

async def delete_many(  # type: ignore[misc]
    self,
    instances_or_class: list[Model] | type[T],
    doc_ids: list[str] | None = None,
    *,
    parent: Model | None = None,
) -> None:
    """Delete multiple documents atomically. Max 500 items."""
    if isinstance(instances_or_class, list):
        if len(instances_or_class) > 500:
            raise CendryError(
                f"Batch limit exceeded: {len(instances_or_class)} items, maximum is 500"
            )
        async with self.batch() as b:
            for instance in instances_or_class:
                b.delete(instance, parent=parent)
    else:
        assert doc_ids is not None
        if len(doc_ids) > 500:
            raise CendryError(
                f"Batch limit exceeded: {len(doc_ids)} items, maximum is 500"
            )
        async with self.batch() as b:
            for doc_id in doc_ids:
                b.delete(instances_or_class, doc_id, parent=parent)
```

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

```python
from .batch import AsyncBatch, Batch
```

Add `"AsyncBatch"` and `"Batch"` to `__all__`.

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

Run: `uv run pytest tests/test_context.py -k "batch or save_many or delete_many" tests/test_batch.py tests/test_public_api.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/context.py src/cendry/__init__.py tests/test_context.py tests/test_public_api.py
git commit -m "feat: add batch(), save_many(), delete_many() and exports"
```

---

### Task 4: 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 batch writes"
```

(Skip if no fixes needed.)
