Persist generic object references

This commit is contained in:
Lyncoln
2026-09-19 20:22:18 -04:00
parent d5caee8b8b
commit dc6505d165
3 changed files with 295 additions and 9 deletions
+132 -7
View File
@@ -6,7 +6,11 @@ from datetime import datetime, timezone
from os import PathLike
from typing import Any
from .adapters import NormalizedObject, Observation
from .adapters import (
NormalizedObject,
ObjectReference,
Observation,
)
_FORBIDDEN_PERSISTED_KEYS = frozenset(
@@ -116,6 +120,71 @@ def _deserialize_payload(value: str) -> dict[str, Any]:
return payload
def _serialize_references(
references: Iterable[ObjectReference],
) -> str:
return json.dumps(
[
{
"relation": reference.relation,
"object_type": reference.object_type,
"local_key": reference.local_key,
"logical_key": reference.logical_key,
}
for reference in references
],
sort_keys=True,
separators=(",", ":"),
)
def _deserialize_references(
value: str,
) -> tuple[ObjectReference, ...]:
try:
serialized = json.loads(value)
except json.JSONDecodeError as exc:
raise RuntimeError(
"Persisted object references are invalid JSON"
) from exc
if not isinstance(serialized, list):
raise RuntimeError(
"Persisted object references are not a list"
)
expected_fields = {
"relation",
"object_type",
"local_key",
"logical_key",
}
references: list[ObjectReference] = []
for item in serialized:
if (
not isinstance(item, dict)
or set(item) != expected_fields
):
raise RuntimeError(
"Persisted object reference has invalid fields"
)
try:
references.append(
ObjectReference(
relation=item["relation"],
object_type=item["object_type"],
local_key=item["local_key"],
logical_key=item["logical_key"],
)
)
except (TypeError, ValueError) as exc:
raise RuntimeError(
"Persisted object reference is invalid"
) from exc
return tuple(references)
class SQLiteStateStore:
def __init__(self, path: str | PathLike[str]):
self.path = str(path)
@@ -185,7 +254,7 @@ class SQLiteStateStore:
)
version = versions[0]["version"]
if version not in (1, 2):
if version not in (1, 2, 3):
raise RuntimeError(
"Unsupported SQLite state schema version: "
f"{version!r}"
@@ -232,6 +301,31 @@ class SQLiteStateStore:
SET version = 2;
"""
)
version = 2
if version == 2:
migrations = (
("observations", "references_json"),
("desired_state", "references_json"),
("applied_state", "references_json"),
)
for table, column in migrations:
existing_columns = {
row["name"]
for row in self.connection.execute(
f"PRAGMA table_info({table})"
)
}
if column not in existing_columns:
self.connection.execute(
f"ALTER TABLE {table} "
f"ADD COLUMN {column} TEXT NOT NULL "
"DEFAULT '[]'"
)
self.connection.execute(
"UPDATE schema_meta SET version = 3"
)
def close(self) -> None:
self.connection.close()
@@ -254,7 +348,9 @@ class SQLiteStateStore:
snapshot_at,
"Observation snapshot timestamp",
)
prepared: list[tuple[str, str, str, str, str, str]] = []
prepared: list[
tuple[str, str, str, str, str, str, str]
] = []
local_keys: set[str] = set()
for observation in rows:
@@ -284,6 +380,7 @@ class SQLiteStateStore:
normalized.logical_key,
normalized.local_key,
_serialize_payload(normalized.payload),
_serialize_references(normalized.references),
_timestamp(
observation.observed_at,
"Observation timestamp",
@@ -307,8 +404,9 @@ class SQLiteStateStore:
logical_key,
local_key,
payload_json,
references_json,
observed_at
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
prepared,
)
@@ -378,6 +476,7 @@ class SQLiteStateStore:
logical_key,
local_key,
payload_json,
references_json,
observed_at
FROM observations
"""
@@ -402,6 +501,9 @@ class SQLiteStateStore:
logical_key=row["logical_key"],
local_key=row["local_key"],
payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
),
observed_at=_parse_timestamp(row["observed_at"]),
)
@@ -419,7 +521,9 @@ class SQLiteStateStore:
selected_at,
"Desired-state selection timestamp",
)
prepared: list[tuple[str, str, str, str]] = []
prepared: list[
tuple[str, str, str, str, str]
] = []
logical_keys: set[str] = set()
for normalized in rows:
@@ -444,6 +548,7 @@ class SQLiteStateStore:
object_type,
normalized.logical_key,
_serialize_payload(normalized.payload),
_serialize_references(normalized.references),
selected_timestamp,
)
)
@@ -462,8 +567,9 @@ class SQLiteStateStore:
object_type,
logical_key,
payload_json,
references_json,
selected_at
) VALUES (?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?)
""",
prepared,
)
@@ -477,6 +583,7 @@ class SQLiteStateStore:
object_type,
logical_key,
payload_json,
references_json,
selected_at
FROM desired_state
"""
@@ -493,6 +600,9 @@ class SQLiteStateStore:
object_type=row["object_type"],
logical_key=row["logical_key"],
payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
),
selected_at=_parse_timestamp(row["selected_at"]),
)
@@ -509,6 +619,9 @@ class SQLiteStateStore:
raise RuntimeError("Applied-state endpoint_id cannot be empty")
payload_json = _serialize_payload(object.payload)
references_json = _serialize_references(
object.references
)
applied_timestamp = _timestamp(
applied_at,
"Application timestamp",
@@ -523,11 +636,12 @@ class SQLiteStateStore:
logical_key,
local_key,
payload_json,
references_json,
applied_at,
verification_attempted_at,
verified_at,
verification_error
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
ON CONFLICT(
endpoint_id,
object_type,
@@ -535,6 +649,7 @@ class SQLiteStateStore:
) DO UPDATE SET
local_key = excluded.local_key,
payload_json = excluded.payload_json,
references_json = excluded.references_json,
applied_at = excluded.applied_at,
verification_attempted_at = NULL,
verified_at = NULL,
@@ -546,6 +661,7 @@ class SQLiteStateStore:
object.logical_key,
object.local_key,
payload_json,
references_json,
applied_timestamp,
),
)
@@ -564,6 +680,9 @@ class SQLiteStateStore:
"Verification timestamp",
)
payload_json = _serialize_payload(object.payload)
references_json = _serialize_references(
object.references
)
if succeeded:
verified_at = attempted_timestamp
@@ -587,6 +706,7 @@ class SQLiteStateStore:
AND object_type = ?
AND logical_key = ?
AND payload_json = ?
AND references_json = ?
""",
(
attempted_timestamp,
@@ -596,6 +716,7 @@ class SQLiteStateStore:
object.object_type,
object.logical_key,
payload_json,
references_json,
),
)
if cursor.rowcount != 1:
@@ -655,6 +776,7 @@ class SQLiteStateStore:
logical_key,
local_key,
payload_json,
references_json,
applied_at,
verification_attempted_at,
verified_at,
@@ -682,6 +804,9 @@ class SQLiteStateStore:
logical_key=row["logical_key"],
local_key=row["local_key"],
payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
),
applied_at=_parse_timestamp(row["applied_at"]),
verification_attempted_at=_parse_timestamp(