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,578 @@
|
|
|
1
|
+
"""Neighborhood traversal over the materialized head (design 8.3).
|
|
2
|
+
|
|
3
|
+
`neighborhood` walks breadth first from seed Records along Links, in one SQL statement: a
|
|
4
|
+
recursive CTE whose recursive step joins each frontier Record laterally to its Links (stored
|
|
5
|
+
Links through the two `mat_links` indexes, foreign-key backed Links through the Records and
|
|
6
|
+
their per-Attribute index), at most `fanout` Links per Record and hop. The walk never
|
|
7
|
+
re-enters a Record already on its own path (`CYCLE`), and it is consumed lazily: the outer
|
|
8
|
+
`LIMIT` on Links walked stops the recursion, so caps bound the work, not only the result.
|
|
9
|
+
The same statement hydrates the Records reached, nearest first up to the node cap, and returns
|
|
10
|
+
the Links walked between them: one round trip.
|
|
11
|
+
|
|
12
|
+
Inbound foreign-key hops compare a JSON value, which row-level security keeps from being an
|
|
13
|
+
index condition; with `store.reads.use_definer_functions` the statement runs inside the
|
|
14
|
+
security definer function `lb_neighborhood` (revision 0005), which builds the same statement
|
|
15
|
+
from the Domain, branch and Relations it is given. On an overlay branch the walk reads the
|
|
16
|
+
resolved rows (`lorebase_store.layout`): Links and Records by key through one lateral lookup
|
|
17
|
+
over the overlay's and the base's rows at once (`BranchLayout.links_lookup`,
|
|
18
|
+
`records_lookup`), inbound foreign keys through both halves and their per-Attribute indexes;
|
|
19
|
+
the function is `lb_neighborhood_overlay` (revisions 0010 and 0012).
|
|
20
|
+
|
|
21
|
+
Path Derivations (design 5.8) are followed when named: a materialized one like a stored
|
|
22
|
+
Relation (its Links are rows of `mat_links`), one composed at read time hop by hop. A walk
|
|
23
|
+
that follows a composed path runs one hop at a time: per hop one statement of depth 1 from the
|
|
24
|
+
frontier for the other Relations, and the path's Links of the frontier composed from the
|
|
25
|
+
Links of its steps (`lorebase_store.materialize.links.path_links_of`), at most `fanout` per
|
|
26
|
+
Record. Caps and the result are the same as for the single statement.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from collections.abc import Mapping, Sequence
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from sqlalchemy import ColumnElement, Integer, LargeBinary, Text, column, text
|
|
37
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
38
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
39
|
+
|
|
40
|
+
from lorebase_core.model import LinkDirection, PathRelation, Relation, Schema
|
|
41
|
+
from lorebase_store.indexes import predicate_sql, sql_literal, values_text
|
|
42
|
+
from lorebase_store.layout import BranchLayout, escape_colons, resolve_layout
|
|
43
|
+
from lorebase_store.materialize.links import path_links_of
|
|
44
|
+
from lorebase_store.reads import (
|
|
45
|
+
MAT_RECORDS,
|
|
46
|
+
RECORD_NAMES,
|
|
47
|
+
Direction,
|
|
48
|
+
LinkRef,
|
|
49
|
+
foreign_key_relations,
|
|
50
|
+
hydrate_records,
|
|
51
|
+
record_columns,
|
|
52
|
+
stored_relation_ids,
|
|
53
|
+
)
|
|
54
|
+
from lorebase_store.repositories.materialized import MaterializedRepository, RecordRow
|
|
55
|
+
from lorebase_store.runtime import StoreRuntime, default_runtime
|
|
56
|
+
from lorebase_store.telemetry import record_definer_call
|
|
57
|
+
|
|
58
|
+
_STEP_COLUMNS = (
|
|
59
|
+
"via_kind, via_key, kind_id, key, relation_id, from_kind, from_key, to_kind, to_key, "
|
|
60
|
+
"link_values, link_hash"
|
|
61
|
+
)
|
|
62
|
+
_NULL_LINK = (
|
|
63
|
+
"CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), "
|
|
64
|
+
"CAST(NULL AS text), CAST(NULL AS jsonb), CAST(NULL AS bytea)"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Neighbor:
|
|
70
|
+
depth: int
|
|
71
|
+
record: RecordRow
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class Neighborhood:
|
|
76
|
+
"""Records reached (nearest first) and the Links walked between them.
|
|
77
|
+
|
|
78
|
+
`truncated` is true when a cap may have cut the walk: the Link or node cap, or a Record
|
|
79
|
+
that had `fanout` Links followed in one hop.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
records: list[Neighbor]
|
|
83
|
+
links: list[LinkRef]
|
|
84
|
+
truncated: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class Hops:
|
|
89
|
+
"""Relations one neighborhood follows: forward (out) and backward (in), each stored or
|
|
90
|
+
foreign-key backed."""
|
|
91
|
+
|
|
92
|
+
stored_out: tuple[Relation, ...]
|
|
93
|
+
stored_in: tuple[Relation, ...]
|
|
94
|
+
foreign_out: tuple[Relation, ...]
|
|
95
|
+
foreign_in: tuple[Relation, ...]
|
|
96
|
+
|
|
97
|
+
def as_json(self) -> str:
|
|
98
|
+
"""The `p_relations` argument of `lb_neighborhood`."""
|
|
99
|
+
|
|
100
|
+
def rows(relations: Sequence[Relation], attribute: bool) -> list[list[str | None]]:
|
|
101
|
+
return [
|
|
102
|
+
[r.id, r.from_kind, r.to_kind, *([r.foreign_key_attribute] if attribute else [])]
|
|
103
|
+
for r in relations
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"out": rows(self.stored_out, False),
|
|
109
|
+
"in": rows(self.stored_in, False),
|
|
110
|
+
"fk_out": rows(self.foreign_out, True),
|
|
111
|
+
"fk_in": rows(self.foreign_in, True),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TraversalLimitError(ValueError):
|
|
117
|
+
"""A requested depth, seed count or cap exceeds the configured maximum."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _within(name: str, value: int, maximum: int) -> int:
|
|
121
|
+
if value < 1:
|
|
122
|
+
raise TraversalLimitError(f"{name} must be positive")
|
|
123
|
+
if value > maximum:
|
|
124
|
+
raise TraversalLimitError(f"{name} {value} exceeds the maximum {maximum}")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def plan_hops(
|
|
129
|
+
schema: Schema,
|
|
130
|
+
direction: Direction = Direction.BOTH,
|
|
131
|
+
relation_ids: Sequence[str] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
follow: Mapping[str, Direction] | None = None,
|
|
134
|
+
) -> Hops:
|
|
135
|
+
"""The Relations to follow: `direction` over `relation_ids` (all when `None`), or each
|
|
136
|
+
Relation of `follow` (Relation id to direction) in its own direction only when given.
|
|
137
|
+
Materialized path Derivations named there are followed as stored Relations; composed
|
|
138
|
+
ones are not part of the statement (`composed_paths`)."""
|
|
139
|
+
relations = sorted(
|
|
140
|
+
[
|
|
141
|
+
*schema.relations.values(),
|
|
142
|
+
*(p.relation for p in schema.path_relations().values() if p.materialized),
|
|
143
|
+
],
|
|
144
|
+
key=lambda r: r.id,
|
|
145
|
+
)
|
|
146
|
+
if follow is not None:
|
|
147
|
+
outgoing = {rid for rid, way in follow.items() if way.outgoing}
|
|
148
|
+
incoming = {rid for rid, way in follow.items() if way.incoming}
|
|
149
|
+
else:
|
|
150
|
+
wanted = set(schema.relations) if relation_ids is None else set(relation_ids)
|
|
151
|
+
outgoing = wanted if direction.outgoing else set()
|
|
152
|
+
incoming = wanted if direction.incoming else set()
|
|
153
|
+
out = [r for r in relations if r.id in outgoing]
|
|
154
|
+
into = [r for r in relations if r.id in incoming]
|
|
155
|
+
stored = set(stored_relation_ids(relations))
|
|
156
|
+
return Hops(
|
|
157
|
+
stored_out=tuple(r for r in out if r.id in stored),
|
|
158
|
+
stored_in=tuple(r for r in into if r.id in stored),
|
|
159
|
+
foreign_out=tuple(foreign_key_relations(out)),
|
|
160
|
+
foreign_in=tuple(foreign_key_relations(into)),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def composed_paths(
|
|
165
|
+
schema: Schema, follow: Mapping[str, Direction]
|
|
166
|
+
) -> dict[str, tuple[PathRelation, Direction]]:
|
|
167
|
+
"""Path Derivations of `follow` composed at read time (by id, with the direction)."""
|
|
168
|
+
paths = schema.path_relations()
|
|
169
|
+
return {
|
|
170
|
+
rid: (paths[rid], way)
|
|
171
|
+
for rid, way in sorted(follow.items())
|
|
172
|
+
if rid in paths and not paths[rid].materialized
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _branches(hops: Hops, layout: BranchLayout) -> list[str]:
|
|
177
|
+
"""Lateral sources of one hop from the frontier Record `w`."""
|
|
178
|
+
|
|
179
|
+
def links(near: str) -> str:
|
|
180
|
+
if layout.overlay:
|
|
181
|
+
# One lateral lookup reads the overlay's and the base's Links of the frontier key
|
|
182
|
+
# in one index scan (`BranchLayout.links_lookup`).
|
|
183
|
+
found = layout.links_lookup(f"x.relation_id = r.relation_id AND x.{near} = w.key")
|
|
184
|
+
return f"CROSS JOIN LATERAL {found} AS l"
|
|
185
|
+
return (
|
|
186
|
+
"JOIN mat_links l ON l.domain_id = :domain AND l.branch = :branch "
|
|
187
|
+
f"AND l.relation_id = r.relation_id AND l.{near} = w.key"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
record = (
|
|
191
|
+
f"CROSS JOIN LATERAL {layout.records_lookup('x.kind_id = w.kind_id AND x.key = w.key')} m"
|
|
192
|
+
if layout.overlay
|
|
193
|
+
else "JOIN mat_records m ON m.domain_id = :domain AND m.branch = :branch "
|
|
194
|
+
"AND m.kind_id = w.kind_id AND m.key = w.key"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
parts: list[str] = []
|
|
198
|
+
if hops.stored_out:
|
|
199
|
+
parts.append(
|
|
200
|
+
"SELECT r.to_kind, l.to_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, "
|
|
201
|
+
f'l.to_key, l."values", l.object_hash FROM out_rels r {links("from_key")} '
|
|
202
|
+
"WHERE r.from_kind = w.kind_id LIMIT :fanout"
|
|
203
|
+
)
|
|
204
|
+
if hops.stored_in:
|
|
205
|
+
parts.append(
|
|
206
|
+
"SELECT r.from_kind, l.from_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, "
|
|
207
|
+
f'l.to_key, l."values", l.object_hash FROM in_rels r {links("to_key")} '
|
|
208
|
+
"WHERE r.to_kind = w.kind_id LIMIT :fanout"
|
|
209
|
+
)
|
|
210
|
+
if hops.foreign_out:
|
|
211
|
+
parts.append(
|
|
212
|
+
'SELECT f.to_kind, m."values" ->> f.attribute, f.relation_id, f.from_kind, m.key, '
|
|
213
|
+
"f.to_kind, m.\"values\" ->> f.attribute, CAST('{}' AS jsonb), CAST(NULL AS bytea) "
|
|
214
|
+
f"FROM fk_rels f {record} "
|
|
215
|
+
'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
|
|
216
|
+
"LIMIT :fanout"
|
|
217
|
+
)
|
|
218
|
+
for relation in hops.foreign_in:
|
|
219
|
+
assert relation.foreign_key_attribute is not None
|
|
220
|
+
from_kind = sql_literal(relation.from_kind)
|
|
221
|
+
to_kind = sql_literal(relation.to_kind)
|
|
222
|
+
predicate = predicate_sql(layout.domain_id, layout.branch, relation.from_kind)
|
|
223
|
+
parts.append(
|
|
224
|
+
f"SELECT CAST({from_kind} AS text), key, CAST({sql_literal(relation.id)} AS text), "
|
|
225
|
+
f"CAST({from_kind} AS text), key, CAST({to_kind} AS text), w.key, "
|
|
226
|
+
f"CAST('{{}}' AS jsonb), CAST(NULL AS bytea) FROM {layout.records_from()} "
|
|
227
|
+
f"WHERE w.kind_id = {to_kind} AND {escape_colons(predicate)} "
|
|
228
|
+
f"AND {values_text(relation.foreign_key_attribute)} = w.key LIMIT :fanout"
|
|
229
|
+
)
|
|
230
|
+
return parts
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def neighborhood_sql(
|
|
234
|
+
schema: Schema,
|
|
235
|
+
domain_id: str,
|
|
236
|
+
branch: str | BranchLayout,
|
|
237
|
+
direction: Direction,
|
|
238
|
+
relation_ids: Sequence[str] | None,
|
|
239
|
+
*,
|
|
240
|
+
follow: Mapping[str, Direction] | None = None,
|
|
241
|
+
) -> tuple[str, dict[str, Any]]:
|
|
242
|
+
"""The neighborhood statement and its schema-derived parameters (see `plan_hops`).
|
|
243
|
+
|
|
244
|
+
`follow` (Relation id to direction), when given, replaces `direction` and
|
|
245
|
+
`relation_ids`: each Relation is followed in its own direction only.
|
|
246
|
+
|
|
247
|
+
Other parameters: `:domain`, `:branch`, `:seed_kinds`, `:seed_keys`, `:depth`,
|
|
248
|
+
`:fanout`, `:max_steps` (Links walked plus seeds) and `:max_nodes`. `lb_neighborhood`
|
|
249
|
+
builds the same statement. A branch given by name is read as a full branch.
|
|
250
|
+
"""
|
|
251
|
+
layout = branch if isinstance(branch, BranchLayout) else BranchLayout(domain_id, branch)
|
|
252
|
+
hops = plan_hops(schema, direction, relation_ids, follow=follow)
|
|
253
|
+
params: dict[str, Any] = {
|
|
254
|
+
"out_ids": [r.id for r in hops.stored_out],
|
|
255
|
+
"out_from": [r.from_kind for r in hops.stored_out],
|
|
256
|
+
"out_to": [r.to_kind for r in hops.stored_out],
|
|
257
|
+
"in_ids": [r.id for r in hops.stored_in],
|
|
258
|
+
"in_from": [r.from_kind for r in hops.stored_in],
|
|
259
|
+
"in_to": [r.to_kind for r in hops.stored_in],
|
|
260
|
+
"fk_ids": [r.id for r in hops.foreign_out],
|
|
261
|
+
"fk_from": [r.from_kind for r in hops.foreign_out],
|
|
262
|
+
"fk_to": [r.to_kind for r in hops.foreign_out],
|
|
263
|
+
"fk_attributes": [r.foreign_key_attribute for r in hops.foreign_out],
|
|
264
|
+
}
|
|
265
|
+
parts = _branches(hops, layout)
|
|
266
|
+
step = (
|
|
267
|
+
"SELECT w.depth + 1, w.kind_id, w.key, n.* FROM walk w CROSS JOIN LATERAL "
|
|
268
|
+
"(SELECT * FROM ("
|
|
269
|
+
+ " UNION ALL ".join(f"({part})" for part in parts)
|
|
270
|
+
+ ") AS hop LIMIT :fanout) AS n WHERE w.depth < :depth"
|
|
271
|
+
if parts
|
|
272
|
+
else f"SELECT w.depth + 1, w.kind_id, w.key, w.kind_id, w.key, {_NULL_LINK} "
|
|
273
|
+
"FROM walk w WHERE false"
|
|
274
|
+
)
|
|
275
|
+
alias = "x" if layout.overlay else "m"
|
|
276
|
+
found = f"{alias}.kind_id = n.kind_id AND {alias}.key = n.key"
|
|
277
|
+
hydrate = (
|
|
278
|
+
# A lateral lookup per node over the overlay's and the base's rows at once.
|
|
279
|
+
f"CROSS JOIN LATERAL {layout.records_lookup(found)} AS m"
|
|
280
|
+
if layout.overlay
|
|
281
|
+
else f"JOIN mat_records m ON m.domain_id = :domain AND m.branch = :branch AND {found}"
|
|
282
|
+
)
|
|
283
|
+
sql = f"""
|
|
284
|
+
WITH RECURSIVE
|
|
285
|
+
out_rels(relation_id, from_kind, to_kind) AS (
|
|
286
|
+
SELECT * FROM unnest(CAST(:out_ids AS text[]), CAST(:out_from AS text[]),
|
|
287
|
+
CAST(:out_to AS text[]))),
|
|
288
|
+
in_rels(relation_id, from_kind, to_kind) AS (
|
|
289
|
+
SELECT * FROM unnest(CAST(:in_ids AS text[]), CAST(:in_from AS text[]),
|
|
290
|
+
CAST(:in_to AS text[]))),
|
|
291
|
+
fk_rels(relation_id, from_kind, to_kind, attribute) AS (
|
|
292
|
+
SELECT * FROM unnest(CAST(:fk_ids AS text[]), CAST(:fk_from AS text[]),
|
|
293
|
+
CAST(:fk_to AS text[]), CAST(:fk_attributes AS text[]))),
|
|
294
|
+
walk(depth, {_STEP_COLUMNS}) AS (
|
|
295
|
+
SELECT 0, CAST(NULL AS text), CAST(NULL AS text), s.kind_id, s.key, {_NULL_LINK}
|
|
296
|
+
FROM unnest(CAST(:seed_kinds AS text[]), CAST(:seed_keys AS text[])) AS s(kind_id, key)
|
|
297
|
+
UNION ALL
|
|
298
|
+
{step}
|
|
299
|
+
) CYCLE kind_id, key SET is_cycle USING path,
|
|
300
|
+
walked AS MATERIALIZED (SELECT depth, {_STEP_COLUMNS} FROM walk LIMIT :max_steps),
|
|
301
|
+
nodes AS MATERIALIZED (
|
|
302
|
+
SELECT kind_id, key, min(depth) AS depth FROM walked GROUP BY kind_id, key
|
|
303
|
+
ORDER BY min(depth), kind_id, key LIMIT :max_nodes + 1),
|
|
304
|
+
links AS (
|
|
305
|
+
SELECT DISTINCT ON (relation_id, from_key, to_key) relation_id, from_key, to_key,
|
|
306
|
+
link_values, link_hash
|
|
307
|
+
FROM walked w
|
|
308
|
+
WHERE relation_id IS NOT NULL
|
|
309
|
+
AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.from_kind AND n.key = w.from_key)
|
|
310
|
+
AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.to_kind AND n.key = w.to_key))
|
|
311
|
+
SELECT 'record' AS item, n.depth, {record_columns()},
|
|
312
|
+
NULL AS relation_id, NULL AS from_key, NULL AS to_key,
|
|
313
|
+
CAST(NULL AS jsonb) AS link_values, CAST(NULL AS bytea) AS link_hash
|
|
314
|
+
FROM nodes n
|
|
315
|
+
{hydrate}
|
|
316
|
+
UNION ALL
|
|
317
|
+
SELECT 'link', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
318
|
+
relation_id, from_key, to_key, link_values, link_hash
|
|
319
|
+
FROM links
|
|
320
|
+
UNION ALL
|
|
321
|
+
SELECT 'steps', (SELECT count(*) FROM walked), NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
322
|
+
NULL, NULL, NULL, NULL, NULL, NULL
|
|
323
|
+
UNION ALL
|
|
324
|
+
SELECT 'fanout', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
325
|
+
NULL, NULL
|
|
326
|
+
WHERE EXISTS (
|
|
327
|
+
SELECT 1 FROM walked WHERE depth > 0 GROUP BY depth, via_kind, via_key
|
|
328
|
+
HAVING count(DISTINCT (relation_id, from_key, to_key)) >= :fanout)
|
|
329
|
+
"""
|
|
330
|
+
return sql, params
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_RESULT_COLUMNS: tuple[ColumnElement[Any], ...] = (
|
|
334
|
+
column("item", Text),
|
|
335
|
+
column("depth", Integer),
|
|
336
|
+
*(MAT_RECORDS.c[name] for name in RECORD_NAMES),
|
|
337
|
+
column("relation_id", Text),
|
|
338
|
+
column("from_key", Text),
|
|
339
|
+
column("to_key", Text),
|
|
340
|
+
column("link_values", JSONB),
|
|
341
|
+
column("link_hash", LargeBinary),
|
|
342
|
+
)
|
|
343
|
+
_DEFINER = {
|
|
344
|
+
function: text(
|
|
345
|
+
f"SELECT item, depth, {record_columns(None)}, relation_id, from_key, to_key, "
|
|
346
|
+
f"link_values, link_hash FROM {function}(:domain, :branch, CAST(:seeds AS jsonb), "
|
|
347
|
+
"CAST(:depth AS integer), CAST(:relations AS jsonb), CAST(:fanout AS integer), "
|
|
348
|
+
"CAST(:max_steps AS integer), CAST(:max_nodes AS integer))"
|
|
349
|
+
).columns(*_RESULT_COLUMNS)
|
|
350
|
+
for function in ("lb_neighborhood", "lb_neighborhood_overlay")
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True, slots=True)
|
|
355
|
+
class _Walked:
|
|
356
|
+
records: list[Neighbor]
|
|
357
|
+
links: list[LinkRef]
|
|
358
|
+
steps: int
|
|
359
|
+
saturated: bool
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def neighborhood(
|
|
363
|
+
session: AsyncSession,
|
|
364
|
+
domain_id: str,
|
|
365
|
+
branch: str | BranchLayout,
|
|
366
|
+
schema: Schema,
|
|
367
|
+
seeds: Sequence[tuple[str, str]],
|
|
368
|
+
*,
|
|
369
|
+
depth: int = 2,
|
|
370
|
+
direction: Direction = Direction.BOTH,
|
|
371
|
+
relation_ids: Sequence[str] | None = None,
|
|
372
|
+
follow: Mapping[str, Direction] | None = None,
|
|
373
|
+
fanout: int | None = None,
|
|
374
|
+
max_nodes: int | None = None,
|
|
375
|
+
max_links: int | None = None,
|
|
376
|
+
runtime: StoreRuntime | None = None,
|
|
377
|
+
) -> Neighborhood:
|
|
378
|
+
"""Records within `depth` hops of `seeds` (`(kind_id, key)` pairs) and the Links between.
|
|
379
|
+
|
|
380
|
+
Follows `direction` over `relation_ids` (every Relation when `None`), or exactly the
|
|
381
|
+
`(relation id, direction)` pairs of `follow`; path Derivations are followed when named
|
|
382
|
+
there (their id is the Relation id). Caps default to and may not exceed
|
|
383
|
+
`store.traverse`. Seeds that do not exist are absent from the result.
|
|
384
|
+
"""
|
|
385
|
+
runtime = runtime or default_runtime()
|
|
386
|
+
caps = runtime.settings.traverse
|
|
387
|
+
layout = await resolve_layout(session, domain_id, branch)
|
|
388
|
+
seeds = list(dict.fromkeys(seeds))
|
|
389
|
+
_within("seed count", len(seeds), caps.max_seeds)
|
|
390
|
+
nodes_cap = _within("max_nodes", max_nodes or caps.max_nodes, caps.max_nodes)
|
|
391
|
+
links_cap = _within("max_links", max_links or caps.max_links, caps.max_links)
|
|
392
|
+
bounds: dict[str, Any] = {
|
|
393
|
+
"domain": domain_id,
|
|
394
|
+
"branch": layout.branch,
|
|
395
|
+
"depth": _within("depth", depth, caps.max_depth),
|
|
396
|
+
"fanout": _within("fanout", fanout or caps.max_fanout, caps.max_fanout),
|
|
397
|
+
"max_steps": links_cap + len(seeds),
|
|
398
|
+
"max_nodes": nodes_cap,
|
|
399
|
+
}
|
|
400
|
+
if follow is None and relation_ids is not None:
|
|
401
|
+
follow = dict.fromkeys(relation_ids, direction)
|
|
402
|
+
composed = {} if follow is None else composed_paths(schema, follow)
|
|
403
|
+
if follow is not None and composed:
|
|
404
|
+
walked = await _hop_by_hop(
|
|
405
|
+
session, layout, schema, seeds, follow, composed, bounds, runtime
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
walked = await _walk(session, layout, schema, seeds, direction, follow, bounds, runtime)
|
|
409
|
+
records = sorted(walked.records, key=lambda n: (n.depth, n.record.kind_id, n.record.key))
|
|
410
|
+
truncated = walked.saturated or walked.steps >= bounds["max_steps"] or len(records) > nodes_cap
|
|
411
|
+
if len(records) > nodes_cap:
|
|
412
|
+
records = records[:nodes_cap]
|
|
413
|
+
kept = {(n.record.kind_id, n.record.key) for n in records}
|
|
414
|
+
links = [link for link in walked.links if _between(schema, link, kept)]
|
|
415
|
+
links.sort(key=lambda link: (link.relation_id, link.from_key, link.to_key))
|
|
416
|
+
return Neighborhood(records, links, truncated)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _walk(
|
|
420
|
+
session: AsyncSession,
|
|
421
|
+
layout: BranchLayout,
|
|
422
|
+
schema: Schema,
|
|
423
|
+
seeds: Sequence[tuple[str, str]],
|
|
424
|
+
direction: Direction,
|
|
425
|
+
follow: Mapping[str, Direction] | None,
|
|
426
|
+
bounds: Mapping[str, Any],
|
|
427
|
+
runtime: StoreRuntime,
|
|
428
|
+
) -> _Walked:
|
|
429
|
+
"""One neighborhood statement over Relations and materialized paths."""
|
|
430
|
+
if runtime.settings.reads.use_definer_functions:
|
|
431
|
+
hops = plan_hops(schema, direction, None, follow=follow)
|
|
432
|
+
params = {
|
|
433
|
+
**bounds,
|
|
434
|
+
"seeds": json.dumps([list(seed) for seed in seeds]),
|
|
435
|
+
"relations": hops.as_json(),
|
|
436
|
+
}
|
|
437
|
+
function = "lb_neighborhood_overlay" if layout.overlay else "lb_neighborhood"
|
|
438
|
+
record_definer_call(function)
|
|
439
|
+
statement = _DEFINER[function]
|
|
440
|
+
else:
|
|
441
|
+
sql, params = neighborhood_sql(
|
|
442
|
+
schema, layout.domain_id, layout, direction, None, follow=follow
|
|
443
|
+
)
|
|
444
|
+
params.update(
|
|
445
|
+
bounds,
|
|
446
|
+
seed_kinds=[kind for kind, _ in seeds],
|
|
447
|
+
seed_keys=[key for _, key in seeds],
|
|
448
|
+
)
|
|
449
|
+
statement = text(sql).columns(*_RESULT_COLUMNS)
|
|
450
|
+
records: list[Neighbor] = []
|
|
451
|
+
links: list[LinkRef] = []
|
|
452
|
+
steps = 0
|
|
453
|
+
saturated = False
|
|
454
|
+
for row in (await session.execute(statement, params)).mappings():
|
|
455
|
+
if row["item"] == "record":
|
|
456
|
+
record = RecordRow(**{name: row[name] for name in RECORD_NAMES})
|
|
457
|
+
records.append(Neighbor(row["depth"], record))
|
|
458
|
+
elif row["item"] == "link":
|
|
459
|
+
links.append(
|
|
460
|
+
LinkRef(
|
|
461
|
+
row["relation_id"],
|
|
462
|
+
row["from_key"],
|
|
463
|
+
row["to_key"],
|
|
464
|
+
row["link_values"],
|
|
465
|
+
row["link_hash"],
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
elif row["item"] == "fanout":
|
|
469
|
+
saturated = True
|
|
470
|
+
else:
|
|
471
|
+
steps = row["depth"]
|
|
472
|
+
return _Walked(records, links, steps, saturated)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def _hop_by_hop(
|
|
476
|
+
session: AsyncSession,
|
|
477
|
+
layout: BranchLayout,
|
|
478
|
+
schema: Schema,
|
|
479
|
+
seeds: Sequence[tuple[str, str]],
|
|
480
|
+
follow: Mapping[str, Direction],
|
|
481
|
+
composed: Mapping[str, tuple[PathRelation, Direction]],
|
|
482
|
+
bounds: Mapping[str, Any],
|
|
483
|
+
runtime: StoreRuntime,
|
|
484
|
+
) -> _Walked:
|
|
485
|
+
"""A walk following composed path Derivations, one hop at a time (module docstring):
|
|
486
|
+
`steps` counts the seeds and the Links walked, like the statement's."""
|
|
487
|
+
settings = runtime.settings
|
|
488
|
+
repository = MaterializedRepository(
|
|
489
|
+
session, layout.domain_id, layout.branch, writes=settings.writes, layout=layout
|
|
490
|
+
)
|
|
491
|
+
others = {rid: way for rid, way in follow.items() if rid not in composed}
|
|
492
|
+
fanout = int(bounds["fanout"])
|
|
493
|
+
max_steps = int(bounds["max_steps"])
|
|
494
|
+
max_nodes = int(bounds["max_nodes"])
|
|
495
|
+
depths: dict[tuple[str, str], int] = dict.fromkeys(seeds, 0)
|
|
496
|
+
rows: dict[tuple[str, str], RecordRow] = {}
|
|
497
|
+
links: dict[tuple[str, str, str], LinkRef] = {}
|
|
498
|
+
frontier = list(seeds)
|
|
499
|
+
steps = len(seeds)
|
|
500
|
+
saturated = False
|
|
501
|
+
for level in range(1, int(bounds["depth"]) + 1):
|
|
502
|
+
if not frontier or steps >= max_steps or len(depths) > max_nodes:
|
|
503
|
+
break
|
|
504
|
+
reached: list[tuple[str, str]] = []
|
|
505
|
+
if others:
|
|
506
|
+
hop = await _walk(
|
|
507
|
+
session,
|
|
508
|
+
layout,
|
|
509
|
+
schema,
|
|
510
|
+
frontier,
|
|
511
|
+
Direction.BOTH,
|
|
512
|
+
others,
|
|
513
|
+
{
|
|
514
|
+
**bounds,
|
|
515
|
+
"depth": 1,
|
|
516
|
+
"max_steps": max_steps - steps + len(frontier),
|
|
517
|
+
"max_nodes": max_nodes + len(frontier),
|
|
518
|
+
},
|
|
519
|
+
runtime,
|
|
520
|
+
)
|
|
521
|
+
saturated = saturated or hop.saturated
|
|
522
|
+
steps += max(hop.steps - len(frontier), 0)
|
|
523
|
+
for neighbor in hop.records:
|
|
524
|
+
identity = (neighbor.record.kind_id, neighbor.record.key)
|
|
525
|
+
rows.setdefault(identity, neighbor.record)
|
|
526
|
+
if neighbor.depth == 1:
|
|
527
|
+
reached.append(identity)
|
|
528
|
+
for link in hop.links:
|
|
529
|
+
links.setdefault((link.relation_id, link.from_key, link.to_key), link)
|
|
530
|
+
for path_id, (path, way) in composed.items():
|
|
531
|
+
for along in _directions(way):
|
|
532
|
+
keys = sorted(
|
|
533
|
+
{key for kind, key in frontier if kind == path.relation.endpoint(along)}
|
|
534
|
+
)
|
|
535
|
+
if not keys:
|
|
536
|
+
continue
|
|
537
|
+
if steps >= max_steps or len(depths.keys() | reached) > max_nodes:
|
|
538
|
+
saturated = True # Links left unwalked
|
|
539
|
+
continue
|
|
540
|
+
far_kind = path.relation.far_endpoint(along)
|
|
541
|
+
found = await path_links_of(
|
|
542
|
+
repository, path, along, keys, settings.traverse.path_fanout
|
|
543
|
+
)
|
|
544
|
+
for near, items in sorted(found.items()):
|
|
545
|
+
# The walk's fan-out and step budget, not the path's own bound
|
|
546
|
+
# (`store.traverse.path_fanout`), which is part of what the path is.
|
|
547
|
+
room = min(fanout, max(max_steps - steps, 0))
|
|
548
|
+
saturated = saturated or len(items) > room
|
|
549
|
+
for far, _ in items[:room]:
|
|
550
|
+
pair = (near, far) if along is LinkDirection.FORWARD else (far, near)
|
|
551
|
+
links.setdefault((path_id, *pair), LinkRef(path_id, *pair, {}, None))
|
|
552
|
+
reached.append((far_kind, far))
|
|
553
|
+
steps += 1
|
|
554
|
+
frontier = []
|
|
555
|
+
for identity in reached:
|
|
556
|
+
if identity not in depths:
|
|
557
|
+
depths[identity] = level
|
|
558
|
+
frontier.append(identity)
|
|
559
|
+
missing = [identity for identity in depths if identity not in rows]
|
|
560
|
+
rows.update(await hydrate_records(session, layout.domain_id, layout, missing, runtime=runtime))
|
|
561
|
+
records = [
|
|
562
|
+
Neighbor(depth, rows[identity]) for identity, depth in depths.items() if identity in rows
|
|
563
|
+
]
|
|
564
|
+
return _Walked(records, list(links.values()), steps, saturated)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _directions(way: Direction) -> list[LinkDirection]:
|
|
568
|
+
return [
|
|
569
|
+
*([LinkDirection.FORWARD] if way.outgoing else []),
|
|
570
|
+
*([LinkDirection.INVERSE] if way.incoming else []),
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _between(schema: Schema, link: LinkRef, kept: set[tuple[str, str]]) -> bool:
|
|
575
|
+
relation = schema.link_relation(link.relation_id)
|
|
576
|
+
if relation is None:
|
|
577
|
+
return False
|
|
578
|
+
return (relation.from_kind, link.from_key) in kept and (relation.to_kind, link.to_key) in kept
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The personal data vault (design 13.5): settings, keys, storage, erasure and key rotation."""
|
|
2
|
+
|
|
3
|
+
from lorebase_store.vault.keys import VaultKeys
|
|
4
|
+
from lorebase_store.vault.quotes import QuoteLimits, has_vault_values, quoted_values
|
|
5
|
+
from lorebase_store.vault.rotation import KeyUsage, RotationReport, rotate, usage
|
|
6
|
+
from lorebase_store.vault.settings import ErasedValues, VaultSettings, vault_settings
|
|
7
|
+
from lorebase_store.vault.store import (
|
|
8
|
+
Lineage,
|
|
9
|
+
Tokenized,
|
|
10
|
+
VaultRow,
|
|
11
|
+
VaultState,
|
|
12
|
+
VaultValue,
|
|
13
|
+
erase_matching,
|
|
14
|
+
erase_record,
|
|
15
|
+
insert_lineage,
|
|
16
|
+
insert_rows,
|
|
17
|
+
linked_keys,
|
|
18
|
+
matching_keys,
|
|
19
|
+
open_values,
|
|
20
|
+
tokenize,
|
|
21
|
+
vault_attributes,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ErasedValues",
|
|
26
|
+
"KeyUsage",
|
|
27
|
+
"Lineage",
|
|
28
|
+
"QuoteLimits",
|
|
29
|
+
"RotationReport",
|
|
30
|
+
"Tokenized",
|
|
31
|
+
"VaultKeys",
|
|
32
|
+
"VaultRow",
|
|
33
|
+
"VaultSettings",
|
|
34
|
+
"VaultState",
|
|
35
|
+
"VaultValue",
|
|
36
|
+
"erase_matching",
|
|
37
|
+
"erase_record",
|
|
38
|
+
"has_vault_values",
|
|
39
|
+
"insert_lineage",
|
|
40
|
+
"insert_rows",
|
|
41
|
+
"linked_keys",
|
|
42
|
+
"matching_keys",
|
|
43
|
+
"open_values",
|
|
44
|
+
"quoted_values",
|
|
45
|
+
"rotate",
|
|
46
|
+
"tokenize",
|
|
47
|
+
"usage",
|
|
48
|
+
"vault_attributes",
|
|
49
|
+
"vault_settings",
|
|
50
|
+
]
|