# Optimistic Locking — Design

## Overview

Add document metadata tracking (`update_time`, `create_time`) and `if_unchanged` preconditions to Cendry write operations. Metadata is stored in a `WeakKeyDictionary` — invisible on the model, accessible via `get_metadata()`.

## Metadata Storage

### `DocumentMetadata` and `get_metadata`

```python
from cendry import get_metadata

city = ctx.get(City, "SF")
meta = get_metadata(city)
print(meta.update_time)  # datetime — when Firestore last wrote this doc
print(meta.create_time)  # datetime — when the doc was first created
```

**Implementation:**

- `DocumentMetadata` dataclass: `update_time: datetime | None`, `create_time: datetime | None`
- Module-level `WeakKeyDictionary[Model, DocumentMetadata]` in `src/cendry/metadata.py`
- `get_metadata(instance)` returns metadata or raises `CendryError` if instance is untracked
- Internal helpers `_set_metadata(instance, update_time, create_time)` and `_clear_metadata(instance)`

### Population

Metadata is populated automatically:

| Operation | Source | What's set |
|-----------|--------|-----------|
| `ctx.get` / `ctx.find` / `ctx.get_many` | `DocumentSnapshot.update_time`, `.create_time` | Both |
| `ctx.refresh` | `DocumentSnapshot.update_time`, `.create_time` | Both (refreshed) |
| `ctx.save` / `ctx.create` | `WriteResult.update_time` | `update_time` only |
| `ctx.update` | `WriteResult.update_time` | `update_time` only |
| `ctx.delete` | — | Metadata cleared |
| `txn.get` / `txn.find` | `DocumentSnapshot.update_time`, `.create_time` | Both |
| `Query` iteration | `DocumentSnapshot.update_time`, `.create_time` | Both |

Batch writes (`Batch.save`, etc.) queue operations — `WriteResult` is only available after `commit()`, which returns a list. Since there's no clean way to map results back to instances, **batch writes do not populate metadata**. Use `refresh` after a batch to get metadata:

```python
with ctx.batch() as batch:
    batch.save(city1)
    batch.save(city2)

# Refresh to populate metadata after batch
ctx.refresh(city1)
ctx.refresh(city2)

# Now optimistic locking works
ctx.update(city1, {"population": 900_000}, if_unchanged=True)
```

## `if_unchanged` Precondition

```python
city = ctx.get(City, "SF")
# ... time passes ...
ctx.update(city, {"population": 900_000}, if_unchanged=True)
ctx.delete(city, if_unchanged=True)
```

### Behavior

- `if_unchanged=True` passes `option=LastUpdateOption(metadata.update_time)` to the Firestore SDK
- Raises `CendryError` if the instance has no metadata or no `update_time`
- On precondition failure, `google.api_core.exceptions.FailedPrecondition` propagates unchanged
- Available on: `save`, `update`, `delete` (both instance and class+ID forms)
- For class+ID form: accepts `if_unchanged=datetime_value` (a `datetime` directly, since there's no instance to look up)
- **Not available on `create`** — preconditions don't make sense for new documents
- Async variants mirror sync

### Implementation sketch

```python
from google.cloud.firestore_v1._helpers import LastUpdateOption

# Instance form
def save(self, instance: T, *, parent=None, if_unchanged: bool = False) -> str:
    ...
    option = None
    if if_unchanged:
        meta = get_metadata(instance)  # raises if untracked
        if meta.update_time is None:
            raise CendryError("No update_time — cannot use if_unchanged")
        option = LastUpdateOption(meta.update_time)
    doc_ref.set(data, option=option)  # Firestore set() does not accept option
    # Actually: set() doesn't support option — only update() and delete() do.
```

**Important correction:** Firestore's `set()` does **not** accept an `option=` parameter. Only `update()` and `delete()` do. So `if_unchanged` is only available on `update` and `delete`, not `save`.

Updated API:

```python
ctx.update(city, {"population": 900_000}, if_unchanged=True)
ctx.delete(city, if_unchanged=True)

# save does NOT support if_unchanged — use update for conditional writes
```

### Class+ID form with datetime

```python
import datetime

# If you know the update_time from elsewhere
ctx.update(City, "SF", {"population": 900_000}, if_unchanged=some_datetime)
ctx.delete(City, "SF", if_unchanged=some_datetime)
```

The `if_unchanged` parameter accepts `bool` (looks up metadata) or `datetime` (uses directly).

## Error Handling

- `get_metadata` on untracked instance: `CendryError("No metadata for this instance")`
- `if_unchanged=True` without metadata: `CendryError`
- `if_unchanged=True` without `update_time`: `CendryError`
- Precondition failure: `google.api_core.exceptions.FailedPrecondition` propagates unchanged

## Exports

`get_metadata` and `DocumentMetadata` exported from `cendry.__init__` and added to `__all__`.

## Files Changed

- `src/cendry/metadata.py` — new file: `DocumentMetadata`, `get_metadata`, `_set_metadata`, `_clear_metadata`
- `src/cendry/context.py` — populate metadata after reads/writes, add `if_unchanged` to `update`/`delete`
- `src/cendry/transaction.py` — populate metadata after `txn.get`/`txn.find`
- `src/cendry/query.py` — populate metadata during iteration
- `src/cendry/__init__.py` — export `get_metadata`, `DocumentMetadata`
- `tests/test_metadata.py` — new file
- `tests/test_context.py` — metadata population and `if_unchanged` tests
- `tests/test_public_api.py` — add exports

## Testing

| Test case | What it verifies |
|-----------|-----------------|
| `get_metadata` after `ctx.get` | `update_time` and `create_time` populated |
| `get_metadata` after `ctx.save` | `update_time` populated from `WriteResult` |
| `get_metadata` after `ctx.create` | `update_time` populated |
| `get_metadata` after `ctx.update` | `update_time` updated |
| `get_metadata` after `ctx.delete` | Metadata cleared |
| `get_metadata` after `ctx.refresh` | Metadata refreshed |
| `get_metadata` after query iteration | Populated per instance |
| `get_metadata` on untracked instance | `CendryError` |
| `update(if_unchanged=True)` | `LastUpdateOption` passed to SDK |
| `delete(if_unchanged=True)` | `LastUpdateOption` passed to SDK |
| `if_unchanged=True` without metadata | `CendryError` |
| `if_unchanged=datetime` (class+ID form) | Datetime passed directly |
| `txn.get` populates metadata | Reads inside transactions track metadata |
| Async variants | Mirror sync |
| Weakref cleanup | Metadata GC'd when instance is GC'd |
