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.
@@ -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