functualize-state-sqlite 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- functualize_state_sqlite/__init__.py +13 -0
- functualize_state_sqlite/_backend.py +160 -0
- functualize_state_sqlite/_execution_store.py +379 -0
- functualize_state_sqlite/_migrations.py +157 -0
- functualize_state_sqlite/_plugin.py +205 -0
- functualize_state_sqlite/plugin.py +345 -0
- functualize_state_sqlite/py.typed +0 -0
- functualize_state_sqlite/sqlite_backend.py +721 -0
- functualize_state_sqlite/state_store.py +178 -0
- functualize_state_sqlite/tracker.py +318 -0
- functualize_state_sqlite-0.1.0.dist-info/METADATA +88 -0
- functualize_state_sqlite-0.1.0.dist-info/RECORD +14 -0
- functualize_state_sqlite-0.1.0.dist-info/WHEEL +4 -0
- functualize_state_sqlite-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Functualize State SQLite Plugin - SQLite-backed state persistence and execution tracking."""
|
|
2
|
+
|
|
3
|
+
from functualize_state_sqlite._backend import SQLiteStateBackend
|
|
4
|
+
from functualize_state_sqlite._execution_store import SQLiteExecutionStore
|
|
5
|
+
from functualize_state_sqlite._plugin import SQLiteStatePlugin
|
|
6
|
+
from functualize_state_sqlite.plugin import ExecutionStatePlugin
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ExecutionStatePlugin",
|
|
10
|
+
"SQLiteExecutionStore",
|
|
11
|
+
"SQLiteStateBackend",
|
|
12
|
+
"SQLiteStatePlugin",
|
|
13
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SQLite StateBackend implementation.
|
|
2
|
+
|
|
3
|
+
Implements the StateBackend protocol from functualize-state using SQLite
|
|
4
|
+
in WAL mode for concurrent access. Values are JSON-encoded for storage.
|
|
5
|
+
Uses only stdlib sqlite3 (zero external dependencies).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = ["SQLiteStateBackend"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_STATE_SCHEMA_SQL = """\
|
|
22
|
+
CREATE TABLE IF NOT EXISTS kv_state (
|
|
23
|
+
key TEXT PRIMARY KEY,
|
|
24
|
+
value TEXT NOT NULL,
|
|
25
|
+
updated_at REAL NOT NULL
|
|
26
|
+
);
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SQLiteStateBackend:
|
|
31
|
+
"""StateBackend protocol implementation backed by SQLite.
|
|
32
|
+
|
|
33
|
+
Provides persistent key-value state storage with JSON-encoded values.
|
|
34
|
+
Uses WAL mode for concurrent read access without blocking writes.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
db_path: Path to the SQLite database file. If None, defaults to
|
|
38
|
+
`.functualize/state.db` relative to the current working directory.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
42
|
+
if db_path is None:
|
|
43
|
+
self._db_path = Path.cwd() / ".functualize" / "state.db"
|
|
44
|
+
else:
|
|
45
|
+
self._db_path = Path(db_path)
|
|
46
|
+
|
|
47
|
+
self._conn: sqlite3.Connection | None = None
|
|
48
|
+
self._initialized = False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def db_path(self) -> Path:
|
|
52
|
+
"""The resolved path to the database file."""
|
|
53
|
+
return self._db_path
|
|
54
|
+
|
|
55
|
+
def _ensure_initialized(self) -> sqlite3.Connection:
|
|
56
|
+
"""Ensure the database is initialized and return the connection."""
|
|
57
|
+
if self._conn is not None and self._initialized:
|
|
58
|
+
return self._conn
|
|
59
|
+
|
|
60
|
+
# Ensure the directory exists
|
|
61
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# Open connection
|
|
64
|
+
self._conn = sqlite3.connect(
|
|
65
|
+
str(self._db_path),
|
|
66
|
+
timeout=10.0,
|
|
67
|
+
check_same_thread=False,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Enable WAL mode for concurrent access
|
|
71
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
72
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
73
|
+
self._conn.commit()
|
|
74
|
+
|
|
75
|
+
# Create schema
|
|
76
|
+
self._conn.executescript(_STATE_SCHEMA_SQL)
|
|
77
|
+
self._conn.commit()
|
|
78
|
+
|
|
79
|
+
self._initialized = True
|
|
80
|
+
logger.debug("SQLiteStateBackend initialized at %s", self._db_path)
|
|
81
|
+
return self._conn
|
|
82
|
+
|
|
83
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
84
|
+
"""Get a value by key, returning default if not found.
|
|
85
|
+
|
|
86
|
+
The stored JSON value is deserialized back to a Python object.
|
|
87
|
+
"""
|
|
88
|
+
conn = self._ensure_initialized()
|
|
89
|
+
cursor = conn.execute(
|
|
90
|
+
"SELECT value FROM kv_state WHERE key = ?",
|
|
91
|
+
(key,),
|
|
92
|
+
)
|
|
93
|
+
row = cursor.fetchone()
|
|
94
|
+
if row is None:
|
|
95
|
+
return default
|
|
96
|
+
return json.loads(row[0])
|
|
97
|
+
|
|
98
|
+
def set(self, key: str, value: Any) -> None:
|
|
99
|
+
"""Set a value for a key.
|
|
100
|
+
|
|
101
|
+
The value is JSON-encoded before storage.
|
|
102
|
+
"""
|
|
103
|
+
conn = self._ensure_initialized()
|
|
104
|
+
value_json = json.dumps(value)
|
|
105
|
+
now = time.time()
|
|
106
|
+
conn.execute(
|
|
107
|
+
"INSERT OR REPLACE INTO kv_state (key, value, updated_at) VALUES (?, ?, ?)",
|
|
108
|
+
(key, value_json, now),
|
|
109
|
+
)
|
|
110
|
+
conn.commit()
|
|
111
|
+
|
|
112
|
+
def delete(self, key: str) -> None:
|
|
113
|
+
"""Delete a key from the state backend.
|
|
114
|
+
|
|
115
|
+
No-op if the key does not exist.
|
|
116
|
+
"""
|
|
117
|
+
conn = self._ensure_initialized()
|
|
118
|
+
conn.execute("DELETE FROM kv_state WHERE key = ?", (key,))
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
def keys(self, prefix: str = "") -> list[str]:
|
|
122
|
+
"""Return all keys, optionally filtered by prefix."""
|
|
123
|
+
conn = self._ensure_initialized()
|
|
124
|
+
if prefix:
|
|
125
|
+
cursor = conn.execute(
|
|
126
|
+
"SELECT key FROM kv_state WHERE key LIKE ? ESCAPE '\\'",
|
|
127
|
+
(self._escape_like(prefix) + "%",),
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
cursor = conn.execute("SELECT key FROM kv_state")
|
|
131
|
+
return [row[0] for row in cursor.fetchall()]
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the database connection."""
|
|
135
|
+
if self._conn is not None:
|
|
136
|
+
try:
|
|
137
|
+
self._conn.close()
|
|
138
|
+
except sqlite3.Error as e:
|
|
139
|
+
logger.warning("Error closing SQLiteStateBackend connection: %s", e)
|
|
140
|
+
finally:
|
|
141
|
+
self._conn = None
|
|
142
|
+
self._initialized = False
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _escape_like(value: str) -> str:
|
|
146
|
+
"""Escape special characters in a LIKE pattern."""
|
|
147
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
148
|
+
|
|
149
|
+
def __enter__(self) -> SQLiteStateBackend:
|
|
150
|
+
"""Context manager entry — initialize if needed."""
|
|
151
|
+
self._ensure_initialized()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, *exc: Any) -> None:
|
|
155
|
+
"""Context manager exit — close connection."""
|
|
156
|
+
self.close()
|
|
157
|
+
|
|
158
|
+
def __del__(self) -> None:
|
|
159
|
+
"""Ensure connection is closed on garbage collection."""
|
|
160
|
+
self.close()
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""SQLite implementation of the ExecutionStore protocol.
|
|
2
|
+
|
|
3
|
+
Provides persistent execution record and phase tracking backed by SQLite,
|
|
4
|
+
conforming to the ExecutionStore protocol defined in functualize-state SDK.
|
|
5
|
+
|
|
6
|
+
Shares the database file with the SQLiteStateBackend to keep all persistent
|
|
7
|
+
state in a single database. Uses only stdlib sqlite3 (zero external deps).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sqlite3
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from functualize_state._types import ExecutionRecord, PhaseRecord
|
|
19
|
+
|
|
20
|
+
__all__ = ["SQLiteExecutionStore"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# SQL to create the ExecutionStore-specific tables following the design schema.
|
|
25
|
+
_EXECUTION_STORE_SCHEMA = """\
|
|
26
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
27
|
+
session_id TEXT PRIMARY KEY,
|
|
28
|
+
started_at REAL NOT NULL,
|
|
29
|
+
metadata TEXT
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS executions (
|
|
33
|
+
execution_id TEXT PRIMARY KEY,
|
|
34
|
+
job_name TEXT NOT NULL,
|
|
35
|
+
session_id TEXT NOT NULL,
|
|
36
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
37
|
+
started_at REAL NOT NULL,
|
|
38
|
+
ended_at REAL,
|
|
39
|
+
duration_ms REAL,
|
|
40
|
+
kwargs TEXT,
|
|
41
|
+
result TEXT,
|
|
42
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS phases (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
execution_id TEXT NOT NULL,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
status TEXT NOT NULL,
|
|
50
|
+
started_at REAL NOT NULL,
|
|
51
|
+
ended_at REAL,
|
|
52
|
+
duration_ms REAL,
|
|
53
|
+
FOREIGN KEY (execution_id) REFERENCES executions(execution_id)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_executions_session_id
|
|
57
|
+
ON executions(session_id);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_phases_execution_id
|
|
60
|
+
ON phases(execution_id);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SQLiteExecutionStore:
|
|
65
|
+
"""SQLite-backed implementation of the ExecutionStore protocol.
|
|
66
|
+
|
|
67
|
+
Stores execution records, phases, and sessions using the SQL schema
|
|
68
|
+
defined in the design document. Uses JSON encoding for complex types
|
|
69
|
+
(kwargs dict, result value).
|
|
70
|
+
|
|
71
|
+
Shares the same database file as the SQLiteStateBackend to keep all
|
|
72
|
+
persistent state collocated. Connection management is handled internally
|
|
73
|
+
using the same WAL mode configuration.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
db_path: Path to the SQLite database file. If None, defaults to
|
|
77
|
+
`.functualize/state.db` relative to the current working directory.
|
|
78
|
+
Should match the path used by SQLiteStateBackend.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
82
|
+
if db_path is None:
|
|
83
|
+
self._db_path = Path.cwd() / ".functualize" / "state.db"
|
|
84
|
+
else:
|
|
85
|
+
self._db_path = Path(db_path)
|
|
86
|
+
|
|
87
|
+
self._conn: sqlite3.Connection | None = None
|
|
88
|
+
self._initialized = False
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def db_path(self) -> Path:
|
|
92
|
+
"""The resolved path to the database file."""
|
|
93
|
+
return self._db_path
|
|
94
|
+
|
|
95
|
+
def _ensure_initialized(self) -> sqlite3.Connection:
|
|
96
|
+
"""Ensure the database is initialized and return the connection.
|
|
97
|
+
|
|
98
|
+
Creates the database directory, opens a connection with WAL mode,
|
|
99
|
+
and initializes the execution store schema tables.
|
|
100
|
+
"""
|
|
101
|
+
if self._conn is not None and self._initialized:
|
|
102
|
+
return self._conn
|
|
103
|
+
|
|
104
|
+
# Ensure the directory exists
|
|
105
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
# Open connection with same settings as SQLiteStateBackend
|
|
108
|
+
self._conn = sqlite3.connect(
|
|
109
|
+
str(self._db_path),
|
|
110
|
+
timeout=10.0,
|
|
111
|
+
check_same_thread=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Enable WAL mode for concurrent access
|
|
115
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
116
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
117
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
118
|
+
self._conn.commit()
|
|
119
|
+
|
|
120
|
+
# Create execution store schema
|
|
121
|
+
self._conn.executescript(_EXECUTION_STORE_SCHEMA)
|
|
122
|
+
self._conn.commit()
|
|
123
|
+
|
|
124
|
+
self._initialized = True
|
|
125
|
+
logger.debug("SQLiteExecutionStore initialized at %s", self._db_path)
|
|
126
|
+
return self._conn
|
|
127
|
+
|
|
128
|
+
# ─── ExecutionStore Protocol Methods ──────────────────────────────
|
|
129
|
+
|
|
130
|
+
def insert_execution(self, record: ExecutionRecord) -> str:
|
|
131
|
+
"""Insert an execution record, returning the execution ID.
|
|
132
|
+
|
|
133
|
+
Ensures the session exists before inserting the execution. If the
|
|
134
|
+
session doesn't exist yet, it is created automatically.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
record: The ExecutionRecord to persist.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The execution_id from the record.
|
|
141
|
+
"""
|
|
142
|
+
conn = self._ensure_initialized()
|
|
143
|
+
|
|
144
|
+
# Ensure the session exists (upsert — don't overwrite existing)
|
|
145
|
+
conn.execute(
|
|
146
|
+
"INSERT OR IGNORE INTO sessions (session_id, started_at) VALUES (?, ?)",
|
|
147
|
+
(record.session_id, record.started_at),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Serialize complex fields
|
|
151
|
+
kwargs_json = json.dumps(record.kwargs) if record.kwargs else None
|
|
152
|
+
result_json = (
|
|
153
|
+
_safe_json_encode(record.result) if record.result is not None else None
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
conn.execute(
|
|
157
|
+
"""INSERT INTO executions
|
|
158
|
+
(execution_id, job_name, session_id, status, started_at,
|
|
159
|
+
ended_at, duration_ms, kwargs, result)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
161
|
+
(
|
|
162
|
+
record.execution_id,
|
|
163
|
+
record.job_name,
|
|
164
|
+
record.session_id,
|
|
165
|
+
record.status,
|
|
166
|
+
record.started_at,
|
|
167
|
+
record.ended_at,
|
|
168
|
+
record.duration_ms,
|
|
169
|
+
kwargs_json,
|
|
170
|
+
result_json,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
conn.commit()
|
|
174
|
+
return record.execution_id
|
|
175
|
+
|
|
176
|
+
def update_execution(self, execution_id: str, **updates: Any) -> None:
|
|
177
|
+
"""Update fields on an existing execution record.
|
|
178
|
+
|
|
179
|
+
Supports updating: status, ended_at, duration_ms, kwargs, result.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
execution_id: The execution to update.
|
|
183
|
+
**updates: Field names and their new values.
|
|
184
|
+
"""
|
|
185
|
+
if not updates:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
conn = self._ensure_initialized()
|
|
189
|
+
set_clauses: list[str] = []
|
|
190
|
+
params: list[Any] = []
|
|
191
|
+
|
|
192
|
+
for field_name, value in updates.items():
|
|
193
|
+
if field_name == "kwargs":
|
|
194
|
+
set_clauses.append("kwargs = ?")
|
|
195
|
+
params.append(json.dumps(value) if value is not None else None)
|
|
196
|
+
elif field_name == "result":
|
|
197
|
+
set_clauses.append("result = ?")
|
|
198
|
+
params.append(_safe_json_encode(value) if value is not None else None)
|
|
199
|
+
elif field_name in ("status", "ended_at", "duration_ms"):
|
|
200
|
+
set_clauses.append(f"{field_name} = ?")
|
|
201
|
+
params.append(value)
|
|
202
|
+
else:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"Ignoring unknown update field '%s' for execution %s",
|
|
205
|
+
field_name,
|
|
206
|
+
execution_id,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if not set_clauses:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
params.append(execution_id)
|
|
213
|
+
sql = f"UPDATE executions SET {', '.join(set_clauses)} WHERE execution_id = ?"
|
|
214
|
+
|
|
215
|
+
conn.execute(sql, tuple(params))
|
|
216
|
+
conn.commit()
|
|
217
|
+
|
|
218
|
+
def get_session_executions(
|
|
219
|
+
self, session_id: str, limit: int = 50
|
|
220
|
+
) -> list[ExecutionRecord]:
|
|
221
|
+
"""Get execution records for a session, ordered by start time descending.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
session_id: The session to query.
|
|
225
|
+
limit: Maximum number of results (default 50).
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
List of ExecutionRecord instances.
|
|
229
|
+
"""
|
|
230
|
+
conn = self._ensure_initialized()
|
|
231
|
+
|
|
232
|
+
cursor = conn.execute(
|
|
233
|
+
"""SELECT execution_id, job_name, session_id, status,
|
|
234
|
+
started_at, ended_at, duration_ms, kwargs, result
|
|
235
|
+
FROM executions
|
|
236
|
+
WHERE session_id = ?
|
|
237
|
+
ORDER BY started_at DESC
|
|
238
|
+
LIMIT ?""",
|
|
239
|
+
(session_id, limit),
|
|
240
|
+
)
|
|
241
|
+
rows = cursor.fetchall()
|
|
242
|
+
return [_row_to_execution_record(row) for row in rows]
|
|
243
|
+
|
|
244
|
+
def insert_phase(self, execution_id: str, phase: PhaseRecord) -> None:
|
|
245
|
+
"""Insert a phase record for an execution.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
execution_id: The execution this phase belongs to.
|
|
249
|
+
phase: The PhaseRecord to persist.
|
|
250
|
+
"""
|
|
251
|
+
conn = self._ensure_initialized()
|
|
252
|
+
|
|
253
|
+
conn.execute(
|
|
254
|
+
"""INSERT INTO phases
|
|
255
|
+
(execution_id, name, status, started_at, ended_at, duration_ms)
|
|
256
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
257
|
+
(
|
|
258
|
+
execution_id,
|
|
259
|
+
phase.name,
|
|
260
|
+
phase.status,
|
|
261
|
+
phase.started_at,
|
|
262
|
+
phase.ended_at,
|
|
263
|
+
phase.duration_ms,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
conn.commit()
|
|
267
|
+
|
|
268
|
+
def get_execution_phases(self, execution_id: str) -> list[PhaseRecord]:
|
|
269
|
+
"""Get all phase records for an execution, ordered by start time.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
execution_id: The execution to query phases for.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of PhaseRecord instances ordered by started_at ascending.
|
|
276
|
+
"""
|
|
277
|
+
conn = self._ensure_initialized()
|
|
278
|
+
|
|
279
|
+
cursor = conn.execute(
|
|
280
|
+
"""SELECT name, status, started_at, ended_at, duration_ms
|
|
281
|
+
FROM phases
|
|
282
|
+
WHERE execution_id = ?
|
|
283
|
+
ORDER BY started_at ASC""",
|
|
284
|
+
(execution_id,),
|
|
285
|
+
)
|
|
286
|
+
rows = cursor.fetchall()
|
|
287
|
+
return [
|
|
288
|
+
PhaseRecord(
|
|
289
|
+
name=row[0],
|
|
290
|
+
status=row[1],
|
|
291
|
+
started_at=row[2],
|
|
292
|
+
ended_at=row[3],
|
|
293
|
+
duration_ms=row[4],
|
|
294
|
+
)
|
|
295
|
+
for row in rows
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
# ─── Lifecycle ────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def close(self) -> None:
|
|
301
|
+
"""Close the database connection."""
|
|
302
|
+
if self._conn is not None:
|
|
303
|
+
try:
|
|
304
|
+
self._conn.close()
|
|
305
|
+
except sqlite3.Error as e:
|
|
306
|
+
logger.warning("Error closing SQLiteExecutionStore connection: %s", e)
|
|
307
|
+
finally:
|
|
308
|
+
self._conn = None
|
|
309
|
+
self._initialized = False
|
|
310
|
+
|
|
311
|
+
def __enter__(self) -> SQLiteExecutionStore:
|
|
312
|
+
"""Context manager entry — initialize if needed."""
|
|
313
|
+
self._ensure_initialized()
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def __exit__(self, *exc: Any) -> None:
|
|
317
|
+
"""Context manager exit — close connection."""
|
|
318
|
+
self.close()
|
|
319
|
+
|
|
320
|
+
def __del__(self) -> None:
|
|
321
|
+
"""Ensure connection is closed on garbage collection."""
|
|
322
|
+
self.close()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ─── Module-Level Helpers ─────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _safe_json_encode(value: Any) -> str | None:
|
|
329
|
+
"""Encode a value to JSON, handling non-serializable values gracefully.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
value: Any Python value to encode.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
JSON string, or None if value is None.
|
|
336
|
+
"""
|
|
337
|
+
if value is None:
|
|
338
|
+
return None
|
|
339
|
+
try:
|
|
340
|
+
return json.dumps(value)
|
|
341
|
+
except (TypeError, ValueError, OverflowError):
|
|
342
|
+
type_name = type(value).__name__
|
|
343
|
+
return json.dumps(f"<non-serializable: {type_name}>")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _row_to_execution_record(row: tuple[Any, ...]) -> ExecutionRecord:
|
|
347
|
+
"""Convert a database row tuple to an ExecutionRecord instance.
|
|
348
|
+
|
|
349
|
+
Expected column order: execution_id, job_name, session_id, status,
|
|
350
|
+
started_at, ended_at, duration_ms, kwargs, result.
|
|
351
|
+
"""
|
|
352
|
+
kwargs_raw = row[7]
|
|
353
|
+
result_raw = row[8]
|
|
354
|
+
|
|
355
|
+
kwargs: dict[str, Any] = {}
|
|
356
|
+
if kwargs_raw is not None:
|
|
357
|
+
try:
|
|
358
|
+
kwargs = json.loads(kwargs_raw)
|
|
359
|
+
except (json.JSONDecodeError, TypeError):
|
|
360
|
+
kwargs = {}
|
|
361
|
+
|
|
362
|
+
result: Any = None
|
|
363
|
+
if result_raw is not None:
|
|
364
|
+
try:
|
|
365
|
+
result = json.loads(result_raw)
|
|
366
|
+
except (json.JSONDecodeError, TypeError):
|
|
367
|
+
result = result_raw
|
|
368
|
+
|
|
369
|
+
return ExecutionRecord(
|
|
370
|
+
execution_id=row[0],
|
|
371
|
+
job_name=row[1],
|
|
372
|
+
session_id=row[2],
|
|
373
|
+
status=row[3],
|
|
374
|
+
started_at=row[4],
|
|
375
|
+
ended_at=row[5],
|
|
376
|
+
duration_ms=row[6],
|
|
377
|
+
kwargs=kwargs,
|
|
378
|
+
result=result,
|
|
379
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Schema migration system for the SQLite state plugin.
|
|
2
|
+
|
|
3
|
+
Provides version tracking and sequential migration upgrades using a
|
|
4
|
+
`schema_version` table. Each migration is a function that takes a
|
|
5
|
+
sqlite3.Connection and applies schema changes for that version.
|
|
6
|
+
|
|
7
|
+
Uses only stdlib sqlite3 (zero external dependencies).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
__all__ = ["migrate", "get_current_version", "LATEST_VERSION"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Type alias for migration functions
|
|
22
|
+
MigrationFn = Callable[[sqlite3.Connection], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _migration_v1(conn: sqlite3.Connection) -> None:
|
|
26
|
+
"""Initial migration: create state, executions, phases, and sessions tables."""
|
|
27
|
+
conn.executescript("""\
|
|
28
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
29
|
+
key TEXT PRIMARY KEY,
|
|
30
|
+
value TEXT NOT NULL,
|
|
31
|
+
updated_at REAL NOT NULL
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
35
|
+
session_id TEXT PRIMARY KEY,
|
|
36
|
+
started_at REAL NOT NULL,
|
|
37
|
+
metadata TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS executions (
|
|
41
|
+
execution_id TEXT PRIMARY KEY,
|
|
42
|
+
job_name TEXT NOT NULL,
|
|
43
|
+
session_id TEXT NOT NULL,
|
|
44
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
45
|
+
started_at REAL NOT NULL,
|
|
46
|
+
ended_at REAL,
|
|
47
|
+
duration_ms REAL,
|
|
48
|
+
kwargs TEXT,
|
|
49
|
+
result TEXT,
|
|
50
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS phases (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
execution_id TEXT NOT NULL,
|
|
56
|
+
name TEXT NOT NULL,
|
|
57
|
+
status TEXT NOT NULL,
|
|
58
|
+
started_at REAL NOT NULL,
|
|
59
|
+
ended_at REAL,
|
|
60
|
+
duration_ms REAL,
|
|
61
|
+
FOREIGN KEY (execution_id) REFERENCES executions(execution_id)
|
|
62
|
+
);
|
|
63
|
+
""")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Registry of all migrations, keyed by version number.
|
|
67
|
+
# Migrations are applied sequentially from current version + 1 to LATEST_VERSION.
|
|
68
|
+
_MIGRATIONS: dict[int, MigrationFn] = {
|
|
69
|
+
1: _migration_v1,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# The latest schema version supported by this plugin.
|
|
73
|
+
LATEST_VERSION: int = max(_MIGRATIONS.keys())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ensure_schema_version_table(conn: sqlite3.Connection) -> None:
|
|
77
|
+
"""Create the schema_version table if it doesn't exist."""
|
|
78
|
+
conn.execute("""\
|
|
79
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
80
|
+
version INTEGER PRIMARY KEY,
|
|
81
|
+
applied_at REAL NOT NULL
|
|
82
|
+
)
|
|
83
|
+
""")
|
|
84
|
+
conn.commit()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_current_version(conn: sqlite3.Connection) -> int:
|
|
88
|
+
"""Get the current schema version from the database.
|
|
89
|
+
|
|
90
|
+
Returns 0 if no migrations have been applied yet.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
conn: An open sqlite3 connection.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The highest version number that has been applied, or 0 if none.
|
|
97
|
+
"""
|
|
98
|
+
_ensure_schema_version_table(conn)
|
|
99
|
+
cursor = conn.execute("SELECT MAX(version) FROM schema_version")
|
|
100
|
+
row = cursor.fetchone()
|
|
101
|
+
if row is None or row[0] is None:
|
|
102
|
+
return 0
|
|
103
|
+
return int(row[0])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def migrate(conn: sqlite3.Connection) -> int:
|
|
107
|
+
"""Apply all pending migrations to bring the database up to LATEST_VERSION.
|
|
108
|
+
|
|
109
|
+
Creates the schema_version table if it doesn't exist, checks the current
|
|
110
|
+
version, and applies migrations sequentially from current + 1 to
|
|
111
|
+
LATEST_VERSION.
|
|
112
|
+
|
|
113
|
+
Each migration is executed within a transaction. If a migration fails,
|
|
114
|
+
the transaction is rolled back and the error is raised.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
conn: An open sqlite3 connection.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The version the database is now at after migrations.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
sqlite3.Error: If a migration fails to apply.
|
|
124
|
+
"""
|
|
125
|
+
_ensure_schema_version_table(conn)
|
|
126
|
+
current = get_current_version(conn)
|
|
127
|
+
|
|
128
|
+
if current >= LATEST_VERSION:
|
|
129
|
+
logger.debug("Schema is up to date (version %d)", current)
|
|
130
|
+
return current
|
|
131
|
+
|
|
132
|
+
logger.info(
|
|
133
|
+
"Migrating schema from version %d to %d",
|
|
134
|
+
current,
|
|
135
|
+
LATEST_VERSION,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for version in range(current + 1, LATEST_VERSION + 1):
|
|
139
|
+
migration_fn = _MIGRATIONS.get(version)
|
|
140
|
+
if migration_fn is None:
|
|
141
|
+
raise RuntimeError(f"Missing migration function for version {version}")
|
|
142
|
+
|
|
143
|
+
logger.debug("Applying migration version %d", version)
|
|
144
|
+
try:
|
|
145
|
+
migration_fn(conn)
|
|
146
|
+
conn.execute(
|
|
147
|
+
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
148
|
+
(version, time.time()),
|
|
149
|
+
)
|
|
150
|
+
conn.commit()
|
|
151
|
+
logger.info("Applied migration version %d", version)
|
|
152
|
+
except sqlite3.Error:
|
|
153
|
+
conn.rollback()
|
|
154
|
+
logger.error("Failed to apply migration version %d", version)
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
return LATEST_VERSION
|