aevum-store-postgres 0.2.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.
- aevum/store/postgres/__init__.py +30 -0
- aevum/store/postgres/consent.py +110 -0
- aevum/store/postgres/ledger.py +198 -0
- aevum/store/postgres/migrate.py +134 -0
- aevum/store/postgres/py.typed +0 -0
- aevum/store/postgres/schema.py +33 -0
- aevum/store/postgres/store.py +110 -0
- aevum_store_postgres-0.2.0.dist-info/METADATA +40 -0
- aevum_store_postgres-0.2.0.dist-info/RECORD +11 -0
- aevum_store_postgres-0.2.0.dist-info/WHEEL +4 -0
- aevum_store_postgres-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aevum.store.postgres -- PostgreSQL backends for graph, consent, and ledger.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from aevum.store.postgres import PostgresStore, PostgresConsentLedger, PostgresLedger
|
|
6
|
+
from aevum.store.postgres.ledger import initialize_ledger_schema
|
|
7
|
+
import psycopg
|
|
8
|
+
|
|
9
|
+
conn = psycopg.connect("postgresql://user:pass@host/dbname")
|
|
10
|
+
initialize_schema(conn) # graph + consent DDL
|
|
11
|
+
initialize_ledger_schema(conn) # ledger DDL
|
|
12
|
+
|
|
13
|
+
store = PostgresStore(conn)
|
|
14
|
+
consent = PostgresConsentLedger(conn)
|
|
15
|
+
ledger = PostgresLedger(conn, sigchain)
|
|
16
|
+
|
|
17
|
+
engine = Engine(
|
|
18
|
+
graph_store=store,
|
|
19
|
+
consent_ledger=consent,
|
|
20
|
+
ledger=ledger,
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from aevum.store.postgres.consent import PostgresConsentLedger
|
|
25
|
+
from aevum.store.postgres.ledger import PostgresLedger
|
|
26
|
+
from aevum.store.postgres.store import PostgresStore
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = ["PostgresStore", "PostgresConsentLedger", "PostgresLedger"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgresConsentLedger — ConsentLedgerProtocol backed by PostgreSQL.
|
|
3
|
+
|
|
4
|
+
Grants stored as JSONB in aevum_consent_grants.
|
|
5
|
+
OR-Set semantics: revocation wins over concurrent grants.
|
|
6
|
+
Expiration checked in Python (matching InMemoryConsentLedger behaviour).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from aevum.core.consent.models import ConsentGrant
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PostgresConsentLedger:
|
|
21
|
+
"""
|
|
22
|
+
ConsentLedgerProtocol implementation backed by PostgreSQL.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
conn: An open psycopg.Connection (autocommit=True recommended).
|
|
26
|
+
lock: Optional shared Lock. Pass the same lock used by PostgresStore
|
|
27
|
+
when both share one connection.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, conn: Any, lock: threading.Lock | None = None) -> None:
|
|
31
|
+
self._conn = conn
|
|
32
|
+
self._lock = lock or threading.Lock()
|
|
33
|
+
|
|
34
|
+
# ── ConsentLedgerProtocol ──────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def add_grant(self, grant: ConsentGrant) -> None:
|
|
37
|
+
"""Upsert a consent grant. Replaces any prior record with the same grant_id."""
|
|
38
|
+
sql = """
|
|
39
|
+
INSERT INTO aevum_consent_grants (grant_id, grant_data)
|
|
40
|
+
VALUES (%s, %s::jsonb)
|
|
41
|
+
ON CONFLICT (grant_id) DO UPDATE SET grant_data = EXCLUDED.grant_data
|
|
42
|
+
"""
|
|
43
|
+
with self._lock, self._conn.cursor() as cur:
|
|
44
|
+
cur.execute(sql, (grant.grant_id, json.dumps(grant.model_dump())))
|
|
45
|
+
|
|
46
|
+
def revoke_grant(self, grant_id: str) -> None:
|
|
47
|
+
"""Mark a grant as revoked (immutable update via JSONB merge)."""
|
|
48
|
+
sql = """
|
|
49
|
+
UPDATE aevum_consent_grants
|
|
50
|
+
SET grant_data = jsonb_set(grant_data, '{revocation_status}', '"revoked"')
|
|
51
|
+
WHERE grant_id = %s
|
|
52
|
+
"""
|
|
53
|
+
with self._lock, self._conn.cursor() as cur:
|
|
54
|
+
cur.execute(sql, (grant_id,))
|
|
55
|
+
|
|
56
|
+
def has_consent(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
subject_id: str,
|
|
60
|
+
operation: str,
|
|
61
|
+
grantee_id: str,
|
|
62
|
+
purpose: str | None = None,
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Return True if an active, unexpired grant covers this operation.
|
|
66
|
+
|
|
67
|
+
Mirrors InMemoryConsentLedger: loads candidates from DB, checks
|
|
68
|
+
expiration and operation in Python to stay consistent with the spec.
|
|
69
|
+
"""
|
|
70
|
+
sql = """
|
|
71
|
+
SELECT grant_data
|
|
72
|
+
FROM aevum_consent_grants
|
|
73
|
+
WHERE grant_data->>'subject_id' = %s
|
|
74
|
+
AND grant_data->>'grantee_id' = %s
|
|
75
|
+
AND grant_data->>'revocation_status' = 'active'
|
|
76
|
+
"""
|
|
77
|
+
with self._lock, self._conn.cursor() as cur:
|
|
78
|
+
cur.execute(sql, (subject_id, grantee_id))
|
|
79
|
+
rows = cur.fetchall()
|
|
80
|
+
|
|
81
|
+
now = datetime.now(UTC)
|
|
82
|
+
for row in rows:
|
|
83
|
+
raw = row[0]
|
|
84
|
+
d: dict[str, Any] = json.loads(raw) if isinstance(raw, str) else raw
|
|
85
|
+
if operation not in d.get("operations", []):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
expires = datetime.fromisoformat(
|
|
89
|
+
d["expires_at"].replace("Z", "+00:00")
|
|
90
|
+
)
|
|
91
|
+
if now > expires:
|
|
92
|
+
continue
|
|
93
|
+
except (KeyError, ValueError):
|
|
94
|
+
continue
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def all_grants(self) -> list[ConsentGrant]:
|
|
99
|
+
"""Return all stored grants (active, revoked, and expired)."""
|
|
100
|
+
sql = "SELECT grant_data FROM aevum_consent_grants"
|
|
101
|
+
with self._lock, self._conn.cursor() as cur:
|
|
102
|
+
cur.execute(sql)
|
|
103
|
+
rows = cur.fetchall()
|
|
104
|
+
grants: list[ConsentGrant] = []
|
|
105
|
+
for row in rows:
|
|
106
|
+
raw = row[0]
|
|
107
|
+
d: dict[str, Any] = json.loads(raw) if isinstance(raw, str) else raw
|
|
108
|
+
with contextlib.suppress(Exception):
|
|
109
|
+
grants.append(ConsentGrant(**d))
|
|
110
|
+
return grants
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgresLedger -- persistent episodic ledger backed by PostgreSQL.
|
|
3
|
+
|
|
4
|
+
AuditEvents are serialized as JSONB. Sigchain verification still works --
|
|
5
|
+
verify_chain() reads all events in sequence order from Postgres.
|
|
6
|
+
|
|
7
|
+
Barrier 4 (Audit Immutability) enforced by __delitem__ and __setitem__.
|
|
8
|
+
The database table is INSERT-only -- no UPDATE or DELETE ever issued.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from aevum.core.audit.event import AuditEvent
|
|
18
|
+
from aevum.core.audit.sigchain import Sigchain
|
|
19
|
+
from aevum.core.exceptions import BarrierViolationError, ReplayNotFoundError
|
|
20
|
+
|
|
21
|
+
_DDL_LEDGER = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS aevum_ledger (
|
|
23
|
+
sequence BIGSERIAL PRIMARY KEY,
|
|
24
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
25
|
+
audit_id TEXT NOT NULL UNIQUE,
|
|
26
|
+
event_type TEXT NOT NULL,
|
|
27
|
+
actor TEXT NOT NULL,
|
|
28
|
+
system_time BIGINT NOT NULL,
|
|
29
|
+
episode_id TEXT,
|
|
30
|
+
causation_id TEXT,
|
|
31
|
+
correlation_id TEXT,
|
|
32
|
+
prior_hash TEXT NOT NULL,
|
|
33
|
+
payload_hash TEXT NOT NULL,
|
|
34
|
+
signature TEXT NOT NULL,
|
|
35
|
+
signer_key_id TEXT NOT NULL,
|
|
36
|
+
schema_version TEXT NOT NULL DEFAULT '1.0',
|
|
37
|
+
valid_from TEXT NOT NULL,
|
|
38
|
+
valid_to TEXT,
|
|
39
|
+
trace_id TEXT,
|
|
40
|
+
span_id TEXT,
|
|
41
|
+
payload JSONB NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_aevum_ledger_audit_id ON aevum_ledger (audit_id);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_aevum_ledger_sequence ON aevum_ledger (sequence);
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def initialize_ledger_schema(conn: Any) -> None:
|
|
49
|
+
"""Create the aevum_ledger table if it does not exist."""
|
|
50
|
+
with conn.cursor() as cur:
|
|
51
|
+
cur.execute(_DDL_LEDGER)
|
|
52
|
+
conn.commit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _event_to_row(event: AuditEvent) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"event_id": event.event_id,
|
|
58
|
+
"audit_id": event.audit_id(),
|
|
59
|
+
"event_type": event.event_type,
|
|
60
|
+
"actor": event.actor,
|
|
61
|
+
"system_time": event.system_time,
|
|
62
|
+
"episode_id": event.episode_id,
|
|
63
|
+
"causation_id": event.causation_id,
|
|
64
|
+
"correlation_id": event.correlation_id,
|
|
65
|
+
"prior_hash": event.prior_hash,
|
|
66
|
+
"payload_hash": event.payload_hash,
|
|
67
|
+
"signature": event.signature,
|
|
68
|
+
"signer_key_id": event.signer_key_id,
|
|
69
|
+
"schema_version": event.schema_version,
|
|
70
|
+
"valid_from": event.valid_from,
|
|
71
|
+
"valid_to": event.valid_to,
|
|
72
|
+
"trace_id": event.trace_id,
|
|
73
|
+
"span_id": event.span_id,
|
|
74
|
+
"payload": json.dumps(event.payload, default=str),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _row_to_event(row: dict[str, Any]) -> AuditEvent:
|
|
79
|
+
payload = row["payload"]
|
|
80
|
+
if isinstance(payload, str):
|
|
81
|
+
payload = json.loads(payload)
|
|
82
|
+
return AuditEvent(
|
|
83
|
+
event_id=row["event_id"],
|
|
84
|
+
episode_id=row.get("episode_id", ""),
|
|
85
|
+
sequence=row["sequence"],
|
|
86
|
+
event_type=row["event_type"],
|
|
87
|
+
schema_version=row.get("schema_version", "1.0"),
|
|
88
|
+
valid_from=row["valid_from"],
|
|
89
|
+
valid_to=row.get("valid_to"),
|
|
90
|
+
system_time=row["system_time"],
|
|
91
|
+
causation_id=row.get("causation_id"),
|
|
92
|
+
correlation_id=row.get("correlation_id"),
|
|
93
|
+
actor=row["actor"],
|
|
94
|
+
trace_id=row.get("trace_id"),
|
|
95
|
+
span_id=row.get("span_id"),
|
|
96
|
+
payload=payload,
|
|
97
|
+
payload_hash=row["payload_hash"],
|
|
98
|
+
prior_hash=row["prior_hash"],
|
|
99
|
+
signature=row["signature"],
|
|
100
|
+
signer_key_id=row["signer_key_id"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PostgresLedger:
|
|
105
|
+
"""
|
|
106
|
+
Persistent episodic ledger backed by PostgreSQL.
|
|
107
|
+
|
|
108
|
+
Thread-safe. INSERT-only (Barrier 4 at application layer).
|
|
109
|
+
Pass a shared threading.Lock if sharing a connection with PostgresStore.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
conn: Any,
|
|
115
|
+
sigchain: Sigchain,
|
|
116
|
+
lock: threading.Lock | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._conn = conn
|
|
119
|
+
self._sigchain = sigchain
|
|
120
|
+
self._lock = lock or threading.Lock()
|
|
121
|
+
|
|
122
|
+
def append(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
event_type: str,
|
|
126
|
+
payload: dict[str, Any],
|
|
127
|
+
actor: str,
|
|
128
|
+
episode_id: str | None = None,
|
|
129
|
+
causation_id: str | None = None,
|
|
130
|
+
correlation_id: str | None = None,
|
|
131
|
+
) -> AuditEvent:
|
|
132
|
+
with self._lock:
|
|
133
|
+
event = self._sigchain.new_event(
|
|
134
|
+
event_type=event_type,
|
|
135
|
+
payload=payload,
|
|
136
|
+
actor=actor,
|
|
137
|
+
episode_id=episode_id,
|
|
138
|
+
causation_id=causation_id,
|
|
139
|
+
correlation_id=correlation_id,
|
|
140
|
+
)
|
|
141
|
+
row = _event_to_row(event)
|
|
142
|
+
with self._conn.cursor() as cur:
|
|
143
|
+
cur.execute(
|
|
144
|
+
"""
|
|
145
|
+
INSERT INTO aevum_ledger (
|
|
146
|
+
event_id, audit_id, event_type, actor, system_time,
|
|
147
|
+
episode_id, causation_id, correlation_id,
|
|
148
|
+
prior_hash, payload_hash, signature, signer_key_id,
|
|
149
|
+
schema_version, valid_from, valid_to,
|
|
150
|
+
trace_id, span_id, payload
|
|
151
|
+
) VALUES (
|
|
152
|
+
%(event_id)s, %(audit_id)s, %(event_type)s, %(actor)s,
|
|
153
|
+
%(system_time)s, %(episode_id)s, %(causation_id)s,
|
|
154
|
+
%(correlation_id)s, %(prior_hash)s, %(payload_hash)s,
|
|
155
|
+
%(signature)s, %(signer_key_id)s, %(schema_version)s,
|
|
156
|
+
%(valid_from)s, %(valid_to)s, %(trace_id)s,
|
|
157
|
+
%(span_id)s, %(payload)s::jsonb
|
|
158
|
+
)
|
|
159
|
+
""",
|
|
160
|
+
row,
|
|
161
|
+
)
|
|
162
|
+
self._conn.commit()
|
|
163
|
+
return event
|
|
164
|
+
|
|
165
|
+
def get(self, audit_id: str) -> AuditEvent:
|
|
166
|
+
from psycopg.rows import dict_row
|
|
167
|
+
with self._lock, self._conn.cursor(row_factory=dict_row) as cur:
|
|
168
|
+
cur.execute(
|
|
169
|
+
"SELECT * FROM aevum_ledger WHERE audit_id = %s",
|
|
170
|
+
(audit_id,),
|
|
171
|
+
)
|
|
172
|
+
row = cur.fetchone()
|
|
173
|
+
if row is None:
|
|
174
|
+
raise ReplayNotFoundError(f"No ledger entry for {audit_id!r}")
|
|
175
|
+
return _row_to_event(row)
|
|
176
|
+
|
|
177
|
+
def all_events(self) -> list[AuditEvent]:
|
|
178
|
+
from psycopg.rows import dict_row
|
|
179
|
+
with self._lock, self._conn.cursor(row_factory=dict_row) as cur:
|
|
180
|
+
cur.execute("SELECT * FROM aevum_ledger ORDER BY sequence ASC")
|
|
181
|
+
rows = cur.fetchall()
|
|
182
|
+
return [_row_to_event(r) for r in rows]
|
|
183
|
+
|
|
184
|
+
def count(self) -> int:
|
|
185
|
+
with self._lock, self._conn.cursor() as cur:
|
|
186
|
+
cur.execute("SELECT COUNT(*) FROM aevum_ledger")
|
|
187
|
+
result = cur.fetchone()
|
|
188
|
+
return result[0] if result else 0
|
|
189
|
+
|
|
190
|
+
def __delitem__(self, key: object) -> None:
|
|
191
|
+
raise BarrierViolationError(
|
|
192
|
+
"Attempted to delete a ledger entry -- Barrier 4 (Audit Immutability) violated."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def __setitem__(self, key: object, value: object) -> None:
|
|
196
|
+
raise BarrierViolationError(
|
|
197
|
+
"Attempted to overwrite a ledger entry -- Barrier 4 (Audit Immutability) violated."
|
|
198
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
migrate_from_oxigraph — export Oxigraph data and import into PostgresStore.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from aevum.store.postgres.migrate import migrate_from_oxigraph
|
|
6
|
+
stats = migrate_from_oxigraph(
|
|
7
|
+
source_store=oxigraph_store,
|
|
8
|
+
target_store=postgres_store,
|
|
9
|
+
source_consent=in_memory_ledger, # optional
|
|
10
|
+
target_consent=postgres_consent, # optional
|
|
11
|
+
)
|
|
12
|
+
print(stats) # {"entities": 42, "grants": 5}
|
|
13
|
+
|
|
14
|
+
CLI (after installation):
|
|
15
|
+
aevum-store-migrate --source-dsn "" --target-dsn "postgresql://..."
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from aevum.store.postgres.consent import PostgresConsentLedger
|
|
26
|
+
from aevum.store.postgres.store import PostgresStore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def migrate_from_oxigraph(
|
|
30
|
+
source_store: Any,
|
|
31
|
+
target_store: PostgresStore,
|
|
32
|
+
source_consent: Any | None = None,
|
|
33
|
+
target_consent: PostgresConsentLedger | None = None,
|
|
34
|
+
) -> dict[str, int]:
|
|
35
|
+
"""
|
|
36
|
+
Copy all entities and consent grants from an OxigraphStore to PostgresStore.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
source_store: An OxigraphStore instance (must have sparql_select,
|
|
40
|
+
get_entity, get_entity_classification).
|
|
41
|
+
target_store: The destination PostgresStore.
|
|
42
|
+
source_consent: Optional ConsentLedger to migrate grants from.
|
|
43
|
+
target_consent: Optional PostgresConsentLedger to migrate grants into.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
dict with counts: {"entities": <n>, "grants": <n>}
|
|
47
|
+
"""
|
|
48
|
+
entities_migrated = 0
|
|
49
|
+
grants_migrated = 0
|
|
50
|
+
|
|
51
|
+
# --- Migrate entities ---
|
|
52
|
+
rows = source_store.sparql_select(
|
|
53
|
+
"SELECT DISTINCT ?s WHERE { GRAPH <urn:aevum:knowledge> { ?s ?p ?o } }"
|
|
54
|
+
)
|
|
55
|
+
for row in rows:
|
|
56
|
+
iri: str = row.get("s", "")
|
|
57
|
+
if not iri:
|
|
58
|
+
continue
|
|
59
|
+
# Strip namespace prefix added by OxigraphStore._entity_node
|
|
60
|
+
entity_id = iri[len("urn:aevum:entity:"):] if iri.startswith("urn:aevum:entity:") else iri
|
|
61
|
+
|
|
62
|
+
data = source_store.get_entity(entity_id)
|
|
63
|
+
if data is None:
|
|
64
|
+
# Try with the full IRI (entity_id was stored as IRI)
|
|
65
|
+
data = source_store.get_entity(iri)
|
|
66
|
+
if data is None:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
classification = source_store.get_entity_classification(entity_id)
|
|
70
|
+
target_store.store_entity(entity_id, data, classification)
|
|
71
|
+
entities_migrated += 1
|
|
72
|
+
|
|
73
|
+
# --- Migrate consent grants ---
|
|
74
|
+
if source_consent is not None and target_consent is not None:
|
|
75
|
+
for grant in source_consent.all_grants():
|
|
76
|
+
target_consent.add_grant(grant)
|
|
77
|
+
grants_migrated += 1
|
|
78
|
+
|
|
79
|
+
return {"entities": entities_migrated, "grants": grants_migrated}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
"""CLI entry point for aevum-store-migrate."""
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
description="Migrate Aevum data from Oxigraph to PostgreSQL"
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--source-path",
|
|
89
|
+
default=None,
|
|
90
|
+
help="Path to Oxigraph store directory (omit for in-memory — useful only for testing)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--target-dsn",
|
|
94
|
+
required=True,
|
|
95
|
+
help="PostgreSQL DSN (e.g. postgresql://user:pass@localhost/aevum)",
|
|
96
|
+
)
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
import psycopg
|
|
101
|
+
except ImportError:
|
|
102
|
+
print("psycopg is required: pip install psycopg[binary]", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
107
|
+
except ImportError:
|
|
108
|
+
print("aevum-store-oxigraph is required for migration", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
from aevum.store.postgres import PostgresConsentLedger, PostgresStore
|
|
112
|
+
from aevum.store.postgres.schema import initialize_schema
|
|
113
|
+
|
|
114
|
+
source = OxigraphStore(args.source_path)
|
|
115
|
+
conn = psycopg.connect(args.target_dsn, autocommit=True)
|
|
116
|
+
initialize_schema(conn)
|
|
117
|
+
|
|
118
|
+
import threading
|
|
119
|
+
lock = threading.Lock()
|
|
120
|
+
target = PostgresStore(conn, lock)
|
|
121
|
+
target_consent = PostgresConsentLedger(conn, lock)
|
|
122
|
+
|
|
123
|
+
stats = migrate_from_oxigraph(
|
|
124
|
+
source_store=source,
|
|
125
|
+
target_store=target,
|
|
126
|
+
source_consent=None,
|
|
127
|
+
target_consent=target_consent,
|
|
128
|
+
)
|
|
129
|
+
conn.close()
|
|
130
|
+
print(f"Migration complete: {stats['entities']} entities, {stats['grants']} grants")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DDL for the Aevum PostgreSQL schema.
|
|
3
|
+
|
|
4
|
+
Two tables:
|
|
5
|
+
aevum_entities — entity data + classification (GraphStore backend)
|
|
6
|
+
aevum_consent_grants — consent grants as JSONB (ConsentLedger backend)
|
|
7
|
+
|
|
8
|
+
Call initialize_schema(conn) once after connecting.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_DDL = """\
|
|
16
|
+
CREATE TABLE IF NOT EXISTS aevum_entities (
|
|
17
|
+
entity_id TEXT PRIMARY KEY,
|
|
18
|
+
data JSONB NOT NULL,
|
|
19
|
+
classification INT NOT NULL DEFAULT 0,
|
|
20
|
+
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS aevum_consent_grants (
|
|
24
|
+
grant_id TEXT PRIMARY KEY,
|
|
25
|
+
grant_data JSONB NOT NULL
|
|
26
|
+
);
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def initialize_schema(conn: Any) -> None:
|
|
31
|
+
"""Create Aevum tables if they do not already exist."""
|
|
32
|
+
with conn.cursor() as cur:
|
|
33
|
+
cur.execute(_DDL)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgresStore — GraphStore Protocol backed by PostgreSQL via psycopg3 (sync).
|
|
3
|
+
|
|
4
|
+
Satisfies aevum.core.protocols.graph_store.GraphStore.
|
|
5
|
+
Uses two tables defined in schema.py.
|
|
6
|
+
|
|
7
|
+
Classification ceiling (Barrier 2) is enforced at query_entities().
|
|
8
|
+
Thread-safe via shared threading.Lock (same lock used by PostgresConsentLedger
|
|
9
|
+
when both share one connection).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PostgresStore:
|
|
20
|
+
"""
|
|
21
|
+
GraphStore implementation backed by PostgreSQL.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
conn: An open psycopg.Connection (autocommit=True recommended).
|
|
25
|
+
lock: Optional shared Lock. If both PostgresStore and
|
|
26
|
+
PostgresConsentLedger share one connection, pass the same lock
|
|
27
|
+
to serialise access.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, conn: Any, lock: threading.Lock | None = None) -> None:
|
|
31
|
+
self._conn = conn
|
|
32
|
+
self._lock = lock or threading.Lock()
|
|
33
|
+
|
|
34
|
+
# ── GraphStore Protocol ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def store_entity(
|
|
37
|
+
self,
|
|
38
|
+
entity_id: str,
|
|
39
|
+
data: dict[str, Any],
|
|
40
|
+
classification: int = 0,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Upsert an entity. Last write wins (update semantics)."""
|
|
43
|
+
sql = """
|
|
44
|
+
INSERT INTO aevum_entities (entity_id, data, classification)
|
|
45
|
+
VALUES (%s, %s::jsonb, %s)
|
|
46
|
+
ON CONFLICT (entity_id) DO UPDATE
|
|
47
|
+
SET data = EXCLUDED.data,
|
|
48
|
+
classification = EXCLUDED.classification,
|
|
49
|
+
ingested_at = NOW()
|
|
50
|
+
"""
|
|
51
|
+
with self._lock, self._conn.cursor() as cur:
|
|
52
|
+
cur.execute(sql, (entity_id, json.dumps(data), classification))
|
|
53
|
+
|
|
54
|
+
def get_entity(self, entity_id: str) -> dict[str, Any] | None:
|
|
55
|
+
"""Return entity data dict, or None if not found."""
|
|
56
|
+
sql = "SELECT data FROM aevum_entities WHERE entity_id = %s"
|
|
57
|
+
with self._lock, self._conn.cursor() as cur:
|
|
58
|
+
cur.execute(sql, (entity_id,))
|
|
59
|
+
row = cur.fetchone()
|
|
60
|
+
if row is None:
|
|
61
|
+
return None
|
|
62
|
+
raw = row[0]
|
|
63
|
+
result: dict[str, Any] = json.loads(raw) if isinstance(raw, str) else raw
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def query_entities(
|
|
67
|
+
self,
|
|
68
|
+
subject_ids: list[str],
|
|
69
|
+
classification_max: int = 0,
|
|
70
|
+
) -> dict[str, dict[str, Any]]:
|
|
71
|
+
"""
|
|
72
|
+
Return entities for the given subject IDs.
|
|
73
|
+
Excludes entities whose classification exceeds classification_max (Barrier 2).
|
|
74
|
+
"""
|
|
75
|
+
if not subject_ids:
|
|
76
|
+
return {}
|
|
77
|
+
sql = """
|
|
78
|
+
SELECT entity_id, data
|
|
79
|
+
FROM aevum_entities
|
|
80
|
+
WHERE entity_id = ANY(%s)
|
|
81
|
+
AND classification <= %s
|
|
82
|
+
"""
|
|
83
|
+
with self._lock, self._conn.cursor() as cur:
|
|
84
|
+
cur.execute(sql, (subject_ids, classification_max))
|
|
85
|
+
rows = cur.fetchall()
|
|
86
|
+
result: dict[str, dict[str, Any]] = {}
|
|
87
|
+
for entity_id, raw in rows:
|
|
88
|
+
data = json.loads(raw) if isinstance(raw, str) else raw
|
|
89
|
+
result[entity_id] = data
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
# ── Extra helpers (not in protocol — used by migrate) ─────────────────────
|
|
93
|
+
|
|
94
|
+
def get_entity_classification(self, entity_id: str) -> int:
|
|
95
|
+
"""Return classification level of an entity (default 0 if not found)."""
|
|
96
|
+
sql = "SELECT classification FROM aevum_entities WHERE entity_id = %s"
|
|
97
|
+
with self._lock, self._conn.cursor() as cur:
|
|
98
|
+
cur.execute(sql, (entity_id,))
|
|
99
|
+
row = cur.fetchone()
|
|
100
|
+
if row is None:
|
|
101
|
+
return 0
|
|
102
|
+
return int(row[0])
|
|
103
|
+
|
|
104
|
+
def entity_count(self) -> int:
|
|
105
|
+
"""Return total number of stored entities."""
|
|
106
|
+
sql = "SELECT COUNT(*) FROM aevum_entities"
|
|
107
|
+
with self._lock, self._conn.cursor() as cur:
|
|
108
|
+
cur.execute(sql)
|
|
109
|
+
row = cur.fetchone()
|
|
110
|
+
return int(row[0]) if row else 0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-store-postgres
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Aevum — PostgreSQL GraphStore + ConsentLedger backend (team deployments).
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: aevum-core
|
|
17
|
+
Requires-Dist: psycopg[binary]<4.0,>=3.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# aevum-store-postgres
|
|
21
|
+
|
|
22
|
+
PostgreSQL-backed graph store for Aevum. Suitable for team deployments with shared state, concurrent writers, and durable persistence.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install aevum-store-postgres
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import psycopg
|
|
30
|
+
from aevum.core import Engine
|
|
31
|
+
from aevum.store.postgres import PostgresStore
|
|
32
|
+
from aevum.store.postgres.store import initialize_schema
|
|
33
|
+
|
|
34
|
+
conn = psycopg.connect("postgresql://user:pass@localhost/aevum")
|
|
35
|
+
initialize_schema(conn)
|
|
36
|
+
engine = Engine(graph_store=PostgresStore(conn))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For single-node deployments without PostgreSQL, use `aevum-store-oxigraph` instead.
|
|
40
|
+
See the [main repository README](https://github.com/aevum-labs/aevum) for backend selection guidance.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aevum/store/postgres/__init__.py,sha256=ZDN5yJPhist1zgmCYgAMXGrrpc86Gms5D5t-yB7wnUQ,973
|
|
2
|
+
aevum/store/postgres/consent.py,sha256=3QvdGIz1E6F4yqnJOtPjlFtn7adJTnT9WBznfA3LUyc,4105
|
|
3
|
+
aevum/store/postgres/ledger.py,sha256=-BF8at-cGgH9ytfQ9KI72lltALs7WMfL19PX1mr3D3M,7185
|
|
4
|
+
aevum/store/postgres/migrate.py,sha256=5kWgp0jsWk-CbdxuQq9rJOFz8fuXgSDEntD_zuvuubs,4455
|
|
5
|
+
aevum/store/postgres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
aevum/store/postgres/schema.py,sha256=jg3ctOKwYbZxTtl8Kytq9KeS1HGHhyrbzylp-4piHjg,889
|
|
7
|
+
aevum/store/postgres/store.py,sha256=0RVmNPNvcw3BW0pWr1yJaga6UP6Q5w-tQ7wriDJu9qg,4177
|
|
8
|
+
aevum_store_postgres-0.2.0.dist-info/METADATA,sha256=Jgzh7W7fcBFj9RlGfRKtSOPafGOJBD7UQwGojrUXnq4,1432
|
|
9
|
+
aevum_store_postgres-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
aevum_store_postgres-0.2.0.dist-info/entry_points.txt,sha256=Fb0hUmDTjjY5E0f5-x4QAudUFQcjiQj4kYI-IGCkUYQ,74
|
|
11
|
+
aevum_store_postgres-0.2.0.dist-info/RECORD,,
|