zelos.storage

Pluggable Storage Backends — Event persistence + State storage.

Phase 2: InMemory, Redis, PostgreSQL, MySQL backends. All share a common interface: connect / disconnect / append / read / state / snapshot.

  1"""
  2Pluggable Storage Backends — Event persistence + State storage.
  3
  4Phase 2: InMemory, Redis, PostgreSQL, MySQL backends.
  5All share a common interface: connect / disconnect / append / read / state / snapshot.
  6"""
  7
  8import json
  9import threading
 10import time
 11from abc import ABC, abstractmethod
 12
 13# ═══════════════════ Common Interface ═══════════════════
 14
 15
 16class StorageBackend(ABC):
 17    """Abstract storage backend for events and state."""
 18
 19    def __init__(self, config: dict | None = None):
 20        self.config = config or {}
 21        self._connected = False
 22
 23    @abstractmethod
 24    def connect(self) -> bool: ...
 25
 26    @abstractmethod
 27    def disconnect(self) -> None: ...
 28
 29    @abstractmethod
 30    def health(self) -> bool: ...
 31
 32    @abstractmethod
 33    def append(self, stream: str, events: list[dict]) -> int: ...
 34
 35    @abstractmethod
 36    def read(self, stream: str, from_position: int, count: int) -> list[dict]: ...
 37
 38    @abstractmethod
 39    def set_state(self, key: str, value: dict) -> None: ...
 40
 41    @abstractmethod
 42    def get_state(self, key: str) -> dict | None: ...
 43
 44    @abstractmethod
 45    def delete_state(self, key: str) -> None: ...
 46
 47    def create_snapshot(self, key: str, events_position: int, state: dict) -> None:
 48        self.set_state(
 49            f"snapshot:{key}", {"events_position": events_position, "state": state, "timestamp": time.time()}
 50        )
 51
 52    def get_snapshot(self, key: str) -> dict | None:
 53        return self.get_state(f"snapshot:{key}")
 54
 55    @property
 56    def is_connected(self) -> bool:
 57        return self._connected
 58
 59
 60# ═══════════════════ InMemory ═══════════════════
 61
 62
 63class InMemoryStorageBackend(StorageBackend):
 64    """Phase 1 compatible — stores everything in memory."""
 65
 66    def __init__(self, config: dict | None = None):
 67        super().__init__(config)
 68        self._streams: dict[str, list[dict]] = {}
 69        self._state: dict[str, dict] = {}
 70        self._lock = threading.Lock()
 71
 72    def connect(self) -> bool:
 73        self._connected = True
 74        return True
 75
 76    def disconnect(self) -> None:
 77        self._connected = False
 78
 79    def health(self) -> bool:
 80        return self._connected
 81
 82    def append(self, stream: str, events: list[dict]) -> int:
 83        with self._lock:
 84            if stream not in self._streams:
 85                self._streams[stream] = []
 86            self._streams[stream].extend(events)
 87            return len(self._streams[stream])
 88
 89    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
 90        with self._lock:
 91            events = self._streams.get(stream, [])
 92            return events[from_position : from_position + count]
 93
 94    def set_state(self, key: str, value: dict) -> None:
 95        with self._lock:
 96            self._state[key] = value
 97
 98    def get_state(self, key: str) -> dict | None:
 99        return self._state.get(key)
100
101    def delete_state(self, key: str) -> None:
102        self._state.pop(key, None)
103
104
105# ═══════════════════ Redis ═══════════════════
106
107
108class RedisStorageBackend(StorageBackend):
109    """Redis-backed storage. Events stored as lists, state as hash.
110
111    Config:
112      url: redis://localhost:6379/0
113      prefix: "zelos" (key namespace)
114    """
115
116    def __init__(self, config: dict | None = None):
117        super().__init__(config)
118        self._url = (config or {}).get("url", "redis://localhost:6379/0")
119        self._prefix = (config or {}).get("prefix", "zelos")
120        self._client = None
121
122    def connect(self) -> bool:
123        try:
124            import redis
125
126            self._client = redis.Redis.from_url(self._url, decode_responses=True)
127            self._client.ping()
128            self._connected = True
129            return True
130        except Exception:
131            self._connected = False
132            return False
133
134    def disconnect(self) -> None:
135        if self._client:
136            self._client.close()
137        self._connected = False
138
139    def health(self) -> bool:
140        if not self._client:
141            return False
142        try:
143            self._client.ping()
144            return True
145        except Exception:
146            return False
147
148    def _stream_key(self, stream: str) -> str:
149        return f"{self._prefix}:stream:{stream}"
150
151    def _state_key(self, key: str) -> str:
152        return f"{self._prefix}:state:{key}"
153
154    def append(self, stream: str, events: list[dict]) -> int:
155        if not self._client:
156            return -1
157        pipe = self._client.pipeline()
158        for e in events:
159            pipe.rpush(self._stream_key(stream), json.dumps(e))
160        pipe.execute()
161        return self._client.llen(self._stream_key(stream))
162
163    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
164        if not self._client:
165            return []
166        raw = self._client.lrange(self._stream_key(stream), from_position, from_position + count - 1)
167        return [json.loads(r) for r in raw]
168
169    def set_state(self, key: str, value: dict) -> None:
170        if self._client:
171            self._client.set(self._state_key(key), json.dumps(value))
172
173    def get_state(self, key: str) -> dict | None:
174        if not self._client:
175            return None
176        raw = self._client.get(self._state_key(key))
177        return json.loads(raw) if raw else None
178
179    def delete_state(self, key: str) -> None:
180        if self._client:
181            self._client.delete(self._state_key(key))
182
183
184# ═══════════════════ PostgreSQL ═══════════════════
185
186
187class PostgreSQLStorageBackend(StorageBackend):
188    """PostgreSQL-backed storage. Events table + state table.
189
190    Config:
191      url: postgresql://user:pass@localhost:5432/zelos
192    """
193
194    def __init__(self, config: dict | None = None):
195        super().__init__(config)
196        self._url = (config or {}).get("url", "postgresql://localhost:5432/zelos")
197        self._conn = None
198
199    def connect(self) -> bool:
200        try:
201            import psycopg2
202
203            self._conn = psycopg2.connect(self._url)
204            self._conn.autocommit = True
205            self._create_tables()
206            self._connected = True
207            return True
208        except Exception:
209            self._connected = False
210            return False
211
212    def disconnect(self) -> None:
213        if self._conn:
214            self._conn.close()
215        self._connected = False
216
217    def health(self) -> bool:
218        if not self._conn:
219            return False
220        try:
221            cur = self._conn.cursor()
222            cur.execute("SELECT 1")
223            cur.close()
224            return True
225        except Exception:
226            return False
227
228    def _create_tables(self) -> None:
229        cur = self._conn.cursor()
230        cur.execute("""
231            CREATE TABLE IF NOT EXISTS zelos_events (
232                id SERIAL PRIMARY KEY,
233                stream VARCHAR(255) NOT NULL,
234                position INTEGER NOT NULL,
235                event_data JSONB NOT NULL,
236                created_at TIMESTAMP DEFAULT NOW()
237            );
238            CREATE INDEX IF NOT EXISTS idx_zelos_events_stream_pos
239                ON zelos_events(stream, position);
240            CREATE TABLE IF NOT EXISTS zelos_state (
241                key VARCHAR(255) PRIMARY KEY,
242                value JSONB NOT NULL,
243                updated_at TIMESTAMP DEFAULT NOW()
244            );
245        """)
246        cur.close()
247
248    def append(self, stream: str, events: list[dict]) -> int:
249        if not self._conn:
250            return -1
251        cur = self._conn.cursor()
252        # Get current max position
253        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
254        pos = cur.fetchone()[0]
255        for e in events:
256            pos += 1
257            cur.execute(
258                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
259                (stream, pos, json.dumps(e)),
260            )
261        cur.close()
262        return pos + 1
263
264    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
265        if not self._conn:
266            return []
267        cur = self._conn.cursor()
268        cur.execute(
269            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
270            (stream, from_position, count),
271        )
272        rows = cur.fetchall()
273        cur.close()
274        return [r[0] for r in rows]
275
276    def set_state(self, key: str, value: dict) -> None:
277        if not self._conn:
278            return
279        cur = self._conn.cursor()
280        cur.execute(
281            "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) "
282            "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
283            (key, json.dumps(value)),
284        )
285        cur.close()
286
287    def get_state(self, key: str) -> dict | None:
288        if not self._conn:
289            return None
290        cur = self._conn.cursor()
291        cur.execute("SELECT value FROM zelos_state WHERE key = %s", (key,))
292        row = cur.fetchone()
293        cur.close()
294        return row[0] if row else None
295
296    def delete_state(self, key: str) -> None:
297        if not self._conn:
298            return
299        cur = self._conn.cursor()
300        cur.execute("DELETE FROM zelos_state WHERE key = %s", (key,))
301        cur.close()
302
303
304# ═══════════════════ MySQL ═══════════════════
305
306
307class MySQLStorageBackend(StorageBackend):
308    """MySQL-backed storage. Same schema as PostgreSQL.
309
310    Config:
311      url: mysql://user:pass@localhost:3306/zelos
312    """
313
314    def __init__(self, config: dict | None = None):
315        super().__init__(config)
316        self._url = (config or {}).get("url", "mysql://localhost:3306/zelos")
317        self._conn = None
318
319    def connect(self) -> bool:
320        try:
321            # Parse URL
322            from urllib.parse import urlparse
323
324            import mysql.connector
325
326            parsed = urlparse(self._url)
327            self._conn = mysql.connector.connect(
328                host=parsed.hostname or "localhost",
329                port=parsed.port or 3306,
330                user=parsed.username or "root",
331                password=parsed.password or "",
332                database=parsed.path.lstrip("/") or "zelos",
333                autocommit=True,
334            )
335            self._create_tables()
336            self._connected = True
337            return True
338        except Exception:
339            self._connected = False
340            return False
341
342    def disconnect(self) -> None:
343        if self._conn:
344            self._conn.close()
345        self._connected = False
346
347    def health(self) -> bool:
348        if not self._conn:
349            return False
350        try:
351            cur = self._conn.cursor()
352            cur.execute("SELECT 1")
353            cur.close()
354            return True
355        except Exception:
356            return False
357
358    def _create_tables(self) -> None:
359        cur = self._conn.cursor()
360        cur.execute("""
361            CREATE TABLE IF NOT EXISTS zelos_events (
362                id INT AUTO_INCREMENT PRIMARY KEY,
363                stream VARCHAR(255) NOT NULL,
364                position INT NOT NULL,
365                event_data JSON NOT NULL,
366                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
367                INDEX idx_stream_pos (stream, position)
368            )
369        """)
370        cur.execute("""
371            CREATE TABLE IF NOT EXISTS zelos_state (
372                `key` VARCHAR(255) PRIMARY KEY,
373                value JSON NOT NULL,
374                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
375            )
376        """)
377        cur.close()
378
379    def append(self, stream: str, events: list[dict]) -> int:
380        if not self._conn:
381            return -1
382        cur = self._conn.cursor()
383        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
384        pos = cur.fetchone()[0]
385        for e in events:
386            pos += 1
387            cur.execute(
388                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
389                (stream, pos, json.dumps(e)),
390            )
391        cur.close()
392        return pos + 1
393
394    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
395        if not self._conn:
396            return []
397        cur = self._conn.cursor()
398        cur.execute(
399            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
400            (stream, from_position, count),
401        )
402        rows = cur.fetchall()
403        cur.close()
404        # JSON type returns str in MySQL connector
405        return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows]
406
407    def set_state(self, key: str, value: dict) -> None:
408        if not self._conn:
409            return
410        cur = self._conn.cursor()
411        cur.execute(
412            "INSERT INTO zelos_state (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)",
413            (key, json.dumps(value)),
414        )
415        cur.close()
416
417    def get_state(self, key: str) -> dict | None:
418        if not self._conn:
419            return None
420        cur = self._conn.cursor()
421        cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,))
422        row = cur.fetchone()
423        cur.close()
424        if row:
425            return json.loads(row[0]) if isinstance(row[0], str) else row[0]
426        return None
427
428    def delete_state(self, key: str) -> None:
429        if not self._conn:
430            return
431        cur = self._conn.cursor()
432        cur.execute("DELETE FROM zelos_state WHERE `key` = %s", (key,))
433        cur.close()
434
435
436# ═══════════════════ Factory ═══════════════════
437
438BACKENDS = {
439    "memory": InMemoryStorageBackend,
440    "redis": RedisStorageBackend,
441    "postgresql": PostgreSQLStorageBackend,
442    "postgres": PostgreSQLStorageBackend,
443    "pgsql": PostgreSQLStorageBackend,
444    "mysql": MySQLStorageBackend,
445}
446
447
448def create_storage_backend(config: dict) -> StorageBackend:
449    """Factory: create a storage backend from configuration."""
450    backend_type = config.get("type", "memory").lower()
451    cls = BACKENDS.get(backend_type)
452    if cls is None:
453        raise ValueError(f"Unsupported storage backend: '{backend_type}'. Supported: {', '.join(BACKENDS.keys())}")
454    return cls(config)
class StorageBackend(abc.ABC):
17class StorageBackend(ABC):
18    """Abstract storage backend for events and state."""
19
20    def __init__(self, config: dict | None = None):
21        self.config = config or {}
22        self._connected = False
23
24    @abstractmethod
25    def connect(self) -> bool: ...
26
27    @abstractmethod
28    def disconnect(self) -> None: ...
29
30    @abstractmethod
31    def health(self) -> bool: ...
32
33    @abstractmethod
34    def append(self, stream: str, events: list[dict]) -> int: ...
35
36    @abstractmethod
37    def read(self, stream: str, from_position: int, count: int) -> list[dict]: ...
38
39    @abstractmethod
40    def set_state(self, key: str, value: dict) -> None: ...
41
42    @abstractmethod
43    def get_state(self, key: str) -> dict | None: ...
44
45    @abstractmethod
46    def delete_state(self, key: str) -> None: ...
47
48    def create_snapshot(self, key: str, events_position: int, state: dict) -> None:
49        self.set_state(
50            f"snapshot:{key}", {"events_position": events_position, "state": state, "timestamp": time.time()}
51        )
52
53    def get_snapshot(self, key: str) -> dict | None:
54        return self.get_state(f"snapshot:{key}")
55
56    @property
57    def is_connected(self) -> bool:
58        return self._connected

Abstract storage backend for events and state.

config
@abstractmethod
def connect(self) -> bool:
24    @abstractmethod
25    def connect(self) -> bool: ...
@abstractmethod
def disconnect(self) -> None:
27    @abstractmethod
28    def disconnect(self) -> None: ...
@abstractmethod
def health(self) -> bool:
30    @abstractmethod
31    def health(self) -> bool: ...
@abstractmethod
def append(self, stream: str, events: list[dict]) -> int:
33    @abstractmethod
34    def append(self, stream: str, events: list[dict]) -> int: ...
@abstractmethod
def read(self, stream: str, from_position: int, count: int) -> list[dict]:
36    @abstractmethod
37    def read(self, stream: str, from_position: int, count: int) -> list[dict]: ...
@abstractmethod
def set_state(self, key: str, value: dict) -> None:
39    @abstractmethod
40    def set_state(self, key: str, value: dict) -> None: ...
@abstractmethod
def get_state(self, key: str) -> dict | None:
42    @abstractmethod
43    def get_state(self, key: str) -> dict | None: ...
@abstractmethod
def delete_state(self, key: str) -> None:
45    @abstractmethod
46    def delete_state(self, key: str) -> None: ...
def create_snapshot(self, key: str, events_position: int, state: dict) -> None:
48    def create_snapshot(self, key: str, events_position: int, state: dict) -> None:
49        self.set_state(
50            f"snapshot:{key}", {"events_position": events_position, "state": state, "timestamp": time.time()}
51        )
def get_snapshot(self, key: str) -> dict | None:
53    def get_snapshot(self, key: str) -> dict | None:
54        return self.get_state(f"snapshot:{key}")
is_connected: bool
56    @property
57    def is_connected(self) -> bool:
58        return self._connected
class InMemoryStorageBackend(StorageBackend):
 64class InMemoryStorageBackend(StorageBackend):
 65    """Phase 1 compatible — stores everything in memory."""
 66
 67    def __init__(self, config: dict | None = None):
 68        super().__init__(config)
 69        self._streams: dict[str, list[dict]] = {}
 70        self._state: dict[str, dict] = {}
 71        self._lock = threading.Lock()
 72
 73    def connect(self) -> bool:
 74        self._connected = True
 75        return True
 76
 77    def disconnect(self) -> None:
 78        self._connected = False
 79
 80    def health(self) -> bool:
 81        return self._connected
 82
 83    def append(self, stream: str, events: list[dict]) -> int:
 84        with self._lock:
 85            if stream not in self._streams:
 86                self._streams[stream] = []
 87            self._streams[stream].extend(events)
 88            return len(self._streams[stream])
 89
 90    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
 91        with self._lock:
 92            events = self._streams.get(stream, [])
 93            return events[from_position : from_position + count]
 94
 95    def set_state(self, key: str, value: dict) -> None:
 96        with self._lock:
 97            self._state[key] = value
 98
 99    def get_state(self, key: str) -> dict | None:
100        return self._state.get(key)
101
102    def delete_state(self, key: str) -> None:
103        self._state.pop(key, None)

Phase 1 compatible — stores everything in memory.

InMemoryStorageBackend(config: dict | None = None)
67    def __init__(self, config: dict | None = None):
68        super().__init__(config)
69        self._streams: dict[str, list[dict]] = {}
70        self._state: dict[str, dict] = {}
71        self._lock = threading.Lock()
def connect(self) -> bool:
73    def connect(self) -> bool:
74        self._connected = True
75        return True
def disconnect(self) -> None:
77    def disconnect(self) -> None:
78        self._connected = False
def health(self) -> bool:
80    def health(self) -> bool:
81        return self._connected
def append(self, stream: str, events: list[dict]) -> int:
83    def append(self, stream: str, events: list[dict]) -> int:
84        with self._lock:
85            if stream not in self._streams:
86                self._streams[stream] = []
87            self._streams[stream].extend(events)
88            return len(self._streams[stream])
def read(self, stream: str, from_position: int, count: int) -> list[dict]:
90    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
91        with self._lock:
92            events = self._streams.get(stream, [])
93            return events[from_position : from_position + count]
def set_state(self, key: str, value: dict) -> None:
95    def set_state(self, key: str, value: dict) -> None:
96        with self._lock:
97            self._state[key] = value
def get_state(self, key: str) -> dict | None:
 99    def get_state(self, key: str) -> dict | None:
100        return self._state.get(key)
def delete_state(self, key: str) -> None:
102    def delete_state(self, key: str) -> None:
103        self._state.pop(key, None)
class RedisStorageBackend(StorageBackend):
109class RedisStorageBackend(StorageBackend):
110    """Redis-backed storage. Events stored as lists, state as hash.
111
112    Config:
113      url: redis://localhost:6379/0
114      prefix: "zelos" (key namespace)
115    """
116
117    def __init__(self, config: dict | None = None):
118        super().__init__(config)
119        self._url = (config or {}).get("url", "redis://localhost:6379/0")
120        self._prefix = (config or {}).get("prefix", "zelos")
121        self._client = None
122
123    def connect(self) -> bool:
124        try:
125            import redis
126
127            self._client = redis.Redis.from_url(self._url, decode_responses=True)
128            self._client.ping()
129            self._connected = True
130            return True
131        except Exception:
132            self._connected = False
133            return False
134
135    def disconnect(self) -> None:
136        if self._client:
137            self._client.close()
138        self._connected = False
139
140    def health(self) -> bool:
141        if not self._client:
142            return False
143        try:
144            self._client.ping()
145            return True
146        except Exception:
147            return False
148
149    def _stream_key(self, stream: str) -> str:
150        return f"{self._prefix}:stream:{stream}"
151
152    def _state_key(self, key: str) -> str:
153        return f"{self._prefix}:state:{key}"
154
155    def append(self, stream: str, events: list[dict]) -> int:
156        if not self._client:
157            return -1
158        pipe = self._client.pipeline()
159        for e in events:
160            pipe.rpush(self._stream_key(stream), json.dumps(e))
161        pipe.execute()
162        return self._client.llen(self._stream_key(stream))
163
164    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
165        if not self._client:
166            return []
167        raw = self._client.lrange(self._stream_key(stream), from_position, from_position + count - 1)
168        return [json.loads(r) for r in raw]
169
170    def set_state(self, key: str, value: dict) -> None:
171        if self._client:
172            self._client.set(self._state_key(key), json.dumps(value))
173
174    def get_state(self, key: str) -> dict | None:
175        if not self._client:
176            return None
177        raw = self._client.get(self._state_key(key))
178        return json.loads(raw) if raw else None
179
180    def delete_state(self, key: str) -> None:
181        if self._client:
182            self._client.delete(self._state_key(key))

Redis-backed storage. Events stored as lists, state as hash.

Config:

url: redis://localhost:6379/0 prefix: "zelos" (key namespace)

RedisStorageBackend(config: dict | None = None)
117    def __init__(self, config: dict | None = None):
118        super().__init__(config)
119        self._url = (config or {}).get("url", "redis://localhost:6379/0")
120        self._prefix = (config or {}).get("prefix", "zelos")
121        self._client = None
def connect(self) -> bool:
123    def connect(self) -> bool:
124        try:
125            import redis
126
127            self._client = redis.Redis.from_url(self._url, decode_responses=True)
128            self._client.ping()
129            self._connected = True
130            return True
131        except Exception:
132            self._connected = False
133            return False
def disconnect(self) -> None:
135    def disconnect(self) -> None:
136        if self._client:
137            self._client.close()
138        self._connected = False
def health(self) -> bool:
140    def health(self) -> bool:
141        if not self._client:
142            return False
143        try:
144            self._client.ping()
145            return True
146        except Exception:
147            return False
def append(self, stream: str, events: list[dict]) -> int:
155    def append(self, stream: str, events: list[dict]) -> int:
156        if not self._client:
157            return -1
158        pipe = self._client.pipeline()
159        for e in events:
160            pipe.rpush(self._stream_key(stream), json.dumps(e))
161        pipe.execute()
162        return self._client.llen(self._stream_key(stream))
def read(self, stream: str, from_position: int, count: int) -> list[dict]:
164    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
165        if not self._client:
166            return []
167        raw = self._client.lrange(self._stream_key(stream), from_position, from_position + count - 1)
168        return [json.loads(r) for r in raw]
def set_state(self, key: str, value: dict) -> None:
170    def set_state(self, key: str, value: dict) -> None:
171        if self._client:
172            self._client.set(self._state_key(key), json.dumps(value))
def get_state(self, key: str) -> dict | None:
174    def get_state(self, key: str) -> dict | None:
175        if not self._client:
176            return None
177        raw = self._client.get(self._state_key(key))
178        return json.loads(raw) if raw else None
def delete_state(self, key: str) -> None:
180    def delete_state(self, key: str) -> None:
181        if self._client:
182            self._client.delete(self._state_key(key))
class PostgreSQLStorageBackend(StorageBackend):
188class PostgreSQLStorageBackend(StorageBackend):
189    """PostgreSQL-backed storage. Events table + state table.
190
191    Config:
192      url: postgresql://user:pass@localhost:5432/zelos
193    """
194
195    def __init__(self, config: dict | None = None):
196        super().__init__(config)
197        self._url = (config or {}).get("url", "postgresql://localhost:5432/zelos")
198        self._conn = None
199
200    def connect(self) -> bool:
201        try:
202            import psycopg2
203
204            self._conn = psycopg2.connect(self._url)
205            self._conn.autocommit = True
206            self._create_tables()
207            self._connected = True
208            return True
209        except Exception:
210            self._connected = False
211            return False
212
213    def disconnect(self) -> None:
214        if self._conn:
215            self._conn.close()
216        self._connected = False
217
218    def health(self) -> bool:
219        if not self._conn:
220            return False
221        try:
222            cur = self._conn.cursor()
223            cur.execute("SELECT 1")
224            cur.close()
225            return True
226        except Exception:
227            return False
228
229    def _create_tables(self) -> None:
230        cur = self._conn.cursor()
231        cur.execute("""
232            CREATE TABLE IF NOT EXISTS zelos_events (
233                id SERIAL PRIMARY KEY,
234                stream VARCHAR(255) NOT NULL,
235                position INTEGER NOT NULL,
236                event_data JSONB NOT NULL,
237                created_at TIMESTAMP DEFAULT NOW()
238            );
239            CREATE INDEX IF NOT EXISTS idx_zelos_events_stream_pos
240                ON zelos_events(stream, position);
241            CREATE TABLE IF NOT EXISTS zelos_state (
242                key VARCHAR(255) PRIMARY KEY,
243                value JSONB NOT NULL,
244                updated_at TIMESTAMP DEFAULT NOW()
245            );
246        """)
247        cur.close()
248
249    def append(self, stream: str, events: list[dict]) -> int:
250        if not self._conn:
251            return -1
252        cur = self._conn.cursor()
253        # Get current max position
254        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
255        pos = cur.fetchone()[0]
256        for e in events:
257            pos += 1
258            cur.execute(
259                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
260                (stream, pos, json.dumps(e)),
261            )
262        cur.close()
263        return pos + 1
264
265    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
266        if not self._conn:
267            return []
268        cur = self._conn.cursor()
269        cur.execute(
270            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
271            (stream, from_position, count),
272        )
273        rows = cur.fetchall()
274        cur.close()
275        return [r[0] for r in rows]
276
277    def set_state(self, key: str, value: dict) -> None:
278        if not self._conn:
279            return
280        cur = self._conn.cursor()
281        cur.execute(
282            "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) "
283            "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
284            (key, json.dumps(value)),
285        )
286        cur.close()
287
288    def get_state(self, key: str) -> dict | None:
289        if not self._conn:
290            return None
291        cur = self._conn.cursor()
292        cur.execute("SELECT value FROM zelos_state WHERE key = %s", (key,))
293        row = cur.fetchone()
294        cur.close()
295        return row[0] if row else None
296
297    def delete_state(self, key: str) -> None:
298        if not self._conn:
299            return
300        cur = self._conn.cursor()
301        cur.execute("DELETE FROM zelos_state WHERE key = %s", (key,))
302        cur.close()

PostgreSQL-backed storage. Events table + state table.

Config:

url: postgresql://user:pass@localhost:5432/zelos

PostgreSQLStorageBackend(config: dict | None = None)
195    def __init__(self, config: dict | None = None):
196        super().__init__(config)
197        self._url = (config or {}).get("url", "postgresql://localhost:5432/zelos")
198        self._conn = None
def connect(self) -> bool:
200    def connect(self) -> bool:
201        try:
202            import psycopg2
203
204            self._conn = psycopg2.connect(self._url)
205            self._conn.autocommit = True
206            self._create_tables()
207            self._connected = True
208            return True
209        except Exception:
210            self._connected = False
211            return False
def disconnect(self) -> None:
213    def disconnect(self) -> None:
214        if self._conn:
215            self._conn.close()
216        self._connected = False
def health(self) -> bool:
218    def health(self) -> bool:
219        if not self._conn:
220            return False
221        try:
222            cur = self._conn.cursor()
223            cur.execute("SELECT 1")
224            cur.close()
225            return True
226        except Exception:
227            return False
def append(self, stream: str, events: list[dict]) -> int:
249    def append(self, stream: str, events: list[dict]) -> int:
250        if not self._conn:
251            return -1
252        cur = self._conn.cursor()
253        # Get current max position
254        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
255        pos = cur.fetchone()[0]
256        for e in events:
257            pos += 1
258            cur.execute(
259                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
260                (stream, pos, json.dumps(e)),
261            )
262        cur.close()
263        return pos + 1
def read(self, stream: str, from_position: int, count: int) -> list[dict]:
265    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
266        if not self._conn:
267            return []
268        cur = self._conn.cursor()
269        cur.execute(
270            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
271            (stream, from_position, count),
272        )
273        rows = cur.fetchall()
274        cur.close()
275        return [r[0] for r in rows]
def set_state(self, key: str, value: dict) -> None:
277    def set_state(self, key: str, value: dict) -> None:
278        if not self._conn:
279            return
280        cur = self._conn.cursor()
281        cur.execute(
282            "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) "
283            "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
284            (key, json.dumps(value)),
285        )
286        cur.close()
def get_state(self, key: str) -> dict | None:
288    def get_state(self, key: str) -> dict | None:
289        if not self._conn:
290            return None
291        cur = self._conn.cursor()
292        cur.execute("SELECT value FROM zelos_state WHERE key = %s", (key,))
293        row = cur.fetchone()
294        cur.close()
295        return row[0] if row else None
def delete_state(self, key: str) -> None:
297    def delete_state(self, key: str) -> None:
298        if not self._conn:
299            return
300        cur = self._conn.cursor()
301        cur.execute("DELETE FROM zelos_state WHERE key = %s", (key,))
302        cur.close()
class MySQLStorageBackend(StorageBackend):
308class MySQLStorageBackend(StorageBackend):
309    """MySQL-backed storage. Same schema as PostgreSQL.
310
311    Config:
312      url: mysql://user:pass@localhost:3306/zelos
313    """
314
315    def __init__(self, config: dict | None = None):
316        super().__init__(config)
317        self._url = (config or {}).get("url", "mysql://localhost:3306/zelos")
318        self._conn = None
319
320    def connect(self) -> bool:
321        try:
322            # Parse URL
323            from urllib.parse import urlparse
324
325            import mysql.connector
326
327            parsed = urlparse(self._url)
328            self._conn = mysql.connector.connect(
329                host=parsed.hostname or "localhost",
330                port=parsed.port or 3306,
331                user=parsed.username or "root",
332                password=parsed.password or "",
333                database=parsed.path.lstrip("/") or "zelos",
334                autocommit=True,
335            )
336            self._create_tables()
337            self._connected = True
338            return True
339        except Exception:
340            self._connected = False
341            return False
342
343    def disconnect(self) -> None:
344        if self._conn:
345            self._conn.close()
346        self._connected = False
347
348    def health(self) -> bool:
349        if not self._conn:
350            return False
351        try:
352            cur = self._conn.cursor()
353            cur.execute("SELECT 1")
354            cur.close()
355            return True
356        except Exception:
357            return False
358
359    def _create_tables(self) -> None:
360        cur = self._conn.cursor()
361        cur.execute("""
362            CREATE TABLE IF NOT EXISTS zelos_events (
363                id INT AUTO_INCREMENT PRIMARY KEY,
364                stream VARCHAR(255) NOT NULL,
365                position INT NOT NULL,
366                event_data JSON NOT NULL,
367                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
368                INDEX idx_stream_pos (stream, position)
369            )
370        """)
371        cur.execute("""
372            CREATE TABLE IF NOT EXISTS zelos_state (
373                `key` VARCHAR(255) PRIMARY KEY,
374                value JSON NOT NULL,
375                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
376            )
377        """)
378        cur.close()
379
380    def append(self, stream: str, events: list[dict]) -> int:
381        if not self._conn:
382            return -1
383        cur = self._conn.cursor()
384        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
385        pos = cur.fetchone()[0]
386        for e in events:
387            pos += 1
388            cur.execute(
389                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
390                (stream, pos, json.dumps(e)),
391            )
392        cur.close()
393        return pos + 1
394
395    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
396        if not self._conn:
397            return []
398        cur = self._conn.cursor()
399        cur.execute(
400            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
401            (stream, from_position, count),
402        )
403        rows = cur.fetchall()
404        cur.close()
405        # JSON type returns str in MySQL connector
406        return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows]
407
408    def set_state(self, key: str, value: dict) -> None:
409        if not self._conn:
410            return
411        cur = self._conn.cursor()
412        cur.execute(
413            "INSERT INTO zelos_state (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)",
414            (key, json.dumps(value)),
415        )
416        cur.close()
417
418    def get_state(self, key: str) -> dict | None:
419        if not self._conn:
420            return None
421        cur = self._conn.cursor()
422        cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,))
423        row = cur.fetchone()
424        cur.close()
425        if row:
426            return json.loads(row[0]) if isinstance(row[0], str) else row[0]
427        return None
428
429    def delete_state(self, key: str) -> None:
430        if not self._conn:
431            return
432        cur = self._conn.cursor()
433        cur.execute("DELETE FROM zelos_state WHERE `key` = %s", (key,))
434        cur.close()

MySQL-backed storage. Same schema as PostgreSQL.

Config:

url: mysql://user:pass@localhost:3306/zelos

MySQLStorageBackend(config: dict | None = None)
315    def __init__(self, config: dict | None = None):
316        super().__init__(config)
317        self._url = (config or {}).get("url", "mysql://localhost:3306/zelos")
318        self._conn = None
def connect(self) -> bool:
320    def connect(self) -> bool:
321        try:
322            # Parse URL
323            from urllib.parse import urlparse
324
325            import mysql.connector
326
327            parsed = urlparse(self._url)
328            self._conn = mysql.connector.connect(
329                host=parsed.hostname or "localhost",
330                port=parsed.port or 3306,
331                user=parsed.username or "root",
332                password=parsed.password or "",
333                database=parsed.path.lstrip("/") or "zelos",
334                autocommit=True,
335            )
336            self._create_tables()
337            self._connected = True
338            return True
339        except Exception:
340            self._connected = False
341            return False
def disconnect(self) -> None:
343    def disconnect(self) -> None:
344        if self._conn:
345            self._conn.close()
346        self._connected = False
def health(self) -> bool:
348    def health(self) -> bool:
349        if not self._conn:
350            return False
351        try:
352            cur = self._conn.cursor()
353            cur.execute("SELECT 1")
354            cur.close()
355            return True
356        except Exception:
357            return False
def append(self, stream: str, events: list[dict]) -> int:
380    def append(self, stream: str, events: list[dict]) -> int:
381        if not self._conn:
382            return -1
383        cur = self._conn.cursor()
384        cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,))
385        pos = cur.fetchone()[0]
386        for e in events:
387            pos += 1
388            cur.execute(
389                "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)",
390                (stream, pos, json.dumps(e)),
391            )
392        cur.close()
393        return pos + 1
def read(self, stream: str, from_position: int, count: int) -> list[dict]:
395    def read(self, stream: str, from_position: int, count: int) -> list[dict]:
396        if not self._conn:
397            return []
398        cur = self._conn.cursor()
399        cur.execute(
400            "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s",
401            (stream, from_position, count),
402        )
403        rows = cur.fetchall()
404        cur.close()
405        # JSON type returns str in MySQL connector
406        return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows]
def set_state(self, key: str, value: dict) -> None:
408    def set_state(self, key: str, value: dict) -> None:
409        if not self._conn:
410            return
411        cur = self._conn.cursor()
412        cur.execute(
413            "INSERT INTO zelos_state (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)",
414            (key, json.dumps(value)),
415        )
416        cur.close()
def get_state(self, key: str) -> dict | None:
418    def get_state(self, key: str) -> dict | None:
419        if not self._conn:
420            return None
421        cur = self._conn.cursor()
422        cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,))
423        row = cur.fetchone()
424        cur.close()
425        if row:
426            return json.loads(row[0]) if isinstance(row[0], str) else row[0]
427        return None
def delete_state(self, key: str) -> None:
429    def delete_state(self, key: str) -> None:
430        if not self._conn:
431            return
432        cur = self._conn.cursor()
433        cur.execute("DELETE FROM zelos_state WHERE `key` = %s", (key,))
434        cur.close()
BACKENDS = {'memory': <class 'InMemoryStorageBackend'>, 'redis': <class 'RedisStorageBackend'>, 'postgresql': <class 'PostgreSQLStorageBackend'>, 'postgres': <class 'PostgreSQLStorageBackend'>, 'pgsql': <class 'PostgreSQLStorageBackend'>, 'mysql': <class 'MySQLStorageBackend'>}
def create_storage_backend(config: dict) -> StorageBackend:
449def create_storage_backend(config: dict) -> StorageBackend:
450    """Factory: create a storage backend from configuration."""
451    backend_type = config.get("type", "memory").lower()
452    cls = BACKENDS.get(backend_type)
453    if cls is None:
454        raise ValueError(f"Unsupported storage backend: '{backend_type}'. Supported: {', '.join(BACKENDS.keys())}")
455    return cls(config)

Factory: create a storage backend from configuration.