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,806 @@
1
+ """Built-in Operations (design 5.7): definitions, parameter binding and the edits they make.
2
+
3
+ For every Kind with the built-in enabled: `create_{kind}`, `update_{kind}`, `delete_{kind}`;
4
+ for every explicit Relation: `link_{relation}`, `unlink_{relation}`; the bulk upserts
5
+ `import_records`, `import_links` and `import_batch`; and the bulk delete `delete_records`.
6
+ Their parameters are derived from the
7
+ schema, so a definition always matches the Revision it is read from:
8
+
9
+ | Operation | Parameters |
10
+ |-----------|------------|
11
+ | `create_{kind}` | one per stored Attribute; `key` for Kinds without a natural key |
12
+ | `update_{kind}` | `key`, then every stored Attribute as optional; `null` removes a value |
13
+ | `delete_{kind}` | `key` |
14
+ | `link_{relation}` | `from`, `to` (Record keys), then the link Attributes |
15
+ | `unlink_{relation}` | `from`, `to` |
16
+ | `import_records` | `kind` (api name), `rows` (objects keyed by Attribute api name; `links`) |
17
+ | `import_links` | `relation` (forward api name), `rows` (`from`, `to` and link Attributes) |
18
+ | `import_batch` | `records` (Record rows with `kind`), `links` (Link rows with `relation`) |
19
+ | `delete_records` | `kind` (api name), `keys` (Record keys), `skip_missing` (optional boolean) |
20
+
21
+ A Record row's `links` maps a Relation name (forward from the Record's Kind, or inverse when the
22
+ Kind is its `to` end) to the key at the other end, an object with `key` and link Attributes, or
23
+ a list of these. A Record and its Links are one unit: bulk imports commit in batches of whole
24
+ units, so a Relation that is `required_from` is satisfied in the Revision that creates the
25
+ Record. Units of Records without Links come first, then Records with Links, then Links of
26
+ Records not in the import, so Links usually point to Records committed in an earlier or the
27
+ same batch.
28
+
29
+ `delete_records` deletes Records of one Kind whose `delete` built-in is enabled, all in one
30
+ Revision (it is not an import: no batching). Keys that do not exist fail the call, or with
31
+ `skip_missing` are left out and reported as warnings. Links of the deleted Records follow each
32
+ Relation's `on_delete` policy as for `delete_{kind}`. A `delete_{kind}` of a Kind whose snake
33
+ case name is `records` takes precedence over the bulk delete.
34
+
35
+ Everything here is pure: reads of existing Records go through `RecordReader`.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
41
+ from dataclasses import dataclass, field
42
+ from enum import StrEnum
43
+ from typing import Any
44
+
45
+ from pydantic import JsonValue
46
+
47
+ from lorebase_core.model import (
48
+ Attribute,
49
+ Kind,
50
+ Link,
51
+ LinkDirection,
52
+ Record,
53
+ Relation,
54
+ RelationBacking,
55
+ Schema,
56
+ builtin_link_operation_names,
57
+ builtin_operation_names,
58
+ new_ulid,
59
+ record_key,
60
+ snake_case,
61
+ )
62
+ from lorebase_core.types import AttributeType, TypeSpec, TypeSystemError, encode_value
63
+ from lorebase_core.types.encoding import normalize_key
64
+ from lorebase_core.validate import (
65
+ DataChange,
66
+ IssueCode,
67
+ LinkDelete,
68
+ LinkUpsert,
69
+ RecordDelete,
70
+ RecordUpsert,
71
+ )
72
+ from lorebase_keeper.reading.sensitivity import UNCLEARED, Masking
73
+ from lorebase_keeper.services.errors import Finding
74
+
75
+ IMPORT_RECORDS = "import_records"
76
+ IMPORT_LINKS = "import_links"
77
+ IMPORT_BATCH = "import_batch"
78
+ DELETE_RECORDS = "delete_records"
79
+ KEY = "key"
80
+ KEYS = "keys"
81
+ SKIP_MISSING = "skip_missing"
82
+ FROM = "from"
83
+ TO = "to"
84
+ LINKS = "links"
85
+ KIND = "kind"
86
+ RELATION = "relation"
87
+
88
+ PARAMETER_UNKNOWN = "parameter.unknown"
89
+ PARAMETER_REQUIRED = "parameter.required"
90
+ PARAMETER_INVALID = "parameter.invalid"
91
+ RECORD_EXISTS = "record.exists"
92
+ LIMIT_EXCEEDED = "parameter.limit_exceeded"
93
+
94
+ RecordReader = Callable[[str, Sequence[str]], Awaitable[Mapping[str, Mapping[str, JsonValue]]]]
95
+ """Current values (Attribute id keyed) of Records of a Kind by key; absent keys do not exist."""
96
+
97
+
98
+ class Action(StrEnum):
99
+ CREATE = "create"
100
+ UPDATE = "update"
101
+ DELETE = "delete"
102
+ LINK = "link"
103
+ UNLINK = "unlink"
104
+ IMPORT_RECORDS = "import_records"
105
+ IMPORT_LINKS = "import_links"
106
+ IMPORT_BATCH = "import_batch"
107
+ DELETE_RECORDS = "delete_records"
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Parameter:
112
+ """A parameter: `type` is a Lorebase type, or `None` for structured JSON (`rows`)."""
113
+
114
+ name: str
115
+ type: TypeSpec | None
116
+ required: bool
117
+ description: str = ""
118
+ attribute: str | None = None
119
+
120
+ def document(self) -> dict[str, Any]:
121
+ return {
122
+ "name": self.name,
123
+ "type": "json"
124
+ if self.type is None
125
+ else self.type.model_dump(mode="json", exclude_none=True),
126
+ "required": self.required,
127
+ "description": self.description,
128
+ }
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class BuiltinOperation:
133
+ """A built-in Operation of one Kind or Relation (or the bulk imports)."""
134
+
135
+ name: str
136
+ action: Action
137
+ description: str
138
+ parameters: tuple[Parameter, ...]
139
+ kind: Kind | None = None
140
+ relation: Relation | None = None
141
+
142
+ @property
143
+ def is_import(self) -> bool:
144
+ return self.action in (Action.IMPORT_RECORDS, Action.IMPORT_LINKS, Action.IMPORT_BATCH)
145
+
146
+ def document(self) -> dict[str, Any]:
147
+ """Description served by the schema API, shaped like a stored Operation."""
148
+ return {
149
+ "api_name": self.name,
150
+ "builtin": True,
151
+ "description": self.description,
152
+ "parameters": [p.document() for p in self.parameters],
153
+ "review_policy": {"mode": "never"},
154
+ "status": "active",
155
+ }
156
+
157
+
158
+ _STRING = TypeSpec.of(AttributeType.STRING)
159
+ _BOOLEAN = TypeSpec.of(AttributeType.BOOLEAN)
160
+
161
+
162
+ def _stored(attributes: Mapping[str, Attribute]) -> list[Attribute]:
163
+ return [a for a in attributes.values() if not a.is_derived]
164
+
165
+
166
+ def _attribute_parameters(
167
+ attributes: Mapping[str, Attribute], *, required: bool
168
+ ) -> list[Parameter]:
169
+ return [
170
+ Parameter(a.api_name, a.type, required and a.required, a.description, a.id)
171
+ for a in _stored(attributes)
172
+ ]
173
+
174
+
175
+ def builtin_definition(schema: Schema, name: str) -> BuiltinOperation | None:
176
+ """The built-in Operation called `name` in `schema`, if any."""
177
+ if name == IMPORT_RECORDS:
178
+ return BuiltinOperation(
179
+ name,
180
+ Action.IMPORT_RECORDS,
181
+ "Create or replace many Records of one Kind. Rows are objects keyed by Attribute "
182
+ "api name; a row's `links` maps Relation names to the keys at the other end, "
183
+ "committed with the Record. Large imports are committed in batches.",
184
+ (
185
+ Parameter("kind", _STRING, True, "Kind api name."),
186
+ Parameter("rows", None, True, "Records to upsert."),
187
+ ),
188
+ )
189
+ if name == IMPORT_LINKS:
190
+ return BuiltinOperation(
191
+ name,
192
+ Action.IMPORT_LINKS,
193
+ "Create or replace many Links of one Relation. Rows hold `from`, `to` and link "
194
+ "Attributes by api name.",
195
+ (
196
+ Parameter("relation", _STRING, True, "Forward api name of the Relation."),
197
+ Parameter("rows", None, True, "Links to upsert."),
198
+ ),
199
+ )
200
+ if name == IMPORT_BATCH:
201
+ return BuiltinOperation(
202
+ name,
203
+ Action.IMPORT_BATCH,
204
+ "Create or replace Records of several Kinds and Links of several Relations in one "
205
+ "call. `records` rows name their `kind` and may hold `links`; `links` rows name "
206
+ "their `relation`. A Record is committed in the same Revision as its Links.",
207
+ (
208
+ Parameter("records", None, False, "Records to upsert, each with `kind`."),
209
+ Parameter("links", None, False, "Links to upsert, each with `relation`."),
210
+ ),
211
+ )
212
+ for kind in schema.kinds.values():
213
+ if name not in builtin_operation_names(kind):
214
+ continue
215
+ action = Action(name.split("_", 1)[0])
216
+ key = Parameter(KEY, _STRING, True, f"Key of the {kind.api_name} Record.")
217
+ if action is Action.CREATE:
218
+ parameters = _attribute_parameters(kind.attributes, required=True)
219
+ if not kind.has_natural_key:
220
+ parameters.insert(0, Parameter(KEY, _STRING, False, "Key; minted when absent."))
221
+ text = f"Create a {kind.api_name} Record."
222
+ elif action is Action.UPDATE:
223
+ parameters = [key, *_attribute_parameters(kind.attributes, required=False)]
224
+ text = f"Change Attributes of a {kind.api_name} Record; null removes a value."
225
+ else:
226
+ parameters = [key]
227
+ text = f"Delete a {kind.api_name} Record and, per Relation policy, its Links."
228
+ return BuiltinOperation(name, action, text, tuple(parameters), kind=kind)
229
+ if name == DELETE_RECORDS:
230
+ return BuiltinOperation(
231
+ name,
232
+ Action.DELETE_RECORDS,
233
+ "Delete many Records of one Kind in one Revision and, per Relation policy, their "
234
+ "Links. Unknown keys fail the call unless `skip_missing` is true.",
235
+ (
236
+ Parameter(KIND, _STRING, True, "Kind api name."),
237
+ Parameter(KEYS, None, True, "Keys of the Records to delete."),
238
+ Parameter(
239
+ SKIP_MISSING,
240
+ _BOOLEAN,
241
+ False,
242
+ "Leave out keys that do not exist (reported as warnings) instead of failing.",
243
+ ),
244
+ ),
245
+ )
246
+ for relation in schema.relations.values():
247
+ if relation.backing is not RelationBacking.EXPLICIT:
248
+ continue
249
+ if name not in builtin_link_operation_names(relation):
250
+ continue
251
+ action = Action.LINK if name.startswith("link_") else Action.UNLINK
252
+ ends = [
253
+ Parameter(FROM, _STRING, True, "Key of the `from` Record."),
254
+ Parameter(TO, _STRING, True, "Key of the `to` Record."),
255
+ ]
256
+ if action is Action.LINK:
257
+ ends.extend(_attribute_parameters(relation.attributes, required=True))
258
+ text = f"Link two Records through {relation.api_name}."
259
+ else:
260
+ text = f"Remove a {relation.api_name} Link."
261
+ return BuiltinOperation(name, action, text, tuple(ends), relation=relation)
262
+ return None
263
+
264
+
265
+ # Parameter binding ---------------------------------------------------------------------------
266
+
267
+
268
+ @dataclass
269
+ class Bound:
270
+ """Canonical parameter values and the findings of binding them."""
271
+
272
+ values: dict[str, JsonValue] = field(default_factory=dict)
273
+ findings: list[Finding] = field(default_factory=list)
274
+
275
+
276
+ def _encode(
277
+ spec: TypeSpec | None, raw: JsonValue, path: tuple[str | int, ...], bound: Bound
278
+ ) -> JsonValue:
279
+ if spec is None:
280
+ return raw
281
+ try:
282
+ return encode_value(spec, raw)
283
+ except (TypeSystemError, ValueError) as error:
284
+ bound.findings.append(Finding(PARAMETER_INVALID, str(error), path))
285
+ return None
286
+
287
+
288
+ def bind(
289
+ definition: BuiltinOperation, parameters: Mapping[str, JsonValue], *, allow_null: bool = False
290
+ ) -> Bound:
291
+ """Validate parameters against the definition and encode them to canonical values."""
292
+ bound = Bound()
293
+ known = {p.name: p for p in definition.parameters}
294
+ for name in parameters:
295
+ if name not in known:
296
+ bound.findings.append(
297
+ Finding(PARAMETER_UNKNOWN, f"unknown parameter {name!r}", ("parameters", name))
298
+ )
299
+ nullable = allow_null or definition.action is Action.UPDATE
300
+ for parameter in definition.parameters:
301
+ path: tuple[str | int, ...] = ("parameters", parameter.name)
302
+ if parameter.name not in parameters or (
303
+ parameters[parameter.name] is None and not nullable
304
+ ):
305
+ if parameter.required:
306
+ bound.findings.append(
307
+ Finding(PARAMETER_REQUIRED, f"{parameter.name!r} is required", path)
308
+ )
309
+ continue
310
+ raw = parameters[parameter.name]
311
+ if raw is None:
312
+ if parameter.required:
313
+ bound.findings.append(
314
+ Finding(PARAMETER_REQUIRED, f"{parameter.name!r} cannot be null", path)
315
+ )
316
+ continue
317
+ bound.values[parameter.name] = None
318
+ continue
319
+ bound.values[parameter.name] = _encode(parameter.type, raw, path, bound)
320
+ return bound
321
+
322
+
323
+ # Edits ----------------------------------------------------------------------------------------
324
+
325
+
326
+ @dataclass
327
+ class Plan:
328
+ """Data changes computed from bound parameters, or the findings preventing them.
329
+
330
+ `units` lists the sizes of consecutive runs of `changes` that must be committed in the
331
+ same Revision (a Record and its Links); empty means every change stands alone.
332
+ """
333
+
334
+ changes: list[DataChange] = field(default_factory=list)
335
+ findings: list[Finding] = field(default_factory=list)
336
+ units: list[int] = field(default_factory=list)
337
+ warnings: list[Finding] = field(default_factory=list)
338
+
339
+
340
+ def _values(
341
+ attributes: Mapping[str, Attribute], bound: Mapping[str, JsonValue]
342
+ ) -> dict[str, JsonValue]:
343
+ by_name = {a.api_name: a for a in _stored(attributes)}
344
+ return {by_name[name].id: value for name, value in bound.items() if name in by_name}
345
+
346
+
347
+ def _key(text: JsonValue, path: tuple[str | int, ...], plan: Plan) -> str | None:
348
+ try:
349
+ return normalize_key(text)
350
+ except (TypeSystemError, ValueError) as error:
351
+ plan.findings.append(Finding(PARAMETER_INVALID, str(error), path))
352
+ return None
353
+
354
+
355
+ async def plan_edits(
356
+ definition: BuiltinOperation,
357
+ bound: Mapping[str, JsonValue],
358
+ read: RecordReader,
359
+ ) -> Plan:
360
+ """The data changes of a single-Record or single-Link built-in."""
361
+ plan = Plan()
362
+ if definition.kind is not None:
363
+ await _record_edit(definition, definition.kind, bound, read, plan)
364
+ elif definition.relation is not None:
365
+ _link_edit(definition, definition.relation, bound, plan)
366
+ return plan
367
+
368
+
369
+ async def _record_edit(
370
+ definition: BuiltinOperation,
371
+ kind: Kind,
372
+ bound: Mapping[str, JsonValue],
373
+ read: RecordReader,
374
+ plan: Plan,
375
+ ) -> None:
376
+ values = _values(kind.attributes, bound)
377
+ if definition.action is Action.CREATE:
378
+ key = record_key(kind, values) if kind.has_natural_key else None
379
+ if key is None:
380
+ given = bound.get(KEY)
381
+ key = _key(given, ("parameters", KEY), plan) if given is not None else new_ulid()
382
+ if key is None:
383
+ return
384
+ if key in await read(kind.id, [key]):
385
+ plan.findings.append(
386
+ Finding(
387
+ RECORD_EXISTS,
388
+ f"{kind.api_name}/{key} exists already",
389
+ (),
390
+ kind=kind.id,
391
+ key=key,
392
+ )
393
+ )
394
+ return
395
+ plan.changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=values)))
396
+ return
397
+ key = _key(bound.get(KEY), ("parameters", KEY), plan)
398
+ if key is None:
399
+ return
400
+ if definition.action is Action.DELETE:
401
+ plan.changes.append(RecordDelete(kind=kind.id, key=key))
402
+ return
403
+ current = (await read(kind.id, [key])).get(key)
404
+ if current is None:
405
+ plan.findings.append(
406
+ Finding(
407
+ IssueCode.RECORD_NOT_FOUND.value,
408
+ f"no {kind.api_name} Record {key!r}",
409
+ ("parameters", KEY),
410
+ kind=kind.id,
411
+ key=key,
412
+ )
413
+ )
414
+ return
415
+ merged: dict[str, JsonValue] = dict(current)
416
+ for attribute_id, value in values.items():
417
+ if value is None:
418
+ merged.pop(attribute_id, None)
419
+ else:
420
+ merged[attribute_id] = value
421
+ plan.changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=merged)))
422
+
423
+
424
+ def _link_edit(
425
+ definition: BuiltinOperation, relation: Relation, bound: Mapping[str, JsonValue], plan: Plan
426
+ ) -> None:
427
+ from_key = _key(bound.get(FROM), ("parameters", FROM), plan)
428
+ to_key = _key(bound.get(TO), ("parameters", TO), plan)
429
+ if from_key is None or to_key is None:
430
+ return
431
+ if definition.action is Action.UNLINK:
432
+ plan.changes.append(LinkDelete(relation=relation.id, from_key=from_key, to_key=to_key))
433
+ return
434
+ values = _values(relation.attributes, bound)
435
+ plan.changes.append(
436
+ LinkUpsert(link=Link(relation=relation.id, from_key=from_key, to_key=to_key, values=values))
437
+ )
438
+
439
+
440
+ # Bulk delete ----------------------------------------------------------------------------------
441
+
442
+
443
+ async def delete_keys(
444
+ schema: Schema, bound: Mapping[str, JsonValue], read: RecordReader, *, max_keys: int
445
+ ) -> Plan:
446
+ """The deletes of `delete_records`, one per distinct key, or the findings of bad keys."""
447
+ plan = Plan()
448
+ kind = schema.kind_by_api_name(str(bound.get(KIND)))
449
+ if kind is None:
450
+ plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", ("parameters", KIND)))
451
+ return plan
452
+ if f"{Action.DELETE.value}_{snake_case(kind.api_name)}" not in builtin_operation_names(kind):
453
+ plan.findings.append(
454
+ Finding(
455
+ PARAMETER_INVALID,
456
+ f"the delete built-in is not enabled for {kind.api_name}",
457
+ ("parameters", KIND),
458
+ kind=kind.id,
459
+ )
460
+ )
461
+ return plan
462
+ raw = bound.get(KEYS)
463
+ if not isinstance(raw, list) or not raw:
464
+ plan.findings.append(
465
+ Finding(PARAMETER_INVALID, "must be a non-empty list of keys", ("parameters", KEYS))
466
+ )
467
+ return plan
468
+ if len(raw) > max_keys:
469
+ plan.findings.append(
470
+ Finding(LIMIT_EXCEEDED, f"at most {max_keys} keys per call", ("parameters", KEYS))
471
+ )
472
+ return plan
473
+ keys: dict[str, int] = {}
474
+ for index, text in enumerate(raw):
475
+ key = _key(text, ("parameters", KEYS, index), plan)
476
+ if key is not None:
477
+ keys.setdefault(key, index)
478
+ if plan.findings:
479
+ return plan
480
+ found = await read(kind.id, list(keys))
481
+ skip = bound.get(SKIP_MISSING) is True
482
+ for key, index in keys.items():
483
+ if key in found:
484
+ plan.changes.append(RecordDelete(kind=kind.id, key=key))
485
+ continue
486
+ missing = Finding(
487
+ IssueCode.RECORD_NOT_FOUND.value,
488
+ f"no {kind.api_name} Record {key!r}" + (" (skipped)" if skip else ""),
489
+ ("parameters", KEYS, index),
490
+ severity="warning" if skip else "error",
491
+ kind=kind.id,
492
+ key=key,
493
+ )
494
+ (plan.warnings if skip else plan.findings).append(missing)
495
+ if plan.findings:
496
+ plan.changes = []
497
+ return plan
498
+
499
+
500
+ # Bulk imports ---------------------------------------------------------------------------------
501
+
502
+ Path = tuple[str | int, ...]
503
+
504
+
505
+ def _rows(value: JsonValue, path: Path, plan: Plan) -> list[dict[str, JsonValue]] | None:
506
+ if not isinstance(value, list) or not all(isinstance(r, dict) for r in value):
507
+ plan.findings.append(Finding(PARAMETER_INVALID, "must be a list of objects", path))
508
+ return None
509
+ return [r for r in value if isinstance(r, dict)]
510
+
511
+
512
+ def import_rows(
513
+ schema: Schema, definition: BuiltinOperation, bound: Mapping[str, JsonValue], *, max_rows: int
514
+ ) -> Plan:
515
+ """Upserts of `import_records`, `import_links` or `import_batch` rows grouped in units (a
516
+ Record with its Links), or the findings of bad rows."""
517
+ plan = Plan()
518
+ importer = _Importer(schema, plan)
519
+ if definition.action is Action.IMPORT_BATCH:
520
+ records = bound.get("records") or []
521
+ links = bound.get("links") or []
522
+ record_rows = _rows(records, ("parameters", "records"), plan)
523
+ link_rows = _rows(links, ("parameters", "links"), plan)
524
+ if record_rows is None or link_rows is None:
525
+ return plan
526
+ if not record_rows and not link_rows:
527
+ plan.findings.append(
528
+ Finding(PARAMETER_REQUIRED, "records or links are required", ("parameters",))
529
+ )
530
+ return plan
531
+ if len(record_rows) + len(link_rows) > max_rows:
532
+ plan.findings.append(
533
+ Finding(LIMIT_EXCEEDED, f"at most {max_rows} rows per call", ("parameters",))
534
+ )
535
+ return plan
536
+ for index, row in enumerate(record_rows):
537
+ path: Path = ("parameters", "records", index)
538
+ kind = schema.kind_by_api_name(str(row.get(KIND)))
539
+ if kind is None:
540
+ plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", (*path, KIND)))
541
+ continue
542
+ importer.record(kind, {k: v for k, v in row.items() if k != KIND}, path)
543
+ for index, row in enumerate(link_rows):
544
+ path = ("parameters", "links", index)
545
+ relation = _stored_relation(schema, str(row.get(RELATION)))
546
+ if relation is None:
547
+ plan.findings.append(
548
+ Finding(PARAMETER_INVALID, "unknown or derived Relation", (*path, RELATION))
549
+ )
550
+ continue
551
+ importer.link(relation, {k: v for k, v in row.items() if k != RELATION}, path)
552
+ return importer.finish(max_rows)
553
+ rows = _rows(bound.get("rows"), ("parameters", "rows"), plan)
554
+ if rows is None:
555
+ return plan
556
+ if len(rows) > max_rows:
557
+ plan.findings.append(
558
+ Finding(LIMIT_EXCEEDED, f"at most {max_rows} rows per call", ("parameters", "rows"))
559
+ )
560
+ return plan
561
+ if definition.action is Action.IMPORT_RECORDS:
562
+ kind = schema.kind_by_api_name(str(bound.get("kind")))
563
+ if kind is None:
564
+ plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", ("parameters", "kind")))
565
+ return plan
566
+ for index, row in enumerate(rows):
567
+ importer.record(kind, row, ("parameters", "rows", index))
568
+ return importer.finish(max_rows)
569
+ relation = _stored_relation(schema, str(bound.get("relation")))
570
+ if relation is None:
571
+ plan.findings.append(
572
+ Finding(PARAMETER_INVALID, "unknown or derived Relation", ("parameters", "relation"))
573
+ )
574
+ return plan
575
+ for index, row in enumerate(rows):
576
+ importer.link(relation, row, ("parameters", "rows", index))
577
+ return importer.finish(max_rows)
578
+
579
+
580
+ def _stored_relation(schema: Schema, name: str) -> Relation | None:
581
+ """The explicit Relation with that forward api name."""
582
+ found = schema.relation_by_api_name(name)
583
+ if found is None or found[1] is not LinkDirection.FORWARD:
584
+ return None
585
+ relation = found[0]
586
+ return relation if relation.backing is RelationBacking.EXPLICIT else None
587
+
588
+
589
+ def _relocate(findings: Sequence[Finding], path: Path, plan: Plan) -> bool:
590
+ """Move parameter findings of a row under `path`; true when there were any."""
591
+ for finding in findings:
592
+ plan.findings.append(Finding(finding.code, finding.message, (*path, *finding.path[1:])))
593
+ return bool(findings)
594
+
595
+
596
+ def _link_definition(relation: Relation) -> BuiltinOperation:
597
+ return BuiltinOperation(
598
+ "",
599
+ Action.LINK,
600
+ "",
601
+ (
602
+ Parameter(FROM, _STRING, True),
603
+ Parameter(TO, _STRING, True),
604
+ *_attribute_parameters(relation.attributes, required=True),
605
+ ),
606
+ relation=relation,
607
+ )
608
+
609
+
610
+ class _Importer:
611
+ """Collects the units of a bulk import: Records with the Links of their rows, Links."""
612
+
613
+ def __init__(self, schema: Schema, plan: Plan) -> None:
614
+ self.schema = schema
615
+ self.plan = plan
616
+ self.records: dict[tuple[str, str], list[DataChange]] = {}
617
+ self.loose: list[DataChange] = []
618
+ self._creates: dict[str, BuiltinOperation] = {}
619
+ self._links: dict[str, BuiltinOperation] = {}
620
+
621
+ def create(self, kind: Kind) -> BuiltinOperation:
622
+ definition = self._creates.get(kind.id)
623
+ if definition is None:
624
+ parameters = _attribute_parameters(kind.attributes, required=True)
625
+ if not kind.has_natural_key:
626
+ parameters.insert(0, Parameter(KEY, _STRING, False))
627
+ definition = BuiltinOperation("", Action.CREATE, "", tuple(parameters), kind=kind)
628
+ self._creates[kind.id] = definition
629
+ return definition
630
+
631
+ def link_definition(self, relation: Relation) -> BuiltinOperation:
632
+ definition = self._links.get(relation.id)
633
+ if definition is None:
634
+ definition = self._links[relation.id] = _link_definition(relation)
635
+ return definition
636
+
637
+ def record(self, kind: Kind, row: Mapping[str, JsonValue], path: Path) -> None:
638
+ links = None
639
+ if LINKS in row and kind.attribute_by_api_name(LINKS) is None:
640
+ links = row[LINKS]
641
+ row = {k: v for k, v in row.items() if k != LINKS}
642
+ row_bound = bind(self.create(kind), row)
643
+ if _relocate(row_bound.findings, path, self.plan):
644
+ return
645
+ values = _values(kind.attributes, row_bound.values)
646
+ key = record_key(kind, values) if kind.has_natural_key else None
647
+ if key is None:
648
+ given = row_bound.values.get(KEY)
649
+ key = _key(given, (*path, KEY), self.plan) if given else new_ulid()
650
+ if key is None:
651
+ return
652
+ unit: list[DataChange] = [RecordUpsert(record=Record(kind=kind.id, key=key, values=values))]
653
+ if links is not None:
654
+ unit.extend(self.row_links(kind, key, links, (*path, LINKS)))
655
+ self.records[(kind.id, key)] = unit
656
+
657
+ def row_links(self, kind: Kind, key: str, links: JsonValue, path: Path) -> list[DataChange]:
658
+ if not isinstance(links, dict):
659
+ self.plan.findings.append(
660
+ Finding(PARAMETER_INVALID, "must be an object keyed by Relation name", path)
661
+ )
662
+ return []
663
+ changes: list[DataChange] = []
664
+ for name, targets in links.items():
665
+ found = self.schema.relation_by_api_name(name)
666
+ if (
667
+ found is None
668
+ or found[0].backing is not RelationBacking.EXPLICIT
669
+ or found[0].endpoint(found[1]) != kind.id
670
+ ):
671
+ self.plan.findings.append(
672
+ Finding(
673
+ PARAMETER_INVALID,
674
+ f"{name!r} is not a stored Relation of {kind.api_name}",
675
+ (*path, name),
676
+ )
677
+ )
678
+ continue
679
+ relation, direction = found
680
+ many = isinstance(targets, list)
681
+ items = targets if isinstance(targets, list) else [targets]
682
+ for position, target in enumerate(items):
683
+ where: Path = (*path, name, position) if many else (*path, name)
684
+ attributes: dict[str, JsonValue] = {}
685
+ other: JsonValue = target
686
+ if isinstance(target, dict):
687
+ other = target.get(KEY)
688
+ attributes = {k: v for k, v in target.items() if k != KEY}
689
+ ends = (
690
+ {FROM: key, TO: other}
691
+ if direction is LinkDirection.FORWARD
692
+ else {FROM: other, TO: key}
693
+ )
694
+ link = self.build_link(relation, {**attributes, **ends}, where)
695
+ if link is not None:
696
+ changes.append(link)
697
+ return changes
698
+
699
+ def build_link(
700
+ self, relation: Relation, row: Mapping[str, JsonValue], path: Path
701
+ ) -> DataChange | None:
702
+ definition = self.link_definition(relation)
703
+ row_bound = bind(definition, row)
704
+ if _relocate(row_bound.findings, path, self.plan):
705
+ return None
706
+ plan = Plan()
707
+ _link_edit(definition, relation, row_bound.values, plan)
708
+ self.plan.findings.extend(
709
+ Finding(f.code, f.message, (*path, *f.path[1:])) for f in plan.findings
710
+ )
711
+ return plan.changes[0] if plan.changes else None
712
+
713
+ def link(self, relation: Relation, row: Mapping[str, JsonValue], path: Path) -> None:
714
+ link = self.build_link(relation, row, path)
715
+ if link is None:
716
+ return
717
+ assert isinstance(link, LinkUpsert)
718
+ owner = self.records.get((relation.from_kind, link.link.from_key)) or self.records.get(
719
+ (relation.to_kind, link.link.to_key)
720
+ )
721
+ if owner is None:
722
+ self.loose.append(link)
723
+ else:
724
+ owner.append(link)
725
+
726
+ def finish(self, max_rows: int) -> Plan:
727
+ """Units in commit order: lone Records, Records with Links, then lone Links."""
728
+ units = [u for u in self.records.values() if len(u) == 1]
729
+ units += [u for u in self.records.values() if len(u) > 1]
730
+ units += [[link] for link in self.loose]
731
+ total = sum(len(u) for u in units)
732
+ if total > max_rows:
733
+ self.plan.findings.append(
734
+ Finding(
735
+ LIMIT_EXCEEDED,
736
+ f"at most {max_rows} Records and Links per call",
737
+ ("parameters",),
738
+ )
739
+ )
740
+ return self.plan
741
+ for unit in units:
742
+ self.plan.changes.extend(unit)
743
+ self.plan.units.append(len(unit))
744
+ return self.plan
745
+
746
+
747
+ def batches(
748
+ changes: Sequence[DataChange], units: Sequence[int], size: int
749
+ ) -> list[list[DataChange]]:
750
+ """`changes` in batches of about `size`, never splitting a unit (a unit larger than `size`
751
+ is a batch of its own). Without `units` every change is its own unit."""
752
+ sizes = list(units) or [1] * len(changes)
753
+ out: list[list[DataChange]] = []
754
+ current: list[DataChange] = []
755
+ position = 0
756
+ for count in sizes:
757
+ unit = list(changes[position : position + count])
758
+ position += count
759
+ if current and len(current) + len(unit) > size:
760
+ out.append(current)
761
+ current = []
762
+ current.extend(unit)
763
+ if current:
764
+ out.append(current)
765
+ return out
766
+
767
+
768
+ # Responses ------------------------------------------------------------------------------------
769
+
770
+
771
+ def edit_document(
772
+ schema: Schema, change: DataChange, masking: Masking = UNCLEARED
773
+ ) -> dict[str, Any]:
774
+ """An edit with api names instead of ids, and the values the caller may not read masked
775
+ (an update carries the Record's current values too)."""
776
+ if isinstance(change, RecordUpsert | RecordDelete):
777
+ kind_id = change.record.kind if isinstance(change, RecordUpsert) else change.kind
778
+ kind = schema.kinds.get(kind_id)
779
+ body: dict[str, Any] = {
780
+ "op": change.op,
781
+ "kind": kind.api_name if kind else kind_id,
782
+ "key": change.identity.key,
783
+ }
784
+ if isinstance(change, RecordUpsert):
785
+ body["values"] = (
786
+ masking.kind_values(kind, change.record.values, omit=False)
787
+ if kind
788
+ else masking.value_map({}, (kind_id,), dict(change.record.values))
789
+ )
790
+ return body
791
+ relation_id = change.link.relation if isinstance(change, LinkUpsert) else change.relation
792
+ relation = schema.relations.get(relation_id)
793
+ identity = change.identity
794
+ body = {
795
+ "op": change.op,
796
+ "relation": relation.api_name if relation else relation_id,
797
+ "from": identity.from_key,
798
+ "to": identity.to_key,
799
+ }
800
+ if isinstance(change, LinkUpsert):
801
+ body["values"] = (
802
+ masking.link_values(schema, relation, change.link.values)
803
+ if relation
804
+ else masking.value_map({}, (), dict(change.link.values))
805
+ )
806
+ return body