@telorun/kernel 0.82.1 → 0.83.0

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.
@@ -5,28 +5,65 @@ import type {
5
5
  ResourceContext,
6
6
  ResourceInstance,
7
7
  } from "@telorun/sdk";
8
- import { isCompiledValue } from "@telorun/sdk";
8
+ import {
9
+ celEvalSites,
10
+ effectiveAuthorSchema,
11
+ evalPathCovers,
12
+ mergeCelEvalSites,
13
+ NO_CEL_EVAL_SITES,
14
+ pathMatchesScope,
15
+ type CelEvalSites,
16
+ } from "@telorun/analyzer";
17
+ import { isCompiledValue, type ResourceDefinition } from "@telorun/sdk";
9
18
  import { isRefSentinel } from "@telorun/templating";
10
19
  import { celSelfView } from "../../evaluation-context.js";
11
20
 
12
- /** CEL variables that are only bound at call time (request handling, step
13
- * chaining, error branches) never at a template's init(). A persistent
14
- * child's body is expanded against `self` only, so a `${{ }}` node referencing
15
- * any of these must survive untouched for the consuming controller (e.g. an
16
- * Http.Api evaluating route CEL per request) to evaluate later. A node mixing
17
- * `self` with a deferred variable in one expression is unsupported by design —
18
- * keep `self`-derived literals and request-derived values in separate fields
19
- * (e.g. a `self`-built SQL string vs. request-built `bindings`). */
20
- const DEFERRED_VARS = new Set(["request", "result", "steps", "error"]);
21
- const DEFERRED_RE = new RegExp(`(?<![.\\w])(?:${[...DEFERRED_VARS].join("|")})(?![\\w])`);
21
+ /**
22
+ * WHICH NODES OF A TEMPLATE BODY SURVIVE init() UNEXPANDED.
23
+ *
24
+ * A `resources:` entry is a DECLARATION of another kind, and that kind decides
25
+ * when each of its fields is evaluated: an `x-telo-eval: runtime` field, or any
26
+ * field under a CEL-bearing region (an `x-telo-context`, an error branch, a step
27
+ * body), is evaluated by the child's OWN controller against a scope only it can
28
+ * build. Those nodes must reach the child compiled.
29
+ *
30
+ * Read off the nested kind's schema through the containment matcher both halves
31
+ * already share (`celEvalSites` / `evalPathCovers`), never off a list of
32
+ * variable names. The name list — `request`, `result`, `steps`, `error` — was
33
+ * the reason a body could not read the call's own arguments (`inputs`) or an
34
+ * iteration's element (`item`): those two names were simply absent from it, so
35
+ * such a node was expanded at init() against a scope where they do not exist and
36
+ * failed with `Unknown variable: inputs`. A list has to be extended for every
37
+ * name any kind ever binds; the annotations already say where evaluation
38
+ * happens.
39
+ */
40
+ /** True when a node at `path` inside a body is evaluated by the child's own
41
+ * controller rather than at the template's init(). `runtime` paths are
42
+ * property-only and match by containment; a region scope is a JSONPath
43
+ * (`$.routes[*].returns`) whose wildcards a plain prefix test cannot resolve. */
44
+ function isDeferredPath(sites: CelEvalSites, path: string): boolean {
45
+ return (
46
+ sites.runtime.some((p) => evalPathCovers(p, path)) ||
47
+ sites.regions.some((scope) => pathMatchesScope(path, scope))
48
+ );
49
+ }
22
50
 
23
- /** True when an expression reads a call-time-only variable. Prefers the AST-
24
- * derived root identifiers stamped on the CompiledValue at compile time (exact
25
- * ignores string literals and substrings); falls back to a source-text scan for
26
- * values produced by engines that surface no AST. */
27
- function referencesDeferred(value: CompiledValue): boolean {
28
- if (value.refs) return value.refs.some((r) => DEFERRED_VARS.has(r));
29
- return typeof value.source === "string" && DEFERRED_RE.test(value.source);
51
+ /**
52
+ * True when an expression reads anything other than `self`. A deferred node that
53
+ * names only `self` is still resolved at `init()`: `self` is fixed for the life
54
+ * of the instance, so expanding it there costs nothing and keeps a `self`-built
55
+ * literal (a SQL string, a route path) a literal.
56
+ *
57
+ * Read off the AST-derived roots the compile step stamps, never off the source
58
+ * text. A regex over the source both over- and under-matches — it fires on a
59
+ * word inside a string literal and misses a bare `${{ item }}` with no member
60
+ * access — and there is nothing to fall back FOR: every built-in engine carries
61
+ * `refs` through. An engine that surfaces none is treated as reading nothing but
62
+ * `self`, which resolves it at `init()` and fails loudly there rather than
63
+ * silently deferring a node the child cannot evaluate.
64
+ */
65
+ function referencesBeyondSelf(value: CompiledValue): boolean {
66
+ return (value.refs ?? []).some((r) => r !== "self");
30
67
  }
31
68
 
32
69
  /** Matches a CEL source that is exactly a `self.<path>` member access (capturing
@@ -75,8 +112,18 @@ export function createTemplateController(definition: {
75
112
  // `inputs:` sibling (same factoring as Run.Sequence steps), never in the
76
113
  // target's resource body — the body is `self`-only so every child can be
77
114
  // created once at init and reused across calls.
78
- const targetName = (field: string | { kind?: string; name: string } | undefined): string | null => {
115
+ const targetName = (
116
+ field: string | { kind?: string; name: string } | undefined,
117
+ ): string | null => {
79
118
  if (field == null) return null;
119
+ // `invoke: !ref body` names a sibling `resources:` entry — the same
120
+ // spelling every other reference uses. Phase 2.5 does not descend into a
121
+ // `Telo.Definition`, so the sentinel arrives raw; `Self.` is the
122
+ // explicit self-qualifier and names the same local entry.
123
+ if (isRefSentinel(field)) {
124
+ const source = field.source;
125
+ return source.startsWith("Self.") ? source.slice("Self.".length) : source;
126
+ }
80
127
  const nameTemplate =
81
128
  typeof field === "object" && !isCompiledValue(field) ? field.name : field;
82
129
  return nameTemplate
@@ -146,16 +193,16 @@ export function createTemplateController(definition: {
146
193
  }
147
194
 
148
195
  // Expand a persistent child's body against `self`. Self-only CEL resolves
149
- // to literals now; CEL bound only at call time (DEFERRED_VARS) passes
150
- // through compiled for the child's own controller. `!ref` sentinels are
196
+ // to literals now; a node the NESTED KIND evaluates later (see
197
+ // `deferredPaths`) passes through compiled for the child's own controller. `!ref` sentinels are
151
198
  // rewritten to the `{kind, name, alias?}` injection shape here — Phase 2.5
152
199
  // (`resolveRefSentinels`) does not descend into template bodies, so the
153
200
  // child context's Phase 5 injection would otherwise see an unrecognized
154
201
  // sentinel and leave the slot unresolved. Kind is left empty: injection
155
202
  // dispatches by name and recovers the kind from the resolved instance.
156
- const expandSelf = (value: any): any => {
203
+ const expandSelf = (value: any, path: string, deferred: CelEvalSites): any => {
157
204
  if (isCompiledValue(value)) {
158
- if (referencesDeferred(value)) return value;
205
+ if (isDeferredPath(deferred, path) && referencesBeyondSelf(value)) return value;
159
206
  // A pure `self.<path>` access (e.g. a `connection: !ref` passed down) is
160
207
  // resolved by navigating the resource directly. Going through CEL would
161
208
  // re-emit the value through CEL's output type-checker, which rejects live
@@ -163,10 +210,11 @@ export function createTemplateController(definition: {
163
210
  // a consumer wired in could never reach a child's slot. Complex self
164
211
  // expressions (string building) still evaluate via CEL, where they yield
165
212
  // CEL-safe scalars.
166
- const path = typeof value.source === "string" ? value.source.trim().match(SELF_PATH) : null;
167
- if (path) {
213
+ const selfPath =
214
+ typeof value.source === "string" ? value.source.trim().match(SELF_PATH) : null;
215
+ if (selfPath) {
168
216
  let cur: any = getSelf();
169
- for (const key of path[1].split(".").slice(1)) cur = cur?.[key];
217
+ for (const key of selfPath[1]!.split(".").slice(1)) cur = cur?.[key];
170
218
  return cur;
171
219
  }
172
220
  // CEL cannot read a member off a live instance, and a ref slot holds
@@ -188,15 +236,54 @@ export function createTemplateController(definition: {
188
236
  const name = alias === "Self" ? source.slice(dot + 1) : source;
189
237
  return { kind: siblingKinds.get(name) ?? "", name };
190
238
  }
191
- if (Array.isArray(value)) return value.map(expandSelf);
239
+ if (Array.isArray(value)) {
240
+ return value.map((item, i) => expandSelf(item, `${path}[${i}]`, deferred));
241
+ }
192
242
  if (value !== null && typeof value === "object") {
193
243
  const out: Record<string, unknown> = {};
194
- for (const [k, v] of Object.entries(value)) out[k] = expandSelf(v);
244
+ for (const [k, v] of Object.entries(value)) {
245
+ out[k] = expandSelf(v, path ? `${path}.${k}` : k, deferred);
246
+ }
195
247
  return out;
196
248
  }
197
249
  return value;
198
250
  };
199
251
 
252
+ /**
253
+ * The nested kind's own CEL evaluation sites, resolved once per body. The
254
+ * kind is written in the DEFINING library's alias scope, so it is
255
+ * resolved there — the consumer has never heard of the alias.
256
+ *
257
+ * The INHERITANCE-RESOLVED schema, merged with the capability abstract's,
258
+ * exactly as the analyzer half reads it (`effectiveSchemaOf` in
259
+ * `cel-scope.ts`). A nested kind that inherits a CEL region from an
260
+ * `extends` parent — or gets one implicitly from `Telo.Provider` — would
261
+ * otherwise be covered on the static side and not here, which is the two
262
+ * halves disagreeing about when a node is evaluated.
263
+ */
264
+ const deferredPathsFor = (kind: unknown): CelEvalSites => {
265
+ if (typeof kind !== "string") return NO_CEL_EVAL_SITES;
266
+ const cached = deferredByKind.get(kind);
267
+ if (cached) return cached;
268
+ const resolveDef = (k: string): ResourceDefinition | undefined =>
269
+ definingContext.getDefinition?.(definingContext.kindResolver?.(k) ?? k) ??
270
+ definingContext.getDefinition?.(k);
271
+ const def = resolveDef(kind);
272
+ const capability = def?.capability;
273
+ const sites = def
274
+ ? mergeCelEvalSites(
275
+ celEvalSites(effectiveAuthorSchema(def, resolveDef) as Record<string, any>),
276
+ celEvalSites(
277
+ (capability ? resolveDef(capability)?.schema : undefined) as
278
+ | Record<string, any>
279
+ | undefined,
280
+ ),
281
+ )
282
+ : NO_CEL_EVAL_SITES;
283
+ deferredByKind.set(kind, sites);
284
+ return sites;
285
+ };
286
+
200
287
  // init() may run more than once: when a child's local ref names a sibling
201
288
  // not yet initialized, child init defers with ERR_LOCAL_REF_PENDING and the
202
289
  // outer multi-pass loop retries this resource. Registration must happen
@@ -204,6 +291,10 @@ export function createTemplateController(definition: {
204
291
  // children are skipped, still-pending ones advance).
205
292
  let registered = false;
206
293
 
294
+ /** Memo per nested kind — a body is expanded once, but a template kind is
295
+ * instantiated many times across an application. */
296
+ const deferredByKind = new Map<string, CelEvalSites>();
297
+
207
298
  return {
208
299
  // The template's own resources are its allocation, and tearing the child
209
300
  // context down is the inverse. `init()` still resumes rather than
@@ -212,8 +303,17 @@ export function createTemplateController(definition: {
212
303
  init: (templateCtx) =>
213
304
  templateCtx.effect("template resources", async () => {
214
305
  if (!registered) {
306
+ // `self` is in scope for the whole body, not only for what init()
307
+ // resolves: a node the nested kind evaluates later (a step's
308
+ // `inputs`, a route's `returns`) may read `self` beside the
309
+ // call-time names its own controller binds. Bound as the
310
+ // published-reading view, so `self.<ref>.<field>` answers exactly
311
+ // as `resources.<name>.<field>` does.
312
+ childContext.bindContextValue?.("self", celSelfView(getSelf()));
215
313
  for (const template of definition.resources ?? []) {
216
- childContext.registerManifest(expandSelf(template));
314
+ childContext.registerManifest(
315
+ expandSelf(template, "", deferredPathsFor(template?.kind)),
316
+ );
217
317
  }
218
318
  registered = true;
219
319
  }
@@ -82,6 +82,12 @@ const SCHEMA_AS_CONTRACT_KINDS = new Set(["Telo.Definition", "Telo.Abstract", "T
82
82
  function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
83
83
  const found = new Map<string, ResourceRef>();
84
84
  const skipSchema = SCHEMA_AS_CONTRACT_KINDS.has(resource.kind as string);
85
+ // A re-created resource is rebuilt from the manifest as REGISTERED, but
86
+ // Phase-5 injection mutated that object in place on the previous pass — so a
87
+ // ref slot may already hold a live instance, whose object graph is cyclic.
88
+ // The walk is a best-effort edge collection for failure attribution, so it
89
+ // stops at anything it has already seen rather than recursing forever.
90
+ const seen = new WeakSet<object>();
85
91
  const visit = (value: unknown): void => {
86
92
  if (isResolvedRef(value)) {
87
93
  const key = `${value.alias ?? ""}::${value.name}`;
@@ -89,8 +95,12 @@ function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
89
95
  return;
90
96
  }
91
97
  if (Array.isArray(value)) {
98
+ if (seen.has(value)) return;
99
+ seen.add(value);
92
100
  for (const item of value) visit(item);
93
101
  } else if (value && typeof value === "object") {
102
+ if (seen.has(value)) return;
103
+ seen.add(value);
94
104
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
95
105
  if (k === "metadata" || (skipSchema && k === "schema")) continue;
96
106
  visit(v);
@@ -484,6 +494,10 @@ export class EvaluationContext implements IEvaluationContext {
484
494
  parent: IEvaluationContext | undefined = undefined;
485
495
  readonly children: IEvaluationContext[] = [];
486
496
 
497
+ /** Where this node sits in its parent's teardown cascade — ascending, default
498
+ * 0, reverse-registration within a tier. See `childTeardownOrder`. */
499
+ teardownPriority: number | undefined = undefined;
500
+
487
501
  /** Current lifecycle state of this context node. */
488
502
  state: LifecycleState = "Pending";
489
503
 
@@ -515,6 +529,84 @@ export class EvaluationContext implements IEvaluationContext {
515
529
  * Their re-creation is not progress — see the create sub-phase. */
516
530
  private readonly recreatedResources = new Set<string>();
517
531
 
532
+ /**
533
+ * Instances this context can reach by name but does NOT own — a library's
534
+ * declared `resources:` inputs, bound here by the import that handed them
535
+ * down.
536
+ *
537
+ * **Borrowed, not owned.** The instance's effect frame belongs to the scope
538
+ * that DECLARED it, so this context must never include it in its own
539
+ * teardown: a library tearing one down would close the application's
540
+ * connection out from under everything else still using it.
541
+ */
542
+ protected readonly borrowedResources = new Set<string>();
543
+
544
+ /**
545
+ * Bind an instance this context does not own under `name`, and mirror its
546
+ * published reading into this context's `resources` scope so
547
+ * `resources.<name>.<field>` reads here exactly as it does where the resource
548
+ * is declared.
549
+ *
550
+ * The mirror is a subscription rather than a copy because a published value is
551
+ * a READING: the owner republishes after `run()`, after every `invoke()` and
552
+ * on every `setStatus()`, and a snapshot taken once at binding would go stale
553
+ * at the first of those — silently, since nothing downstream can tell a stale
554
+ * reading from a current one.
555
+ */
556
+ adoptBorrowedResource(
557
+ name: string,
558
+ resource: ResourceManifest,
559
+ instance: ResourceInstance,
560
+ owner: EvaluationContext,
561
+ ): void | (() => void) {
562
+ this.resourceInstances.set(name, { resource, instance });
563
+ this.borrowedResources.add(name);
564
+ this.declaredManifests.set(name, resource);
565
+ const unmirror = owner.mirrorPublications(resource.metadata.name as string, (props) =>
566
+ this.onResourceSnapshotted(name, props),
567
+ );
568
+ // The INVERSE, returned rather than performed: an import whose `init()`
569
+ // fails is discarded and re-created on the next pass, so a subscription left
570
+ // behind would be appended again on every pass — unbounded — and would keep
571
+ // a dead child context reachable from the live owner. The binding is not on
572
+ // this context's teardown path either (`teardownOrder` filters a borrowed
573
+ // name out, and that is also the only place an entry is deleted), so
574
+ // undoing it has to be stated here.
575
+ return () => {
576
+ unmirror();
577
+ this.borrowedResources.delete(name);
578
+ this.resourceInstances.delete(name);
579
+ this.declaredManifests.delete(name);
580
+ };
581
+ }
582
+
583
+ /** Publication mirrors registered by {@link adoptBorrowedResource}, by the
584
+ * OWNER's name for the resource. */
585
+ private readonly publicationMirrors = new Map<
586
+ string,
587
+ Array<(props: Record<string, unknown>) => void>
588
+ >();
589
+
590
+ /** Register a mirror and replay the current reading, so a borrower that binds
591
+ * after the owner has already published does not wait for the next one.
592
+ * Returns the unsubscribe — a subscription with no way to end it outlives
593
+ * whatever registered it. */
594
+ mirrorPublications(name: string, sink: (props: Record<string, unknown>) => void): () => void {
595
+ const bucket = this.publicationMirrors.get(name);
596
+ if (bucket) bucket.push(sink);
597
+ else this.publicationMirrors.set(name, [sink]);
598
+ const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
599
+ const current = entry ? publishedByInstance.get(entry.instance) : undefined;
600
+ if (current) sink(current);
601
+ return () => {
602
+ const sinks = this.publicationMirrors.get(name);
603
+ if (!sinks) return;
604
+ const at = sinks.indexOf(sink);
605
+ if (at >= 0) sinks.splice(at, 1);
606
+ if (sinks.length === 0) this.publicationMirrors.delete(name);
607
+ };
608
+ }
609
+
518
610
  /** Resources queued for initialization on this context node. */
519
611
  private pendingResources: ResourceManifest[] = [];
520
612
 
@@ -684,6 +776,7 @@ export class EvaluationContext implements IEvaluationContext {
684
776
  });
685
777
  publishedByInstance.set(entry.instance, props);
686
778
  this.onResourceSnapshotted(name, props);
779
+ for (const sink of this.publicationMirrors.get(name) ?? []) sink(props);
687
780
  }
688
781
 
689
782
  get context(): Record<string, unknown> {
@@ -801,6 +894,21 @@ export class EvaluationContext implements IEvaluationContext {
801
894
  return child;
802
895
  }
803
896
 
897
+ /**
898
+ * Bind a name into this context's own CEL scope.
899
+ *
900
+ * A copy is written rather than a mutation: `spawnChildContext` hands the
901
+ * child the PARENT's context object by reference, so mutating it in place
902
+ * would put the binding in the parent's scope as well.
903
+ *
904
+ * Used by a template to put `self` in scope for the body it creates, so a node
905
+ * the nested kind evaluates later can read the enclosing resource's
906
+ * configuration beside the call-time names that kind binds.
907
+ */
908
+ bindContextValue(name: string, value: unknown): void {
909
+ this._context = { ...this._context, [name]: value };
910
+ }
911
+
804
912
  /** Spawn a fresh child context attached to this node — the isolated scope a
805
913
  * templated definition registers its `resources:` into. Rooting it on the
806
914
  * context that DEFINED the template (not the consumer that instantiated the
@@ -1216,7 +1324,7 @@ export class EvaluationContext implements IEvaluationContext {
1216
1324
  this.state = "Draining";
1217
1325
  const failures: Array<{ resource: string; error: unknown }> = [];
1218
1326
 
1219
- for (const child of [...this.children].reverse()) {
1327
+ for (const child of this.childTeardownOrder()) {
1220
1328
  try {
1221
1329
  await child.teardownResources();
1222
1330
  } catch (err) {
@@ -1294,6 +1402,28 @@ export class EvaluationContext implements IEvaluationContext {
1294
1402
  }
1295
1403
  }
1296
1404
 
1405
+ /**
1406
+ * Child contexts in teardown order: ascending `teardownPriority` (default 0),
1407
+ * with the base reverse-registration order preserved within each tier.
1408
+ *
1409
+ * The same rule `teardownOrder` applies to resource instances, and for the
1410
+ * same reason: reverse registration is reverse init order in the happy path,
1411
+ * but a node that must reliably outlive the rest has to say so rather than
1412
+ * depend on when it happened to be created. A `lifecycle: shared` library is
1413
+ * registered when the FIRST import reaches it — which, for an import declared
1414
+ * inside another library, is after that library's own context — so reverse
1415
+ * registration would tear the singleton down while a borrower still holds it.
1416
+ */
1417
+ private childTeardownOrder(): IEvaluationContext[] {
1418
+ return [...this.children]
1419
+ .reverse()
1420
+ .sort(
1421
+ (a, b) =>
1422
+ ((a as { teardownPriority?: number }).teardownPriority ?? 0) -
1423
+ ((b as { teardownPriority?: number }).teardownPriority ?? 0),
1424
+ );
1425
+ }
1426
+
1297
1427
  /**
1298
1428
  * Resource instances in teardown order: ascending `teardownPriority`, with the
1299
1429
  * base reverse-insertion order preserved within each priority tier.
@@ -1308,7 +1438,11 @@ export class EvaluationContext implements IEvaluationContext {
1308
1438
  * instance shape.
1309
1439
  */
1310
1440
  private teardownOrder(): Array<[string, { resource: any; instance: any }]> {
1311
- const entries = [...this.resourceInstances.entries()].reverse();
1441
+ // A borrowed instance is torn down by the scope that declared it, never
1442
+ // here — see `borrowedResources`.
1443
+ const entries = [...this.resourceInstances.entries()]
1444
+ .filter(([name]) => !this.borrowedResources.has(name))
1445
+ .reverse();
1312
1446
  // Stable sort by priority (default 0); Array.prototype.sort is stable, so
1313
1447
  // the reverse-insertion order survives within each tier.
1314
1448
  return entries.sort(
@@ -552,6 +552,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
552
552
  return `${realModule}.${suffix}`;
553
553
  }
554
554
 
555
+ /** A module context IS the alias table, so it answers the resolver seam
556
+ * directly rather than inheriting one from a parent it does not have. Public
557
+ * because a template body's nested kind is written in the DEFINING library's
558
+ * scope and its controller has to resolve it there. */
559
+ override kindResolver = (kind: string): string => this.resolveKindSafe(kind);
560
+
555
561
  protected override resolveKindSafe(kind: string): string {
556
562
  // `resolveKind` throws for unqualified / ungated kinds — an expected signal,
557
563
  // not a failure: a capability probe that can't resolve falls back to the raw