brainlessdb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brainlessdb-0.1.0/PKG-INFO +454 -0
- brainlessdb-0.1.0/README.md +444 -0
- brainlessdb-0.1.0/pyproject.toml +44 -0
- brainlessdb-0.1.0/setup.cfg +4 -0
- brainlessdb-0.1.0/src/brainless/__init__.py +57 -0
- brainlessdb-0.1.0/src/brainless/bucket.py +127 -0
- brainlessdb-0.1.0/src/brainless/client.py +150 -0
- brainlessdb-0.1.0/src/brainless/collection.py +628 -0
- brainlessdb-0.1.0/src/brainless/entity.py +572 -0
- brainlessdb-0.1.0/src/brainless/py.typed +0 -0
- brainlessdb-0.1.0/src/brainless/schema.py +164 -0
- brainlessdb-0.1.0/src/brainlessdb.egg-info/PKG-INFO +454 -0
- brainlessdb-0.1.0/src/brainlessdb.egg-info/SOURCES.txt +14 -0
- brainlessdb-0.1.0/src/brainlessdb.egg-info/dependency_links.txt +1 -0
- brainlessdb-0.1.0/src/brainlessdb.egg-info/requires.txt +1 -0
- brainlessdb-0.1.0/src/brainlessdb.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brainlessdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Schema-first async persistence for NATS JetStream KV
|
|
5
|
+
Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: nats-py>=2.10.0
|
|
10
|
+
|
|
11
|
+
# Brainless
|
|
12
|
+
|
|
13
|
+
"Because without brain, you can’t have split-brain!"
|
|
14
|
+
|
|
15
|
+
Database build on top of [NATS](https://nats.io/) JetStream Key/Value Store.
|
|
16
|
+
The main purpose is to maintain configuration for a multi-service, multi-node system
|
|
17
|
+
where uptime is more important than strict consistency. Data is held in memory and
|
|
18
|
+
automatically synchronized to NATS KV.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import brainless
|
|
25
|
+
|
|
26
|
+
# Connect to NATS and setup
|
|
27
|
+
await brainless.setup(nats, namespace="myapp", location="prague-1")
|
|
28
|
+
|
|
29
|
+
# Add entity - schema inferred from first add()
|
|
30
|
+
call = brainless.call.add(channel_id="123", queue_id=5, state="waiting")
|
|
31
|
+
|
|
32
|
+
# Attribute access with auto dirty tracking
|
|
33
|
+
call.state = "ringing" # marked dirty, flushed in background
|
|
34
|
+
|
|
35
|
+
# Find and filter
|
|
36
|
+
call = await brainless.call.find(channel_id="123")
|
|
37
|
+
waiting = await brainless.call.filter(state="waiting")
|
|
38
|
+
|
|
39
|
+
# Delete
|
|
40
|
+
del brainless.call[call]
|
|
41
|
+
|
|
42
|
+
# Cleanup
|
|
43
|
+
await brainless.stop()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Type-Safe Usage
|
|
47
|
+
|
|
48
|
+
Use dataclasses for type hints and IDE support:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from typing import Optional
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Call:
|
|
56
|
+
channel_id: str
|
|
57
|
+
queue_id: int
|
|
58
|
+
state: str
|
|
59
|
+
uuid: Optional[str] = None # auto-populated from entity
|
|
60
|
+
|
|
61
|
+
# Set type for collection - all queries return typed instances
|
|
62
|
+
brainless.call.typed(Call)
|
|
63
|
+
|
|
64
|
+
# Now find/filter/all/order_by return Call instances
|
|
65
|
+
call = await brainless.call.find(channel_id="123")
|
|
66
|
+
print(call.channel_id) # IDE knows this is str
|
|
67
|
+
print(call.uuid) # UUID auto-populated
|
|
68
|
+
|
|
69
|
+
# Add from dataclass
|
|
70
|
+
call = brainless.call.add(Call(channel_id="456", queue_id=1, state="new"))
|
|
71
|
+
|
|
72
|
+
# Delete works with typed instances (uses uuid attribute)
|
|
73
|
+
del brainless.call[call]
|
|
74
|
+
if call in brainless.call:
|
|
75
|
+
print("still exists")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### One-off Type Conversion
|
|
79
|
+
|
|
80
|
+
Convert individual entities without setting collection type:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
entity = await brainless.call.find(channel_id="123")
|
|
84
|
+
call = entity.as_type(Call) # Convert to dataclass
|
|
85
|
+
uuid = entity.uuid # Still have access to entity
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Collections
|
|
89
|
+
|
|
90
|
+
Collections are created on first access and persist to NATS KV buckets:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# These create/access collections automatically
|
|
94
|
+
brainless.call.add(...) # creates 'myapp-call' bucket
|
|
95
|
+
brainless.user.add(...) # creates 'myapp-user' bucket
|
|
96
|
+
brainless.queue_item.add(...) # creates 'myapp-queue_item' bucket
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Adding Entities
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# From keyword arguments
|
|
103
|
+
call = brainless.call.add(channel_id="123", state="waiting")
|
|
104
|
+
|
|
105
|
+
# From dictionary
|
|
106
|
+
call = brainless.call.add({"channel_id": "456", "state": "ringing"})
|
|
107
|
+
|
|
108
|
+
# From dataclass instance
|
|
109
|
+
call = brainless.call.add(Call(channel_id="789", queue_id=1, state="active"))
|
|
110
|
+
|
|
111
|
+
# Mixed - dataclass + overrides
|
|
112
|
+
call = brainless.call.add(base_call, state="overridden")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Retrieving Entities
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# By UUID
|
|
119
|
+
call = await brainless.call.get("550e8400-e29b-41d4-a716-446655440000")
|
|
120
|
+
|
|
121
|
+
# Find first match (or None)
|
|
122
|
+
call = await brainless.call.find(channel_id="123")
|
|
123
|
+
call = await brainless.call.find(state="waiting", queue_id=5)
|
|
124
|
+
|
|
125
|
+
# Filter all matches
|
|
126
|
+
waiting = await brainless.call.filter(state="waiting")
|
|
127
|
+
active_q5 = await brainless.call.filter(queue_id=5, state="active")
|
|
128
|
+
|
|
129
|
+
# Get all
|
|
130
|
+
all_calls = await brainless.call.all()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Nested Filtering
|
|
134
|
+
|
|
135
|
+
Use double underscore for nested field access (Django-style):
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# Filter by nested fields
|
|
139
|
+
call = await brainless.call.find(caller__city="Prague")
|
|
140
|
+
calls = await brainless.call.filter(contact__address__zip="12345")
|
|
141
|
+
|
|
142
|
+
# Works with order_by too
|
|
143
|
+
calls = await brainless.call.order_by("caller__name")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Sorting
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# Ascending
|
|
150
|
+
calls = await brainless.call.order_by("priority")
|
|
151
|
+
|
|
152
|
+
# Descending (minus prefix)
|
|
153
|
+
calls = await brainless.call.order_by("-created_at")
|
|
154
|
+
|
|
155
|
+
# Multiple keys
|
|
156
|
+
calls = await brainless.call.order_by("state", "-priority")
|
|
157
|
+
|
|
158
|
+
# With filter criteria
|
|
159
|
+
calls = await brainless.call.order_by("-created_at", state="active")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Deleting Entities
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# By UUID string
|
|
166
|
+
brainless.call.delete("550e8400-e29b-41d4-a716-446655440000")
|
|
167
|
+
|
|
168
|
+
# By Entity instance
|
|
169
|
+
brainless.call.delete(entity)
|
|
170
|
+
|
|
171
|
+
# By typed dataclass (uses uuid attribute)
|
|
172
|
+
brainless.call.delete(call)
|
|
173
|
+
|
|
174
|
+
# Dict-style deletion
|
|
175
|
+
del brainless.call[call]
|
|
176
|
+
del brainless.call["550e8400-..."]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Collection Info
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
count = brainless.call.count()
|
|
183
|
+
count = len(brainless.call)
|
|
184
|
+
|
|
185
|
+
# Check existence (accepts uuid string, Entity, or typed object)
|
|
186
|
+
if "550e8400-..." in brainless.call:
|
|
187
|
+
...
|
|
188
|
+
if call in brainless.call:
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
# Iteration
|
|
192
|
+
for call in brainless.call:
|
|
193
|
+
print(call.channel_id)
|
|
194
|
+
|
|
195
|
+
# Dict-style access by UUID
|
|
196
|
+
call = brainless.call["550e8400-..."]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Entity Access
|
|
200
|
+
|
|
201
|
+
Entities wrap stored data with attribute access and dirty tracking:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
# Attribute access
|
|
205
|
+
print(call.state)
|
|
206
|
+
call.state = "ringing" # auto-marks dirty
|
|
207
|
+
|
|
208
|
+
# Dict-style access
|
|
209
|
+
print(call["state"])
|
|
210
|
+
call["state"] = "active"
|
|
211
|
+
|
|
212
|
+
# Check field exists
|
|
213
|
+
if "queue_id" in call:
|
|
214
|
+
print(call.queue_id)
|
|
215
|
+
|
|
216
|
+
# Get data
|
|
217
|
+
data = call.to_dict() # {"uuid": "...", "channel_id": "123", ...}
|
|
218
|
+
data = call.data # same but without uuid
|
|
219
|
+
|
|
220
|
+
# Properties
|
|
221
|
+
call.uuid # entity UUID
|
|
222
|
+
call.dirty # True if modified since last flush
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Persistence
|
|
226
|
+
|
|
227
|
+
### Background Flush
|
|
228
|
+
|
|
229
|
+
Changes are flushed to NATS automatically in the background:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# Default: flush every 0.5 seconds
|
|
233
|
+
await brainless.setup(nats, namespace="app")
|
|
234
|
+
|
|
235
|
+
# Custom interval
|
|
236
|
+
await brainless.setup(nats, namespace="app", flush_interval=1.0)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Manual Flush
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
# Flush all collections
|
|
243
|
+
await brainless.flush()
|
|
244
|
+
|
|
245
|
+
# Flush single collection
|
|
246
|
+
await brainless.call.flush()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Graceful Shutdown
|
|
250
|
+
|
|
251
|
+
Always call stop() for final flush:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
await brainless.stop()
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Instance-Based Usage
|
|
258
|
+
|
|
259
|
+
For tests or multiple databases:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from brainless import Brainless
|
|
263
|
+
|
|
264
|
+
db = Brainless(nats, namespace="test", location="local")
|
|
265
|
+
await db.start()
|
|
266
|
+
|
|
267
|
+
call = db.call.add(channel_id="456", state="waiting")
|
|
268
|
+
call = await db.call.find(channel_id="456")
|
|
269
|
+
|
|
270
|
+
await db.stop()
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### In-Memory Mode
|
|
274
|
+
|
|
275
|
+
Works without NATS for testing:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
db = Brainless(None, namespace="test")
|
|
279
|
+
await db.start()
|
|
280
|
+
|
|
281
|
+
# All operations work, just no persistence
|
|
282
|
+
call = db.call.add(channel_id="123", state="new")
|
|
283
|
+
call = await db.call.find(channel_id="123")
|
|
284
|
+
|
|
285
|
+
await db.stop()
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Use Cases
|
|
289
|
+
|
|
290
|
+
### Queue Management
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
@dataclass
|
|
294
|
+
class QueueItem:
|
|
295
|
+
priority: int
|
|
296
|
+
caller_id: str
|
|
297
|
+
queue_id: int
|
|
298
|
+
state: str
|
|
299
|
+
uuid: Optional[str] = None
|
|
300
|
+
|
|
301
|
+
brainless.queue.typed(QueueItem)
|
|
302
|
+
|
|
303
|
+
# Add to queue
|
|
304
|
+
item = brainless.queue.add(QueueItem(
|
|
305
|
+
priority=1,
|
|
306
|
+
caller_id="caller-123",
|
|
307
|
+
queue_id=5,
|
|
308
|
+
state="waiting"
|
|
309
|
+
))
|
|
310
|
+
|
|
311
|
+
# Get next item by priority
|
|
312
|
+
next_item = await brainless.queue.order_by("priority", state="waiting")
|
|
313
|
+
if next_item:
|
|
314
|
+
next_item[0].state = "processing"
|
|
315
|
+
|
|
316
|
+
# Remove completed
|
|
317
|
+
del brainless.queue[item]
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Session Storage
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
@dataclass
|
|
324
|
+
class Session:
|
|
325
|
+
user_id: int
|
|
326
|
+
token: str
|
|
327
|
+
created_at: float
|
|
328
|
+
last_seen: float
|
|
329
|
+
uuid: Optional[str] = None
|
|
330
|
+
|
|
331
|
+
brainless.session.typed(Session)
|
|
332
|
+
|
|
333
|
+
# Create session
|
|
334
|
+
session = brainless.session.add(Session(
|
|
335
|
+
user_id=42,
|
|
336
|
+
token="abc123",
|
|
337
|
+
created_at=time.time(),
|
|
338
|
+
last_seen=time.time()
|
|
339
|
+
))
|
|
340
|
+
|
|
341
|
+
# Find by token
|
|
342
|
+
session = await brainless.session.find(token="abc123")
|
|
343
|
+
|
|
344
|
+
# Update last seen
|
|
345
|
+
session.last_seen = time.time()
|
|
346
|
+
|
|
347
|
+
# Find stale sessions
|
|
348
|
+
stale = await brainless.session.filter(last_seen__lt=time.time() - 3600)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Call State Tracking
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
@dataclass
|
|
355
|
+
class Call:
|
|
356
|
+
channel_id: str
|
|
357
|
+
state: str
|
|
358
|
+
caller: dict
|
|
359
|
+
queue_id: Optional[int] = None
|
|
360
|
+
agent_id: Optional[int] = None
|
|
361
|
+
uuid: Optional[str] = None
|
|
362
|
+
|
|
363
|
+
brainless.call.typed(Call)
|
|
364
|
+
|
|
365
|
+
# New call
|
|
366
|
+
call = brainless.call.add(Call(
|
|
367
|
+
channel_id="chan-123",
|
|
368
|
+
state="ringing",
|
|
369
|
+
caller={"number": "+1234567890", "city": "Prague"}
|
|
370
|
+
))
|
|
371
|
+
|
|
372
|
+
# Find by nested field
|
|
373
|
+
call = await brainless.call.find(caller__city="Prague")
|
|
374
|
+
|
|
375
|
+
# State transitions
|
|
376
|
+
call.state = "answered"
|
|
377
|
+
call.agent_id = 5
|
|
378
|
+
|
|
379
|
+
# Get calls by agent
|
|
380
|
+
agent_calls = await brainless.call.filter(agent_id=5, state="answered")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Multi-Location Sync
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
# Prague datacenter
|
|
387
|
+
await brainless.setup(nats, namespace="app", location="prague-1")
|
|
388
|
+
|
|
389
|
+
# UUIDs include location for conflict resolution
|
|
390
|
+
call = brainless.call.add(channel_id="123", state="new")
|
|
391
|
+
print(call.uuid) # "550e8400-e29b-41d4-a716-446655440000" (UUID1 with location-based node)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Architecture
|
|
395
|
+
|
|
396
|
+
- **Schema inference**: Schema locked after first `add()` to collection
|
|
397
|
+
- **Lazy indexing**: O(1) lookups, indexes built on first filter/find
|
|
398
|
+
- **Background flush**: Async persistence with configurable interval
|
|
399
|
+
- **NATS KV storage**: One bucket per collection (`{namespace}-{collection}`)
|
|
400
|
+
- **UUID1 generation**: Time-based UUIDs with location-derived node ID
|
|
401
|
+
|
|
402
|
+
## API Reference
|
|
403
|
+
|
|
404
|
+
### Global Functions
|
|
405
|
+
|
|
406
|
+
| Function | Description |
|
|
407
|
+
|----------|-------------|
|
|
408
|
+
| `await setup(nats, namespace, location, flush_interval)` | Initialize global instance |
|
|
409
|
+
| `await stop()` | Stop and final flush |
|
|
410
|
+
| `await flush()` | Flush all collections |
|
|
411
|
+
| `brainless.{name}` | Access collection by name |
|
|
412
|
+
|
|
413
|
+
### Collection Methods
|
|
414
|
+
|
|
415
|
+
| Method | Description |
|
|
416
|
+
|--------|-------------|
|
|
417
|
+
| `typed(cls)` | Set dataclass type for results |
|
|
418
|
+
| `add(data, **kwargs)` | Add entity (dict, dataclass, or kwargs) |
|
|
419
|
+
| `await get(uuid)` | Get by UUID |
|
|
420
|
+
| `await find(**criteria)` | Find first match |
|
|
421
|
+
| `await filter(**criteria)` | Find all matches |
|
|
422
|
+
| `await all()` | Get all entities |
|
|
423
|
+
| `await order_by(*keys, **criteria)` | Sorted results |
|
|
424
|
+
| `delete(entity)` | Delete by uuid/entity/typed object |
|
|
425
|
+
| `count()` / `len()` | Entity count |
|
|
426
|
+
| `await load()` | Load from NATS |
|
|
427
|
+
| `await flush()` | Flush to NATS |
|
|
428
|
+
| `clear()` | Clear in-memory (not NATS) |
|
|
429
|
+
|
|
430
|
+
### Entity Properties & Methods
|
|
431
|
+
|
|
432
|
+
| Property/Method | Description |
|
|
433
|
+
|-----------------|-------------|
|
|
434
|
+
| `uuid` | Entity UUID |
|
|
435
|
+
| `data` | Raw data dict (no uuid) |
|
|
436
|
+
| `dirty` | Modified since flush |
|
|
437
|
+
| `to_dict()` | Data with uuid |
|
|
438
|
+
| `as_type(cls)` | Convert to dataclass |
|
|
439
|
+
|
|
440
|
+
## Status
|
|
441
|
+
|
|
442
|
+
🚧 **Work in progress**
|
|
443
|
+
|
|
444
|
+
- [x] Collections with add/get/delete/filter/find
|
|
445
|
+
- [x] Entity dirty tracking
|
|
446
|
+
- [x] NATS KV bucket integration
|
|
447
|
+
- [x] Background flush
|
|
448
|
+
- [x] Lazy auto-indexing for O(1) lookups
|
|
449
|
+
- [x] Sorted iteration with order_by
|
|
450
|
+
- [x] Type-safe dataclass conversion
|
|
451
|
+
- [x] UUID auto-population in typed results
|
|
452
|
+
- [ ] Schema storage in NATS
|
|
453
|
+
- [ ] Watch for changes from other nodes
|
|
454
|
+
- [ ] CRDTs for conflict resolution
|