# Write Operations — Design

## Scope

Add three write operations to Cendry: `save`, `create`, `delete`. This is the minimal write surface — no batch writes, transactions, partial updates, or optimistic locking.

## Approach

Methods directly on `Cendry` / `AsyncCendry`, following the existing pattern for `get`, `find`, `get_many`. Shared helpers on `_BaseCendry` where applicable. Serialization uses existing `to_dict(instance, by_alias=True)`.

## New Exception

```python
# src/cendry/exceptions.py
class DocumentAlreadyExistsError(CendryError):
    """Raised when creating a document that already exists."""

    def __init__(self, collection: str, document_id: str) -> None:
        self.collection = collection
        self.document_id = document_id
        super().__init__(f"Document {document_id!r} already exists in {collection!r}")
```

- Mirrors `DocumentNotFoundError` structure (same `collection` + `document_id` attributes).
- Raised with `raise DocumentAlreadyExistsError(...) from e` to preserve `google.cloud.exceptions.Conflict` as `__cause__`.
- Exported from `cendry.__init__` and added to `__all__`.

## Serialization

Write methods call `to_dict(instance, by_alias=True)` directly — no wrapper. Uses `default_registry` like the rest of the codebase. Type handlers, enum conversion, set/tuple→list all already handled.

> **Note:** `type_registry` on the context is not threaded through to serialization today (same for reads). This is a known gap to fix separately for both reads and writes.

## Validation

`save` and `create` validate that required fields (fields without a default) are not `None` before writing. If any are, raise `CendryError` listing the offending fields. This check iterates `dataclasses.fields()` and skips `id` (which is always optional). The validation is a shared helper on `_BaseCendry`:

```python
def _validate_required_fields(self, instance: Model) -> None:
    """Raise CendryError if any required fields are None."""
    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}"
        )
```

Called at the top of both `save` and `create`, before serialization.

## Operations

### `save(instance, *, parent=None) -> str`

Upsert — insert or overwrite. Maps to Firestore's `DocumentReference.set()`. Returns the document ID.

- If `instance.id` is `None`: calls `collection.document()` to auto-generate an ID, then `.set(data)`, then assigns `doc_ref.id` back to `instance.id` (in-place mutation).
- If `instance.id` is set: calls `collection.document(instance.id).set(data)`.
- Returns `doc_ref.id` — the auto-generated or existing document ID.
- `parent=` works via existing `_get_collection_ref`.
- No `merge` parameter — full overwrite only. Merge semantics deferred to a future `update` feature.

```python
# Cendry
def save(self, instance: T, *, parent: Model | None = None) -> str:
    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)
    doc_ref.set(to_dict(instance, by_alias=True))
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id

# AsyncCendry
async def save(self, instance: T, *, parent: Model | None = None) -> str:
    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)
    await doc_ref.set(to_dict(instance, by_alias=True))
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id
```

### `create(instance, *, parent=None) -> str`

Insert only — raises if the document already exists. Maps to Firestore's `DocumentReference.create()`. Returns the document ID.

- Uses Firestore's native `.create()` which raises `google.cloud.exceptions.Conflict` on duplicate.
- Wraps into `DocumentAlreadyExistsError` with `__cause__` preserved.
- Same auto-ID, `parent=`, and return behavior as `save`.

```python
# Cendry
def create(self, instance: T, *, parent: Model | None = None) -> str:
    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)
    try:
        doc_ref.create(to_dict(instance, by_alias=True))
    except Conflict as e:
        raise DocumentAlreadyExistsError(
            type(instance).__collection__, doc_ref.id
        ) from e
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id

# AsyncCendry
async def create(self, instance: T, *, parent: Model | None = None) -> str:
    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)
    try:
        await doc_ref.create(to_dict(instance, by_alias=True))
    except Conflict as e:
        raise DocumentAlreadyExistsError(
            type(instance).__collection__, doc_ref.id
        ) from e
    if instance.id is None:
        instance.id = doc_ref.id
    return doc_ref.id
```

### `delete(instance | ModelClass, doc_id, *, parent=None, must_exist=False) -> None`

Delete a document. Two calling conventions via `@overload`.

- `delete(instance)`: deletes by instance. Raises `CendryError` if `instance.id is None`.
- `delete(ModelClass, doc_id)`: deletes by class + ID. Silent by default (matches Firestore). Raises `DocumentNotFoundError` when `must_exist=True`.
- `must_exist` does a get-then-delete (not atomic — Firestore has no "delete with precondition" in the public SDK).
- `parent=` works via existing `_get_collection_ref`.

```python
# Cendry — overloads for type checking
@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, must_exist: bool = False) -> None: ...

def delete(self, instance_or_class, doc_id=None, *, parent=None, must_exist=False):
    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)
        col_ref.document(instance_or_class.id).delete()
    else:
        col_ref = self._get_collection_ref(instance_or_class, parent)
        if must_exist:
            doc = col_ref.document(doc_id).get()
            if not doc.exists:
                raise DocumentNotFoundError(instance_or_class.__collection__, doc_id)
        col_ref.document(doc_id).delete()

# AsyncCendry — same shape, async
@overload
async def delete(self, instance: Model, *, parent: Model | None = None) -> None: ...
@overload
async def delete(self, model_class: type[T], doc_id: str, *, parent: Model | None = None, must_exist: bool = False) -> None: ...

async def delete(self, instance_or_class, doc_id=None, *, parent=None, must_exist=False):
    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)
        await col_ref.document(instance_or_class.id).delete()
    else:
        col_ref = self._get_collection_ref(instance_or_class, parent)
        if must_exist:
            doc = await col_ref.document(doc_id).get()
            if not doc.exists:
                raise DocumentNotFoundError(instance_or_class.__collection__, doc_id)
        await col_ref.document(doc_id).delete()
```

## Public API Changes

- Add `DocumentAlreadyExistsError` to `cendry.__init__` and `__all__`.
- New import: `from google.cloud.exceptions import Conflict` in `context.py`.
- New import: `from .serialize import to_dict` in `context.py`.

## Testing

All tests use `unittest.mock` to mock the Firestore client, consistent with existing tests.

| Method | Test cases |
|--------|-----------|
| `save` | with explicit ID (returns ID), with auto-generated ID (verify instance mutation + returned ID), with parent, raises `CendryError` when required field is `None` |
| `create` | success (returns ID), raises `DocumentAlreadyExistsError` on conflict (verify `__cause__`), auto-ID (verify instance mutation + returned ID), with parent, raises `CendryError` when required field is `None` |
| `delete(instance)` | success, raises `CendryError` when `id is None` |
| `delete(Class, id)` | silent when missing, raises `DocumentNotFoundError` with `must_exist=True`, with parent |
| async variants | mirror all sync tests using `anyio` |

BDD feature file in `tests/features/` covering happy paths and error cases.

## Files Changed

- `src/cendry/exceptions.py` — add `DocumentAlreadyExistsError`
- `src/cendry/context.py` — add `save`, `create`, `delete` to `Cendry` and `AsyncCendry`, new imports
- `src/cendry/__init__.py` — export `DocumentAlreadyExistsError`
- `tests/` — new test files for write operations + BDD feature file

## Out of Scope

- Batch writes (`save_many`, `delete_many`)
- Transactions
- Partial updates (`update` / `merge`)
- Optimistic locking / preconditions
- Threading `type_registry` through serialization (affects both reads and writes — separate task)
