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.
- nl2data_semantic_catalog_postgres/__init__.py +18 -0
- nl2data_semantic_catalog_postgres/client.py +139 -0
- nl2data_semantic_catalog_postgres/config.py +136 -0
- nl2data_semantic_catalog_postgres/envelope.py +327 -0
- nl2data_semantic_catalog_postgres/errors.py +188 -0
- nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
- nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
- nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
- nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
- nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
- nl2data_semantic_catalog_postgres/maintenance.py +235 -0
- nl2data_semantic_catalog_postgres/py.typed +0 -0
- nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
- nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
- nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
- nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
- nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
- nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
- nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
- nl2data_semantic_catalog_postgres/schema.py +337 -0
- nl2data_semantic_catalog_postgres/sql.py +520 -0
- nl2data_semantic_catalog_postgres/store.py +716 -0
- nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Assembly audit-evidence trail repository over the shared unit of work.
|
|
2
|
+
|
|
3
|
+
Entries persist as bounded canonical envelopes whose fingerprint excludes
|
|
4
|
+
the presentation ``occurred_at`` timestamp, so the row column is applied
|
|
5
|
+
after reconstruction and the recomputed fingerprint is the tamper witness.
|
|
6
|
+
Every lookup is scoped by the opaque tenant namespace and returns a
|
|
7
|
+
deterministic, bounded trail page ordered by ``(occurred_at, event_id)``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from nl2data_core.assembly.audit_evidence import (
|
|
16
|
+
MAX_TRAIL_ENTRIES,
|
|
17
|
+
AssemblyAuditEvidenceEntry,
|
|
18
|
+
AuditTrail,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ..envelope import ENVELOPE_SCHEMA_VERSION, ArtifactKind
|
|
22
|
+
from ..errors import SemanticCatalogError, SemanticCatalogErrorCode
|
|
23
|
+
from ..unit_of_work import CatalogUnitOfWork, _namespace
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuditEvidenceRepository:
|
|
27
|
+
"""Bounded, tenant-scoped audit-evidence trail persistence."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, uow: CatalogUnitOfWork) -> None:
|
|
30
|
+
self._uow = uow
|
|
31
|
+
|
|
32
|
+
def insert_audit_entries(
|
|
33
|
+
self,
|
|
34
|
+
conn: Any,
|
|
35
|
+
namespace: str,
|
|
36
|
+
entries: Sequence[AssemblyAuditEvidenceEntry],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Persist entries inside the caller's transaction; idempotent by id.
|
|
39
|
+
|
|
40
|
+
A conflicting re-record (same event id, different fingerprint) is
|
|
41
|
+
corruption and fails closed instead of silently no-opping.
|
|
42
|
+
"""
|
|
43
|
+
for entry in entries:
|
|
44
|
+
existing = self._uow.execute(
|
|
45
|
+
conn, "read_audit_entry", (namespace, entry.event_id)
|
|
46
|
+
).fetchone()
|
|
47
|
+
if existing is not None:
|
|
48
|
+
if existing["entry_fingerprint"] != entry.fingerprint:
|
|
49
|
+
raise SemanticCatalogError(
|
|
50
|
+
SemanticCatalogErrorCode.ENVELOPE_REJECTED,
|
|
51
|
+
"audit-evidence event id conflict",
|
|
52
|
+
details={"cause_type": "AuditEvidenceEventIdConflict"},
|
|
53
|
+
)
|
|
54
|
+
continue
|
|
55
|
+
self._uow.execute(
|
|
56
|
+
conn,
|
|
57
|
+
"insert_audit_entry",
|
|
58
|
+
(
|
|
59
|
+
namespace,
|
|
60
|
+
entry.event_id,
|
|
61
|
+
entry.event_kind.value,
|
|
62
|
+
entry.subject_kind.value,
|
|
63
|
+
entry.subject_reference,
|
|
64
|
+
entry.draft_id,
|
|
65
|
+
entry.draft_revision,
|
|
66
|
+
entry.assertion_id,
|
|
67
|
+
entry.bundle_fingerprint,
|
|
68
|
+
entry.lifecycle_reference,
|
|
69
|
+
entry.fingerprint,
|
|
70
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
71
|
+
self._uow.encode(
|
|
72
|
+
ArtifactKind.ASSEMBLY_AUDIT_EVIDENCE,
|
|
73
|
+
entry.canonical_payload(),
|
|
74
|
+
entry.fingerprint,
|
|
75
|
+
),
|
|
76
|
+
entry.occurred_at,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def record_audit_entries(
|
|
81
|
+
self,
|
|
82
|
+
entries: Sequence[AssemblyAuditEvidenceEntry],
|
|
83
|
+
*,
|
|
84
|
+
tenant_scope_fingerprint: str,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Record externally supplied entries under one tenant scope."""
|
|
87
|
+
for entry in entries:
|
|
88
|
+
if entry.tenant_scope_fingerprint != tenant_scope_fingerprint:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"audit evidence entries must match the tenant scope"
|
|
91
|
+
)
|
|
92
|
+
if not entry.verify_fingerprint():
|
|
93
|
+
raise ValueError("audit evidence entry fingerprint mismatch")
|
|
94
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
95
|
+
with self._uow.transaction() as conn:
|
|
96
|
+
self.insert_audit_entries(conn, namespace, entries)
|
|
97
|
+
|
|
98
|
+
def find_publication_entry(
|
|
99
|
+
self,
|
|
100
|
+
conn: Any,
|
|
101
|
+
namespace: str,
|
|
102
|
+
bundle_fingerprint: str,
|
|
103
|
+
) -> AssemblyAuditEvidenceEntry | None:
|
|
104
|
+
"""The latest publication-kind entry recorded for one fingerprint."""
|
|
105
|
+
row = self._uow.execute(
|
|
106
|
+
conn,
|
|
107
|
+
"read_latest_publication_entry",
|
|
108
|
+
(namespace, bundle_fingerprint),
|
|
109
|
+
).fetchone()
|
|
110
|
+
if row is None:
|
|
111
|
+
return None
|
|
112
|
+
return self.read_audit_entry(conn, namespace, row["event_id"])
|
|
113
|
+
|
|
114
|
+
def read_audit_entry(
|
|
115
|
+
self,
|
|
116
|
+
conn: Any,
|
|
117
|
+
namespace: str,
|
|
118
|
+
event_id: str,
|
|
119
|
+
) -> AssemblyAuditEvidenceEntry | None:
|
|
120
|
+
"""Load one entry inside an existing transaction, revalidated."""
|
|
121
|
+
row = self._uow.execute(
|
|
122
|
+
conn, "read_audit_entry", (namespace, event_id)
|
|
123
|
+
).fetchone()
|
|
124
|
+
if row is None:
|
|
125
|
+
return None
|
|
126
|
+
envelope = self._uow.decode(
|
|
127
|
+
row["envelope"],
|
|
128
|
+
ArtifactKind.ASSEMBLY_AUDIT_EVIDENCE,
|
|
129
|
+
row_schema_version=row["schema_version"],
|
|
130
|
+
)
|
|
131
|
+
return self._uow.audit_entry_from_envelope(
|
|
132
|
+
envelope,
|
|
133
|
+
occurred_at=row["occurred_at"],
|
|
134
|
+
entry_fingerprint=row["entry_fingerprint"],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def audit_entries(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
tenant_scope_fingerprint: str | None = None,
|
|
141
|
+
draft_id: str | None = None,
|
|
142
|
+
draft_revision_min: int | None = None,
|
|
143
|
+
draft_revision_max: int | None = None,
|
|
144
|
+
assertion_id: str | None = None,
|
|
145
|
+
bundle_fingerprint: str | None = None,
|
|
146
|
+
lifecycle_reference: str | None = None,
|
|
147
|
+
predecessor_event_id: str | None = None,
|
|
148
|
+
limit: int = MAX_TRAIL_ENTRIES,
|
|
149
|
+
cursor: str | None = None,
|
|
150
|
+
) -> AuditTrail:
|
|
151
|
+
"""One deterministic, bounded trail page under the requested scope."""
|
|
152
|
+
if limit < 1 or limit > MAX_TRAIL_ENTRIES:
|
|
153
|
+
raise ValueError("audit trail limit must be between 1 and the maximum")
|
|
154
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
155
|
+
# The cursor is the last returned event id; its persisted position
|
|
156
|
+
# keys the deterministic page continuation. A pruned or unknown
|
|
157
|
+
# cursor restarts from the beginning rather than failing.
|
|
158
|
+
cursor_at: Any = None
|
|
159
|
+
cursor_id: str | None = None
|
|
160
|
+
if cursor is not None:
|
|
161
|
+
with self._uow.transaction() as conn:
|
|
162
|
+
row = self._uow.execute(
|
|
163
|
+
conn, "read_audit_entry", (namespace, cursor)
|
|
164
|
+
).fetchone()
|
|
165
|
+
if row is not None:
|
|
166
|
+
cursor_at = row["occurred_at"]
|
|
167
|
+
cursor_id = cursor
|
|
168
|
+
# Each optional equality filter is passed twice, mirroring the
|
|
169
|
+
# ``(%s IS NULL OR col = %s)`` guard: an unbound filter never
|
|
170
|
+
# compares a column against itself, so rows with NULL columns stay
|
|
171
|
+
# visible exactly like the in-memory trail.
|
|
172
|
+
filters = (
|
|
173
|
+
namespace,
|
|
174
|
+
draft_id,
|
|
175
|
+
draft_id,
|
|
176
|
+
assertion_id,
|
|
177
|
+
assertion_id,
|
|
178
|
+
bundle_fingerprint,
|
|
179
|
+
bundle_fingerprint,
|
|
180
|
+
lifecycle_reference,
|
|
181
|
+
lifecycle_reference,
|
|
182
|
+
draft_revision_min,
|
|
183
|
+
draft_revision_min,
|
|
184
|
+
draft_revision_max,
|
|
185
|
+
draft_revision_max,
|
|
186
|
+
predecessor_event_id,
|
|
187
|
+
predecessor_event_id,
|
|
188
|
+
cursor_at,
|
|
189
|
+
cursor_at,
|
|
190
|
+
cursor_id,
|
|
191
|
+
)
|
|
192
|
+
with self._uow.transaction() as conn:
|
|
193
|
+
count_row = self._uow.execute(
|
|
194
|
+
conn, "count_audit_evidence", filters
|
|
195
|
+
).fetchone()
|
|
196
|
+
total = int(count_row["total"])
|
|
197
|
+
rows = self._uow.execute(
|
|
198
|
+
conn, "list_audit_evidence", (*filters, limit)
|
|
199
|
+
).fetchall()
|
|
200
|
+
page = [
|
|
201
|
+
self._uow.audit_entry_from_envelope(
|
|
202
|
+
self._uow.decode(
|
|
203
|
+
row["envelope"],
|
|
204
|
+
ArtifactKind.ASSEMBLY_AUDIT_EVIDENCE,
|
|
205
|
+
row_schema_version=row["schema_version"],
|
|
206
|
+
),
|
|
207
|
+
occurred_at=row["occurred_at"],
|
|
208
|
+
entry_fingerprint=row["entry_fingerprint"],
|
|
209
|
+
)
|
|
210
|
+
for row in rows
|
|
211
|
+
]
|
|
212
|
+
has_more = total > len(page)
|
|
213
|
+
return AuditTrail(
|
|
214
|
+
entries=tuple(page),
|
|
215
|
+
total_count=total,
|
|
216
|
+
next_cursor=(page[-1].event_id if page and has_more else None),
|
|
217
|
+
has_more=has_more,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["AuditEvidenceRepository"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Assembly draft persistence repository.
|
|
2
|
+
|
|
3
|
+
Drafts are mutable control-plane records: creates are idempotent-failing,
|
|
4
|
+
reads revalidate the persisted envelope against the draft identifier, and
|
|
5
|
+
replacements are optimistic-concurrency (revision CAS) operations. The
|
|
6
|
+
repository never participates in publication transactions beyond exposing
|
|
7
|
+
the draft-lock reads the publication repository performs under the
|
|
8
|
+
publication transaction.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from nl2data_core.assembly.models import AssemblyDraft, DraftRevisionConflict
|
|
14
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
15
|
+
from nl2data_core.control_plane.publication.contracts import PublicationDraftBinding
|
|
16
|
+
|
|
17
|
+
from ..envelope import ENVELOPE_SCHEMA_VERSION, ArtifactKind
|
|
18
|
+
from ..errors import SemanticCatalogError, SemanticCatalogErrorCode
|
|
19
|
+
from ..unit_of_work import CatalogUnitOfWork, _namespace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DraftRepository:
|
|
23
|
+
"""Tenant-scoped assembly draft persistence with revision CAS."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, uow: CatalogUnitOfWork) -> None:
|
|
26
|
+
self._uow = uow
|
|
27
|
+
|
|
28
|
+
def create(
|
|
29
|
+
self,
|
|
30
|
+
draft: AssemblyDraft,
|
|
31
|
+
*,
|
|
32
|
+
tenant_scope_fingerprint: str,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Persist a new tenant-scoped assembly draft."""
|
|
35
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
36
|
+
payload = draft.file_payload()
|
|
37
|
+
envelope = self._uow.encode(
|
|
38
|
+
ArtifactKind.ASSEMBLY_DRAFT,
|
|
39
|
+
payload,
|
|
40
|
+
strict_sha256_fingerprint(payload),
|
|
41
|
+
)
|
|
42
|
+
with self._uow.transaction() as conn:
|
|
43
|
+
cursor = self._uow.execute(
|
|
44
|
+
conn,
|
|
45
|
+
"insert_assembly_draft",
|
|
46
|
+
(
|
|
47
|
+
namespace,
|
|
48
|
+
draft.draft_id,
|
|
49
|
+
draft.bundle_id,
|
|
50
|
+
draft.source_id,
|
|
51
|
+
draft.draft_revision,
|
|
52
|
+
draft.state.value,
|
|
53
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
54
|
+
envelope,
|
|
55
|
+
self._uow.now(),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
if cursor.rowcount == 0:
|
|
59
|
+
raise ValueError(f"assembly draft '{draft.draft_id}' already exists")
|
|
60
|
+
|
|
61
|
+
def get_draft(
|
|
62
|
+
self,
|
|
63
|
+
draft_id: str,
|
|
64
|
+
*,
|
|
65
|
+
tenant_scope_fingerprint: str,
|
|
66
|
+
) -> AssemblyDraft | None:
|
|
67
|
+
"""Load a tenant-scoped assembly draft by opaque identifier."""
|
|
68
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
69
|
+
with self._uow.transaction() as conn:
|
|
70
|
+
row = self._uow.execute(
|
|
71
|
+
conn,
|
|
72
|
+
"read_assembly_draft",
|
|
73
|
+
(namespace, draft_id),
|
|
74
|
+
).fetchone()
|
|
75
|
+
if row is None:
|
|
76
|
+
return None
|
|
77
|
+
envelope = self._uow.decode(
|
|
78
|
+
row["envelope"],
|
|
79
|
+
ArtifactKind.ASSEMBLY_DRAFT,
|
|
80
|
+
row_schema_version=row["schema_version"],
|
|
81
|
+
)
|
|
82
|
+
draft = self._uow.draft_from_envelope(envelope)
|
|
83
|
+
if draft.draft_id != draft_id or draft.draft_revision != int(
|
|
84
|
+
row["draft_revision"]
|
|
85
|
+
):
|
|
86
|
+
raise SemanticCatalogError(
|
|
87
|
+
SemanticCatalogErrorCode.ENVELOPE_REJECTED,
|
|
88
|
+
"persisted assembly draft metadata does not match its envelope",
|
|
89
|
+
details={"cause_type": "DraftMetadataMismatch"},
|
|
90
|
+
)
|
|
91
|
+
return draft
|
|
92
|
+
|
|
93
|
+
def authoritative_release_binding_matches(
|
|
94
|
+
self,
|
|
95
|
+
binding: PublicationDraftBinding,
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""Preflight the exact persisted draft before external verification work."""
|
|
98
|
+
authoritative = self.get_draft(
|
|
99
|
+
binding.draft_id,
|
|
100
|
+
tenant_scope_fingerprint=binding.tenant_scope_fingerprint,
|
|
101
|
+
)
|
|
102
|
+
return (
|
|
103
|
+
authoritative is not None
|
|
104
|
+
and authoritative.draft_revision == binding.draft_revision
|
|
105
|
+
and strict_sha256_fingerprint(authoritative.file_payload())
|
|
106
|
+
== binding.draft_payload_fingerprint
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def replace(
|
|
110
|
+
self,
|
|
111
|
+
draft: AssemblyDraft,
|
|
112
|
+
*,
|
|
113
|
+
expected_revision: int,
|
|
114
|
+
tenant_scope_fingerprint: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Replace a draft only when its persisted revision matches."""
|
|
117
|
+
if draft.draft_revision != expected_revision + 1:
|
|
118
|
+
raise DraftRevisionConflict(
|
|
119
|
+
expected=expected_revision + 1,
|
|
120
|
+
actual=draft.draft_revision,
|
|
121
|
+
)
|
|
122
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
123
|
+
payload = draft.file_payload()
|
|
124
|
+
envelope = self._uow.encode(
|
|
125
|
+
ArtifactKind.ASSEMBLY_DRAFT,
|
|
126
|
+
payload,
|
|
127
|
+
strict_sha256_fingerprint(payload),
|
|
128
|
+
)
|
|
129
|
+
with self._uow.transaction() as conn:
|
|
130
|
+
cursor = self._uow.execute(
|
|
131
|
+
conn,
|
|
132
|
+
"replace_assembly_draft",
|
|
133
|
+
(
|
|
134
|
+
draft.bundle_id,
|
|
135
|
+
draft.source_id,
|
|
136
|
+
draft.draft_revision,
|
|
137
|
+
draft.state.value,
|
|
138
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
139
|
+
envelope,
|
|
140
|
+
self._uow.now(),
|
|
141
|
+
namespace,
|
|
142
|
+
draft.draft_id,
|
|
143
|
+
expected_revision,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
if cursor.rowcount == 0:
|
|
147
|
+
current = self._uow.execute(
|
|
148
|
+
conn,
|
|
149
|
+
"read_assembly_draft",
|
|
150
|
+
(namespace, draft.draft_id),
|
|
151
|
+
).fetchone()
|
|
152
|
+
actual = -1 if current is None else int(current["draft_revision"])
|
|
153
|
+
raise DraftRevisionConflict(expected=expected_revision, actual=actual)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["DraftRepository"]
|