brainlessdb 0.14.2__tar.gz → 0.14.4__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.
Files changed (31) hide show
  1. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/CHANGELOG.md +18 -0
  2. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/PKG-INFO +2 -2
  3. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/README.md +1 -1
  4. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/__init__.py +1 -1
  5. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/client.py +1 -1
  6. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/collection.py +25 -7
  7. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/pyproject.toml +1 -1
  8. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_dead_letter.py +117 -4
  9. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/uv.lock +1 -1
  10. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/.gitignore +0 -0
  11. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/.python-version +0 -0
  12. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/bucket.py +0 -0
  13. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/fields.py +0 -0
  14. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/brainlessdb/struct.py +0 -0
  15. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/cliff.toml +0 -0
  16. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/code_style_guide.md +0 -0
  17. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/justfile +0 -0
  18. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/test_results.txt +0 -0
  19. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/__init__.py +0 -0
  20. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/conftest.py +0 -0
  21. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/mock_nats.py +0 -0
  22. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_bucket.py +0 -0
  23. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_client.py +0 -0
  24. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_collection.py +0 -0
  25. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_deserialization.py +0 -0
  26. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_fields.py +0 -0
  27. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_flush_errors.py +0 -0
  28. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_load_errors.py +0 -0
  29. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_sync.py +0 -0
  30. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_thread_safety.py +0 -0
  31. {brainlessdb-0.14.2 → brainlessdb-0.14.4}/tests/test_version.py +0 -0
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
5
 
6
+ ## [0.14.4] - 2026-06-01
7
+
8
+ ### 📚 Documentation
9
+
10
+ - Dlq key format
11
+
12
+ ### 🧪 Testing
13
+
14
+ - Pull state+local for new entities in config watcher, avoid `:` in DLQ key
15
+
16
+
17
+ ## [0.14.3] - 2026-06-01
18
+
19
+ ### 🐛 Bug Fixes
20
+
21
+ - Pull state+local for new entities in config watcher, avoid `:` in DLQ key
22
+
23
+
6
24
  ## [0.14.2] - 2026-04-14
7
25
 
8
26
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainlessdb
3
- Version: 0.14.2
3
+ Version: 0.14.4
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
@@ -291,6 +291,6 @@ async for entry in bucket.watch(timeout=3):
291
291
 
292
292
  Entities that fail to deserialize (e.g. after a schema change) are automatically moved to the `brainless_dlq` NATS KV bucket and removed from the source bucket. This applies during both initial load and live watch updates.
293
293
 
294
- DLQ key format: `{SourceBucket}::{EntityUID}::{Timestamp}`
294
+ DLQ key format: `{SourceBucket}__{EntityUID}__{Timestamp}` (double underscore — NATS KV keys disallow `:`)
295
295
 
296
296
  Each entry contains `source_bucket`, `key`, `error`, `timestamp`, and the original `value` for recovery.
@@ -280,6 +280,6 @@ async for entry in bucket.watch(timeout=3):
280
280
 
281
281
  Entities that fail to deserialize (e.g. after a schema change) are automatically moved to the `brainless_dlq` NATS KV bucket and removed from the source bucket. This applies during both initial load and live watch updates.
282
282
 
283
- DLQ key format: `{SourceBucket}::{EntityUID}::{Timestamp}`
283
+ DLQ key format: `{SourceBucket}__{EntityUID}__{Timestamp}` (double underscore — NATS KV keys disallow `:`)
284
284
 
285
285
  Each entry contains `source_bucket`, `key`, `error`, `timestamp`, and the original `value` for recovery.
@@ -1,6 +1,6 @@
1
1
  """Brainless DB - Typed collections backed by NATS JetStream KV."""
2
2
 
3
- __version__ = "0.14.2"
3
+ __version__ = "0.14.4"
4
4
 
5
5
  from typing import Any, Optional, TypeVar
6
6
 
@@ -62,7 +62,7 @@ class BrainlessDB:
62
62
  "value": original,
63
63
  }
64
64
  ts = entry["timestamp"].replace(":", "-").replace("+", "-")
65
- dlq_key = f"{source_bucket}::{key}::{ts}"
65
+ dlq_key = f"{source_bucket}__{key}__{ts}"
66
66
  payload = json.dumps(entry).encode()
67
67
  try:
68
68
  if self._dlq_bucket is None:
@@ -226,6 +226,20 @@ class Collection(Generic[T]):
226
226
  _log.error("Failed to decode key '%s' from bucket '%s': %s", k, bucket.name, ex)
227
227
  return result
228
228
 
229
+ @staticmethod
230
+ async def _fetch_extra(bucket: Optional[Bucket], uid: str) -> Optional[dict]:
231
+ """Fetch a single state/local row for `uid`. Returns None if absent."""
232
+ if not bucket:
233
+ return None
234
+ raw = await bucket.get(uid)
235
+ if not raw:
236
+ return None
237
+ try:
238
+ return msgspec.json.decode(raw)
239
+ except Exception as ex:
240
+ _log.error("Failed to decode '%s' from bucket '%s': %s", uid, bucket.name, ex)
241
+ return None
242
+
229
243
  # === CRUD ===
230
244
 
231
245
  def add(self, obj: Union[T, dict[str, Any]]):
@@ -493,13 +507,17 @@ class Collection(Generic[T]):
493
507
  self._metadata[uid] = w.m
494
508
  if old:
495
509
  self._index_remove(old)
496
-
497
- new = self._from_parts(
498
- uid,
499
- w.d,
500
- self._to_state(old) if old else None,
501
- self._to_local(old),
502
- )
510
+ state_data = self._to_state(old)
511
+ local_data = self._to_local(old)
512
+ else:
513
+ # New entity (created by another process) — pull state
514
+ # and local from their buckets, since _to_state(None)
515
+ # would yield no STATE-flagged fields and required
516
+ # fields would be missing.
517
+ state_data = await self._fetch_extra(self._state_bucket, uid)
518
+ local_data = await self._fetch_extra(self._local_bucket, uid)
519
+
520
+ new = self._from_parts(uid, w.d, state_data, local_data)
503
521
  self._entities[uid] = new
504
522
  self._index_add(new)
505
523
  _register(new.uuid, self)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brainlessdb"
3
- version = "0.14.2"
3
+ version = "0.14.4"
4
4
  description = "Typed collections backed by NATS JetStream KV"
5
5
  authors = [
6
6
  {name = "INSOFT s.r.o.", email = "helpdesk@insoft.cz"}
@@ -29,9 +29,12 @@ def _kv_config(name: str):
29
29
  return type("C", (), {"bucket": name})()
30
30
 
31
31
 
32
+ DLQ_SEP = "__"
33
+
34
+
32
35
  def _dlq_find(dlq_kv, bucket: str, uid: str) -> list[dict]:
33
- """Find all DLQ entries matching bucket::uid prefix."""
34
- prefix = f"{bucket}::{uid}::"
36
+ """Find all DLQ entries matching the bucket+uid prefix."""
37
+ prefix = f"{bucket}{DLQ_SEP}{uid}{DLQ_SEP}"
35
38
  results = []
36
39
  for key, entry in dlq_kv._entries.items():
37
40
  if key.startswith(prefix):
@@ -40,8 +43,8 @@ def _dlq_find(dlq_kv, bucket: str, uid: str) -> list[dict]:
40
43
 
41
44
 
42
45
  def _dlq_has(dlq_kv, bucket: str, uid: str) -> bool:
43
- """Check if any DLQ entry exists for bucket::uid."""
44
- prefix = f"{bucket}::{uid}::"
46
+ """Check if any DLQ entry exists for the given bucket+uid."""
47
+ prefix = f"{bucket}{DLQ_SEP}{uid}{DLQ_SEP}"
45
48
  return any(k.startswith(prefix) for k in dlq_kv._entries)
46
49
 
47
50
 
@@ -316,3 +319,113 @@ class TestDeadLetterClientAPI:
316
319
  assert entries[0]["error"] == "error 2"
317
320
 
318
321
  await db.stop()
322
+
323
+
324
+ class TestDeadLetterKeyFormat:
325
+ """DLQ keys must be valid NATS KV keys (no `:`, `*`, `>`, spaces)."""
326
+
327
+ @pytest.mark.asyncio
328
+ async def test_dlq_key_has_no_colon(self):
329
+ """Regression: NATS KV rejects `:` in keys, so the DLQ key must not contain one."""
330
+ nats = MockNats()
331
+ db = BrainlessDB(nats, namespace="test")
332
+ await db.start()
333
+
334
+ await db.dead_letter("SomeBucket", "some-key", b'{"v": 1}', "boom")
335
+
336
+ dlq_kv = nats.jetstream()._buckets["brainless_dlq"]
337
+ assert len(dlq_kv._entries) == 1
338
+ (key,) = dlq_kv._entries.keys()
339
+ assert ":" not in key
340
+ # And the parts we expect are still recoverable from the key
341
+ assert key.startswith("SomeBucket")
342
+ assert "some-key" in key
343
+
344
+ await db.stop()
345
+
346
+
347
+ class TestWatcherStateMerge:
348
+ """Live config-bucket watcher must merge state for brand-new entities."""
349
+
350
+ @pytest.mark.asyncio
351
+ async def test_new_entity_with_required_state_field_loads_via_watch(self):
352
+ """Another process writes config+state; the watcher must build with state merged in,
353
+ not fail with 'missing required field'."""
354
+ from typing import Annotated
355
+
356
+ from msgspec import Meta
357
+
358
+ from brainlessdb import BrainlessBucket, BrainlessDBFeat
359
+
360
+ class StatefulV1(BrainlessBucket):
361
+ @staticmethod
362
+ def allows_app(app: str) -> bool:
363
+ return True
364
+
365
+ # Required STATE field — no default. Pre-fix bug: build with state=None fails here.
366
+ name: Annotated[str, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})]
367
+
368
+ nats = MockNats()
369
+ db = BrainlessDB(nats, namespace="test", flush_interval=0)
370
+ items = db.collection(StatefulV1)
371
+ await db.start()
372
+ await asyncio.sleep(0.05) # let watcher initialize
373
+
374
+ # Simulate another process flushing a fresh entity. Brainless writes
375
+ # config then state per uid; we put state first so the row exists by
376
+ # the time the watcher processes the config event (the common case
377
+ # since the watcher is async and runs after both puts have landed).
378
+ js = nats.jetstream()
379
+ state_kv = await js.create_key_value(_kv_config("StatefulV1-State"))
380
+ config_kv = js._buckets["StatefulV1"]
381
+ uid = _uuid()
382
+ await state_kv.put(uid, msgspec.json.encode({"name": "alice"}))
383
+ await config_kv.put(uid, _make_config_wrapper({}))
384
+
385
+ await asyncio.sleep(0.1) # let watcher pick up the config event
386
+
387
+ # Entity loaded with state merged in — not dead-lettered
388
+ assert uid in items, "entity should have loaded via watcher"
389
+ assert items.get(uid).name == "alice"
390
+ dlq = js._buckets.get("brainless_dlq")
391
+ if dlq is not None:
392
+ assert not _dlq_has(dlq, "StatefulV1", uid)
393
+
394
+ await db.stop()
395
+
396
+ @pytest.mark.asyncio
397
+ async def test_new_entity_without_state_row_still_handles_required_state(self):
398
+ """If state row genuinely doesn't exist when config arrives, dead-letter cleanly."""
399
+ from typing import Annotated
400
+
401
+ from msgspec import Meta
402
+
403
+ from brainlessdb import BrainlessBucket, BrainlessDBFeat
404
+
405
+ class StatefulV1(BrainlessBucket):
406
+ @staticmethod
407
+ def allows_app(app: str) -> bool:
408
+ return True
409
+
410
+ name: Annotated[str, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.STATE})]
411
+
412
+ nats = MockNats()
413
+ db = BrainlessDB(nats, namespace="test", flush_interval=0)
414
+ items = db.collection(StatefulV1)
415
+ await db.start()
416
+ await asyncio.sleep(0.05)
417
+
418
+ # Config-only write — no state. Build must fail, row must be DLQ'd
419
+ # (and removed from source so the watcher doesn't loop on the same row).
420
+ js = nats.jetstream()
421
+ config_kv = js._buckets["StatefulV1"]
422
+ uid = _uuid()
423
+ await config_kv.put(uid, _make_config_wrapper({}))
424
+ await asyncio.sleep(0.1)
425
+
426
+ assert uid not in items
427
+ assert uid not in config_kv._entries, "bad row should be removed from source"
428
+ dlq = js._buckets["brainless_dlq"]
429
+ assert _dlq_has(dlq, "StatefulV1", uid)
430
+
431
+ await db.stop()
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "brainlessdb"
20
- version = "0.14.1"
20
+ version = "0.14.3"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "msgspec" },
File without changes
File without changes
File without changes