# Batch Writes — Design

## Overview

Add batch write support to Cendry: a `batch()` context manager for mixing operations atomically, plus `save_many`/`delete_many` convenience methods.

## `batch()` Context Manager

```python
# Sync
with ctx.batch() as batch:
    batch.save(city1)
    batch.save(city2)
    batch.create(city3)
    batch.update(city4, {"population": 1_000_000})
    batch.delete(city5)
# auto-commits on exit

# Async
async with ctx.batch() as batch:
    batch.save(city1)
    batch.create(city2)
    batch.delete(city3)
# auto-commits on exit
```

### `Batch` / `AsyncBatch` Wrapper

`Batch` and `AsyncBatch` wrap Firestore's `WriteBatch`/`AsyncWriteBatch` with model-aware methods. They live in a new `src/cendry/batch.py` file.

**Constructor:** Receives the Firestore batch, `_get_collection_ref` callable, and `type_registry`.

**Methods:**

| Method | Behavior |
|--------|----------|
| `.save(instance, *, parent=None)` | Queues `set()`. Mutates `instance.id` if None (client-side ID generation). |
| `.create(instance, *, parent=None)` | Queues `create()`. Mutates `instance.id` if None. |
| `.update(instance, field_updates, *, parent=None)` | Queues `update()`. Dual overload (instance or class+ID), same as context. |
| `.delete(instance, *, parent=None)` | Queues `delete()`. Dual overload (instance or class+ID), same as context. |

All methods serialize values via `to_dict`, `serialize_update_value`, `resolve_field_path` using the threaded `type_registry`.

`save` and `create` validate required fields. To enable this, `_validate_required_fields` is extracted from `_BaseCendry` to a standalone function `validate_required_fields(instance)` in `serialize.py` (follows project convention: "standalone functions over methods"). Both `_BaseCendry` and `Batch` call it.

`update` and `delete` use the same dual-overload pattern as the context methods (instance or class+ID), including `# type: ignore[misc]` and explicit assertions.

### Context Manager Protocol

- `Batch.__enter__` returns `self`, `__exit__` calls `self._batch.commit()`.
- `AsyncBatch.__aenter__` returns `self`, `__aexit__` calls `await self._batch.commit()`.
- On exception, the batch is not committed (changes are discarded).

### Context Method

```python
# Cendry
def batch(self) -> Batch:
    return Batch(
        self._client.batch(),
        self._get_collection_ref,
        self.type_registry,
    )

# AsyncCendry
def batch(self) -> AsyncBatch:
    return AsyncBatch(
        self._client.batch(),
        self._get_collection_ref,
        self.type_registry,
    )
```

Note: `batch()` is a regular `def` (not async) — it returns the wrapper synchronously.

## `save_many` / `delete_many`

```python
# Save many
ctx.save_many([city1, city2, city3])
ctx.save_many([nb1, nb2], parent=city)

# Delete many — by instances
ctx.delete_many([city1, city2, city3])

# Delete many — by class + IDs
ctx.delete_many(City, ["SF", "LA", "NYC"])
```

### Behavior

- **Atomic** — uses `WriteBatch` internally, one commit.
- **Max 500 items** — raises `CendryError("Batch limit exceeded: {n} items, maximum is 500")` if exceeded.
- `save_many` mutates `instance.id` for auto-generated IDs.
- `save_many` validates required fields on each instance before starting the batch.
- `delete_many` has two calling conventions via `@overload`: list of instances, or class + list of IDs.
- `delete_many` by instance raises `CendryError` if any instance has `id=None`.
- `parent=` for subcollections.
- Both return `None`.

### Implementation

These are methods on `Cendry`/`AsyncCendry` that internally create a batch, add all items, and commit:

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

def delete_many(self, instances_or_class, doc_ids_or_none=None, *, parent=None):
    # Two overloads: list[Model] or (type[T], list[str])
    ...
```

## Error Handling

- `save_many`/`delete_many` > 500 items: `CendryError`
- `delete_many` by instance with `id=None`: `CendryError`
- `save_many` with required field `None`: `CendryError`
- Batch commit failures: Firestore exceptions propagate unchanged. This is intentional — the batch is all-or-nothing, and wrapping commit-time errors (e.g. `Conflict` from `create`) would obscure which operation failed. Users handle commit errors directly.
- `batch()` context manager does **not** commit on exception — changes are discarded (mirrors Firestore SDK behavior).

## Exports

`Batch` and `AsyncBatch` exported from `cendry.__init__` and added to `__all__`.

## Files Changed

- `src/cendry/serialize.py` — extract `validate_required_fields` as standalone function
- `src/cendry/batch.py` — new file: `Batch`, `AsyncBatch` classes
- `src/cendry/context.py` — add `batch()`, `save_many`, `delete_many`; refactor to use extracted `validate_required_fields`
- `src/cendry/__init__.py` — export `Batch`, `AsyncBatch`
- `tests/test_batch.py` — new file: tests for Batch/AsyncBatch wrapper methods
- `tests/test_context.py` — tests for `save_many`, `delete_many`, `batch()` context manager
- `tests/test_public_api.py` — add `Batch`, `AsyncBatch` to expected exports

## Testing

| Test case | What it verifies |
|-----------|-----------------|
| `batch()` context manager commits on exit | Auto-commit |
| `batch()` does not commit on exception | Changes discarded |
| `batch.save` with explicit ID | Queues set |
| `batch.save` with auto ID | Mutates instance.id |
| `batch.create` | Queues create |
| `batch.update` by instance | Queues update with serialization |
| `batch.delete` by instance | Queues delete |
| `batch.delete` by class + ID | Queues delete |
| `save_many` happy path | All saved atomically |
| `save_many` > 500 raises | `CendryError` |
| `save_many` with parent | Subcollection support |
| `save_many` validates required fields | `CendryError` |
| `delete_many` by instances | All deleted atomically |
| `delete_many` by class + IDs | All deleted atomically |
| `delete_many` > 500 raises | `CendryError` |
| `delete_many` instance with `id=None` raises | `CendryError` |
| Async variants | Mirror all sync tests |
