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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,987 @@
1
+ """The `vault` table: storing values behind tokens, opening them, erasing them (design 13.5).
2
+
3
+ - `tokenize` runs in the commit pipeline: the plain values of vault Attributes in Record
4
+ upserts become references, and the rows to insert are returned. A reference already in an
5
+ upsert is accepted when it is the value the Record holds at the branch head, or when a vault
6
+ row binds it to the same Kind, Record key and Attribute. A reference another Record of the
7
+ Kind holds in the same Attribute is taken over when the same change set deletes that Record
8
+ and the taking Record does not exist at the head (a key change), once per reference: its
9
+ value is stored again under a token of the new key, and `vault_lineage` records that both
10
+ keys hold the same data subject's values. Any other reference is refused, so a writer cannot
11
+ copy another Record's value.
12
+ - In commits of the origins of `vault.tombstone_origins` (Source syncs, extraction, imports),
13
+ a plain value whose fingerprint an erasure recorded (`vault_tombstones`) is stored erased,
14
+ refused, or stored again, as `vault.erased_values` says. Other writers are people and agents
15
+ who choose the value: checking their writes would tell them whether a guessed value was
16
+ erased.
17
+ - `open_values` reads rows by token: the value, `erased`, or `unavailable` (the key that
18
+ sealed it is not configured).
19
+ - `erase_record` and `erase_matching` erase rows: the ciphertext, key id and fingerprint are
20
+ cleared and `erased_at` is set. The row stays, so the token keeps answering `erased`; the
21
+ fingerprints of the erased values go to `vault_tombstones`. Erasing an erased row changes
22
+ nothing. Erasure by record follows `vault_lineage` to every key the Record had; erasure by
23
+ value compares matching forms (`lorebase_core.types.matching`), so case variants of the value
24
+ are erased too.
25
+
26
+ Commits that store vault values hold the Domain's vault lock shared from the first vault read to
27
+ the end of their transaction, erasures hold it exclusively: an erasure waits for the commits in
28
+ flight, and a commit that starts during an erasure reads the vault after it. A value is thus
29
+ either written before the erasure (and erased by it) or after it (and finds its tombstone).
30
+
31
+ Every statement filters on `domain_id` and runs under the Domain's row-level security scope.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from collections import Counter, defaultdict
38
+ from collections.abc import Collection, Iterable, Sequence
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime
41
+ from enum import StrEnum
42
+ from typing import Any, Protocol
43
+
44
+ from pydantic import JsonValue
45
+ from sqlalchemy import Table, func, select, text, update
46
+ from sqlalchemy.dialects.postgresql import insert
47
+ from sqlalchemy.exc import DBAPIError
48
+ from sqlalchemy.ext.asyncio import AsyncSession
49
+ from sqlalchemy.orm import Session
50
+
51
+ from lorebase_core.model import Record, Schema, looks_like_reference, vault_reference, vault_token
52
+ from lorebase_core.validate import (
53
+ DataChange,
54
+ Issue,
55
+ IssueCode,
56
+ RecordDelete,
57
+ RecordUpsert,
58
+ check_values,
59
+ )
60
+ from lorebase_store.models import metadata
61
+ from lorebase_store.repositories.refs import LOCK_NOT_AVAILABLE, sqlstate
62
+ from lorebase_store.vault.keys import VaultKeys
63
+ from lorebase_store.vault.settings import ErasedValues
64
+
65
+ VAULT: Table = metadata.tables["vault"]
66
+ TOMBSTONES: Table = metadata.tables["vault_tombstones"]
67
+ LINEAGE: Table = metadata.tables["vault_lineage"]
68
+
69
+ MAX_GENERATIONS = 64
70
+ """Erased generations of one value of one Record tried before a write is refused."""
71
+
72
+ MAX_LINKED_KEYS = 1000
73
+ """Keys of one data subject above which an erasure by key is flagged for an administrator
74
+ (`Erasure.linked`); it still erases every one of them."""
75
+
76
+
77
+ class VaultLockTimeoutError(Exception):
78
+ """The Domain's vault lock was not acquired within the configured time."""
79
+
80
+
81
+ _CHUNK = 1000
82
+
83
+ _LOCK = "SELECT {function}(hashtextextended('lorebase.vault/' || :domain, 0))"
84
+
85
+
86
+ class RecordValues(Protocol):
87
+ """The values a Record holds at the branch head (`MaterializedIndexView`)."""
88
+
89
+ def record_state(self, kind_id: str, key: str) -> Any: ...
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class VaultRow:
94
+ """A row to insert. An erased row (a write of an erased value, `vault.erased_values:
95
+ erase`) holds no ciphertext, key id or fingerprint."""
96
+
97
+ token: str
98
+ kind_id: str
99
+ attribute_id: str
100
+ record_key: str
101
+ fingerprint: bytes | None
102
+ ciphertext: bytes | None
103
+ key_id: str | None
104
+ fingerprint_key: str | None = None
105
+ erased: bool = False
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class Lineage:
110
+ """Record `record_key` took over vaulted values of `previous_key` (same Kind)."""
111
+
112
+ kind_id: str
113
+ record_key: str
114
+ previous_key: str
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class Move:
119
+ """A key change took `token` over: its value moved from `previous_key` to `key`."""
120
+
121
+ token: str
122
+ kind_id: str
123
+ attribute_id: str
124
+ previous_key: str
125
+ key: str
126
+
127
+
128
+ def _moves(moved: dict[str, tuple[str, str, str, str]]) -> list[Move]:
129
+ return [Move(token, *found) for token, found in sorted(moved.items())]
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class Tokenized:
134
+ changes: list[DataChange]
135
+ rows: list[VaultRow]
136
+ issues: list[Issue]
137
+ lineage: list[Lineage] = field(default_factory=list)
138
+ moved: list[Move] = field(default_factory=list)
139
+ """Tokens a key change took over and where their value moved (`mark_moved`)."""
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class _Binding:
144
+ kind_id: str
145
+ attribute_id: str
146
+ record_key: str
147
+ erased: bool
148
+ moved_to: str | None = None
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class _Plain:
153
+ index: int
154
+ kind_id: str
155
+ key: str
156
+ attribute_id: str
157
+ value: JsonValue
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class _Claim:
162
+ index: int
163
+ kind_id: str
164
+ key: str
165
+ attribute_id: str
166
+ token: str
167
+
168
+
169
+ def vault_attributes(schema: Schema) -> dict[str, list[str]]:
170
+ """Kind id -> ids of its vault Attributes."""
171
+ found: dict[str, list[str]] = {}
172
+ for kind in schema.kinds.values():
173
+ ids = [a.id for a in kind.attributes.values() if a.in_vault]
174
+ if ids:
175
+ found[kind.id] = ids
176
+ return found
177
+
178
+
179
+ def lock_vault(sync: Session, domain_id: str, *, timeout_ms: int = 0) -> None:
180
+ """Hold the Domain's vault lock shared until the transaction ends (module doc), waiting
181
+ at most `timeout_ms` (0: no limit); raises `VaultLockTimeoutError`, after which the
182
+ transaction must be rolled back."""
183
+ statement = text(_LOCK.format(function="pg_advisory_xact_lock_shared"))
184
+ if timeout_ms <= 0:
185
+ sync.execute(statement, {"domain": domain_id})
186
+ return
187
+ previous = sync.scalar(text("SELECT current_setting('lock_timeout')"))
188
+ sync.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": f"{timeout_ms}ms"})
189
+ try:
190
+ sync.execute(statement, {"domain": domain_id})
191
+ except DBAPIError as error:
192
+ if sqlstate(error) == LOCK_NOT_AVAILABLE:
193
+ raise VaultLockTimeoutError(
194
+ f"the vault of Domain {domain_id!r} stayed locked for {timeout_ms} ms"
195
+ ) from error
196
+ raise
197
+ sync.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": str(previous)})
198
+
199
+
200
+ async def lock_vault_exclusive(
201
+ session: AsyncSession, domain_id: str, *, timeout_ms: int = 0
202
+ ) -> None:
203
+ """Hold the Domain's vault lock exclusively until the transaction ends (module doc),
204
+ waiting at most `timeout_ms` (0: no limit); raises `VaultLockTimeoutError`."""
205
+ statement = text(_LOCK.format(function="pg_advisory_xact_lock"))
206
+ if timeout_ms <= 0:
207
+ await session.execute(statement, {"domain": domain_id})
208
+ return
209
+ previous = await session.scalar(text("SELECT current_setting('lock_timeout')"))
210
+ await session.execute(
211
+ text("SELECT set_config('lock_timeout', :t, true)"), {"t": f"{timeout_ms}ms"}
212
+ )
213
+ try:
214
+ await session.execute(statement, {"domain": domain_id})
215
+ except DBAPIError as error:
216
+ if sqlstate(error) == LOCK_NOT_AVAILABLE:
217
+ raise VaultLockTimeoutError(
218
+ f"the vault of Domain {domain_id!r} stayed locked for {timeout_ms} ms"
219
+ ) from error
220
+ raise
221
+ await session.execute(text("SELECT set_config('lock_timeout', :t, true)"), {"t": str(previous)})
222
+
223
+
224
+ def tokenize(
225
+ sync: Session,
226
+ domain_id: str,
227
+ schema: Schema,
228
+ changes: Sequence[DataChange],
229
+ keys: VaultKeys | None,
230
+ current: RecordValues,
231
+ *,
232
+ erased_values: ErasedValues = ErasedValues.ERASE,
233
+ tombstones: bool = True,
234
+ lock_timeout_ms: int = 0,
235
+ ) -> Tokenized:
236
+ """Replace the plain values of vault Attributes in `changes` by references (module doc).
237
+
238
+ Plain values are validated first (type, canonical form, constraints), as the core rules
239
+ only see the reference afterwards.
240
+ """
241
+ vaulted = vault_attributes(schema)
242
+ if not vaulted:
243
+ return Tokenized(list(changes), [], [])
244
+ deleted = {(change.kind, change.key) for change in changes if isinstance(change, RecordDelete)}
245
+ plain: list[_Plain] = []
246
+ claimed: list[_Claim] = []
247
+ issues: list[Issue] = []
248
+ for index, upsert in enumerate(changes):
249
+ if not isinstance(upsert, RecordUpsert) or upsert.record.kind not in vaulted:
250
+ continue
251
+ record = upsert.record
252
+ kind = schema.kinds[record.kind]
253
+ held = None
254
+ for attribute_id in vaulted[record.kind]:
255
+ value = record.values.get(attribute_id)
256
+ if value is None:
257
+ continue
258
+ if looks_like_reference(value):
259
+ token = vault_token(value)
260
+ if token is None:
261
+ continue # malformed: the core rules refuse it
262
+ if held is None:
263
+ state = current.record_state(record.kind, record.key)
264
+ held = {} if state is None else state.values
265
+ if held.get(attribute_id) != value:
266
+ claimed.append(_Claim(index, record.kind, record.key, attribute_id, token))
267
+ continue
268
+ attribute = kind.attributes[attribute_id]
269
+ found = check_values(
270
+ {attribute_id: attribute},
271
+ {attribute_id: value},
272
+ ("changes", index, "record"),
273
+ kind=record.kind,
274
+ key=record.key,
275
+ )
276
+ if found:
277
+ issues.extend(found)
278
+ else:
279
+ plain.append(_Plain(index, record.kind, record.key, attribute_id, value))
280
+ if not plain and not claimed:
281
+ return Tokenized(list(changes), [], issues)
282
+ lock_vault(sync, domain_id, timeout_ms=lock_timeout_ms)
283
+ taken, lineage, moved, claim_issues = _check_claims(
284
+ sync, domain_id, claimed, deleted, current, keys
285
+ )
286
+ issues.extend(claim_issues)
287
+ plain.extend(taken)
288
+ if plain and keys is None:
289
+ issues.extend(
290
+ _issue(
291
+ IssueCode.VALUE_VAULT_UNAVAILABLE,
292
+ item,
293
+ "the vault is not configured (vault.token_key, vault.encryption_key)",
294
+ )
295
+ for item in plain
296
+ )
297
+ if issues:
298
+ return Tokenized(list(changes), [], issues)
299
+ erased: set[_Plain] = set()
300
+ if keys is not None and plain and tombstones and erased_values is not ErasedValues.ALLOW:
301
+ erased = _tombstoned(sync, domain_id, plain, keys)
302
+ if erased and erased_values is ErasedValues.REFUSE:
303
+ return Tokenized(
304
+ list(changes),
305
+ [],
306
+ [
307
+ _issue(IssueCode.VALUE_VAULT_ERASED, item, "this value was erased")
308
+ for item in plain
309
+ if item in erased
310
+ ],
311
+ )
312
+ assert keys is not None or not plain
313
+ tokens, rows, exhausted = _mint(sync, domain_id, plain, keys, erased) if keys else ({}, [], [])
314
+ issues.extend(
315
+ _issue(
316
+ IssueCode.VALUE_VAULT_UNAVAILABLE,
317
+ item,
318
+ f"this value was erased {MAX_GENERATIONS} times and cannot be stored again",
319
+ )
320
+ for item in exhausted
321
+ )
322
+ if issues:
323
+ return Tokenized(list(changes), [], issues)
324
+ replaced: dict[int, dict[str, JsonValue]] = defaultdict(dict)
325
+ for item in plain:
326
+ replaced[item.index][item.attribute_id] = vault_reference(tokens[item])
327
+ out = list(changes)
328
+ for index, values in replaced.items():
329
+ upsert = out[index]
330
+ assert isinstance(upsert, RecordUpsert)
331
+ updated: Record = upsert.record.model_copy(
332
+ update={"values": {**upsert.record.values, **values}}
333
+ )
334
+ out[index] = RecordUpsert(record=updated)
335
+ return Tokenized(out, rows, [], lineage, moved)
336
+
337
+
338
+ def _issue(code: IssueCode, item: _Plain | _Claim, message: str) -> Issue:
339
+ return Issue(
340
+ code=code,
341
+ path=("changes", item.index, "record", "values", item.attribute_id),
342
+ message=message,
343
+ kind=item.kind_id,
344
+ key=item.key,
345
+ attribute=item.attribute_id,
346
+ )
347
+
348
+
349
+ def _check_claims(
350
+ sync: Session,
351
+ domain_id: str,
352
+ claimed: Sequence[_Claim],
353
+ deleted: Collection[tuple[str, str]],
354
+ current: RecordValues,
355
+ keys: VaultKeys | None,
356
+ ) -> tuple[list[_Plain], list[Lineage], list[Move], list[Issue]]:
357
+ """References no vault row binds to the Record and Attribute claiming them: the values of
358
+ key changes to store again under the new key, the lineage they make, the tokens they move
359
+ and issues for the others. A token moved once (`moved_to`) is taken over by no other key
360
+ change and claimed again by no Record; its rows are locked, so two commits cannot move it
361
+ at the same time."""
362
+ if not claimed:
363
+ return [], [], [], []
364
+ bindings = _bindings(sync, domain_id, {claim.token for claim in claimed}, for_update=True)
365
+ claims_per_token = Counter(claim.token for claim in claimed)
366
+ taken: list[tuple[_Claim, str]] = []
367
+ lineage: dict[tuple[str, str, str], Lineage] = {}
368
+ moved: dict[str, tuple[str, str, str, str]] = {}
369
+ issues: list[Issue] = []
370
+ for claim in claimed:
371
+ found = bindings.get(claim.token)
372
+ if found is not None and (found.kind_id, found.attribute_id) == (
373
+ claim.kind_id,
374
+ claim.attribute_id,
375
+ ):
376
+ # An erased token carries no value: it may be held and moved freely.
377
+ free = found.erased or found.moved_to is None
378
+ if found.record_key == claim.key and free:
379
+ continue
380
+ source = (
381
+ _moved(claim, found, deleted, current)
382
+ if found.record_key != claim.key
383
+ and (free or found.moved_to == claim.key) # the same move, on another branch
384
+ and claims_per_token[claim.token] == 1
385
+ else None
386
+ )
387
+ if source is not None:
388
+ lineage[(claim.kind_id, claim.key, source)] = Lineage(
389
+ claim.kind_id, claim.key, source
390
+ )
391
+ if not found.erased:
392
+ moved[claim.token] = (
393
+ claim.kind_id,
394
+ claim.attribute_id,
395
+ found.record_key,
396
+ claim.key,
397
+ )
398
+ taken.append((claim, found.record_key))
399
+ continue # an erased value moves as the erased reference it is
400
+ issues.append(
401
+ _issue(
402
+ IssueCode.VALUE_VAULT_REFERENCE,
403
+ claim,
404
+ "the vault reference does not belong to this Record and Attribute",
405
+ )
406
+ )
407
+ if issues or not taken:
408
+ return [], list(lineage.values()), _moves(moved), issues
409
+ opened = _open_sync(sync, domain_id, [claim.token for claim, _ in taken], keys)
410
+ plain: list[_Plain] = []
411
+ for claim, _ in taken:
412
+ value = opened.get(claim.token)
413
+ if value is None:
414
+ issues.append(
415
+ _issue(
416
+ IssueCode.VALUE_VAULT_UNAVAILABLE,
417
+ claim,
418
+ "the value of the vault reference cannot be opened with the configured keys",
419
+ )
420
+ )
421
+ else:
422
+ plain.append(_Plain(claim.index, claim.kind_id, claim.key, claim.attribute_id, value))
423
+ return plain, list(lineage.values()), _moves(moved), issues
424
+
425
+
426
+ def _moved(
427
+ claim: _Claim,
428
+ found: _Binding,
429
+ deleted: Collection[tuple[str, str]],
430
+ current: RecordValues,
431
+ ) -> str | None:
432
+ """The key of the Record the claim takes the reference over from: one the same change set
433
+ deletes, which holds it in that Attribute at the branch head, for a Record that does not
434
+ exist there yet. A live token comes from the Record its row binds it to; an erased one,
435
+ which a key change keeps as it is, from whichever deleted Record holds it."""
436
+ if current.record_state(claim.kind_id, claim.key) is not None:
437
+ return None
438
+ candidates = (
439
+ [key for kind_id, key in deleted if kind_id == claim.kind_id]
440
+ if found.erased
441
+ else [found.record_key]
442
+ if (claim.kind_id, found.record_key) in deleted
443
+ else []
444
+ )
445
+ for key in sorted(candidates):
446
+ state = current.record_state(claim.kind_id, key)
447
+ held = None if state is None else state.values.get(claim.attribute_id)
448
+ if vault_token(held) == claim.token:
449
+ return key
450
+ return None
451
+
452
+
453
+ def _open_sync(
454
+ sync: Session, domain_id: str, tokens: Sequence[str], keys: VaultKeys | None
455
+ ) -> dict[str, JsonValue]:
456
+ """The values of live rows that open with `keys`."""
457
+ if keys is None:
458
+ return {}
459
+ v = VAULT
460
+ out: dict[str, JsonValue] = {}
461
+ ordered = sorted(set(tokens))
462
+ for start in range(0, len(ordered), _CHUNK):
463
+ rows = sync.execute(
464
+ select(v.c.id, v.c.value_ciphertext, v.c.key_id).where(
465
+ v.c.domain_id == domain_id,
466
+ v.c.id.in_(ordered[start : start + _CHUNK]),
467
+ v.c.value_ciphertext.is_not(None),
468
+ )
469
+ )
470
+ for row in rows:
471
+ opened = keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
472
+ if opened is not None:
473
+ out[row.id] = json.loads(opened)
474
+ return out
475
+
476
+
477
+ def _tombstoned(
478
+ sync: Session, domain_id: str, plain: Sequence[_Plain], keys: VaultKeys
479
+ ) -> set[_Plain]:
480
+ """The plain values an erasure recorded, for their Record or for every Record of the
481
+ Kind."""
482
+ fingerprints = {
483
+ item: keys.fingerprints(domain_id, item.kind_id, item.attribute_id, item.value)
484
+ for item in plain
485
+ }
486
+ wanted = sorted({f for found in fingerprints.values() for f in found})
487
+ t = TOMBSTONES
488
+ erased: dict[bytes, list[tuple[str, str, str | None]]] = defaultdict(list)
489
+ for start in range(0, len(wanted), _CHUNK):
490
+ rows = sync.execute(
491
+ select(t.c.fingerprint, t.c.kind_id, t.c.attribute_id, t.c.record_key).where(
492
+ t.c.domain_id == domain_id, t.c.fingerprint.in_(wanted[start : start + _CHUNK])
493
+ )
494
+ )
495
+ for row in rows:
496
+ erased[bytes(row.fingerprint)].append((row.kind_id, row.attribute_id, row.record_key))
497
+ if not erased:
498
+ return set()
499
+ return {
500
+ item
501
+ for item, found in fingerprints.items()
502
+ if any(
503
+ kind_id == item.kind_id
504
+ and attribute_id == item.attribute_id
505
+ and record_key in (None, item.key)
506
+ for fingerprint in found
507
+ for kind_id, attribute_id, record_key in erased.get(fingerprint, ())
508
+ )
509
+ }
510
+
511
+
512
+ def _bindings(
513
+ sync: Session, domain_id: str, tokens: Collection[str], *, for_update: bool = False
514
+ ) -> dict[str, _Binding]:
515
+ v = VAULT
516
+ found: dict[str, _Binding] = {}
517
+ ordered = sorted(tokens)
518
+ for start in range(0, len(ordered), _CHUNK):
519
+ query = (
520
+ select(
521
+ v.c.id, v.c.kind_id, v.c.attribute_id, v.c.record_key, v.c.erased_at, v.c.moved_to
522
+ )
523
+ .where(v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK]))
524
+ .order_by(v.c.id)
525
+ )
526
+ if for_update:
527
+ query = query.with_for_update()
528
+ for row in sync.execute(query):
529
+ found[row.id] = _Binding(
530
+ row.kind_id,
531
+ row.attribute_id,
532
+ row.record_key,
533
+ row.erased_at is not None,
534
+ row.moved_to,
535
+ )
536
+ return found
537
+
538
+
539
+ @dataclass(slots=True)
540
+ class _Search:
541
+ """Where the generation search of one value stands."""
542
+
543
+ generation: int = 0
544
+ free: int | None = None
545
+ """Lowest generation whose token under the current key has no row."""
546
+ erased: str | None = None
547
+ """An erased token of the value (any key), reused by a write of an erased value."""
548
+
549
+
550
+ def _mint(
551
+ sync: Session,
552
+ domain_id: str,
553
+ plain: Sequence[_Plain],
554
+ keys: VaultKeys,
555
+ erase: Collection[_Plain],
556
+ ) -> tuple[dict[_Plain, str], list[VaultRow], list[_Plain]]:
557
+ """Token of every plain value, the rows to insert and the values whose generations ran
558
+ out.
559
+
560
+ Generation by generation, the value's token under each configured token key is looked up:
561
+ a live row (under any key) is reused as it is. Rows of a key exist for consecutive
562
+ generations from 0, so a generation without a row under any key ends the search; the value
563
+ then gets the token of the lowest free generation under the current key. A value an erasure
564
+ recorded (`erase`) never reuses a live row: it reuses an erased token of its own, or gets a
565
+ new erased row.
566
+ """
567
+ tokens: dict[_Plain, str] = {}
568
+ rows: dict[str, VaultRow] = {}
569
+ exhausted: list[_Plain] = []
570
+ pending = {item: _Search() for item in plain}
571
+
572
+ def mint(item: _Plain, search: _Search) -> None:
573
+ if item in erase and search.erased is not None:
574
+ tokens[item] = search.erased
575
+ return
576
+ if search.free is None:
577
+ exhausted.append(item)
578
+ return
579
+ token = keys.token(
580
+ domain_id, item.kind_id, item.key, item.attribute_id, item.value, search.free
581
+ )
582
+ tokens[item] = token
583
+ if token in rows:
584
+ return
585
+ if item in erase:
586
+ rows[token] = VaultRow(
587
+ token, item.kind_id, item.attribute_id, item.key, None, None, None, erased=True
588
+ )
589
+ return
590
+ rows[token] = VaultRow(
591
+ token=token,
592
+ kind_id=item.kind_id,
593
+ attribute_id=item.attribute_id,
594
+ record_key=item.key,
595
+ fingerprint=keys.fingerprint(domain_id, item.kind_id, item.attribute_id, item.value),
596
+ ciphertext=keys.seal(domain_id, token, item.value),
597
+ key_id=keys.key_id,
598
+ fingerprint_key=keys.fingerprint_key,
599
+ )
600
+
601
+ while pending:
602
+ candidates = {
603
+ item: [
604
+ keys.token(
605
+ domain_id,
606
+ item.kind_id,
607
+ item.key,
608
+ item.attribute_id,
609
+ item.value,
610
+ search.generation,
611
+ ),
612
+ *keys.earlier_tokens(
613
+ domain_id,
614
+ item.kind_id,
615
+ item.key,
616
+ item.attribute_id,
617
+ item.value,
618
+ search.generation,
619
+ ),
620
+ ]
621
+ for item, search in pending.items()
622
+ }
623
+ existing = _bindings(sync, domain_id, {t for found in candidates.values() for t in found})
624
+ retry: dict[_Plain, _Search] = {}
625
+ for item, found in candidates.items():
626
+ search = pending[item]
627
+ stored = [(token, existing[token]) for token in found if token in existing]
628
+ live = next((token for token, binding in stored if not binding.erased), None)
629
+ if live is not None and item not in erase:
630
+ tokens[item] = live
631
+ continue
632
+ if search.erased is None:
633
+ search.erased = next((token for token, b in stored if b.erased), None)
634
+ if search.free is None and found[0] not in existing:
635
+ search.free = search.generation
636
+ if not stored or search.generation + 1 >= MAX_GENERATIONS:
637
+ mint(item, search)
638
+ continue
639
+ search.generation += 1
640
+ retry[item] = search
641
+ pending = retry
642
+ return tokens, list(rows.values()), exhausted
643
+
644
+
645
+ async def insert_rows(
646
+ session: AsyncSession, domain_id: str, rows: Sequence[VaultRow], *, at: datetime
647
+ ) -> None:
648
+ """Insert rows minted by `tokenize`; a token stored meanwhile is kept as it is."""
649
+ if not rows:
650
+ return
651
+ for start in range(0, len(rows), 500):
652
+ chunk = rows[start : start + 500]
653
+ await session.execute(
654
+ insert(VAULT)
655
+ .values(
656
+ [
657
+ {
658
+ "domain_id": domain_id,
659
+ "id": row.token,
660
+ "kind_id": row.kind_id,
661
+ "attribute_id": row.attribute_id,
662
+ "record_key": row.record_key,
663
+ "fingerprint": row.fingerprint,
664
+ "fingerprint_key": row.fingerprint_key,
665
+ "value_ciphertext": row.ciphertext,
666
+ "key_id": row.key_id,
667
+ "created_at": at,
668
+ "erased_at": at if row.erased else None,
669
+ }
670
+ for row in chunk
671
+ ]
672
+ )
673
+ .on_conflict_do_nothing(index_elements=["domain_id", "id"])
674
+ )
675
+
676
+
677
+ async def insert_lineage(
678
+ session: AsyncSession, domain_id: str, lineage: Sequence[Lineage], *, at: datetime
679
+ ) -> None:
680
+ """Record key changes found by `tokenize`."""
681
+ if not lineage:
682
+ return
683
+ await session.execute(
684
+ insert(LINEAGE)
685
+ .values(
686
+ [
687
+ {
688
+ "domain_id": domain_id,
689
+ "kind_id": item.kind_id,
690
+ "record_key": item.record_key,
691
+ "previous_key": item.previous_key,
692
+ "created_at": at,
693
+ }
694
+ for item in lineage
695
+ ]
696
+ )
697
+ .on_conflict_do_nothing()
698
+ )
699
+
700
+
701
+ class VaultState(StrEnum):
702
+ VALUE = "value"
703
+ ERASED = "erased"
704
+ UNAVAILABLE = "unavailable"
705
+
706
+
707
+ @dataclass(frozen=True, slots=True)
708
+ class VaultValue:
709
+ """What a token stands for. `kind_id`/`attribute_id` are None for an unknown token (from a
710
+ bundle of another installation, say), which reads as erased."""
711
+
712
+ state: VaultState
713
+ kind_id: str | None = None
714
+ attribute_id: str | None = None
715
+ value: JsonValue = None
716
+
717
+
718
+ async def open_values(
719
+ session: AsyncSession, domain_id: str, tokens: Iterable[str], keys: VaultKeys | None
720
+ ) -> dict[str, VaultValue]:
721
+ """The value of each token (see `VaultValue`)."""
722
+ wanted = sorted(set(tokens))
723
+ out: dict[str, VaultValue] = {t: VaultValue(VaultState.ERASED) for t in wanted}
724
+ v = VAULT
725
+ for start in range(0, len(wanted), _CHUNK):
726
+ rows = await session.execute(
727
+ select(v.c.id, v.c.kind_id, v.c.attribute_id, v.c.value_ciphertext, v.c.key_id).where(
728
+ v.c.domain_id == domain_id, v.c.id.in_(wanted[start : start + _CHUNK])
729
+ )
730
+ )
731
+ for row in rows:
732
+ if row.value_ciphertext is None or row.key_id is None:
733
+ out[row.id] = VaultValue(VaultState.ERASED, row.kind_id, row.attribute_id)
734
+ continue
735
+ opened = (
736
+ None
737
+ if keys is None
738
+ else keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
739
+ )
740
+ if opened is None:
741
+ out[row.id] = VaultValue(VaultState.UNAVAILABLE, row.kind_id, row.attribute_id)
742
+ else:
743
+ out[row.id] = VaultValue(
744
+ VaultState.VALUE, row.kind_id, row.attribute_id, json.loads(opened)
745
+ )
746
+ return out
747
+
748
+
749
+ async def linked_keys(session: AsyncSession, domain_id: str, kind_id: str, key: str) -> list[str]:
750
+ """`key` and every key of the Kind linked to it by `vault_lineage`, in both directions, read
751
+ a batch of keys at a time (no limit: erasure never stops short)."""
752
+ seen = {key}
753
+ frontier = sorted(seen)
754
+ lin = LINEAGE
755
+ while frontier:
756
+ found: set[str] = set()
757
+ for start in range(0, len(frontier), _CHUNK):
758
+ batch = frontier[start : start + _CHUNK]
759
+ rows = await session.execute(
760
+ select(lin.c.record_key, lin.c.previous_key).where(
761
+ lin.c.domain_id == domain_id,
762
+ lin.c.kind_id == kind_id,
763
+ (lin.c.record_key.in_(batch)) | (lin.c.previous_key.in_(batch)),
764
+ )
765
+ )
766
+ found.update(k for row in rows for k in (row.record_key, row.previous_key))
767
+ frontier = sorted(found - seen)
768
+ seen |= found
769
+ return sorted(seen)
770
+
771
+
772
+ @dataclass(frozen=True, slots=True)
773
+ class Erasure:
774
+ """What an erasure did: `erased` rows erased now; `unopenable` rows it looked at that are
775
+ sealed under a key no longer configured, whose fingerprints could not be made again (an
776
+ erasure by value may miss a case variant among them); `keys` Record keys it covered
777
+ (`linked`: more than `MAX_LINKED_KEYS`, which an administrator should look at: a data
778
+ subject is rarely renamed that often)."""
779
+
780
+ erased: int
781
+ unopenable: int = 0
782
+ keys: int = 1
783
+
784
+ @property
785
+ def linked(self) -> bool:
786
+ return self.keys > MAX_LINKED_KEYS
787
+
788
+
789
+ async def erase_record(
790
+ session: AsyncSession,
791
+ domain_id: str,
792
+ kind_id: str,
793
+ key: str,
794
+ attribute_ids: Collection[str] | None = None,
795
+ *,
796
+ keys: VaultKeys | None = None,
797
+ at: datetime | None = None,
798
+ lock_timeout_ms: int = 0,
799
+ ) -> Erasure:
800
+ """Erase every vaulted value a Record ever held (of `attribute_ids` only, when given), on
801
+ every branch and Revision and under every key it had (`vault_lineage`).
802
+
803
+ The Domain's vault lock is taken first (waiting at most `lock_timeout_ms`, else
804
+ `VaultLockTimeoutError`): the keys are read under it, so a key change committing meanwhile
805
+ is either seen or waits. With `keys`, the rows' fingerprints are made again under the
806
+ current keys before they are recorded as tombstones, so those match case variants written
807
+ later. Keys are erased a batch at a time. The costly part of that, after a key rotation,
808
+ is done before the lock is taken, so commits wait only for a second, cheap pass."""
809
+ if keys is not None:
810
+ for _, batch in _batches(await linked_keys(session, domain_id, kind_id, key)):
811
+ await _refresh(
812
+ session, keys, _record_condition(domain_id, kind_id, batch, attribute_ids)
813
+ )
814
+ await lock_vault_exclusive(session, domain_id, timeout_ms=lock_timeout_ms)
815
+ found = await linked_keys(session, domain_id, kind_id, key)
816
+ erased = unopenable = 0
817
+ for _, batch in _batches(found):
818
+ condition = _record_condition(domain_id, kind_id, batch, attribute_ids)
819
+ if keys is not None:
820
+ unopenable += await _refresh(session, keys, condition)
821
+ erased += await _erase(session, domain_id, condition, at)
822
+ return Erasure(erased, unopenable, len(found))
823
+
824
+
825
+ async def erase_matching(
826
+ session: AsyncSession,
827
+ domain_id: str,
828
+ kind_id: str,
829
+ attribute_id: str,
830
+ value: JsonValue,
831
+ keys: VaultKeys,
832
+ *,
833
+ at: datetime | None = None,
834
+ lock_timeout_ms: int = 0,
835
+ ) -> Erasure:
836
+ """Erase the canonical `value` and its case variants (same matching form) in every Record
837
+ of the Kind. Under the Domain's vault lock (taken first, see `erase_record`), rows whose
838
+ fingerprints were made under an earlier key or scheme are made again, so none that opens
839
+ is missed; the costly first pass runs before the lock is taken."""
840
+ v = VAULT
841
+ scope = [v.c.domain_id == domain_id, v.c.kind_id == kind_id, v.c.attribute_id == attribute_id]
842
+ await _refresh(session, keys, scope)
843
+ await lock_vault_exclusive(session, domain_id, timeout_ms=lock_timeout_ms)
844
+ unopenable = await _refresh(session, keys, scope)
845
+ await session.execute(
846
+ insert(TOMBSTONES).values(
847
+ domain_id=domain_id,
848
+ kind_id=kind_id,
849
+ attribute_id=attribute_id,
850
+ record_key=None,
851
+ fingerprint=keys.fingerprint(domain_id, kind_id, attribute_id, value),
852
+ fingerprint_key=keys.fingerprint_key,
853
+ created_at=at or func.now(),
854
+ )
855
+ )
856
+ fingerprints = keys.fingerprints(domain_id, kind_id, attribute_id, value)
857
+ erased = await _erase(session, domain_id, [*scope, v.c.fingerprint.in_(fingerprints)], at)
858
+ return Erasure(erased, unopenable)
859
+
860
+
861
+ async def mark_moved(session: AsyncSession, domain_id: str, moved: Sequence[Move]) -> None:
862
+ """Record the key changes `tokenize` found. Tokens moved earlier to the key a value moves
863
+ away from now point at its new key, so a chain of renames is followed to its end."""
864
+ v = VAULT
865
+ for move in moved:
866
+ await session.execute(
867
+ update(v)
868
+ .where(v.c.domain_id == domain_id, v.c.id == move.token)
869
+ .values(moved_to=move.key)
870
+ )
871
+ await session.execute(
872
+ update(v)
873
+ .where(
874
+ v.c.domain_id == domain_id,
875
+ v.c.kind_id == move.kind_id,
876
+ v.c.attribute_id == move.attribute_id,
877
+ v.c.moved_to == move.previous_key,
878
+ )
879
+ .values(moved_to=move.key)
880
+ )
881
+
882
+
883
+ def _batches(keys: Sequence[str]) -> list[tuple[int, Sequence[str]]]:
884
+ return [(start, keys[start : start + _CHUNK]) for start in range(0, len(keys), _CHUNK)]
885
+
886
+
887
+ def _record_condition(
888
+ domain_id: str, kind_id: str, keys: Sequence[str], attribute_ids: Collection[str] | None
889
+ ) -> list[Any]:
890
+ v = VAULT
891
+ condition = [v.c.domain_id == domain_id, v.c.kind_id == kind_id, v.c.record_key.in_(keys)]
892
+ if attribute_ids is not None:
893
+ condition.append(v.c.attribute_id.in_(sorted(attribute_ids)))
894
+ return condition
895
+
896
+
897
+ async def _refresh(session: AsyncSession, keys: VaultKeys, condition: list[Any]) -> int:
898
+ """Make the outdated rows matching `condition` again in batches; the rows that do not
899
+ open."""
900
+ from lorebase_store.vault.rotation import refresh_rows
901
+
902
+ unopenable = 0
903
+ after: tuple[str, str] | None = None
904
+ while True:
905
+ report = await refresh_rows(session, keys, condition, limit=_CHUNK, after=after)
906
+ unopenable += report.unopenable
907
+ if report.rows < _CHUNK or report.after is None:
908
+ return unopenable
909
+ after = report.after
910
+
911
+
912
+ async def matching_keys(
913
+ session: AsyncSession,
914
+ domain_id: str,
915
+ kind_id: str,
916
+ attribute_id: str,
917
+ value: JsonValue,
918
+ keys: VaultKeys,
919
+ ) -> list[str]:
920
+ """Keys of the Records whose live vault rows hold the value or a case variant of it."""
921
+ v = VAULT
922
+ rows = await session.execute(
923
+ select(v.c.record_key)
924
+ .where(
925
+ v.c.domain_id == domain_id,
926
+ v.c.kind_id == kind_id,
927
+ v.c.attribute_id == attribute_id,
928
+ v.c.fingerprint.in_(keys.fingerprints(domain_id, kind_id, attribute_id, value)),
929
+ )
930
+ .distinct()
931
+ .order_by(v.c.record_key)
932
+ )
933
+ return list(rows.scalars())
934
+
935
+
936
+ async def _erase(
937
+ session: AsyncSession, domain_id: str, condition: list[Any], at: datetime | None
938
+ ) -> int:
939
+ """Erase the live rows matching `condition` and record their fingerprints."""
940
+ v = VAULT
941
+ erased = (
942
+ await session.execute(
943
+ select(
944
+ v.c.kind_id, v.c.attribute_id, v.c.record_key, v.c.fingerprint, v.c.fingerprint_key
945
+ )
946
+ .where(*condition, v.c.erased_at.is_(None))
947
+ .order_by(v.c.id)
948
+ .with_for_update()
949
+ )
950
+ ).all()
951
+ tombstones = {
952
+ (row.kind_id, row.attribute_id, row.record_key, bytes(row.fingerprint)): row.fingerprint_key
953
+ for row in erased
954
+ if row.fingerprint is not None
955
+ }
956
+ if tombstones:
957
+ await session.execute(
958
+ insert(TOMBSTONES).values(
959
+ [
960
+ {
961
+ "domain_id": domain_id,
962
+ "kind_id": kind_id,
963
+ "attribute_id": attribute_id,
964
+ "record_key": record_key,
965
+ "fingerprint": fingerprint,
966
+ "fingerprint_key": fingerprint_key,
967
+ "created_at": at or func.now(),
968
+ }
969
+ for (kind_id, attribute_id, record_key, fingerprint), fingerprint_key in sorted(
970
+ tombstones.items(), key=lambda item: item[0]
971
+ )
972
+ ]
973
+ )
974
+ )
975
+ result = await session.execute(
976
+ update(v)
977
+ .where(*condition, v.c.erased_at.is_(None))
978
+ .values(
979
+ value_ciphertext=None,
980
+ key_id=None,
981
+ fingerprint=None,
982
+ fingerprint_key=None,
983
+ erased_at=at or func.now(),
984
+ )
985
+ .returning(v.c.id)
986
+ )
987
+ return len(result.all())