brainlessdb 0.6.0__tar.gz → 0.7.2__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.6.0 → brainlessdb-0.7.2}/CHANGELOG.md +25 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/PKG-INFO +20 -20
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/README.md +19 -19
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/__init__.py +3 -3
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/bucket.py +4 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/collection.py +25 -11
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/struct.py +3 -3
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/justfile +1 -1
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/pyproject.toml +1 -1
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/test_results.txt +40 -40
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/conftest.py +6 -6
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_collection.py +96 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_deserialization.py +15 -15
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_fields.py +6 -6
- brainlessdb-0.7.2/tests/test_load_errors.py +274 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/uv.lock +1 -1
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/.gitignore +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/.python-version +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/client.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/brainlessdb/fields.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/cliff.toml +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/code_style_guide.md +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/__init__.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/mock_nats.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_client.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_sync.py +0 -0
- {brainlessdb-0.6.0 → brainlessdb-0.7.2}/tests/test_thread_safety.py +0 -0
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## [0.7.2] - 2026-02-08
|
|
7
|
+
|
|
8
|
+
### 🐛 Bug Fixes
|
|
9
|
+
|
|
10
|
+
- Commit pyproject.toml and brainlessdb/__init__.py on just release
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [0.7.1] - 2026-02-08
|
|
14
|
+
|
|
15
|
+
### Fchore
|
|
16
|
+
|
|
17
|
+
- Renamed BrainlessStruct to BrainlessBucket and NestedStruct to BrainlessStruct
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## [0.7.0] - 2026-02-06
|
|
21
|
+
|
|
22
|
+
### 🚀 Features
|
|
23
|
+
|
|
24
|
+
- When deserialization fails log error and move on
|
|
25
|
+
|
|
26
|
+
### 🐛 Bug Fixes
|
|
27
|
+
|
|
28
|
+
- Preserve None fields in config serialization to prevent ValidationError on reload
|
|
29
|
+
|
|
30
|
+
|
|
6
31
|
## [0.6.0] - 2026-02-05
|
|
7
32
|
|
|
8
33
|
### 🚀 Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brainlessdb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Typed collections backed by NATS JetStream KV
|
|
5
5
|
Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,9 +18,9 @@ Typed collections backed by NATS JetStream KV. In-memory with background sync.
|
|
|
18
18
|
```python
|
|
19
19
|
from typing import Annotated
|
|
20
20
|
from msgspec import Meta
|
|
21
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
21
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
22
22
|
|
|
23
|
-
class UserV1(
|
|
23
|
+
class UserV1(BrainlessBucket):
|
|
24
24
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})]
|
|
25
25
|
name: Annotated[str, Meta()] = ""
|
|
26
26
|
|
|
@@ -46,24 +46,24 @@ await db.stop()
|
|
|
46
46
|
|
|
47
47
|
| Class | Use for | Has UUID | Stored in |
|
|
48
48
|
|-------|---------|----------|-----------|
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
49
|
+
| `BrainlessBucket` | Main entities (User, Order, etc.) | Yes | Own bucket(s) |
|
|
50
|
+
| `BrainlessStruct` | Nested data, app-local data | No | Inside parent entity |
|
|
51
51
|
|
|
52
52
|
```python
|
|
53
|
-
from brainlessdb import
|
|
53
|
+
from brainlessdb import BrainlessBucket, BrainlessStruct
|
|
54
54
|
|
|
55
|
-
# App-local data -
|
|
56
|
-
class UcsLocal(
|
|
55
|
+
# App-local data - BrainlessStruct (no UUID, not an entity)
|
|
56
|
+
class UcsLocal(BrainlessStruct):
|
|
57
57
|
sid: int = 0
|
|
58
58
|
pointer: int = 0
|
|
59
59
|
|
|
60
|
-
# Nested data -
|
|
61
|
-
class Address(
|
|
60
|
+
# Nested data - BrainlessStruct
|
|
61
|
+
class Address(BrainlessStruct):
|
|
62
62
|
street: str = ""
|
|
63
63
|
city: str = ""
|
|
64
64
|
|
|
65
|
-
# Main entity -
|
|
66
|
-
class UserV1(
|
|
65
|
+
# Main entity - BrainlessBucket (has UUID, stored in NATS)
|
|
66
|
+
class UserV1(BrainlessBucket):
|
|
67
67
|
id: Annotated[int, Meta()]
|
|
68
68
|
address: Optional[Address] = None # nested struct
|
|
69
69
|
_: Optional[UcsLocal] = None # app-local struct
|
|
@@ -87,7 +87,7 @@ await brainlessdb.stop()
|
|
|
87
87
|
Persistent data synced across all instances:
|
|
88
88
|
|
|
89
89
|
```python
|
|
90
|
-
class UserV1(
|
|
90
|
+
class UserV1(BrainlessBucket):
|
|
91
91
|
id: Annotated[int, Meta()]
|
|
92
92
|
name: Annotated[str, Meta()] = ""
|
|
93
93
|
```
|
|
@@ -96,7 +96,7 @@ class UserV1(BrainlessStruct):
|
|
|
96
96
|
Ephemeral data (separate bucket, faster sync):
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
|
-
class UserV1(
|
|
99
|
+
class UserV1(BrainlessBucket):
|
|
100
100
|
id: Annotated[int, Meta()]
|
|
101
101
|
status: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})] = 0
|
|
102
102
|
```
|
|
@@ -105,7 +105,7 @@ class UserV1(BrainlessStruct):
|
|
|
105
105
|
Fast O(1) lookups:
|
|
106
106
|
|
|
107
107
|
```python
|
|
108
|
-
class UserV1(
|
|
108
|
+
class UserV1(BrainlessBucket):
|
|
109
109
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})]
|
|
110
110
|
```
|
|
111
111
|
|
|
@@ -113,7 +113,7 @@ class UserV1(BrainlessStruct):
|
|
|
113
113
|
Enforces uniqueness constraint (also auto-indexed):
|
|
114
114
|
|
|
115
115
|
```python
|
|
116
|
-
class UserV1(
|
|
116
|
+
class UserV1(BrainlessBucket):
|
|
117
117
|
email: Annotated[Optional[str], Meta(extra={"brainlessdb_flags": BrainlessDBFeat.UNIQUE})] = None
|
|
118
118
|
```
|
|
119
119
|
|
|
@@ -127,16 +127,16 @@ counter: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX |
|
|
|
127
127
|
Data private to each namespace:
|
|
128
128
|
|
|
129
129
|
```python
|
|
130
|
-
from brainlessdb import
|
|
130
|
+
from brainlessdb import BrainlessBucket, BrainlessStruct
|
|
131
131
|
|
|
132
|
-
class UcsLocal(
|
|
132
|
+
class UcsLocal(BrainlessStruct):
|
|
133
133
|
sid: int = 0
|
|
134
134
|
|
|
135
|
-
class AriLocal(
|
|
135
|
+
class AriLocal(BrainlessStruct):
|
|
136
136
|
channel_id: str = ""
|
|
137
137
|
|
|
138
138
|
# Entity with app-local field
|
|
139
|
-
class UserV1(
|
|
139
|
+
class UserV1(BrainlessBucket):
|
|
140
140
|
id: Annotated[int, Meta()]
|
|
141
141
|
_: Union[UcsLocal, AriLocal, None] = None
|
|
142
142
|
```
|
|
@@ -7,9 +7,9 @@ Typed collections backed by NATS JetStream KV. In-memory with background sync.
|
|
|
7
7
|
```python
|
|
8
8
|
from typing import Annotated
|
|
9
9
|
from msgspec import Meta
|
|
10
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
10
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
11
11
|
|
|
12
|
-
class UserV1(
|
|
12
|
+
class UserV1(BrainlessBucket):
|
|
13
13
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})]
|
|
14
14
|
name: Annotated[str, Meta()] = ""
|
|
15
15
|
|
|
@@ -35,24 +35,24 @@ await db.stop()
|
|
|
35
35
|
|
|
36
36
|
| Class | Use for | Has UUID | Stored in |
|
|
37
37
|
|-------|---------|----------|-----------|
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
38
|
+
| `BrainlessBucket` | Main entities (User, Order, etc.) | Yes | Own bucket(s) |
|
|
39
|
+
| `BrainlessStruct` | Nested data, app-local data | No | Inside parent entity |
|
|
40
40
|
|
|
41
41
|
```python
|
|
42
|
-
from brainlessdb import
|
|
42
|
+
from brainlessdb import BrainlessBucket, BrainlessStruct
|
|
43
43
|
|
|
44
|
-
# App-local data -
|
|
45
|
-
class UcsLocal(
|
|
44
|
+
# App-local data - BrainlessStruct (no UUID, not an entity)
|
|
45
|
+
class UcsLocal(BrainlessStruct):
|
|
46
46
|
sid: int = 0
|
|
47
47
|
pointer: int = 0
|
|
48
48
|
|
|
49
|
-
# Nested data -
|
|
50
|
-
class Address(
|
|
49
|
+
# Nested data - BrainlessStruct
|
|
50
|
+
class Address(BrainlessStruct):
|
|
51
51
|
street: str = ""
|
|
52
52
|
city: str = ""
|
|
53
53
|
|
|
54
|
-
# Main entity -
|
|
55
|
-
class UserV1(
|
|
54
|
+
# Main entity - BrainlessBucket (has UUID, stored in NATS)
|
|
55
|
+
class UserV1(BrainlessBucket):
|
|
56
56
|
id: Annotated[int, Meta()]
|
|
57
57
|
address: Optional[Address] = None # nested struct
|
|
58
58
|
_: Optional[UcsLocal] = None # app-local struct
|
|
@@ -76,7 +76,7 @@ await brainlessdb.stop()
|
|
|
76
76
|
Persistent data synced across all instances:
|
|
77
77
|
|
|
78
78
|
```python
|
|
79
|
-
class UserV1(
|
|
79
|
+
class UserV1(BrainlessBucket):
|
|
80
80
|
id: Annotated[int, Meta()]
|
|
81
81
|
name: Annotated[str, Meta()] = ""
|
|
82
82
|
```
|
|
@@ -85,7 +85,7 @@ class UserV1(BrainlessStruct):
|
|
|
85
85
|
Ephemeral data (separate bucket, faster sync):
|
|
86
86
|
|
|
87
87
|
```python
|
|
88
|
-
class UserV1(
|
|
88
|
+
class UserV1(BrainlessBucket):
|
|
89
89
|
id: Annotated[int, Meta()]
|
|
90
90
|
status: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})] = 0
|
|
91
91
|
```
|
|
@@ -94,7 +94,7 @@ class UserV1(BrainlessStruct):
|
|
|
94
94
|
Fast O(1) lookups:
|
|
95
95
|
|
|
96
96
|
```python
|
|
97
|
-
class UserV1(
|
|
97
|
+
class UserV1(BrainlessBucket):
|
|
98
98
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})]
|
|
99
99
|
```
|
|
100
100
|
|
|
@@ -102,7 +102,7 @@ class UserV1(BrainlessStruct):
|
|
|
102
102
|
Enforces uniqueness constraint (also auto-indexed):
|
|
103
103
|
|
|
104
104
|
```python
|
|
105
|
-
class UserV1(
|
|
105
|
+
class UserV1(BrainlessBucket):
|
|
106
106
|
email: Annotated[Optional[str], Meta(extra={"brainlessdb_flags": BrainlessDBFeat.UNIQUE})] = None
|
|
107
107
|
```
|
|
108
108
|
|
|
@@ -116,16 +116,16 @@ counter: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX |
|
|
|
116
116
|
Data private to each namespace:
|
|
117
117
|
|
|
118
118
|
```python
|
|
119
|
-
from brainlessdb import
|
|
119
|
+
from brainlessdb import BrainlessBucket, BrainlessStruct
|
|
120
120
|
|
|
121
|
-
class UcsLocal(
|
|
121
|
+
class UcsLocal(BrainlessStruct):
|
|
122
122
|
sid: int = 0
|
|
123
123
|
|
|
124
|
-
class AriLocal(
|
|
124
|
+
class AriLocal(BrainlessStruct):
|
|
125
125
|
channel_id: str = ""
|
|
126
126
|
|
|
127
127
|
# Entity with app-local field
|
|
128
|
-
class UserV1(
|
|
128
|
+
class UserV1(BrainlessBucket):
|
|
129
129
|
id: Annotated[int, Meta()]
|
|
130
130
|
_: Union[UcsLocal, AriLocal, None] = None
|
|
131
131
|
```
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
"""Brainless DB - Typed collections backed by NATS JetStream KV."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.7.2"
|
|
4
4
|
|
|
5
5
|
from typing import Any, Optional, TypeVar
|
|
6
6
|
|
|
7
7
|
from .client import BrainlessDB
|
|
8
8
|
from .collection import Collection, HasUUID
|
|
9
|
-
from .struct import BrainlessDBFeat,
|
|
9
|
+
from .struct import BrainlessDBFeat, BrainlessBucket, BrainlessStruct, UniqueConstraintError
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"__version__",
|
|
13
13
|
"BrainlessDB",
|
|
14
14
|
"BrainlessDBFeat",
|
|
15
|
+
"BrainlessBucket",
|
|
15
16
|
"BrainlessStruct",
|
|
16
|
-
"NestedStruct",
|
|
17
17
|
"Collection",
|
|
18
18
|
"HasUUID",
|
|
19
19
|
"UniqueConstraintError",
|
|
@@ -11,7 +11,7 @@ import msgspec
|
|
|
11
11
|
from .bucket import Bucket
|
|
12
12
|
from .fields import analyze_fields
|
|
13
13
|
from .struct import (
|
|
14
|
-
|
|
14
|
+
BrainlessBucket,
|
|
15
15
|
ConfigWrapper,
|
|
16
16
|
UniqueConstraintError,
|
|
17
17
|
_register,
|
|
@@ -45,8 +45,8 @@ class Collection(Generic[T]):
|
|
|
45
45
|
"""Collection of structs backed by NATS KV buckets."""
|
|
46
46
|
|
|
47
47
|
def __init__(self, client: "BrainlessDB", cls: type[T]) -> None:
|
|
48
|
-
if
|
|
49
|
-
raise TypeError(f"{cls.__name__} must directly inherit from
|
|
48
|
+
if BrainlessBucket not in cls.__bases__:
|
|
49
|
+
raise TypeError(f"{cls.__name__} must directly inherit from BrainlessBucket")
|
|
50
50
|
|
|
51
51
|
self._client = client
|
|
52
52
|
self._cls = cls
|
|
@@ -156,8 +156,7 @@ class Collection(Generic[T]):
|
|
|
156
156
|
data = {}
|
|
157
157
|
for f in self._analysis.config_fields:
|
|
158
158
|
val = getattr(obj, f, None)
|
|
159
|
-
if val
|
|
160
|
-
data[f] = msgspec.to_builtins(val) if hasattr(val, "__struct_fields__") else val
|
|
159
|
+
data[f] = msgspec.to_builtins(val) if hasattr(val, "__struct_fields__") else val
|
|
161
160
|
return data
|
|
162
161
|
|
|
163
162
|
def _to_state(self, obj: T) -> dict[str, Any]:
|
|
@@ -202,7 +201,13 @@ class Collection(Generic[T]):
|
|
|
202
201
|
async def _load_bucket(bucket: Optional[Bucket]) -> dict[str, dict]:
|
|
203
202
|
if not bucket:
|
|
204
203
|
return {}
|
|
205
|
-
|
|
204
|
+
result: dict[str, dict] = {}
|
|
205
|
+
for k, v in (await bucket.all()).items():
|
|
206
|
+
try:
|
|
207
|
+
result[k] = msgspec.json.decode(v)
|
|
208
|
+
except Exception as ex:
|
|
209
|
+
_log.error("Failed to decode key '%s' from bucket '%s': %s", k, bucket.name, ex)
|
|
210
|
+
return result
|
|
206
211
|
|
|
207
212
|
# === CRUD ===
|
|
208
213
|
|
|
@@ -336,7 +341,11 @@ class Collection(Generic[T]):
|
|
|
336
341
|
config_entries: dict[str, tuple[dict, Any]] = {}
|
|
337
342
|
if self._config_bucket:
|
|
338
343
|
for uid, raw in (await self._config_bucket.all()).items():
|
|
339
|
-
|
|
344
|
+
try:
|
|
345
|
+
w = msgspec.json.decode(raw, type=ConfigWrapper)
|
|
346
|
+
except Exception as ex:
|
|
347
|
+
_log.error("Failed to decode config for '%s' in '%s': %s", uid, self.name, ex)
|
|
348
|
+
continue
|
|
340
349
|
config_entries[uid] = (w.d, w.m)
|
|
341
350
|
self._metadata[uid] = w.m
|
|
342
351
|
|
|
@@ -345,15 +354,20 @@ class Collection(Generic[T]):
|
|
|
345
354
|
|
|
346
355
|
# Build entities
|
|
347
356
|
for uid, (cfg, _) in config_entries.items():
|
|
348
|
-
|
|
357
|
+
try:
|
|
358
|
+
obj = self._from_parts(uid, cfg, state.get(uid), local.get(uid))
|
|
359
|
+
except Exception as ex:
|
|
360
|
+
_log.error("Failed to build entity '%s' in '%s': %s", uid, self.name, ex)
|
|
361
|
+
continue
|
|
349
362
|
self._entities[uid] = obj
|
|
350
363
|
self._index_add(obj)
|
|
351
364
|
_register(obj.uuid, self)
|
|
352
365
|
|
|
353
366
|
self._loaded = True
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
count = len(self._entities)
|
|
368
|
+
if count:
|
|
369
|
+
_log.info("Loaded %d entities into '%s'", count, self.name)
|
|
370
|
+
return count
|
|
357
371
|
|
|
358
372
|
async def flush(self) -> int:
|
|
359
373
|
connected = await self._ensure_buckets()
|
|
@@ -38,11 +38,11 @@ def _unregister(uuid: UUID) -> None:
|
|
|
38
38
|
_registry.pop(uuid, None)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
class
|
|
41
|
+
class BrainlessStruct(Struct, tag=True, tag_field="TYPE"):
|
|
42
42
|
"""Base struct for nested/embedded types."""
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
class
|
|
45
|
+
class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
46
46
|
"""Base struct for brainlessdb entities."""
|
|
47
47
|
|
|
48
48
|
_uuid: UUID = field(default_factory=uuid1)
|
|
@@ -57,7 +57,7 @@ class BrainlessStruct(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
57
57
|
if collection:
|
|
58
58
|
collection.add(self)
|
|
59
59
|
|
|
60
|
-
def update(self, data: dict) -> "
|
|
60
|
+
def update(self, data: dict) -> "BrainlessBucket":
|
|
61
61
|
"""Update fields from dict, supports nested structs."""
|
|
62
62
|
for key, value in data.items():
|
|
63
63
|
if not hasattr(self, key):
|
|
@@ -6,7 +6,7 @@ create-changelog:
|
|
|
6
6
|
release: create-changelog
|
|
7
7
|
awk -i inplace '{ sub(/^version = "[0-9]+\.[0-9]+\.[0-9]+"/, "version = \"{{next_version}}\"") }; { print }' pyproject.toml
|
|
8
8
|
awk -i inplace '{ sub(/^__version__ = "[0-9]+\.[0-9]+\.[0-9]+"/, "__version__ = \"{{next_version}}\"") }; { print }' brainlessdb/__init__.py
|
|
9
|
-
git add --ignore-errors CHANGELOG.md
|
|
9
|
+
git add --ignore-errors CHANGELOG.md pyproject.toml uv.lock brainlessdb/__init__.py
|
|
10
10
|
git commit -m {{next_version}}
|
|
11
11
|
git tag -a {{next_version}} -m {{next_version}}
|
|
12
12
|
|
|
@@ -425,14 +425,14 @@ self = <tests.test_deserialization.TestRealWorldUserPattern object at 0x720273f1
|
|
|
425
425
|
"""Test Optional[Annotated[int, Meta(...)]] pattern like queue_reason."""
|
|
426
426
|
from enum import IntEnum
|
|
427
427
|
import msgspec
|
|
428
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
428
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
429
429
|
from brainlessdb.struct import ConfigWrapper
|
|
430
|
-
|
|
430
|
+
|
|
431
431
|
class Status(IntEnum):
|
|
432
432
|
OFFLINE = 0
|
|
433
433
|
ONLINE = 1
|
|
434
|
-
|
|
435
|
-
class UserLike(
|
|
434
|
+
|
|
435
|
+
class UserLike(BrainlessBucket):
|
|
436
436
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})] = 0
|
|
437
437
|
group_id: int = 0
|
|
438
438
|
# Pattern from queue_reason - Optional[Annotated[int, ...]]
|
|
@@ -440,7 +440,7 @@ self = <tests.test_deserialization.TestRealWorldUserPattern object at 0x720273f1
|
|
|
440
440
|
queue_status_changed: Optional[Annotated[int, Meta(description="")]] = None
|
|
441
441
|
# State field with enum
|
|
442
442
|
status: Annotated[Status, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})] = Status.OFFLINE
|
|
443
|
-
|
|
443
|
+
|
|
444
444
|
db = BrainlessDB(namespace="test")
|
|
445
445
|
> coll = await db.collection(UserLike)
|
|
446
446
|
E TypeError: object Collection can't be used in 'await' expression
|
|
@@ -454,17 +454,17 @@ self = <tests.test_deserialization.TestRealWorldUserPattern object at 0x720273f1
|
|
|
454
454
|
async def test_nested_struct_pattern(self):
|
|
455
455
|
"""Test nested struct like AgentChannelsV1."""
|
|
456
456
|
import msgspec
|
|
457
|
-
from brainlessdb import BrainlessDB,
|
|
457
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
458
458
|
from brainlessdb.struct import ConfigWrapper
|
|
459
|
-
|
|
459
|
+
|
|
460
460
|
class Channels(Struct):
|
|
461
461
|
voice: int = 0
|
|
462
462
|
chat: int = 0
|
|
463
|
-
|
|
464
|
-
class UserWithChannels(
|
|
463
|
+
|
|
464
|
+
class UserWithChannels(BrainlessBucket):
|
|
465
465
|
id: int = 0
|
|
466
466
|
channels: Channels = field(default_factory=Channels)
|
|
467
|
-
|
|
467
|
+
|
|
468
468
|
db = BrainlessDB(namespace="test")
|
|
469
469
|
> coll = await db.collection(UserWithChannels)
|
|
470
470
|
E TypeError: object Collection can't be used in 'await' expression
|
|
@@ -478,17 +478,17 @@ self = <tests.test_deserialization.TestRealWorldUserPattern object at 0x720273f1
|
|
|
478
478
|
async def test_vcard_list_pattern(self):
|
|
479
479
|
"""Test list of nested structs like vcard: list[VCardV1]."""
|
|
480
480
|
import msgspec
|
|
481
|
-
from brainlessdb import BrainlessDB,
|
|
481
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
482
482
|
from brainlessdb.struct import ConfigWrapper
|
|
483
|
-
|
|
483
|
+
|
|
484
484
|
class VCard(Struct):
|
|
485
485
|
name: str = ""
|
|
486
486
|
value: str = ""
|
|
487
|
-
|
|
488
|
-
class UserWithVCard(
|
|
487
|
+
|
|
488
|
+
class UserWithVCard(BrainlessBucket):
|
|
489
489
|
id: int = 0
|
|
490
490
|
vcard: Optional[list[VCard]] = None
|
|
491
|
-
|
|
491
|
+
|
|
492
492
|
db = BrainlessDB(namespace="test")
|
|
493
493
|
> coll = await db.collection(UserWithVCard)
|
|
494
494
|
E TypeError: object Collection can't be used in 'await' expression
|
|
@@ -501,18 +501,18 @@ self = <tests.test_deserialization.TestLocalClassDetection object at 0x720273f19
|
|
|
501
501
|
@pytest.mark.asyncio
|
|
502
502
|
async def test_optional_annotated_union_pattern(self):
|
|
503
503
|
"""Test Optional[Annotated[Union[A, B], Meta()]] pattern from UserV2."""
|
|
504
|
-
from brainlessdb import BrainlessDB,
|
|
505
|
-
|
|
504
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
505
|
+
|
|
506
506
|
class AriLocal(Struct):
|
|
507
507
|
counter: int = 0
|
|
508
|
-
|
|
508
|
+
|
|
509
509
|
class UcsLocal(Struct):
|
|
510
510
|
counter: int = 0
|
|
511
|
-
|
|
512
|
-
class UserWithUnionLocal(
|
|
511
|
+
|
|
512
|
+
class UserWithUnionLocal(BrainlessBucket):
|
|
513
513
|
id: int = 0
|
|
514
514
|
_: Optional[Annotated[Union[UcsLocal, AriLocal], Meta()]] = None
|
|
515
|
-
|
|
515
|
+
|
|
516
516
|
# Test with "ari" namespace - should find AriLocal
|
|
517
517
|
db_ari = BrainlessDB(namespace="ari")
|
|
518
518
|
> coll_ari = await db_ari.collection(UserWithUnionLocal)
|
|
@@ -526,15 +526,15 @@ self = <tests.test_deserialization.TestLocalClassDetection object at 0x720273f19
|
|
|
526
526
|
@pytest.mark.asyncio
|
|
527
527
|
async def test_optional_single_class_pattern(self):
|
|
528
528
|
"""Test Optional[LocalClass] pattern."""
|
|
529
|
-
from brainlessdb import BrainlessDB,
|
|
530
|
-
|
|
529
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
530
|
+
|
|
531
531
|
class TestLocal(Struct):
|
|
532
532
|
value: int = 0
|
|
533
|
-
|
|
534
|
-
class UserWithLocal(
|
|
533
|
+
|
|
534
|
+
class UserWithLocal(BrainlessBucket):
|
|
535
535
|
id: int = 0
|
|
536
536
|
_: Optional[TestLocal] = None
|
|
537
|
-
|
|
537
|
+
|
|
538
538
|
db = BrainlessDB(namespace="test")
|
|
539
539
|
> coll = await db.collection(UserWithLocal)
|
|
540
540
|
E TypeError: object Collection can't be used in 'await' expression
|
|
@@ -547,18 +547,18 @@ self = <tests.test_deserialization.TestLocalClassDetection object at 0x720273f12
|
|
|
547
547
|
@pytest.mark.asyncio
|
|
548
548
|
async def test_union_without_optional_pattern(self):
|
|
549
549
|
"""Test Union[A, B] (without Optional) pattern."""
|
|
550
|
-
from brainlessdb import BrainlessDB,
|
|
551
|
-
|
|
550
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
551
|
+
|
|
552
552
|
class AriData(Struct):
|
|
553
553
|
x: int = 0
|
|
554
|
-
|
|
554
|
+
|
|
555
555
|
class UcsData(Struct):
|
|
556
556
|
x: int = 0
|
|
557
|
-
|
|
558
|
-
class UserUnion(
|
|
557
|
+
|
|
558
|
+
class UserUnion(BrainlessBucket):
|
|
559
559
|
id: int = 0
|
|
560
560
|
_: Union[UcsData, AriData] = field(default_factory=UcsData)
|
|
561
|
-
|
|
561
|
+
|
|
562
562
|
db = BrainlessDB(namespace="ari")
|
|
563
563
|
> coll = await db.collection(UserUnion)
|
|
564
564
|
E TypeError: object Collection can't be used in 'await' expression
|
|
@@ -572,12 +572,12 @@ self = <tests.test_sync.TestMultiLocationSync object at 0x720273f197c0>
|
|
|
572
572
|
async def test_two_locations_see_same_data(self):
|
|
573
573
|
"""Both locations can read data written by either."""
|
|
574
574
|
nats = MockNats()
|
|
575
|
-
|
|
575
|
+
|
|
576
576
|
db1 = BrainlessDB(nats, namespace="loc1", flush_interval=0)
|
|
577
577
|
db2 = BrainlessDB(nats, namespace="loc2", flush_interval=0)
|
|
578
578
|
await db1.start()
|
|
579
579
|
await db2.start()
|
|
580
|
-
|
|
580
|
+
|
|
581
581
|
> users1 = await db1.collection(UserV1)
|
|
582
582
|
E TypeError: object Collection can't be used in 'await' expression
|
|
583
583
|
|
|
@@ -590,12 +590,12 @@ self = <tests.test_sync.TestMultiLocationSync object at 0x720273f125b0>
|
|
|
590
590
|
async def test_watch_receives_remote_changes(self):
|
|
591
591
|
"""Watch fires when other location makes changes."""
|
|
592
592
|
nats = MockNats()
|
|
593
|
-
|
|
593
|
+
|
|
594
594
|
db1 = BrainlessDB(nats, namespace="loc1", flush_interval=0)
|
|
595
595
|
db2 = BrainlessDB(nats, namespace="loc2", flush_interval=0)
|
|
596
596
|
await db1.start()
|
|
597
597
|
await db2.start()
|
|
598
|
-
|
|
598
|
+
|
|
599
599
|
> users1 = await db1.collection(UserV1)
|
|
600
600
|
E TypeError: object Collection can't be used in 'await' expression
|
|
601
601
|
|
|
@@ -608,10 +608,10 @@ self = <tests.test_sync.TestMultiLocationSync object at 0x720273eadd00>
|
|
|
608
608
|
async def test_location_tracked_in_metadata(self):
|
|
609
609
|
"""Config bucket tracks which location made the change."""
|
|
610
610
|
nats = MockNats()
|
|
611
|
-
|
|
611
|
+
|
|
612
612
|
db1 = BrainlessDB(nats, namespace="loc1", flush_interval=0)
|
|
613
613
|
await db1.start()
|
|
614
|
-
|
|
614
|
+
|
|
615
615
|
> users1 = await db1.collection(UserV1)
|
|
616
616
|
E TypeError: object Collection can't be used in 'await' expression
|
|
617
617
|
|
|
@@ -624,12 +624,12 @@ self = <tests.test_sync.TestMultiLocationSync object at 0x720273ead040>
|
|
|
624
624
|
async def test_remote_delete_fires_event(self):
|
|
625
625
|
"""Delete from one location fires event on other."""
|
|
626
626
|
nats = MockNats()
|
|
627
|
-
|
|
627
|
+
|
|
628
628
|
db1 = BrainlessDB(nats, namespace="loc1", flush_interval=0)
|
|
629
629
|
db2 = BrainlessDB(nats, namespace="loc2", flush_interval=0)
|
|
630
630
|
await db1.start()
|
|
631
631
|
await db2.start()
|
|
632
|
-
|
|
632
|
+
|
|
633
633
|
> users1 = await db1.collection(UserV1)
|
|
634
634
|
E TypeError: object Collection can't be used in 'await' expression
|
|
635
635
|
|
|
@@ -5,10 +5,10 @@ from typing import Annotated, Optional
|
|
|
5
5
|
import pytest
|
|
6
6
|
from msgspec import Meta
|
|
7
7
|
|
|
8
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
8
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class UserV1(
|
|
11
|
+
class UserV1(BrainlessBucket):
|
|
12
12
|
"""Test user struct with indexed username."""
|
|
13
13
|
|
|
14
14
|
id: Annotated[int, Meta()] = 0
|
|
@@ -20,21 +20,21 @@ class UserV1(BrainlessStruct):
|
|
|
20
20
|
active: Annotated[bool, Meta()] = True
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
class ChannelV1(
|
|
23
|
+
class ChannelV1(BrainlessBucket):
|
|
24
24
|
"""Test channel struct."""
|
|
25
25
|
|
|
26
26
|
id: Annotated[str, Meta()] = ""
|
|
27
27
|
name: Annotated[str, Meta()] = ""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class AgentV1(
|
|
30
|
+
class AgentV1(BrainlessBucket):
|
|
31
31
|
"""Test agent struct."""
|
|
32
32
|
|
|
33
33
|
id: Annotated[int, Meta()] = 0
|
|
34
34
|
channel: Optional[ChannelV1] = None
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
class QueueItemV1(
|
|
37
|
+
class QueueItemV1(BrainlessBucket):
|
|
38
38
|
"""Test queue item with nested structs."""
|
|
39
39
|
|
|
40
40
|
inbound_id: Annotated[
|
|
@@ -45,7 +45,7 @@ class QueueItemV1(BrainlessStruct):
|
|
|
45
45
|
agents: Optional[AgentV1] = None
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
class UniqueUserV1(
|
|
48
|
+
class UniqueUserV1(BrainlessBucket):
|
|
49
49
|
"""Test struct with UNIQUE field."""
|
|
50
50
|
|
|
51
51
|
id: int = 0
|
|
@@ -515,3 +515,99 @@ class TestUnique:
|
|
|
515
515
|
unique_users.add(UniqueUserV1(id=1, email=""))
|
|
516
516
|
with pytest.raises(UniqueConstraintError):
|
|
517
517
|
unique_users.add(UniqueUserV1(id=2, email=""))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class TestOptionalFieldRoundTrip:
|
|
521
|
+
"""Test that Optional fields without defaults survive config round-trip."""
|
|
522
|
+
|
|
523
|
+
@pytest.mark.asyncio
|
|
524
|
+
async def test_none_optional_field_preserved_in_config(self):
|
|
525
|
+
"""Optional field set to None must be stored and restored."""
|
|
526
|
+
from typing import Optional
|
|
527
|
+
|
|
528
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
529
|
+
|
|
530
|
+
class DeviceV1(BrainlessBucket):
|
|
531
|
+
name: str = ""
|
|
532
|
+
host_override: Optional[str] = None
|
|
533
|
+
|
|
534
|
+
db = BrainlessDB(namespace="test")
|
|
535
|
+
devices = db.collection(DeviceV1)
|
|
536
|
+
await db.start()
|
|
537
|
+
|
|
538
|
+
device = DeviceV1(name="phone1")
|
|
539
|
+
devices.add(device)
|
|
540
|
+
|
|
541
|
+
config = devices._to_config(device)
|
|
542
|
+
assert "host_override" in config
|
|
543
|
+
assert config["host_override"] is None
|
|
544
|
+
|
|
545
|
+
restored = devices._from_parts(str(device.uuid), config)
|
|
546
|
+
assert restored.host_override is None
|
|
547
|
+
assert restored.name == "phone1"
|
|
548
|
+
|
|
549
|
+
@pytest.mark.asyncio
|
|
550
|
+
async def test_none_field_survives_json_round_trip(self):
|
|
551
|
+
"""Optional[str] = None must survive JSON encode/decode cycle."""
|
|
552
|
+
from typing import Optional
|
|
553
|
+
|
|
554
|
+
import msgspec
|
|
555
|
+
|
|
556
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
557
|
+
from brainlessdb.struct import ConfigWrapper
|
|
558
|
+
|
|
559
|
+
class DeviceV1(BrainlessBucket):
|
|
560
|
+
name: str = ""
|
|
561
|
+
host_override: Optional[str] = None
|
|
562
|
+
|
|
563
|
+
db = BrainlessDB(namespace="test")
|
|
564
|
+
devices = db.collection(DeviceV1)
|
|
565
|
+
await db.start()
|
|
566
|
+
|
|
567
|
+
device = DeviceV1(name="phone1")
|
|
568
|
+
devices.add(device)
|
|
569
|
+
|
|
570
|
+
# Simulate flush + load cycle
|
|
571
|
+
config = devices._to_config(device)
|
|
572
|
+
wrapper = ConfigWrapper.create(config, "test")
|
|
573
|
+
raw = msgspec.json.encode(wrapper)
|
|
574
|
+
decoded = msgspec.json.decode(raw, type=ConfigWrapper)
|
|
575
|
+
|
|
576
|
+
restored = devices._from_parts(str(device.uuid), decoded.d)
|
|
577
|
+
assert restored.host_override is None
|
|
578
|
+
assert restored.name == "phone1"
|
|
579
|
+
|
|
580
|
+
@pytest.mark.asyncio
|
|
581
|
+
async def test_none_field_with_nats_flush_reload(self):
|
|
582
|
+
"""Full flush/reload through mock NATS preserves None fields."""
|
|
583
|
+
from typing import Optional
|
|
584
|
+
|
|
585
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
586
|
+
from tests.mock_nats import MockNats
|
|
587
|
+
|
|
588
|
+
class DeviceV1(BrainlessBucket):
|
|
589
|
+
name: str = ""
|
|
590
|
+
host_override: Optional[str] = None
|
|
591
|
+
|
|
592
|
+
nats = MockNats()
|
|
593
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
594
|
+
devices = db.collection(DeviceV1)
|
|
595
|
+
await db.start()
|
|
596
|
+
|
|
597
|
+
device = DeviceV1(name="phone1")
|
|
598
|
+
devices.add(device)
|
|
599
|
+
await db.flush()
|
|
600
|
+
device_uuid = str(device.uuid)
|
|
601
|
+
|
|
602
|
+
# Reload from scratch
|
|
603
|
+
await db.unwatch()
|
|
604
|
+
devices.clear()
|
|
605
|
+
devices._loaded = False
|
|
606
|
+
await devices.load()
|
|
607
|
+
|
|
608
|
+
restored = devices.get(device_uuid)
|
|
609
|
+
assert restored is not None
|
|
610
|
+
assert restored.name == "phone1"
|
|
611
|
+
assert restored.host_override is None
|
|
612
|
+
|
|
613
|
+
await db.stop()
|
|
@@ -9,7 +9,7 @@ from typing import Annotated, Optional, Union
|
|
|
9
9
|
import pytest
|
|
10
10
|
from msgspec import Meta, Struct, field
|
|
11
11
|
|
|
12
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
12
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
13
13
|
from brainlessdb.collection import Collection
|
|
14
14
|
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ class LocalData(Struct):
|
|
|
20
20
|
count: int = 0
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
class EntityWithIntFields(
|
|
23
|
+
class EntityWithIntFields(BrainlessBucket):
|
|
24
24
|
"""Entity with various int fields in different buckets."""
|
|
25
25
|
|
|
26
26
|
# Config field (no flags)
|
|
@@ -48,7 +48,7 @@ class EntityWithIntFields(BrainlessStruct):
|
|
|
48
48
|
_: Optional[LocalData] = None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
class EntityMixedTypes(
|
|
51
|
+
class EntityMixedTypes(BrainlessBucket):
|
|
52
52
|
"""Entity with mixed types to test type preservation."""
|
|
53
53
|
|
|
54
54
|
int_field: int = 0
|
|
@@ -427,14 +427,14 @@ class TestRealWorldUserPattern:
|
|
|
427
427
|
"""Test Optional[Annotated[int, Meta(...)]] pattern like queue_reason."""
|
|
428
428
|
from enum import IntEnum
|
|
429
429
|
import msgspec
|
|
430
|
-
from brainlessdb import BrainlessDB, BrainlessDBFeat,
|
|
430
|
+
from brainlessdb import BrainlessDB, BrainlessDBFeat, BrainlessBucket
|
|
431
431
|
from brainlessdb.struct import ConfigWrapper
|
|
432
432
|
|
|
433
433
|
class Status(IntEnum):
|
|
434
434
|
OFFLINE = 0
|
|
435
435
|
ONLINE = 1
|
|
436
436
|
|
|
437
|
-
class UserLike(
|
|
437
|
+
class UserLike(BrainlessBucket):
|
|
438
438
|
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})] = 0
|
|
439
439
|
group_id: int = 0
|
|
440
440
|
# Pattern from queue_reason - Optional[Annotated[int, ...]]
|
|
@@ -490,14 +490,14 @@ class TestRealWorldUserPattern:
|
|
|
490
490
|
async def test_nested_struct_pattern(self):
|
|
491
491
|
"""Test nested struct like AgentChannelsV1."""
|
|
492
492
|
import msgspec
|
|
493
|
-
from brainlessdb import BrainlessDB,
|
|
493
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
494
494
|
from brainlessdb.struct import ConfigWrapper
|
|
495
495
|
|
|
496
496
|
class Channels(Struct):
|
|
497
497
|
voice: int = 0
|
|
498
498
|
chat: int = 0
|
|
499
499
|
|
|
500
|
-
class UserWithChannels(
|
|
500
|
+
class UserWithChannels(BrainlessBucket):
|
|
501
501
|
id: int = 0
|
|
502
502
|
channels: Channels = field(default_factory=Channels)
|
|
503
503
|
|
|
@@ -526,14 +526,14 @@ class TestRealWorldUserPattern:
|
|
|
526
526
|
async def test_vcard_list_pattern(self):
|
|
527
527
|
"""Test list of nested structs like vcard: list[VCardV1]."""
|
|
528
528
|
import msgspec
|
|
529
|
-
from brainlessdb import BrainlessDB,
|
|
529
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
530
530
|
from brainlessdb.struct import ConfigWrapper
|
|
531
531
|
|
|
532
532
|
class VCard(Struct):
|
|
533
533
|
name: str = ""
|
|
534
534
|
value: str = ""
|
|
535
535
|
|
|
536
|
-
class UserWithVCard(
|
|
536
|
+
class UserWithVCard(BrainlessBucket):
|
|
537
537
|
id: int = 0
|
|
538
538
|
vcard: Optional[list[VCard]] = None
|
|
539
539
|
|
|
@@ -568,7 +568,7 @@ class TestLocalClassDetection:
|
|
|
568
568
|
@pytest.mark.asyncio
|
|
569
569
|
async def test_optional_annotated_union_pattern(self):
|
|
570
570
|
"""Test Optional[Annotated[Union[A, B], Meta()]] pattern from UserV2."""
|
|
571
|
-
from brainlessdb import BrainlessDB,
|
|
571
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
572
572
|
|
|
573
573
|
class AriLocal(Struct):
|
|
574
574
|
counter: int = 0
|
|
@@ -576,7 +576,7 @@ class TestLocalClassDetection:
|
|
|
576
576
|
class UcsLocal(Struct):
|
|
577
577
|
counter: int = 0
|
|
578
578
|
|
|
579
|
-
class UserWithUnionLocal(
|
|
579
|
+
class UserWithUnionLocal(BrainlessBucket):
|
|
580
580
|
id: int = 0
|
|
581
581
|
_: Optional[Annotated[Union[UcsLocal, AriLocal], Meta()]] = None
|
|
582
582
|
|
|
@@ -593,12 +593,12 @@ class TestLocalClassDetection:
|
|
|
593
593
|
@pytest.mark.asyncio
|
|
594
594
|
async def test_optional_single_class_pattern(self):
|
|
595
595
|
"""Test Optional[LocalClass] pattern."""
|
|
596
|
-
from brainlessdb import BrainlessDB,
|
|
596
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
597
597
|
|
|
598
598
|
class TestLocal(Struct):
|
|
599
599
|
value: int = 0
|
|
600
600
|
|
|
601
|
-
class UserWithLocal(
|
|
601
|
+
class UserWithLocal(BrainlessBucket):
|
|
602
602
|
id: int = 0
|
|
603
603
|
_: Optional[TestLocal] = None
|
|
604
604
|
|
|
@@ -609,7 +609,7 @@ class TestLocalClassDetection:
|
|
|
609
609
|
@pytest.mark.asyncio
|
|
610
610
|
async def test_union_without_optional_pattern(self):
|
|
611
611
|
"""Test Union[A, B] (without Optional) pattern."""
|
|
612
|
-
from brainlessdb import BrainlessDB,
|
|
612
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
613
613
|
|
|
614
614
|
class AriData(Struct):
|
|
615
615
|
x: int = 0
|
|
@@ -617,7 +617,7 @@ class TestLocalClassDetection:
|
|
|
617
617
|
class UcsData(Struct):
|
|
618
618
|
x: int = 0
|
|
619
619
|
|
|
620
|
-
class UserUnion(
|
|
620
|
+
class UserUnion(BrainlessBucket):
|
|
621
621
|
id: int = 0
|
|
622
622
|
_: Union[UcsData, AriData] = field(default_factory=UcsData)
|
|
623
623
|
|
|
@@ -4,7 +4,7 @@ from typing import Annotated, Optional, Union
|
|
|
4
4
|
|
|
5
5
|
from msgspec import Meta, Struct
|
|
6
6
|
|
|
7
|
-
from brainlessdb import BrainlessDBFeat,
|
|
7
|
+
from brainlessdb import BrainlessDBFeat, BrainlessBucket
|
|
8
8
|
from brainlessdb.fields import analyze_fields
|
|
9
9
|
|
|
10
10
|
|
|
@@ -17,14 +17,14 @@ class AriV1(Struct):
|
|
|
17
17
|
channel_id: str = ""
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
class SimpleV1(
|
|
20
|
+
class SimpleV1(BrainlessBucket):
|
|
21
21
|
"""Simple struct with no state or app-local."""
|
|
22
22
|
|
|
23
23
|
id: Annotated[int, Meta()] = 0
|
|
24
24
|
name: Annotated[str, Meta()] = ""
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
class WithStateV1(
|
|
27
|
+
class WithStateV1(BrainlessBucket):
|
|
28
28
|
"""Struct with state fields."""
|
|
29
29
|
|
|
30
30
|
id: Annotated[int, Meta()] = 0
|
|
@@ -39,21 +39,21 @@ class WithStateV1(BrainlessStruct):
|
|
|
39
39
|
] = 0
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
class WithLocalV1(
|
|
42
|
+
class WithLocalV1(BrainlessBucket):
|
|
43
43
|
"""Struct with app-local field."""
|
|
44
44
|
|
|
45
45
|
id: Annotated[int, Meta()] = 0
|
|
46
46
|
_: Optional[UcsV1] = None
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
class WithUnionLocalV1(
|
|
49
|
+
class WithUnionLocalV1(BrainlessBucket):
|
|
50
50
|
"""Struct with Union app-local field."""
|
|
51
51
|
|
|
52
52
|
id: Annotated[int, Meta()] = 0
|
|
53
53
|
_: Union[UcsV1, AriV1, None] = None
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
class FullV1(
|
|
56
|
+
class FullV1(BrainlessBucket):
|
|
57
57
|
"""Struct with config, state, and app-local."""
|
|
58
58
|
|
|
59
59
|
id: Annotated[int, Meta()] = 0
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Test graceful error handling during bucket deserialization.
|
|
2
|
+
|
|
3
|
+
Verifies that corrupt items in buckets are skipped with a logged error
|
|
4
|
+
while valid items still load successfully.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from uuid import uuid1
|
|
9
|
+
|
|
10
|
+
import msgspec
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from brainlessdb import BrainlessDB
|
|
14
|
+
from brainlessdb.struct import ConfigWrapper
|
|
15
|
+
from tests.conftest import UserV1
|
|
16
|
+
from tests.mock_nats import MockNats
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def nats():
|
|
21
|
+
return MockNats()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
async def db_and_users(nats):
|
|
26
|
+
"""Create a DB with UserV1 collection connected to mock NATS."""
|
|
27
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
28
|
+
users = db.collection(UserV1)
|
|
29
|
+
return db, users
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestCorruptConfigBucket:
|
|
33
|
+
"""Test load() skips corrupt entries in config bucket."""
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_corrupt_config_entry_skipped(self, nats, db_and_users, caplog):
|
|
37
|
+
db, users = db_and_users
|
|
38
|
+
await db.start()
|
|
39
|
+
|
|
40
|
+
# Add a valid user and flush
|
|
41
|
+
valid = UserV1(id=1, username="alice")
|
|
42
|
+
users.add(valid)
|
|
43
|
+
await db.flush()
|
|
44
|
+
valid_uuid = str(valid.uuid)
|
|
45
|
+
|
|
46
|
+
# Stop watch to avoid interference during reload
|
|
47
|
+
await db.unwatch()
|
|
48
|
+
|
|
49
|
+
# Inject corrupt data directly into config bucket
|
|
50
|
+
js = nats.jetstream()
|
|
51
|
+
kv = await js.key_value("UserV1")
|
|
52
|
+
await kv.put("corrupt-uuid-1", b"not valid json {{{")
|
|
53
|
+
|
|
54
|
+
# Reload collection
|
|
55
|
+
users.clear()
|
|
56
|
+
users._loaded = False
|
|
57
|
+
|
|
58
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
59
|
+
count = await users.load()
|
|
60
|
+
|
|
61
|
+
assert count == 1
|
|
62
|
+
assert users.get(valid_uuid) is not None
|
|
63
|
+
assert users.get("corrupt-uuid-1") is None
|
|
64
|
+
assert "Failed to decode config for 'corrupt-uuid-1'" in caplog.text
|
|
65
|
+
|
|
66
|
+
await db.stop()
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_all_corrupt_config_loads_zero(self, nats, db_and_users, caplog):
|
|
70
|
+
db, users = db_and_users
|
|
71
|
+
|
|
72
|
+
# Inject only corrupt data before start
|
|
73
|
+
js = nats.jetstream()
|
|
74
|
+
kv = await js.create_key_value(type("C", (), {"bucket": "UserV1"})())
|
|
75
|
+
await kv.put("bad-1", b"???")
|
|
76
|
+
await kv.put("bad-2", b"{invalid")
|
|
77
|
+
|
|
78
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
79
|
+
await db.start()
|
|
80
|
+
|
|
81
|
+
assert len(users) == 0
|
|
82
|
+
assert "Failed to decode config" in caplog.text
|
|
83
|
+
|
|
84
|
+
await db.stop()
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_valid_entries_survive_alongside_corrupt(self, nats, db_and_users, caplog):
|
|
88
|
+
db, users = db_and_users
|
|
89
|
+
await db.start()
|
|
90
|
+
|
|
91
|
+
# Flush two valid users
|
|
92
|
+
u1 = UserV1(id=1, username="alice")
|
|
93
|
+
u2 = UserV1(id=2, username="bob")
|
|
94
|
+
users.add(u1)
|
|
95
|
+
users.add(u2)
|
|
96
|
+
await db.flush()
|
|
97
|
+
|
|
98
|
+
# Stop watch to avoid interference during reload
|
|
99
|
+
await db.unwatch()
|
|
100
|
+
|
|
101
|
+
# Inject corrupt entry
|
|
102
|
+
js = nats.jetstream()
|
|
103
|
+
kv = await js.key_value("UserV1")
|
|
104
|
+
await kv.put("corrupt-1", b"nope")
|
|
105
|
+
|
|
106
|
+
# Reload
|
|
107
|
+
users.clear()
|
|
108
|
+
users._loaded = False
|
|
109
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
110
|
+
count = await users.load()
|
|
111
|
+
|
|
112
|
+
assert count == 2
|
|
113
|
+
assert users.find(username="alice") is not None
|
|
114
|
+
assert users.find(username="bob") is not None
|
|
115
|
+
|
|
116
|
+
await db.stop()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestCorruptStateBucket:
|
|
120
|
+
"""Test load() skips corrupt entries in state bucket."""
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_corrupt_state_entry_skipped(self, nats, caplog):
|
|
124
|
+
from typing import Annotated
|
|
125
|
+
|
|
126
|
+
from msgspec import Meta
|
|
127
|
+
|
|
128
|
+
from brainlessdb import BrainlessDBFeat, BrainlessBucket
|
|
129
|
+
|
|
130
|
+
class AgentV1(BrainlessBucket):
|
|
131
|
+
id: int = 0
|
|
132
|
+
status: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})] = 0
|
|
133
|
+
|
|
134
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
135
|
+
agents = db.collection(AgentV1)
|
|
136
|
+
await db.start()
|
|
137
|
+
|
|
138
|
+
# Add valid agent and flush
|
|
139
|
+
a1 = AgentV1(id=1, status=1)
|
|
140
|
+
agents.add(a1)
|
|
141
|
+
await db.flush()
|
|
142
|
+
a1_uuid = str(a1.uuid)
|
|
143
|
+
|
|
144
|
+
# Stop watch to avoid interference
|
|
145
|
+
await db.unwatch()
|
|
146
|
+
|
|
147
|
+
# Inject corrupt state data
|
|
148
|
+
js = nats.jetstream()
|
|
149
|
+
kv = await js.key_value("AgentV1-State")
|
|
150
|
+
await kv.put("corrupt-state-uuid", b"not json!!!")
|
|
151
|
+
|
|
152
|
+
# Reload
|
|
153
|
+
agents.clear()
|
|
154
|
+
agents._loaded = False
|
|
155
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
156
|
+
count = await agents.load()
|
|
157
|
+
|
|
158
|
+
assert count == 1
|
|
159
|
+
assert agents.get(a1_uuid) is not None
|
|
160
|
+
assert "Failed to decode key 'corrupt-state-uuid' from bucket 'AgentV1-State'" in caplog.text
|
|
161
|
+
|
|
162
|
+
await db.stop()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestCorruptLocalBucket:
|
|
166
|
+
"""Test load() skips corrupt entries in local bucket."""
|
|
167
|
+
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_corrupt_local_entry_skipped(self, nats, caplog):
|
|
170
|
+
from typing import Optional
|
|
171
|
+
|
|
172
|
+
from msgspec import Struct
|
|
173
|
+
|
|
174
|
+
from brainlessdb import BrainlessDB, BrainlessBucket
|
|
175
|
+
|
|
176
|
+
class TestLocal(Struct):
|
|
177
|
+
sid: int = 0
|
|
178
|
+
|
|
179
|
+
class ItemV1(BrainlessBucket):
|
|
180
|
+
id: int = 0
|
|
181
|
+
_: Optional[TestLocal] = None
|
|
182
|
+
|
|
183
|
+
db = BrainlessDB(nats, namespace="test", flush_interval=0)
|
|
184
|
+
items = db.collection(ItemV1)
|
|
185
|
+
await db.start()
|
|
186
|
+
|
|
187
|
+
# Add valid item
|
|
188
|
+
i1 = ItemV1(id=1, _=TestLocal(sid=42))
|
|
189
|
+
items.add(i1)
|
|
190
|
+
await db.flush()
|
|
191
|
+
|
|
192
|
+
# Stop watch to avoid interference
|
|
193
|
+
await db.unwatch()
|
|
194
|
+
|
|
195
|
+
# Inject corrupt local data
|
|
196
|
+
js = nats.jetstream()
|
|
197
|
+
kv = await js.key_value("ItemV1-TestLocal")
|
|
198
|
+
await kv.put("corrupt-local-uuid", b"{{bad}}")
|
|
199
|
+
|
|
200
|
+
# Reload
|
|
201
|
+
items.clear()
|
|
202
|
+
items._loaded = False
|
|
203
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
204
|
+
count = await items.load()
|
|
205
|
+
|
|
206
|
+
assert count == 1
|
|
207
|
+
assert items.get(str(i1.uuid)) is not None
|
|
208
|
+
assert "Failed to decode key 'corrupt-local-uuid' from bucket 'ItemV1-TestLocal'" in caplog.text
|
|
209
|
+
|
|
210
|
+
await db.stop()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestCorruptEntityAssembly:
|
|
214
|
+
"""Test load() skips entities that fail during _from_parts assembly."""
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
async def test_invalid_config_data_skipped(self, nats, db_and_users, caplog):
|
|
218
|
+
"""Config data that decodes as JSON but fails struct conversion."""
|
|
219
|
+
db, users = db_and_users
|
|
220
|
+
|
|
221
|
+
js = nats.jetstream()
|
|
222
|
+
kv = await js.create_key_value(type("C", (), {"bucket": "UserV1"})())
|
|
223
|
+
|
|
224
|
+
# Valid ConfigWrapper but with data that will fail msgspec.convert
|
|
225
|
+
bad_uuid = str(uuid1())
|
|
226
|
+
bad_wrapper = ConfigWrapper.create({"id": "not-an-int", "username": [1, 2, 3]}, "test")
|
|
227
|
+
await kv.put(bad_uuid, msgspec.json.encode(bad_wrapper))
|
|
228
|
+
|
|
229
|
+
# Valid entry with proper UUID and data
|
|
230
|
+
good_uuid = str(uuid1())
|
|
231
|
+
good_wrapper = ConfigWrapper.create({"id": 1, "username": "alice"}, "test")
|
|
232
|
+
await kv.put(good_uuid, msgspec.json.encode(good_wrapper))
|
|
233
|
+
|
|
234
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
235
|
+
await db.start()
|
|
236
|
+
|
|
237
|
+
assert len(users) == 1
|
|
238
|
+
assert users.get(good_uuid) is not None
|
|
239
|
+
assert users.get(bad_uuid) is None
|
|
240
|
+
assert f"Failed to build entity '{bad_uuid}'" in caplog.text
|
|
241
|
+
|
|
242
|
+
await db.stop()
|
|
243
|
+
|
|
244
|
+
@pytest.mark.asyncio
|
|
245
|
+
async def test_mix_of_all_error_types(self, nats, db_and_users, caplog):
|
|
246
|
+
"""Corrupt config JSON, invalid assembly, and valid entries all together."""
|
|
247
|
+
db, users = db_and_users
|
|
248
|
+
|
|
249
|
+
js = nats.jetstream()
|
|
250
|
+
kv = await js.create_key_value(type("C", (), {"bucket": "UserV1"})())
|
|
251
|
+
|
|
252
|
+
# 1. Corrupt JSON - fails at decode
|
|
253
|
+
await kv.put("corrupt-json", b"not json")
|
|
254
|
+
|
|
255
|
+
# 2. Valid JSON, bad schema - fails at entity assembly
|
|
256
|
+
bad_uuid = str(uuid1())
|
|
257
|
+
bad_wrapper = ConfigWrapper.create({"id": [1, 2], "username": {"nested": True}}, "test")
|
|
258
|
+
await kv.put(bad_uuid, msgspec.json.encode(bad_wrapper))
|
|
259
|
+
|
|
260
|
+
# 3. Valid entry
|
|
261
|
+
good_uuid = str(uuid1())
|
|
262
|
+
good_wrapper = ConfigWrapper.create({"id": 42, "username": "valid"}, "test")
|
|
263
|
+
await kv.put(good_uuid, msgspec.json.encode(good_wrapper))
|
|
264
|
+
|
|
265
|
+
with caplog.at_level(logging.ERROR, logger="brainlessdb.collection"):
|
|
266
|
+
await db.start()
|
|
267
|
+
|
|
268
|
+
assert len(users) == 1
|
|
269
|
+
assert users.get(good_uuid) is not None
|
|
270
|
+
assert users.get(good_uuid).username == "valid"
|
|
271
|
+
assert "Failed to decode config for 'corrupt-json'" in caplog.text
|
|
272
|
+
assert f"Failed to build entity '{bad_uuid}'" in caplog.text
|
|
273
|
+
|
|
274
|
+
await db.stop()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|