eventsourcing 9.5.0b3__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.
- eventsourcing/__init__.py +0 -0
- eventsourcing/application.py +998 -0
- eventsourcing/cipher.py +107 -0
- eventsourcing/compressor.py +15 -0
- eventsourcing/cryptography.py +91 -0
- eventsourcing/dcb/__init__.py +0 -0
- eventsourcing/dcb/api.py +144 -0
- eventsourcing/dcb/application.py +159 -0
- eventsourcing/dcb/domain.py +369 -0
- eventsourcing/dcb/msgpack.py +38 -0
- eventsourcing/dcb/persistence.py +193 -0
- eventsourcing/dcb/popo.py +178 -0
- eventsourcing/dcb/postgres_tt.py +704 -0
- eventsourcing/dcb/tests.py +608 -0
- eventsourcing/dispatch.py +80 -0
- eventsourcing/domain.py +1964 -0
- eventsourcing/interface.py +164 -0
- eventsourcing/persistence.py +1429 -0
- eventsourcing/popo.py +267 -0
- eventsourcing/postgres.py +1441 -0
- eventsourcing/projection.py +502 -0
- eventsourcing/py.typed +0 -0
- eventsourcing/sqlite.py +816 -0
- eventsourcing/system.py +1203 -0
- eventsourcing/tests/__init__.py +3 -0
- eventsourcing/tests/application.py +483 -0
- eventsourcing/tests/domain.py +105 -0
- eventsourcing/tests/persistence.py +1744 -0
- eventsourcing/tests/postgres_utils.py +131 -0
- eventsourcing/utils.py +257 -0
- eventsourcing-9.5.0b3.dist-info/METADATA +253 -0
- eventsourcing-9.5.0b3.dist-info/RECORD +35 -0
- eventsourcing-9.5.0b3.dist-info/WHEEL +4 -0
- eventsourcing-9.5.0b3.dist-info/licenses/AUTHORS +10 -0
- eventsourcing-9.5.0b3.dist-info/licenses/LICENSE +29 -0
eventsourcing/cipher.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from base64 import b64decode, b64encode
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Cipher._mode_gcm import (
|
|
9
|
+
GcmMode, # pyright: ignore [reportPrivateImportUsage]
|
|
10
|
+
)
|
|
11
|
+
from Crypto.Cipher.AES import key_size
|
|
12
|
+
|
|
13
|
+
from eventsourcing.persistence import Cipher
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from eventsourcing.utils import Environment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AESCipher(Cipher):
|
|
20
|
+
"""Cipher strategy that uses AES cipher (in GCM mode)
|
|
21
|
+
from the Python pycryptodome package.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
CIPHER_KEY = "CIPHER_KEY"
|
|
25
|
+
KEY_SIZES = key_size
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def create_key(num_bytes: int) -> str:
|
|
29
|
+
"""Creates AES cipher key, with length num_bytes.
|
|
30
|
+
|
|
31
|
+
:param num_bytes: An int value, either 16, 24, or 32.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
AESCipher.check_key_size(num_bytes)
|
|
35
|
+
return b64encode(AESCipher.random_bytes(num_bytes)).decode("utf8")
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def check_key_size(num_bytes: int) -> None:
|
|
39
|
+
if num_bytes not in AESCipher.KEY_SIZES:
|
|
40
|
+
msg = f"Invalid key size: {num_bytes} not in {AESCipher.KEY_SIZES}"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def random_bytes(num_bytes: int) -> bytes:
|
|
45
|
+
return os.urandom(num_bytes)
|
|
46
|
+
|
|
47
|
+
def __init__(self, environment: Environment):
|
|
48
|
+
"""Initialises AES cipher with ``cipher_key``.
|
|
49
|
+
|
|
50
|
+
:param str cipher_key: 16, 24, or 32 bytes encoded as base64
|
|
51
|
+
"""
|
|
52
|
+
cipher_key = environment.get(self.CIPHER_KEY)
|
|
53
|
+
if not cipher_key:
|
|
54
|
+
msg = f"'{self.CIPHER_KEY}' not in env"
|
|
55
|
+
raise OSError(msg)
|
|
56
|
+
key = b64decode(cipher_key.encode("utf8"))
|
|
57
|
+
AESCipher.check_key_size(len(key))
|
|
58
|
+
self.key = key
|
|
59
|
+
|
|
60
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
61
|
+
"""Return ciphertext for given plaintext."""
|
|
62
|
+
# Construct AES-GCM cipher, with 96-bit nonce.
|
|
63
|
+
nonce = AESCipher.random_bytes(12)
|
|
64
|
+
cipher = self.construct_cipher(nonce)
|
|
65
|
+
|
|
66
|
+
# Encrypt and digest.
|
|
67
|
+
result = cipher.encrypt_and_digest(plaintext)
|
|
68
|
+
encrypted = result[0]
|
|
69
|
+
tag = result[1]
|
|
70
|
+
|
|
71
|
+
# Return ciphertext.
|
|
72
|
+
return nonce + tag + encrypted
|
|
73
|
+
# return nonce + tag + encrypted
|
|
74
|
+
|
|
75
|
+
def construct_cipher(self, nonce: bytes) -> GcmMode:
|
|
76
|
+
cipher = AES.new(
|
|
77
|
+
self.key,
|
|
78
|
+
AES.MODE_GCM,
|
|
79
|
+
nonce,
|
|
80
|
+
)
|
|
81
|
+
assert isinstance(cipher, GcmMode)
|
|
82
|
+
return cipher
|
|
83
|
+
|
|
84
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
85
|
+
"""Return plaintext for given ciphertext."""
|
|
86
|
+
# Split out the nonce, tag, and encrypted data.
|
|
87
|
+
nonce = ciphertext[:12]
|
|
88
|
+
if len(nonce) != 12:
|
|
89
|
+
msg = "Damaged cipher text: invalid nonce length"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
|
|
92
|
+
tag = ciphertext[12:28]
|
|
93
|
+
if len(tag) != 16:
|
|
94
|
+
msg = "Damaged cipher text: invalid tag length"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
encrypted = ciphertext[28:]
|
|
97
|
+
|
|
98
|
+
# Construct AES cipher, with old nonce.
|
|
99
|
+
cipher = self.construct_cipher(nonce)
|
|
100
|
+
|
|
101
|
+
# Decrypt and verify.
|
|
102
|
+
try:
|
|
103
|
+
plaintext = cipher.decrypt_and_verify(encrypted, tag)
|
|
104
|
+
except ValueError as e:
|
|
105
|
+
msg = f"Cipher text is damaged: {e}"
|
|
106
|
+
raise ValueError(msg) from None
|
|
107
|
+
return plaintext
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import zlib
|
|
4
|
+
|
|
5
|
+
from eventsourcing.persistence import Compressor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ZlibCompressor(Compressor):
|
|
9
|
+
def compress(self, data: bytes) -> bytes:
|
|
10
|
+
"""Compress bytes using zlib."""
|
|
11
|
+
return zlib.compress(data)
|
|
12
|
+
|
|
13
|
+
def decompress(self, data: bytes) -> bytes:
|
|
14
|
+
"""Decompress bytes using zlib."""
|
|
15
|
+
return zlib.decompress(data)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from base64 import b64decode, b64encode
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cryptography.exceptions import InvalidTag
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
|
|
10
|
+
from eventsourcing.persistence import Cipher
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from eventsourcing.utils import Environment
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AESCipher(Cipher):
|
|
17
|
+
"""Cipher strategy that uses AES cipher (in GCM mode)
|
|
18
|
+
from the Python cryptography package.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
CIPHER_KEY = "CIPHER_KEY"
|
|
22
|
+
KEY_SIZES = (16, 24, 32)
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def create_key(num_bytes: int) -> str:
|
|
26
|
+
"""Creates AES cipher key, with length num_bytes.
|
|
27
|
+
|
|
28
|
+
:param num_bytes: An int value, either 16, 24, or 32.
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
AESCipher.check_key_size(num_bytes)
|
|
32
|
+
key = AESGCM.generate_key(num_bytes * 8)
|
|
33
|
+
return b64encode(key).decode("utf8")
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def check_key_size(num_bytes: int) -> None:
|
|
37
|
+
if num_bytes not in AESCipher.KEY_SIZES:
|
|
38
|
+
msg = f"Invalid key size: {num_bytes} not in {AESCipher.KEY_SIZES}"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def random_bytes(num_bytes: int) -> bytes:
|
|
43
|
+
return os.urandom(num_bytes)
|
|
44
|
+
|
|
45
|
+
def __init__(self, environment: Environment):
|
|
46
|
+
"""Initialises AES cipher with ``cipher_key``.
|
|
47
|
+
|
|
48
|
+
:param str cipher_key: 16, 24, or 32 bytes encoded as base64
|
|
49
|
+
"""
|
|
50
|
+
cipher_key = environment.get(self.CIPHER_KEY)
|
|
51
|
+
if not cipher_key:
|
|
52
|
+
msg = f"'{self.CIPHER_KEY}' not in env"
|
|
53
|
+
raise OSError(msg)
|
|
54
|
+
key = b64decode(cipher_key.encode("utf8"))
|
|
55
|
+
AESCipher.check_key_size(len(key))
|
|
56
|
+
self.key = key
|
|
57
|
+
|
|
58
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
59
|
+
"""Return ciphertext for given plaintext."""
|
|
60
|
+
# Construct AES-GCM cipher, with 96-bit nonce.
|
|
61
|
+
aesgcm = AESGCM(self.key)
|
|
62
|
+
nonce = AESCipher.random_bytes(12)
|
|
63
|
+
res = aesgcm.encrypt(nonce, plaintext, None)
|
|
64
|
+
# Put tag at the front for compatibility with eventsourcing.crypto.AESCipher.
|
|
65
|
+
tag = res[-16:]
|
|
66
|
+
encrypted = res[:-16]
|
|
67
|
+
return nonce + tag + encrypted
|
|
68
|
+
|
|
69
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
70
|
+
"""Return plaintext for given ciphertext."""
|
|
71
|
+
# Split out the nonce, tag, and encrypted data.
|
|
72
|
+
nonce = ciphertext[:12]
|
|
73
|
+
if len(nonce) != 12:
|
|
74
|
+
msg = "Damaged cipher text: invalid nonce length"
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
|
|
77
|
+
# Expect tag at the front.
|
|
78
|
+
tag = ciphertext[12:28]
|
|
79
|
+
if len(tag) != 16:
|
|
80
|
+
msg = "Damaged cipher text: invalid tag length"
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
encrypted = ciphertext[28:]
|
|
83
|
+
|
|
84
|
+
aesgcm = AESGCM(self.key)
|
|
85
|
+
try:
|
|
86
|
+
plaintext = aesgcm.decrypt(nonce, encrypted + tag, None)
|
|
87
|
+
except InvalidTag as e:
|
|
88
|
+
msg = "Invalid cipher tag"
|
|
89
|
+
raise ValueError(msg) from e
|
|
90
|
+
# Decrypt and verify.
|
|
91
|
+
return plaintext
|
|
File without changes
|
eventsourcing/dcb/api.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from eventsourcing.persistence import ProgrammingError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DCBQueryItem:
|
|
18
|
+
types: list[str] = field(default_factory=list)
|
|
19
|
+
tags: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DCBQuery:
|
|
24
|
+
items: list[DCBQueryItem] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DCBAppendCondition:
|
|
29
|
+
fail_if_events_match: DCBQuery = field(default_factory=DCBQuery)
|
|
30
|
+
after: int | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DCBEvent:
|
|
35
|
+
type: str
|
|
36
|
+
data: bytes
|
|
37
|
+
tags: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DCBSequencedEvent:
|
|
42
|
+
event: DCBEvent
|
|
43
|
+
position: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DCBReadResponse(Iterator[DCBSequencedEvent], ABC):
|
|
47
|
+
@property
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def head(self) -> int | None:
|
|
50
|
+
pass # pragma: no cover
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def __next__(self) -> DCBSequencedEvent:
|
|
54
|
+
pass # pragma: no cover
|
|
55
|
+
|
|
56
|
+
# @abstractmethod
|
|
57
|
+
# def next_batch(self) -> list[DCBSequencedEvent]:
|
|
58
|
+
# """
|
|
59
|
+
# Returns a batch of events as a list.
|
|
60
|
+
# Updates the head position similar to __next__.
|
|
61
|
+
# """
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DCBRecorder(ABC):
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def read(
|
|
67
|
+
self,
|
|
68
|
+
query: DCBQuery | None = None,
|
|
69
|
+
*,
|
|
70
|
+
after: int | None = None,
|
|
71
|
+
limit: int | None = None,
|
|
72
|
+
) -> DCBReadResponse:
|
|
73
|
+
"""
|
|
74
|
+
Returns all events, unless 'after' is given then only those with position
|
|
75
|
+
greater than 'after', and unless any query items are given, then only those
|
|
76
|
+
that match at least one query item. An event matches a query item if its type
|
|
77
|
+
is in the item types or there are no item types, and if all the item tags are
|
|
78
|
+
in the event tags.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def append(
|
|
83
|
+
self, events: Sequence[DCBEvent], condition: DCBAppendCondition | None = None
|
|
84
|
+
) -> int:
|
|
85
|
+
"""
|
|
86
|
+
Appends given events to the event store, unless the condition fails.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def subscribe(
|
|
91
|
+
self,
|
|
92
|
+
query: DCBQuery | None = None,
|
|
93
|
+
*,
|
|
94
|
+
after: int | None = None,
|
|
95
|
+
) -> DCBSubscription[Self]:
|
|
96
|
+
"""
|
|
97
|
+
Returns all events, unless 'after' is given then only those with position
|
|
98
|
+
greater than 'after', and unless any query items are given, then only those
|
|
99
|
+
that match at least one query item. An event matches a query item if its type
|
|
100
|
+
is in the item types or there are no item types, and if all the item tags are
|
|
101
|
+
in the event tags. The subscription will block when the last recorded event
|
|
102
|
+
is received, and then continue when new events are recorded.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
TDCBRecorder_co = TypeVar("TDCBRecorder_co", bound=DCBRecorder, covariant=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DCBSubscription(Iterator[DCBSequencedEvent], Generic[TDCBRecorder_co]):
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
recorder: TDCBRecorder_co,
|
|
113
|
+
query: DCBQuery | None = None,
|
|
114
|
+
after: int | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._recorder = recorder
|
|
117
|
+
self._query = query
|
|
118
|
+
self._has_been_entered = False
|
|
119
|
+
self._has_been_stopped = False
|
|
120
|
+
self._last_position: int = after or 0
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> Self:
|
|
123
|
+
if self._has_been_entered:
|
|
124
|
+
msg = "Already entered subscription context manager"
|
|
125
|
+
raise ProgrammingError(msg)
|
|
126
|
+
self._has_been_entered = True
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *args: object, **kwargs: Any) -> None:
|
|
130
|
+
if not self._has_been_entered:
|
|
131
|
+
msg = "Not already entered subscription context manager"
|
|
132
|
+
raise ProgrammingError(msg)
|
|
133
|
+
self.stop()
|
|
134
|
+
|
|
135
|
+
def stop(self) -> None:
|
|
136
|
+
"""Stops the subscription."""
|
|
137
|
+
self._has_been_stopped = True
|
|
138
|
+
|
|
139
|
+
def __iter__(self) -> Self:
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
@abstractmethod
|
|
143
|
+
def __next__(self) -> DCBSequencedEvent:
|
|
144
|
+
"""Returns the next DCBEvent in the sequence."""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Generic, cast
|
|
5
|
+
|
|
6
|
+
from eventsourcing.dcb.domain import (
|
|
7
|
+
EnduringObject,
|
|
8
|
+
InitialDecision,
|
|
9
|
+
Perspective,
|
|
10
|
+
Selector,
|
|
11
|
+
TDecision,
|
|
12
|
+
TGroup,
|
|
13
|
+
TPerspective,
|
|
14
|
+
TSlice,
|
|
15
|
+
)
|
|
16
|
+
from eventsourcing.dcb.persistence import (
|
|
17
|
+
DCBEventStore,
|
|
18
|
+
DCBInfrastructureFactory,
|
|
19
|
+
DCBMapper,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
)
|
|
22
|
+
from eventsourcing.utils import Environment, EnvType, resolve_topic
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
|
|
28
|
+
from typing_extensions import Self
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DCBApplication:
|
|
32
|
+
name = "DCBApplication"
|
|
33
|
+
env: Mapping[str, str] = {"PERSISTENCE_MODULE": "eventsourcing.dcb.popo"}
|
|
34
|
+
|
|
35
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
36
|
+
if "name" not in cls.__dict__:
|
|
37
|
+
cls.name = cls.__name__
|
|
38
|
+
|
|
39
|
+
def __init__(self, env: EnvType | None = None):
|
|
40
|
+
self.env = self.construct_env(self.name, env)
|
|
41
|
+
self.factory = DCBInfrastructureFactory.construct(self.env)
|
|
42
|
+
self.recorder = self.factory.dcb_recorder()
|
|
43
|
+
if "MAPPER_TOPIC" in self.env:
|
|
44
|
+
# Only need a mapper, event store, and repository
|
|
45
|
+
# if we are using the higher-level abstractions.
|
|
46
|
+
self.mapper = cast(
|
|
47
|
+
DCBMapper[Any], resolve_topic(self.env["MAPPER_TOPIC"])()
|
|
48
|
+
)
|
|
49
|
+
assert isinstance(self.mapper, DCBMapper)
|
|
50
|
+
self.events = DCBEventStore(self.mapper, self.recorder)
|
|
51
|
+
self.repository = DCBRepository(self.events)
|
|
52
|
+
|
|
53
|
+
def construct_env(self, name: str, env: EnvType | None = None) -> Environment:
|
|
54
|
+
"""Constructs environment from which application will be configured."""
|
|
55
|
+
_env = dict(type(self).env)
|
|
56
|
+
_env.update(os.environ)
|
|
57
|
+
if env is not None:
|
|
58
|
+
_env.update(env)
|
|
59
|
+
return Environment(name, _env)
|
|
60
|
+
|
|
61
|
+
def do(self, s: TSlice) -> TSlice:
|
|
62
|
+
"""
|
|
63
|
+
Advances and executes a slice, then saves new decisions.
|
|
64
|
+
"""
|
|
65
|
+
if type(s).do_projection:
|
|
66
|
+
s = self.repository.advance(s)
|
|
67
|
+
s.execute()
|
|
68
|
+
if s.new_decisions:
|
|
69
|
+
self.repository.save(s)
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self.factory.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> Self:
|
|
76
|
+
self.factory.__enter__()
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: type[BaseException] | None,
|
|
82
|
+
exc_val: BaseException | None,
|
|
83
|
+
exc_tb: TracebackType | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
self.factory.__exit__(exc_type, exc_val, exc_tb)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DCBRepository(Generic[TDecision]):
|
|
90
|
+
def __init__(self, eventstore: DCBEventStore[TDecision]):
|
|
91
|
+
self.eventstore = eventstore
|
|
92
|
+
|
|
93
|
+
def save(self, p: Perspective[TDecision]) -> int:
|
|
94
|
+
return self.eventstore.append(
|
|
95
|
+
events=p.collect_events(),
|
|
96
|
+
cb=p.consistency_boundary(),
|
|
97
|
+
after=p.last_known_position,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def get(
|
|
101
|
+
self,
|
|
102
|
+
enduring_object_id: str,
|
|
103
|
+
) -> EnduringObject[TDecision]:
|
|
104
|
+
cb = [Selector(tags=[enduring_object_id])]
|
|
105
|
+
events = self.eventstore.read(*cb)
|
|
106
|
+
obj: EnduringObject[TDecision] | None = None
|
|
107
|
+
for event in events:
|
|
108
|
+
obj = event.decision.mutate(obj)
|
|
109
|
+
if obj is None:
|
|
110
|
+
raise NotFoundError
|
|
111
|
+
obj.last_known_position = events.head
|
|
112
|
+
return obj
|
|
113
|
+
|
|
114
|
+
def get_many(
|
|
115
|
+
self,
|
|
116
|
+
*enduring_object_ids: str,
|
|
117
|
+
) -> list[EnduringObject[TDecision] | None]:
|
|
118
|
+
cb = [
|
|
119
|
+
Selector(tags=[enduring_object_id])
|
|
120
|
+
for enduring_object_id in enduring_object_ids
|
|
121
|
+
]
|
|
122
|
+
tagged_decisions = self.eventstore.read(cb)
|
|
123
|
+
objs: dict[str, EnduringObject[TDecision] | None] = dict.fromkeys(
|
|
124
|
+
enduring_object_ids
|
|
125
|
+
)
|
|
126
|
+
for tagged in tagged_decisions:
|
|
127
|
+
for tag in tagged.tags:
|
|
128
|
+
obj = objs.get(tag)
|
|
129
|
+
if not isinstance(tagged.decision, InitialDecision) and not obj:
|
|
130
|
+
continue
|
|
131
|
+
obj = tagged.decision.mutate(obj)
|
|
132
|
+
objs[tag] = obj
|
|
133
|
+
for obj in objs.values():
|
|
134
|
+
if obj is not None:
|
|
135
|
+
obj.last_known_position = tagged_decisions.head
|
|
136
|
+
return list(objs.values())
|
|
137
|
+
|
|
138
|
+
def get_group(self, cls: type[TGroup], *enduring_object_ids: str) -> TGroup:
|
|
139
|
+
enduring_objects = self.get_many(*enduring_object_ids)
|
|
140
|
+
perspective = cls(*enduring_objects)
|
|
141
|
+
last_known_positions = [
|
|
142
|
+
o.last_known_position
|
|
143
|
+
for o in enduring_objects
|
|
144
|
+
if o and o.last_known_position
|
|
145
|
+
]
|
|
146
|
+
perspective.last_known_position = (
|
|
147
|
+
max(last_known_positions) if last_known_positions else None
|
|
148
|
+
)
|
|
149
|
+
return perspective
|
|
150
|
+
|
|
151
|
+
def advance(self, p: TPerspective) -> TPerspective:
|
|
152
|
+
events = self.eventstore.read(
|
|
153
|
+
cb=p.consistency_boundary(),
|
|
154
|
+
after=p.last_known_position,
|
|
155
|
+
)
|
|
156
|
+
for event in events:
|
|
157
|
+
event.decision.mutate(p)
|
|
158
|
+
p.last_known_position = events.head
|
|
159
|
+
return p
|