xqo-sqlite 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dudikkocak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: xqo-sqlite
3
+ Version: 3.0.0
4
+ Summary: SQLite event store for xqo
5
+ Author: dudikkocak
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dudikkocak/xqo
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: xqo<4,>=3.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8; extra == "dev"
14
+ Requires-Dist: pytest-timeout>=2.3; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # xqo-sqlite
18
+
19
+ The `sqlite` event store for [xqo](https://github.com/dudikkocak/xqo): one SQLite file with indexed events, so task
20
+ pages, deletes and history reads don't scan the log.
21
+
22
+ ```bash
23
+ pip install xqo-sqlite
24
+ ```
25
+
26
+ ```json
27
+ "storage": {"backend": "sqlite"}
28
+ ```
29
+
30
+ | Key | Default | |
31
+ |---|---|---|
32
+ | `path` | `events.db` | relative to the data dir's `logs/` folder |
33
+ | `busy_timeout_ms` | `5000` | how long a second writer waits instead of failing |
34
+
35
+ WAL mode (readers never block the writer), `synchronous=NORMAL` (a crash loses at most the last commits, never the
36
+ file), and a quick integrity check when it opens. `xqo backup` writes a consistent copy with SQLite's online
37
+ backup API. `XQO_STORAGE=sqlite` picks it for one run. Switching backends starts an empty store: events are not
38
+ copied between stores.
@@ -0,0 +1,22 @@
1
+ # xqo-sqlite
2
+
3
+ The `sqlite` event store for [xqo](https://github.com/dudikkocak/xqo): one SQLite file with indexed events, so task
4
+ pages, deletes and history reads don't scan the log.
5
+
6
+ ```bash
7
+ pip install xqo-sqlite
8
+ ```
9
+
10
+ ```json
11
+ "storage": {"backend": "sqlite"}
12
+ ```
13
+
14
+ | Key | Default | |
15
+ |---|---|---|
16
+ | `path` | `events.db` | relative to the data dir's `logs/` folder |
17
+ | `busy_timeout_ms` | `5000` | how long a second writer waits instead of failing |
18
+
19
+ WAL mode (readers never block the writer), `synchronous=NORMAL` (a crash loses at most the last commits, never the
20
+ file), and a quick integrity check when it opens. `xqo backup` writes a consistent copy with SQLite's online
21
+ backup API. `XQO_STORAGE=sqlite` picks it for one run. Switching backends starts an empty store: events are not
22
+ copied between stores.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xqo-sqlite"
7
+ version = "3.0.0"
8
+ description = "SQLite event store for xqo"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "dudikkocak"}]
13
+ dependencies = [
14
+ "xqo>=3.0,<4",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8", "pytest-timeout>=2.3"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/dudikkocak/xqo"
22
+
23
+ [project.entry-points."xqo.plugins"]
24
+ sqlite = "xqo_sqlite:SqlitePlugin"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,134 @@
1
+ """xqo-sqlite: the "sqlite" event store for xqo.
2
+
3
+ pip install xqo-sqlite
4
+ "storage": {"backend": "sqlite"} # logs/events.db
5
+ "storage": {"backend": "sqlite", "path": "events.db", "busy_timeout_ms": 5000}
6
+
7
+ One SQLite file with indexed events: task pages, deletes and history reads don't scan the log. One server process
8
+ per data dir, like every xqo store. `xqo backup` writes a consistent copy through SQLite's online backup API.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import Iterable, Iterator
17
+
18
+ from xqo.api import SEED_KINDS, EventStore, Plugin, register_store, store_path
19
+
20
+ __version__ = "3.0.0"
21
+ KEYS = {"backend", "path", "busy_timeout_ms"}
22
+
23
+
24
+ class SqliteStore(EventStore):
25
+ """One SQLite file with indexed events: reads of one task tree, kinds and recent events don't scan the log.
26
+ WAL mode (readers never block the writer), busy_timeout (a second writer waits instead of failing),
27
+ synchronous=NORMAL (a crash loses at most the last commits, never the file), a quick_check when it opens."""
28
+ backend = "sqlite"
29
+ SCHEMA = """
30
+ CREATE TABLE IF NOT EXISTS events (
31
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ ts TEXT, kind TEXT, task_id TEXT, root_id TEXT, data TEXT NOT NULL);
33
+ CREATE INDEX IF NOT EXISTS events_root ON events(root_id);
34
+ CREATE INDEX IF NOT EXISTS events_task ON events(task_id);
35
+ CREATE INDEX IF NOT EXISTS events_kind ON events(kind);
36
+ """
37
+
38
+ def __init__(self, path: str | Path, busy_timeout_ms: int = 5000):
39
+ self.path = Path(path)
40
+ self.path.parent.mkdir(parents=True, exist_ok=True)
41
+ self._lock = threading.Lock()
42
+ try:
43
+ self.db = sqlite3.connect(str(self.path), timeout=busy_timeout_ms / 1000, check_same_thread=False,
44
+ isolation_level=None)
45
+ self.db.execute(f"PRAGMA busy_timeout={int(busy_timeout_ms)}")
46
+ self.db.execute("PRAGMA journal_mode=WAL")
47
+ self.db.execute("PRAGMA synchronous=NORMAL")
48
+ ok = self.db.execute("PRAGMA quick_check").fetchone()[0]
49
+ if ok != "ok":
50
+ raise ValueError(f"storage: {self.path} failed its integrity check ({ok}). Restore a backup, or "
51
+ f"recover the readable rows: sqlite3 {self.path} \".recover\" | sqlite3 fixed.db")
52
+ self.db.executescript(self.SCHEMA)
53
+ except sqlite3.DatabaseError as e:
54
+ raise ValueError(f"storage: can't open {self.path}: {e}") from None
55
+
56
+ def _rows(self, sql: str, args: tuple = ()) -> list[dict]:
57
+ with self._lock:
58
+ rows = self.db.execute(sql, args).fetchall()
59
+ return [json.loads(r[0]) for r in rows]
60
+
61
+ def append(self, d: dict) -> None:
62
+ with self._lock:
63
+ self.db.execute("INSERT INTO events(ts, kind, task_id, root_id, data) VALUES (?, ?, ?, ?, ?)",
64
+ (d.get("ts"), d.get("kind"), d.get("task_id"), d.get("root_task_id"), json.dumps(d)))
65
+
66
+ def recent(self, limit: int) -> list[dict]:
67
+ return self._rows("SELECT data FROM (SELECT seq, data FROM events ORDER BY seq DESC LIMIT ?) ORDER BY seq",
68
+ (int(limit),))
69
+
70
+ def seeds(self) -> list[dict]:
71
+ marks = ",".join("?" * len(SEED_KINDS))
72
+ return self._rows(f"SELECT data FROM events WHERE kind IN ({marks}) ORDER BY seq", SEED_KINDS)
73
+
74
+ def tree(self, root_id: str) -> list[dict]:
75
+ return self._rows("SELECT data FROM events WHERE root_id = ? OR task_id = ? ORDER BY seq", (root_id, root_id))
76
+
77
+ def by_kind_prefix(self, prefix: str) -> list[dict]:
78
+ esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
79
+ return self._rows("SELECT data FROM events WHERE kind LIKE ? ESCAPE '\\' ORDER BY seq", (esc + "%",))
80
+
81
+ def purge(self, root_id: str, ids: set[str], keep_kinds: Iterable[str]) -> int:
82
+ keep = tuple(keep_kinds) or ("",)
83
+ tids = tuple(ids) or ("",)
84
+ with self._lock:
85
+ cur = self.db.execute(
86
+ f"DELETE FROM events WHERE kind NOT IN ({','.join('?' * len(keep))}) AND "
87
+ f"(root_id = ? OR task_id = ? OR task_id IN ({','.join('?' * len(tids))}))",
88
+ (*keep, root_id, root_id, *tids))
89
+ return cur.rowcount
90
+
91
+ def iter_all(self) -> Iterator[dict]:
92
+ yield from self._rows("SELECT data FROM events ORDER BY seq")
93
+
94
+ def count(self) -> int:
95
+ with self._lock:
96
+ return self.db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
97
+
98
+ def backup_files(self, scratch: Path) -> dict[str, Path]:
99
+ """A consistent copy through SQLite's online backup API (never a raw copy of a live database)."""
100
+ scratch.mkdir(parents=True, exist_ok=True)
101
+ dst = scratch / self.path.name
102
+ dst.unlink(missing_ok=True)
103
+ out = sqlite3.connect(str(dst))
104
+ try:
105
+ with self._lock:
106
+ self.db.backup(out)
107
+ finally:
108
+ out.close()
109
+ return {self.path.name: dst}
110
+
111
+ def describe(self) -> dict:
112
+ return {"backend": self.backend, "path": str(self.path), "events": self.count()}
113
+
114
+ def close(self) -> None:
115
+ with self._lock:
116
+ self.db.close()
117
+
118
+
119
+
120
+ def _factory(cfg: dict, logs: Path) -> SqliteStore:
121
+ unknown = set(cfg) - KEYS
122
+ if unknown:
123
+ raise ValueError(f"storage: unknown setting(s) {', '.join(sorted(unknown))} for sqlite; "
124
+ f"known: {', '.join(sorted(KEYS))}")
125
+ return SqliteStore(store_path(cfg, logs, "events.db"), int(cfg.get("busy_timeout_ms", 5000)))
126
+
127
+
128
+ class SqlitePlugin(Plugin):
129
+ name = "sqlite"
130
+ description = "SQLite storage backend for the event log"
131
+
132
+ @classmethod
133
+ def register(cls) -> None:
134
+ register_store("sqlite", _factory)
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: xqo-sqlite
3
+ Version: 3.0.0
4
+ Summary: SQLite event store for xqo
5
+ Author: dudikkocak
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dudikkocak/xqo
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: xqo<4,>=3.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8; extra == "dev"
14
+ Requires-Dist: pytest-timeout>=2.3; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # xqo-sqlite
18
+
19
+ The `sqlite` event store for [xqo](https://github.com/dudikkocak/xqo): one SQLite file with indexed events, so task
20
+ pages, deletes and history reads don't scan the log.
21
+
22
+ ```bash
23
+ pip install xqo-sqlite
24
+ ```
25
+
26
+ ```json
27
+ "storage": {"backend": "sqlite"}
28
+ ```
29
+
30
+ | Key | Default | |
31
+ |---|---|---|
32
+ | `path` | `events.db` | relative to the data dir's `logs/` folder |
33
+ | `busy_timeout_ms` | `5000` | how long a second writer waits instead of failing |
34
+
35
+ WAL mode (readers never block the writer), `synchronous=NORMAL` (a crash loses at most the last commits, never the
36
+ file), and a quick integrity check when it opens. `xqo backup` writes a consistent copy with SQLite's online
37
+ backup API. `XQO_STORAGE=sqlite` picks it for one run. Switching backends starts an empty store: events are not
38
+ copied between stores.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/xqo_sqlite/__init__.py
5
+ src/xqo_sqlite.egg-info/PKG-INFO
6
+ src/xqo_sqlite.egg-info/SOURCES.txt
7
+ src/xqo_sqlite.egg-info/dependency_links.txt
8
+ src/xqo_sqlite.egg-info/entry_points.txt
9
+ src/xqo_sqlite.egg-info/requires.txt
10
+ src/xqo_sqlite.egg-info/top_level.txt
11
+ tests/test_sqlite.py
@@ -0,0 +1,2 @@
1
+ [xqo.plugins]
2
+ sqlite = xqo_sqlite:SqlitePlugin
@@ -0,0 +1,5 @@
1
+ xqo<4,>=3.0
2
+
3
+ [dev]
4
+ pytest>=8
5
+ pytest-timeout>=2.3
@@ -0,0 +1 @@
1
+ xqo_sqlite
@@ -0,0 +1,58 @@
1
+ """xqo-sqlite: the storage contract, restarts, backups, config checks and how xqo finds it."""
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from xqo.config import build
7
+ from xqo.store import open_store
8
+ from xqo.testing import check_event_store, check_restart_keeps_tasks, event
9
+ from xqo_sqlite import SqliteStore
10
+
11
+
12
+ def test_contract(tmp_path):
13
+ check_event_store(SqliteStore(tmp_path / "events.db"))
14
+
15
+
16
+ def test_all_tasks_survive_a_restart(tmp_path):
17
+ again = check_restart_keeps_tasks(tmp_path, {"backend": "sqlite"})
18
+ assert again.store.backend == "sqlite" and again.store.path == tmp_path / "logs" / "events.db"
19
+
20
+
21
+ def test_found_as_a_plugin_and_not_missing(tmp_path):
22
+ cfg = tmp_path / "agents.json"
23
+ cfg.write_text(json.dumps({"agents": [{"name": "planner", "skills": ["planning"]}],
24
+ "storage": {"backend": "sqlite"}}))
25
+ bus = build(cfg, tmp_path / "a", str(tmp_path / "logs" / "events.jsonl"), mock=True, departments="none")
26
+ st = bus.plugins.states["sqlite"]
27
+ assert st.state != "missing" and st.source == "xqo_sqlite:SqlitePlugin"
28
+ assert bus.store.backend == "sqlite"
29
+
30
+
31
+ def test_file_and_backup_copy(tmp_path):
32
+ build_dir = tmp_path / "logs"
33
+ from xqo_sqlite import SqlitePlugin
34
+ SqlitePlugin.register()
35
+ s = open_store({"backend": "sqlite"}, build_dir / "events.jsonl")
36
+ assert s.path == build_dir / "events.db"
37
+ s.append(event("task", "r1"))
38
+ files = s.backup_files(tmp_path / "scratch")
39
+ copy = SqliteStore(files["events.db"])
40
+ assert [d["kind"] for d in copy.iter_all()] == ["task"]
41
+
42
+
43
+ def test_bad_config_and_bad_file_stop_startup(tmp_path):
44
+ from xqo_sqlite import SqlitePlugin
45
+ SqlitePlugin.register()
46
+ with pytest.raises(ValueError, match="unknown setting.*for sqlite"):
47
+ open_store({"backend": "sqlite", "table": "x"}, tmp_path / "events.jsonl")
48
+ bad = tmp_path / "bad.db"
49
+ bad.write_bytes(b"this is not a database" * 100)
50
+ with pytest.raises(ValueError, match="storage: can't open"):
51
+ SqliteStore(bad)
52
+
53
+
54
+ def test_env_picks_sqlite(tmp_path, monkeypatch):
55
+ from xqo_sqlite import SqlitePlugin
56
+ SqlitePlugin.register()
57
+ monkeypatch.setenv("XQO_STORAGE", "sqlite")
58
+ assert open_store({"backend": "jsonl"}, tmp_path / "events.jsonl").backend == "sqlite"