brainlessdb 0.1.0__py3-none-any.whl
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.
- brainless/__init__.py +57 -0
- brainless/bucket.py +127 -0
- brainless/client.py +150 -0
- brainless/collection.py +628 -0
- brainless/entity.py +572 -0
- brainless/py.typed +0 -0
- brainless/schema.py +164 -0
- brainlessdb-0.1.0.dist-info/METADATA +454 -0
- brainlessdb-0.1.0.dist-info/RECORD +11 -0
- brainlessdb-0.1.0.dist-info/WHEEL +5 -0
- brainlessdb-0.1.0.dist-info/top_level.txt +1 -0
brainless/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Brainless - Schema-first async persistence for NATS JetStream KV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from brainless.client import Brainless
|
|
8
|
+
from brainless.collection import Collection
|
|
9
|
+
from brainless.entity import Entity
|
|
10
|
+
|
|
11
|
+
__all__ = ["Brainless", "Collection", "Entity", "setup", "stop", "flush"]
|
|
12
|
+
|
|
13
|
+
_instance: Brainless | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def setup(
|
|
17
|
+
nats: Any = None,
|
|
18
|
+
namespace: str = "default",
|
|
19
|
+
location: str = "local",
|
|
20
|
+
flush_interval: float = 0.5,
|
|
21
|
+
) -> Brainless:
|
|
22
|
+
"""Initialize and start the global Brainless instance
|
|
23
|
+
|
|
24
|
+
@param nats: Connected NATS client (optional for in-memory only mode)
|
|
25
|
+
@param namespace: Bucket prefix for isolation
|
|
26
|
+
@param location: Name of this site/node for UUID prefixes
|
|
27
|
+
@param flush_interval: Background flush interval in seconds
|
|
28
|
+
@return: The Brainless instance
|
|
29
|
+
"""
|
|
30
|
+
global _instance
|
|
31
|
+
if _instance is not None:
|
|
32
|
+
await _instance.stop()
|
|
33
|
+
_instance = Brainless(nats, namespace, location, flush_interval)
|
|
34
|
+
await _instance.start()
|
|
35
|
+
return _instance
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def stop() -> None:
|
|
39
|
+
"""Stop the global Brainless instance."""
|
|
40
|
+
global _instance
|
|
41
|
+
if _instance is not None:
|
|
42
|
+
await _instance.stop()
|
|
43
|
+
_instance = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def flush() -> int:
|
|
47
|
+
"""Flush all dirty entities in the global instance."""
|
|
48
|
+
if _instance is None:
|
|
49
|
+
raise RuntimeError("Call brainless.setup() first")
|
|
50
|
+
return await _instance.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str) -> Any:
|
|
54
|
+
"""Proxy attribute access to global instance for collection access."""
|
|
55
|
+
if _instance is None:
|
|
56
|
+
raise RuntimeError("Call brainless.setup() first")
|
|
57
|
+
return getattr(_instance, name)
|
brainless/bucket.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""NATS JetStream KV bucket wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from nats.js.errors import BucketNotFoundError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nats.js.kv import KeyValue
|
|
13
|
+
|
|
14
|
+
_log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Bucket:
|
|
18
|
+
"""Wrapper around NATS JetStream KV bucket
|
|
19
|
+
|
|
20
|
+
Provides simple get/put/delete operations with JSON serialization.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str, kv: KeyValue) -> None:
|
|
24
|
+
self._name = name
|
|
25
|
+
self._kv = kv
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return self._name
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
async def create(
|
|
33
|
+
cls,
|
|
34
|
+
js: Any,
|
|
35
|
+
name: str,
|
|
36
|
+
ttl: float | None = None,
|
|
37
|
+
max_bytes: int | None = None,
|
|
38
|
+
) -> Bucket:
|
|
39
|
+
"""Create or get existing KV bucket
|
|
40
|
+
|
|
41
|
+
@param js: JetStream context
|
|
42
|
+
@param name: Bucket name
|
|
43
|
+
@param ttl: Time-to-live in seconds (optional)
|
|
44
|
+
@param max_bytes: Max bucket size in bytes (optional)
|
|
45
|
+
@return: Bucket instance
|
|
46
|
+
"""
|
|
47
|
+
from nats.js.api import KeyValueConfig
|
|
48
|
+
|
|
49
|
+
config = KeyValueConfig(bucket=name)
|
|
50
|
+
if ttl is not None:
|
|
51
|
+
config.ttl = ttl
|
|
52
|
+
if max_bytes is not None:
|
|
53
|
+
config.max_bytes = max_bytes
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
kv = await js.key_value(name)
|
|
57
|
+
_log.debug("Using existing bucket: %s", name)
|
|
58
|
+
except BucketNotFoundError:
|
|
59
|
+
kv = await js.create_key_value(config)
|
|
60
|
+
_log.info("Created bucket: %s", name)
|
|
61
|
+
|
|
62
|
+
return cls(name, kv)
|
|
63
|
+
|
|
64
|
+
async def get(self, key: str) -> dict[str, Any] | None:
|
|
65
|
+
"""Get value by key
|
|
66
|
+
|
|
67
|
+
@param key: Key to retrieve
|
|
68
|
+
@return: Deserialized value or None if not found
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
entry = await self._kv.get(key)
|
|
72
|
+
if entry is None or entry.value is None:
|
|
73
|
+
return None
|
|
74
|
+
envelope = json.loads(entry.value.decode())
|
|
75
|
+
return envelope["data"]
|
|
76
|
+
except Exception as e:
|
|
77
|
+
if "not found" in str(e).lower():
|
|
78
|
+
return None
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
async def put(self, key: str, value: dict[str, Any]) -> None:
|
|
82
|
+
"""Store value by key
|
|
83
|
+
|
|
84
|
+
@param key: Key to store
|
|
85
|
+
@param value: Value to serialize and store
|
|
86
|
+
"""
|
|
87
|
+
envelope = {"version": "1", "data": value}
|
|
88
|
+
data = json.dumps(envelope).encode()
|
|
89
|
+
await self._kv.put(key, data)
|
|
90
|
+
|
|
91
|
+
async def delete(self, key: str) -> bool:
|
|
92
|
+
"""Delete key
|
|
93
|
+
|
|
94
|
+
@param key: Key to delete
|
|
95
|
+
@return: True if deleted, False if not found
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
await self._kv.delete(key)
|
|
99
|
+
return True
|
|
100
|
+
except Exception as e:
|
|
101
|
+
if "not found" in str(e).lower():
|
|
102
|
+
return False
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
async def keys(self) -> list[str]:
|
|
106
|
+
"""Get all keys in bucket
|
|
107
|
+
|
|
108
|
+
@return: List of keys
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
return await self._kv.keys()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
if "no keys" in str(e).lower():
|
|
114
|
+
return []
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
async def all(self) -> dict[str, dict[str, Any]]:
|
|
118
|
+
"""Get all key-value pairs
|
|
119
|
+
|
|
120
|
+
@return: Dictionary of all entries
|
|
121
|
+
"""
|
|
122
|
+
result = {}
|
|
123
|
+
for key in await self.keys():
|
|
124
|
+
value = await self.get(key)
|
|
125
|
+
if value is not None:
|
|
126
|
+
result[key] = value
|
|
127
|
+
return result
|
brainless/client.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Brainless main client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from brainless.bucket import Bucket
|
|
10
|
+
from brainless.collection import Collection
|
|
11
|
+
|
|
12
|
+
_log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Brainless:
|
|
16
|
+
"""Schema-first async persistence for NATS JetStream KV
|
|
17
|
+
|
|
18
|
+
Manages collections backed by NATS JetStream KV buckets. Schema is stored
|
|
19
|
+
in NATS itself, allowing data access without predefined classes.
|
|
20
|
+
|
|
21
|
+
Access collections as attributes: db.users, db.calls, etc.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
nats: Any = None,
|
|
27
|
+
namespace: str = "default",
|
|
28
|
+
location: str = "local",
|
|
29
|
+
flush_interval: float = 0.5,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize Brainless client
|
|
32
|
+
|
|
33
|
+
@param nats: Connected NATS client (optional for in-memory only mode)
|
|
34
|
+
@param namespace: Bucket prefix for isolation (e.g., "myapp" -> "myapp-calls")
|
|
35
|
+
@param location: Name of this site/node (e.g., "prague-1"), used as prefix
|
|
36
|
+
in record UUIDs and for ownership in multi-location sync
|
|
37
|
+
@param flush_interval: Background flush interval in seconds
|
|
38
|
+
"""
|
|
39
|
+
self._nats = nats
|
|
40
|
+
self._namespace = namespace
|
|
41
|
+
self._location = location
|
|
42
|
+
self._flush_interval = flush_interval
|
|
43
|
+
self._started = False
|
|
44
|
+
self._collections: dict[str, Collection] = {}
|
|
45
|
+
self._js: Any = None
|
|
46
|
+
self._flush_task: asyncio.Task | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def namespace(self) -> str:
|
|
50
|
+
return self._namespace
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def location(self) -> str:
|
|
54
|
+
return self._location
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def connected(self) -> bool:
|
|
58
|
+
"""True if connected to NATS."""
|
|
59
|
+
return self._nats is not None and self._js is not None
|
|
60
|
+
|
|
61
|
+
def _bucket_name(self, collection: str) -> str:
|
|
62
|
+
"""Generate bucket name for collection."""
|
|
63
|
+
return f"{self._namespace}-{collection}"
|
|
64
|
+
|
|
65
|
+
async def get_bucket(self, collection: str) -> Bucket | None:
|
|
66
|
+
"""Get or create bucket for collection."""
|
|
67
|
+
if not self.connected:
|
|
68
|
+
return None
|
|
69
|
+
name = self._bucket_name(collection)
|
|
70
|
+
return await Bucket.create(self._js, name)
|
|
71
|
+
|
|
72
|
+
def collection(self, name: str) -> Collection:
|
|
73
|
+
"""Get or create a collection by name
|
|
74
|
+
|
|
75
|
+
@param name: Collection name
|
|
76
|
+
@return: Collection instance
|
|
77
|
+
"""
|
|
78
|
+
if name not in self._collections:
|
|
79
|
+
self._collections[name] = Collection(self, name)
|
|
80
|
+
return self._collections[name]
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, name: str) -> Collection:
|
|
83
|
+
"""Access collections as attributes: db.users, db.calls, etc."""
|
|
84
|
+
if name.startswith("_"):
|
|
85
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
86
|
+
return self.collection(name)
|
|
87
|
+
|
|
88
|
+
async def _flush_loop(self) -> None:
|
|
89
|
+
"""Background task to flush dirty entities."""
|
|
90
|
+
try:
|
|
91
|
+
while self._started:
|
|
92
|
+
await asyncio.sleep(self._flush_interval)
|
|
93
|
+
await self.flush()
|
|
94
|
+
except asyncio.CancelledError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def flush(self) -> int:
|
|
98
|
+
"""Flush all dirty entities to NATS
|
|
99
|
+
|
|
100
|
+
@return: Number of entities flushed
|
|
101
|
+
"""
|
|
102
|
+
total = 0
|
|
103
|
+
for collection in self._collections.values():
|
|
104
|
+
total += await collection.flush()
|
|
105
|
+
return total
|
|
106
|
+
|
|
107
|
+
async def start(self) -> None:
|
|
108
|
+
"""Start the client and connect to NATS."""
|
|
109
|
+
if self._started:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Get JetStream context if NATS is available
|
|
113
|
+
if self._nats is not None:
|
|
114
|
+
self._js = self._nats.jetstream()
|
|
115
|
+
|
|
116
|
+
self._started = True
|
|
117
|
+
|
|
118
|
+
# Start background flush task
|
|
119
|
+
if self.connected:
|
|
120
|
+
self._flush_task = asyncio.create_task(self._flush_loop())
|
|
121
|
+
|
|
122
|
+
_log.info(
|
|
123
|
+
"Brainless started (namespace=%s, location=%s, connected=%s)",
|
|
124
|
+
self._namespace,
|
|
125
|
+
self._location,
|
|
126
|
+
self.connected,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def stop(self) -> None:
|
|
130
|
+
"""Stop the client and flush pending changes."""
|
|
131
|
+
if not self._started:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
self._started = False
|
|
135
|
+
|
|
136
|
+
# Cancel flush task
|
|
137
|
+
if self._flush_task is not None:
|
|
138
|
+
self._flush_task.cancel()
|
|
139
|
+
try:
|
|
140
|
+
await self._flush_task
|
|
141
|
+
except asyncio.CancelledError:
|
|
142
|
+
pass
|
|
143
|
+
self._flush_task = None
|
|
144
|
+
|
|
145
|
+
# Final flush
|
|
146
|
+
flushed = await self.flush()
|
|
147
|
+
if flushed > 0:
|
|
148
|
+
_log.info("Flushed %d entities on shutdown", flushed)
|
|
149
|
+
|
|
150
|
+
_log.info("Brainless stopped")
|