@telorun/kernel 0.82.1 → 0.84.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.
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +237 -12
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/module/shared-libraries.d.ts +83 -0
- package/dist/controllers/module/shared-libraries.d.ts.map +1 -0
- package/dist/controllers/module/shared-libraries.js +110 -0
- package/dist/controllers/module/shared-libraries.js.map +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts +3 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +8 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.js +104 -30
- package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts +82 -0
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +200 -5
- package/dist/evaluation-context.js.map +1 -1
- package/dist/instance-sensitive-paths.d.ts +61 -0
- package/dist/instance-sensitive-paths.d.ts.map +1 -0
- package/dist/instance-sensitive-paths.js +116 -0
- package/dist/instance-sensitive-paths.js.map +1 -0
- package/dist/invocation-contract-binding.d.ts +3 -0
- package/dist/invocation-contract-binding.d.ts.map +1 -1
- package/dist/invocation-contract-binding.js +9 -1
- package/dist/invocation-contract-binding.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +25 -0
- package/dist/kernel.js.map +1 -1
- package/dist/module-context.d.ts +5 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +5 -0
- package/dist/module-context.js.map +1 -1
- package/package.json +4 -4
- package/src/controllers/module/import-controller.ts +324 -13
- package/src/controllers/module/shared-libraries.ts +180 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
- package/src/controllers/resource-definition/resource-template-controller.ts +129 -29
- package/src/evaluation-context.ts +216 -7
- package/src/instance-sensitive-paths.ts +123 -0
- package/src/invocation-contract-binding.ts +12 -0
- package/src/kernel.ts +29 -1
- package/src/module-context.ts +6 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { EvaluationContext as IEvaluationContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
3
|
+
import type { ModuleContext } from "../../module-context.js";
|
|
4
|
+
import type { ParsedExportEntry } from "@telorun/analyzer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* LIBRARY SINGLETONS — one instantiation of a `lifecycle: shared` library per
|
|
8
|
+
* application, borrowed by every import that names it.
|
|
9
|
+
*
|
|
10
|
+
* An import declaration otherwise builds its own child scope with its own
|
|
11
|
+
* instances, so two libraries importing a third get two of everything in it.
|
|
12
|
+
* That is right for a library whose instances are the importer's — a client
|
|
13
|
+
* configured per consumer — and wrong for one that owns a resource the
|
|
14
|
+
* application has exactly one of. `lifecycle: shared` names the second case, and
|
|
15
|
+
* it is what lets a set of libraries share a dependency without linearizing them
|
|
16
|
+
* into a chain that re-exports the union of everything beneath it.
|
|
17
|
+
*
|
|
18
|
+
* **The root owns it; every import borrows it** — the same rule an injected
|
|
19
|
+
* resource follows. The child context is spawned under the ROOT rather than
|
|
20
|
+
* under whichever import happened to reach it first, because otherwise tearing
|
|
21
|
+
* that importer down would close a library two others still hold, and which
|
|
22
|
+
* importer that is depends on init order. It is torn down after every other root
|
|
23
|
+
* child (`TEARDOWN_LAST` on the context), so a borrower's own inverses still
|
|
24
|
+
* find it alive.
|
|
25
|
+
*
|
|
26
|
+
* **Registered only when shared**, which is what makes a registry HIT the answer
|
|
27
|
+
* to "is this library shared" — a second import of one costs no fetch, no parse
|
|
28
|
+
* and no analysis pass at all.
|
|
29
|
+
*/
|
|
30
|
+
export interface SharedLibrary {
|
|
31
|
+
/** The resolved module URL — the identity two imports must agree on. Carries
|
|
32
|
+
* any `#sha256-` pin, so two imports of the same source at different
|
|
33
|
+
* integrity are different libraries, which is the truth. */
|
|
34
|
+
readonly url: string;
|
|
35
|
+
readonly module: string;
|
|
36
|
+
/** The alias of the import that instantiated it, named in a conflict. */
|
|
37
|
+
readonly owner: string;
|
|
38
|
+
readonly context: ModuleContext;
|
|
39
|
+
readonly child: IEvaluationContext;
|
|
40
|
+
readonly variables: Record<string, unknown>;
|
|
41
|
+
readonly secrets: Record<string, unknown>;
|
|
42
|
+
/** The instances supplied for the library's declared `resources:` inputs,
|
|
43
|
+
* compared by IDENTITY: two imports handing down different instances of the
|
|
44
|
+
* same kind is exactly the split a singleton exists to prevent. */
|
|
45
|
+
readonly resources: ReadonlyMap<string, ResourceInstance>;
|
|
46
|
+
readonly declaredVariables: Record<string, any>;
|
|
47
|
+
readonly declaredSecrets: Record<string, any>;
|
|
48
|
+
readonly exportEntries: readonly ParsedExportEntry[];
|
|
49
|
+
readonly kindEntries: readonly ParsedExportEntry[];
|
|
50
|
+
readonly exportedResourceNames: readonly string[];
|
|
51
|
+
readonly exportedKindSuffixes: readonly string[] | undefined;
|
|
52
|
+
/** Build the library's resources and export tables. Carried on the ENTRY
|
|
53
|
+
* rather than in one import's closure because the import that REGISTERS a
|
|
54
|
+
* singleton is not necessarily the one whose `init()` runs first. */
|
|
55
|
+
readonly build: () => Promise<void>;
|
|
56
|
+
/** Memoized initialization. Whichever import's `init()` runs first starts it
|
|
57
|
+
* and every other awaits the same promise, so a borrower can never proceed
|
|
58
|
+
* against a library whose resources have not been built — an ordering the
|
|
59
|
+
* multi-pass loop does not otherwise guarantee. */
|
|
60
|
+
initialized?: Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Per-kernel, hung off the ROOT context rather than held in module scope, so
|
|
64
|
+
* two in-process kernels never share a library instance. */
|
|
65
|
+
const registries = new WeakMap<object, Map<string, SharedLibrary>>();
|
|
66
|
+
|
|
67
|
+
/** The root of a context's lifecycle tree — the kernel's own root context. */
|
|
68
|
+
export function rootContextOf(ctx: IEvaluationContext): IEvaluationContext {
|
|
69
|
+
let node: IEvaluationContext = ctx;
|
|
70
|
+
while (node.parent) node = node.parent;
|
|
71
|
+
return node;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The shared-library registry for the kernel `ctx` belongs to. */
|
|
75
|
+
export function sharedLibraries(ctx: IEvaluationContext): Map<string, SharedLibrary> {
|
|
76
|
+
const root = rootContextOf(ctx) as unknown as object;
|
|
77
|
+
let registry = registries.get(root);
|
|
78
|
+
if (!registry) registries.set(root, (registry = new Map()));
|
|
79
|
+
return registry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refuse a second import that would instantiate the library differently.
|
|
84
|
+
*
|
|
85
|
+
* A singleton has one configuration, so the only sound reading of two imports
|
|
86
|
+
* supplying different values is that one of them is wrong — and which one cannot
|
|
87
|
+
* be decided here. Resolved by init order it would be whichever import was
|
|
88
|
+
* created first, silently, which is the failure mode `lifecycle: shared` is
|
|
89
|
+
* supposed to remove rather than relocate.
|
|
90
|
+
*
|
|
91
|
+
* A secret's VALUE is never printed: the key is what the author has to look at.
|
|
92
|
+
*/
|
|
93
|
+
export function assertSharedInputsAgree(
|
|
94
|
+
entry: SharedLibrary,
|
|
95
|
+
alias: string,
|
|
96
|
+
variables: Record<string, unknown>,
|
|
97
|
+
secrets: Record<string, unknown>,
|
|
98
|
+
resources: ReadonlyMap<string, { instance: ResourceInstance }>,
|
|
99
|
+
): void {
|
|
100
|
+
const conflict = (block: string, key: string, detail?: string): never => {
|
|
101
|
+
throw new RuntimeError(
|
|
102
|
+
"ERR_SHARED_LIBRARY_CONFLICT",
|
|
103
|
+
`Import '${alias}' and import '${entry.owner}' both reach module '${entry.module}', which ` +
|
|
104
|
+
`declares 'lifecycle: shared' — one instantiation for the whole application — but they ` +
|
|
105
|
+
`supply different values for ${block}.${key}${detail ? ` (${detail})` : ""}. Make the two ` +
|
|
106
|
+
`imports agree, or make the library 'lifecycle: isolated'.`,
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const key of union(Object.keys(entry.variables), Object.keys(variables))) {
|
|
111
|
+
if (!sameValue(entry.variables[key], variables[key])) {
|
|
112
|
+
conflict("variables", key, `'${render(entry.variables[key])}' vs '${render(variables[key])}'`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Values withheld deliberately — a diagnostic must not become a way to read a
|
|
116
|
+
// secret out of a running process.
|
|
117
|
+
for (const key of union(Object.keys(entry.secrets), Object.keys(secrets))) {
|
|
118
|
+
if (!sameValue(entry.secrets[key], secrets[key])) conflict("secrets", key);
|
|
119
|
+
}
|
|
120
|
+
for (const key of union([...entry.resources.keys()], [...resources.keys()])) {
|
|
121
|
+
if (entry.resources.get(key) !== resources.get(key)?.instance) {
|
|
122
|
+
conflict("resources", key, "different instances");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Reject a per-import override a singleton has no room for. The analyzer
|
|
128
|
+
* reports the same thing as `SHARED_LIBRARY_OVERRIDE`; this is the runtime
|
|
129
|
+
* half, for a library reached through a programmatic load that never passed
|
|
130
|
+
* `telo check`. */
|
|
131
|
+
export function assertNoSharedOverride(resource: any, alias: string, module: string): void {
|
|
132
|
+
for (const field of ["logging", "runtime"] as const) {
|
|
133
|
+
if (resource[field] === undefined) continue;
|
|
134
|
+
throw new RuntimeError(
|
|
135
|
+
"ERR_SHARED_LIBRARY_OVERRIDE",
|
|
136
|
+
`Import '${alias}' declares '${field}:', but module '${module}' is 'lifecycle: shared' — ` +
|
|
137
|
+
`one instantiation for the whole application, so a per-import override cannot apply to ` +
|
|
138
|
+
`it. Remove it, or make the library 'lifecycle: isolated'.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function union(a: readonly string[], b: readonly string[]): string[] {
|
|
144
|
+
return [...new Set([...a, ...b])];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Structural equality over the JSON-shaped values a config input carries.
|
|
149
|
+
* `undefined` and an absent key are the same absence.
|
|
150
|
+
*
|
|
151
|
+
* Key ORDER is not part of a value: two imports writing the same object variable
|
|
152
|
+
* with its keys in a different YAML order are supplying the same thing, and a
|
|
153
|
+
* conflict here is a hard boot failure telling the author the two imports
|
|
154
|
+
* disagree — a false positive is both expensive and unexplainable.
|
|
155
|
+
*/
|
|
156
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
157
|
+
if (a === b) return true;
|
|
158
|
+
if (a === undefined || b === undefined) return false;
|
|
159
|
+
if (a === null || b === null) return false;
|
|
160
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
161
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
162
|
+
return a.every((item, i) => sameValue(item, b[i]));
|
|
163
|
+
}
|
|
164
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
165
|
+
const left = a as Record<string, unknown>;
|
|
166
|
+
const right = b as Record<string, unknown>;
|
|
167
|
+
const keys = union(Object.keys(left), Object.keys(right));
|
|
168
|
+
return keys.every((key) => sameValue(left[key], right[key]));
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function render(value: unknown): string {
|
|
174
|
+
if (typeof value === "string") return value;
|
|
175
|
+
try {
|
|
176
|
+
return JSON.stringify(value) ?? String(value);
|
|
177
|
+
} catch {
|
|
178
|
+
return String(value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -9,6 +9,7 @@ import { RuntimeError } from "@telorun/sdk";
|
|
|
9
9
|
import {
|
|
10
10
|
controllerBearingAncestor,
|
|
11
11
|
effectiveAuthorSchema,
|
|
12
|
+
publishedOwnFields,
|
|
12
13
|
effectiveStatusSchema,
|
|
13
14
|
hasOwnControllerOrTemplate,
|
|
14
15
|
inheritedCapability,
|
|
@@ -30,6 +31,9 @@ type ResourceDefinitionResource = RuntimeResource & {
|
|
|
30
31
|
};
|
|
31
32
|
schema: Record<string, any>;
|
|
32
33
|
status?: Record<string, any>;
|
|
34
|
+
/** Derived stamp: the fields a merge-form inheriting child publishes over the
|
|
35
|
+
* parent instance's reading (see `publishedOwnFields`). */
|
|
36
|
+
publishedOwnFields?: string[];
|
|
33
37
|
capability?: string;
|
|
34
38
|
extends?: string;
|
|
35
39
|
base?: Record<string, any>;
|
|
@@ -77,6 +81,16 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
77
81
|
`Telo.Definition '${this.resource.metadata.name}': 'extends' target '${this.resource.extends}' is not loaded yet.`,
|
|
78
82
|
);
|
|
79
83
|
}
|
|
84
|
+
// Which of the child's fields publish over the parent's reading. Stamped
|
|
85
|
+
// here for the reason `status:` is: an `extends` alias belongs to the file
|
|
86
|
+
// that declared it, so the set must be derived in the DEFINING scope — a
|
|
87
|
+
// consumer importing only the backend has no alias for the parent's
|
|
88
|
+
// library. Derived before the schema stamp only because it reads the same
|
|
89
|
+
// resolver, not because it reads the pre-stamp value.
|
|
90
|
+
this.resource.publishedOwnFields = publishedOwnFields(
|
|
91
|
+
this.resource as ResourceDefinitionManifest,
|
|
92
|
+
resolveDef,
|
|
93
|
+
);
|
|
80
94
|
this.resource.schema = effectiveAuthorSchema(
|
|
81
95
|
this.resource as ResourceDefinitionManifest,
|
|
82
96
|
resolveDef,
|
|
@@ -5,28 +5,65 @@ import type {
|
|
|
5
5
|
ResourceContext,
|
|
6
6
|
ResourceInstance,
|
|
7
7
|
} from "@telorun/sdk";
|
|
8
|
-
import {
|
|
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
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 = (
|
|
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;
|
|
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 (
|
|
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
|
|
167
|
-
|
|
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
|
|
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))
|
|
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))
|
|
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(
|
|
314
|
+
childContext.registerManifest(
|
|
315
|
+
expandSelf(template, "", deferredPathsFor(template?.kind)),
|
|
316
|
+
);
|
|
217
317
|
}
|
|
218
318
|
registered = true;
|
|
219
319
|
}
|