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.
@@ -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