hivemind-sqlite-database 0.3.0a4__tar.gz → 0.4.0a1__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.
Files changed (14) hide show
  1. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/PKG-INFO +1 -1
  2. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database/__init__.py +143 -23
  3. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database/version.py +2 -2
  4. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/PKG-INFO +1 -1
  5. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/tests/test_sqlitedb.py +331 -2
  6. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/LICENSE.md +0 -0
  7. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/README.md +0 -0
  8. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
  9. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
  10. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
  11. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/requires.txt +0 -0
  12. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
  13. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/pyproject.toml +0 -0
  14. {hivemind_sqlite_database-0.3.0a4 → hivemind_sqlite_database-0.4.0a1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.3.0a4
3
+ Version: 0.4.0a1
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
@@ -65,6 +65,7 @@ class SQLiteDB(AbstractDB):
65
65
  self.conn.execute("PRAGMA journal_mode=WAL")
66
66
  self._write_lock = threading.Lock()
67
67
  self._initialize_database()
68
+ self._maybe_migrate()
68
69
 
69
70
  def _initialize_database(self):
70
71
  """Initialize the database schema."""
@@ -98,6 +99,81 @@ class SQLiteDB(AbstractDB):
98
99
  if "metadata" not in columns:
99
100
  self.conn.execute("ALTER TABLE clients ADD COLUMN metadata TEXT")
100
101
 
102
+ def _maybe_migrate(self) -> None:
103
+ """Run schema migration if the on-disk version is behind ``SCHEMA_VERSION``.
104
+
105
+ Persisted version lives in SQLite's ``PRAGMA user_version`` (a
106
+ signed integer slot reserved exactly for this use case).
107
+ Tolerates older HPM versions that predate ``SCHEMA_VERSION``.
108
+ """
109
+ target = getattr(AbstractDB, "SCHEMA_VERSION", 1)
110
+ stored = self.conn.execute("PRAGMA user_version").fetchone()[0]
111
+ # Tolerate older HPM that predates the forward-compat guard, the
112
+ # same way SCHEMA_VERSION is read defensively above.
113
+ if hasattr(self, "_check_forward_compat"):
114
+ self._check_forward_compat(int(stored))
115
+ if stored < target:
116
+ LOG.info("SQLiteDB: migrating schema v%d -> v%d", stored, target)
117
+ # Migrate row rewrites and the user_version bump share one
118
+ # transaction so a crash never leaves the DB at "migrated rows
119
+ # but stale sentinel" or vice versa.
120
+ with self._write_lock, self.conn:
121
+ self._migrate_locked(from_version=stored)
122
+ self.conn.execute(f"PRAGMA user_version = {int(target)}")
123
+
124
+ def migrate(self, from_version: int) -> None:
125
+ """Migrate on-disk rows to the current ``SCHEMA_VERSION``.
126
+
127
+ Idempotent and crash-safe: a partial migration re-run produces the
128
+ same final state because the merge is ``setdefault``-style (never
129
+ clobbers explicit metadata values) and the legacy columns are
130
+ unconditionally NULLed in the same transaction.
131
+
132
+ v1 -> v2: fold ``intent_blacklist`` / ``skill_blacklist`` column
133
+ values into each row's ``metadata`` JSON dict, then NULL the
134
+ legacy columns. ``message_blacklist`` is purged outright (the
135
+ field is not part of the ``Client`` data model). The columns
136
+ themselves remain in the table (SQLite ``ALTER TABLE ... DROP
137
+ COLUMN`` is unreliable on older versions) but are no longer
138
+ written by ``add_item``.
139
+ """
140
+ if from_version >= 2:
141
+ return
142
+ with self._write_lock, self.conn:
143
+ self._migrate_locked(from_version=from_version)
144
+
145
+ def _migrate_locked(self, from_version: int) -> None:
146
+ """Inner migration body — assumes the caller already holds
147
+ ``_write_lock`` and is inside a ``with self.conn`` transaction.
148
+ Lets ``_maybe_migrate`` bundle the version-sentinel bump into the
149
+ same transaction as the row rewrites."""
150
+ if from_version >= 2:
151
+ return
152
+ for row in self.conn.execute(
153
+ "SELECT client_id, intent_blacklist, skill_blacklist, "
154
+ "message_blacklist, metadata FROM clients"
155
+ ).fetchall():
156
+ metadata = self._metadata_from_row(row) or {}
157
+ # Drop any pre-existing metadata["message_blacklist"]
158
+ # from earlier migration runs that folded it in.
159
+ metadata.pop("message_blacklist", None)
160
+ for key in ("intent_blacklist", "skill_blacklist"):
161
+ raw = row[key]
162
+ if not raw:
163
+ continue
164
+ try:
165
+ legacy = json.loads(raw)
166
+ except (TypeError, ValueError):
167
+ continue
168
+ if legacy and key not in metadata:
169
+ metadata[key] = list(legacy)
170
+ self.conn.execute(
171
+ "UPDATE clients SET intent_blacklist = NULL, "
172
+ "skill_blacklist = NULL, message_blacklist = NULL, "
173
+ "metadata = ? WHERE client_id = ?",
174
+ (self._metadata_to_json(metadata), int(row["client_id"])),
175
+ )
176
+
101
177
  def add_item(self, client: Client) -> bool:
102
178
  """
103
179
  Add a client to the SQLite database.
@@ -121,9 +197,14 @@ class SQLiteDB(AbstractDB):
121
197
  """, (
122
198
  client.client_id, client.api_key, client.name, client.description,
123
199
  client.is_admin, client.last_seen,
124
- json.dumps(client.intent_blacklist),
125
- json.dumps(client.skill_blacklist),
126
- json.dumps(client.message_blacklist),
200
+ # Legacy OVOS blacklist columns are no longer written —
201
+ # the data lives in ``Client.metadata`` (see SCHEMA_VERSION
202
+ # v2 migration). Columns kept in the table for back-compat
203
+ # with older readers; NULLed on write so the disk stays
204
+ # clean.
205
+ None,
206
+ None,
207
+ None,
127
208
  json.dumps(client.allowed_types),
128
209
  client.crypto_key, client.password,
129
210
  client.can_broadcast, client.can_escalate, client.can_propagate,
@@ -157,6 +238,27 @@ class SQLiteDB(AbstractDB):
157
238
  LOG.error(f"Failed to search clients in SQLite: {e}")
158
239
  return []
159
240
 
241
+ def get_client_by_id(self, client_id: int) -> Optional[Client]:
242
+ """Fetch a single client row by primary key.
243
+
244
+ Targeted lookup used by :meth:`refresh` on the admission hot
245
+ path — avoids the full ``search_by_value`` fallback. Returns
246
+ ``None`` if the row does not exist or on any DB error.
247
+ """
248
+ if client_id is None:
249
+ return None
250
+ try:
251
+ cur = self.conn.execute(
252
+ "SELECT * FROM clients WHERE client_id = ?", (int(client_id),),
253
+ )
254
+ row = cur.fetchone()
255
+ if row is None:
256
+ return None
257
+ return self._row_to_client(row)
258
+ except (sqlite3.Error, TypeError, ValueError) as e:
259
+ LOG.error(f"Failed to fetch client {client_id} from SQLite: {e}")
260
+ return None
261
+
160
262
  def __len__(self) -> int:
161
263
  """Get the number of clients in the database."""
162
264
  try:
@@ -189,26 +291,44 @@ class SQLiteDB(AbstractDB):
189
291
 
190
292
  @staticmethod
191
293
  def _row_to_client(row: sqlite3.Row) -> Client:
192
- """Convert a database row to a Client instance."""
193
- kwargs = {
194
- "client_id": int(row["client_id"]),
195
- "api_key": row["api_key"],
196
- "name": row["name"],
197
- "description": row["description"],
198
- "is_admin": bool(row["is_admin"]),
199
- "last_seen": row["last_seen"],
200
- "intent_blacklist": json.loads(row["intent_blacklist"] or "[]"),
201
- "skill_blacklist": json.loads(row["skill_blacklist"] or "[]"),
202
- "message_blacklist": json.loads(row["message_blacklist"] or "[]"),
203
- "allowed_types": json.loads(row["allowed_types"] or "[]"),
204
- "crypto_key": row["crypto_key"],
205
- "password": row["password"],
206
- "can_broadcast": bool(row["can_broadcast"]),
207
- "can_escalate": bool(row["can_escalate"]),
208
- "can_propagate": bool(row["can_propagate"]),
209
- "metadata": SQLiteDB._metadata_from_row(row),
210
- }
211
- return Client(**kwargs)
294
+ """Convert a database row to a Client instance.
295
+
296
+ Legacy OVOS blacklist columns are no longer passed as kwargs to
297
+ ``Client(...)`` — after the v2 migration they are NULL on disk
298
+ and the canonical data lives in ``metadata``. If a row predates
299
+ migration (e.g. read by an older plugin version that didn't
300
+ migrate, then read again here), the values are folded into
301
+ ``metadata`` locally as a defensive fallback.
302
+ """
303
+ metadata = SQLiteDB._metadata_from_row(row) or {}
304
+ # message_blacklist was removed from the Client data model — drop
305
+ # any residual metadata key from earlier migrations.
306
+ metadata.pop("message_blacklist", None)
307
+ for key in ("intent_blacklist", "skill_blacklist"):
308
+ raw = row[key] if key in row.keys() else None
309
+ if not raw or key in metadata:
310
+ continue
311
+ try:
312
+ legacy = json.loads(raw)
313
+ except (TypeError, ValueError):
314
+ continue
315
+ if legacy:
316
+ metadata[key] = list(legacy)
317
+ return Client(
318
+ client_id=int(row["client_id"]),
319
+ api_key=row["api_key"],
320
+ name=row["name"],
321
+ description=row["description"],
322
+ is_admin=bool(row["is_admin"]),
323
+ last_seen=row["last_seen"],
324
+ allowed_types=json.loads(row["allowed_types"] or "[]"),
325
+ crypto_key=row["crypto_key"],
326
+ password=row["password"],
327
+ can_broadcast=bool(row["can_broadcast"]),
328
+ can_escalate=bool(row["can_escalate"]),
329
+ can_propagate=bool(row["can_propagate"]),
330
+ metadata=metadata,
331
+ )
212
332
 
213
333
  @staticmethod
214
334
  def _metadata_to_json(metadata: object) -> str:
@@ -1,8 +1,8 @@
1
1
  # START_VERSION_BLOCK
2
2
  VERSION_MAJOR = 0
3
- VERSION_MINOR = 3
3
+ VERSION_MINOR = 4
4
4
  VERSION_BUILD = 0
5
- VERSION_ALPHA = 4
5
+ VERSION_ALPHA = 1
6
6
  # END_VERSION_BLOCK
7
7
 
8
8
  __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.3.0a4
3
+ Version: 0.4.0a1
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
@@ -388,7 +388,6 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
388
388
  last_seen=1234567890.0,
389
389
  intent_blacklist=["a:b"],
390
390
  skill_blacklist=["c:d"],
391
- message_blacklist=["e:f"],
392
391
  allowed_types=["recognizer_loop:utterance"],
393
392
  crypto_key="1234567890123456",
394
393
  password="secret",
@@ -409,7 +408,18 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
409
408
  self.assertEqual(r.crypto_key, "1234567890123456")
410
409
  self.assertEqual(r.password, "secret")
411
410
  self.assertFalse(r.can_escalate)
412
- self.assertEqual(r.metadata, {"owner_id": "owner-123"})
411
+ # After SCHEMA_VERSION=2: legacy skill/intent kwargs auto-migrate
412
+ # into ``Client.metadata`` via Client.__init__. message_blacklist
413
+ # is gone from the data model — not accepted as a kwarg and not
414
+ # carried in metadata.
415
+ self.assertEqual(r.metadata, {
416
+ "owner_id": "owner-123",
417
+ "intent_blacklist": ["a:b"],
418
+ "skill_blacklist": ["c:d"],
419
+ })
420
+ # Property shims surface skill/intent blacklists at legacy names.
421
+ self.assertEqual(r.skill_blacklist, ["c:d"])
422
+ self.assertEqual(r.intent_blacklist, ["a:b"])
413
423
 
414
424
 
415
425
  class TestSQLiteDBCommit(unittest.TestCase):
@@ -532,5 +542,324 @@ class TestSQLiteDBMissingCipher(unittest.TestCase):
532
542
  self.assertIn("sqlcipher3", str(ctx.exception))
533
543
 
534
544
 
545
+ class TestSQLiteDBMigration(unittest.TestCase):
546
+ """v1 -> v2: legacy OVOS blacklist columns folded into metadata."""
547
+
548
+ def _make_v1_db_with_legacy_rows(self) -> SQLiteDB:
549
+ """Construct a DB in the v1 shape: legacy columns populated,
550
+ ``PRAGMA user_version`` left at 0 (the SQLite default)."""
551
+ db = object.__new__(SQLiteDB)
552
+ db.name = "clients"
553
+ db.subfolder = "hivemind-core"
554
+ db.conn = sqlite3.connect(":memory:", check_same_thread=False)
555
+ db.conn.row_factory = sqlite3.Row
556
+ db._write_lock = threading.Lock()
557
+ db._initialize_database()
558
+ # Write a row directly with legacy column data — bypass add_item
559
+ # which would NULL them.
560
+ db.conn.execute(
561
+ "INSERT INTO clients (client_id, api_key, intent_blacklist, "
562
+ "skill_blacklist, message_blacklist, allowed_types, metadata) "
563
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
564
+ (7, "legacy-key",
565
+ '["i:1"]', '["s:1"]', '["m:1"]', '[]', '{"owner": "u"}'),
566
+ )
567
+ db.conn.commit()
568
+ return db
569
+
570
+ def test_pragma_user_version_starts_at_zero(self):
571
+ db = self._make_v1_db_with_legacy_rows()
572
+ stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
573
+ self.assertEqual(stored, 0)
574
+
575
+ def test_migrate_folds_legacy_columns_into_metadata(self):
576
+ db = self._make_v1_db_with_legacy_rows()
577
+ db.migrate(from_version=1)
578
+ row = db.conn.execute(
579
+ "SELECT intent_blacklist, skill_blacklist, message_blacklist, "
580
+ "metadata FROM clients WHERE client_id = 7"
581
+ ).fetchone()
582
+ self.assertIsNone(row["intent_blacklist"])
583
+ self.assertIsNone(row["skill_blacklist"])
584
+ self.assertIsNone(row["message_blacklist"])
585
+ import json as _json
586
+ meta = _json.loads(row["metadata"])
587
+ self.assertEqual(meta["owner"], "u")
588
+ self.assertEqual(meta["intent_blacklist"], ["i:1"])
589
+ self.assertEqual(meta["skill_blacklist"], ["s:1"])
590
+ # message_blacklist is purged outright, NOT folded into metadata.
591
+ self.assertNotIn("message_blacklist", meta)
592
+
593
+ def test_migrate_purges_residual_metadata_message_blacklist(self):
594
+ """An older plugin version may have folded message_blacklist
595
+ into metadata before HPM removed the field. The newer migrate()
596
+ must purge it on re-run, leaving the disk clean."""
597
+ db = self._make_v1_db_with_legacy_rows()
598
+ # Seed an already-half-migrated row: legacy columns NULL, but
599
+ # metadata still carries the old key from the prior migration.
600
+ db.conn.execute(
601
+ "UPDATE clients SET intent_blacklist = NULL, "
602
+ "skill_blacklist = NULL, message_blacklist = NULL, "
603
+ "metadata = ? WHERE client_id = 7",
604
+ ('{"owner": "u", "message_blacklist": ["m:1"]}',),
605
+ )
606
+ db.conn.commit()
607
+
608
+ db.migrate(from_version=1)
609
+
610
+ import json as _json
611
+ meta = _json.loads(db.conn.execute(
612
+ "SELECT metadata FROM clients WHERE client_id = 7"
613
+ ).fetchone()["metadata"])
614
+ self.assertNotIn("message_blacklist", meta)
615
+ self.assertEqual(meta["owner"], "u")
616
+
617
+ def test_migrate_is_idempotent(self):
618
+ db = self._make_v1_db_with_legacy_rows()
619
+ db.migrate(from_version=1)
620
+ db.migrate(from_version=1) # second run = no-op on already-migrated row
621
+ row = db.conn.execute(
622
+ "SELECT metadata FROM clients WHERE client_id = 7"
623
+ ).fetchone()
624
+ import json as _json
625
+ meta = _json.loads(row["metadata"])
626
+ self.assertEqual(meta["skill_blacklist"], ["s:1"])
627
+
628
+ def test_migrate_setdefault_does_not_clobber_explicit_metadata(self):
629
+ db = self._make_v1_db_with_legacy_rows()
630
+ # Explicit metadata.skill_blacklist takes precedence over the
631
+ # legacy column.
632
+ db.conn.execute(
633
+ "UPDATE clients SET metadata = ? WHERE client_id = 7",
634
+ ('{"owner": "u", "skill_blacklist": ["explicit"]}',),
635
+ )
636
+ db.migrate(from_version=1)
637
+ import json as _json
638
+ meta = _json.loads(db.conn.execute(
639
+ "SELECT metadata FROM clients WHERE client_id = 7"
640
+ ).fetchone()["metadata"])
641
+ self.assertEqual(meta["skill_blacklist"], ["explicit"])
642
+
643
+ def test_migrate_skips_when_already_at_target(self):
644
+ db = self._make_v1_db_with_legacy_rows()
645
+ # Stub: a from_version >= 2 should not touch the row.
646
+ db.migrate(from_version=2)
647
+ row = db.conn.execute(
648
+ "SELECT intent_blacklist FROM clients WHERE client_id = 7"
649
+ ).fetchone()
650
+ self.assertEqual(row["intent_blacklist"], '["i:1"]')
651
+
652
+ def test_maybe_migrate_bumps_user_version(self):
653
+ db = self._make_v1_db_with_legacy_rows()
654
+ db._maybe_migrate()
655
+ stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
656
+ self.assertEqual(stored, 2)
657
+ # second invocation is a no-op
658
+ db._maybe_migrate()
659
+ self.assertEqual(
660
+ db.conn.execute("PRAGMA user_version").fetchone()[0], 2,
661
+ )
662
+
663
+ def test_post_init_runs_migration_on_existing_db(self):
664
+ """End-to-end: open a v1 DB file, observe v2 on-disk shape."""
665
+ with tempfile.TemporaryDirectory() as tmp:
666
+ db_path = os.path.join(tmp, "hivemind-core", "clients.db")
667
+ os.makedirs(os.path.dirname(db_path))
668
+ # Seed a v1-shape DB with legacy column data.
669
+ seed = sqlite3.connect(db_path)
670
+ seed.execute("""
671
+ CREATE TABLE clients (
672
+ client_id INTEGER PRIMARY KEY,
673
+ api_key VARCHAR(255) NOT NULL,
674
+ name VARCHAR(255),
675
+ description VARCHAR(255),
676
+ is_admin BOOLEAN DEFAULT FALSE,
677
+ last_seen REAL DEFAULT -1,
678
+ intent_blacklist TEXT,
679
+ skill_blacklist TEXT,
680
+ message_blacklist TEXT,
681
+ allowed_types TEXT,
682
+ crypto_key VARCHAR(16),
683
+ password TEXT,
684
+ can_broadcast BOOLEAN DEFAULT TRUE,
685
+ can_escalate BOOLEAN DEFAULT TRUE,
686
+ can_propagate BOOLEAN DEFAULT TRUE,
687
+ metadata TEXT
688
+ )
689
+ """)
690
+ seed.execute(
691
+ "INSERT INTO clients (client_id, api_key, skill_blacklist) "
692
+ "VALUES (?, ?, ?)",
693
+ (9, "k", '["legacy.skill"]'),
694
+ )
695
+ seed.commit()
696
+ seed.close()
697
+ # Now open it via SQLiteDB — __post_init__ should migrate.
698
+ import unittest.mock as mock
699
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
700
+ return_value=tmp):
701
+ db = SQLiteDB(name="clients", subfolder="hivemind-core")
702
+ self.assertEqual(
703
+ db.conn.execute("PRAGMA user_version").fetchone()[0], 2,
704
+ )
705
+ row = db.conn.execute(
706
+ "SELECT skill_blacklist, metadata FROM clients "
707
+ "WHERE client_id = 9"
708
+ ).fetchone()
709
+ self.assertIsNone(row["skill_blacklist"])
710
+ import json as _json
711
+ self.assertEqual(_json.loads(row["metadata"])["skill_blacklist"],
712
+ ["legacy.skill"])
713
+
714
+
715
+ class TestSQLiteDBEmptyDatabaseMigration(unittest.TestCase):
716
+ """A fresh DB (no rows, user_version=0) must still bump to the
717
+ current SCHEMA_VERSION on first open. Validates the cotransactional
718
+ migrate + sentinel write."""
719
+
720
+ def test_empty_new_db_bumps_user_version(self):
721
+ with tempfile.TemporaryDirectory() as tmp:
722
+ import unittest.mock as mock
723
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
724
+ return_value=tmp):
725
+ db = SQLiteDB(name="clients", subfolder="hivemind-core")
726
+ stored = db.conn.execute("PRAGMA user_version").fetchone()[0]
727
+ from hivemind_plugin_manager.database import AbstractDB
728
+ target = getattr(AbstractDB, "SCHEMA_VERSION", 1)
729
+ self.assertEqual(stored, target)
730
+ # No rows in the clients table — migration must be a no-op
731
+ # over rows but the sentinel still moves.
732
+ count = db.conn.execute(
733
+ "SELECT COUNT(*) FROM clients"
734
+ ).fetchone()[0]
735
+ self.assertEqual(count, 0)
736
+
737
+
738
+ class TestSQLiteDBForwardCompat(unittest.TestCase):
739
+ """A DB whose ``user_version`` is newer than this backend supports
740
+ must fail loudly with a RuntimeError instead of silently downgrading.
741
+ """
742
+
743
+ def test_forward_version_raises_runtime_error(self):
744
+ import unittest.mock as mock
745
+ with tempfile.TemporaryDirectory() as tmp:
746
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
747
+ return_value=tmp):
748
+ db = SQLiteDB(name="clients", subfolder="hivemind-core")
749
+ db.conn.execute("PRAGMA user_version = 999")
750
+ db.conn.commit()
751
+ db.conn.close()
752
+ with self.assertRaises(RuntimeError) as ctx:
753
+ SQLiteDB(name="clients", subfolder="hivemind-core")
754
+ self.assertIn("999", str(ctx.exception))
755
+
756
+
757
+ class TestSQLiteDBGetClientByID(unittest.TestCase):
758
+ def test_get_client_by_id_returns_row(self):
759
+ import unittest.mock as mock
760
+ with tempfile.TemporaryDirectory() as tmp:
761
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
762
+ return_value=tmp):
763
+ db = SQLiteDB(name="clients", subfolder="hivemind-core")
764
+ from hivemind_plugin_manager.database import Client
765
+ db.add_item(Client(client_id=42, api_key="k", name="alice"))
766
+ got = db.get_client_by_id(42)
767
+ self.assertIsNotNone(got)
768
+ self.assertEqual(got.client_id, 42)
769
+ self.assertIsNone(db.get_client_by_id(999))
770
+
771
+ def test_refresh_picks_up_updates(self):
772
+ import unittest.mock as mock
773
+ with tempfile.TemporaryDirectory() as tmp:
774
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
775
+ return_value=tmp):
776
+ db = SQLiteDB(name="clients", subfolder="hivemind-core")
777
+ from hivemind_plugin_manager.database import Client
778
+ db.add_item(Client(client_id=1, api_key="k", name="a",
779
+ allowed_types=["x"]))
780
+ self.assertEqual(db.refresh(1).allowed_types, ["x"])
781
+ db.add_item(Client(client_id=1, api_key="k", name="a",
782
+ allowed_types=["y"]))
783
+ self.assertEqual(db.refresh(1).allowed_types, ["y"])
784
+
785
+
786
+ class TestSQLiteDBSchemaV2RoundTrip(unittest.TestCase):
787
+ """v2 schema: allowed_types + skill/intent blacklists (in metadata) survive
788
+ add→search and add→refresh cycles without loss or mutation."""
789
+
790
+ def test_allowed_types_survives_round_trip(self):
791
+ db = make_db()
792
+ allowed = ["recognizer_loop:utterance", "speak:b64_audio"]
793
+ db.add_item(make_client(1, "k", allowed_types=allowed))
794
+ found = db.search_by_value("api_key", "k")
795
+ self.assertEqual(len(found), 1)
796
+ self.assertEqual(found[0].allowed_types, allowed)
797
+
798
+ def test_skill_blacklist_in_metadata_survives_round_trip(self):
799
+ db = make_db()
800
+ c = make_client(2, "k2", metadata={"skill_blacklist": ["my.skill"]})
801
+ db.add_item(c)
802
+ found = db.search_by_value("api_key", "k2")
803
+ self.assertEqual(len(found), 1)
804
+ self.assertEqual(found[0].skill_blacklist, ["my.skill"])
805
+ self.assertEqual(found[0].metadata["skill_blacklist"], ["my.skill"])
806
+
807
+ def test_intent_blacklist_in_metadata_survives_round_trip(self):
808
+ db = make_db()
809
+ c = make_client(3, "k3", metadata={"intent_blacklist": ["my.skill:action"]})
810
+ db.add_item(c)
811
+ found = db.search_by_value("api_key", "k3")
812
+ self.assertEqual(len(found), 1)
813
+ self.assertEqual(found[0].intent_blacklist, ["my.skill:action"])
814
+ self.assertEqual(found[0].metadata["intent_blacklist"], ["my.skill:action"])
815
+
816
+ def test_message_blacklist_not_present_in_stored_record(self):
817
+ """message_blacklist must not appear in a freshly-stored record."""
818
+ db = make_db()
819
+ db.add_item(make_client(4, "k4"))
820
+ row = db.conn.execute(
821
+ "SELECT message_blacklist, metadata FROM clients WHERE client_id = 4"
822
+ ).fetchone()
823
+ self.assertIsNone(row["message_blacklist"])
824
+ import json as _json
825
+ meta = _json.loads(row["metadata"] or "{}")
826
+ self.assertNotIn("message_blacklist", meta)
827
+
828
+ def test_v1_row_reads_cleanly_forward_compat(self):
829
+ """A v1 row (legacy columns populated) must deserialize via
830
+ _row_to_client without crashing."""
831
+ db = make_db()
832
+ db.conn.execute(
833
+ "INSERT INTO clients (client_id, api_key, skill_blacklist, "
834
+ "intent_blacklist, message_blacklist, allowed_types, metadata) "
835
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
836
+ (5, "k5", '["old.skill"]', '[]', '["drop.me"]',
837
+ '["recognizer_loop:utterance"]', "{}"),
838
+ )
839
+ db.conn.commit()
840
+ found = db.search_by_value("api_key", "k5")
841
+ self.assertEqual(len(found), 1)
842
+ self.assertEqual(found[0].api_key, "k5")
843
+ self.assertEqual(found[0].allowed_types, ["recognizer_loop:utterance"])
844
+
845
+ def test_refresh_returns_v2_fields(self):
846
+ db = make_db()
847
+ import unittest.mock as mock
848
+ import tempfile
849
+ import os
850
+ with tempfile.TemporaryDirectory() as tmp:
851
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
852
+ return_value=tmp):
853
+ filedb = SQLiteDB(name="clients", subfolder="hivemind-core")
854
+ allowed = ["recognizer_loop:utterance"]
855
+ meta = {"skill_blacklist": ["s:1"], "intent_blacklist": ["i:1"]}
856
+ filedb.add_item(make_client(6, "k6", allowed_types=allowed, metadata=meta))
857
+ got = filedb.refresh(6)
858
+ self.assertIsNotNone(got)
859
+ self.assertEqual(got.allowed_types, allowed)
860
+ self.assertEqual(got.skill_blacklist, ["s:1"])
861
+ self.assertEqual(got.intent_blacklist, ["i:1"])
862
+
863
+
535
864
  if __name__ == "__main__":
536
865
  unittest.main()