lorebase-store 0.2.3__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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Revision events: what a commit changed, for the change feed and webhooks (design 10.5).
|
|
2
|
+
|
|
3
|
+
The commit pipeline writes one `revision.committed` outbox event per Revision (branch,
|
|
4
|
+
Revision, parents, change counts). `describe` turns Revisions into the event documents the
|
|
5
|
+
change feed and webhooks send: the Revision metadata, changes per Kind and Relation (api
|
|
6
|
+
names), whether the schema changed, and up to `keys_max` changed Record keys.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import Table, and_, func, select, text
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
19
|
+
|
|
20
|
+
from lorebase_core.model import Schema
|
|
21
|
+
from lorebase_store.commit.pipeline import COMMITTED_EVENT
|
|
22
|
+
from lorebase_store.commit.schema import load_compiled
|
|
23
|
+
from lorebase_store.materialize.rows import PartitionType, parse_partition
|
|
24
|
+
from lorebase_store.models import metadata
|
|
25
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
26
|
+
from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
|
|
27
|
+
from lorebase_store.settings import WriteSettings
|
|
28
|
+
|
|
29
|
+
CHANGESETS: Table = metadata.tables["changesets"]
|
|
30
|
+
EVENT_TYPES: tuple[str, ...] = (COMMITTED_EVENT,)
|
|
31
|
+
"""Event types of the change feed (Revision events; see `outbox_events` for webhooks)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class RevisionChanges:
|
|
36
|
+
"""Changed elements of one Revision: counts per partition, some Record keys."""
|
|
37
|
+
|
|
38
|
+
partitions: Mapping[str, int] = field(default_factory=dict)
|
|
39
|
+
keys: Mapping[str, Sequence[str]] = field(default_factory=dict)
|
|
40
|
+
"""Changed Record keys per Kind partition, in key order, `keys_max` in all."""
|
|
41
|
+
keys_truncated: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def revision_changes(
|
|
45
|
+
session: AsyncSession,
|
|
46
|
+
domain_id: str,
|
|
47
|
+
revisions: Collection[bytes],
|
|
48
|
+
*,
|
|
49
|
+
keys_max: int,
|
|
50
|
+
kind_partitions: Collection[str] | None = None,
|
|
51
|
+
) -> dict[bytes, RevisionChanges]:
|
|
52
|
+
"""Changes of each Revision against its first parent (its stored changeset).
|
|
53
|
+
|
|
54
|
+
`kind_partitions` restricts the keys listed (not the counts) to those Kinds.
|
|
55
|
+
"""
|
|
56
|
+
if not revisions:
|
|
57
|
+
return {}
|
|
58
|
+
c = CHANGESETS
|
|
59
|
+
scope = and_(c.c.domain_id == domain_id, c.c.revision_id.in_(list(revisions)))
|
|
60
|
+
counts: dict[bytes, dict[str, int]] = defaultdict(dict)
|
|
61
|
+
result = await session.execute(
|
|
62
|
+
select(c.c.revision_id, c.c.partition, func.count())
|
|
63
|
+
.where(scope)
|
|
64
|
+
.group_by(c.c.revision_id, c.c.partition)
|
|
65
|
+
)
|
|
66
|
+
for revision, partition, count in result:
|
|
67
|
+
counts[bytes(revision)][partition] = int(count)
|
|
68
|
+
keys: dict[bytes, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
|
|
69
|
+
truncated: set[bytes] = set()
|
|
70
|
+
if keys_max > 0:
|
|
71
|
+
records = c.c.partition.startswith("k:")
|
|
72
|
+
if kind_partitions is not None:
|
|
73
|
+
records = c.c.partition.in_(list(kind_partitions))
|
|
74
|
+
ranked = (
|
|
75
|
+
select(
|
|
76
|
+
c.c.revision_id,
|
|
77
|
+
c.c.partition,
|
|
78
|
+
c.c.element_key,
|
|
79
|
+
func.row_number()
|
|
80
|
+
.over(partition_by=c.c.revision_id, order_by=(c.c.partition, c.c.element_key))
|
|
81
|
+
.label("rank"),
|
|
82
|
+
)
|
|
83
|
+
.where(scope, records)
|
|
84
|
+
.subquery()
|
|
85
|
+
)
|
|
86
|
+
rows = await session.execute(
|
|
87
|
+
select(ranked.c.revision_id, ranked.c.partition, ranked.c.element_key)
|
|
88
|
+
.where(ranked.c.rank <= keys_max + 1)
|
|
89
|
+
.order_by(ranked.c.revision_id, ranked.c.rank)
|
|
90
|
+
)
|
|
91
|
+
listed: dict[bytes, int] = defaultdict(int)
|
|
92
|
+
for revision, partition, key in rows:
|
|
93
|
+
revision = bytes(revision)
|
|
94
|
+
if listed[revision] == keys_max:
|
|
95
|
+
truncated.add(revision)
|
|
96
|
+
continue
|
|
97
|
+
listed[revision] += 1
|
|
98
|
+
keys[revision][partition].append(key)
|
|
99
|
+
return {
|
|
100
|
+
revision: RevisionChanges(
|
|
101
|
+
counts.get(revision, {}),
|
|
102
|
+
{p: list(k) for p, k in keys.get(revision, {}).items()},
|
|
103
|
+
revision in truncated,
|
|
104
|
+
)
|
|
105
|
+
for revision in revisions
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _named(schema: Schema | None, partition: str) -> tuple[PartitionType, str]:
|
|
110
|
+
parsed = parse_partition(partition)
|
|
111
|
+
if schema is not None:
|
|
112
|
+
if parsed.type is PartitionType.KIND and parsed.element_id in schema.kinds:
|
|
113
|
+
return parsed.type, schema.kinds[parsed.element_id].api_name
|
|
114
|
+
if parsed.type is PartitionType.RELATION and parsed.element_id in schema.relations:
|
|
115
|
+
return parsed.type, schema.relations[parsed.element_id].api_name
|
|
116
|
+
return parsed.type, parsed.element_id
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def revision_event(
|
|
120
|
+
revision: RevisionRow,
|
|
121
|
+
changes: RevisionChanges,
|
|
122
|
+
*,
|
|
123
|
+
domain_id: str,
|
|
124
|
+
branch: str,
|
|
125
|
+
schema: Schema | None,
|
|
126
|
+
sequence: int | None = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""The JSON document of a `revision.committed` event."""
|
|
129
|
+
kinds: dict[str, int] = {}
|
|
130
|
+
relations: dict[str, int] = {}
|
|
131
|
+
schema_changed = False
|
|
132
|
+
for partition, count in sorted(changes.partitions.items()):
|
|
133
|
+
element, name = _named(schema, partition)
|
|
134
|
+
if element is PartitionType.KIND:
|
|
135
|
+
kinds[name] = kinds.get(name, 0) + count
|
|
136
|
+
elif element is PartitionType.RELATION:
|
|
137
|
+
relations[name] = relations.get(name, 0) + count
|
|
138
|
+
else:
|
|
139
|
+
schema_changed = True
|
|
140
|
+
keys = {_named(schema, p)[1]: list(k) for p, k in sorted(changes.keys.items())}
|
|
141
|
+
document: dict[str, Any] = {}
|
|
142
|
+
if sequence is not None:
|
|
143
|
+
document["sequence"] = sequence
|
|
144
|
+
document.update(
|
|
145
|
+
{
|
|
146
|
+
"type": COMMITTED_EVENT,
|
|
147
|
+
"domain": domain_id,
|
|
148
|
+
"branch": branch,
|
|
149
|
+
"revision": revision.id.hex(),
|
|
150
|
+
"parents": [p.hex() for p in revision.parents],
|
|
151
|
+
"generation": revision.generation,
|
|
152
|
+
"author": revision.author,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
if revision.on_behalf_of is not None:
|
|
156
|
+
document["on_behalf_of"] = revision.on_behalf_of
|
|
157
|
+
document.update(
|
|
158
|
+
{
|
|
159
|
+
"message": revision.message,
|
|
160
|
+
"origin": revision.origin,
|
|
161
|
+
"stats": revision.stats,
|
|
162
|
+
"created_at": revision.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
|
163
|
+
"kinds": kinds,
|
|
164
|
+
"relations": relations,
|
|
165
|
+
"schema_changed": schema_changed,
|
|
166
|
+
"keys": keys,
|
|
167
|
+
"keys_truncated": changes.keys_truncated,
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
return document
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class EventRef:
|
|
175
|
+
"""A Revision committed on a branch, with its outbox sequence when known."""
|
|
176
|
+
|
|
177
|
+
revision: bytes
|
|
178
|
+
branch: str
|
|
179
|
+
sequence: int | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def describe(
|
|
183
|
+
session: AsyncSession,
|
|
184
|
+
domain_id: str,
|
|
185
|
+
refs: Sequence[EventRef],
|
|
186
|
+
*,
|
|
187
|
+
schemas: Mapping[str, Schema | None],
|
|
188
|
+
keys_max: int,
|
|
189
|
+
kind_partitions: Collection[str] | None = None,
|
|
190
|
+
) -> list[dict[str, Any]]:
|
|
191
|
+
"""Event documents of `refs`, in their order; Revisions that no longer exist are skipped.
|
|
192
|
+
|
|
193
|
+
`schemas` maps each branch to the schema naming its Kinds and Relations.
|
|
194
|
+
"""
|
|
195
|
+
repository = RevisionRepository(session, domain_id)
|
|
196
|
+
rows: dict[bytes, RevisionRow] = {}
|
|
197
|
+
for ref in refs:
|
|
198
|
+
if ref.revision not in rows:
|
|
199
|
+
found = await repository.get(ref.revision)
|
|
200
|
+
if found is not None:
|
|
201
|
+
rows[ref.revision] = found
|
|
202
|
+
changes = await revision_changes(
|
|
203
|
+
session, domain_id, list(rows), keys_max=keys_max, kind_partitions=kind_partitions
|
|
204
|
+
)
|
|
205
|
+
return [
|
|
206
|
+
revision_event(
|
|
207
|
+
rows[ref.revision],
|
|
208
|
+
changes[ref.revision],
|
|
209
|
+
domain_id=domain_id,
|
|
210
|
+
branch=ref.branch,
|
|
211
|
+
schema=schemas.get(ref.branch),
|
|
212
|
+
sequence=ref.sequence,
|
|
213
|
+
)
|
|
214
|
+
for ref in refs
|
|
215
|
+
if ref.revision in rows
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def branch_schema(
|
|
220
|
+
session: AsyncSession, domain_id: str, branch: str, *, writes: WriteSettings
|
|
221
|
+
) -> Schema | None:
|
|
222
|
+
"""Compiled schema of a branch head (none when the branch is not materialized)."""
|
|
223
|
+
row = await MaterializedRepository(session, domain_id, branch, writes=writes).schema()
|
|
224
|
+
return None if row is None else load_compiled(row.compiled)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_FIRST_PARENTS = text(
|
|
228
|
+
"""
|
|
229
|
+
WITH RECURSIVE chain(id, generation, step) AS (
|
|
230
|
+
SELECT r.id, r.generation, 0 FROM revisions r
|
|
231
|
+
WHERE r.domain_id = :domain AND r.id = :head
|
|
232
|
+
UNION ALL
|
|
233
|
+
SELECT r.id, r.generation, chain.step + 1
|
|
234
|
+
FROM chain
|
|
235
|
+
JOIN revision_parents p
|
|
236
|
+
ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
|
|
237
|
+
JOIN revisions r ON r.domain_id = :domain AND r.id = p.parent_id
|
|
238
|
+
WHERE chain.generation > :floor AND chain.step < :max_steps
|
|
239
|
+
)
|
|
240
|
+
SELECT id, generation FROM chain ORDER BY step
|
|
241
|
+
"""
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def first_parents(
|
|
246
|
+
session: AsyncSession, domain_id: str, head: bytes, *, floor: int, max_steps: int
|
|
247
|
+
) -> list[tuple[bytes, int]]:
|
|
248
|
+
"""`head` and its first-parent ancestors, newest first, down to the first Revision at or
|
|
249
|
+
below generation `floor` (included) or `max_steps` steps back."""
|
|
250
|
+
result = await session.execute(
|
|
251
|
+
_FIRST_PARENTS,
|
|
252
|
+
{"domain": domain_id, "head": head, "floor": floor, "max_steps": max_steps},
|
|
253
|
+
)
|
|
254
|
+
return [(bytes(row.id), int(row.generation)) for row in result]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def matches(document: Mapping[str, Any], kinds: Iterable[str] | None) -> bool:
|
|
258
|
+
"""Whether an event touches one of `kinds` (api names); every event when `kinds` is None."""
|
|
259
|
+
if kinds is None:
|
|
260
|
+
return True
|
|
261
|
+
return not set(document["kinds"]).isdisjoint(kinds)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Commit pipeline (design 6.6): validated, atomic writes of data and schema changes."""
|
|
2
|
+
|
|
3
|
+
from lorebase_store.commit.changes import (
|
|
4
|
+
Change,
|
|
5
|
+
CommitResult,
|
|
6
|
+
SchemaChange,
|
|
7
|
+
SchemaDelete,
|
|
8
|
+
SchemaElementModel,
|
|
9
|
+
SchemaUpsert,
|
|
10
|
+
)
|
|
11
|
+
from lorebase_store.commit.errors import (
|
|
12
|
+
BranchLockTimeoutError,
|
|
13
|
+
BranchMaterializingError,
|
|
14
|
+
BranchNotFoundError,
|
|
15
|
+
BranchNotMaterializedError,
|
|
16
|
+
CommitError,
|
|
17
|
+
SchemaChangeError,
|
|
18
|
+
SensitivityLoweredError,
|
|
19
|
+
StaleHeadError,
|
|
20
|
+
ValidationFailedError,
|
|
21
|
+
)
|
|
22
|
+
from lorebase_store.commit.pipeline import commit, create_domain
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BranchLockTimeoutError",
|
|
26
|
+
"BranchMaterializingError",
|
|
27
|
+
"BranchNotFoundError",
|
|
28
|
+
"BranchNotMaterializedError",
|
|
29
|
+
"Change",
|
|
30
|
+
"CommitError",
|
|
31
|
+
"CommitResult",
|
|
32
|
+
"SchemaChange",
|
|
33
|
+
"SchemaChangeError",
|
|
34
|
+
"SchemaDelete",
|
|
35
|
+
"SchemaElementModel",
|
|
36
|
+
"SchemaUpsert",
|
|
37
|
+
"SensitivityLoweredError",
|
|
38
|
+
"StaleHeadError",
|
|
39
|
+
"ValidationFailedError",
|
|
40
|
+
"commit",
|
|
41
|
+
"create_domain",
|
|
42
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Inputs and outcome of a commit: data changes with provenance, schema changes, result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from lorebase_core.model import (
|
|
10
|
+
Derivation,
|
|
11
|
+
Domain,
|
|
12
|
+
Kind,
|
|
13
|
+
Operation,
|
|
14
|
+
Relation,
|
|
15
|
+
Source,
|
|
16
|
+
Trait,
|
|
17
|
+
)
|
|
18
|
+
from lorebase_core.revision import ChangesetEntry
|
|
19
|
+
from lorebase_core.validate import DataChange
|
|
20
|
+
|
|
21
|
+
SchemaElementModel = Domain | Kind | Relation | Trait | Operation | Derivation | Source
|
|
22
|
+
"""Anything stored in the `schema` partition."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Change:
|
|
27
|
+
"""A data change and the provenance its changed Attributes are attributed to.
|
|
28
|
+
|
|
29
|
+
`provenance` is a provenance entry id for every changed Attribute, or a map from
|
|
30
|
+
Attribute id to entry id (Attributes left out fall back to the commit's own entry).
|
|
31
|
+
`None` attributes the change to the commit's own entry, built from its origin and author.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
change: DataChange
|
|
35
|
+
provenance: str | Mapping[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SchemaUpsert:
|
|
40
|
+
"""Create or replace a schema element (the Domain element included)."""
|
|
41
|
+
|
|
42
|
+
element: SchemaElementModel
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class SchemaDelete:
|
|
47
|
+
"""Remove a schema element by id; the Domain element cannot be removed."""
|
|
48
|
+
|
|
49
|
+
element_id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SchemaChange = SchemaUpsert | SchemaDelete
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class CommitResult:
|
|
57
|
+
"""Outcome of a commit. `no_op` commits change nothing and leave the head in place."""
|
|
58
|
+
|
|
59
|
+
revision_id: bytes
|
|
60
|
+
parents: tuple[bytes, ...]
|
|
61
|
+
root: bytes
|
|
62
|
+
generation: int
|
|
63
|
+
ref_version: int
|
|
64
|
+
created_at: datetime
|
|
65
|
+
changeset: tuple[ChangesetEntry, ...]
|
|
66
|
+
derived_changes: tuple[DataChange, ...] = ()
|
|
67
|
+
no_op: bool = False
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typed failures of the commit pipeline. Any failure leaves nothing visible."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
|
|
7
|
+
from lorebase_core.validate import Issue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CommitError(Exception):
|
|
11
|
+
"""Base class of commit failures."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BranchNotFoundError(CommitError):
|
|
15
|
+
"""The branch does not exist (or the ref is a tag, which never moves)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BranchNotMaterializedError(CommitError):
|
|
19
|
+
"""The branch has no materialized head to validate against."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BranchMaterializingError(CommitError):
|
|
23
|
+
"""The branch is still being copied in the background, and the change (a schema change)
|
|
24
|
+
needs its full copy: retry once its materialization is done."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StaleHeadError(CommitError):
|
|
28
|
+
"""The branch head is not the one the caller expected (or moved during the commit)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, branch: str, expected: bytes, actual: bytes | None) -> None:
|
|
31
|
+
actual_text = "unknown" if actual is None else actual.hex()
|
|
32
|
+
super().__init__(f"branch {branch!r} is at {actual_text}, expected {expected.hex()}")
|
|
33
|
+
self.branch = branch
|
|
34
|
+
self.expected = expected
|
|
35
|
+
self.actual = actual
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BranchLockTimeoutError(CommitError):
|
|
39
|
+
"""Another writer held the branch lock longer than the configured timeout."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SchemaChangeError(CommitError):
|
|
43
|
+
"""A schema change cannot be applied (unknown element, Domain removal, wrong Domain)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SensitivityLoweredError(CommitError):
|
|
47
|
+
"""The change removes sensitivity tags (or sensitive Attributes) without having been
|
|
48
|
+
allowed to: only callers cleared for the tags may (design 10.2)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, tags: Iterable[str]) -> None:
|
|
51
|
+
self.tags = sorted(tags)
|
|
52
|
+
super().__init__(f"the change removes the sensitivity tags {', '.join(self.tags)}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationFailedError(CommitError):
|
|
56
|
+
"""The changes violate the schema or the existing data; nothing was written."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, issues: Sequence[Issue]) -> None:
|
|
59
|
+
super().__init__(f"{len(issues)} validation issue(s); first: {issues[0].message}")
|
|
60
|
+
self.issues = list(issues)
|