nl2data-semantic-catalog-postgres 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.
Files changed (34) hide show
  1. nl2data_semantic_catalog_postgres/__init__.py +18 -0
  2. nl2data_semantic_catalog_postgres/client.py +139 -0
  3. nl2data_semantic_catalog_postgres/config.py +136 -0
  4. nl2data_semantic_catalog_postgres/envelope.py +327 -0
  5. nl2data_semantic_catalog_postgres/errors.py +188 -0
  6. nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
  7. nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
  8. nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
  9. nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
  10. nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
  11. nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
  12. nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
  13. nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
  14. nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
  15. nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
  16. nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
  17. nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
  18. nl2data_semantic_catalog_postgres/maintenance.py +235 -0
  19. nl2data_semantic_catalog_postgres/py.typed +0 -0
  20. nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
  21. nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
  22. nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
  23. nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
  24. nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
  25. nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
  26. nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
  27. nl2data_semantic_catalog_postgres/schema.py +337 -0
  28. nl2data_semantic_catalog_postgres/sql.py +520 -0
  29. nl2data_semantic_catalog_postgres/store.py +716 -0
  30. nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
  31. nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
  32. nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
  33. nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
  34. nl2data_semantic_catalog_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,188 @@
1
+ """Normalized safe errors for the PostgreSQL semantic catalog.
2
+
3
+ Backend failures are converted into deterministic, serializable error
4
+ records. Records carry only a stable code, category, bounded message, and
5
+ redacted details - never DSNs, credentials, raw backend exception text, or
6
+ native driver objects. Unavailability and timeouts are retryable; schema
7
+ mismatches, conflicts, authorization failures, envelope rejections,
8
+ fingerprint mismatches, and bound violations are not, because retrying them
9
+ without a host decision cannot succeed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from enum import StrEnum
16
+ from typing import Any
17
+
18
+ from nl2data._redact import REDACTED_VALUE, redact_key_value
19
+ from nl2data.errors import ErrorCategory, ErrorCode, ErrorRecord
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
21
+
22
+ from .envelope import EnvelopeRejectedError
23
+
24
+
25
+ class SemanticCatalogErrorCode(StrEnum):
26
+ """Stable machine-readable catalog error codes."""
27
+
28
+ CATALOG_UNAVAILABLE = "CATALOG_UNAVAILABLE"
29
+ CATALOG_TIMEOUT = "CATALOG_TIMEOUT"
30
+ SCHEMA_MISMATCH = "SCHEMA_MISMATCH"
31
+ CONFLICT = "CONFLICT"
32
+ UNAUTHORIZED = "UNAUTHORIZED"
33
+ ENVELOPE_REJECTED = "ENVELOPE_REJECTED"
34
+ FINGERPRINT_MISMATCH = "FINGERPRINT_MISMATCH"
35
+ BOUNDS_EXCEEDED = "BOUNDS_EXCEEDED"
36
+
37
+
38
+ #: Codes that are safe to retry without changing the invocation.
39
+ _RETRYABLE_CODES = frozenset(
40
+ {
41
+ SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
42
+ SemanticCatalogErrorCode.CATALOG_TIMEOUT,
43
+ }
44
+ )
45
+
46
+ #: Mapping onto the stable public error contract for public outcomes.
47
+ _PUBLIC_CODE: dict[SemanticCatalogErrorCode, ErrorCode] = {
48
+ SemanticCatalogErrorCode.CATALOG_UNAVAILABLE: ErrorCode.STORE_UNAVAILABLE,
49
+ SemanticCatalogErrorCode.CATALOG_TIMEOUT: ErrorCode.STORE_TIMEOUT,
50
+ SemanticCatalogErrorCode.SCHEMA_MISMATCH: ErrorCode.UNSUPPORTED_SCHEMA_VERSION,
51
+ SemanticCatalogErrorCode.CONFLICT: ErrorCode.INVALID_TRANSITION,
52
+ SemanticCatalogErrorCode.UNAUTHORIZED: ErrorCode.METADATA_UNAUTHORIZED,
53
+ SemanticCatalogErrorCode.ENVELOPE_REJECTED: ErrorCode.INVALID_INPUT,
54
+ SemanticCatalogErrorCode.FINGERPRINT_MISMATCH: ErrorCode.INVALID_INPUT,
55
+ SemanticCatalogErrorCode.BOUNDS_EXCEEDED: ErrorCode.METADATA_BOUNDS_EXCEEDED,
56
+ }
57
+
58
+
59
+ class SemanticCatalogError(Exception):
60
+ """Structured catalog failure; safe to serialize.
61
+
62
+ The original driver exception may be retained for debugging but is
63
+ never part of the serialized record and never leaks through ``str``.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ code: SemanticCatalogErrorCode | str,
69
+ message: str,
70
+ *,
71
+ details: Mapping[str, Any] | None = None,
72
+ cause: BaseException | None = None,
73
+ ) -> None:
74
+ super().__init__(message)
75
+ self.code = SemanticCatalogErrorCode(code)
76
+ self.message = message
77
+ self.details = dict(details or {})
78
+ self.cause = cause
79
+
80
+ @property
81
+ def category(self) -> ErrorCategory:
82
+ return ErrorCategory.ADAPTER
83
+
84
+ @property
85
+ def retryable(self) -> bool:
86
+ return self.code in _RETRYABLE_CODES
87
+
88
+ def safe_details(self) -> dict[str, str]:
89
+ """Scalar-only details; anything unsafe is replaced with a marker."""
90
+ return {str(k): redact_key_value(k, v) for k, v in self.details.items()}
91
+
92
+ def to_record(self) -> SemanticCatalogErrorRecord:
93
+ """Convert to the transport-neutral safe catalog error record."""
94
+ return SemanticCatalogErrorRecord(
95
+ code=self.code,
96
+ message=self.message,
97
+ retryable=self.retryable,
98
+ details=self.safe_details(),
99
+ cause_type=type(self.cause).__name__ if self.cause is not None else None,
100
+ )
101
+
102
+ def to_public_record(self) -> ErrorRecord:
103
+ """Convert to the public error contract used by host outcomes."""
104
+ return ErrorRecord(
105
+ code=_PUBLIC_CODE[self.code],
106
+ category=self.category,
107
+ message=self.message,
108
+ retryable=self.retryable,
109
+ details=self.safe_details(),
110
+ cause_type=type(self).__name__,
111
+ )
112
+
113
+
114
+ class SemanticCatalogErrorRecord(BaseModel):
115
+ """Immutable normalized catalog error with redacted details only."""
116
+
117
+ model_config = ConfigDict(frozen=True, extra="forbid")
118
+
119
+ code: SemanticCatalogErrorCode
120
+ message: str = Field(min_length=1, max_length=2000)
121
+ retryable: bool = False
122
+ details: dict[str, str] = Field(default_factory=dict, max_length=64)
123
+ cause_type: str | None = Field(default=None, max_length=256)
124
+
125
+ @field_validator("details")
126
+ @classmethod
127
+ def _sanitize_details(cls, value: Mapping[str, Any]) -> dict[str, str]:
128
+ sanitized: dict[str, str] = {}
129
+ for key, raw in value.items():
130
+ sanitized[key] = redact_key_value(key, raw)
131
+ return sanitized
132
+
133
+ def safe_dump(self) -> dict[str, Any]:
134
+ """Serialize with only stable, redacted fields - never raw backend text."""
135
+ return self.model_dump()
136
+
137
+
138
+ def normalize_catalog_error(error: BaseException) -> SemanticCatalogError:
139
+ """Convert any exception into a normalized, safe catalog error.
140
+
141
+ Envelope rejections keep their bounded reason code; known driver failure
142
+ names become retryable unavailability/timeout errors; unknown exception
143
+ types become retryable unavailability errors with a redacted message so
144
+ backend internals never leak across the boundary.
145
+ """
146
+ if isinstance(error, SemanticCatalogError):
147
+ return error
148
+ if isinstance(error, EnvelopeRejectedError):
149
+ return SemanticCatalogError(
150
+ SemanticCatalogErrorCode.ENVELOPE_REJECTED,
151
+ "catalog artifact was rejected by safe envelope validation",
152
+ details={"reason": error.code, "cause_type": type(error).__name__},
153
+ cause=error,
154
+ )
155
+ name = type(error).__name__
156
+ if name in {"TimeoutError", "QueryCanceledError", "QueryCanceled", "Timeout"}:
157
+ return SemanticCatalogError(
158
+ SemanticCatalogErrorCode.CATALOG_TIMEOUT,
159
+ "catalog backend command timed out",
160
+ details={"cause_type": name},
161
+ cause=error,
162
+ )
163
+ if name in {
164
+ "OperationalError",
165
+ "InterfaceError",
166
+ "ConnectionError",
167
+ "PoolTimeout",
168
+ "PoolClosed",
169
+ }:
170
+ return SemanticCatalogError(
171
+ SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
172
+ "catalog backend is unreachable",
173
+ details={"cause_type": name},
174
+ cause=error,
175
+ )
176
+ if name in {"UniqueViolation", "IntegrityError", "SerializationFailure"}:
177
+ return SemanticCatalogError(
178
+ SemanticCatalogErrorCode.CONFLICT,
179
+ "catalog backend rejected a conflicting record",
180
+ details={"cause_type": name},
181
+ cause=error,
182
+ )
183
+ return SemanticCatalogError(
184
+ SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
185
+ REDACTED_VALUE,
186
+ details={"cause_type": name},
187
+ cause=error,
188
+ )
@@ -0,0 +1,53 @@
1
+ """In-memory PostgreSQL pool for the durable semantic catalog.
2
+
3
+ Executes the exact statement set produced by :data:`SQL_TEMPLATES` against
4
+ plain Python dictionaries, with per-connection transactions, row locks
5
+ (``FOR UPDATE`` / conflict waits), a mutable server clock driving ``NOW()``,
6
+ and failure injection so catalog behavior under backend outages can be
7
+ tested without a real database or the optional driver.
8
+
9
+ The fake mirrors the semantics the store relies on:
10
+
11
+ - every ``connection()`` checkout is one transaction; ``commit`` publishes
12
+ mutations, ``rollback`` restores the pre-transaction values of touched
13
+ rows;
14
+ - rows locked by one connection block conflicting statements from another
15
+ connection (bounded by the statement timeout), matching PostgreSQL row
16
+ locking closely enough for concurrency tests;
17
+ - ``NOW()`` always reads the shared :class:`FakeClock`, so retention expiry
18
+ and cleanup can be advanced deterministically;
19
+ - injected failure exceptions surface with the same class names the lazy
20
+ driver boundary classifies (``OperationalError``, ``TimeoutError``,
21
+ ``UniqueViolation``, ``SerializationFailure``), so the store's error
22
+ normalization path is exercised for real.
23
+
24
+ Unrecognized statements fail loudly with ``AssertionError`` so template
25
+ drift between the store and the fake is caught at the first test run.
26
+
27
+ The pool is split by repository domain: shared driver, lock/key, and
28
+ transaction infrastructure live in :mod:`.driver`, :mod:`.keys`, and
29
+ :mod:`.pool`; statement handlers live in one module per repository domain
30
+ and are wired together in :mod:`.registry`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from .driver import (
36
+ FakeClock,
37
+ OperationalError,
38
+ PoolClosed,
39
+ SerializationFailure,
40
+ TimeoutError,
41
+ UniqueViolation,
42
+ )
43
+ from .pool import FakePostgresPool
44
+
45
+ __all__ = [
46
+ "FakeClock",
47
+ "FakePostgresPool",
48
+ "OperationalError",
49
+ "PoolClosed",
50
+ "SerializationFailure",
51
+ "TimeoutError",
52
+ "UniqueViolation",
53
+ ]
@@ -0,0 +1,209 @@
1
+ """Shared fake-driver infrastructure: exceptions, clock, transactions.
2
+
3
+ The fake driver exception classes intentionally match the real psycopg
4
+ class names so the store's class-name-first error classification works.
5
+ Connections are one transaction each: mutations are published on commit
6
+ and undone on rollback from the pre-transaction snapshots taken by
7
+ ``_touch``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import builtins
13
+ import contextlib
14
+ import re
15
+ import time
16
+ from collections.abc import Iterator
17
+ from datetime import UTC, datetime, timedelta
18
+ from typing import TYPE_CHECKING, Any
19
+ from uuid import uuid4
20
+
21
+ if TYPE_CHECKING:
22
+ from .pool import FakePostgresPool
23
+
24
+ #: Lock-key kind -> pool attribute holding the backing table (per instance).
25
+ # Lives here (its only consumer) instead of keys.py so the driver->keys
26
+ # import edge does not close a cycle with keys' TYPE_CHECKING driver import.
27
+ _TABLE_ATTRS: dict[str, str] = {
28
+ "snapshots": "snapshots",
29
+ "snapshot_pointers": "snapshot_pointers",
30
+ "proposal_sets": "proposal_sets",
31
+ "assembly_drafts": "assembly_drafts",
32
+ "publications": "publications",
33
+ "accepted_manifests": "accepted_manifests",
34
+ "verification_evidence": "verification_evidence",
35
+ "publish_audits": "publish_audits",
36
+ "published_versions": "published_versions",
37
+ "supersession_edges": "supersession_edges",
38
+ "bundle_pointers": "bundle_pointers",
39
+ "bundle_history": "bundle_history",
40
+ "events": "events",
41
+ "publication_audit_evidence": "publication_audit_evidence",
42
+ "audit_entries": "audit_entries",
43
+ }
44
+
45
+
46
+ class OperationalError(Exception):
47
+ """Fake ``psycopg.OperationalError``: connection-level failure."""
48
+
49
+
50
+ class TimeoutError(builtins.TimeoutError): # noqa: A001 - fake driver class by design
51
+ """Fake ``psycopg.errors.QueryCanceled``: statement timed out."""
52
+
53
+
54
+ class UniqueViolation(Exception):
55
+ """Fake ``psycopg.errors.UniqueViolation``: unique-key conflict."""
56
+
57
+
58
+ class SerializationFailure(Exception):
59
+ """Fake ``psycopg.errors.SerializationFailure``: retryable conflict."""
60
+
61
+
62
+ class PoolClosed(Exception):
63
+ """Fake ``psycopg_pool.PoolClosed``: pool was closed."""
64
+
65
+
66
+ class _PassFailure:
67
+ """Internal marker: a statement slot that succeeds before a failure."""
68
+
69
+
70
+ _SCHEMA_TOKEN = re.compile(r'"[A-Za-z0-9_]+"')
71
+
72
+
73
+ def _normalize(sql: str) -> str:
74
+ """Collapse whitespace and mask the quoted schema for template matching."""
75
+ return _SCHEMA_TOKEN.sub('"S"', " ".join(sql.split()))
76
+
77
+
78
+ def _as_dt(value: Any) -> datetime | None:
79
+ """Accept datetimes or ISO strings (as the store sends them)."""
80
+ if value is None or isinstance(value, datetime):
81
+ return value
82
+ return datetime.fromisoformat(str(value))
83
+
84
+
85
+ def _monotonic() -> float:
86
+ return time.monotonic()
87
+
88
+
89
+ class FakeClock:
90
+ """Mutable deterministic clock standing in for PostgreSQL ``NOW()``."""
91
+
92
+ def __init__(self, start: datetime | None = None) -> None:
93
+ self._now = start or datetime(2026, 1, 1, tzinfo=UTC)
94
+
95
+ def now(self) -> datetime:
96
+ """The current fake server time (timezone-aware, UTC)."""
97
+ return self._now
98
+
99
+ def advance(self, seconds: float) -> None:
100
+ """Move the clock forward by ``seconds`` (may be negative)."""
101
+ self._now += timedelta(seconds=seconds)
102
+
103
+ def set(self, value: datetime) -> None:
104
+ """Pin the clock to an exact instant (tests seed expiry)."""
105
+ self._now = value
106
+
107
+
108
+ class _FakeConnection:
109
+ """One transaction; mutations are published on commit, undone on rollback."""
110
+
111
+ def __init__(self, pool: FakePostgresPool) -> None:
112
+ self._pool = pool
113
+ self._active = False
114
+ self._touched: dict[tuple[Any, ...], Any] = {}
115
+ self._owned_keys: set[tuple[Any, ...]] = set()
116
+ self.id = f"conn-{uuid4().hex[:12]}"
117
+
118
+ def cursor(self) -> _FakeCursor:
119
+ return _FakeCursor(self._pool, self, [], 0)
120
+
121
+ def execute(
122
+ self, sql: str, params: tuple[Any, ...] = (), timeout: float = 30.0
123
+ ) -> _FakeCursor:
124
+ return self._pool._execute(self, sql, params, timeout)
125
+
126
+ def commit(self) -> None:
127
+ """Publish the transaction; touched rows become visible to all."""
128
+ self._active = False
129
+ self._touched.clear()
130
+ self._pool._release(self)
131
+
132
+ def rollback(self) -> None:
133
+ """Undo every mutation this transaction made."""
134
+ if not self._active:
135
+ self._pool._release(self)
136
+ return
137
+ for key, previous in self._touched.items():
138
+ table = getattr(self._pool, _TABLE_ATTRS[key[0]])
139
+ table_key = key[1:]
140
+ if previous is None:
141
+ table.pop(table_key, None)
142
+ else:
143
+ table[table_key] = previous
144
+ self._active = False
145
+ self._touched.clear()
146
+ self._pool._release(self)
147
+
148
+ def _finish(self) -> None:
149
+ """Context-manager exit: drop any uncommitted transaction."""
150
+ if self._active:
151
+ self.rollback()
152
+ self._pool._release(self)
153
+
154
+ def _touch(self, key: tuple[Any, ...]) -> None:
155
+ """Remember the pre-transaction value of a key before mutating it."""
156
+ if key in self._touched:
157
+ return
158
+ table = getattr(self._pool, _TABLE_ATTRS[key[0]])
159
+ value = table.get(key[1:])
160
+ if isinstance(value, dict):
161
+ # Snapshot copies so later in-place updates stay reversible.
162
+ value = {
163
+ sub_key: dict(sub_row) if isinstance(sub_row, dict) else sub_row
164
+ for sub_key, sub_row in value.items()
165
+ }
166
+ self._touched[key] = value
167
+ self._active = True
168
+
169
+
170
+ class _FakeCursor:
171
+ """Cursor-shaped result of one executed statement."""
172
+
173
+ def __init__(
174
+ self,
175
+ pool: FakePostgresPool,
176
+ connection: _FakeConnection,
177
+ rows: list[dict[str, Any]],
178
+ rowcount: int,
179
+ ) -> None:
180
+ self._pool = pool
181
+ self._connection = connection
182
+ self._rows = list(rows)
183
+ self.rowcount = rowcount
184
+ self.timeout: float = 30.0
185
+
186
+ def execute(
187
+ self, sql: str, params: tuple[Any, ...] = ()
188
+ ) -> _FakeCursor:
189
+ return self._pool._execute(self._connection, sql, params, self.timeout)
190
+
191
+ def fetchone(self) -> dict[str, Any] | None:
192
+ return self._rows.pop(0) if self._rows else None
193
+
194
+ def fetchall(self) -> list[dict[str, Any]]:
195
+ rows, self._rows = self._rows, []
196
+ return rows
197
+
198
+
199
+ @contextlib.contextmanager
200
+ def _connection(pool: FakePostgresPool) -> Iterator[_FakeConnection]:
201
+ """One transaction-backed connection; ``commit``/``rollback`` explicit."""
202
+ with pool._struct_lock:
203
+ if pool._closed:
204
+ raise PoolClosed("fake pool is closed")
205
+ connection = _FakeConnection(pool)
206
+ try:
207
+ yield connection
208
+ finally:
209
+ connection._finish()
@@ -0,0 +1,243 @@
1
+ """Assembly audit-evidence statement handlers for the fake pool.
2
+
3
+ Writers lock the key slot before checking existence so concurrent writers
4
+ serialize like the real unique indexes. The bounded trail handlers mirror
5
+ the real SQL's optional-filter and keyset-cursor semantics exactly: an
6
+ unbound equality filter matches every row (the SQL guard is
7
+ ``(%s IS NULL OR col = %s)``), and the cursor keyset resumes strictly
8
+ after ``(occurred_at, event_id)``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from .driver import UniqueViolation, _as_dt
17
+ from .keys import (
18
+ _audit_entry_key,
19
+ _lock_or_fail,
20
+ _publication_audit_evidence_key,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from .driver import _FakeConnection
25
+ from .pool import FakePostgresPool
26
+
27
+
28
+ def _h_insert_publication_audit_evidence(
29
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
30
+ ) -> tuple[list[dict[str, Any]], int]:
31
+ key = _publication_audit_evidence_key(params[0], params[1], params[2])
32
+ _lock_or_fail(pool, conn, key, timeout)
33
+ if key[1:] in pool.publication_audit_evidence:
34
+ raise UniqueViolation("publication audit evidence already exists")
35
+ if any(
36
+ row["scope_namespace"] == params[0]
37
+ and row["evidence_fingerprint"] == params[3]
38
+ for row in pool.publication_audit_evidence.values()
39
+ ):
40
+ raise UniqueViolation("publication audit evidence fingerprint already exists")
41
+ conn._touch(key)
42
+ pool.publication_audit_evidence[key[1:]] = {
43
+ "scope_namespace": params[0],
44
+ "bundle_id": params[1],
45
+ "bundle_fingerprint": params[2],
46
+ "evidence_fingerprint": params[3],
47
+ "schema_version": params[4],
48
+ "envelope": params[5],
49
+ "created_at": _as_dt(params[6]),
50
+ }
51
+ return ([], 1)
52
+
53
+
54
+ def _h_read_publication_audit_evidence(
55
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
56
+ ) -> tuple[list[dict[str, Any]], int]:
57
+ row = pool.publication_audit_evidence.get(params)
58
+ if row is None:
59
+ return ([], 0)
60
+ return (
61
+ [
62
+ {
63
+ "evidence_fingerprint": row["evidence_fingerprint"],
64
+ "envelope": row["envelope"],
65
+ "schema_version": row["schema_version"],
66
+ }
67
+ ],
68
+ 0,
69
+ )
70
+
71
+
72
+ def _h_insert_audit_entry(
73
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
74
+ ) -> tuple[list[dict[str, Any]], int]:
75
+ namespace, event_id = params[0], params[1]
76
+ key = _audit_entry_key(namespace, event_id)
77
+ _lock_or_fail(pool, conn, key, timeout)
78
+ if (namespace, event_id) in pool.audit_entries:
79
+ return ([], 0) # ON CONFLICT (scope_namespace, event_id) DO NOTHING
80
+ conn._touch(key)
81
+ pool.audit_entries[(namespace, event_id)] = {
82
+ "scope_namespace": namespace,
83
+ "event_id": event_id,
84
+ "event_kind": params[2],
85
+ "subject_kind": params[3],
86
+ "subject_reference": params[4],
87
+ "draft_id": params[5],
88
+ "draft_revision": params[6],
89
+ "assertion_id": params[7],
90
+ "bundle_fingerprint": params[8],
91
+ "lifecycle_reference": params[9],
92
+ "entry_fingerprint": params[10],
93
+ "schema_version": params[11],
94
+ "envelope": params[12],
95
+ "occurred_at": _as_dt(params[13]),
96
+ }
97
+ return ([], 1)
98
+
99
+
100
+ def _h_read_audit_entry(
101
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
102
+ ) -> tuple[list[dict[str, Any]], int]:
103
+ row = pool.audit_entries.get(params)
104
+ if row is None:
105
+ return ([], 0)
106
+ return (
107
+ [
108
+ {
109
+ "entry_fingerprint": row["entry_fingerprint"],
110
+ "envelope": row["envelope"],
111
+ "schema_version": row["schema_version"],
112
+ "occurred_at": row["occurred_at"],
113
+ }
114
+ ],
115
+ 0,
116
+ )
117
+
118
+
119
+ def _h_read_publication_audit_entry(
120
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
121
+ ) -> tuple[list[dict[str, Any]], int]:
122
+ namespace, lifecycle_reference = params
123
+ for (scope, event_id), row in pool.audit_entries.items():
124
+ if (
125
+ scope == namespace
126
+ and row["lifecycle_reference"] == lifecycle_reference
127
+ and row["event_kind"] == "publication"
128
+ ):
129
+ return ([{"event_id": event_id}], 0)
130
+ return ([], 0)
131
+
132
+
133
+ def _h_read_latest_publication_entry(
134
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
135
+ ) -> tuple[list[dict[str, Any]], int]:
136
+ namespace, bundle_fingerprint = params
137
+ matches = [
138
+ (row["occurred_at"], event_id)
139
+ for (scope, event_id), row in pool.audit_entries.items()
140
+ if (
141
+ scope == namespace
142
+ and row["bundle_fingerprint"] == bundle_fingerprint
143
+ and row["event_kind"] == "publication"
144
+ )
145
+ ]
146
+ if not matches:
147
+ return ([], 0)
148
+ matches.sort(reverse=True)
149
+ return ([{"event_id": matches[0][1]}], 0)
150
+
151
+
152
+ def _audit_entry_matches(
153
+ row: dict[str, Any], event_id: str, params: tuple[Any, ...]
154
+ ) -> bool:
155
+ """Mirror the shared ``count_audit_evidence``/``list_audit_evidence`` WHERE."""
156
+ (
157
+ _namespace,
158
+ draft_id,
159
+ _draft_id,
160
+ assertion_id,
161
+ _assertion_id,
162
+ bundle_fingerprint,
163
+ _bundle_fingerprint,
164
+ lifecycle_reference,
165
+ _lifecycle_reference,
166
+ revision_min,
167
+ _revision_min,
168
+ revision_max,
169
+ _revision_max,
170
+ predecessor_event_id,
171
+ _predecessor,
172
+ cursor_at,
173
+ _cursor_at,
174
+ cursor_id,
175
+ ) = params
176
+ if draft_id is not None and row["draft_id"] != draft_id:
177
+ return False
178
+ if assertion_id is not None and row["assertion_id"] != assertion_id:
179
+ return False
180
+ if bundle_fingerprint is not None and row["bundle_fingerprint"] != bundle_fingerprint:
181
+ return False
182
+ if lifecycle_reference is not None and row["lifecycle_reference"] != lifecycle_reference:
183
+ return False
184
+ revision = row["draft_revision"]
185
+ if revision_min is not None and (revision is None or int(revision) < int(revision_min)):
186
+ return False
187
+ if revision_max is not None and (revision is None or int(revision) > int(revision_max)):
188
+ return False
189
+ if predecessor_event_id is not None:
190
+ envelope = json.loads(row["envelope"])
191
+ predecessors = (
192
+ (envelope.get("payload") or {}).get("predecessor_event_ids") or []
193
+ )
194
+ if predecessor_event_id not in predecessors:
195
+ return False
196
+ if cursor_at is not None:
197
+ position = (row["occurred_at"], event_id)
198
+ if position <= (_as_dt(cursor_at), cursor_id):
199
+ return False
200
+ return True
201
+
202
+
203
+ def _h_count_audit_evidence(
204
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
205
+ ) -> tuple[list[dict[str, Any]], int]:
206
+ namespace = params[0]
207
+ total = sum(
208
+ 1
209
+ for (scope, event_id), row in pool.audit_entries.items()
210
+ if scope == namespace and _audit_entry_matches(row, event_id, params)
211
+ )
212
+ return ([{"total": total}], 0)
213
+
214
+
215
+ def _h_list_audit_evidence(
216
+ pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
217
+ ) -> tuple[list[dict[str, Any]], int]:
218
+ namespace, limit = params[0], int(params[-1])
219
+ matches = [
220
+ (
221
+ row["occurred_at"],
222
+ event_id,
223
+ row["entry_fingerprint"],
224
+ row["envelope"],
225
+ row["schema_version"],
226
+ )
227
+ for (scope, event_id), row in pool.audit_entries.items()
228
+ if scope == namespace
229
+ and _audit_entry_matches(row, event_id, params[:-1])
230
+ ]
231
+ matches.sort()
232
+ rows = [
233
+ {
234
+ "event_id": event_id,
235
+ "entry_fingerprint": entry_fingerprint,
236
+ "envelope": envelope,
237
+ "schema_version": schema_version,
238
+ "occurred_at": occurred_at,
239
+ }
240
+ for occurred_at, event_id, entry_fingerprint, envelope, schema_version
241
+ in matches[:limit]
242
+ ]
243
+ return (rows, len(rows))