Metadata-Version: 2.4
Name: brainlessdb
Version: 0.1.1
Summary: Schema-first async persistence for NATS JetStream KV
Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
License-Expression: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: nats-py>=2.10.0

# Brainless DB

"Because without brain, you can’t have split-brain!"

Database build on top of [NATS](https://nats.io/) JetStream Key/Value Store.
The main purpose is to maintain configuration for a multi-service, multi-node system
where uptime is more important than strict consistency. Data is held in memory and
automatically synchronized to NATS KV.


## Quick Start

```python
import brainlessdb

# Connect to NATS and setup
await brainlessdb.setup(nats, namespace="myapp", location="prague-1")

# Add entity - schema inferred from first add()
call = brainlessdb.call.add(channel_id="123", queue_id=5, state="waiting")

# Attribute access with auto dirty tracking
call.state = "ringing"  # marked dirty, flushed in background

# Find and filter
call = await brainlessdb.call.find(channel_id="123")
waiting = await brainlessdb.call.filter(state="waiting")

# Delete
del brainlessdb.call[call]

# Cleanup
await brainlessdb.stop()
```

## Type-Safe Usage

Use dataclasses for type hints and IDE support:

```python
from dataclasses import dataclass
from typing import Optional

@dataclass
class Call:
    channel_id: str
    queue_id: int
    state: str
    uuid: Optional[str] = None  # auto-populated from entity

# Set type for collection - all queries return typed instances
brainlessdb.call.typed(Call)

# Now find/filter/all/order_by return Call instances
call = await brainlessdb.call.find(channel_id="123")
print(call.channel_id)  # IDE knows this is str
print(call.uuid)        # UUID auto-populated

# Add from dataclass
call = brainlessdb.call.add(Call(channel_id="456", queue_id=1, state="new"))

# Delete works with typed instances (uses uuid attribute)
del brainlessdb.call[call]
if call in brainlessdb.call:
    print("still exists")
```

### One-off Type Conversion

Convert individual entities without setting collection type:

```python
entity = await brainlessdb.call.find(channel_id="123")
call = entity.as_type(Call)  # Convert to dataclass
uuid = entity.uuid           # Still have access to entity
```

## Collections

Collections are created on first access and persist to NATS KV buckets:

```python
# These create/access collections automatically
brainlessdb.call.add(...)      # creates 'myapp-call' bucket
brainlessdb.user.add(...)      # creates 'myapp-user' bucket
brainlessdb.queue_item.add(...) # creates 'myapp-queue_item' bucket
```

### Adding Entities

```python
# From keyword arguments
call = brainlessdb.call.add(channel_id="123", state="waiting")

# From dictionary
call = brainlessdb.call.add({"channel_id": "456", "state": "ringing"})

# From dataclass instance
call = brainlessdb.call.add(Call(channel_id="789", queue_id=1, state="active"))

# Mixed - dataclass + overrides
call = brainlessdb.call.add(base_call, state="overridden")
```

### Retrieving Entities

```python
# By UUID
call = await brainlessdb.call.get("550e8400-e29b-41d4-a716-446655440000")

# Find first match (or None)
call = await brainlessdb.call.find(channel_id="123")
call = await brainlessdb.call.find(state="waiting", queue_id=5)

# Filter all matches
waiting = await brainlessdb.call.filter(state="waiting")
active_q5 = await brainlessdb.call.filter(queue_id=5, state="active")

# Get all
all_calls = await brainlessdb.call.all()
```

### Nested Filtering

Use double underscore for nested field access (Django-style):

```python
# Filter by nested fields
call = await brainlessdb.call.find(caller__city="Prague")
calls = await brainlessdb.call.filter(contact__address__zip="12345")

# Works with order_by too
calls = await brainlessdb.call.order_by("caller__name")
```

### Sorting

```python
# Ascending
calls = await brainlessdb.call.order_by("priority")

# Descending (minus prefix)
calls = await brainlessdb.call.order_by("-created_at")

# Multiple keys
calls = await brainlessdb.call.order_by("state", "-priority")

# With filter criteria
calls = await brainlessdb.call.order_by("-created_at", state="active")
```

### Deleting Entities

```python
# By UUID string
brainlessdb.call.delete("550e8400-e29b-41d4-a716-446655440000")

# By Entity instance
brainlessdb.call.delete(entity)

# By typed dataclass (uses uuid attribute)
brainlessdb.call.delete(call)

# Dict-style deletion
del brainlessdb.call[call]
del brainlessdb.call["550e8400-..."]
```

### Collection Info

```python
count = brainlessdb.call.count()
count = len(brainlessdb.call)

# Check existence (accepts uuid string, Entity, or typed object)
if "550e8400-..." in brainlessdb.call:
    ...
if call in brainlessdb.call:
    ...

# Iteration
for call in brainlessdb.call:
    print(call.channel_id)

# Dict-style access by UUID
call = brainlessdb.call["550e8400-..."]
```

## Entity Access

Entities wrap stored data with attribute access and dirty tracking:

```python
# Attribute access
print(call.state)
call.state = "ringing"  # auto-marks dirty

# Dict-style access
print(call["state"])
call["state"] = "active"

# Check field exists
if "queue_id" in call:
    print(call.queue_id)

# Get data
data = call.to_dict()  # {"uuid": "...", "channel_id": "123", ...}
data = call.data       # same but without uuid

# Properties
call.uuid   # entity UUID
call.dirty  # True if modified since last flush
```

## Persistence

### Background Flush

Changes are flushed to NATS automatically in the background:

```python
# Default: flush every 0.5 seconds
await brainlessdb.setup(nats, namespace="app")

# Custom interval
await brainlessdb.setup(nats, namespace="app", flush_interval=1.0)
```

### Manual Flush

```python
# Flush all collections
await brainlessdb.flush()

# Flush single collection
await brainlessdb.call.flush()
```

### Graceful Shutdown

Always call stop() for final flush:

```python
await brainlessdb.stop()
```

## Instance-Based Usage

For tests or multiple databases:

```python
from brainlessdb import Brainless

db = Brainless(nats, namespace="test", location="local")
await db.start()

call = db.call.add(channel_id="456", state="waiting")
call = await db.call.find(channel_id="456")

await db.stop()
```

### In-Memory Mode

Works without NATS for testing:

```python
db = Brainless(None, namespace="test")
await db.start()

# All operations work, just no persistence
call = db.call.add(channel_id="123", state="new")
call = await db.call.find(channel_id="123")

await db.stop()
```

## Use Cases

### Queue Management

```python
@dataclass
class QueueItem:
    priority: int
    caller_id: str
    queue_id: int
    state: str
    uuid: Optional[str] = None

brainlessdb.queue.typed(QueueItem)

# Add to queue
item = brainlessdb.queue.add(QueueItem(
    priority=1,
    caller_id="caller-123",
    queue_id=5,
    state="waiting"
))

# Get next item by priority
next_item = await brainlessdb.queue.order_by("priority", state="waiting")
if next_item:
    next_item[0].state = "processing"

# Remove completed
del brainlessdb.queue[item]
```

### Session Storage

```python
@dataclass
class Session:
    user_id: int
    token: str
    created_at: float
    last_seen: float
    uuid: Optional[str] = None

brainlessdb.session.typed(Session)

# Create session
session = brainlessdb.session.add(Session(
    user_id=42,
    token="abc123",
    created_at=time.time(),
    last_seen=time.time()
))

# Find by token
session = await brainlessdb.session.find(token="abc123")

# Update last seen
session.last_seen = time.time()

# Find stale sessions
stale = await brainlessdb.session.filter(last_seen__lt=time.time() - 3600)
```

### Call State Tracking

```python
@dataclass
class Call:
    channel_id: str
    state: str
    caller: dict
    queue_id: Optional[int] = None
    agent_id: Optional[int] = None
    uuid: Optional[str] = None

brainlessdb.call.typed(Call)

# New call
call = brainlessdb.call.add(Call(
    channel_id="chan-123",
    state="ringing",
    caller={"number": "+1234567890", "city": "Prague"}
))

# Find by nested field
call = await brainlessdb.call.find(caller__city="Prague")

# State transitions
call.state = "answered"
call.agent_id = 5

# Get calls by agent
agent_calls = await brainlessdb.call.filter(agent_id=5, state="answered")
```

### Multi-Location Sync

```python
# Prague datacenter
await brainlessdb.setup(nats, namespace="app", location="prague-1")

# UUIDs include location for conflict resolution
call = brainlessdb.call.add(channel_id="123", state="new")
print(call.uuid)  # "550e8400-e29b-41d4-a716-446655440000" (UUID1 with location-based node)
```

## Architecture

- **Schema inference**: Schema locked after first `add()` to collection
- **Lazy indexing**: O(1) lookups, indexes built on first filter/find
- **Background flush**: Async persistence with configurable interval
- **NATS KV storage**: One bucket per collection (`{namespace}-{collection}`)
- **UUID1 generation**: Time-based UUIDs with location-derived node ID

## API Reference

### Global Functions

| Function | Description |
|----------|-------------|
| `await setup(nats, namespace, location, flush_interval)` | Initialize global instance |
| `await stop()` | Stop and final flush |
| `await flush()` | Flush all collections |
| `brainlessdb.{name}` | Access collection by name |

### Collection Methods

| Method | Description |
|--------|-------------|
| `typed(cls)` | Set dataclass type for results |
| `add(data, **kwargs)` | Add entity (dict, dataclass, or kwargs) |
| `await get(uuid)` | Get by UUID |
| `await find(**criteria)` | Find first match |
| `await filter(**criteria)` | Find all matches |
| `await all()` | Get all entities |
| `await order_by(*keys, **criteria)` | Sorted results |
| `delete(entity)` | Delete by uuid/entity/typed object |
| `count()` / `len()` | Entity count |
| `await load()` | Load from NATS |
| `await flush()` | Flush to NATS |
| `clear()` | Clear in-memory (not NATS) |

### Entity Properties & Methods

| Property/Method | Description |
|-----------------|-------------|
| `uuid` | Entity UUID |
| `data` | Raw data dict (no uuid) |
| `dirty` | Modified since flush |
| `to_dict()` | Data with uuid |
| `as_type(cls)` | Convert to dataclass |

## Status

🚧 **Work in progress**

- [x] Collections with add/get/delete/filter/find
- [x] Entity dirty tracking
- [x] NATS KV bucket integration
- [x] Background flush
- [x] Lazy auto-indexing for O(1) lookups
- [x] Sorted iteration with order_by
- [x] Type-safe dataclass conversion
- [x] UUID auto-population in typed results
- [ ] Schema storage in NATS
- [ ] Watch for changes from other nodes
- [ ] CRDTs for conflict resolution
