lorebase-keeper 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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,512 @@
1
+ """Queries and checks behind the health reports, over the materialized head of one branch.
2
+
3
+ Each function answers one question for one Kind or Relation and reads the database through the
4
+ caller's session. Nothing here writes. Statements are scoped to the Domain and the branch and
5
+ use only primary-key friendly predicates, so the cost is a scan per Kind (validation, duplicates,
6
+ derived values) or per Relation (orphans and gaps) at worst. An overlay branch is read through
7
+ its resolved rows (`lorebase_store.layout`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from collections import defaultdict
14
+ from collections.abc import Callable, Iterable, Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from functools import cached_property
17
+ from typing import Any
18
+
19
+ from sqlalchemy import (
20
+ ColumnElement,
21
+ FromClause,
22
+ and_,
23
+ exists,
24
+ func,
25
+ literal_column,
26
+ or_,
27
+ select,
28
+ text,
29
+ )
30
+ from sqlalchemy.dialects.postgresql import distinct_on
31
+ from sqlalchemy.ext.asyncio import AsyncSession
32
+
33
+ from lorebase_core.model import Attribute, Kind, Record, Relation, Schema
34
+ from lorebase_core.model.relation import RelationBacking
35
+ from lorebase_core.tree import kind_partition
36
+ from lorebase_core.validate import Issue, validate_record
37
+ from lorebase_keeper.kindhealth.config import HealthSettings
38
+ from lorebase_keeper.kindhealth.models import (
39
+ DerivedReport,
40
+ DuplicateGroup,
41
+ DuplicateReport,
42
+ HiddenDuplicates,
43
+ IssueGroup,
44
+ LastChange,
45
+ LinkRef,
46
+ OrphanLinks,
47
+ RelationHealth,
48
+ RequiredGaps,
49
+ ViolationReport,
50
+ )
51
+ from lorebase_keeper.reading.views import kind_name
52
+ from lorebase_store.layout import BranchLayout
53
+
54
+ BATCH = 500
55
+ """Records fetched per round trip while re-validating."""
56
+
57
+ _DUPLICATES = r"""
58
+ WITH normalized AS (
59
+ SELECT key, lower(regexp_replace(btrim("values" ->> :attribute), '\s+', ' ', 'g')) AS norm
60
+ FROM {records}
61
+ WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
62
+ AND "values" ->> :attribute IS NOT NULL
63
+ )
64
+ SELECT norm, count(*) AS members,
65
+ (array_agg(key ORDER BY key))[1:CAST(:keys AS int)] AS member_keys,
66
+ count(*) OVER () AS total_groups,
67
+ sum(count(*)) OVER () AS total_members
68
+ FROM normalized
69
+ WHERE norm <> ''
70
+ GROUP BY norm
71
+ HAVING count(*) > 1
72
+ ORDER BY count(*) DESC, norm
73
+ LIMIT :groups
74
+ """
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Branch:
79
+ """The materialized head being examined; `layout` locates its rows (default: the
80
+ branch's own rows)."""
81
+
82
+ domain_id: str
83
+ branch: str
84
+ layout: BranchLayout | None = None
85
+
86
+ @cached_property
87
+ def rows(self) -> BranchLayout:
88
+ return self.layout or BranchLayout(self.domain_id, self.branch)
89
+
90
+ @cached_property
91
+ def records(self) -> FromClause:
92
+ return self.rows.records()
93
+
94
+ @cached_property
95
+ def links(self) -> FromClause:
96
+ return self.rows.links()
97
+
98
+ @cached_property
99
+ def history(self) -> FromClause:
100
+ return self.rows.history()
101
+
102
+
103
+ def _records(where: Branch) -> ColumnElement[bool]:
104
+ r = where.records
105
+ return and_(r.c.domain_id == where.domain_id, r.c.branch == where.branch)
106
+
107
+
108
+ def _links(where: Branch) -> ColumnElement[bool]:
109
+ link = where.links
110
+ return and_(link.c.domain_id == where.domain_id, link.c.branch == where.branch)
111
+
112
+
113
+ # Counts and history -------------------------------------------------------------------------
114
+
115
+
116
+ async def record_counts(session: AsyncSession, where: Branch) -> dict[str, int]:
117
+ """Records per Kind id."""
118
+ r = where.records
119
+ result = await session.execute(
120
+ select(r.c.kind_id, func.count()).where(_records(where)).group_by(r.c.kind_id)
121
+ )
122
+ return {kind_id: int(count) for kind_id, count in result}
123
+
124
+
125
+ async def link_counts(session: AsyncSession, where: Branch) -> dict[str, int]:
126
+ """Stored Links per Relation id."""
127
+ link = where.links
128
+ result = await session.execute(
129
+ select(link.c.relation_id, func.count()).where(_links(where)).group_by(link.c.relation_id)
130
+ )
131
+ return {relation_id: int(count) for relation_id, count in result}
132
+
133
+
134
+ async def last_changes(
135
+ session: AsyncSession, where: Branch, kinds: Iterable[str]
136
+ ) -> dict[str, LastChange]:
137
+ """The latest Revision of the branch that touched each Kind (Record writes and deletions)."""
138
+ partitions = {kind_partition(kind_id): kind_id for kind_id in kinds}
139
+ if not partitions:
140
+ return {}
141
+ h = where.history
142
+ result = await session.execute(
143
+ select(h.c.partition, h.c.revision_id, h.c.at)
144
+ .ext(distinct_on(h.c.partition))
145
+ .where(
146
+ h.c.domain_id == where.domain_id,
147
+ h.c.branch == where.branch,
148
+ h.c.partition.in_(list(partitions)),
149
+ )
150
+ .order_by(h.c.partition, h.c.at.desc(), h.c.revision_id)
151
+ )
152
+ return {
153
+ partitions[partition]: LastChange(revision=bytes(revision).hex(), at=at)
154
+ for partition, revision, at in result
155
+ }
156
+
157
+
158
+ # Constraint violations ----------------------------------------------------------------------
159
+
160
+
161
+ def _sample_filter(step: int) -> ColumnElement[bool]:
162
+ """Keeps one Record in `step`, chosen by a hash of its key: spread evenly and stable."""
163
+ bits = "('x' || substr(md5(mat_records.key), 1, 7))::bit(28)::int"
164
+ return literal_column(f"mod({bits}, {int(step)})") == 0
165
+
166
+
167
+ async def validate_records(
168
+ session: AsyncSession,
169
+ where: Branch,
170
+ schema: Schema,
171
+ kind: Kind,
172
+ total: int,
173
+ settings: HealthSettings,
174
+ generalize: Callable[[list[Issue]], list[Issue]] | None = None,
175
+ hidden: Callable[[Attribute], bool] = lambda a: False,
176
+ *,
177
+ projected: bool = False,
178
+ ) -> ViolationReport:
179
+ """Re-validate the Records of a Kind with `validate_record` against the branch's schema.
180
+ `generalize` rewrites the issues of each Record before they are grouped (issues about
181
+ values the caller may not read lose their specific code); issues about a `hidden`
182
+ Attribute list no Record keys, which would tell which values fail the rule. With
183
+ `projected` (a schema projected to the Kinds the caller may read, design 13.3), values of
184
+ Attributes the projection left out are not validated."""
185
+ r = where.records
186
+ step = max(1, math.ceil(total / settings.max_records_validated))
187
+ checked = invalid = 0
188
+ groups: dict[tuple[str, str | None], list[Any]] = defaultdict(lambda: [0, []])
189
+ last: str | None = None
190
+ while checked < settings.max_records_validated:
191
+ query = (
192
+ select(r.c.key, r.c["values"])
193
+ .where(_records(where), r.c.kind_id == kind.id)
194
+ .order_by(r.c.key)
195
+ .limit(min(BATCH, settings.max_records_validated - checked))
196
+ )
197
+ if step > 1:
198
+ query = query.where(_sample_filter(step))
199
+ if last is not None:
200
+ query = query.where(r.c.key > last)
201
+ rows = (await session.execute(query)).all()
202
+ if not rows:
203
+ break
204
+ for key, values in rows:
205
+ checked += 1
206
+ if projected:
207
+ values = {k: v for k, v in (values or {}).items() if k in kind.attributes}
208
+ issues = validate_record(
209
+ schema, Record.model_construct(kind=kind.id, key=key, values=values)
210
+ )
211
+ if generalize is not None:
212
+ issues = generalize(issues)
213
+ if issues:
214
+ invalid += 1
215
+ seen: set[tuple[str, str | None]] = set()
216
+ for issue in issues:
217
+ attribute = kind.attributes.get(issue.attribute) if issue.attribute else None
218
+ name = attribute.api_name if attribute is not None else issue.attribute
219
+ label = (issue.code.value, name)
220
+ entry = groups[label]
221
+ entry[0] += 1
222
+ if (
223
+ label not in seen
224
+ and len(entry[1]) < settings.examples_per_issue
225
+ and not (attribute is not None and hidden(attribute))
226
+ ):
227
+ entry[1].append(key)
228
+ seen.add(label)
229
+ last = rows[-1][0]
230
+ ordered = sorted(groups.items(), key=lambda item: (-item[1][0], item[0][0], item[0][1] or ""))
231
+ return ViolationReport(
232
+ checked=checked,
233
+ sampled=step > 1,
234
+ invalid_records=invalid,
235
+ issues=[
236
+ IssueGroup(code=code, attribute=name, count=count, examples=examples)
237
+ for (code, name), (count, examples) in ordered[: settings.max_issue_groups]
238
+ ],
239
+ issues_truncated=len(ordered) > settings.max_issue_groups,
240
+ )
241
+
242
+
243
+ # Duplicates ---------------------------------------------------------------------------------
244
+
245
+
246
+ def duplicate_attributes(kind: Kind) -> list[str]:
247
+ """Attribute ids checked for duplicates: unique ones, then the title Attribute."""
248
+ ids = [a.id for a in kind.attributes.values() if a.unique and not a.is_derived]
249
+ title = kind.attributes.get(kind.title_attribute) if kind.title_attribute else None
250
+ if title is not None and not title.is_derived and title.id not in ids:
251
+ ids.append(title.id)
252
+ return ids
253
+
254
+
255
+ async def find_duplicates(
256
+ session: AsyncSession,
257
+ where: Branch,
258
+ kind: Kind,
259
+ total: int,
260
+ settings: HealthSettings,
261
+ sensitive: Callable[[Attribute], bool] = lambda a: a.is_sensitive,
262
+ hidden: Callable[[Attribute], bool] = lambda a: False,
263
+ ) -> DuplicateReport:
264
+ """Records of a Kind sharing a unique or title value, ignoring case and spacing.
265
+
266
+ Groups of a `hidden` Attribute (one whose values the caller may not read) are not listed:
267
+ which Records share a value would tell that their values are equal. Only how many groups
268
+ and Records there are is reported for it."""
269
+ ids = duplicate_attributes(kind)
270
+ names = [kind.attributes[i].api_name for i in ids]
271
+ if settings.duplicates_max_records == 0:
272
+ return DuplicateReport(attributes=names, groups=[], total_groups=0, skipped="disabled")
273
+ if total > settings.duplicates_max_records:
274
+ return DuplicateReport(
275
+ attributes=names, groups=[], total_groups=0, skipped="too_many_records"
276
+ )
277
+ found: list[DuplicateGroup] = []
278
+ withheld: list[HiddenDuplicates] = []
279
+ groups = 0
280
+ for attribute_id in ids:
281
+ attribute = kind.attributes[attribute_id]
282
+ rows = (
283
+ await session.execute(
284
+ text(_DUPLICATES.format(records=where.rows.records_from())),
285
+ {
286
+ "domain": where.domain_id,
287
+ "branch": where.branch,
288
+ "kind": kind.id,
289
+ "attribute": attribute_id,
290
+ "keys": settings.duplicate_keys_per_group,
291
+ "groups": settings.max_duplicate_groups,
292
+ },
293
+ )
294
+ ).all()
295
+ if rows:
296
+ groups += int(rows[0].total_groups)
297
+ if hidden(attribute):
298
+ if rows:
299
+ withheld.append(
300
+ HiddenDuplicates(
301
+ attribute=attribute.api_name,
302
+ unique=attribute.unique,
303
+ groups=int(rows[0].total_groups),
304
+ records=int(rows[0].total_members),
305
+ )
306
+ )
307
+ continue
308
+ found.extend(
309
+ DuplicateGroup(
310
+ attribute=attribute.api_name,
311
+ unique=attribute.unique,
312
+ value=None if sensitive(attribute) else row.norm,
313
+ count=int(row.members),
314
+ keys=list(row.member_keys),
315
+ )
316
+ for row in rows
317
+ )
318
+ return DuplicateReport(attributes=names, groups=found, total_groups=groups, hidden=withheld)
319
+
320
+
321
+ # Derived values -----------------------------------------------------------------------------
322
+
323
+
324
+ async def derived_status(
325
+ session: AsyncSession, where: Branch, kind: Kind, total: int
326
+ ) -> DerivedReport | None:
327
+ """Records lacking the materialized value of a derived Attribute (None: no such Attribute)."""
328
+ derived = [a for a in kind.attributes.values() if a.is_derived]
329
+ if not derived:
330
+ return None
331
+ r = where.records
332
+ missing = 0
333
+ if total:
334
+ missing = int(
335
+ (
336
+ await session.execute(
337
+ select(func.count()).where(
338
+ _records(where),
339
+ r.c.kind_id == kind.id,
340
+ or_(*[~r.c.derived.has_key(a.id) for a in derived]),
341
+ )
342
+ )
343
+ ).scalar_one()
344
+ )
345
+ return DerivedReport(
346
+ attributes=[a.api_name for a in derived], records_missing_value=missing, stale=missing > 0
347
+ )
348
+
349
+
350
+ # Relations ----------------------------------------------------------------------------------
351
+
352
+
353
+ def _gaps(missing: int, keys: Iterable[str]) -> RequiredGaps:
354
+ return RequiredGaps(missing=missing, examples=list(keys))
355
+
356
+
357
+ async def relation_health(
358
+ session: AsyncSession,
359
+ where: Branch,
360
+ schema: Schema,
361
+ relation: Relation,
362
+ stored_links: Mapping[str, int],
363
+ settings: HealthSettings,
364
+ ) -> RelationHealth:
365
+ """Links, orphan Links and required-relation gaps of a Relation."""
366
+ if relation.backing is RelationBacking.FOREIGN_KEY and relation.foreign_key_attribute:
367
+ links, orphans, gaps = await _foreign_key_health(session, where, relation, settings)
368
+ else:
369
+ links = stored_links.get(relation.id, 0)
370
+ orphans = await _orphan_links(session, where, relation, settings)
371
+ gaps = await _missing_links(session, where, relation, settings)
372
+ return RelationHealth(
373
+ relation=relation.api_name,
374
+ from_kind=kind_name(schema, relation.from_kind),
375
+ to_kind=kind_name(schema, relation.to_kind),
376
+ backing=relation.backing,
377
+ links=links,
378
+ orphan_links=orphans,
379
+ required=relation.required_from,
380
+ required_gaps=gaps if relation.required_from else None,
381
+ )
382
+
383
+
384
+ async def _orphan_links(
385
+ session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
386
+ ) -> OrphanLinks:
387
+ link = where.links
388
+ f, t = where.rows.records("f"), where.rows.records("t")
389
+ from_exists = exists().where(
390
+ f.c.domain_id == link.c.domain_id,
391
+ f.c.branch == link.c.branch,
392
+ f.c.kind_id == relation.from_kind,
393
+ f.c.key == link.c.from_key,
394
+ )
395
+ to_exists = exists().where(
396
+ t.c.domain_id == link.c.domain_id,
397
+ t.c.branch == link.c.branch,
398
+ t.c.kind_id == relation.to_kind,
399
+ t.c.key == link.c.to_key,
400
+ )
401
+ scope = (_links(where), link.c.relation_id == relation.id)
402
+ counts = (
403
+ await session.execute(
404
+ select(
405
+ func.count().filter(~from_exists),
406
+ func.count().filter(~to_exists),
407
+ func.count().filter(or_(~from_exists, ~to_exists)),
408
+ ).where(*scope)
409
+ )
410
+ ).one()
411
+ examples: list[LinkRef] = []
412
+ if counts[2] and settings.orphan_examples:
413
+ rows = await session.execute(
414
+ select(link.c.from_key, link.c.to_key)
415
+ .where(*scope, or_(~from_exists, ~to_exists))
416
+ .order_by(link.c.from_key, link.c.to_key)
417
+ .limit(settings.orphan_examples)
418
+ )
419
+ examples = [LinkRef(from_key=a, to_key=b) for a, b in rows]
420
+ return OrphanLinks(
421
+ count=int(counts[2]),
422
+ missing_from=int(counts[0]),
423
+ missing_to=int(counts[1]),
424
+ examples=examples,
425
+ )
426
+
427
+
428
+ async def _missing_links(
429
+ session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
430
+ ) -> RequiredGaps:
431
+ r, link = where.records, where.links
432
+ linked = exists().where(
433
+ link.c.domain_id == r.c.domain_id,
434
+ link.c.branch == r.c.branch,
435
+ link.c.relation_id == relation.id,
436
+ link.c.from_key == r.c.key,
437
+ )
438
+ return await _gap_query(session, where, relation.from_kind, ~linked, settings)
439
+
440
+
441
+ async def _gap_query(
442
+ session: AsyncSession,
443
+ where: Branch,
444
+ kind_id: str,
445
+ condition: ColumnElement[bool],
446
+ settings: HealthSettings,
447
+ ) -> RequiredGaps:
448
+ r = where.records
449
+ scope = (_records(where), r.c.kind_id == kind_id, condition)
450
+ missing = int((await session.execute(select(func.count()).where(*scope))).scalar_one())
451
+ keys: Sequence[str] = []
452
+ if missing and settings.orphan_examples:
453
+ rows = await session.execute(
454
+ select(r.c.key).where(*scope).order_by(r.c.key).limit(settings.orphan_examples)
455
+ )
456
+ keys = [key for (key,) in rows]
457
+ return _gaps(missing, keys)
458
+
459
+
460
+ async def _foreign_key_health(
461
+ session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
462
+ ) -> tuple[int, OrphanLinks, RequiredGaps]:
463
+ """A foreign-key backed Relation has no stored Links: they are the `ref` values of the
464
+ `from` Kind. Orphans are references to missing Records; gaps are Records without one."""
465
+ r, t = where.records, where.rows.records("t")
466
+ attribute = str(relation.foreign_key_attribute)
467
+ value = r.c["values"][attribute].astext
468
+ target_exists = exists().where(
469
+ t.c.domain_id == r.c.domain_id,
470
+ t.c.branch == r.c.branch,
471
+ t.c.kind_id == relation.to_kind,
472
+ t.c.key == value,
473
+ )
474
+ scope = (_records(where), r.c.kind_id == relation.from_kind)
475
+ links = int(
476
+ (await session.execute(select(func.count()).where(*scope, value.is_not(None)))).scalar_one()
477
+ )
478
+ dangling = (*scope, value.is_not(None), ~target_exists)
479
+ count = int((await session.execute(select(func.count()).where(*dangling))).scalar_one())
480
+ examples: list[LinkRef] = []
481
+ if count and settings.orphan_examples:
482
+ rows = await session.execute(
483
+ select(r.c.key, value)
484
+ .where(*dangling)
485
+ .order_by(r.c.key)
486
+ .limit(settings.orphan_examples)
487
+ )
488
+ examples = [LinkRef(from_key=a, to_key=b) for a, b in rows]
489
+ orphans = OrphanLinks(count=count, missing_from=0, missing_to=count, examples=examples)
490
+ gaps = await _gap_query(session, where, relation.from_kind, value.is_(None), settings)
491
+ return links, orphans, gaps
492
+
493
+
494
+ def is_timeout(error: BaseException) -> bool:
495
+ """Whether a database error is a statement timeout (SQLSTATE 57014)."""
496
+ original = getattr(error, "orig", error)
497
+ code = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
498
+ return code == "57014" or "statement timeout" in str(original)
499
+
500
+
501
+ __all__ = [
502
+ "Branch",
503
+ "derived_status",
504
+ "duplicate_attributes",
505
+ "find_duplicates",
506
+ "is_timeout",
507
+ "last_changes",
508
+ "link_counts",
509
+ "record_counts",
510
+ "relation_health",
511
+ "validate_records",
512
+ ]