# Transactions — Design

## Overview

Add transaction support to Cendry: a callback pattern with auto-retry for read-then-write atomicity, and a context manager for simple single-attempt transactions.

## API Surface

### Callback pattern (auto-retry on contention)

```python
# Sync
def transfer(txn):
    from_city = txn.get(City, "SF")
    to_city = txn.get(City, "LA")
    txn.update(from_city, {"population": from_city.population - 1000})
    txn.update(to_city, {"population": to_city.population + 1000})

ctx.transaction(transfer)
ctx.transaction(transfer, max_attempts=10)
ctx.transaction(transfer, read_only=True)

# Async
async def transfer(txn):
    from_city = await txn.get(City, "SF")
    to_city = await txn.get(City, "LA")
    txn.update(from_city, {"population": from_city.population - 1000})
    txn.update(to_city, {"population": to_city.population + 1000})

await ctx.transaction(transfer)
```

The callback receives a `Txn` (or `AsyncTxn`). Firestore's `@transactional` (from `google.cloud.firestore_v1.transaction`) / `@async_transactional` (from `google.cloud.firestore_v1.async_transaction`) decorator handles retry on contention (up to `max_attempts`, default 5).

### Context manager pattern (single attempt, no retry)

```python
# Sync
with ctx.transaction() as txn:
    city = txn.get(City, "SF")
    txn.update(city, {"population": city.population + 1000})

# Async
async with ctx.transaction() as txn:
    city = await txn.get(City, "SF")
    txn.update(city, {"population": city.population + 1000})
```

Single attempt — contention conflicts propagate as exceptions. The `with` block can't be re-entered, so auto-retry is not possible.

### Dual dispatch

`ctx.transaction()` with no callable returns a context manager. `ctx.transaction(fn)` with a callable runs the callback pattern. Overload signatures for type safety:

```python
# Cendry
@overload
def transaction(
    self,
    fn: Callable[[Txn], Any],
    *,
    max_attempts: int = 5,
    read_only: bool = False,
) -> Any: ...
@overload
def transaction(
    self,
    fn: None = None,
    *,
    max_attempts: int = 5,
    read_only: bool = False,
) -> Txn: ...
```

## `Txn` / `AsyncTxn`

`Txn` and `AsyncTxn` wrap Firestore's `Transaction`/`AsyncTransaction` with model-aware methods. They live in `src/cendry/transaction.py`.

### Constructor

Receives the Firestore transaction, `_get_collection_ref` callable, and `type_registry`. `Txn` holds a **reference** to the Firestore `Transaction` object — not a copy. On retry, Firestore's `@transactional` mutates the same object in-place (`_clean_up()` + `_begin()`), so `Txn` automatically sees the updated state.

### Read Methods

| Method | Behavior |
|--------|----------|
| `.get(ModelClass, doc_id, *, parent=None)` | Read a document, return deserialized model. Raises `DocumentNotFoundError`. |
| `.find(ModelClass, doc_id, *, parent=None)` | Read a document, return model or `None`. |

**Implementation:** Reads use `col_ref.document(doc_id).get(transaction=self._transaction)` — the standard document get with the transaction parameter. This is consistent with how `Cendry.get()` fetches documents. Results are deserialized through `deserialize()` with the type handler registry.

For `AsyncTxn`, reads are `async def` and use `await col_ref.document(doc_id).get(transaction=self._transaction)`.

> **Out of scope:** `get_many` equivalent is not included. `Transaction.get_all()` exists in Firestore but is deferred to a future iteration.

### Write Methods

| Method | Behavior |
|--------|----------|
| `.save(instance, *, parent=None)` | Queue set. Mutates `instance.id` if None. |
| `.create(instance, *, parent=None)` | Queue create. Mutates `instance.id` if None. |
| `.update(instance, field_updates, *, parent=None)` | Queue update. Dual overload (instance or class+ID). |
| `.delete(instance, *, parent=None)` | Queue delete. Dual overload (instance or class+ID). |

Write methods are identical to `Batch` — they queue operations on the Firestore `Transaction` (which inherits from `WriteBatch`). Serialization uses `to_dict`, `serialize_update_value`, `resolve_field_path`, `validate_required_fields`.

### Context Manager Protocol

- `Txn.__enter__`: calls `self._transaction._begin()` to start the transaction, returns `self`
- `Txn.__exit__`: on success calls `self._transaction._commit()`, on exception calls `self._transaction._rollback()`
- `AsyncTxn.__aenter__`: `async def`, calls `await self._transaction._begin()`, returns `self`
- `AsyncTxn.__aexit__`: `async def`, commits or rolls back accordingly

## `ctx.transaction()` Implementation

### Callback form

```python
from google.cloud.firestore_v1.transaction import transactional

# Cendry
def transaction(self, fn, *, max_attempts=5, read_only=False):
    fs_txn = self._client.transaction(max_attempts=max_attempts, read_only=read_only)
    txn = Txn(fs_txn, self._get_collection_ref, self.type_registry)

    @transactional
    def _run(transaction):
        return fn(txn)

    return _run(fs_txn)
```

`Txn` holds a reference to `fs_txn`. The `@transactional` decorator manages `_begin()`, retry, and `_commit()` on the same `fs_txn` object. On retry, `fs_txn` is cleaned up and re-begun — `Txn` sees this automatically since it holds the same reference.

### Async callback form

```python
from google.cloud.firestore_v1.async_transaction import async_transactional

# AsyncCendry
async def transaction(self, fn, *, max_attempts=5, read_only=False):
    fs_txn = self._client.transaction(max_attempts=max_attempts, read_only=read_only)
    txn = AsyncTxn(fs_txn, self._get_collection_ref, self.type_registry)

    @async_transactional
    async def _run(transaction):
        return await fn(txn)

    return await _run(fs_txn)
```

### Context manager form (no callable)

When `fn` is not passed, returns a `Txn`/`AsyncTxn` for use as a context manager:

```python
def transaction(self, fn=None, *, max_attempts=5, read_only=False):
    fs_txn = self._client.transaction(max_attempts=max_attempts, read_only=read_only)
    txn = Txn(fs_txn, self._get_collection_ref, self.type_registry)
    if fn is None:
        return txn
    # callback form...
```

## Error Handling

- `txn.get()` on missing document: `DocumentNotFoundError`
- `txn.update`/`txn.delete` with `id=None`: `CendryError`
- `txn.save`/`txn.create` with required field `None`: `CendryError`
- Write on `read_only=True`: Firestore's `ValueError` propagates unchanged
- Callback contention exhaustion: Firestore's `ValueError("Failed to commit transaction in N attempts.")` propagates unchanged
- Context manager contention: `google.api_core.exceptions.Aborted` propagates unchanged (single attempt)

## Exports

`Txn` and `AsyncTxn` exported from `cendry.__init__` and added to `__all__`.

## Files Changed

- `src/cendry/transaction.py` — new file: `Txn`, `AsyncTxn`
- `src/cendry/context.py` — add `transaction()` to both context classes
- `src/cendry/__init__.py` — export `Txn`, `AsyncTxn`
- `tests/test_transaction.py` — new file: tests for `Txn`/`AsyncTxn`
- `tests/test_context.py` — tests for `ctx.transaction()`
- `tests/test_public_api.py` — add exports

## Testing

| Test case | What it verifies |
|-----------|-----------------|
| `txn.get` returns deserialized model | Read + deserialization through registry |
| `txn.get` missing doc raises | `DocumentNotFoundError` |
| `txn.find` returns model or None | Find semantics |
| `txn.save` with explicit ID | Queues set |
| `txn.save` with auto ID | Mutates instance.id |
| `txn.create` | Queues create |
| `txn.update` by instance | Queues update with serialization |
| `txn.update` by class + ID | Dual overload |
| `txn.update` instance with `id=None` raises | `CendryError` |
| `txn.delete` by instance | Queues delete |
| `txn.delete` by class + ID | Dual overload |
| `txn.delete` instance with `id=None` raises | `CendryError` |
| `ctx.transaction(fn)` callback | Executes fn with Txn, commits |
| `ctx.transaction()` context manager | Single attempt, commits on exit |
| Context manager no commit on exception | Rolled back |
| Async variants | Mirror all sync tests |
| Public API exports | `Txn`, `AsyncTxn` in `__all__` |
