python-event-sourcery 0.4.0a9__tar.gz → 0.4.0a10__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 (45) hide show
  1. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/PKG-INFO +1 -1
  2. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/__init__.py +2 -0
  3. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/dispatcher.py +8 -2
  4. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/event/dto.py +3 -0
  5. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/event/serde.py +1 -0
  6. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/event_store.py +19 -0
  7. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/exceptions.py +4 -0
  8. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/in_memory.py +44 -33
  9. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/interfaces.py +6 -0
  10. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/outbox.py +1 -0
  11. python_event_sourcery-0.4.0a10/event_sourcery/event_store/tenant_id.py +5 -0
  12. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/dto.py +4 -2
  13. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/event_store.py +13 -6
  14. python_event_sourcery-0.4.0a10/event_sourcery_esdb/stream.py +60 -0
  15. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/subscription.py +1 -3
  16. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/event_store.py +22 -7
  17. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/models.py +5 -2
  18. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/outbox.py +6 -1
  19. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/subscription.py +1 -0
  20. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/pyproject.toml +1 -1
  21. python_event_sourcery-0.4.0a9/event_sourcery_esdb/stream.py +0 -65
  22. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/LICENSE +0 -0
  23. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/__init__.py +0 -0
  24. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/aggregate/__init__.py +0 -0
  25. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/aggregate/aggregate.py +0 -0
  26. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/aggregate/repository.py +0 -0
  27. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/event/__init__.py +0 -0
  28. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/event/registry.py +0 -0
  29. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/factory.py +0 -0
  30. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/stream_id.py +0 -0
  31. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/subscription.py +0 -0
  32. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/event_store/versioning.py +0 -0
  33. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/py.typed +0 -0
  34. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/read_model/__init__.py +0 -0
  35. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/read_model/cursors_dao.py +0 -0
  36. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery/read_model/projector.py +0 -0
  37. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/__init__.py +0 -0
  38. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/outbox.py +0 -0
  39. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_esdb/py.typed +0 -0
  40. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/__init__.py +0 -0
  41. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/cursors_dao.py +0 -0
  42. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/dto.py +0 -0
  43. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/guid.py +0 -0
  44. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/jsonb.py +0 -0
  45. {python_event_sourcery-0.4.0a9 → python_event_sourcery-0.4.0a10}/event_sourcery_sqlalchemy/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-event-sourcery
3
- Version: 0.4.0a9
3
+ Version: 0.4.0a10
4
4
  Summary:
5
5
  License: MIT
6
6
  Author: Your Name
@@ -23,6 +23,7 @@ __all__ = [
23
23
  "factory",
24
24
  "interfaces",
25
25
  "subscription",
26
+ "TenantId",
26
27
  ]
27
28
 
28
29
  from event_sourcery.event_store import exceptions, factory, interfaces, subscription
@@ -45,6 +46,7 @@ from event_sourcery.event_store.factory import (
45
46
  )
46
47
  from event_sourcery.event_store.in_memory import InMemoryBackendFactory
47
48
  from event_sourcery.event_store.stream_id import StreamId, StreamUUID
49
+ from event_sourcery.event_store.tenant_id import TenantId
48
50
  from event_sourcery.event_store.versioning import (
49
51
  NO_VERSIONING,
50
52
  ExplicitVersioning,
@@ -9,8 +9,9 @@ from event_sourcery.event_store.event import (
9
9
  WrappedEvent,
10
10
  )
11
11
  from event_sourcery.event_store.stream_id import StreamId
12
+ from event_sourcery.event_store.tenant_id import TenantId
12
13
 
13
- Listener = Callable[[WrappedEvent, StreamId, Position | None], None]
14
+ Listener = Callable[[WrappedEvent, StreamId, TenantId, Position | None], None]
14
15
 
15
16
 
16
17
  class Dispatcher:
@@ -25,7 +26,12 @@ class Dispatcher:
25
26
  if not isinstance(record.wrapped_event.event, event_type):
26
27
  continue
27
28
  for listener in listeners:
28
- listener(record.wrapped_event, record.stream_id, record.position)
29
+ listener(
30
+ record.wrapped_event,
31
+ record.stream_id,
32
+ record.tenant_id,
33
+ record.position,
34
+ )
29
35
 
30
36
  def register(self, listener: Listener, to: type[Event]) -> None:
31
37
  self._listeners[to].add(listener)
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
6
6
 
7
7
  from event_sourcery.event_store.event.registry import EventRegistry
8
8
  from event_sourcery.event_store.stream_id import StreamId
9
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
9
10
 
10
11
 
11
12
  class RawEvent(BaseModel):
@@ -24,6 +25,7 @@ Position: TypeAlias = int
24
25
  class RecordedRaw(BaseModel):
25
26
  entry: RawEvent
26
27
  position: Position
28
+ tenant_id: TenantId = DEFAULT_TENANT
27
29
 
28
30
 
29
31
  class Event(BaseModel, extra="forbid"):
@@ -83,3 +85,4 @@ class Entry(BaseModel):
83
85
 
84
86
  class Recorded(Entry):
85
87
  position: Position
88
+ tenant_id: TenantId = DEFAULT_TENANT
@@ -36,6 +36,7 @@ class Serde:
36
36
  wrapped_event=self.deserialize(record.entry),
37
37
  stream_id=record.entry.stream_id,
38
38
  position=record.position,
39
+ tenant_id=record.tenant_id,
39
40
  )
40
41
 
41
42
  def serialize(
@@ -10,6 +10,7 @@ from event_sourcery.event_store.event import (
10
10
  )
11
11
  from event_sourcery.event_store.interfaces import StorageStrategy
12
12
  from event_sourcery.event_store.stream_id import StreamId
13
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
13
14
  from event_sourcery.event_store.versioning import (
14
15
  NO_VERSIONING,
15
16
  ExplicitVersioning,
@@ -189,3 +190,21 @@ class EventStore:
189
190
 
190
191
  """
191
192
  return self._storage_strategy.current_position
193
+
194
+ def scoped_for_tenant(self, tenant_id: TenantId = DEFAULT_TENANT) -> "EventStore":
195
+ """Factory method to create a new event store instance scoped to a tenant.
196
+
197
+ Examples:
198
+ >>> event_store.scoped_for_tenant("tenant_1")
199
+ <EventStore ...>
200
+
201
+ Args:
202
+ tenant_id: The tenant identifier to work with.
203
+
204
+ Returns:
205
+ An event store instance scoped to the tenant.
206
+ """
207
+ return EventStore(
208
+ storage_strategy=self._storage_strategy.scoped_for_tenant(tenant_id),
209
+ serde=self._serde,
210
+ )
@@ -18,6 +18,10 @@ class IllegalCategoryName(EventStoreException):
18
18
  pass
19
19
 
20
20
 
21
+ class IllegalTenantId(EventStoreException):
22
+ pass
23
+
24
+
21
25
  class VersioningMismatch(EventStoreException):
22
26
  pass
23
27
 
@@ -32,18 +32,19 @@ from event_sourcery.event_store.interfaces import (
32
32
  )
33
33
  from event_sourcery.event_store.outbox import Outbox
34
34
  from event_sourcery.event_store.stream_id import StreamId
35
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
35
36
  from event_sourcery.event_store.versioning import NO_VERSIONING, Versioning
36
37
 
37
38
 
38
39
  @dataclass
39
40
  class Storage:
40
- events: list[RawEvent] = field(default_factory=list, init=False)
41
- _data: dict[StreamId, list[RawEvent]] = field(default_factory=dict, init=False)
41
+ records: list[RecordedRaw] = field(default_factory=list, init=False)
42
+ _data: dict[StreamId, list[RecordedRaw]] = field(default_factory=dict, init=False)
42
43
  _versions: dict[StreamId, int | None] = field(default_factory=dict, init=False)
43
44
 
44
45
  @property
45
- def current_position(self) -> int:
46
- return len(self.events)
46
+ def current_position(self) -> int | None:
47
+ return self.records[-1].position if self.records else None
47
48
 
48
49
  def __contains__(self, stream_id: object) -> bool:
49
50
  return stream_id in self._data
@@ -55,19 +56,19 @@ class Storage:
55
56
  else:
56
57
  self._versions[stream_id] = 0
57
58
 
58
- def append(self, events: list[RawEvent]) -> None:
59
- self.events.extend(events)
60
- for event in events:
61
- stream_id = event.stream_id
62
- self._data[stream_id].append(event)
63
- self._versions[stream_id] = event.version
59
+ def append(self, records: list[RecordedRaw]) -> None:
60
+ self.records.extend(records)
61
+ for record in records:
62
+ stream_id = record.entry.stream_id
63
+ self._data[stream_id].append(record)
64
+ self._versions[stream_id] = record.entry.version
64
65
 
65
- def replace(self, with_snapshot: RawEvent) -> None:
66
- stream_id = with_snapshot.stream_id
66
+ def replace(self, with_snapshot: RecordedRaw) -> None:
67
+ stream_id = with_snapshot.entry.stream_id
67
68
  self._data[stream_id] = [with_snapshot]
68
- self._versions[stream_id] = with_snapshot.version
69
+ self._versions[stream_id] = with_snapshot.entry.version
69
70
 
70
- def read(self, stream_id: StreamId) -> list[RawEvent]:
71
+ def read(self, stream_id: StreamId) -> list[RecordedRaw]:
71
72
  return copy(self._data[stream_id])
72
73
 
73
74
  def delete(self, stream_id: StreamId) -> None:
@@ -84,15 +85,15 @@ class InMemorySubscription(Iterator[list[RecordedRaw]]):
84
85
  _batch_size: int
85
86
  _timelimit: timedelta
86
87
 
87
- def _pop_record(self) -> tuple[RawEvent, Position] | None:
88
- if len(self._storage.events) <= self._current_position:
88
+ def _pop_record(self) -> RecordedRaw | None:
89
+ if (self._storage.current_position or 0) <= self._current_position:
89
90
  return None
90
- record = self._storage.events[self._current_position]
91
+ record = self._storage.records[self._current_position]
91
92
  self._current_position += 1
92
- return record, self._current_position - 1
93
+ return record
93
94
 
94
95
  def __next__(self) -> list[RecordedRaw]:
95
- batch: list[tuple[RawEvent, Position]] = []
96
+ batch: list[RecordedRaw] = []
96
97
 
97
98
  start = time.monotonic()
98
99
  while len(batch) < self._batch_size:
@@ -103,23 +104,19 @@ class InMemorySubscription(Iterator[list[RecordedRaw]]):
103
104
  if time.monotonic() - start > self._timelimit.total_seconds():
104
105
  break
105
106
 
106
- return [
107
- RecordedRaw(entry=copy(record), position=position)
108
- for record, position in batch
109
- ]
107
+ return batch
110
108
 
111
109
 
112
110
  @dataclass
113
111
  class InMemoryToCategorySubscription(InMemorySubscription):
114
112
  _category: str
115
113
 
116
- def _pop_record(self) -> tuple[RawEvent, Position] | None:
114
+ def _pop_record(self) -> RecordedRaw | None:
117
115
  while True:
118
116
  record = super()._pop_record()
119
117
  if record is None:
120
118
  return None
121
- raw, position = record
122
- if raw.stream_id.category != self._category:
119
+ if record.entry.stream_id.category != self._category:
123
120
  continue
124
121
  return record
125
122
 
@@ -128,13 +125,12 @@ class InMemoryToCategorySubscription(InMemorySubscription):
128
125
  class InMemoryToEventTypesSubscription(InMemorySubscription):
129
126
  _types: list[str]
130
127
 
131
- def _pop_record(self) -> tuple[RawEvent, Position] | None:
128
+ def _pop_record(self) -> RecordedRaw | None:
132
129
  while True:
133
130
  record = super()._pop_record()
134
131
  if record is None:
135
132
  return None
136
- raw, position = record
137
- if raw.name not in self._types:
133
+ if record.entry.name not in self._types:
138
134
  continue
139
135
  return record
140
136
 
@@ -225,11 +221,13 @@ class InMemoryStorageStrategy(StorageStrategy):
225
221
  storage: Storage,
226
222
  dispatcher: Dispatcher,
227
223
  outbox_strategy: InMemoryOutboxStorageStrategy | None,
224
+ tenant_id: TenantId = DEFAULT_TENANT,
228
225
  ) -> None:
229
226
  self._names: dict[str | None, str] = {}
230
227
  self._storage = storage
231
228
  self._dispatcher = dispatcher
232
229
  self._outbox = outbox_strategy
230
+ self._tenant_id = tenant_id
233
231
 
234
232
  def fetch_events(
235
233
  self,
@@ -243,24 +241,29 @@ class InMemoryStorageStrategy(StorageStrategy):
243
241
  self._storage.read(stream_id),
244
242
  slice(start and start - 1, stop and stop - 1),
245
243
  )
246
- return list(stream)
244
+ return [r.entry for r in stream if r.tenant_id == self._tenant_id]
247
245
 
248
246
  def insert_events(
249
247
  self, stream_id: StreamId, versioning: Versioning, events: list[RawEvent]
250
248
  ) -> None:
251
249
  position = self.current_position or 0
252
250
  self._ensure_stream(stream_id=stream_id, versioning=versioning)
253
- self._storage.append(events)
254
251
  records = [
255
- RecordedRaw(entry=raw, position=position)
252
+ RecordedRaw(entry=raw, position=position, tenant_id=self._tenant_id)
256
253
  for position, raw in enumerate(events, start=position + 1)
257
254
  ]
255
+ self._storage.append(records)
258
256
  if self._outbox:
259
257
  self._outbox.put_into_outbox(records)
260
258
  self._dispatcher.dispatch(*records)
261
259
 
262
260
  def save_snapshot(self, snapshot: RawEvent) -> None:
263
- self._storage.replace(with_snapshot=snapshot)
261
+ record = RecordedRaw(
262
+ entry=snapshot,
263
+ position=(self.current_position or 0) + 1,
264
+ tenant_id=self._tenant_id,
265
+ )
266
+ self._storage.replace(with_snapshot=record)
264
267
 
265
268
  def _ensure_stream(self, stream_id: StreamId, versioning: Versioning) -> None:
266
269
  if stream_id not in self._storage:
@@ -289,6 +292,14 @@ class InMemoryStorageStrategy(StorageStrategy):
289
292
  current_position = self._storage.current_position
290
293
  return current_position and Position(current_position)
291
294
 
295
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
296
+ return type(self)(
297
+ storage=self._storage,
298
+ dispatcher=self._dispatcher,
299
+ outbox_strategy=self._outbox,
300
+ tenant_id=tenant_id,
301
+ )
302
+
292
303
 
293
304
  class Config(BaseModel):
294
305
  model_config = ConfigDict(extra="forbid", frozen=True)
@@ -4,6 +4,8 @@ from contextlib import AbstractContextManager
4
4
  from datetime import timedelta
5
5
  from typing import Protocol
6
6
 
7
+ from typing_extensions import Self
8
+
7
9
  from event_sourcery.event_store.event import Position, RawEvent, RecordedRaw
8
10
  from event_sourcery.event_store.stream_id import StreamId
9
11
  from event_sourcery.event_store.versioning import Versioning
@@ -80,3 +82,7 @@ class StorageStrategy(abc.ABC):
80
82
  @abc.abstractmethod
81
83
  def current_position(self) -> Position | None:
82
84
  pass
85
+
86
+ @abc.abstractmethod
87
+ def scoped_for_tenant(self, tenant_id: str) -> Self:
88
+ pass
@@ -22,5 +22,6 @@ class Outbox:
22
22
  wrapped_event=event,
23
23
  stream_id=raw_record.entry.stream_id,
24
24
  position=raw_record.position,
25
+ tenant_id=raw_record.tenant_id,
25
26
  )
26
27
  publisher(record)
@@ -0,0 +1,5 @@
1
+ from typing import Final, TypeAlias
2
+
3
+ TenantId: TypeAlias = str
4
+
5
+ DEFAULT_TENANT: Final[TenantId] = ""
@@ -16,7 +16,7 @@ def raw_event(from_entry: RecordedEvent) -> RawEvent:
16
16
  position = stream.Position(from_entry.stream_position)
17
17
  return RawEvent(
18
18
  uuid=from_entry.id,
19
- stream_id=stream.Name(stream_name=from_entry.stream_name).uuid,
19
+ stream_id=stream.Name.from_stream_name(from_entry.stream_name).uuid,
20
20
  created_at=created_at,
21
21
  version=position.as_version(),
22
22
  name=from_entry.type,
@@ -49,7 +49,9 @@ def new_entry(from_raw: RawEvent, **metadata: Any) -> NewEvent:
49
49
 
50
50
 
51
51
  def raw_record(from_entry: RecordedEvent) -> RecordedRaw:
52
+ stream_name = stream.Name.from_stream_name(from_entry.stream_name)
52
53
  return RecordedRaw(
53
- entry=raw_event(from_entry=from_entry),
54
+ entry=raw_event(from_entry),
54
55
  position=Position(from_entry.commit_position or 0),
56
+ tenant_id=stream_name.tenant_id,
55
57
  )
@@ -1,18 +1,21 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, replace
2
2
  from typing import cast
3
3
 
4
4
  from esdbclient import EventStoreDBClient, StreamState
5
5
  from esdbclient.exceptions import NotFound
6
+ from typing_extensions import Self
6
7
 
7
8
  from event_sourcery.event_store import (
8
9
  NO_VERSIONING,
9
10
  Position,
10
11
  RawEvent,
11
12
  StreamId,
13
+ TenantId,
12
14
  Versioning,
13
15
  )
14
16
  from event_sourcery.event_store.exceptions import ConcurrentStreamWriteError
15
17
  from event_sourcery.event_store.interfaces import StorageStrategy
18
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT
16
19
  from event_sourcery_esdb import dto, stream
17
20
 
18
21
 
@@ -20,6 +23,7 @@ from event_sourcery_esdb import dto, stream
20
23
  class ESDBStorageStrategy(StorageStrategy):
21
24
  _client: EventStoreDBClient
22
25
  _timeout: float | None
26
+ _tenant_id: TenantId = DEFAULT_TENANT
23
27
 
24
28
  def fetch_events(
25
29
  self,
@@ -28,7 +32,7 @@ class ESDBStorageStrategy(StorageStrategy):
28
32
  stop: int | None = None,
29
33
  ) -> list[RawEvent]:
30
34
  snapshot = None
31
- name = stream.Name(stream_id)
35
+ name = stream.Name(self._tenant_id, stream_id)
32
36
  if start is None and (snapshot := self._read_snapshot(name)) is not None:
33
37
  start = cast(int, snapshot.version) + 1
34
38
 
@@ -65,7 +69,7 @@ class ESDBStorageStrategy(StorageStrategy):
65
69
  ) -> None:
66
70
  for sid in {e.stream_id for e in events}:
67
71
  self._ensure_stream(stream_id=sid, versioning=versioning)
68
- stream_name = stream.Name(sid)
72
+ stream_name = stream.Name(self._tenant_id, sid)
69
73
  stream_events = [e for e in events if e.stream_id == sid]
70
74
  self._append_events(stream_name, events=stream_events)
71
75
 
@@ -78,7 +82,7 @@ class ESDBStorageStrategy(StorageStrategy):
78
82
  )
79
83
 
80
84
  def save_snapshot(self, snapshot: RawEvent) -> None:
81
- name = stream.Name(snapshot.stream_id)
85
+ name = stream.Name(self._tenant_id, snapshot.stream_id)
82
86
  stream_position = stream.Position.from_version(cast(int, snapshot.version))
83
87
  self._client.append_events(
84
88
  name.snapshot,
@@ -88,7 +92,7 @@ class ESDBStorageStrategy(StorageStrategy):
88
92
  )
89
93
 
90
94
  def _ensure_stream(self, stream_id: StreamId, versioning: Versioning) -> None:
91
- name = stream.Name(stream_id)
95
+ name = stream.Name(self._tenant_id, stream_id)
92
96
 
93
97
  if versioning is not NO_VERSIONING and versioning.expected_version:
94
98
  expected = stream.Position.from_version(versioning.expected_version)
@@ -112,7 +116,7 @@ class ESDBStorageStrategy(StorageStrategy):
112
116
  return None
113
117
 
114
118
  def delete_stream(self, stream_id: StreamId) -> None:
115
- name = stream.Name(stream_id)
119
+ name = stream.Name(self._tenant_id, stream_id)
116
120
  try:
117
121
  self._client.delete_stream(
118
122
  str(name),
@@ -125,3 +129,6 @@ class ESDBStorageStrategy(StorageStrategy):
125
129
  @property
126
130
  def current_position(self) -> Position | None:
127
131
  return Position(self._client.get_commit_position(timeout=self._timeout))
132
+
133
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
134
+ return replace(self, _tenant_id=tenant_id)
@@ -0,0 +1,60 @@
1
+ import sys
2
+ from collections import UserString
3
+ from typing import cast
4
+
5
+ from typing_extensions import Self
6
+
7
+ from event_sourcery.event_store import StreamId, TenantId
8
+ from event_sourcery.event_store.exceptions import IllegalCategoryName, IllegalTenantId
9
+
10
+
11
+ class Name(UserString):
12
+ tenant_id: TenantId
13
+ uuid: StreamId
14
+
15
+ def __init__(self, tenant_id: TenantId, stream_id: StreamId) -> None:
16
+ if "-" in tenant_id:
17
+ raise IllegalTenantId("ESDB can't handle tenant id with '-'")
18
+ self.tenant_id = tenant_id
19
+ self.uuid = stream_id
20
+ super().__init__(self._as_string(self.uuid))
21
+
22
+ @classmethod
23
+ def from_stream_name(cls, stream_name: str) -> Self:
24
+ if stream_name.endswith("-snapshot"):
25
+ stream_name, _ = stream_name.rsplit("-", 1)
26
+
27
+ category, tenant_id, id_as_hex = stream_name.split("-", 2)
28
+ return cls(tenant_id, StreamId(from_hex=id_as_hex, category=category or None))
29
+
30
+ def _as_string(self, stream_id: StreamId) -> str:
31
+ if stream_id.category and "-" in stream_id.category:
32
+ raise IllegalCategoryName("ESDB storage can't handle category with '-'")
33
+ return f"{stream_id.category or ''}-{self.tenant_id}-{stream_id.hex}"
34
+
35
+ @property
36
+ def snapshot(self) -> str:
37
+ return f"{self!s}-snapshot"
38
+
39
+
40
+ class Position(int):
41
+ @classmethod
42
+ def from_version(cls, version: int) -> "Position":
43
+ return Position(version - 1)
44
+
45
+ def as_version(self) -> int:
46
+ return self + 1
47
+
48
+
49
+ MAX_POSITION = Position(sys.maxsize)
50
+
51
+
52
+ def scope(
53
+ start_version: int | None,
54
+ stop_version: int | None,
55
+ ) -> tuple[Position | None, int]:
56
+ start = cast(
57
+ Position | None, start_version and Position.from_version(start_version)
58
+ )
59
+ stop = stop_version and Position.from_version(stop_version) or MAX_POSITION
60
+ return start, stop - (start or 0)
@@ -66,9 +66,7 @@ class ESDBSubscriptionStrategy(SubscriptionStrategy):
66
66
  self._client.subscribe_to_all,
67
67
  commit_position=start_from,
68
68
  timeout=timelimit.total_seconds(),
69
- filter_include=[
70
- f"{category}-\\w+",
71
- ],
69
+ filter_include=[f"{category}-[^-]*-\\w+"],
72
70
  filter_by_stream_name=True,
73
71
  )
74
72
  return self._iterator(builder, batch_size)
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Sequence
2
- from dataclasses import dataclass
2
+ from dataclasses import dataclass, replace
3
3
  from typing import cast
4
4
 
5
5
  from more_itertools import first_true
@@ -7,6 +7,7 @@ from sqlalchemy import delete, func, select, update
7
7
  from sqlalchemy.dialects.postgresql import insert as postgresql_insert
8
8
  from sqlalchemy.exc import NoResultFound
9
9
  from sqlalchemy.orm import Session
10
+ from typing_extensions import Self
10
11
 
11
12
  from event_sourcery.event_store import (
12
13
  NO_VERSIONING,
@@ -22,6 +23,7 @@ from event_sourcery.event_store.exceptions import (
22
23
  ConcurrentStreamWriteError,
23
24
  )
24
25
  from event_sourcery.event_store.interfaces import StorageStrategy
26
+ from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
25
27
  from event_sourcery_sqlalchemy.models import Event as EventModel
26
28
  from event_sourcery_sqlalchemy.models import Snapshot as SnapshotModel
27
29
  from event_sourcery_sqlalchemy.models import Stream as StreamModel
@@ -33,6 +35,7 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
33
35
  _session: Session
34
36
  _dispatcher: Dispatcher
35
37
  _outbox: SqlAlchemyOutboxStorageStrategy | None = None
38
+ _tenant_id: TenantId = DEFAULT_TENANT
36
39
 
37
40
  def fetch_events(
38
41
  self,
@@ -42,7 +45,7 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
42
45
  ) -> list[RawEvent]:
43
46
  events_stmt = (
44
47
  select(EventModel)
45
- .filter_by(stream_id=stream_id)
48
+ .filter_by(stream_id=stream_id, tenant_id=self._tenant_id)
46
49
  .order_by(EventModel.version)
47
50
  )
48
51
 
@@ -57,7 +60,10 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
57
60
  snapshot_stmt = (
58
61
  select(SnapshotModel)
59
62
  .join(StreamModel)
60
- .filter(StreamModel.stream_id == stream_id)
63
+ .filter(
64
+ StreamModel.stream_id == stream_id,
65
+ StreamModel.tenant_id == self._tenant_id,
66
+ )
61
67
  .order_by(SnapshotModel.created_at.desc())
62
68
  .limit(1)
63
69
  )
@@ -97,13 +103,16 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
97
103
  def _ensure_stream(self, stream_id: StreamId, versioning: Versioning) -> None:
98
104
  initial_version = versioning.initial_version
99
105
 
100
- condition = (StreamModel.uuid == stream_id) & (
101
- StreamModel.category == (stream_id.category or "")
106
+ condition = (
107
+ (StreamModel.uuid == stream_id)
108
+ & (StreamModel.category == (stream_id.category or ""))
109
+ & (StreamModel.tenant_id == self._tenant_id)
102
110
  )
103
111
  if stream_id.name:
104
112
  condition = condition | (
105
113
  (StreamModel.name == stream_id.name)
106
114
  & (StreamModel.category == (stream_id.category or ""))
115
+ & (StreamModel.tenant_id == self._tenant_id)
107
116
  )
108
117
  matching_streams_stmt = select(StreamModel).where(condition)
109
118
  matching_streams = self._session.execute(matching_streams_stmt).scalars().all()
@@ -115,6 +124,7 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
115
124
  name=stream_id.name,
116
125
  category=stream_id.category or "",
117
126
  version=initial_version,
127
+ tenant_id=self._tenant_id,
118
128
  )
119
129
  .on_conflict_do_nothing()
120
130
  )
@@ -163,12 +173,14 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
163
173
  self, stream_id: StreamId, versioning: Versioning, events: list[RawEvent]
164
174
  ) -> None:
165
175
  self._ensure_stream(stream_id=stream_id, versioning=versioning)
176
+
166
177
  stream = cast(
167
178
  StreamModel,
168
179
  first_true(
169
180
  self._session.info["strong_set"],
170
181
  pred=lambda model: isinstance(model, StreamModel)
171
- and model.stream_id == stream_id,
182
+ and model.stream_id == stream_id
183
+ and model.tenant_id == self._tenant_id,
172
184
  ),
173
185
  )
174
186
 
@@ -186,7 +198,7 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
186
198
  stream.events.extend(entries)
187
199
  self._session.flush()
188
200
  records = [
189
- RecordedRaw(entry=raw, position=db.id)
201
+ RecordedRaw(entry=raw, position=db.id, tenant_id=db.tenant_id)
190
202
  for raw, db in zip(events, entries, strict=False)
191
203
  ]
192
204
  if self._outbox:
@@ -226,3 +238,6 @@ class SqlAlchemyStorageStrategy(StorageStrategy):
226
238
  stmt = select(func.max(EventModel.id))
227
239
  last_event = self._session.scalar(stmt)
228
240
  return last_event or Position(0)
241
+
242
+ def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
243
+ return replace(self, _tenant_id=tenant_id)
@@ -26,6 +26,7 @@ from sqlalchemy.orm import (
26
26
  )
27
27
 
28
28
  from event_sourcery.event_store import StreamId
29
+ from event_sourcery.event_store.tenant_id import TenantId
29
30
  from event_sourcery_sqlalchemy.guid import GUID
30
31
  from event_sourcery_sqlalchemy.jsonb import JSONB
31
32
 
@@ -57,14 +58,15 @@ class StreamIdComparator(Comparator[StreamId]):
57
58
  class Stream:
58
59
  __tablename__ = "event_sourcery_streams"
59
60
  __table_args__ = (
60
- UniqueConstraint("uuid", "category"),
61
- UniqueConstraint("name", "category"),
61
+ UniqueConstraint("uuid", "category", "tenant_id"),
62
+ UniqueConstraint("name", "category", "tenant_id"),
62
63
  )
63
64
 
64
65
  id = mapped_column(BigInteger().with_variant(Integer(), "sqlite"), primary_key=True)
65
66
  uuid = mapped_column(GUID(), nullable=False)
66
67
  name = mapped_column(String(255), nullable=True, default=None)
67
68
  category = mapped_column(String(255), nullable=False, default="")
69
+ tenant_id = mapped_column(String(255), nullable=False)
68
70
  version = mapped_column(BigInteger(), nullable=True)
69
71
 
70
72
  events: Mapped[list["Event"]]
@@ -119,6 +121,7 @@ class Event:
119
121
  )
120
122
  stream: Mapped[Stream] = relationship(Stream, back_populates="events")
121
123
  stream_id: AssociationProxy[StreamId] = association_proxy("stream", "stream_id")
124
+ tenant_id: AssociationProxy[TenantId] = association_proxy("stream", "tenant_id")
122
125
  name = mapped_column(String(200), nullable=False)
123
126
  data = mapped_column(JSONB(), nullable=False)
124
127
  event_context = mapped_column(JSONB(), nullable=False)
@@ -36,6 +36,7 @@ class SqlAlchemyOutboxStorageStrategy(OutboxStorageStrategy):
36
36
  as_dict["created_at"] = created_at.isoformat()
37
37
  as_dict["uuid"] = str(as_dict["uuid"])
38
38
  as_dict["stream_id"] = str(as_dict["stream_id"])
39
+ as_dict["tenant_id"] = str(record.tenant_id)
39
40
  rows.append(
40
41
  {
41
42
  "created_at": datetime.utcnow(),
@@ -82,7 +83,11 @@ class SqlAlchemyOutboxStorageStrategy(OutboxStorageStrategy):
82
83
  context=entry.data["context"],
83
84
  )
84
85
  try:
85
- yield RecordedRaw(entry=raw, position=entry.position)
86
+ yield RecordedRaw(
87
+ entry=raw,
88
+ position=entry.position,
89
+ tenant_id=entry.data["tenant_id"],
90
+ )
86
91
  except Exception:
87
92
  logger.exception("Failed to publish message #%d", entry.id)
88
93
  entry.tries_left -= 1
@@ -171,6 +171,7 @@ class GapDetectingIterator(Iterator[list[RecordedRaw]]):
171
171
  RecordedRaw(
172
172
  entry=dto.raw_event(event, event.stream),
173
173
  position=event.id,
174
+ tenant_id=event.tenant_id,
174
175
  )
175
176
  for event in batch
176
177
  ]
@@ -3,7 +3,7 @@ requires-python = ">=3.10"
3
3
 
4
4
  [tool.poetry]
5
5
  name = "python-event-sourcery"
6
- version = "0.4.0a9"
6
+ version = "0.4.0a10"
7
7
  description = ""
8
8
  authors = ["Your Name <you@example.com>"]
9
9
  license = "MIT"
@@ -1,65 +0,0 @@
1
- import sys
2
- from collections import UserString
3
- from typing import cast
4
-
5
- from event_sourcery.event_store import StreamId
6
- from event_sourcery.event_store.exceptions import IllegalCategoryName
7
-
8
-
9
- class Name(UserString):
10
- uuid: StreamId
11
-
12
- def __init__(
13
- self,
14
- stream_id: StreamId | None = None,
15
- stream_name: str | None = None,
16
- ) -> None:
17
- self.uuid = stream_id or self._get_id(from_name=(stream_name or ""))
18
- super().__init__(self._as_string(self.uuid))
19
-
20
- @staticmethod
21
- def _get_id(from_name: str) -> StreamId:
22
- category: str | None = None
23
- if from_name.endswith("-snapshot"):
24
- from_name, _ = from_name.rsplit("-", 1)
25
- if "-" in from_name:
26
- category, from_name = from_name.split("-", 1)
27
- return StreamId(from_hex=from_name, category=category)
28
-
29
- @staticmethod
30
- def _as_string(stream_id: StreamId) -> str:
31
- if stream_id.category:
32
- if "-" in stream_id.category:
33
- raise IllegalCategoryName("ESDB storage can't handle category with '-'")
34
- return f"{stream_id.category}-{stream_id.hex}"
35
- return stream_id.hex
36
-
37
- @property
38
- def snapshot(self) -> str:
39
- return f"{self!s}-snapshot"
40
-
41
-
42
- class Position(int):
43
- def __new__(cls, value: int | str) -> "Position":
44
- return super().__new__(Position, value)
45
-
46
- @classmethod
47
- def from_version(cls, version: int) -> "Position":
48
- return Position(version - 1)
49
-
50
- def as_version(self) -> int:
51
- return self + 1
52
-
53
-
54
- MAX_POSITION = Position(sys.maxsize)
55
-
56
-
57
- def scope(
58
- start_version: int | None,
59
- stop_version: int | None,
60
- ) -> tuple[Position | None, int]:
61
- start = cast(
62
- Position | None, start_version and Position.from_version(start_version)
63
- )
64
- stop = stop_version and Position.from_version(stop_version) or MAX_POSITION
65
- return start, stop - (start or 0)