nlbone 0.6.20__py3-none-any.whl → 0.7.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.
Files changed (41) hide show
  1. nlbone/adapters/db/postgres/__init__.py +1 -1
  2. nlbone/adapters/db/postgres/base.py +2 -1
  3. nlbone/adapters/db/postgres/repository.py +254 -29
  4. nlbone/adapters/db/postgres/uow.py +36 -1
  5. nlbone/adapters/messaging/__init__.py +1 -1
  6. nlbone/adapters/messaging/event_bus.py +97 -17
  7. nlbone/adapters/messaging/rabbitmq.py +45 -0
  8. nlbone/adapters/outbox/__init__.py +1 -0
  9. nlbone/adapters/outbox/outbox_consumer.py +112 -0
  10. nlbone/adapters/outbox/outbox_repo.py +191 -0
  11. nlbone/adapters/ticketing/client.py +39 -0
  12. nlbone/config/settings.py +9 -5
  13. nlbone/container.py +1 -8
  14. nlbone/core/application/bus.py +7 -7
  15. nlbone/core/application/di.py +43 -14
  16. nlbone/core/application/registry.py +12 -6
  17. nlbone/core/domain/base.py +30 -9
  18. nlbone/core/domain/models.py +46 -3
  19. nlbone/core/ports/__init__.py +0 -2
  20. nlbone/core/ports/event_bus.py +23 -6
  21. nlbone/core/ports/outbox.py +73 -0
  22. nlbone/core/ports/repository.py +10 -9
  23. nlbone/core/ports/uow.py +20 -1
  24. nlbone/interfaces/api/additional_filed/field_registry.py +2 -0
  25. nlbone/interfaces/cli/init_db.py +39 -2
  26. nlbone/interfaces/cli/main.py +2 -0
  27. nlbone/interfaces/cli/ticket.py +29 -0
  28. nlbone/interfaces/jobs/dispatch_outbox.py +2 -2
  29. nlbone/utils/crypto.py +7 -4
  30. {nlbone-0.6.20.dist-info → nlbone-0.7.0.dist-info}/METADATA +3 -2
  31. {nlbone-0.6.20.dist-info → nlbone-0.7.0.dist-info}/RECORD +35 -34
  32. nlbone/adapters/repositories/outbox_repo.py +0 -18
  33. nlbone/core/application/events.py +0 -20
  34. nlbone/core/application/services.py +0 -0
  35. nlbone/core/domain/events.py +0 -0
  36. nlbone/core/ports/messaging.py +0 -0
  37. nlbone/core/ports/repo.py +0 -19
  38. /nlbone/adapters/{messaging/redis.py → ticketing/__init__.py} +0 -0
  39. {nlbone-0.6.20.dist-info → nlbone-0.7.0.dist-info}/WHEEL +0 -0
  40. {nlbone-0.6.20.dist-info → nlbone-0.7.0.dist-info}/entry_points.txt +0 -0
  41. {nlbone-0.6.20.dist-info → nlbone-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
5
+
6
+ from sqlalchemy import (
7
+ delete,
8
+ select,
9
+ update,
10
+ )
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlalchemy.orm import Session
13
+
14
+ from nlbone.core.domain.models import Outbox, OutboxStatus
15
+ from nlbone.core.ports.outbox import AsyncOutboxRepository, OutboxRepository
16
+
17
+
18
+ class SQLAlchemyOutboxRepository(OutboxRepository):
19
+ def __init__(self, session: Session) -> None:
20
+ self.session = session
21
+
22
+ def _now(self) -> datetime:
23
+ return datetime.now(timezone.utc)
24
+
25
+ def enqueue(self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None) -> Outbox:
26
+ row = Outbox(
27
+ topic=topic,
28
+ payload=payload,
29
+ headers=headers or {},
30
+ key=key,
31
+ available_at=available_at or self._now(),
32
+ )
33
+ self.session.add(row)
34
+ # Do not commit here; caller's transaction should commit
35
+ self.session.flush()
36
+ return row
37
+
38
+ def enqueue_many(
39
+ self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
40
+ ) -> List[Outbox]:
41
+ rows = [
42
+ Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
43
+ for t, p in items
44
+ ]
45
+ self.session.add_all(rows)
46
+ self.session.flush()
47
+ return [r for r in rows]
48
+
49
+ def claim_batch(self, *, topics: list[str] = None, limit: int = 100, now: Optional[datetime] = None) -> List[
50
+ Outbox]:
51
+ now = now or self._now()
52
+ # Select candidates eligible to process
53
+ stmt = (
54
+ select(Outbox)
55
+ .where(
56
+ Outbox.topic.in_(topics),
57
+ Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
58
+ Outbox.available_at <= now,
59
+ (Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
60
+ )
61
+ .order_by(Outbox.id)
62
+ .limit(limit)
63
+ .with_for_update(skip_locked=True)
64
+ )
65
+ rows = self.session.execute(stmt).scalars().all()
66
+ if not rows:
67
+ return []
68
+ # Mark as PROCESSING and increment attempts
69
+ ids = [r.id for r in rows]
70
+ self.session.execute(
71
+ update(Outbox)
72
+ .where(Outbox.id.in_(ids))
73
+ .values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
74
+ )
75
+ self.session.flush()
76
+ return [r for r in rows]
77
+
78
+ def mark_published(self, ids: Iterable[int]) -> None:
79
+ ids = list(ids)
80
+ if not ids:
81
+ return
82
+ self.session.execute(
83
+ update(Outbox)
84
+ .where(Outbox.id.in_(ids))
85
+ .values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
86
+ )
87
+
88
+ def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
89
+ self.session.execute(
90
+ update(Outbox)
91
+ .where(Outbox.id == id)
92
+ .values(
93
+ status=OutboxStatus.FAILED,
94
+ last_error=error,
95
+ next_attempt_at=self._now() + backoff,
96
+ )
97
+ )
98
+
99
+ def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
100
+ conds = [Outbox.created_at < before]
101
+ if status is not None:
102
+ conds.append(Outbox.status == status)
103
+ stmt = delete(Outbox).where(*conds)
104
+ res = self.session.execute(stmt)
105
+ return res.rowcount or 0
106
+
107
+
108
+ class SQLAlchemyAsyncOutboxRepository(AsyncOutboxRepository):
109
+ def __init__(self, session: AsyncSession) -> None:
110
+ self.session = session
111
+
112
+ def _now(self) -> datetime:
113
+ return datetime.now(timezone.utc)
114
+
115
+ async def enqueue(
116
+ self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None
117
+ ) -> Outbox:
118
+ row = Outbox(
119
+ topic=topic,
120
+ payload=payload,
121
+ headers=headers or {},
122
+ key=key,
123
+ available_at=available_at or self._now(),
124
+ )
125
+ self.session.add(row)
126
+ await self.session.flush()
127
+ return row
128
+
129
+ async def enqueue_many(
130
+ self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
131
+ ) -> List[Outbox]:
132
+ rows = [
133
+ Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
134
+ for t, p in items
135
+ ]
136
+ self.session.add_all(rows)
137
+ await self.session.flush()
138
+ return [r for r in rows]
139
+
140
+ async def claim_batch(self, *, limit: int = 100, now: Optional[datetime] = None) -> List[Outbox]:
141
+ now = now or self._now()
142
+ stmt = (
143
+ select(Outbox)
144
+ .where(
145
+ Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
146
+ Outbox.available_at <= now,
147
+ (Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
148
+ )
149
+ .order_by(Outbox.id)
150
+ .limit(limit)
151
+ .with_for_update(skip_locked=True)
152
+ )
153
+ rows = (await self.session.execute(stmt)).scalars().all()
154
+ if not rows:
155
+ return []
156
+ ids = [r.id for r in rows]
157
+ await self.session.execute(
158
+ update(Outbox)
159
+ .where(Outbox.id.in_(ids))
160
+ .values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
161
+ )
162
+ await self.session.flush()
163
+ return [r.to_domain() for r in rows]
164
+
165
+ async def mark_published(self, ids: Iterable[int]) -> None:
166
+ ids = list(ids)
167
+ if not ids:
168
+ return
169
+ await self.session.execute(
170
+ update(Outbox)
171
+ .where(Outbox.id.in_(ids))
172
+ .values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
173
+ )
174
+
175
+ async def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
176
+ await self.session.execute(
177
+ update(Outbox)
178
+ .where(Outbox.id == id)
179
+ .values(
180
+ status=OutboxStatus.FAILED,
181
+ last_error=error,
182
+ next_attempt_at=self._now() + backoff,
183
+ )
184
+ )
185
+
186
+ async def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
187
+ conds = [Outbox.created_at < before]
188
+ if status is not None:
189
+ conds.append(Outbox.status == status)
190
+ res = await self.session.execute(delete(Outbox).where(*conds))
191
+ return res.rowcount or 0
@@ -0,0 +1,39 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from nlbone.adapters.messaging.rabbitmq import RabbitMQEventBus
6
+ from nlbone.config.settings import get_settings
7
+
8
+
9
+ class CreateTicketIn(BaseModel):
10
+ assignee_id: str
11
+ category_id: int
12
+ channel: str
13
+ direction: str
14
+ entity_id: str
15
+ entity_type: str
16
+ message: str
17
+ priority: str
18
+ product_id: int
19
+ status: str
20
+ title: str
21
+ user_id: int
22
+
23
+
24
+ class TicketingClient:
25
+ def __init__(self):
26
+ settings = get_settings()
27
+ self._bus = RabbitMQEventBus(settings.RABBITMQ_URL)
28
+ self._exchange = settings.RABBITMQ_TICKETING_EXCHANGE
29
+ self._rk_create_v1 = settings.RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1
30
+
31
+ async def create_ticket(self, payload: CreateTicketIn, created_by_id: int, *,
32
+ override_exchange: Optional[str] = None,
33
+ override_routing_key: Optional[str] = None) -> None:
34
+ exchange = override_exchange or self._exchange
35
+ routing_key = override_routing_key or self._rk_create_v1
36
+ payload = payload.model_dump()
37
+ payload.update({'created_by_id': created_by_id})
38
+ print(payload)
39
+ await self._bus.publish(exchange=exchange, routing_key=routing_key, payload=payload)
nlbone/config/settings.py CHANGED
@@ -26,11 +26,11 @@ def _guess_env_file() -> str | None:
26
26
  raise Exception("Failed to guess env file path!") from e
27
27
 
28
28
 
29
- def is_production_env() -> bool:
29
+ def read_from_env_fild() -> bool:
30
30
  raw = os.getenv("NLBONE_ENV") or os.getenv("ENV") or os.getenv("ENVIRONMENT")
31
31
  if not raw:
32
32
  return False
33
- return raw.strip().lower() in {"prod", "production"}
33
+ return raw.strip().lower() not in {"prod", "production", "stage", "staging"}
34
34
 
35
35
 
36
36
  class Settings(BaseSettings):
@@ -101,6 +101,10 @@ class Settings(BaseSettings):
101
101
  # ---------------------------
102
102
  FERNET_KEY: str = Field(default="")
103
103
 
104
+ RABBITMQ_URL: str = Field(default="", description="amqp(s)://user:pass@host:5672/vhost")
105
+ RABBITMQ_TICKETING_EXCHANGE: str = "crm_stage.ticket"
106
+ RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1: str = "crm_stage.ticket.create.v1"
107
+
104
108
  model_config = SettingsConfigDict(
105
109
  env_prefix="",
106
110
  env_file=None,
@@ -110,9 +114,9 @@ class Settings(BaseSettings):
110
114
 
111
115
  @classmethod
112
116
  def load(cls, env_file: str | None = None) -> "Settings":
113
- if is_production_env():
114
- return cls()
115
- return cls(_env_file=env_file or _guess_env_file())
117
+ if read_from_env_fild():
118
+ return cls(_env_file=env_file or _guess_env_file())
119
+ return cls()
116
120
 
117
121
 
118
122
  @lru_cache(maxsize=4)
nlbone/container.py CHANGED
@@ -10,13 +10,10 @@ from nlbone.adapters.auth.token_provider import ClientTokenProvider
10
10
  from nlbone.adapters.cache.async_redis import AsyncRedisCache
11
11
  from nlbone.adapters.cache.memory import InMemoryCache
12
12
  from nlbone.adapters.cache.redis import RedisCache
13
- from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
14
13
  from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
15
14
  from nlbone.adapters.http_clients import PricingService
16
15
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
17
16
  from nlbone.adapters.http_clients.uploadchi.uploadchi_async import UploadchiAsyncClient
18
- from nlbone.adapters.messaging import InMemoryEventBus
19
- from nlbone.core.ports import EventBusPort
20
17
  from nlbone.core.ports.cache import AsyncCachePort, CachePort
21
18
  from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
22
19
 
@@ -27,12 +24,8 @@ class Container(containers.DeclarativeContainer):
27
24
  sync_session_factory = providers.Singleton(get_sync_session_factory)
28
25
  async_session_factory = providers.Singleton(get_async_session_factory)
29
26
 
30
- # --- UoW ---
31
- uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
32
- async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
33
-
34
27
  # --- Event bus ---
35
- event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
28
+ # event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
36
29
 
37
30
  # --- Services ---
38
31
  auth: providers.Singleton[KeycloakAuthService] = providers.Singleton(KeycloakAuthService)
@@ -1,10 +1,10 @@
1
1
  import traceback
2
- from typing import Optional, List, Callable, Coroutine, Any, Protocol
2
+ from typing import Any, Callable, Coroutine, List, Optional, Protocol
3
3
 
4
- from nlbone.core.application.registry import HandlerRegistry, AsyncHandlerRegistry
5
- from nlbone.core.domain.base import Command, DomainEvent
6
- from nlbone.core.ports import UnitOfWork, AsyncUnitOfWork
7
- from nlbone.core.ports.event_bus import OutboxPublisher, Message, AsyncOutboxPublisher
4
+ from nlbone.core.application.registry import AsyncHandlerRegistry, HandlerRegistry
5
+ from nlbone.core.domain.base import Command, DomainEvent, Message
6
+ from nlbone.core.ports import AsyncUnitOfWork, UnitOfWork
7
+ from nlbone.core.ports.event_bus import AsyncOutboxPublisher, OutboxPublisher
8
8
  from nlbone.interfaces.api.middleware.access_log import logger
9
9
 
10
10
 
@@ -149,7 +149,7 @@ class AsyncMessageBus:
149
149
  produced = await handler(event)
150
150
  if produced:
151
151
  self._queue.extend(produced)
152
- self._queue.extend(self.uow.collect_new_events())
152
+ self._queue.extend(await self.uow.collect_new_events())
153
153
  except Exception: # noqa: BLE001
154
154
  logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
155
155
  continue
@@ -161,7 +161,7 @@ class AsyncMessageBus:
161
161
  produced = await handler(command)
162
162
  if produced:
163
163
  self._queue.extend(produced)
164
- self._queue.extend(self.uow.collect_new_events())
164
+ self._queue.extend(await self.uow.collect_new_events())
165
165
  if self.outbox:
166
166
  await self.outbox.publish(self._queue)
167
167
  except Exception: # noqa: BLE001
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
+
2
3
  import inspect
3
- from typing import Any, Callable, Dict, Optional, Type, get_type_hints, get_origin, get_args
4
+ import sys
5
+ from typing import Any, Callable, Dict, Optional, Type, get_args, get_origin, get_type_hints
6
+
4
7
 
5
8
  class TypeContainer:
6
9
  """Tiny type-based DI: register_instance(T, obj) / register_factory(T, () -> obj)."""
10
+
7
11
  def __init__(self) -> None:
8
12
  self._instances: Dict[Type[Any], Any] = {}
9
13
  self._factories: Dict[Type[Any], Callable[[], Any]] = {}
@@ -62,6 +66,7 @@ class TypeContainer:
62
66
  return None
63
67
  raise LookupError(f"No provider for {types}")
64
68
 
69
+
65
70
  def _mro_distance(sub: Type[Any], sup: Type[Any]) -> int:
66
71
  try:
67
72
  return sub.mro().index(sup)
@@ -69,31 +74,55 @@ def _mro_distance(sub: Type[Any], sup: Type[Any]) -> int:
69
74
  return 10**6
70
75
 
71
76
 
77
+ def _type_hints(obj):
78
+ mod = sys.modules.get(getattr(obj, "__module__", "__name__"))
79
+ return get_type_hints(obj, globalns=vars(mod) if mod else None)
80
+
81
+
82
+ def _build_kwargs(
83
+ sig: inspect.Signature,
84
+ hints: Dict[str, Any],
85
+ container: TypeContainer,
86
+ *,
87
+ skip_params: int = 0,
88
+ ) -> Dict[str, Any]:
89
+ kwargs: Dict[str, Any] = {}
90
+ for name, param in list(sig.parameters.items())[skip_params:]:
91
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
92
+ continue
93
+ ann = hints.get(name, param.annotation)
94
+ if param.default is not inspect._empty:
95
+ kwargs[name] = param.default
96
+ else:
97
+ kwargs[name] = container.resolve(ann)
98
+ return kwargs
99
+
100
+
72
101
  def bind_callable(fn: Callable[..., Any], c: TypeContainer) -> Callable[..., Any]:
73
102
  sig = inspect.signature(fn)
74
- hints = get_type_hints(fn)
103
+ hints = _type_hints(fn)
104
+
75
105
  def wrapper(message: Any):
76
- kwargs = {}
77
- for name, param in list(sig.parameters.items())[1:]:
78
- ann = hints.get(name, param.annotation)
79
- kwargs[name] = c.resolve(ann)
106
+ kwargs = _build_kwargs(sig, hints, c, skip_params=1)
80
107
  return fn(message, **kwargs)
108
+
81
109
  return wrapper
82
110
 
83
111
 
84
112
  def bind_handler(handler: Any, c: TypeContainer) -> Callable[..., Any]:
85
113
  if inspect.isclass(handler):
86
- init = handler.__init__ # type: ignore[attr-defined]
114
+ init = handler.__init__
87
115
  sig = inspect.signature(init)
88
- hints = get_type_hints(init)
89
- kwargs = {}
90
- for name, param in list(sig.parameters.items())[1:]:
91
- ann = hints.get(name, param.annotation)
92
- kwargs[name] = c.resolve(ann)
93
- instance = handler(**kwargs) # type: ignore
116
+ hints = _type_hints(init)
117
+
118
+ kwargs = _build_kwargs(sig, hints, c, skip_params=1)
119
+
120
+ instance = handler(**kwargs) # type: ignore[arg-type]
94
121
  if not callable(instance):
95
122
  raise TypeError(f"{handler!r} must implement __call__")
96
123
  return instance
97
- elif callable(handler):
124
+
125
+ if callable(handler):
98
126
  return bind_callable(handler, c)
127
+
99
128
  raise TypeError(f"Unsupported handler: {handler!r}")
@@ -1,8 +1,7 @@
1
- from typing import Dict, Type, List, Callable, TypeVar, Optional, Iterable, Coroutine, Any
1
+ from typing import Any, Callable, Coroutine, Dict, Iterable, List, Optional, Type, TypeVar
2
2
 
3
3
  from nlbone.core.application.di import TypeContainer, bind_handler
4
- from nlbone.core.domain.base import DomainEvent, Command
5
- from nlbone.core.ports.event_bus import Message
4
+ from nlbone.core.domain.base import Command, DomainEvent, Message
6
5
 
7
6
  TMsg = TypeVar("TMsg", bound=Message)
8
7
  SyncHandler = Callable[[TMsg], Optional[Iterable[Message]]]
@@ -88,9 +87,16 @@ def handles_command_async(cmd_type: Type[Command], registry: AsyncHandlerRegistr
88
87
 
89
88
 
90
89
  def make_sync_decorators(registry: HandlerRegistry, container: TypeContainer):
91
- def handles_command(cmd_type: Type[Any]):
92
- def deco(h: Any):
93
- registry.register_command(cmd_type, bind_handler(h, container))
90
+ def handles_command(cmd_type):
91
+ def deco(h):
92
+ instance_ref = {"inst": None}
93
+
94
+ def lazy_handler(message):
95
+ if instance_ref["inst"] is None:
96
+ instance_ref["inst"] = bind_handler(h, container)
97
+ return instance_ref["inst"](message)
98
+
99
+ registry.register_command(cmd_type, lazy_handler)
94
100
  return h
95
101
 
96
102
  return deco
@@ -2,7 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from datetime import datetime, timezone
5
+ from enum import Enum
5
6
  from typing import Any, Generic, List, TypeVar
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel
6
10
 
7
11
  TId = TypeVar("TId")
8
12
 
@@ -11,17 +15,28 @@ class DomainError(Exception):
11
15
  """Base domain exception."""
12
16
 
13
17
 
14
- @dataclass(frozen=True)
15
- class DomainEvent:
18
+
19
+ class Message(BaseModel):
20
+ pass
21
+
22
+
23
+
24
+ class DomainEvent(Message):
16
25
  """Immutable domain event."""
17
26
 
18
27
  occurred_at: datetime = datetime.now(timezone.utc)
28
+ event_id: str = uuid4()
19
29
 
20
30
  @property
21
31
  def name(self):
22
32
  return self.__class__.__name__
23
33
 
24
34
 
35
+ @dataclass(frozen=True)
36
+ class Command:
37
+ pass
38
+
39
+
25
40
  class ValueObject:
26
41
  """Base for value objects (immutable in practice)."""
27
42
 
@@ -39,13 +54,19 @@ class Entity(Generic[TId]):
39
54
  class AggregateRoot(Entity[TId]):
40
55
  """Aggregate root with domain event collection."""
41
56
 
42
- def __init__(self, *args, **kwargs) -> None:
43
- self._domain_events: List[DomainEvent] = []
57
+ def __init__(self) -> None:
58
+ self._events: List[DomainEvent] = []
59
+
60
+ @property
61
+ def events(self) -> List[DomainEvent]:
62
+ return self._events
44
63
 
45
64
  def _raise(self, event: DomainEvent) -> None:
46
- self._domain_events.append(event)
65
+ self._events.append(event)
66
+
67
+ def clear_events(self) -> None:
68
+ self._events.clear()
69
+
47
70
 
48
- def pull_events(self) -> List[DomainEvent]:
49
- events = list(self._domain_events)
50
- self._domain_events.clear()
51
- return events
71
+ class BaseEnum(Enum):
72
+ pass
@@ -1,12 +1,16 @@
1
+ import enum
1
2
  import uuid
2
- from datetime import datetime
3
+ from datetime import datetime, timezone, timedelta
4
+ from typing import Any, Dict, Optional
3
5
 
6
+ from sqlalchemy import JSON, DateTime, Index, Integer, String, Text
4
7
  from sqlalchemy import JSON as SA_JSON
5
- from sqlalchemy import DateTime, Index, String, Text
8
+ from sqlalchemy import Enum as SA_Enum
6
9
  from sqlalchemy.orm import Mapped, mapped_column
7
10
  from sqlalchemy.sql import func
8
11
 
9
- from nlbone.adapters.db import Base
12
+ from nlbone.adapters.db.postgres.base import Base
13
+ from nlbone.utils.time import now
10
14
 
11
15
  try:
12
16
  from sqlalchemy.dialects.postgresql import JSONB, UUID
@@ -38,3 +42,42 @@ class AuditLog(Base):
38
42
  Index("ix_audit_entity_entityid", "entity", "entity_id"),
39
43
  Index("ix_audit_created_at", "created_at"),
40
44
  )
45
+
46
+
47
+ class OutboxStatus(str, enum.Enum):
48
+ PENDING = "pending"
49
+ PROCESSING = "processing"
50
+ PUBLISHED = "published"
51
+ FAILED = "failed"
52
+
53
+
54
+ class Outbox(Base):
55
+ __tablename__ = "outbox"
56
+
57
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
58
+ topic: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
59
+ payload: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
60
+ headers: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
61
+ key: Mapped[Optional[str]] = mapped_column(String(200), nullable=True, index=True)
62
+
63
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
64
+ available_at: Mapped[datetime] = mapped_column(
65
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
66
+ )
67
+
68
+ status: Mapped[OutboxStatus] = mapped_column(SA_Enum(OutboxStatus), default=OutboxStatus.PENDING, index=True)
69
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
70
+ last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
71
+ next_attempt_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
72
+
73
+ def mark_failed(self, error: str, *, backoff: timedelta = timedelta(seconds=30)):
74
+ self.status = OutboxStatus.FAILED
75
+ self.last_error = error
76
+ self.next_attempt_at = now() + backoff
77
+
78
+ def mark_published(self):
79
+ self.status = OutboxStatus.PUBLISHED
80
+ self.next_attempt_at = None
81
+
82
+ def to_outbox_row(evt) -> Outbox:
83
+ return Outbox(topic=evt.topic, payload=evt.__dict__)
@@ -1,5 +1,3 @@
1
1
  from .auth import AuthService
2
- from .event_bus import EventBusPort
3
2
  from .files import AsyncFileServicePort, FileServicePort
4
- from .repo import AsyncRepository, Repository
5
3
  from .uow import AsyncUnitOfWork, UnitOfWork
@@ -1,10 +1,27 @@
1
- from __future__ import annotations
1
+ from typing import Any, Awaitable, Callable, Iterable, Protocol, Type, runtime_checkable
2
2
 
3
- from typing import Callable, Iterable, Protocol
3
+ from nlbone.core.domain.base import DomainEvent, Message
4
4
 
5
- from nlbone.core.domain.base import DomainEvent
5
+ EventHandler = Callable[[DomainEvent], Any] | Callable[[DomainEvent], Awaitable[Any]]
6
6
 
7
7
 
8
- class EventBusPort(Protocol):
9
- def publish(self, events: Iterable[DomainEvent]) -> None: ...
10
- def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None: ...
8
+ class EventBus(Protocol):
9
+ def subscribe(self, event_type: Type[DomainEvent] | str, handler: EventHandler) -> None: ...
10
+
11
+ def publish(self, event: DomainEvent) -> None: ...
12
+
13
+
14
+ class EventPublisher(Protocol):
15
+ def publish(self, event: DomainEvent) -> None: ...
16
+
17
+
18
+ @runtime_checkable
19
+ class OutboxPublisher(Protocol):
20
+ """Optional: publish integration messages reliably after commit."""
21
+
22
+ def publish(self, messages: Iterable[Message]) -> None: ...
23
+
24
+
25
+ @runtime_checkable
26
+ class AsyncOutboxPublisher(Protocol):
27
+ async def publish(self, messages: Iterable[Message]) -> None: ...