lorebase-core 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 (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,52 @@
1
+ """Bounds of compiling and evaluating one expression (the sandbox budget).
2
+
3
+ Every bound is a field with a documented default; services expose the object in their
4
+ settings (``keeper.operations.expression_limits``) so deployments can tune it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+
12
+ class ExprLimits(BaseModel):
13
+ """Budget of one expression. Exceeding a bound is an ``expression.limit_exceeded`` error."""
14
+
15
+ model_config = ConfigDict(frozen=True, extra="forbid")
16
+
17
+ max_source_length: int = Field(
18
+ default=4096, ge=1, description="Characters of expression source accepted by compile."
19
+ )
20
+ max_depth: int = Field(
21
+ default=48,
22
+ ge=1,
23
+ description="Nesting depth of the expression tree (operators, calls, literals).",
24
+ )
25
+ max_steps: int = Field(
26
+ default=10_000,
27
+ ge=1,
28
+ description="Evaluation steps (one per node visited, comprehension iterations "
29
+ "included) of one evaluation.",
30
+ )
31
+ max_duration_ms: int = Field(
32
+ default=250,
33
+ ge=1,
34
+ description="Wall-clock budget of one evaluation; a safety net behind max_steps.",
35
+ )
36
+ max_list_size: int = Field(
37
+ default=10_000, ge=1, description="Elements of a list or map built during evaluation."
38
+ )
39
+ max_string_length: int = Field(
40
+ default=65_536, ge=1, description="Characters of a string built during evaluation."
41
+ )
42
+ max_record_lookups: int = Field(
43
+ default=100,
44
+ ge=0,
45
+ description="Records and link lists read through record(), links() and links.<name> "
46
+ "in one evaluation.",
47
+ )
48
+ decimal_precision: int = Field(
49
+ default=34,
50
+ ge=1,
51
+ description="Significant digits of decimal arithmetic (34 is IEEE 754 decimal128).",
52
+ )
@@ -0,0 +1,479 @@
1
+ """Operations (design 5.7): compile their expressions, check preconditions, plan their steps.
2
+
3
+ Everything is pure: Records are read through the context's ``RecordResolver`` and the
4
+ result is a list of ``lorebase_core.validate`` data changes for the commit pipeline.
5
+
6
+ Steps run in order. A step sees the Records written by earlier steps (``record($step[0])``
7
+ reads the Record step 0 created), and ``$step[n]`` is the reference of the Record step
8
+ ``n`` created, updated or deleted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Mapping
14
+ from dataclasses import dataclass, field
15
+
16
+ from pydantic import JsonValue
17
+
18
+ from lorebase_core.expr.ast import STEP_VARIABLE
19
+ from lorebase_core.expr.compiler import CompiledExpression, compile, run
20
+ from lorebase_core.expr.context import EvaluationContext, activation
21
+ from lorebase_core.expr.environment import PARAMS, PRINCIPAL, Environment, operation_environment
22
+ from lorebase_core.expr.errors import ExprError, ExprErrorCode, PathPart, evaluation_error
23
+ from lorebase_core.expr.limits import ExprLimits
24
+ from lorebase_core.expr.ops import ref_key, require_bool
25
+ from lorebase_core.expr.types import BOOL, ExprType, from_type_spec, ref_of
26
+ from lorebase_core.expr.values import Ref, encode_result
27
+ from lorebase_core.model.attribute import Attribute
28
+ from lorebase_core.model.operation import (
29
+ CreateLinkStep,
30
+ CreateRecordStep,
31
+ DeleteLinkStep,
32
+ DeleteRecordStep,
33
+ Operation,
34
+ OperationStep,
35
+ ReviewMode,
36
+ UpdateRecordStep,
37
+ )
38
+ from lorebase_core.model.record import Link, Record, format_ref, record_key
39
+ from lorebase_core.model.relation import RelationBacking
40
+ from lorebase_core.model.schema import Schema
41
+ from lorebase_core.types import normalize_key
42
+ from lorebase_core.validate.changes import (
43
+ DataChange,
44
+ LinkDelete,
45
+ LinkUpsert,
46
+ RecordDelete,
47
+ RecordUpsert,
48
+ )
49
+
50
+ #: Names Operation expressions define themselves; parameters cannot take them.
51
+ RESERVED_PARAMETER_NAMES = frozenset({PARAMS, PRINCIPAL, STEP_VARIABLE})
52
+
53
+
54
+ @dataclass(frozen=True, eq=False)
55
+ class CompiledStep:
56
+ """One step with its compiled expressions (``values`` keyed by Attribute id)."""
57
+
58
+ index: int
59
+ step: OperationStep
60
+ environment: Environment
61
+ values: Mapping[str, CompiledExpression] = field(default_factory=dict)
62
+ target: CompiledExpression | None = None
63
+ from_: CompiledExpression | None = None
64
+ to: CompiledExpression | None = None
65
+
66
+
67
+ @dataclass(frozen=True, eq=False)
68
+ class CompiledOperation:
69
+ schema: Schema
70
+ operation: Operation
71
+ preconditions: tuple[CompiledExpression, ...]
72
+ review_policy: CompiledExpression | None
73
+ steps: tuple[CompiledStep, ...]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class PreconditionFailure:
78
+ """A precondition that is false (``error`` is ``None``) or could not be evaluated."""
79
+
80
+ index: int
81
+ message: str
82
+ error: ExprError | None = None
83
+
84
+
85
+ @dataclass
86
+ class StepPlan:
87
+ """The data changes of an Operation's steps and the Record each step touched."""
88
+
89
+ changes: list[DataChange] = field(default_factory=list)
90
+ records: list[Ref | None] = field(default_factory=list)
91
+
92
+
93
+ class _Builder:
94
+ """Compiles every expression of an Operation, raising or collecting errors."""
95
+
96
+ def __init__(self, schema: Schema, limits: ExprLimits, *, collect: bool) -> None:
97
+ self.schema = schema
98
+ self.limits = limits
99
+ self.errors: list[ExprError] | None = [] if collect else None
100
+
101
+ def fail(self, error: ExprError, path: tuple[PathPart, ...]) -> None:
102
+ error.located(path=path)
103
+ if self.errors is None:
104
+ raise error
105
+ self.errors.append(error)
106
+
107
+ def unknown(self, message: str, path: tuple[PathPart, ...]) -> None:
108
+ """A dangling reference: schema validation reports it, compiling for use refuses it."""
109
+ if self.errors is None:
110
+ raise ExprError(ExprErrorCode.TYPE, message, path=path)
111
+
112
+ def expression(
113
+ self,
114
+ path: tuple[PathPart, ...],
115
+ source: str,
116
+ environment: Environment,
117
+ expected: ExprType,
118
+ *,
119
+ nullable: bool,
120
+ ) -> CompiledExpression | None:
121
+ try:
122
+ return compile(
123
+ source, environment, expected=expected, nullable=nullable, limits=self.limits
124
+ )
125
+ except ExprError as error:
126
+ self.fail(error, path)
127
+ return None
128
+
129
+ def values(
130
+ self,
131
+ path: tuple[PathPart, ...],
132
+ values: Mapping[str, str],
133
+ attributes: Mapping[str, Attribute],
134
+ environment: Environment,
135
+ ) -> dict[str, CompiledExpression]:
136
+ compiled: dict[str, CompiledExpression] = {}
137
+ for attribute_id, source in values.items():
138
+ attribute = attributes.get(attribute_id)
139
+ if attribute is None:
140
+ continue # reported by schema validation
141
+ program = self.expression(
142
+ (*path, "values", attribute_id),
143
+ source,
144
+ environment,
145
+ from_type_spec(attribute.type),
146
+ nullable=True,
147
+ )
148
+ if program is not None:
149
+ compiled[attribute_id] = program
150
+ return compiled
151
+
152
+ def operation(self, operation: Operation) -> CompiledOperation:
153
+ schema = self.schema
154
+ for index, parameter in enumerate(operation.parameters):
155
+ if parameter.name in RESERVED_PARAMETER_NAMES:
156
+ self.fail(
157
+ ExprError(
158
+ ExprErrorCode.TYPE,
159
+ f"parameter name {parameter.name!r} is reserved in expressions",
160
+ ),
161
+ ("parameters", index, "name"),
162
+ )
163
+ environment = operation_environment(schema, operation)
164
+ preconditions = []
165
+ for index, precondition in enumerate(operation.preconditions):
166
+ program = self.expression(
167
+ ("preconditions", index, "expression"),
168
+ precondition.expression,
169
+ environment,
170
+ BOOL,
171
+ nullable=False,
172
+ )
173
+ if program is not None:
174
+ preconditions.append(program)
175
+ review = None
176
+ if operation.review_policy.mode is ReviewMode.EXPRESSION:
177
+ assert operation.review_policy.expression is not None
178
+ review = self.expression(
179
+ ("review_policy", "expression"),
180
+ operation.review_policy.expression,
181
+ environment,
182
+ BOOL,
183
+ nullable=False,
184
+ )
185
+ steps = tuple(self.step(operation, index) for index in range(len(operation.steps)))
186
+ return CompiledOperation(schema, operation, tuple(preconditions), review, steps)
187
+
188
+ def step(self, operation: Operation, index: int) -> CompiledStep:
189
+ step = operation.steps[index]
190
+ path: tuple[PathPart, ...] = ("steps", index)
191
+ environment = operation_environment(self.schema, operation, step_index=index)
192
+ if isinstance(step, CreateLinkStep | DeleteLinkStep):
193
+ relation = self.schema.relations.get(step.relation)
194
+ if relation is None or relation.backing is not RelationBacking.EXPLICIT:
195
+ self.unknown(f"Relation {step.relation} cannot be written", (*path, "relation"))
196
+ return CompiledStep(index, step, environment)
197
+ from_ = self.expression(
198
+ (*path, "from"), step.from_, environment, ref_of(relation.from_kind), nullable=False
199
+ )
200
+ to = self.expression(
201
+ (*path, "to"), step.to, environment, ref_of(relation.to_kind), nullable=False
202
+ )
203
+ values = {}
204
+ if isinstance(step, CreateLinkStep):
205
+ values = self.values(path, step.values, relation.attributes, environment)
206
+ return CompiledStep(index, step, environment, values, from_=from_, to=to)
207
+ kind = self.schema.kinds.get(step.kind)
208
+ if kind is None:
209
+ self.unknown(f"unknown Kind {step.kind}", (*path, "kind"))
210
+ return CompiledStep(index, step, environment)
211
+ target = None
212
+ if isinstance(step, UpdateRecordStep | DeleteRecordStep):
213
+ target = self.expression(
214
+ (*path, "target"), step.target, environment, ref_of(kind.id), nullable=False
215
+ )
216
+ values = {}
217
+ if isinstance(step, CreateRecordStep | UpdateRecordStep):
218
+ values = self.values(path, step.values, kind.attributes, environment)
219
+ return CompiledStep(index, step, environment, values, target=target)
220
+
221
+
222
+ def compile_operation(
223
+ schema: Schema, operation: Operation, limits: ExprLimits | None = None
224
+ ) -> CompiledOperation:
225
+ """Compile every expression of ``operation``; raises the first ``ExprError`` (with path)."""
226
+ return _Builder(schema, limits or ExprLimits(), collect=False).operation(operation)
227
+
228
+
229
+ def operation_expression_errors(
230
+ schema: Schema, operation: Operation, limits: ExprLimits | None = None
231
+ ) -> list[ExprError]:
232
+ """Every expression problem of ``operation``; each error's ``path`` is inside the element."""
233
+ builder = _Builder(schema, limits or ExprLimits(), collect=True)
234
+ builder.operation(operation)
235
+ assert builder.errors is not None
236
+ return builder.errors
237
+
238
+
239
+ # Evaluation --------------------------------------------------------------------------------------
240
+
241
+
242
+ def _evaluate(
243
+ compiled: CompiledExpression,
244
+ context: EvaluationContext,
245
+ variables: Mapping[str, object],
246
+ *,
247
+ overlay: Mapping[tuple[str, str], Mapping[str, JsonValue] | None] | None = None,
248
+ ) -> object:
249
+ return run(
250
+ compiled,
251
+ variables,
252
+ now=context.now,
253
+ resolver=context.resolver,
254
+ limits=context.limits,
255
+ overlay=overlay,
256
+ )
257
+
258
+
259
+ def evaluate_precondition(
260
+ compiled: CompiledExpression,
261
+ record: Record | None,
262
+ parameters: Mapping[str, JsonValue],
263
+ context: EvaluationContext,
264
+ ) -> bool:
265
+ """Value of a precondition (or any bool expression of an Operation environment).
266
+
267
+ ``record`` is the subject Record when the environment has one (``None`` for Operation
268
+ preconditions); ``parameters`` are canonical values by name.
269
+ """
270
+ variables = activation(
271
+ compiled.environment, record=record, parameters=parameters, principal=context.principal
272
+ )
273
+ return require_bool(_evaluate(compiled, context, variables), "precondition")
274
+
275
+
276
+ def check_preconditions(
277
+ compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
278
+ ) -> list[PreconditionFailure]:
279
+ """Preconditions that are false or fail to evaluate, in declaration order.
280
+
281
+ ``RecordNotLoaded`` from the resolver propagates so that the caller can load and retry.
282
+ """
283
+ failures: list[PreconditionFailure] = []
284
+ for index, (program, precondition) in enumerate(
285
+ zip(compiled.preconditions, compiled.operation.preconditions, strict=True)
286
+ ):
287
+ try:
288
+ if not evaluate_precondition(program, None, parameters, context):
289
+ failures.append(PreconditionFailure(index, precondition.message))
290
+ except ExprError as error:
291
+ error.located(path=("preconditions", index, "expression"))
292
+ failures.append(PreconditionFailure(index, precondition.message, error))
293
+ return failures
294
+
295
+
296
+ def evaluate_review_policy(
297
+ compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
298
+ ) -> bool:
299
+ """Whether the edits go to a Proposal: ``always``, ``never`` or the policy expression."""
300
+ mode = compiled.operation.review_policy.mode
301
+ if mode is ReviewMode.ALWAYS:
302
+ return True
303
+ if compiled.review_policy is None:
304
+ return False
305
+ try:
306
+ return evaluate_precondition(compiled.review_policy, None, parameters, context)
307
+ except ExprError as error:
308
+ raise error.located(path=("review_policy", "expression")) from None
309
+
310
+
311
+ def plan_steps(
312
+ compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
313
+ ) -> StepPlan:
314
+ """Data changes of the Operation's steps.
315
+
316
+ Raises ``ExprError`` (with ``path`` to the failing step expression) when a value cannot
317
+ be computed or does not fit its Attribute, when an updated Record does not exist
318
+ (``record.not_found``) or a created one does (``record.exists``); lets
319
+ ``RecordNotLoaded`` through.
320
+ """
321
+ plan = StepPlan()
322
+ overlay: dict[tuple[str, str], Mapping[str, JsonValue] | None] = {}
323
+ for compiled_step in compiled.steps:
324
+ variables = activation(
325
+ compiled_step.environment,
326
+ parameters=parameters,
327
+ principal=context.principal,
328
+ steps=plan.records,
329
+ )
330
+ planner = _StepPlanner(compiled, compiled_step, context, variables, overlay)
331
+ plan.records.append(planner.run(plan.changes))
332
+ return plan
333
+
334
+
335
+ class _StepPlanner:
336
+ def __init__(
337
+ self,
338
+ compiled: CompiledOperation,
339
+ step: CompiledStep,
340
+ context: EvaluationContext,
341
+ variables: Mapping[str, object],
342
+ overlay: dict[tuple[str, str], Mapping[str, JsonValue] | None],
343
+ ) -> None:
344
+ self.schema = compiled.schema
345
+ self.step = step
346
+ self.context = context
347
+ self.variables = variables
348
+ self.overlay = overlay
349
+ self.path: tuple[PathPart, ...] = ("steps", step.index)
350
+
351
+ def value(self, program: CompiledExpression | None, *where: PathPart) -> object:
352
+ if program is None:
353
+ raise evaluation_error("the step expression did not compile").located(
354
+ path=(*self.path, *where)
355
+ )
356
+ try:
357
+ return _evaluate(program, self.context, self.variables, overlay=self.overlay)
358
+ except ExprError as error:
359
+ raise error.located(path=(*self.path, *where)) from None
360
+
361
+ def ref(self, program: CompiledExpression | None, kind_id: str, where: str) -> Ref:
362
+ value = self.value(program, where)
363
+ assert program is not None # `value` raises without it
364
+ try:
365
+ if isinstance(value, str):
366
+ return Ref(kind_id, normalize_key(value))
367
+ ref = ref_key(value, where)
368
+ except ExprError as error:
369
+ raise error.located(source=program.source, path=(*self.path, where)) from None
370
+ if ref.kind != kind_id:
371
+ raise ExprError(
372
+ ExprErrorCode.VALUE_INVALID,
373
+ f"{where} refers to a {self.kind_name(ref.kind)} Record, expected "
374
+ f"{self.kind_name(kind_id)}",
375
+ path=(*self.path, where),
376
+ )
377
+ return ref
378
+
379
+ def kind_name(self, kind_id: str) -> str:
380
+ kind = self.schema.kinds.get(kind_id)
381
+ return kind.api_name if kind is not None else kind_id
382
+
383
+ def encoded(self, attributes: Mapping[str, Attribute]) -> dict[str, JsonValue]:
384
+ values: dict[str, JsonValue] = {}
385
+ for attribute_id, program in self.step.values.items():
386
+ value = self.value(program, "values", attribute_id)
387
+ try:
388
+ values[attribute_id] = encode_result(attributes[attribute_id].type, value)
389
+ except ExprError as error:
390
+ error.message = f"{attributes[attribute_id].api_name}: {error.message}"
391
+ raise error.located(
392
+ source=program.source, path=(*self.path, "values", attribute_id)
393
+ ) from None
394
+ return values
395
+
396
+ def current(self, kind_id: str, key: str) -> Mapping[str, JsonValue] | None:
397
+ if (kind_id, key) in self.overlay:
398
+ return self.overlay[(kind_id, key)]
399
+ if self.context.resolver is None:
400
+ return None
401
+ return self.context.resolver.record(kind_id, key)
402
+
403
+ def run(self, changes: list[DataChange]) -> Ref | None:
404
+ step = self.step.step
405
+ if isinstance(step, CreateLinkStep | DeleteLinkStep):
406
+ relation = self.schema.relations[step.relation]
407
+ from_ref = self.ref(self.step.from_, relation.from_kind, "from")
408
+ to_ref = self.ref(self.step.to, relation.to_kind, "to")
409
+ if isinstance(step, DeleteLinkStep):
410
+ changes.append(
411
+ LinkDelete(relation=relation.id, from_key=from_ref.key, to_key=to_ref.key)
412
+ )
413
+ return None
414
+ link = Link(
415
+ relation=relation.id,
416
+ from_key=from_ref.key,
417
+ to_key=to_ref.key,
418
+ values=self.encoded(relation.attributes),
419
+ )
420
+ changes.append(LinkUpsert(link=link))
421
+ return None
422
+ kind = self.schema.kinds[step.kind]
423
+ if isinstance(step, CreateRecordStep):
424
+ values = self.encoded(kind.attributes)
425
+ key = record_key(kind, values) if kind.has_natural_key else None
426
+ if key is None:
427
+ key = self.mint(kind.has_natural_key)
428
+ elif self.current(kind.id, key) is not None:
429
+ raise ExprError(
430
+ ExprErrorCode.RECORD_EXISTS,
431
+ f"Record {format_ref(kind.api_name, key)} exists already",
432
+ path=self.path,
433
+ )
434
+ changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=values)))
435
+ self.overlay[(kind.id, key)] = values
436
+ return Ref(kind.id, key)
437
+ target = self.ref(self.step.target, kind.id, "target")
438
+ if isinstance(step, DeleteRecordStep):
439
+ changes.append(RecordDelete(kind=kind.id, key=target.key))
440
+ self.overlay[(kind.id, target.key)] = None
441
+ return target
442
+ current = self.current(kind.id, target.key)
443
+ if current is None:
444
+ raise ExprError(
445
+ ExprErrorCode.RECORD_NOT_FOUND,
446
+ f"Record {format_ref(kind.api_name, target.key)} does not exist",
447
+ source=self.step.target.source if self.step.target is not None else None,
448
+ path=(*self.path, "target"),
449
+ )
450
+ # derived values the resolver may hold are not Record content (AC-07)
451
+ merged: dict[str, JsonValue] = {
452
+ attribute_id: value
453
+ for attribute_id, value in current.items()
454
+ if attribute_id not in kind.attributes or not kind.attributes[attribute_id].is_derived
455
+ }
456
+ for attribute_id, value in self.encoded(kind.attributes).items():
457
+ if value is None:
458
+ merged.pop(attribute_id, None)
459
+ else:
460
+ merged[attribute_id] = value
461
+ changes.append(RecordUpsert(record=Record(kind=kind.id, key=target.key, values=merged)))
462
+ self.overlay[(kind.id, target.key)] = merged
463
+ return target
464
+
465
+ def mint(self, natural: bool) -> str:
466
+ if natural:
467
+ raise ExprError(
468
+ ExprErrorCode.VALUE_INVALID,
469
+ "a primary key value of the created Record is missing",
470
+ path=self.path,
471
+ )
472
+ mint: Callable[[], str] | None = self.context.mint_key
473
+ if mint is None:
474
+ raise ExprError(
475
+ ExprErrorCode.EVALUATION,
476
+ "the Kind has no natural key and the caller supplied no key minting",
477
+ path=self.path,
478
+ )
479
+ return mint()