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,72 @@
|
|
|
1
|
+
"""Keys derived from one configured secret, one per namespace and purpose.
|
|
2
|
+
|
|
3
|
+
HKDF-SHA256 turns the secret into independent 256-bit keys (info `lorebase/<namespace>/<purpose>`),
|
|
4
|
+
so a value sealed for one purpose never opens for another. Sealing is AES-256-GCM with a random
|
|
5
|
+
96-bit nonce and the purpose (plus an optional context) as associated data; MACs are
|
|
6
|
+
HMAC-SHA256. Keeper's web sessions (`keeper.auth.sessions.encryption_key`, namespace `keeper`),
|
|
7
|
+
webhook signing keys at rest (`webhooks.encryption_key`, namespace `webhooks`) and the
|
|
8
|
+
personal data vault (`vault.encryption_key` and `vault.token_key`, namespace `vault`) use it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidTag
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
21
|
+
|
|
22
|
+
_NONCE_BYTES = 12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Keyring:
|
|
26
|
+
"""Seals, opens and authenticates values under keys derived from one secret."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, secret: str, *, namespace: str) -> None:
|
|
29
|
+
self._secret = secret.encode()
|
|
30
|
+
self._info = f"lorebase/{namespace}/".encode()
|
|
31
|
+
self._keys: dict[str, bytes] = {}
|
|
32
|
+
|
|
33
|
+
def _key(self, purpose: str) -> bytes:
|
|
34
|
+
key = self._keys.get(purpose)
|
|
35
|
+
if key is None:
|
|
36
|
+
key = HKDF(
|
|
37
|
+
algorithm=hashes.SHA256(),
|
|
38
|
+
length=32,
|
|
39
|
+
salt=None,
|
|
40
|
+
info=self._info + purpose.encode(),
|
|
41
|
+
).derive(self._secret)
|
|
42
|
+
self._keys[purpose] = key
|
|
43
|
+
return key
|
|
44
|
+
|
|
45
|
+
def seal(self, purpose: str, plaintext: bytes, *, context: bytes = b"") -> bytes:
|
|
46
|
+
"""Encrypt and authenticate `plaintext` for `purpose`. A non-empty `context` (such as
|
|
47
|
+
the row a value is stored in) is authenticated too: the value opens only with it."""
|
|
48
|
+
nonce = os.urandom(_NONCE_BYTES)
|
|
49
|
+
aad = _associated(purpose, context)
|
|
50
|
+
return nonce + AESGCM(self._key(purpose)).encrypt(nonce, plaintext, aad)
|
|
51
|
+
|
|
52
|
+
def open(self, purpose: str, sealed: bytes, *, context: bytes = b"") -> bytes | None:
|
|
53
|
+
"""The plaintext of a value sealed for `purpose` (and `context`); None when it was
|
|
54
|
+
altered, sealed for another purpose or context, or under another secret."""
|
|
55
|
+
if len(sealed) <= _NONCE_BYTES:
|
|
56
|
+
return None
|
|
57
|
+
nonce, ciphertext = sealed[:_NONCE_BYTES], sealed[_NONCE_BYTES:]
|
|
58
|
+
try:
|
|
59
|
+
return AESGCM(self._key(purpose)).decrypt(
|
|
60
|
+
nonce, ciphertext, _associated(purpose, context)
|
|
61
|
+
)
|
|
62
|
+
except InvalidTag:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def mac(self, purpose: str, message: bytes) -> bytes:
|
|
66
|
+
"""HMAC-SHA256 of `message` under the key of `purpose`."""
|
|
67
|
+
return hmac.new(self._key(purpose), message, hashlib.sha256).digest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _associated(purpose: str, context: bytes) -> bytes:
|
|
71
|
+
"""Associated data: the purpose alone (as before contexts existed), or purpose and context."""
|
|
72
|
+
return purpose.encode() if not context else purpose.encode() + b"\x00" + context
|
lorebase_store/layout.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Where the materialized rows of a branch live: its own rows, or an overlay on a base branch.
|
|
2
|
+
|
|
3
|
+
A `full` branch has every row of its head in `mat_records`, `mat_links` and `mat_history`.
|
|
4
|
+
An `overlay` branch (design 6.11, AC-16) stores only the rows it changed, tombstones
|
|
5
|
+
(`deleted`) included, and reads the rows of its base branch as they were at the fork point:
|
|
6
|
+
|
|
7
|
+
overlay rows (not deleted)
|
|
8
|
+
UNION ALL
|
|
9
|
+
base rows with revision_generation <= base_generation that the overlay does not shadow
|
|
10
|
+
|
|
11
|
+
A base commit that changes a row an overlay still reads copies the previous row into the
|
|
12
|
+
overlay first (copy on write, `MaterializedRepository`), so the base rows an overlay reads
|
|
13
|
+
never changed after its fork; the generation filter hides the rows the base wrote since.
|
|
14
|
+
|
|
15
|
+
This module renders that row set once, as SQL text with the Domain, branch, base branch and
|
|
16
|
+
generation as literals (per-Attribute partial indexes of the base match, see
|
|
17
|
+
`lorebase_store.indexes`), and offers it both as text (`records_from`) and as a SQLAlchemy
|
|
18
|
+
`FromClause` with the table's columns (`records`). Every row of the set reports the overlay
|
|
19
|
+
as its `branch`, so predicates written for a table (`branch = :branch`) keep working. For a
|
|
20
|
+
full branch both return the plain table, so its statements are unchanged.
|
|
21
|
+
|
|
22
|
+
The base half also checks that the ref is still an overlay forked at that generation: a
|
|
23
|
+
reader that resolved the layout before a promotion or a re-fork (READ COMMITTED) then reads
|
|
24
|
+
only the overlay rows, which a promotion has completed. Revision `0010` renders the same text
|
|
25
|
+
inside the security definer read functions (`lb_overlay_rows`).
|
|
26
|
+
|
|
27
|
+
For lookups by identity (`records_lookup`, `links_lookup`) one index scan reads both
|
|
28
|
+
branches and the overlay's row of an identity wins; revision `0012` renders the same text
|
|
29
|
+
(`lb_overlay_lookup`).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from sqlalchemy import FromClause, Label, Table, func, text
|
|
38
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
39
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
40
|
+
from sqlalchemy.orm import Session
|
|
41
|
+
|
|
42
|
+
from lorebase_store.models import metadata
|
|
43
|
+
from lorebase_store.models.enums import Materialization
|
|
44
|
+
|
|
45
|
+
MAT_RECORDS: Table = metadata.tables["mat_records"]
|
|
46
|
+
MAT_LINKS: Table = metadata.tables["mat_links"]
|
|
47
|
+
MAT_HISTORY: Table = metadata.tables["mat_history"]
|
|
48
|
+
|
|
49
|
+
RECORD_COLUMNS = tuple(c.name for c in MAT_RECORDS.columns)
|
|
50
|
+
LINK_COLUMNS = tuple(c.name for c in MAT_LINKS.columns)
|
|
51
|
+
HISTORY_COLUMNS = tuple(c.name for c in MAT_HISTORY.columns)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lineage_column(rows: FromClause, column: str = "lineage") -> Label[Any]:
|
|
55
|
+
"""A map column of Record or Link `rows` (`lineage`, `lineage_revisions`) as the map it
|
|
56
|
+
stands for: with shared lineage maps (revision 0036) the column may hold a reference."""
|
|
57
|
+
return func.lb_lineage(rows.c.domain_id, rows.c[column], type_=JSONB).label(column)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_LAYOUT = text(
|
|
61
|
+
"SELECT CAST(materialization AS text), base_branch, base_generation FROM refs "
|
|
62
|
+
"WHERE domain_id = :domain AND name = :branch"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class BranchLayout:
|
|
68
|
+
"""The rows a branch reads: its own (`base` is None) or an overlay on `base`."""
|
|
69
|
+
|
|
70
|
+
domain_id: str
|
|
71
|
+
branch: str
|
|
72
|
+
base: str | None = None
|
|
73
|
+
base_generation: int | None = None
|
|
74
|
+
building: bool = field(default=False, compare=False)
|
|
75
|
+
"""A `building` branch: its own rows are still being written from its tree by a
|
|
76
|
+
background job (`materialize.background`); commits wait for it."""
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
if (self.base is None) != (self.base_generation is None):
|
|
80
|
+
raise ValueError("an overlay needs both its base branch and its base generation")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def overlay(self) -> bool:
|
|
84
|
+
return self.base is not None
|
|
85
|
+
|
|
86
|
+
# SQL text -----------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def records_sql(self, *, probe: bool = True) -> str:
|
|
89
|
+
"""The resolved Record rows as one statement (all `mat_records` columns).
|
|
90
|
+
|
|
91
|
+
`probe` keeps the anti-join a primary-key probe of the overlay per base row read,
|
|
92
|
+
right for lookups by key and short pages; false lets the planner hash the overlay's
|
|
93
|
+
keys instead, which reads that match many base rows (search) prefer.
|
|
94
|
+
"""
|
|
95
|
+
return self._resolved("mat_records", RECORD_COLUMNS, ("kind_id", "key"), probe=probe)
|
|
96
|
+
|
|
97
|
+
def links_sql(self) -> str:
|
|
98
|
+
"""The resolved Link rows as one statement (all `mat_links` columns)."""
|
|
99
|
+
return self._resolved("mat_links", LINK_COLUMNS, ("relation_id", "from_key", "to_key"))
|
|
100
|
+
|
|
101
|
+
def history_sql(self) -> str:
|
|
102
|
+
"""The branch history: the overlay's own entries and the base's up to the fork that
|
|
103
|
+
it does not hold itself (a background copy, `materialize.background`, copies them in
|
|
104
|
+
batches before the overlay becomes a full copy)."""
|
|
105
|
+
own = ", ".join(f"h.{_quoted(c)}" for c in HISTORY_COLUMNS)
|
|
106
|
+
base = ", ".join(
|
|
107
|
+
f"CAST({_literal(self.branch)} AS text)" if c == "branch" else f"b.{_quoted(c)}"
|
|
108
|
+
for c in HISTORY_COLUMNS
|
|
109
|
+
)
|
|
110
|
+
domain = _literal(self.domain_id)
|
|
111
|
+
return (
|
|
112
|
+
f"SELECT {own} FROM mat_history h WHERE h.domain_id = {domain} "
|
|
113
|
+
f"AND h.branch = {_literal(self.branch)} UNION ALL SELECT {base} FROM mat_history b "
|
|
114
|
+
f"WHERE b.domain_id = {domain} AND b.branch = {_literal(self._base())} "
|
|
115
|
+
f"AND EXISTS (SELECT 1 FROM revisions r WHERE r.domain_id = {domain} "
|
|
116
|
+
f"AND r.id = b.revision_id AND r.generation <= {self._generation()} OFFSET 0) "
|
|
117
|
+
f"AND {self._guard()} AND NOT EXISTS (SELECT 1 FROM mat_history x "
|
|
118
|
+
f"WHERE x.domain_id = {domain} AND x.branch = {_literal(self.branch)} "
|
|
119
|
+
f"AND x.partition = b.partition AND x.element_key = b.element_key "
|
|
120
|
+
f"AND x.revision_id = b.revision_id OFFSET 0)"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def records_from(self, alias: str | None = None, *, probe: bool = True) -> str:
|
|
124
|
+
"""A FROM item for text statements: `mat_records [alias]` or the resolved rows
|
|
125
|
+
(`probe` as in `records_sql`)."""
|
|
126
|
+
return self._from_item("mat_records", alias, lambda: self.records_sql(probe=probe))
|
|
127
|
+
|
|
128
|
+
def links_from(self, alias: str | None = None) -> str:
|
|
129
|
+
return self._from_item("mat_links", alias, self.links_sql)
|
|
130
|
+
|
|
131
|
+
def history_from(self, alias: str | None = None) -> str:
|
|
132
|
+
return self._from_item("mat_history", alias, self.history_sql)
|
|
133
|
+
|
|
134
|
+
def records_lookup(self, where: str) -> str:
|
|
135
|
+
"""The resolved Record rows of an overlay that match `where`, as a parenthesized
|
|
136
|
+
subquery for a lateral lookup by identity (conditions on alias `x` that pin one
|
|
137
|
+
identity or a few, without bind parameters).
|
|
138
|
+
|
|
139
|
+
One index scan reads the overlay's and the base's rows together (`branch IN
|
|
140
|
+
(overlay, base)`), and the overlay's row of an identity, tombstone included, wins
|
|
141
|
+
over the base's (`DISTINCT ON`): one scan per lookup instead of one per half plus a
|
|
142
|
+
primary-key probe of the overlay per base row. Revision `0012` renders the same
|
|
143
|
+
text inside the read functions (`lb_overlay_lookup`).
|
|
144
|
+
"""
|
|
145
|
+
return self._lookup("mat_records", RECORD_COLUMNS, ("kind_id", "key"), where)
|
|
146
|
+
|
|
147
|
+
def links_lookup(self, where: str) -> str:
|
|
148
|
+
"""The resolved Link rows of an overlay that match `where` (see `records_lookup`)."""
|
|
149
|
+
key = ("relation_id", "from_key", "to_key")
|
|
150
|
+
return self._lookup("mat_links", LINK_COLUMNS, key, where)
|
|
151
|
+
|
|
152
|
+
# SQLAlchemy ---------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def records(self, name: str | None = None) -> FromClause:
|
|
155
|
+
"""`mat_records` (aliased when `name` is given) or the resolved rows of an overlay."""
|
|
156
|
+
return self._from_clause(MAT_RECORDS, name)
|
|
157
|
+
|
|
158
|
+
def links(self, name: str | None = None) -> FromClause:
|
|
159
|
+
return self._from_clause(MAT_LINKS, name)
|
|
160
|
+
|
|
161
|
+
def history(self, name: str | None = None) -> FromClause:
|
|
162
|
+
return self._from_clause(MAT_HISTORY, name)
|
|
163
|
+
|
|
164
|
+
def records_alias(self) -> FromClause:
|
|
165
|
+
"""An anonymous alias of the rows (for self-joins)."""
|
|
166
|
+
return self._resolved_clause(MAT_RECORDS) if self.overlay else MAT_RECORDS.alias()
|
|
167
|
+
|
|
168
|
+
def links_alias(self) -> FromClause:
|
|
169
|
+
return self._resolved_clause(MAT_LINKS) if self.overlay else MAT_LINKS.alias()
|
|
170
|
+
|
|
171
|
+
# Rendering ----------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _resolved(
|
|
174
|
+
self, table: str, columns: tuple[str, ...], key: tuple[str, ...], *, probe: bool = True
|
|
175
|
+
) -> str:
|
|
176
|
+
own, base = self._halves(table, columns, key, own="o", base="s", probe=probe)
|
|
177
|
+
return f"{own} UNION ALL {base}"
|
|
178
|
+
|
|
179
|
+
def _halves(
|
|
180
|
+
self,
|
|
181
|
+
table: str,
|
|
182
|
+
columns: tuple[str, ...],
|
|
183
|
+
key: tuple[str, ...],
|
|
184
|
+
*,
|
|
185
|
+
own: str,
|
|
186
|
+
base: str,
|
|
187
|
+
probe: bool = True,
|
|
188
|
+
) -> tuple[str, str]:
|
|
189
|
+
"""The overlay half and the base half of the resolved rows."""
|
|
190
|
+
domain, branch = _literal(self.domain_id), _literal(self.branch)
|
|
191
|
+
own_columns = ", ".join(f"{own}.{_quoted(c)}" for c in columns)
|
|
192
|
+
base_columns = ", ".join(
|
|
193
|
+
f"CAST({branch} AS text)" if c == "branch" else f"{base}.{_quoted(c)}" for c in columns
|
|
194
|
+
)
|
|
195
|
+
shadow = " AND ".join(f"x.{c} = {base}.{c}" for c in key)
|
|
196
|
+
# Postgres does not flatten a UNION ALL whose members have WHERE clauses: the halves
|
|
197
|
+
# stay subqueries; restriction quals and lateral references are pushed into each,
|
|
198
|
+
# ordering and LIMIT are not (`records_arms` repeats them per half). OFFSET 0 keeps
|
|
199
|
+
# the anti-join a primary-key probe per base row read (`probe`).
|
|
200
|
+
return (
|
|
201
|
+
f"SELECT {own_columns} FROM {table} {own} WHERE {own}.domain_id = {domain} "
|
|
202
|
+
f"AND {own}.branch = {branch} AND NOT {own}.deleted",
|
|
203
|
+
f"SELECT {base_columns} FROM {table} {base} WHERE {base}.domain_id = {domain} "
|
|
204
|
+
f"AND {base}.branch = {_literal(self._base())} "
|
|
205
|
+
f"AND {base}.revision_generation <= {self._generation()} AND {self._guard()} "
|
|
206
|
+
f"AND NOT EXISTS (SELECT 1 FROM {table} x WHERE x.domain_id = {domain} "
|
|
207
|
+
f"AND x.branch = {branch} AND {shadow}{' OFFSET 0' if probe else ''})",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def records_arms(self, tail: str, alias: str = "m") -> str:
|
|
211
|
+
"""A FROM item with the resolved Record rows of an overlay where `tail` is applied
|
|
212
|
+
inside each half: conditions (starting with ` AND`) on alias `m`, then `ORDER BY` and
|
|
213
|
+
`LIMIT`, so that a keyset page reads at most `LIMIT` rows of each half. `tail` may hold
|
|
214
|
+
bind parameters and must not test `branch` (the halves read different branches)."""
|
|
215
|
+
if not self.overlay:
|
|
216
|
+
raise ValueError("records_arms applies to overlay branches")
|
|
217
|
+
own, base = (
|
|
218
|
+
escape_colons(half)
|
|
219
|
+
for half in self._halves(
|
|
220
|
+
"mat_records", RECORD_COLUMNS, ("kind_id", "key"), own="m", base="m"
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return f"(({own}{tail}) UNION ALL ({base}{tail})) AS {alias}"
|
|
224
|
+
|
|
225
|
+
def _lookup(
|
|
226
|
+
self, table: str, columns: tuple[str, ...], key: tuple[str, ...], where: str
|
|
227
|
+
) -> str:
|
|
228
|
+
if not self.overlay:
|
|
229
|
+
raise ValueError("lookups over both branches apply to overlay branches")
|
|
230
|
+
domain, branch, base = (
|
|
231
|
+
_literal(self.domain_id),
|
|
232
|
+
_literal(self.branch),
|
|
233
|
+
_literal(self._base()),
|
|
234
|
+
)
|
|
235
|
+
select = ", ".join(
|
|
236
|
+
f"CAST({branch} AS text) AS branch" if c == "branch" else f"x.{_quoted(c)}"
|
|
237
|
+
for c in columns
|
|
238
|
+
)
|
|
239
|
+
identity = ", ".join(f"x.{c}" for c in key)
|
|
240
|
+
return escape_colons(
|
|
241
|
+
f"(SELECT {select} FROM (SELECT DISTINCT ON ({identity}) * FROM {table} x "
|
|
242
|
+
f"WHERE x.domain_id = {domain} AND x.branch IN ({branch}, {base}) "
|
|
243
|
+
f"AND (x.branch = {branch} OR (x.revision_generation <= {self._generation()} "
|
|
244
|
+
f"AND {self._guard()})) AND {where} "
|
|
245
|
+
f"ORDER BY {identity}, x.branch = {branch} DESC) x WHERE NOT x.deleted)"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _guard(self) -> str:
|
|
249
|
+
return (
|
|
250
|
+
f"EXISTS (SELECT 1 FROM refs f WHERE f.domain_id = {_literal(self.domain_id)} "
|
|
251
|
+
f"AND f.name = {_literal(self.branch)} AND f.base_branch = {_literal(self._base())} "
|
|
252
|
+
f"AND f.base_generation = {self._generation()} OFFSET 0)"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _base(self) -> str:
|
|
256
|
+
assert self.base is not None
|
|
257
|
+
return self.base
|
|
258
|
+
|
|
259
|
+
def _generation(self) -> int:
|
|
260
|
+
assert self.base_generation is not None
|
|
261
|
+
return int(self.base_generation)
|
|
262
|
+
|
|
263
|
+
def _from_item(self, table: str, alias: str | None, resolved: Any) -> str:
|
|
264
|
+
if not self.overlay:
|
|
265
|
+
return table if alias is None else f"{table} {alias}"
|
|
266
|
+
return f"({escape_colons(resolved())}) AS {alias or table}"
|
|
267
|
+
|
|
268
|
+
def _from_clause(self, table: Table, name: str | None) -> FromClause:
|
|
269
|
+
if not self.overlay:
|
|
270
|
+
return table.alias(name) if name else table
|
|
271
|
+
return self._resolved_clause(table, name or table.name)
|
|
272
|
+
|
|
273
|
+
def _resolved_clause(self, table: Table, name: str | None = None) -> FromClause:
|
|
274
|
+
sql = {
|
|
275
|
+
MAT_RECORDS.name: self.records_sql,
|
|
276
|
+
MAT_LINKS.name: self.links_sql,
|
|
277
|
+
MAT_HISTORY.name: self.history_sql,
|
|
278
|
+
}[table.name]()
|
|
279
|
+
return text(escape_colons(sql)).columns(*table.columns).subquery(name)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def escape_colons(fragment: str) -> str:
|
|
283
|
+
"""A literal-holding SQL fragment made safe for `text()`, where `:name` binds."""
|
|
284
|
+
return fragment.replace(":", r"\:")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def full_layout(domain_id: str, branch: str) -> BranchLayout:
|
|
288
|
+
return BranchLayout(domain_id, branch)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def layout_from(
|
|
292
|
+
domain_id: str,
|
|
293
|
+
branch: str,
|
|
294
|
+
materialization: Materialization | str | None,
|
|
295
|
+
base: str | None,
|
|
296
|
+
generation: int | None,
|
|
297
|
+
) -> BranchLayout:
|
|
298
|
+
"""The layout of a ref row's materialization columns."""
|
|
299
|
+
if materialization == Materialization.OVERLAY and base is not None:
|
|
300
|
+
return BranchLayout(domain_id, branch, base, generation)
|
|
301
|
+
return BranchLayout(domain_id, branch, building=materialization == Materialization.BUILDING)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def load_layout(session: AsyncSession, domain_id: str, branch: str) -> BranchLayout:
|
|
305
|
+
"""The layout of a branch (one primary-key read); a missing ref reads as full."""
|
|
306
|
+
found = (await session.execute(_LAYOUT, {"domain": domain_id, "branch": branch})).one_or_none()
|
|
307
|
+
if found is None:
|
|
308
|
+
return BranchLayout(domain_id, branch)
|
|
309
|
+
return layout_from(domain_id, branch, found[0], found[1], found[2])
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def load_layout_sync(session: Session, domain_id: str, branch: str) -> BranchLayout:
|
|
313
|
+
found = session.execute(_LAYOUT, {"domain": domain_id, "branch": branch}).one_or_none()
|
|
314
|
+
if found is None:
|
|
315
|
+
return BranchLayout(domain_id, branch)
|
|
316
|
+
return layout_from(domain_id, branch, found[0], found[1], found[2])
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def resolve_layout(
|
|
320
|
+
session: AsyncSession, domain_id: str, branch: str | BranchLayout
|
|
321
|
+
) -> BranchLayout:
|
|
322
|
+
"""`branch` itself when it is a layout, else the layout read from its ref."""
|
|
323
|
+
if isinstance(branch, BranchLayout):
|
|
324
|
+
return branch
|
|
325
|
+
return await load_layout(session, domain_id, branch)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _literal(value: str) -> str:
|
|
329
|
+
if "\x00" in value:
|
|
330
|
+
raise ValueError("SQL literals cannot contain NUL")
|
|
331
|
+
return "'" + value.replace("'", "''") + "'"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _quoted(column: str) -> str:
|
|
335
|
+
return f'"{column}"'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Materialized heads (design 6.11): incremental apply, full rebuild, branch copy or overlay,
|
|
2
|
+
promotion of overlays, background copies and deletions of large branches, rewrite of rows
|
|
3
|
+
materialized before a sensitivity tag, backfill of lineage revisions, verify."""
|
|
4
|
+
|
|
5
|
+
from lorebase_store.jobs import (
|
|
6
|
+
BRANCH_DELETE_JOB,
|
|
7
|
+
LINEAGE_BACKFILL_JOB,
|
|
8
|
+
MATERIALIZE_JOB,
|
|
9
|
+
SENSITIVITY_REFRESH_JOB,
|
|
10
|
+
)
|
|
11
|
+
from lorebase_store.materialize.apply import MaterializedChange, apply_changeset, refresh_kind
|
|
12
|
+
from lorebase_store.materialize.background import (
|
|
13
|
+
BranchDeletion,
|
|
14
|
+
delete_branch,
|
|
15
|
+
queue_materialization,
|
|
16
|
+
run_branch_delete_job,
|
|
17
|
+
run_materialize_job,
|
|
18
|
+
)
|
|
19
|
+
from lorebase_store.materialize.branch import (
|
|
20
|
+
BranchCreation,
|
|
21
|
+
BranchDeletingError,
|
|
22
|
+
BranchExistsError,
|
|
23
|
+
BranchMode,
|
|
24
|
+
MaterializationMode,
|
|
25
|
+
create_branch,
|
|
26
|
+
)
|
|
27
|
+
from lorebase_store.materialize.lineage_backfill import (
|
|
28
|
+
LineageBackfill,
|
|
29
|
+
backfill_lineage_revisions,
|
|
30
|
+
queue_lineage_sweep,
|
|
31
|
+
run_lineage_backfill_job,
|
|
32
|
+
)
|
|
33
|
+
from lorebase_store.materialize.overlay import (
|
|
34
|
+
OverlayError,
|
|
35
|
+
promote,
|
|
36
|
+
promote_overlays_of,
|
|
37
|
+
refork,
|
|
38
|
+
)
|
|
39
|
+
from lorebase_store.materialize.paths import (
|
|
40
|
+
refresh_path_fanout,
|
|
41
|
+
refresh_stale_paths,
|
|
42
|
+
run_path_fanout_sweep,
|
|
43
|
+
)
|
|
44
|
+
from lorebase_store.materialize.rebuild import RebuildReport, materialize_revision, rebuild
|
|
45
|
+
from lorebase_store.materialize.stale import (
|
|
46
|
+
StaleRefresh,
|
|
47
|
+
queue_after_tagging,
|
|
48
|
+
queue_stale_refresh,
|
|
49
|
+
refresh_stale_rows,
|
|
50
|
+
run_sensitivity_refresh_job,
|
|
51
|
+
stale_kinds,
|
|
52
|
+
)
|
|
53
|
+
from lorebase_store.materialize.verify import (
|
|
54
|
+
Divergence,
|
|
55
|
+
DivergenceReason,
|
|
56
|
+
VerifyReport,
|
|
57
|
+
verify,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"BRANCH_DELETE_JOB",
|
|
62
|
+
"LINEAGE_BACKFILL_JOB",
|
|
63
|
+
"MATERIALIZE_JOB",
|
|
64
|
+
"SENSITIVITY_REFRESH_JOB",
|
|
65
|
+
"BranchCreation",
|
|
66
|
+
"BranchDeletingError",
|
|
67
|
+
"BranchDeletion",
|
|
68
|
+
"BranchExistsError",
|
|
69
|
+
"BranchMode",
|
|
70
|
+
"Divergence",
|
|
71
|
+
"DivergenceReason",
|
|
72
|
+
"LineageBackfill",
|
|
73
|
+
"MaterializationMode",
|
|
74
|
+
"MaterializedChange",
|
|
75
|
+
"OverlayError",
|
|
76
|
+
"RebuildReport",
|
|
77
|
+
"StaleRefresh",
|
|
78
|
+
"VerifyReport",
|
|
79
|
+
"apply_changeset",
|
|
80
|
+
"backfill_lineage_revisions",
|
|
81
|
+
"create_branch",
|
|
82
|
+
"delete_branch",
|
|
83
|
+
"materialize_revision",
|
|
84
|
+
"promote",
|
|
85
|
+
"promote_overlays_of",
|
|
86
|
+
"queue_after_tagging",
|
|
87
|
+
"queue_lineage_sweep",
|
|
88
|
+
"queue_materialization",
|
|
89
|
+
"queue_stale_refresh",
|
|
90
|
+
"rebuild",
|
|
91
|
+
"refork",
|
|
92
|
+
"refresh_kind",
|
|
93
|
+
"refresh_path_fanout",
|
|
94
|
+
"refresh_stale_paths",
|
|
95
|
+
"refresh_stale_rows",
|
|
96
|
+
"run_branch_delete_job",
|
|
97
|
+
"run_lineage_backfill_job",
|
|
98
|
+
"run_materialize_job",
|
|
99
|
+
"run_path_fanout_sweep",
|
|
100
|
+
"run_sensitivity_refresh_job",
|
|
101
|
+
"stale_kinds",
|
|
102
|
+
"verify",
|
|
103
|
+
]
|