@enricai/barnacle 1.12.19 → 1.12.21
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/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/merge-folded-primary-bodies.d.ts +10 -0
- package/dist/lib/merge-folded-primary-bodies.d.ts.map +1 -0
- package/dist/lib/merge-folded-primary-bodies.js +29 -0
- package/dist/lib/merge-folded-primary-bodies.js.map +1 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +4 -1
- package/dist/plugins/loader.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +11 -13
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +7 -0
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +33 -0
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.d.ts +634 -0
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +1793 -3
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +195 -9
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +1820 -61
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/site-plugin.d.ts +4 -3
- package/dist/site-plugin.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -29,6 +29,7 @@ exports.harvestPersonaBindings = harvestPersonaBindings;
|
|
|
29
29
|
exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
|
|
30
30
|
exports.collectConditionalGraphQLFieldNames = collectConditionalGraphQLFieldNames;
|
|
31
31
|
exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
|
|
32
|
+
exports.detectAggregateUnitBasisFindings = detectAggregateUnitBasisFindings;
|
|
32
33
|
exports.selectPayloadAction = selectPayloadAction;
|
|
33
34
|
exports.selectReturnAction = selectReturnAction;
|
|
34
35
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
@@ -51,6 +52,9 @@ exports.compileActionSteps = compileActionSteps;
|
|
|
51
52
|
exports.collectHeaderBindings = collectHeaderBindings;
|
|
52
53
|
exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
|
|
53
54
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
55
|
+
exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
|
|
56
|
+
exports.parseFoldReturnSpec = parseFoldReturnSpec;
|
|
57
|
+
exports.resolveFoldPlan = resolveFoldPlan;
|
|
54
58
|
exports.buildContractChecklist = buildContractChecklist;
|
|
55
59
|
exports.emitContractTs = emitContractTs;
|
|
56
60
|
exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
|
|
@@ -62,6 +66,7 @@ const node_path_1 = require("node:path");
|
|
|
62
66
|
const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
|
|
63
67
|
const errors_1 = require("../lib/errors");
|
|
64
68
|
const logging_1 = require("../lib/logging");
|
|
69
|
+
const merge_folded_primary_bodies_1 = require("../lib/merge-folded-primary-bodies");
|
|
65
70
|
const plugin_api_version_1 = require("../plugins/plugin-api-version");
|
|
66
71
|
const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
|
|
67
72
|
const capture_filters_1 = require("../recon/capture-filters");
|
|
@@ -509,7 +514,7 @@ function collectConditionalGraphQLFieldNames(query) {
|
|
|
509
514
|
* what the endpoint actually returns rather than what one capture happened to
|
|
510
515
|
* show.
|
|
511
516
|
*/
|
|
512
|
-
function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
|
|
517
|
+
function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}, path = []) {
|
|
513
518
|
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_INFER_DEPTH;
|
|
514
519
|
if (depth > maxDepth)
|
|
515
520
|
return "z.unknown()";
|
|
@@ -547,7 +552,7 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
|
|
|
547
552
|
const items = nonNull.flat();
|
|
548
553
|
if (items.length === 0)
|
|
549
554
|
return wrap("z.array(z.unknown())");
|
|
550
|
-
return wrap(`z.array(${inferZodSchemaFromSamples(items, depth + 1, indent, opts)})`);
|
|
555
|
+
return wrap(`z.array(${inferZodSchemaFromSamples(items, depth + 1, indent, opts, path)})`);
|
|
551
556
|
}
|
|
552
557
|
if (kind === "object") {
|
|
553
558
|
const objects = nonNull;
|
|
@@ -559,15 +564,22 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
|
|
|
559
564
|
// the generated file on first lint:fix.
|
|
560
565
|
const fields = keys
|
|
561
566
|
.map((k) => {
|
|
567
|
+
const fieldPath = [...path, k];
|
|
562
568
|
const valuesForKey = objects.filter((o) => k in o).map((o) => o[k]);
|
|
563
|
-
const expr = inferZodSchemaFromSamples(valuesForKey, depth + 1, inner, opts);
|
|
569
|
+
const expr = inferZodSchemaFromSamples(valuesForKey, depth + 1, inner, opts, fieldPath);
|
|
564
570
|
// Seen on some samples but not others (the endpoint omits it sometimes)
|
|
565
571
|
// OR the query marks it @include/@skip-conditional (the server can
|
|
566
572
|
// legally omit it regardless of what this sample happened to show):
|
|
567
573
|
// either signal alone is enough to require callers to guard the field.
|
|
568
574
|
const isOptional = valuesForKey.length < objects.length || (opts.conditionalFieldNames?.has(k) ?? false);
|
|
569
575
|
const optional = isOptional ? `${expr}.optional()` : expr;
|
|
570
|
-
|
|
576
|
+
const findings = opts.aggregateUnitBasisFindingsByPath?.get(fieldPath.join("."));
|
|
577
|
+
const described = findings?.length
|
|
578
|
+
? `${optional}.describe(${JSON.stringify(`Derived: ${findings
|
|
579
|
+
.map((finding) => `equals the sum of "${finding.unitFieldName}" across every entry of "${finding.breakdownPath.join(".")}"`)
|
|
580
|
+
.join("; also ")}.`)})`
|
|
581
|
+
: optional;
|
|
582
|
+
return `${inner}${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${described}`;
|
|
571
583
|
})
|
|
572
584
|
.join(",\n");
|
|
573
585
|
const objectExpr = `z.object({\n${fields},\n${indent}})${opts.looseServerResponse ? ".loose()" : ""}`;
|
|
@@ -581,6 +593,198 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
|
|
|
581
593
|
function inferZodSchema(value, depth = 0, indent = "", opts = {}) {
|
|
582
594
|
return inferZodSchemaFromSamples([value], depth, indent, opts);
|
|
583
595
|
}
|
|
596
|
+
/** Bound on how far {@link detectAggregateUnitBasisFindings} descends into a
|
|
597
|
+
* response tree, matching {@link DEFAULT_MAX_INFER_DEPTH} so detection never
|
|
598
|
+
* outruns the schema inference it feeds. */
|
|
599
|
+
const MAX_AGGREGATE_UNIT_BASIS_DEPTH = DEFAULT_MAX_INFER_DEPTH;
|
|
600
|
+
/** Relative tolerance for aggregate-vs-sum comparison, wide enough to absorb
|
|
601
|
+
* float rounding in tax/currency math but tight enough that two merely
|
|
602
|
+
* similar-magnitude fields won't pass by coincidence. */
|
|
603
|
+
const AGGREGATE_UNIT_BASIS_RELATIVE_EPSILON = 1e-6;
|
|
604
|
+
const AGGREGATE_UNIT_BASIS_ABSOLUTE_EPSILON = 1e-6;
|
|
605
|
+
function isPlainObject(value) {
|
|
606
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
607
|
+
}
|
|
608
|
+
/** A "map of objects" is a dynamically-keyed collection (guest index, room id,
|
|
609
|
+
* line-item id, ...) where every value is itself a record — the per-unit
|
|
610
|
+
* breakdown shape, as opposed to a plain nested object or a scalar map. */
|
|
611
|
+
function isMapOfObjects(value) {
|
|
612
|
+
if (!isPlainObject(value))
|
|
613
|
+
return false;
|
|
614
|
+
const values = Object.values(value);
|
|
615
|
+
if (values.length === 0)
|
|
616
|
+
return false;
|
|
617
|
+
return values.every(isPlainObject);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Collects, for one merged object node, every numeric field that could be an
|
|
621
|
+
* aggregate: fields directly on the node, and fields one level deeper inside
|
|
622
|
+
* a plain (non-map) nested object — the shape the report evidences
|
|
623
|
+
* (`price.summary.total`, where `summary` is a single object, not a
|
|
624
|
+
* dynamically-keyed map).
|
|
625
|
+
*/
|
|
626
|
+
function collectAggregateCandidates(instances, path, keys) {
|
|
627
|
+
const candidates = [];
|
|
628
|
+
for (const key of keys) {
|
|
629
|
+
if (instances.some((instance) => typeof instance[key] === "number")) {
|
|
630
|
+
candidates.push({
|
|
631
|
+
fieldName: key,
|
|
632
|
+
path: [...path, key],
|
|
633
|
+
getValue: (instance) => typeof instance[key] === "number" ? instance[key] : undefined,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const nestedObjects = instances.map((instance) => instance[key]).filter(isPlainObject);
|
|
637
|
+
if (nestedObjects.length === 0 || nestedObjects.some(isMapOfObjects))
|
|
638
|
+
continue;
|
|
639
|
+
const innerKeys = new Set(nestedObjects.flatMap((o) => Object.keys(o)));
|
|
640
|
+
for (const innerKey of innerKeys) {
|
|
641
|
+
if (!nestedObjects.some((o) => typeof o[innerKey] === "number"))
|
|
642
|
+
continue;
|
|
643
|
+
candidates.push({
|
|
644
|
+
fieldName: innerKey,
|
|
645
|
+
path: [...path, key, innerKey],
|
|
646
|
+
getValue: (instance) => {
|
|
647
|
+
const nested = instance[key];
|
|
648
|
+
return isPlainObject(nested) && typeof nested[innerKey] === "number"
|
|
649
|
+
? nested[innerKey]
|
|
650
|
+
: undefined;
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return candidates;
|
|
656
|
+
}
|
|
657
|
+
function collectBreakdownCandidates(instances, keys) {
|
|
658
|
+
const candidates = [];
|
|
659
|
+
for (const key of keys) {
|
|
660
|
+
if (!instances.some((instance) => isMapOfObjects(instance[key])))
|
|
661
|
+
continue;
|
|
662
|
+
candidates.push({
|
|
663
|
+
key,
|
|
664
|
+
getEntries: (instance) => {
|
|
665
|
+
const value = instance[key];
|
|
666
|
+
return isMapOfObjects(value) ? Object.values(value) : undefined;
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
return candidates;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Checks one aggregate/breakdown candidate pair against every instance at
|
|
674
|
+
* this node and records a finding only when the sum relation holds in ALL
|
|
675
|
+
* evidence. A single sample that violates the relation, or a breakdown entry
|
|
676
|
+
* missing the shared field, disqualifies the pair entirely -- proving the
|
|
677
|
+
* relation from a subset of samples is exactly the false-positive pattern
|
|
678
|
+
* this detector exists to avoid (a field that merely happens to share
|
|
679
|
+
* magnitude with a sum on some responses).
|
|
680
|
+
*/
|
|
681
|
+
function evaluateAggregateUnitBasisPair(instances, breakdownPath, aggregate, breakdown, findings) {
|
|
682
|
+
let confirmedSamples = 0;
|
|
683
|
+
let maxEntries = 0;
|
|
684
|
+
let sawEvidence = false;
|
|
685
|
+
for (const instance of instances) {
|
|
686
|
+
const aggregateValue = aggregate.getValue(instance);
|
|
687
|
+
const entries = breakdown.getEntries(instance);
|
|
688
|
+
if (aggregateValue === undefined || entries === undefined || entries.length === 0)
|
|
689
|
+
continue;
|
|
690
|
+
sawEvidence = true;
|
|
691
|
+
maxEntries = Math.max(maxEntries, entries.length);
|
|
692
|
+
const unitValues = entries.map((entry) => entry[aggregate.fieldName]);
|
|
693
|
+
if (!unitValues.every((v) => typeof v === "number"))
|
|
694
|
+
return;
|
|
695
|
+
const sum = unitValues.reduce((total, v) => total + v, 0);
|
|
696
|
+
const tolerance = Math.max(Math.abs(aggregateValue) * AGGREGATE_UNIT_BASIS_RELATIVE_EPSILON, AGGREGATE_UNIT_BASIS_ABSOLUTE_EPSILON);
|
|
697
|
+
if (Math.abs(sum - aggregateValue) > tolerance)
|
|
698
|
+
return;
|
|
699
|
+
confirmedSamples++;
|
|
700
|
+
}
|
|
701
|
+
if (!sawEvidence || confirmedSamples === 0)
|
|
702
|
+
return;
|
|
703
|
+
// A single-entry breakdown makes aggregate == unit trivially true and
|
|
704
|
+
// proves nothing about summation -- at least one sample must carry a real
|
|
705
|
+
// multi-entry breakdown for the relation to be evidence of aggregation.
|
|
706
|
+
if (maxEntries < 2)
|
|
707
|
+
return;
|
|
708
|
+
findings.push({
|
|
709
|
+
aggregatePath: aggregate.path,
|
|
710
|
+
breakdownPath,
|
|
711
|
+
unitFieldName: aggregate.fieldName,
|
|
712
|
+
sampleCount: confirmedSamples,
|
|
713
|
+
maxBreakdownEntries: maxEntries,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function detectAggregateUnitBasisAtNode(instances, path, findings) {
|
|
717
|
+
const keys = new Set(instances.flatMap((instance) => Object.keys(instance)));
|
|
718
|
+
const aggregateCandidates = collectAggregateCandidates(instances, path, keys);
|
|
719
|
+
const breakdownCandidates = collectBreakdownCandidates(instances, keys);
|
|
720
|
+
for (const aggregate of aggregateCandidates) {
|
|
721
|
+
// The immediate child key the aggregate was found under -- either the
|
|
722
|
+
// aggregate's own key (direct case) or the plain-object container it was
|
|
723
|
+
// found nested inside (one-level-deeper case).
|
|
724
|
+
const aggregateContainerKey = aggregate.path[path.length];
|
|
725
|
+
for (const breakdown of breakdownCandidates) {
|
|
726
|
+
if (aggregateContainerKey === breakdown.key)
|
|
727
|
+
continue;
|
|
728
|
+
evaluateAggregateUnitBasisPair(instances, [...path, breakdown.key], aggregate, breakdown, findings);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function walkAggregateUnitBasis(values, path, depth, findings) {
|
|
733
|
+
if (depth > MAX_AGGREGATE_UNIT_BASIS_DEPTH)
|
|
734
|
+
return;
|
|
735
|
+
const instances = values.filter(isPlainObject);
|
|
736
|
+
if (instances.length > 0) {
|
|
737
|
+
detectAggregateUnitBasisAtNode(instances, path, findings);
|
|
738
|
+
const keys = new Set(instances.flatMap((instance) => Object.keys(instance)));
|
|
739
|
+
for (const key of keys) {
|
|
740
|
+
const childValues = instances
|
|
741
|
+
.map((instance) => instance[key])
|
|
742
|
+
.filter((v) => v !== undefined && v !== null);
|
|
743
|
+
if (childValues.length === 0)
|
|
744
|
+
continue;
|
|
745
|
+
walkAggregateUnitBasis(childValues, [...path, key], depth + 1, findings);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const arrayItems = values.filter((v) => Array.isArray(v)).flat();
|
|
749
|
+
if (arrayItems.length > 0) {
|
|
750
|
+
walkAggregateUnitBasis(arrayItems, path, depth + 1, findings);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Finds every object path where a numeric field's value observably equals
|
|
755
|
+
* the sum of a same-named numeric field carried by every entry of a sibling
|
|
756
|
+
* map-of-objects field -- the party-total/per-guest-breakdown shape
|
|
757
|
+
* generalized to any aggregate/per-unit pair, regardless of field or site
|
|
758
|
+
* naming, so recon-generate can annotate the price basis instead of leaving
|
|
759
|
+
* every plugin author and consumer to re-derive it independently and
|
|
760
|
+
* diverge. See docs/architecture.md, "Why the generator annotates
|
|
761
|
+
* aggregate/per-unit basis instead of deriving it".
|
|
762
|
+
*
|
|
763
|
+
* Pure and read-only: walks the merged sample set the same way
|
|
764
|
+
* {@link inferZodSchemaFromSamples} does, but only ever compares numbers
|
|
765
|
+
* already present in the samples -- it emits no schema and mutates nothing.
|
|
766
|
+
*/
|
|
767
|
+
function detectAggregateUnitBasisFindings(samples) {
|
|
768
|
+
const findings = [];
|
|
769
|
+
walkAggregateUnitBasis(samples.filter((s) => s !== undefined && s !== null), [], 0, findings);
|
|
770
|
+
return findings;
|
|
771
|
+
}
|
|
772
|
+
/** Groups {@link detectAggregateUnitBasisFindings} output by aggregate path so
|
|
773
|
+
* {@link inferZodSchemaFromSamples} can look findings up per-field in O(1). */
|
|
774
|
+
function groupAggregateUnitBasisFindingsByPath(samples) {
|
|
775
|
+
const byPath = new Map();
|
|
776
|
+
for (const finding of detectAggregateUnitBasisFindings(samples)) {
|
|
777
|
+
const key = finding.aggregatePath.join(".");
|
|
778
|
+
const existing = byPath.get(key);
|
|
779
|
+
if (existing) {
|
|
780
|
+
existing.push(finding);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
byPath.set(key, [finding]);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return byPath;
|
|
787
|
+
}
|
|
584
788
|
function deriveMinTime(rateLimits) {
|
|
585
789
|
const first = rateLimits.find((f) => f.safeRps !== null);
|
|
586
790
|
return first?.safeRps ? Math.floor(1000 / first.safeRps) : 200;
|
|
@@ -683,11 +887,50 @@ function selectReturnAction(steps) {
|
|
|
683
887
|
* submission flow's `executeHttp` and its inferred type/schema have to agree
|
|
684
888
|
* on which call they describe, or the emitted type disagrees with the value
|
|
685
889
|
* actually returned. Falls back to the replay body for single-endpoint sites.
|
|
890
|
+
*
|
|
891
|
+
* A resolved drill-down fold plan (see {@link resolveFoldPlan}) bypasses
|
|
892
|
+
* `selectReturnAction` entirely, exactly as `emitMultiStepExecuteHttp` does
|
|
893
|
+
* when it emits the per-item loop-and-merge — so the shape this infers has to
|
|
894
|
+
* be the FOLDED primary body, not the plain one, or the schema would omit
|
|
895
|
+
* every field the fold adds. Both resolve through `resolveFoldPlan` with the
|
|
896
|
+
* same `foldReturnSpec` so they can never disagree on whether a fold applies.
|
|
897
|
+
* When multiple plans resolve, each plan's own folded body is merged via
|
|
898
|
+
* `mergeFoldedPrimaryBodies` into one combined object — the same merge
|
|
899
|
+
* `emitMultiStepExecuteHttp` performs for `return { data }` — so the inferred
|
|
900
|
+
* shape and the runtime return value always describe the same call, and two
|
|
901
|
+
* plans that share a top-level array key (e.g. the same paginated primary
|
|
902
|
+
* endpoint drilled into twice) both contribute their items instead of one
|
|
903
|
+
* clobbering the other. A plan whose folded body isn't a plain object can't
|
|
904
|
+
* be merged meaningfully, so in that case this falls back to the LAST plan's
|
|
905
|
+
* folded body alone, exactly as before this merge was introduced.
|
|
686
906
|
*/
|
|
687
|
-
function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody) {
|
|
907
|
+
function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody, foldReturnSpec = null) {
|
|
688
908
|
if (!isSubmissionFlow)
|
|
689
909
|
return replayResponseBody;
|
|
690
|
-
|
|
910
|
+
const foldPlans = resolveFoldPlan(actionSteps, foldReturnSpec);
|
|
911
|
+
const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
|
|
912
|
+
if (foldPlans.length <= 1) {
|
|
913
|
+
if (lastFoldPlan)
|
|
914
|
+
return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
|
|
915
|
+
return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
|
|
916
|
+
}
|
|
917
|
+
// Plans sharing a primaryStepIndex (independent arrays on one primary
|
|
918
|
+
// response) MUST fold onto the SAME accumulating body, not independent
|
|
919
|
+
// copies of the original — folding each in isolation and then merging via
|
|
920
|
+
// mergeFoldedPrimaryBodies would array-concat one plan's folded items with
|
|
921
|
+
// the other's still-unfolded ones, duplicating every item.
|
|
922
|
+
const plansByPrimaryStep = new Map();
|
|
923
|
+
for (const plan of foldPlans) {
|
|
924
|
+
plansByPrimaryStep.set(plan.primaryStepIndex, [
|
|
925
|
+
...(plansByPrimaryStep.get(plan.primaryStepIndex) ?? []),
|
|
926
|
+
plan,
|
|
927
|
+
]);
|
|
928
|
+
}
|
|
929
|
+
const foldedBodies = [...plansByPrimaryStep.values()].map((plans) => plans.reduce((body, plan) => foldResponseBodyForShapeInference(actionSteps, plan, body), actionSteps[plans[0].primaryStepIndex].capture.responseBody));
|
|
930
|
+
if (foldedBodies.every(isPlainObject)) {
|
|
931
|
+
return (0, merge_folded_primary_bodies_1.mergeFoldedPrimaryBodies)(...foldedBodies);
|
|
932
|
+
}
|
|
933
|
+
return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
|
|
691
934
|
}
|
|
692
935
|
/**
|
|
693
936
|
* A capture's own URL is not guaranteed parseable (see the try/catch in
|
|
@@ -1171,16 +1414,24 @@ function resolveManifestActionSequence(runRoot, captures) {
|
|
|
1171
1414
|
* JWT refresh, reference-lookup) that a browser fires incidentally. Absent
|
|
1172
1415
|
* patterns preserve the noise heuristic exactly.
|
|
1173
1416
|
*
|
|
1417
|
+
* When the flow declares a `foldReturnSpec`, a GET whose URL matches its
|
|
1418
|
+
* `endpointPattern` is admitted despite the GET drop above — the same scoped
|
|
1419
|
+
* rule `buildFoldPlanFromSpec` later uses to resolve the fold plan, so a
|
|
1420
|
+
* spec-declared GET drill-down survives to reach it instead of being
|
|
1421
|
+
* dropped before the fold pipeline ever sees it. Every other GET is still
|
|
1422
|
+
* dropped.
|
|
1423
|
+
*
|
|
1174
1424
|
* Exported for tests: this predicate decides what a generated plugin will POST
|
|
1175
1425
|
* at a live site, and it is the only gate between a browser's incidental
|
|
1176
1426
|
* chatter and the emitted hot path.
|
|
1177
1427
|
*/
|
|
1178
|
-
function extractActionSequence(captures, submitPatterns = null) {
|
|
1428
|
+
function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
|
|
1179
1429
|
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1430
|
+
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
1180
1431
|
return captures
|
|
1181
1432
|
.map((capture, index) => ({ capture, index }))
|
|
1182
1433
|
.filter(({ capture }) => {
|
|
1183
|
-
if (capture.method === "GET")
|
|
1434
|
+
if (capture.method === "GET" && !matchesFoldReturn(capture))
|
|
1184
1435
|
return false;
|
|
1185
1436
|
if (capture.status < 200 || capture.status >= 300)
|
|
1186
1437
|
return false;
|
|
@@ -1202,11 +1453,23 @@ function extractActionSequence(captures, submitPatterns = null) {
|
|
|
1202
1453
|
* what let a chronologically-first fallback pick an unrelated query. Host is
|
|
1203
1454
|
* NOT a filter criterion, matching {@link extractActionSequence}.
|
|
1204
1455
|
*
|
|
1456
|
+
* When the flow declares a `foldReturnSpec`, a non-mutation capture whose
|
|
1457
|
+
* URL matches its `endpointPattern` is admitted despite the query drop
|
|
1458
|
+
* above, mirroring {@link extractActionSequence}'s GET admission. So is a
|
|
1459
|
+
* non-mutation capture whose response resolves the spec's own `resultsPath`
|
|
1460
|
+
* — the GraphQL-primary read op the drill-down folds onto, which
|
|
1461
|
+
* `endpointPattern` (naming the drill, not the primary) never matches on its
|
|
1462
|
+
* own; without this a declared spec would admit the drill-down but leave
|
|
1463
|
+
* `resolveFoldPlan` with no primary capture to resolve `resultsPath`
|
|
1464
|
+
* against. Every other non-mutation capture is still dropped.
|
|
1465
|
+
*
|
|
1205
1466
|
* Exported for tests: this predicate decides what a generated GraphQL plugin
|
|
1206
1467
|
* will send at a live site.
|
|
1207
1468
|
*/
|
|
1208
|
-
function extractGraphQLActionSequence(captures, submitPatterns = null) {
|
|
1469
|
+
function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
|
|
1209
1470
|
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1471
|
+
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
1472
|
+
const matchesFoldReturnResults = compileFoldReturnResultsMatcher(foldReturnSpec);
|
|
1210
1473
|
return captures
|
|
1211
1474
|
.map((capture, index) => ({ capture, index }))
|
|
1212
1475
|
.filter(({ capture }) => {
|
|
@@ -1216,7 +1479,9 @@ function extractGraphQLActionSequence(captures, submitPatterns = null) {
|
|
|
1216
1479
|
return false;
|
|
1217
1480
|
if (!matchesSubmit(capture))
|
|
1218
1481
|
return false;
|
|
1219
|
-
|
|
1482
|
+
if (capture.query !== null && /^\s*mutation\b/.test(capture.query))
|
|
1483
|
+
return true;
|
|
1484
|
+
return matchesFoldReturn(capture) || matchesFoldReturnResults(capture);
|
|
1220
1485
|
});
|
|
1221
1486
|
}
|
|
1222
1487
|
/**
|
|
@@ -1287,8 +1552,10 @@ function jsonBodyLeafValues(requestPostData) {
|
|
|
1287
1552
|
if (parsed === undefined)
|
|
1288
1553
|
return null;
|
|
1289
1554
|
const values = [];
|
|
1290
|
-
for (const { value } of
|
|
1291
|
-
|
|
1555
|
+
for (const { value } of walkAllPrimitiveLeaves(parsed)) {
|
|
1556
|
+
if (value !== null)
|
|
1557
|
+
values.push(String(value));
|
|
1558
|
+
}
|
|
1292
1559
|
return values;
|
|
1293
1560
|
}
|
|
1294
1561
|
/**
|
|
@@ -2222,9 +2489,25 @@ function locateFormEnvelopePath(parsedBody) {
|
|
|
2222
2489
|
* whole blob is caller-supplied; the generator can't reach inside a value it
|
|
2223
2490
|
* has delegated wholesale.
|
|
2224
2491
|
*
|
|
2492
|
+
* A candidate whose leaves include a value already threaded from elsewhere in
|
|
2493
|
+
* the fold — either a PRIOR step's response (e.g. a per-item join token
|
|
2494
|
+
* wrapped in a bulk-lookup array like `{"tokens":["<prior-response-value>"]}`)
|
|
2495
|
+
* OR the fold's own PRIMARY ITEM field feeding the immediate drill step's
|
|
2496
|
+
* request (e.g. `{"orderIds":["<primary-item-value>"]}`) — is never swallowed
|
|
2497
|
+
* here, no matter how array/object-shaped it looks: both are threaded
|
|
2498
|
+
* dependent-drill-down coordinates, not caller-supplied history data. The
|
|
2499
|
+
* prior-step-response case must stay reachable for `interpolateStateValues`
|
|
2500
|
+
* (Pass 1, which runs AFTER this pass); the primary-item case must stay
|
|
2501
|
+
* reachable as literal text for the fold-loop's own `parameterize` pass
|
|
2502
|
+
* (which runs even later, once rendering enters the per-item loop) to find
|
|
2503
|
+
* and swap for `${item.<field>}`. Freezing either into an opaque
|
|
2504
|
+
* `${JSON.stringify(payload.tokens)}` blob here would silently drop the
|
|
2505
|
+
* value the request depends on, breaking the fold at exactly the case an
|
|
2506
|
+
* ARRAY/OBJECT-wrapped join field represents.
|
|
2507
|
+
*
|
|
2225
2508
|
* Site-agnostic: operates only on the recon body's own shape.
|
|
2226
2509
|
*/
|
|
2227
|
-
function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
|
|
2510
|
+
function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateValues = new Set()) {
|
|
2228
2511
|
if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
|
|
2229
2512
|
return template;
|
|
2230
2513
|
}
|
|
@@ -2248,6 +2531,11 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
|
|
|
2248
2531
|
Object.keys(value).length > 0;
|
|
2249
2532
|
if (!isNonEmptyArray && !isNestedObject)
|
|
2250
2533
|
continue;
|
|
2534
|
+
if (priorStepStateValues.size > 0) {
|
|
2535
|
+
const carriesThreadedValue = [...walkAllPrimitiveLeaves(value)].some(({ value: leaf }) => leaf !== null && priorStepStateValues.has(String(leaf)));
|
|
2536
|
+
if (carriesThreadedValue)
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2251
2539
|
const keyMarker = `"${key}":`;
|
|
2252
2540
|
const markerIdx = result.indexOf(keyMarker);
|
|
2253
2541
|
if (markerIdx === -1)
|
|
@@ -2358,11 +2646,29 @@ function* walkSetCookiePairs(rawSetCookie) {
|
|
|
2358
2646
|
* Exception: values in `PLACEHOLDER_STATE_VALUES` are skipped entirely so
|
|
2359
2647
|
* the LATER non-placeholder occurrence at the same JSON path becomes the
|
|
2360
2648
|
* canonical binding instead.
|
|
2649
|
+
*
|
|
2650
|
+
* `forceIncludeValues` (see {@link collectDependentDrillDownChainValues})
|
|
2651
|
+
* bypasses `MIN_STATE_VALUE_LENGTH` for the specific values it names — a
|
|
2652
|
+
* value already confirmed, by the fold-chain detector itself, to be threaded
|
|
2653
|
+
* from one dependent-drill-down chain hop's response into the next hop's
|
|
2654
|
+
* request is exactly as legitimate a produced state value as a long one; a
|
|
2655
|
+
* length floor exists to keep an UNRELATED short value (an enum code, a page
|
|
2656
|
+
* number) from being mistaken for reused state by blind substring/value
|
|
2657
|
+
* matching, and a value the chain detector already confirmed is threaded
|
|
2658
|
+
* carries no such ambiguity. Every other filter (MAX length, placeholder,
|
|
2659
|
+
* shielded UUID, GET-non-UUID) still applies.
|
|
2361
2660
|
*/
|
|
2362
2661
|
/** Exported for unit testing — lets tests exercise the produces[] walk (body
|
|
2363
2662
|
* AND header/cookie origins) directly against synthetic Capture sequences. */
|
|
2364
|
-
function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
|
|
2663
|
+
function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Set()) {
|
|
2365
2664
|
const index = new Map();
|
|
2665
|
+
// Computed structurally off the SAME captures being indexed (no
|
|
2666
|
+
// foldReturnSpec available at this layer) — a spec-declared fold's own
|
|
2667
|
+
// chain values reach here via the caller-supplied `forceIncludeValues`
|
|
2668
|
+
// (see recon-generate's top-level `collectDependentDrillDownChainValues`
|
|
2669
|
+
// call), so this indexes a chain-produced value regardless of whether the
|
|
2670
|
+
// fold plan that confirmed it is structural or spec-declared.
|
|
2671
|
+
const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
|
|
2366
2672
|
// First pass: identify the earliest origin among ACTION captures for each
|
|
2367
2673
|
// value. Action-only earliest-origin tracking is what compileActionSteps'
|
|
2368
2674
|
// produces[] check needs — it ignores non-action captures (telemetry GETs,
|
|
@@ -2381,7 +2687,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2381
2687
|
const rawSetCookie = Object.entries(c.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
|
|
2382
2688
|
if (rawSetCookie !== undefined) {
|
|
2383
2689
|
for (const { name, value } of walkSetCookiePairs(rawSetCookie)) {
|
|
2384
|
-
|
|
2690
|
+
// Same chain/force exemption as the body-value MIN_STATE_VALUE_LENGTH
|
|
2691
|
+
// floor below: a cookie-sourced value the fold-chain detector already
|
|
2692
|
+
// confirmed is threaded into a later hop's request is exactly as
|
|
2693
|
+
// legitimate as a long one, so it must not be dropped for being short.
|
|
2694
|
+
if (value.length < MIN_STATE_VALUE_LENGTH &&
|
|
2695
|
+
!chainForceIncludeValues.has(value) &&
|
|
2696
|
+
!forceIncludeValues.has(value))
|
|
2385
2697
|
continue;
|
|
2386
2698
|
if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
|
|
2387
2699
|
continue;
|
|
@@ -2397,6 +2709,31 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2397
2709
|
}
|
|
2398
2710
|
}
|
|
2399
2711
|
}
|
|
2712
|
+
// Non-cookie response headers are only indexed for values the fold-chain
|
|
2713
|
+
// detector already confirmed are threaded from this hop's response into a
|
|
2714
|
+
// later hop's request (`chainForceIncludeValues`) — unlike Set-Cookie,
|
|
2715
|
+
// which is always a plausible token mint, an arbitrary header (e.g.
|
|
2716
|
+
// `X-Conversation-Id`) is indexed as producible state only when chain
|
|
2717
|
+
// detection itself has already established that reuse, so this never
|
|
2718
|
+
// sweeps every header value as noise.
|
|
2719
|
+
for (const [headerName, headerValue] of Object.entries(c.responseHeaders)) {
|
|
2720
|
+
if (headerName.toLowerCase() === "set-cookie")
|
|
2721
|
+
continue;
|
|
2722
|
+
if (!chainForceIncludeValues.has(headerValue))
|
|
2723
|
+
continue;
|
|
2724
|
+
if (headerValue.length > MAX_COOKIE_STATE_VALUE_LENGTH)
|
|
2725
|
+
continue;
|
|
2726
|
+
if (PLACEHOLDER_STATE_VALUES.has(headerValue))
|
|
2727
|
+
continue;
|
|
2728
|
+
if (!index.has(headerValue)) {
|
|
2729
|
+
index.set(headerValue, {
|
|
2730
|
+
value: headerValue,
|
|
2731
|
+
originIndex: i,
|
|
2732
|
+
path: [],
|
|
2733
|
+
headerOrigin: { sourceHeader: headerName },
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2400
2737
|
if (c.responseBody === undefined || c.responseBody === null)
|
|
2401
2738
|
continue;
|
|
2402
2739
|
// For GET captures, only index UUID-shaped strings. GET captures (today,
|
|
@@ -2407,8 +2744,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2407
2744
|
// e.g. "candidate" as a state value gets substituted INSIDE an already-
|
|
2408
2745
|
// emitted ${candidateId} interpolation, producing ${${entityTypeCode}Id}.
|
|
2409
2746
|
const isGet = c.method === "GET";
|
|
2410
|
-
for (const { value, path } of
|
|
2411
|
-
if (
|
|
2747
|
+
for (const { value: rawValue, path } of walkAllPrimitiveLeaves(c.responseBody)) {
|
|
2748
|
+
if (rawValue === null)
|
|
2749
|
+
continue;
|
|
2750
|
+
const value = String(rawValue);
|
|
2751
|
+
if (value.length < MIN_STATE_VALUE_LENGTH &&
|
|
2752
|
+
!chainForceIncludeValues.has(value) &&
|
|
2753
|
+
!forceIncludeValues.has(value))
|
|
2412
2754
|
continue;
|
|
2413
2755
|
if (value.length > MAX_STATE_VALUE_LENGTH)
|
|
2414
2756
|
continue;
|
|
@@ -2421,7 +2763,16 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2421
2763
|
// corrupt T2/T3's already-substituted Values.
|
|
2422
2764
|
if (shieldedUuids.has(value))
|
|
2423
2765
|
continue;
|
|
2424
|
-
|
|
2766
|
+
// Same chain/force exemption as the MIN_STATE_VALUE_LENGTH floor above:
|
|
2767
|
+
// a value the fold-chain detector already confirmed is threaded from
|
|
2768
|
+
// this GET hop's response into a later hop's request is exactly as
|
|
2769
|
+
// legitimate as a UUID anchor, so it must not be dropped just because
|
|
2770
|
+
// this hop happens to be a GET rather than every existing fixture's
|
|
2771
|
+
// POST.
|
|
2772
|
+
if (isGet &&
|
|
2773
|
+
!UUID_REGEX.test(value) &&
|
|
2774
|
+
!chainForceIncludeValues.has(value) &&
|
|
2775
|
+
!forceIncludeValues.has(value))
|
|
2425
2776
|
continue;
|
|
2426
2777
|
if (!index.has(value)) {
|
|
2427
2778
|
index.set(value, { value, originIndex: i, path });
|
|
@@ -2485,6 +2836,44 @@ function pathToAssertionType(path) {
|
|
|
2485
2836
|
const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
|
|
2486
2837
|
return `{ ${key}: ${pathToAssertionType(path.slice(1))} }`;
|
|
2487
2838
|
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Same nesting as {@link pathToAssertionType} but the leaf types as
|
|
2841
|
+
* `Record<string, unknown>[]` instead of `string` — used to cast a step's
|
|
2842
|
+
* response down to the object-array field a {@link FoldPlan} located
|
|
2843
|
+
* (the primary results array, or the drill-down's per-item match array). An
|
|
2844
|
+
* {@link ARRAY_WILDCARD_SEGMENT} segment types as an array of whatever the
|
|
2845
|
+
* rest of the path resolves to, matching the `.flatMap` accessor
|
|
2846
|
+
* {@link pathToFoldAccessorExpr} emits for the same segment.
|
|
2847
|
+
*/
|
|
2848
|
+
function foldArrayAssertionType(path) {
|
|
2849
|
+
if (path.length === 0)
|
|
2850
|
+
return "Record<string, unknown>[]";
|
|
2851
|
+
const segment = path[0];
|
|
2852
|
+
if (segment === ARRAY_WILDCARD_SEGMENT) {
|
|
2853
|
+
return `(${foldArrayAssertionType(path.slice(1))})[]`;
|
|
2854
|
+
}
|
|
2855
|
+
const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
|
|
2856
|
+
return `{ ${key}: ${foldArrayAssertionType(path.slice(1))} }`;
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Builds a JS access expression reading `path` off of `expr`, generalizing
|
|
2860
|
+
* across every {@link ARRAY_WILDCARD_SEGMENT} in `path` via `.flatMap` so the
|
|
2861
|
+
* emitted accessor visits every element of that outer array instead of
|
|
2862
|
+
* freezing the single index that happened to contain the matched item during
|
|
2863
|
+
* detection (see {@link ARRAY_WILDCARD_SEGMENT}'s docstring). A path with no
|
|
2864
|
+
* wildcard segment degrades to the plain {@link pathToAccessor} chain.
|
|
2865
|
+
*/
|
|
2866
|
+
function pathToFoldAccessorExpr(expr, path, depth = 0) {
|
|
2867
|
+
const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
|
|
2868
|
+
if (wildcardIndex === -1) {
|
|
2869
|
+
return `${expr}${pathToAccessor(path, { assertNonNull: false })}`;
|
|
2870
|
+
}
|
|
2871
|
+
const before = path.slice(0, wildcardIndex);
|
|
2872
|
+
const after = path.slice(wildcardIndex + 1);
|
|
2873
|
+
const groupVar = `g${depth}`;
|
|
2874
|
+
const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
|
|
2875
|
+
return `${outerExpr}.flatMap((${groupVar}) => ${pathToFoldAccessorExpr(groupVar, after, depth + 1)})`;
|
|
2876
|
+
}
|
|
2488
2877
|
/** Suggests a JS-camelCase variable name for a state value path. Falls back
|
|
2489
2878
|
* up the path if the tail is numeric or not a valid JS identifier. */
|
|
2490
2879
|
function pathToVarName(path) {
|
|
@@ -2589,8 +2978,48 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
2589
2978
|
});
|
|
2590
2979
|
}
|
|
2591
2980
|
}
|
|
2981
|
+
// Non-cookie response-header-origin produces — mirrors the Set-Cookie
|
|
2982
|
+
// block above but for a plain header (e.g. `X-Price-Token`) whose value
|
|
2983
|
+
// `indexStateValues` indexed with `headerOrigin.sourceHeader` set to the
|
|
2984
|
+
// real header name. Only emitted when the value is actually consumed as
|
|
2985
|
+
// a REQUEST HEADER downstream (`usedValueTargetHeader`) — `createHttpClient`'s
|
|
2986
|
+
// `bind` option (see http-client.ts) is the only mechanism that can
|
|
2987
|
+
// thread a header-origin value forward, since the emitted response
|
|
2988
|
+
// variable never exposes response headers to the rest of the generated
|
|
2989
|
+
// code the way it exposes the parsed body.
|
|
2990
|
+
for (const [headerName, headerValue] of Object.entries(capture.responseHeaders)) {
|
|
2991
|
+
if (headerName.toLowerCase() === "set-cookie")
|
|
2992
|
+
continue;
|
|
2993
|
+
if (!usedValues.has(headerValue))
|
|
2994
|
+
continue;
|
|
2995
|
+
const sv = stateIndex.get(headerValue);
|
|
2996
|
+
if (!sv || sv.originIndex !== index || !sv.headerOrigin)
|
|
2997
|
+
continue;
|
|
2998
|
+
const targetHeader = usedValueTargetHeader.get(headerValue);
|
|
2999
|
+
if (!targetHeader)
|
|
3000
|
+
continue;
|
|
3001
|
+
let name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header`;
|
|
3002
|
+
if (!/^[A-Za-z_$]/.test(name))
|
|
3003
|
+
name = `_${name}`;
|
|
3004
|
+
let suffix = 1;
|
|
3005
|
+
while (seenNames.has(name)) {
|
|
3006
|
+
suffix++;
|
|
3007
|
+
name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header${suffix}`;
|
|
3008
|
+
}
|
|
3009
|
+
seenNames.add(name);
|
|
3010
|
+
produces.push({
|
|
3011
|
+
kind: "header",
|
|
3012
|
+
name,
|
|
3013
|
+
sourceHeader: sv.headerOrigin.sourceHeader,
|
|
3014
|
+
cookieName: sv.headerOrigin.cookieName,
|
|
3015
|
+
targetHeader,
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
2592
3018
|
if (capture.responseBody !== undefined && capture.responseBody !== null) {
|
|
2593
|
-
for (const { value, path } of
|
|
3019
|
+
for (const { value: rawValue, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
|
|
3020
|
+
if (rawValue === null)
|
|
3021
|
+
continue;
|
|
3022
|
+
const value = String(rawValue);
|
|
2594
3023
|
if (!usedValues.has(value))
|
|
2595
3024
|
continue;
|
|
2596
3025
|
const sv = stateIndex.get(value);
|
|
@@ -2675,7 +3104,9 @@ function resolveResponsePathValue(responseBody, path) {
|
|
|
2675
3104
|
return null;
|
|
2676
3105
|
}
|
|
2677
3106
|
}
|
|
2678
|
-
return typeof cursor === "string"
|
|
3107
|
+
return typeof cursor === "string" || typeof cursor === "number" || typeof cursor === "boolean"
|
|
3108
|
+
? String(cursor)
|
|
3109
|
+
: null;
|
|
2679
3110
|
}
|
|
2680
3111
|
/**
|
|
2681
3112
|
* Replaces occurrences of state values in `template` with `${varName}`
|
|
@@ -3224,7 +3655,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
|
|
|
3224
3655
|
}
|
|
3225
3656
|
/** Exported for unit testing — lets tests drive the multipart-upload code path directly
|
|
3226
3657
|
* without going through the full emitContractTs pipeline. */
|
|
3227
|
-
function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map()) {
|
|
3658
|
+
function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null) {
|
|
3228
3659
|
// Walk the first action's request body to map each leaf string value to its
|
|
3229
3660
|
// `payload.<accessor>` expression. The emit's second interpolation pass uses
|
|
3230
3661
|
// this to substitute literal occurrences (e.g. "Reginald") with their
|
|
@@ -3397,6 +3828,66 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3397
3828
|
const boundValues = new Set(payloadAccessorByValue.keys());
|
|
3398
3829
|
// Captured literals that survived every pass — surfaced as a review TODO.
|
|
3399
3830
|
const unboundLiteralKeys = new Set();
|
|
3831
|
+
// A fold target's join value can be threaded through a request HEADER
|
|
3832
|
+
// rather than the URL/body (see collectRequestValuesIncludingHeaders) —
|
|
3833
|
+
// the structural heuristic can't see those, but a flow-declared foldReturn
|
|
3834
|
+
// spec resolves the target anyway, and the drill step's per-item re-issue
|
|
3835
|
+
// below must still carry that header or every iteration replays the SAME
|
|
3836
|
+
// captured header value instead of re-keying it. Resolved once, up front
|
|
3837
|
+
// (fold plans depend only on `actions`/`foldReturnSpec`, not on Pass 1's
|
|
3838
|
+
// render), so Pass 1's header collection below knows which non-auth header
|
|
3839
|
+
// names are actually load-bearing for a resolved fold.
|
|
3840
|
+
const earlyFoldPlans = resolveFoldPlan(actions, foldReturnSpec);
|
|
3841
|
+
const joinCarryingHeaderNamesByStep = new Map();
|
|
3842
|
+
// A fold target's join value is the PRIMARY ITEM's own field, threaded into
|
|
3843
|
+
// the drill step's request text-literally by the fold-loop's own
|
|
3844
|
+
// `parameterize` pass (later, once rendering enters the per-item loop) — it
|
|
3845
|
+
// is never a prior STEP's produced state var, so `deriveStateVarByValue`
|
|
3846
|
+
// (built from `produces[]`) never sees it. Mechanism B
|
|
3847
|
+
// (`applyStructuredValuePayloadSubstitutions`) runs BEFORE that loop-aware
|
|
3848
|
+
// pass and only knows to spare a candidate carrying a prior step's state
|
|
3849
|
+
// value; without this, it freezes an array/object-wrapped join field (e.g.
|
|
3850
|
+
// `{"orderIds":["<item.orderId>"]}`) into an opaque
|
|
3851
|
+
// `${JSON.stringify(payload.orderIds)}` blob, destroying the literal text
|
|
3852
|
+
// `parameterize` needs to find and swap for `${item.orderId}` — every
|
|
3853
|
+
// iteration then replays one caller-supplied value instead of the item's
|
|
3854
|
+
// own. Collected once here (fold plans depend only on `actions`/
|
|
3855
|
+
// `foldReturnSpec`, not on Pass 1's render) so Pass 1 knows which values to
|
|
3856
|
+
// spare per step, mirroring the header-name collection above.
|
|
3857
|
+
const joinFieldValuesByStep = new Map();
|
|
3858
|
+
for (const plan of earlyFoldPlans) {
|
|
3859
|
+
const primaryItems = objectItemsAtPath(actions[plan.primaryStepIndex].capture.responseBody, plan.primaryArrayPath);
|
|
3860
|
+
for (const target of plan.targets) {
|
|
3861
|
+
const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
|
|
3862
|
+
if (!firstItem)
|
|
3863
|
+
continue;
|
|
3864
|
+
for (const stepIndex of [target.drillStepIndex, ...target.chain]) {
|
|
3865
|
+
const headerNames = joinCarryingHeaderNamesByStep.get(stepIndex) ?? new Set();
|
|
3866
|
+
for (const [headerName, headerValue] of Object.entries(actions[stepIndex].capture.requestHeaders)) {
|
|
3867
|
+
const matchesJoinField = target.joinFields.some((field) => {
|
|
3868
|
+
const value = readValueAtPath(firstItem, field.split("."));
|
|
3869
|
+
return ((typeof value === "string" && value.length > 0 && value === headerValue) ||
|
|
3870
|
+
((typeof value === "number" || typeof value === "boolean") &&
|
|
3871
|
+
String(value) === headerValue));
|
|
3872
|
+
});
|
|
3873
|
+
if (matchesJoinField)
|
|
3874
|
+
headerNames.add(headerName);
|
|
3875
|
+
}
|
|
3876
|
+
if (headerNames.size > 0)
|
|
3877
|
+
joinCarryingHeaderNamesByStep.set(stepIndex, headerNames);
|
|
3878
|
+
const joinValues = joinFieldValuesByStep.get(stepIndex) ?? new Set();
|
|
3879
|
+
for (const field of target.joinFields) {
|
|
3880
|
+
const value = readValueAtPath(firstItem, field.split("."));
|
|
3881
|
+
if (typeof value === "string" && value.length > 0)
|
|
3882
|
+
joinValues.add(value);
|
|
3883
|
+
else if (typeof value === "number" || typeof value === "boolean")
|
|
3884
|
+
joinValues.add(String(value));
|
|
3885
|
+
}
|
|
3886
|
+
if (joinValues.size > 0)
|
|
3887
|
+
joinFieldValuesByStep.set(stepIndex, joinValues);
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3400
3891
|
// Pass 1: render every step's emitted strings; collect referenced var names.
|
|
3401
3892
|
const rendered = [];
|
|
3402
3893
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -3432,9 +3923,16 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3432
3923
|
// (experienceData/educationData history, opaque eventData) BEFORE value
|
|
3433
3924
|
// substitution reaches inside them: swallowing the entire array/object first
|
|
3434
3925
|
// keeps interpolateStateValues from binding a code buried in the history
|
|
3435
|
-
// sample (e.g. a work entry's state code) to an unrelated field.
|
|
3926
|
+
// sample (e.g. a work entry's state code) to an unrelated field. Excludes
|
|
3927
|
+
// any candidate that itself carries a PRIOR step's produced value (see
|
|
3928
|
+
// applyStructuredValuePayloadSubstitutions' docstring) — an array-wrapped
|
|
3929
|
+
// dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
|
|
3930
|
+
// must stay reachable for state threading, not get frozen as caller data.
|
|
3436
3931
|
const rawBodyWithStructuredSubs = parsedBody !== null
|
|
3437
|
-
? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys
|
|
3932
|
+
? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
|
|
3933
|
+
...deriveStateVarByValue(prior).keys(),
|
|
3934
|
+
...(joinFieldValuesByStep.get(i) ?? []),
|
|
3935
|
+
]))
|
|
3438
3936
|
: rawBodyWithFormSubs;
|
|
3439
3937
|
// Whole-value caller coordinates bind here — after structured subs, BEFORE
|
|
3440
3938
|
// state threading — so a composite coordinate (a jobLocation or jobSeqNo
|
|
@@ -3490,10 +3988,11 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3490
3988
|
unboundLiteralKeys.add(key);
|
|
3491
3989
|
}
|
|
3492
3990
|
}
|
|
3991
|
+
const joinCarryingHeaderNames = joinCarryingHeaderNamesByStep.get(i);
|
|
3493
3992
|
const perCallHeaders = {};
|
|
3494
3993
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
3495
3994
|
const lower = k.toLowerCase();
|
|
3496
|
-
if (lower === "api-token" || lower === "authorization") {
|
|
3995
|
+
if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
|
|
3497
3996
|
perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
|
|
3498
3997
|
}
|
|
3499
3998
|
}
|
|
@@ -3527,7 +4026,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3527
4026
|
// validates any individual call. Without this override, HttpRequestInit.schema
|
|
3528
4027
|
// would default to the client's z.unknown() and narrowing the caller-facing
|
|
3529
4028
|
// contract would enforce that narrowed shape on every call in the chain.
|
|
3530
|
-
const schemaExpr = inferZodSchema(cap.responseBody, 0, "", {
|
|
4029
|
+
const schemaExpr = inferZodSchema(cap.responseBody, 0, "", {
|
|
4030
|
+
looseServerResponse: true,
|
|
4031
|
+
aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([cap.responseBody]),
|
|
4032
|
+
});
|
|
3531
4033
|
rendered.push({ url, method: cap.method, headersExpr, bodyArg, schemaExpr });
|
|
3532
4034
|
}
|
|
3533
4035
|
// Identifier scan against the rendered text — captures `${foo}`, `${foo.bar}`,
|
|
@@ -3551,11 +4053,17 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3551
4053
|
}
|
|
3552
4054
|
}
|
|
3553
4055
|
}
|
|
3554
|
-
//
|
|
3555
|
-
//
|
|
3556
|
-
|
|
4056
|
+
// Every resolved drill-down fold plan bypasses selectReturnAction entirely:
|
|
4057
|
+
// each plan's primary step's array is folded in place (see the loop-and-merge
|
|
4058
|
+
// emitted below, one such loop per plan), so a primary step's var — not
|
|
4059
|
+
// whichever call selectReturnAction would otherwise pick — is what
|
|
4060
|
+
// `return { data }` must reference when any plan resolves.
|
|
4061
|
+
const foldPlans = earlyFoldPlans;
|
|
4062
|
+
const returnAction = foldPlans.length > 0 ? null : selectReturnAction(actions);
|
|
3557
4063
|
if (returnAction)
|
|
3558
4064
|
referencedNames.add(returnAction.varName);
|
|
4065
|
+
for (const plan of foldPlans)
|
|
4066
|
+
referencedNames.add(actions[plan.primaryStepIndex].varName);
|
|
3559
4067
|
// Pass 2: emit. Skip response bindings that aren't referenced; skip
|
|
3560
4068
|
// produces[] entries whose name isn't referenced. A step's response var
|
|
3561
4069
|
// is still needed when at least one of its produces[] entries IS
|
|
@@ -3575,10 +4083,195 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3575
4083
|
lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
|
|
3576
4084
|
}
|
|
3577
4085
|
const declaredNames = new Set();
|
|
4086
|
+
// Every non-terminal chain step past the drill step is folded into the SAME
|
|
4087
|
+
// per-item loop the drill step itself emits (see below) — it must not also
|
|
4088
|
+
// get the normal single-call treatment this pass gives every other step, or
|
|
4089
|
+
// its request would be issued a second time, unconditionally, outside the
|
|
4090
|
+
// loop.
|
|
4091
|
+
// Every step in a fold target's chain (the drill step and every further
|
|
4092
|
+
// step transitively dependent on it — see FoldTarget.chain) is emitted
|
|
4093
|
+
// together as a single per-item loop below, not by this pass's normal
|
|
4094
|
+
// per-step produce/response declarations: their responses and produces are
|
|
4095
|
+
// block-scoped to that loop and never escape to the rest of the function,
|
|
4096
|
+
// so none of them may run through the outer `declaredNames`/produceLines
|
|
4097
|
+
// bookkeeping below (that bookkeeping assumes function-scope declarations).
|
|
4098
|
+
const foldChainIndices = new Set(foldPlans.flatMap((plan) => plan.targets.flatMap((target) => target.chain)));
|
|
3578
4099
|
for (let i = 0; i < actions.length; i++) {
|
|
3579
4100
|
const step = actions[i];
|
|
3580
4101
|
const cap = step.capture;
|
|
3581
4102
|
const r = rendered[i];
|
|
4103
|
+
// Every independent fold target within a given plan — regardless of which
|
|
4104
|
+
// drill step starts it — is folded into ONE shared per-item loop over
|
|
4105
|
+
// that plan's primary array, emitted once, at the plan's FIRST target's
|
|
4106
|
+
// drillStepIndex: each target's chain calls and Object.assign both run
|
|
4107
|
+
// inside that same `for (const item of foldItems)` body, so a primary
|
|
4108
|
+
// item ends up with fields folded in from every independent dependent
|
|
4109
|
+
// drill-down of that plan, not just the first. When more than one
|
|
4110
|
+
// independent plan resolves (distinct primary arrays), each plan gets its
|
|
4111
|
+
// OWN loop block, anchored at that plan's own first target's
|
|
4112
|
+
// drillStepIndex, so each primary array is folded with only its own
|
|
4113
|
+
// drill-downs' matched fields.
|
|
4114
|
+
// Each chain step re-issues the SAME url/headers/bodyArg/schemaExpr this
|
|
4115
|
+
// pass already rendered for it, only with the captured join value(s)
|
|
4116
|
+
// swapped for the loop item's own field accessor (the drill step) or its
|
|
4117
|
+
// own produced response values (later chain steps, which already render
|
|
4118
|
+
// with `${producedName}` templates — see deriveStateVarByValue).
|
|
4119
|
+
const matchingPlanIndex = foldPlans.findIndex((plan) => plan.targets.length > 0 && plan.targets[0].drillStepIndex === i);
|
|
4120
|
+
if (matchingPlanIndex !== -1) {
|
|
4121
|
+
const foldPlan = foldPlans[matchingPlanIndex];
|
|
4122
|
+
const primaryStep = actions[foldPlan.primaryStepIndex];
|
|
4123
|
+
// Read at the plan's OWN path rather than re-running the DFS: a
|
|
4124
|
+
// flow-declared `resultsPath` (see FoldReturnSpec) can name a different
|
|
4125
|
+
// array than findObjectArrayField's first match.
|
|
4126
|
+
const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
|
|
4127
|
+
const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
|
|
4128
|
+
// Plan-level suffix mirrors the target-level suffix below: multiple
|
|
4129
|
+
// loop blocks now sharing the same function scope can't declare
|
|
4130
|
+
// unsuffixed `foldItems`/`item` locals without colliding. The
|
|
4131
|
+
// overwhelmingly common single-plan case keeps the original
|
|
4132
|
+
// unsuffixed names.
|
|
4133
|
+
const planSuffix = foldPlans.length > 1 ? String(matchingPlanIndex) : "";
|
|
4134
|
+
const foldItemsVar = `foldItems${planSuffix}`;
|
|
4135
|
+
const foldItemsExpr = pathToFoldAccessorExpr(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
|
|
4136
|
+
const itemVar = `item${planSuffix}`;
|
|
4137
|
+
lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
|
|
4138
|
+
for (const [targetIndex, target] of foldPlan.targets.entries()) {
|
|
4139
|
+
// `firstItem` decides which captured literal `parameterize` rewrites
|
|
4140
|
+
// — it must be the item at `primaryMatchedItemIndex`, the one THIS
|
|
4141
|
+
// target's drill request was actually built from, not always index
|
|
4142
|
+
// 0, and can differ per target even though every target now shares
|
|
4143
|
+
// the same runtime loop item.
|
|
4144
|
+
const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
|
|
4145
|
+
if (!firstItem) {
|
|
4146
|
+
throw new Error(`emitMultiStepExecuteHttp: fold plan primary step ${primaryStep.varName} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
|
|
4147
|
+
}
|
|
4148
|
+
// Each target's chain variables and merge result get their own
|
|
4149
|
+
// suffixed local names so multiple independent targets sharing the
|
|
4150
|
+
// same loop body can each declare their own locals without
|
|
4151
|
+
// colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
|
|
4152
|
+
// single-target case keeps the original unsuffixed names.
|
|
4153
|
+
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
4154
|
+
const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
4155
|
+
// A join field can reach the render either as the raw captured literal
|
|
4156
|
+
// (URL query params) or as an already-generic `${payload.<field>}`
|
|
4157
|
+
// reference (top-level JSON body keys — see
|
|
4158
|
+
// applyPayloadKeyValueSubstitutions, which payload-ifies every scalar
|
|
4159
|
+
// body key regardless of length, running BEFORE this fold branch ever
|
|
4160
|
+
// sees the value). Both must resolve to the loop item's own field, not
|
|
4161
|
+
// a caller-supplied payload value shared across every iteration.
|
|
4162
|
+
// Word-boundary anchored: a plain `.split(value).join(...)` would also
|
|
4163
|
+
// rewrite unrelated substrings that happen to contain the join value
|
|
4164
|
+
// (e.g. a "p1" product id colliding with a "/v1/" path segment or a
|
|
4165
|
+
// "p10" sibling id), corrupting parts of the request the join field
|
|
4166
|
+
// never touched.
|
|
4167
|
+
const replaceWholeValue = (haystack, value, replacement) => haystack.replace(new RegExp(`\\b${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), replacement);
|
|
4168
|
+
const parameterize = (text) => target.joinFields.reduce((acc, field) => {
|
|
4169
|
+
const replacement = `\${${joinAccessor(field)}}`;
|
|
4170
|
+
// applyPayloadKeyValueSubstitutions only ever names a payload
|
|
4171
|
+
// accessor after the DRILL REQUEST's own top-level JSON key
|
|
4172
|
+
// (`${payload.sku}`), never after `field`'s dot path into the
|
|
4173
|
+
// PRIMARY ITEM — those are unrelated structures that only
|
|
4174
|
+
// happen to share a leaf name for a top-level join field. A
|
|
4175
|
+
// nested join field (e.g. `identifiers.sku`) must therefore
|
|
4176
|
+
// also match on its bare last segment, or the accessor swap
|
|
4177
|
+
// silently no-ops and leaves an undefined `payload.sku`
|
|
4178
|
+
// reference behind once the literal value itself has already
|
|
4179
|
+
// been replaced by the payload-key-value pass.
|
|
4180
|
+
const lastSegment = field.split(".").pop();
|
|
4181
|
+
const withAccessorSwapped = acc
|
|
4182
|
+
.split(`\${payload.${field}}`)
|
|
4183
|
+
.join(replacement)
|
|
4184
|
+
.split(`\${payload.${lastSegment}}`)
|
|
4185
|
+
.join(replacement);
|
|
4186
|
+
const value = readValueAtPath(firstItem, field.split("."));
|
|
4187
|
+
const stringValue = typeof value === "string" && value.length > 0
|
|
4188
|
+
? value
|
|
4189
|
+
: typeof value === "number" || typeof value === "boolean"
|
|
4190
|
+
? String(value)
|
|
4191
|
+
: null;
|
|
4192
|
+
return stringValue !== null
|
|
4193
|
+
? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
|
|
4194
|
+
: withAccessorSwapped;
|
|
4195
|
+
}, text);
|
|
4196
|
+
// Every chain step's response and produces are block-scoped to this
|
|
4197
|
+
// `for` — they never escape to the rest of the function. That is
|
|
4198
|
+
// exactly the constraint the previous (now-removed) throw enforced by
|
|
4199
|
+
// refusing to run at all: instead of failing, each chain step's
|
|
4200
|
+
// produces are re-declared here as loop-scoped locals, so a later
|
|
4201
|
+
// chain step's own request (already rendered with `${producedName}`
|
|
4202
|
+
// templates by the pass above, same as it would be for any two
|
|
4203
|
+
// sequential non-fold steps) resolves them from this narrower scope.
|
|
4204
|
+
const chainDeclared = new Set();
|
|
4205
|
+
for (const chainIndex of target.chain) {
|
|
4206
|
+
const chainStep = actions[chainIndex];
|
|
4207
|
+
const chainRendered = rendered[chainIndex];
|
|
4208
|
+
lines.push(` const ${chainStep.varName} = (await httpClient(\`${parameterize(chainRendered.url)}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
|
|
4209
|
+
const joined = [
|
|
4210
|
+
parameterize(chainRendered.headersExpr),
|
|
4211
|
+
parameterize(chainRendered.bodyArg),
|
|
4212
|
+
]
|
|
4213
|
+
.filter((s) => s !== "")
|
|
4214
|
+
.join(" ");
|
|
4215
|
+
if (joined !== "")
|
|
4216
|
+
lines.push(` ${joined}`);
|
|
4217
|
+
lines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
|
|
4218
|
+
for (const p of chainStep.produces) {
|
|
4219
|
+
if (p.kind === "header")
|
|
4220
|
+
continue;
|
|
4221
|
+
if (chainDeclared.has(p.name))
|
|
4222
|
+
continue;
|
|
4223
|
+
if (!referencedNames.has(p.name))
|
|
4224
|
+
continue;
|
|
4225
|
+
chainDeclared.add(p.name);
|
|
4226
|
+
const assertion = pathToAssertionType(p.path);
|
|
4227
|
+
lines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
const terminalStep = actions[target.chainTerminalIndex];
|
|
4231
|
+
// An empty chainArrayPath means the terminal step's response IS the
|
|
4232
|
+
// implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
|
|
4233
|
+
// / objectItemsAtPath's flat-object branch): the response is a flat
|
|
4234
|
+
// object at runtime, not an array. There is exactly one candidate, so
|
|
4235
|
+
// no join-field match is needed (or even possible against an array
|
|
4236
|
+
// API) — emit a direct object reference instead of the array
|
|
4237
|
+
// `.find()` machinery the multi-item branch below needs.
|
|
4238
|
+
if (target.chainArrayPath.length === 0) {
|
|
4239
|
+
lines.push(` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
|
|
4240
|
+
}
|
|
4241
|
+
else {
|
|
4242
|
+
const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
|
|
4243
|
+
lines.push(` const foldMatches${suffix} = ${foldMatchesExpr};`, ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
|
|
4244
|
+
.map((f) => {
|
|
4245
|
+
const segments = f.split(".");
|
|
4246
|
+
// The drill-down response is a DIFFERENT payload than the
|
|
4247
|
+
// primary item, so it has no obligation to mirror the
|
|
4248
|
+
// primary item's own nesting for the join key (e.g. a
|
|
4249
|
+
// primary item's `identifiers.sku` is typically echoed
|
|
4250
|
+
// back flat, as `sku`, on the drill response). Try the
|
|
4251
|
+
// full nested path first (optional-chained, since an
|
|
4252
|
+
// intermediate segment may not exist on a flat response),
|
|
4253
|
+
// then fall back to the bare last segment.
|
|
4254
|
+
const lastSegment = segments[segments.length - 1];
|
|
4255
|
+
const optionalBracketAccessor = segments
|
|
4256
|
+
.map((segment) => `?.[${JSON.stringify(segment)}]`)
|
|
4257
|
+
.join("");
|
|
4258
|
+
const matchAccessor = segments.length > 1
|
|
4259
|
+
? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
|
|
4260
|
+
: `m[${JSON.stringify(lastSegment)}]`;
|
|
4261
|
+
return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
|
|
4262
|
+
})
|
|
4263
|
+
.join(" && ")}) ?? foldMatches${suffix}[0];`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
lines.push(` }`, "");
|
|
4267
|
+
continue;
|
|
4268
|
+
}
|
|
4269
|
+
// Every other chain step (already fully emitted, inline, by the fold
|
|
4270
|
+
// block above) must not also get the normal single-call treatment this
|
|
4271
|
+
// pass gives every step, or its request would be issued a second time,
|
|
4272
|
+
// unconditionally, outside the loop.
|
|
4273
|
+
if (foldChainIndices.has(i))
|
|
4274
|
+
continue;
|
|
3582
4275
|
// Build the produce-extraction lines FIRST so the binding decision reflects
|
|
3583
4276
|
// what is actually emitted, not a pre-scan predicate. A produce whose name
|
|
3584
4277
|
// was already declared by an earlier step is de-dup-skipped here — and must
|
|
@@ -3690,8 +4383,38 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
3690
4383
|
lines.push(line);
|
|
3691
4384
|
lines.push("");
|
|
3692
4385
|
}
|
|
3693
|
-
|
|
3694
|
-
|
|
4386
|
+
// When multiple plans resolve, every plan's own primary var is deep-merged
|
|
4387
|
+
// together into one combined result via mergeFoldedPrimaryBodies —
|
|
4388
|
+
// mirroring selectEffectiveResponseBody's merge — so the runtime return
|
|
4389
|
+
// value and the inferred shape always describe the same call. A plain
|
|
4390
|
+
// object-spread would silently drop one plan's array whenever two plans'
|
|
4391
|
+
// primary bodies share a top-level array key (e.g. the same paginated
|
|
4392
|
+
// primary endpoint drilled into twice). A primary whose OWN top-level body
|
|
4393
|
+
// isn't a plain object can't be merged meaningfully, so in that case this
|
|
4394
|
+
// falls back to the LAST plan's primary var alone, same as before this
|
|
4395
|
+
// merge was introduced.
|
|
4396
|
+
//
|
|
4397
|
+
// Deduped by var name (not one entry per plan): two plans anchored on the
|
|
4398
|
+
// SAME primary step (e.g. a structural plan and a spec-only plan each
|
|
4399
|
+
// resolving a different array on one shared response — see
|
|
4400
|
+
// mergeSpecPlanOntoSamePrimary) both mutate that ONE response object's own
|
|
4401
|
+
// arrays in place per their own loop above. Passing that same var into
|
|
4402
|
+
// mergeFoldedPrimaryBodies once per plan would concatenate every one of its
|
|
4403
|
+
// arrays with itself, duplicating every already-folded item.
|
|
4404
|
+
const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
|
|
4405
|
+
const uniquePrimaryVarNames = [
|
|
4406
|
+
...new Set(foldPlans.map((plan) => actions[plan.primaryStepIndex].varName)),
|
|
4407
|
+
];
|
|
4408
|
+
const everyPrimaryIsPlainObject = foldPlans.every((plan) => isPlainObject(actions[plan.primaryStepIndex].capture.responseBody));
|
|
4409
|
+
if (uniquePrimaryVarNames.length > 1 && everyPrimaryIsPlainObject) {
|
|
4410
|
+
lines.push(` return { data: mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")}) };`);
|
|
4411
|
+
}
|
|
4412
|
+
else {
|
|
4413
|
+
const returnVar = lastFoldPlan
|
|
4414
|
+
? actions[lastFoldPlan.primaryStepIndex].varName
|
|
4415
|
+
: (returnAction?.varName ?? "undefined");
|
|
4416
|
+
lines.push(` return { data: ${returnVar} };`);
|
|
4417
|
+
}
|
|
3695
4418
|
return lines.join("\n");
|
|
3696
4419
|
}
|
|
3697
4420
|
function summariseResponseShape(value) {
|
|
@@ -3843,23 +4566,986 @@ function findNumericFieldByName(value, pattern, path = []) {
|
|
|
3843
4566
|
}
|
|
3844
4567
|
return null;
|
|
3845
4568
|
}
|
|
3846
|
-
/**
|
|
4569
|
+
/** Filter predicate isolating an array's (non-array) object elements — the
|
|
4570
|
+
* shape both {@link findAllObjectArrayFields} and {@link objectItemsAtPath}
|
|
4571
|
+
* treat as a "results array" candidate. */
|
|
4572
|
+
function isObjectArrayItem(v) {
|
|
4573
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4574
|
+
}
|
|
4575
|
+
/** Sentinel path segment marking "every element of the array reached so
|
|
4576
|
+
* far", emitted by {@link findAllObjectArrayFields} in place of a literal
|
|
4577
|
+
* numeric index whenever it descends through an array to keep searching —
|
|
4578
|
+
* the array itself is a container of candidate groups, not a single fixed
|
|
4579
|
+
* one. Freezing the literal index of whichever group happened to contain
|
|
4580
|
+
* the matched item (the bug this sentinel fixes) meant a multi-element
|
|
4581
|
+
* outer array — e.g. a paginated/grouped response wrapping several
|
|
4582
|
+
* sub-collections — only ever resolved/iterated the ONE group seen during
|
|
4583
|
+
* detection. {@link objectItemsAtPath} and the fold-emission accessor
|
|
4584
|
+
* builders below both flatten across every element at this position instead
|
|
4585
|
+
* of indexing into one. */
|
|
4586
|
+
const ARRAY_WILDCARD_SEGMENT = "*";
|
|
4587
|
+
/** Depth-first search for every array whose elements are (non-array)
|
|
3847
4588
|
* objects — the same "per-item response array" shape schema inference
|
|
3848
|
-
* already resolves to when it emits `z.array(z.object({...}))`.
|
|
3849
|
-
|
|
4589
|
+
* already resolves to when it emits `z.array(z.object({...}))`. Ordered by
|
|
4590
|
+
* DFS/key order, so `[0]` is {@link findObjectArrayField}'s first match.
|
|
4591
|
+
* A path segment for an array index the search descended through (to keep
|
|
4592
|
+
* looking for a nested candidate array) is the {@link ARRAY_WILDCARD_SEGMENT}
|
|
4593
|
+
* sentinel, never a literal index — see its docstring. */
|
|
4594
|
+
function findAllObjectArrayFields(value, path = []) {
|
|
3850
4595
|
if (value === null || typeof value !== "object")
|
|
3851
|
-
return
|
|
4596
|
+
return [];
|
|
3852
4597
|
if (Array.isArray(value)) {
|
|
3853
|
-
const objectItems = value.filter(
|
|
3854
|
-
|
|
4598
|
+
const objectItems = value.filter(isObjectArrayItem);
|
|
4599
|
+
const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFields(item, [...path, ARRAY_WILDCARD_SEGMENT]));
|
|
4600
|
+
return objectItems.length > 0
|
|
4601
|
+
? [{ path, items: objectItems }, ...nestedCandidates]
|
|
4602
|
+
: nestedCandidates;
|
|
4603
|
+
}
|
|
4604
|
+
return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFields(v, [...path, key]));
|
|
4605
|
+
}
|
|
4606
|
+
/** The first object-array field by DFS/key order — see
|
|
4607
|
+
* {@link findAllObjectArrayFields}. Every call site that must disambiguate
|
|
4608
|
+
* between several candidate arrays (e.g. a decoy array positioned earlier in
|
|
4609
|
+
* key order than the real one) uses {@link findAllObjectArrayFields}
|
|
4610
|
+
* directly instead of this first-match shortcut. */
|
|
4611
|
+
function findObjectArrayField(value, path = []) {
|
|
4612
|
+
return findAllObjectArrayFields(value, path)[0] ?? null;
|
|
4613
|
+
}
|
|
4614
|
+
/** {@link findAllObjectArrayFields}, widened to ALSO offer the flat
|
|
4615
|
+
* (non-array) whole object itself as a candidate — a detail-by-id
|
|
4616
|
+
* drill/chain response (e.g. `GET /widgets/{id}` returning the widget
|
|
4617
|
+
* object directly, not `{ widget: {...} }`) is exactly as foldable as a
|
|
4618
|
+
* one-element array would be. The flat entry's path is `[]` (the whole
|
|
4619
|
+
* body); {@link objectItemsAtPath} recognizes a flat object at a resolved
|
|
4620
|
+
* path the same way, so a {@link FoldTarget} built from this fallback
|
|
4621
|
+
* resolves correctly end to end. The flat candidate is appended AFTER every
|
|
4622
|
+
* real object-array candidate (never in place of one) so a caller that
|
|
4623
|
+
* needs to compare candidates by richness — see {@link
|
|
4624
|
+
* chainTerminalItemRichness} — can prefer the flat shape when it carries
|
|
4625
|
+
* more genuine per-item data than a small real nested object-array; a
|
|
4626
|
+
* caller that just wants the first real array (the common case) is
|
|
4627
|
+
* unaffected since it still comes first. */
|
|
4628
|
+
function findAllObjectArrayFieldsOrWholeObject(value, path = []) {
|
|
4629
|
+
const found = findAllObjectArrayFields(value, path);
|
|
4630
|
+
return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
|
|
4631
|
+
}
|
|
4632
|
+
/** The first candidate from {@link findAllObjectArrayFieldsOrWholeObject} —
|
|
4633
|
+
* the flat-object-aware counterpart of {@link findObjectArrayField}. */
|
|
4634
|
+
function findObjectArrayFieldOrWholeObject(value, path = []) {
|
|
4635
|
+
return findAllObjectArrayFieldsOrWholeObject(value, path)[0] ?? null;
|
|
4636
|
+
}
|
|
4637
|
+
/** Every string and numeric value present in a capture's outbound request —
|
|
4638
|
+
* its URL path segments and query parameters (always strings) and its JSON
|
|
4639
|
+
* body's string and numeric leaves (numeric leaves stringified) — the set a
|
|
4640
|
+
* drill-down request's threaded join value must appear in. Path segments are
|
|
4641
|
+
* included because REST-style APIs commonly thread a primary item's id as a
|
|
4642
|
+
* path segment (e.g. `/orders/{id}`) rather than a query param or body
|
|
4643
|
+
* field. Numeric leaves are included because a join key is just as often a
|
|
4644
|
+
* numeric id (threaded as a query param string or a JSON body number
|
|
4645
|
+
* literal) as a string one. */
|
|
4646
|
+
function collectRequestStringValues(capture) {
|
|
4647
|
+
const values = new Set();
|
|
4648
|
+
try {
|
|
4649
|
+
const url = new URL(capture.url);
|
|
4650
|
+
for (const v of url.searchParams.values())
|
|
4651
|
+
values.add(v);
|
|
4652
|
+
for (const segment of url.pathname.split("/").filter(Boolean))
|
|
4653
|
+
values.add(segment);
|
|
3855
4654
|
}
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
4655
|
+
catch {
|
|
4656
|
+
// Relative or malformed URL — no query params or path segments to contribute.
|
|
4657
|
+
}
|
|
4658
|
+
for (const v of jsonBodyLeafValues(capture.requestPostData) ?? [])
|
|
4659
|
+
values.add(v);
|
|
4660
|
+
const parsedBody = (() => {
|
|
4661
|
+
try {
|
|
4662
|
+
return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
|
|
4663
|
+
? JSON.parse(capture.requestPostData)
|
|
4664
|
+
: undefined;
|
|
4665
|
+
}
|
|
4666
|
+
catch {
|
|
4667
|
+
return undefined;
|
|
4668
|
+
}
|
|
4669
|
+
})();
|
|
4670
|
+
if (parsedBody !== undefined) {
|
|
4671
|
+
for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
4672
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
4673
|
+
values.add(String(value));
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
4676
|
+
return values;
|
|
4677
|
+
}
|
|
4678
|
+
/**
|
|
4679
|
+
* Yields every string/numeric/boolean leaf reachable from `item` by walking nested
|
|
4680
|
+
* plain objects only (not arrays — a join key is a scalar field of the item
|
|
4681
|
+
* or one of its nested objects, never an element drawn from a nested array),
|
|
4682
|
+
* paired with its dot-separated path from `item`'s root. A bare top-level
|
|
4683
|
+
* field yields a single-segment path (e.g. `["sku"]`), matching every
|
|
4684
|
+
* existing joinFields entry's shape unchanged; a field nested inside an
|
|
4685
|
+
* object (e.g. `{ identifiers: { sku } }`) yields `["identifiers", "sku"]`.
|
|
4686
|
+
*/
|
|
4687
|
+
function* walkItemFieldPaths(item, path = []) {
|
|
4688
|
+
for (const [k, v] of Object.entries(item)) {
|
|
4689
|
+
const childPath = [...path, k];
|
|
4690
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
4691
|
+
yield* walkItemFieldPaths(v, childPath);
|
|
4692
|
+
continue;
|
|
4693
|
+
}
|
|
4694
|
+
yield { path: childPath, value: v };
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* Finds the ordered list of an array item's string/numeric/boolean field
|
|
4699
|
+
* paths whose values are threaded into `drillCapture`'s outbound request — the join key a
|
|
4700
|
+
* dependent drill-down call was built from. Each entry is a dot-separated
|
|
4701
|
+
* path (see {@link readValueAtPath} / {@link pathToAccessor}), so a bare
|
|
4702
|
+
* top-level field stays a single segment (e.g. `"sku"`) and a field nested
|
|
4703
|
+
* inside an object becomes e.g. `"identifiers.sku"`. Field order follows the
|
|
4704
|
+
* item's own key order (nested objects walked depth-first as encountered),
|
|
4705
|
+
* so a composite join (e.g. `accountId` + `region`) comes out in the same
|
|
4706
|
+
* order the primary response declares them, not sorted. Returns `[]` when no
|
|
4707
|
+
* field of the item threads into the request at all.
|
|
4708
|
+
*/
|
|
4709
|
+
function findThreadedJoinFields(item, drillCapture) {
|
|
4710
|
+
const requestValues = collectRequestStringValues(drillCapture);
|
|
4711
|
+
if (requestValues.size === 0)
|
|
4712
|
+
return [];
|
|
4713
|
+
return [...walkItemFieldPaths(item)]
|
|
4714
|
+
.filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
|
|
4715
|
+
(typeof v === "number" && requestValues.has(String(v))) ||
|
|
4716
|
+
(typeof v === "boolean" && requestValues.has(String(v))))
|
|
4717
|
+
.map(({ path }) => path.join("."));
|
|
4718
|
+
}
|
|
4719
|
+
/** Every string, numeric, and boolean leaf value present anywhere in a response —
|
|
4720
|
+
* the set a chained drill-down step's request must overlap with for that
|
|
4721
|
+
* step to count as depending on this response. Deliberately walks the WHOLE
|
|
4722
|
+
* body (not just object-array items, unlike {@link findThreadedJoinFields})
|
|
4723
|
+
* since a chained step can thread any response value, not only a per-item
|
|
4724
|
+
* join field. Also walks every response HEADER value, mirroring
|
|
4725
|
+
* {@link collectRequestValuesIncludingHeaders} on the request side, since a
|
|
4726
|
+
* chain hop can just as easily mint its join token in a response header
|
|
4727
|
+
* (e.g. a `Location` or custom correlation header) as in the body. */
|
|
4728
|
+
function collectResponseLeafValues(capture) {
|
|
4729
|
+
const values = new Set();
|
|
4730
|
+
for (const { value } of walkAllPrimitiveLeaves(capture.responseBody)) {
|
|
4731
|
+
if (typeof value === "string" && value.length > 0)
|
|
4732
|
+
values.add(value);
|
|
4733
|
+
if (typeof value === "number")
|
|
4734
|
+
values.add(String(value));
|
|
4735
|
+
if (typeof value === "boolean")
|
|
4736
|
+
values.add(String(value));
|
|
4737
|
+
}
|
|
4738
|
+
for (const v of Object.values(capture.responseHeaders))
|
|
4739
|
+
values.add(v);
|
|
4740
|
+
return values;
|
|
4741
|
+
}
|
|
4742
|
+
/**
|
|
4743
|
+
* Walks forward from `drillStepIndex`, following the transitive per-item
|
|
4744
|
+
* dependency chain: each subsequent step whose request threads a value out
|
|
4745
|
+
* of ANY step already in the chain is itself added to the chain, since its
|
|
4746
|
+
* own request — and therefore its response — only makes sense once per
|
|
4747
|
+
* matched primary item. Reuses {@link collectRequestValuesIncludingHeaders}
|
|
4748
|
+
* (URL, body, AND headers) so a chained step threading its join value
|
|
4749
|
+
* through a header, exactly like {@link buildFoldPlanFromSpec}'s own
|
|
4750
|
+
* threading, is still picked up. Returns the ordered step indices (starting
|
|
4751
|
+
* at `drillStepIndex`) plus the object-array path AND owning step index found
|
|
4752
|
+
* on the LAST chain step whose response actually has an object-array field,
|
|
4753
|
+
* OR — failing that — the last chain step whose response is a flat object
|
|
4754
|
+
* carrying MORE of its own per-item data than the chain's best terminal
|
|
4755
|
+
* found so far (see {@link chainTerminalItemRichness}). Either way this is
|
|
4756
|
+
* NOT necessarily `chain`'s last entry, since a step can be chained purely
|
|
4757
|
+
* because it threads a value onward (e.g. a status-check response with no
|
|
4758
|
+
* array of its own and no richer data than what came before) without itself
|
|
4759
|
+
* holding the foldable data. Falls back to `drillArrayPath`/`drillStepIndex`
|
|
4760
|
+
* (the immediate drill step's own array or flat object) when nothing
|
|
4761
|
+
* threads further, so `chain` degrades to `[drillStepIndex]` for the common
|
|
4762
|
+
* single-step case.
|
|
4763
|
+
*/
|
|
4764
|
+
/** Disambiguates among every object-array (or flat-object) candidate on
|
|
4765
|
+
* `responseBody`, preferring the one whose items thread `capture`'s own
|
|
4766
|
+
* request/header values (see {@link findThreadedJoinFields}), falling back
|
|
4767
|
+
* to the richest candidate by {@link chainTerminalItemRichness} when none
|
|
4768
|
+
* thread. Both the immediate drill step ({@link scanPrimaryCandidateGroups})
|
|
4769
|
+
* and every later chained step ({@link computeFoldChain}) must disambiguate
|
|
4770
|
+
* identically — a decoy candidate positioned earlier in key order than the
|
|
4771
|
+
* real per-item array is exactly as invalid a pick on a chain hop as it is
|
|
4772
|
+
* on the immediate drill call — so this is the single implementation both
|
|
4773
|
+
* reuse instead of each doing its own first-DFS-match shortcut. */
|
|
4774
|
+
function selectDisambiguatedCandidate(responseBody, capture) {
|
|
4775
|
+
const candidates = findAllObjectArrayFieldsOrWholeObject(responseBody);
|
|
4776
|
+
if (candidates.length === 0)
|
|
4777
|
+
return null;
|
|
4778
|
+
const requestValues = collectRequestValuesIncludingHeaders(capture);
|
|
4779
|
+
const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields(item, capture).length > 0));
|
|
4780
|
+
if (threaded)
|
|
4781
|
+
return threaded;
|
|
4782
|
+
return candidates.reduce((richest, candidate) => {
|
|
4783
|
+
const candidateRichness = chainTerminalItemRichness(responseBody, candidate.path, requestValues);
|
|
4784
|
+
const richestSoFar = chainTerminalItemRichness(responseBody, richest.path, requestValues);
|
|
4785
|
+
return candidateRichness > richestSoFar ? candidate : richest;
|
|
4786
|
+
});
|
|
4787
|
+
}
|
|
4788
|
+
function computeFoldChain(actions, drillStepIndex, drillArrayPath) {
|
|
4789
|
+
const chain = [drillStepIndex];
|
|
4790
|
+
let chainArrayPath = drillArrayPath;
|
|
4791
|
+
let chainTerminalIndex = drillStepIndex;
|
|
4792
|
+
let chainTerminalRichness = chainTerminalItemRichness(actions[drillStepIndex].capture.responseBody, drillArrayPath, collectRequestValuesIncludingHeaders(actions[drillStepIndex].capture));
|
|
4793
|
+
for (let i = drillStepIndex + 1; i < actions.length; i++) {
|
|
4794
|
+
const candidate = actions[i];
|
|
4795
|
+
const requestValues = collectRequestValuesIncludingHeaders(candidate.capture);
|
|
4796
|
+
const dependsOnChain = chain.some((chainIndex) => {
|
|
4797
|
+
const chainStepCapture = actions[chainIndex].capture;
|
|
4798
|
+
const responseValues = collectResponseLeafValues(chainStepCapture);
|
|
4799
|
+
const echoedValues = collectRequestValuesIncludingHeaders(chainStepCapture);
|
|
4800
|
+
return [...responseValues].some((v) => !echoedValues.has(v) && requestValues.has(v));
|
|
4801
|
+
});
|
|
4802
|
+
if (!dependsOnChain)
|
|
4803
|
+
continue;
|
|
4804
|
+
chain.push(i);
|
|
4805
|
+
// Disambiguated identically to the immediate drill step (see
|
|
4806
|
+
// selectDisambiguatedCandidate): a decoy object-array field positioned
|
|
4807
|
+
// earlier in key order than the real per-item array must not win just
|
|
4808
|
+
// for being found first on THIS chain step's own response, any more
|
|
4809
|
+
// than it would on the immediate drill call. The winning candidate
|
|
4810
|
+
// still only displaces the terminal when it's STRICTLY richer than the
|
|
4811
|
+
// chain's best terminal so far, OR ties it with a genuine object-ARRAY
|
|
4812
|
+
// candidate (isGenuineArrayCandidate: candidateArray.path is non-empty).
|
|
4813
|
+
// A flat single-object candidate never wins a tie — otherwise a step
|
|
4814
|
+
// chained purely for threading a value onward (e.g. a `{ held: true }`
|
|
4815
|
+
// confirmation, never richer than the real per-item shape it merely
|
|
4816
|
+
// threads from) would always qualify as an implicit one-item collection
|
|
4817
|
+
// and collapse this into "always advance the terminal to the newest
|
|
4818
|
+
// chain member" regardless of whether that member actually holds
|
|
4819
|
+
// foldable data. But a later hop that DOES resolve to a real per-item
|
|
4820
|
+
// array, tied only because a same-shaped flat confirmation hop sits
|
|
4821
|
+
// earlier in the chain, is the genuine terminal and must still win —
|
|
4822
|
+
// see buildMulticallSingleShotSearchDrillDownRichnessTiedConfirmationHopChainedDependentActionSteps.
|
|
4823
|
+
const candidateArray = selectDisambiguatedCandidate(candidate.capture.responseBody, candidate.capture);
|
|
4824
|
+
if (!candidateArray)
|
|
4825
|
+
continue;
|
|
4826
|
+
const candidateRichness = chainTerminalItemRichness(candidate.capture.responseBody, candidateArray.path, requestValues);
|
|
4827
|
+
const isGenuineArrayCandidate = candidateArray.path.length > 0;
|
|
4828
|
+
const advancesOnTie = candidateRichness === chainTerminalRichness &&
|
|
4829
|
+
candidateRichness > 0 &&
|
|
4830
|
+
isGenuineArrayCandidate;
|
|
4831
|
+
if (candidateRichness > chainTerminalRichness || advancesOnTie) {
|
|
4832
|
+
chainArrayPath = candidateArray.path;
|
|
4833
|
+
chainTerminalIndex = i;
|
|
4834
|
+
chainTerminalRichness = candidateRichness;
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
return { chain, chainArrayPath, chainTerminalIndex };
|
|
4838
|
+
}
|
|
4839
|
+
/** The per-item primitive-field richness of a chain terminal candidate at
|
|
4840
|
+
* `path` — {@link directPrimitiveChildCountExcludingEchoed} of the first item
|
|
4841
|
+
* {@link objectItemsAtPath} resolves there (an object-array item when `path`
|
|
4842
|
+
* names a real array, or the whole flat object when `path` is `[]`), or 0
|
|
4843
|
+
* when `path` resolves to nothing. `requestValues` excludes fields the
|
|
4844
|
+
* candidate's own response merely echoes back from its request (join keys,
|
|
4845
|
+
* threaded ids), so an echo can never be mistaken for genuine per-item data.
|
|
4846
|
+
* The single metric every {@link computeFoldChain} comparison — baseline,
|
|
4847
|
+
* array-branch, and flat-branch alike — uses, so a later step only ever
|
|
4848
|
+
* displaces the terminal by actually contributing more of its own data. */
|
|
4849
|
+
function chainTerminalItemRichness(responseBody, path, requestValues) {
|
|
4850
|
+
const items = objectItemsAtPath(responseBody, path);
|
|
4851
|
+
return items && items.length > 0
|
|
4852
|
+
? directPrimitiveChildCountExcludingEchoed(items[0], requestValues)
|
|
4853
|
+
: 0;
|
|
4854
|
+
}
|
|
4855
|
+
/** Like {@link directPrimitiveChildCount}, but skips a field whose value was
|
|
4856
|
+
* itself already threaded INTO this response's own request (present in
|
|
4857
|
+
* `requestValues`) — a confirmation step routinely echoes the id/token it
|
|
4858
|
+
* was called with alongside a status flag, and that echo must not count as
|
|
4859
|
+
* genuine per-item data or a side-effect-only response (e.g. `{ token:
|
|
4860
|
+
* "t1", held: true }` echoing a threaded `token`) would out-rank the real
|
|
4861
|
+
* terminal on field count alone. */
|
|
4862
|
+
function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
|
|
4863
|
+
let n = 0;
|
|
4864
|
+
for (const v of Object.values(obj)) {
|
|
4865
|
+
if (v === null || (typeof v !== "object" && typeof v !== "function")) {
|
|
4866
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
4867
|
+
if (requestValues.has(String(v)))
|
|
4868
|
+
continue;
|
|
4869
|
+
}
|
|
4870
|
+
n++;
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
return n;
|
|
4874
|
+
}
|
|
4875
|
+
/**
|
|
4876
|
+
* Scans every candidate object-array field of `actions[primaryIndex]`'s
|
|
4877
|
+
* response independently, each yielding its own {@link PrimaryScanGroup} of
|
|
4878
|
+
* targets when at least one later step threads a join field out of it — a
|
|
4879
|
+
* single primary response holding two genuinely unrelated per-item
|
|
4880
|
+
* collections (e.g. a search response with both a `results[]` and a
|
|
4881
|
+
* `facets[]` that are each independently drilled by their own later call)
|
|
4882
|
+
* must fold BOTH, not just whichever array the first qualifying drill-down
|
|
4883
|
+
* happens to thread from. `consumedIndices` is shared across every
|
|
4884
|
+
* candidate's scan (not reset per candidate) so a step already folded into
|
|
4885
|
+
* one array's target chain is never re-claimed as a fresh target thread of
|
|
4886
|
+
* a different, independent array on the same primary response.
|
|
4887
|
+
*/
|
|
4888
|
+
function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices) {
|
|
4889
|
+
const primary = actions[primaryIndex];
|
|
4890
|
+
const primaryCandidates = findAllObjectArrayFields(primary.capture.responseBody);
|
|
4891
|
+
if (primaryCandidates.length === 0)
|
|
4892
|
+
return [];
|
|
4893
|
+
const groups = [];
|
|
4894
|
+
// A step already consumed as a later member of an earlier target's
|
|
4895
|
+
// chain — from THIS primary array or an independent one on the same
|
|
4896
|
+
// primary response — threads its request from that target's own
|
|
4897
|
+
// response, not straight off the primary array, so it must not also be
|
|
4898
|
+
// picked up as a second, independent target of any candidate array.
|
|
4899
|
+
const consumedIndices = new Set();
|
|
4900
|
+
for (const primaryArray of primaryCandidates) {
|
|
4901
|
+
const targets = [];
|
|
4902
|
+
for (let drillIndex = primaryIndex + 1; drillIndex < actions.length; drillIndex++) {
|
|
4903
|
+
const drill = actions[drillIndex];
|
|
4904
|
+
if (drill === primary)
|
|
4905
|
+
continue;
|
|
4906
|
+
if (consumedIndices.has(drillIndex))
|
|
4907
|
+
continue;
|
|
4908
|
+
if (globallyConsumedIndices.has(drillIndex))
|
|
4909
|
+
continue;
|
|
4910
|
+
// Every item in THIS candidate array is searched, not just items[0]
|
|
4911
|
+
// — a flow that only ever drilled into a later item (never the
|
|
4912
|
+
// first) must still resolve.
|
|
4913
|
+
const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields(item, drill.capture).length > 0);
|
|
4914
|
+
if (primaryMatchedItemIndex === -1)
|
|
4915
|
+
continue;
|
|
4916
|
+
const joinFields = findThreadedJoinFields(primaryArray.items[primaryMatchedItemIndex], drill.capture);
|
|
4917
|
+
// Widened to a flat (non-array) object response when the drill step has
|
|
4918
|
+
// no object-array field of its own — see
|
|
4919
|
+
// findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
|
|
4920
|
+
// `GET /widgets/{id}` returning the widget directly) is just as valid a
|
|
4921
|
+
// fold target as a one-element array would be; only skipping it, rather
|
|
4922
|
+
// than treating it as an implicit one-item collection, was the actual
|
|
4923
|
+
// root cause of dependent drill-downs never folding onto primary
|
|
4924
|
+
// results. Disambiguated via selectDisambiguatedCandidate — a per-item
|
|
4925
|
+
// drill array commonly echoes the join value(s) it was looked up by
|
|
4926
|
+
// (e.g. a `sku` search parameter mirrored back on each result), which
|
|
4927
|
+
// distinguishes it from a decoy array (e.g. an `errors[]` collection)
|
|
4928
|
+
// that never does. Not every real drill array echoes the join value,
|
|
4929
|
+
// though (e.g. a `productId`-keyed lookup returning `units[]` with no
|
|
4930
|
+
// `productId` field of its own), so when no candidate threads, the
|
|
4931
|
+
// richest candidate by per-item primitive-field count wins — the same
|
|
4932
|
+
// selection computeFoldChain applies to every later chained step, so a
|
|
4933
|
+
// decoy is never disambiguated differently on the immediate drill call
|
|
4934
|
+
// than it is one hop further down the chain.
|
|
4935
|
+
// An immediate hop with no object-array/flat-object candidate at all
|
|
4936
|
+
// (a bare token/id array, a scalar, an empty body) must not abandon
|
|
4937
|
+
// the whole candidate here — a LATER hop in the chain may still
|
|
4938
|
+
// thread forward to the real per-item data (e.g. an id array whose
|
|
4939
|
+
// values are looked up individually on a following step). Feed
|
|
4940
|
+
// computeFoldChain the empty-path baseline FoldTarget already
|
|
4941
|
+
// documents as its fallback contract, and only bail once the
|
|
4942
|
+
// resolved chain terminal itself has no real items to fold onto.
|
|
4943
|
+
const drillArray = selectDisambiguatedCandidate(drill.capture.responseBody, drill.capture);
|
|
4944
|
+
const drillArrayPath = drillArray?.path ?? [];
|
|
4945
|
+
const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, drillIndex, drillArrayPath);
|
|
4946
|
+
const chainTerminalItems = objectItemsAtPath(actions[chainTerminalIndex].capture.responseBody, chainArrayPath);
|
|
4947
|
+
if (!chainTerminalItems || chainTerminalItems.length === 0)
|
|
4948
|
+
continue;
|
|
4949
|
+
// `primaryArray.path` can carry an ARRAY_WILDCARD_SEGMENT (a matched
|
|
4950
|
+
// item nested inside a multi-element outer array — e.g. a
|
|
4951
|
+
// paginated/grouped response wrapping several sub-collections), in
|
|
4952
|
+
// which case `primaryMatchedItemIndex` above is only the LOCAL index
|
|
4953
|
+
// within the one group `primaryArray.items` happens to be. Re-resolve
|
|
4954
|
+
// it against the FLATTENED items every group at that path contributes,
|
|
4955
|
+
// by object identity (findAllObjectArrayFields and objectItemsAtPath
|
|
4956
|
+
// both read the same references, never cloning), so downstream
|
|
4957
|
+
// consumers reading through `objectItemsAtPath` — the emitter's
|
|
4958
|
+
// `firstItem` lookup and the shape-inference fold — land on the exact
|
|
4959
|
+
// same item regardless of which group it came from.
|
|
4960
|
+
const flattenedPrimaryItems = objectItemsAtPath(primary.capture.responseBody, primaryArray.path) ?? [];
|
|
4961
|
+
const globalMatchedItemIndex = flattenedPrimaryItems.indexOf(primaryArray.items[primaryMatchedItemIndex]);
|
|
4962
|
+
targets.push({
|
|
4963
|
+
joinFields,
|
|
4964
|
+
drillStepIndex: drillIndex,
|
|
4965
|
+
drillArrayPath,
|
|
4966
|
+
primaryMatchedItemIndex: globalMatchedItemIndex === -1 ? primaryMatchedItemIndex : globalMatchedItemIndex,
|
|
4967
|
+
chain,
|
|
4968
|
+
chainArrayPath,
|
|
4969
|
+
chainTerminalIndex,
|
|
4970
|
+
});
|
|
4971
|
+
// Everything past drillIndex already folded into this target's own
|
|
4972
|
+
// chain is per-item dependent on THIS drill-down, not independently
|
|
4973
|
+
// threaded off the primary array, so it must be skipped rather than
|
|
4974
|
+
// re-considered as a new target — by this candidate array's own
|
|
4975
|
+
// further scanning or any other candidate array's scan.
|
|
4976
|
+
for (const chainIndex of chain)
|
|
4977
|
+
consumedIndices.add(chainIndex);
|
|
4978
|
+
}
|
|
4979
|
+
if (targets.length > 0)
|
|
4980
|
+
groups.push({ primaryArrayPath: primaryArray.path, targets });
|
|
4981
|
+
}
|
|
4982
|
+
return groups;
|
|
4983
|
+
}
|
|
4984
|
+
function detectDrillDownFoldPlan(actions) {
|
|
4985
|
+
const plans = [];
|
|
4986
|
+
// Spans every primary candidate, not just the current one's own drill
|
|
4987
|
+
// scan: a step already folded into an earlier plan's chain — as either
|
|
4988
|
+
// its drill step or a later chained step — depended on that earlier
|
|
4989
|
+
// primary's response, not on this later primary's array, so it must
|
|
4990
|
+
// never be re-claimed as a fresh drill target for a subsequent primary.
|
|
4991
|
+
const globallyConsumedIndices = new Set();
|
|
4992
|
+
for (let primaryIndex = 0; primaryIndex < actions.length; primaryIndex++) {
|
|
4993
|
+
if (globallyConsumedIndices.has(primaryIndex))
|
|
4994
|
+
continue;
|
|
4995
|
+
const primary = actions[primaryIndex];
|
|
4996
|
+
const groups = scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices);
|
|
4997
|
+
if (groups.length === 0)
|
|
4998
|
+
continue;
|
|
4999
|
+
const primaryEndpointKey = endpointKey(primary.capture.url);
|
|
5000
|
+
// Each independent array group on this primary is resolved (and
|
|
5001
|
+
// freshest-wins-checked) on its own — a re-queried primary can have one
|
|
5002
|
+
// array whose freshest occurrence is a later re-query while a second,
|
|
5003
|
+
// unrelated array on the SAME step is only ever threaded from the
|
|
5004
|
+
// first occurrence, so the two groups must not be forced to share one
|
|
5005
|
+
// anchor index.
|
|
5006
|
+
for (const group of groups) {
|
|
5007
|
+
// A re-queried primary (same endpoint hit more than once, per
|
|
5008
|
+
// findRequeriedActions) can have MULTIPLE occurrences that each
|
|
5009
|
+
// independently thread a join key into the SAME later drill-down —
|
|
5010
|
+
// e.g. two "available-products" calls that both happen to contain the
|
|
5011
|
+
// item the drill-down looks up. selectReturnAction/selectPayloadAction
|
|
5012
|
+
// already establish freshest-wins for this exact re-queried-primary
|
|
5013
|
+
// case, so the plan must anchor on the LAST such occurrence, not the
|
|
5014
|
+
// first one the forward scan happens to reach.
|
|
5015
|
+
let freshestIndex = primaryIndex;
|
|
5016
|
+
let freshestGroup = group;
|
|
5017
|
+
for (let laterIndex = primaryIndex + 1; laterIndex < actions.length; laterIndex++) {
|
|
5018
|
+
if (globallyConsumedIndices.has(laterIndex))
|
|
5019
|
+
continue;
|
|
5020
|
+
const laterAction = actions[laterIndex];
|
|
5021
|
+
if (endpointKey(laterAction.capture.url) !== primaryEndpointKey)
|
|
5022
|
+
continue;
|
|
5023
|
+
const laterGroups = scanPrimaryCandidateGroups(actions, laterIndex, globallyConsumedIndices);
|
|
5024
|
+
const laterGroup = laterGroups.find((g) => JSON.stringify(g.primaryArrayPath) === JSON.stringify(freshestGroup.primaryArrayPath));
|
|
5025
|
+
if (laterGroup === undefined)
|
|
5026
|
+
continue;
|
|
5027
|
+
const threadsSameDrill = laterGroup.targets.some((laterTarget) => freshestGroup.targets.some((currentTarget) => currentTarget.drillStepIndex === laterTarget.drillStepIndex));
|
|
5028
|
+
if (!threadsSameDrill)
|
|
5029
|
+
continue;
|
|
5030
|
+
// A primary occurrence's targets all read from THAT occurrence's own
|
|
5031
|
+
// response array, so switching the anchor to a later occurrence can
|
|
5032
|
+
// only be done wholesale, not merged field-by-field. Doing so is only
|
|
5033
|
+
// safe when the later occurrence re-threads EVERY drill-down the
|
|
5034
|
+
// current anchor already covers — otherwise a drill-down target
|
|
5035
|
+
// unique to the earlier occurrence (one it threads independently of
|
|
5036
|
+
// the re-queried join key) would be silently dropped instead of
|
|
5037
|
+
// folded at all.
|
|
5038
|
+
const laterCoversEveryCurrentTarget = freshestGroup.targets.every((currentTarget) => laterGroup.targets.some((laterTarget) => laterTarget.drillStepIndex === currentTarget.drillStepIndex));
|
|
5039
|
+
if (!laterCoversEveryCurrentTarget)
|
|
5040
|
+
continue;
|
|
5041
|
+
freshestIndex = laterIndex;
|
|
5042
|
+
freshestGroup = laterGroup;
|
|
5043
|
+
}
|
|
5044
|
+
// Defer to the later occurrence: it will be picked up on its own turn
|
|
5045
|
+
// through the outer loop, once it is reached as `primaryIndex`.
|
|
5046
|
+
if (freshestIndex !== primaryIndex)
|
|
5047
|
+
continue;
|
|
5048
|
+
plans.push({
|
|
5049
|
+
primaryStepIndex: freshestIndex,
|
|
5050
|
+
primaryArrayPath: freshestGroup.primaryArrayPath,
|
|
5051
|
+
targets: freshestGroup.targets,
|
|
5052
|
+
});
|
|
5053
|
+
// A step already folded into this plan's chains — the drill step(s)
|
|
5054
|
+
// and everything threaded onward from them — was already merged
|
|
5055
|
+
// into this primary's own array; it must not be re-picked up as a
|
|
5056
|
+
// fresh PRIMARY (its response was already consumed here) nor as a
|
|
5057
|
+
// drill target for a later, independent primary. The primary index
|
|
5058
|
+
// itself is marked consumed once per group pushed (idempotent via
|
|
5059
|
+
// Set.add), since the step itself is only visited once regardless of
|
|
5060
|
+
// how many independent array groups it yields.
|
|
5061
|
+
globallyConsumedIndices.add(freshestIndex);
|
|
5062
|
+
for (const target of freshestGroup.targets) {
|
|
5063
|
+
for (const chainIndex of target.chain)
|
|
5064
|
+
globallyConsumedIndices.add(chainIndex);
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
return plans;
|
|
5069
|
+
}
|
|
5070
|
+
/**
|
|
5071
|
+
* Parses an object-form recon-flow.json's optional `foldReturn` declaration
|
|
5072
|
+
* into a typed {@link FoldReturnSpec}, or `null` when the flow is array-form,
|
|
5073
|
+
* doesn't declare `foldReturn`, or declares it with a non-string field —
|
|
5074
|
+
* mirroring the same null-safe pattern the flow loader already applies to
|
|
5075
|
+
* `submitEndpointPattern`/`submitBodyPattern`.
|
|
5076
|
+
*/
|
|
5077
|
+
function parseFoldReturnSpec(flowFileContents) {
|
|
5078
|
+
try {
|
|
5079
|
+
const raw = JSON.parse(flowFileContents);
|
|
5080
|
+
if (Array.isArray(raw))
|
|
5081
|
+
return null;
|
|
5082
|
+
if (raw === null ||
|
|
5083
|
+
typeof raw !== "object" ||
|
|
5084
|
+
!("steps" in raw) ||
|
|
5085
|
+
!Array.isArray(raw.steps)) {
|
|
5086
|
+
return null;
|
|
5087
|
+
}
|
|
5088
|
+
const foldReturn = raw.foldReturn;
|
|
5089
|
+
if (foldReturn === undefined || foldReturn === null || typeof foldReturn !== "object") {
|
|
5090
|
+
return null;
|
|
5091
|
+
}
|
|
5092
|
+
const { endpointPattern, resultsPath, drillResultsPath, joinFields } = foldReturn;
|
|
5093
|
+
if (typeof endpointPattern !== "string" ||
|
|
5094
|
+
typeof resultsPath !== "string" ||
|
|
5095
|
+
(drillResultsPath !== undefined &&
|
|
5096
|
+
(typeof drillResultsPath !== "string" || drillResultsPath.length === 0)) ||
|
|
5097
|
+
!Array.isArray(joinFields) ||
|
|
5098
|
+
joinFields.length === 0 ||
|
|
5099
|
+
!joinFields.every((f) => typeof f === "string" && f.length > 0)) {
|
|
5100
|
+
return null;
|
|
5101
|
+
}
|
|
5102
|
+
return {
|
|
5103
|
+
endpointPattern,
|
|
5104
|
+
resultsPath,
|
|
5105
|
+
...(drillResultsPath !== undefined ? { drillResultsPath } : {}),
|
|
5106
|
+
joinFields,
|
|
5107
|
+
};
|
|
5108
|
+
}
|
|
5109
|
+
catch {
|
|
5110
|
+
return null;
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
/** Reads the value at an exact JSON path out of a response body, or
|
|
5114
|
+
* `undefined` when any segment doesn't resolve. The exactness is the point:
|
|
5115
|
+
* {@link findObjectArrayField} is a DFS FIRST-match, so it would silently
|
|
5116
|
+
* override a flow-declared `resultsPath` that names a later array. */
|
|
5117
|
+
function readValueAtPath(body, path) {
|
|
5118
|
+
return path.reduce((node, segment) => {
|
|
5119
|
+
if (node === null || typeof node !== "object")
|
|
5120
|
+
return undefined;
|
|
5121
|
+
return node[segment];
|
|
5122
|
+
}, body);
|
|
5123
|
+
}
|
|
5124
|
+
/** The object items of the array at `path` — the same subset
|
|
5125
|
+
* {@link findObjectArrayField} exposes as `items`, but anchored to a
|
|
5126
|
+
* caller-supplied path instead of discovered by DFS. `null` when `path`
|
|
5127
|
+
* doesn't resolve to an array holding at least one non-array object, UNLESS
|
|
5128
|
+
* `path` resolves to a flat (non-array) object itself, in which case that
|
|
5129
|
+
* object is treated as an implicit one-item collection — see {@link
|
|
5130
|
+
* findAllObjectArrayFieldsOrWholeObject}, which resolves a {@link
|
|
5131
|
+
* FoldTarget}'s `chainArrayPath` to exactly this whole-body shape for a
|
|
5132
|
+
* detail-by-id drill/chain response. An {@link ARRAY_WILDCARD_SEGMENT}
|
|
5133
|
+
* segment in `path` flattens across every element of the array reached at
|
|
5134
|
+
* that point — in DFS/outer-array order — instead of indexing into one,
|
|
5135
|
+
* mirroring the `.flatMap` accessor {@link pathToFoldAccessorExpr} emits for
|
|
5136
|
+
* the same segment, so plan resolution and codegen always agree on which
|
|
5137
|
+
* items a fold covers. */
|
|
5138
|
+
function objectItemsAtPath(body, path) {
|
|
5139
|
+
const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
|
|
5140
|
+
if (wildcardIndex === -1) {
|
|
5141
|
+
const value = readValueAtPath(body, path);
|
|
5142
|
+
if (Array.isArray(value)) {
|
|
5143
|
+
const items = value.filter(isObjectArrayItem);
|
|
5144
|
+
return items.length > 0 ? items : null;
|
|
5145
|
+
}
|
|
5146
|
+
return isObjectArrayItem(value) ? [value] : null;
|
|
5147
|
+
}
|
|
5148
|
+
const outer = readValueAtPath(body, path.slice(0, wildcardIndex));
|
|
5149
|
+
if (!Array.isArray(outer))
|
|
5150
|
+
return null;
|
|
5151
|
+
const after = path.slice(wildcardIndex + 1);
|
|
5152
|
+
const items = outer.flatMap((element) => objectItemsAtPath(element, after) ?? []);
|
|
5153
|
+
return items.length > 0 ? items : null;
|
|
5154
|
+
}
|
|
5155
|
+
/**
|
|
5156
|
+
* Builds a {@link FoldPlan} from a flow-declared {@link FoldReturnSpec}, so a
|
|
5157
|
+
* site author can express a fold the structural heuristic misses.
|
|
5158
|
+
*
|
|
5159
|
+
* Returns `null` — the same null-safe contract as
|
|
5160
|
+
* {@link detectDrillDownFoldPlan} — when `endpointPattern` is not a valid
|
|
5161
|
+
* regex, when `resultsPath` resolves to no object array on any action, when
|
|
5162
|
+
* no strictly-later action's URL matches `endpointPattern`, or when neither
|
|
5163
|
+
* the matched drill-down's own response nor any later chained hop off of it
|
|
5164
|
+
* holds an object array or flat object (the emitter folds `foldMatches[0]`
|
|
5165
|
+
* out of that collection, so a plan without one has nothing to merge).
|
|
5166
|
+
*/
|
|
5167
|
+
/** Same value set as {@link collectRequestStringValues}, plus every request
|
|
5168
|
+
* header value — a flow-declared `foldReturn` exists specifically to cover
|
|
5169
|
+
* joins the structural heuristic can't see (most notably a value threaded
|
|
5170
|
+
* through a request HEADER), so matching a spec's `joinFields` against the
|
|
5171
|
+
* drill capture must search headers even though the structural heuristic
|
|
5172
|
+
* deliberately doesn't (see {@link collectRequestStringValues}'s docstring). */
|
|
5173
|
+
function collectRequestValuesIncludingHeaders(capture) {
|
|
5174
|
+
const values = collectRequestStringValues(capture);
|
|
5175
|
+
for (const v of Object.values(capture.requestHeaders))
|
|
5176
|
+
values.add(v);
|
|
5177
|
+
return values;
|
|
5178
|
+
}
|
|
5179
|
+
/** Finds which of `primaryItems` the drill call actually captured, by
|
|
5180
|
+
* checking every field named in `joinFields` against the drill request's
|
|
5181
|
+
* full value set (URL, body, and headers) — the item matches only when ALL
|
|
5182
|
+
* join fields resolve, since a composite join (e.g. `accountId` + `region`)
|
|
5183
|
+
* is only a real match when every field lines up together. Returns `null`
|
|
5184
|
+
* (never a guessed index) when no item fully matches. */
|
|
5185
|
+
function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCapture) {
|
|
5186
|
+
const requestValues = collectRequestValuesIncludingHeaders(drillCapture);
|
|
5187
|
+
if (requestValues.size === 0)
|
|
5188
|
+
return null;
|
|
5189
|
+
const matchedIndex = primaryItems.findIndex((item) => joinFields.every((field) => {
|
|
5190
|
+
const value = readValueAtPath(item, field.split("."));
|
|
5191
|
+
return ((typeof value === "string" && value.length > 0 && requestValues.has(value)) ||
|
|
5192
|
+
(typeof value === "number" && requestValues.has(String(value))) ||
|
|
5193
|
+
(typeof value === "boolean" && requestValues.has(String(value))));
|
|
5194
|
+
}));
|
|
5195
|
+
return matchedIndex === -1 ? null : matchedIndex;
|
|
5196
|
+
}
|
|
5197
|
+
/** Like {@link resolveSpecMatchedPrimaryItemIndex}, but also looks upstream of
|
|
5198
|
+
* `drillStepIndex` for the join key when `drillStepIndex`'s own request
|
|
5199
|
+
* doesn't carry it — a `foldReturn` spec's `endpointPattern` naturally names
|
|
5200
|
+
* the chain TERMINAL (the response actually holding the data an author wants
|
|
5201
|
+
* folded), not the opaque entry hop that carries the join key onward (e.g.
|
|
5202
|
+
* as a header-threaded token). Walks every earlier step back to
|
|
5203
|
+
* `primaryStepIndex`, in reverse so the closest (least ambiguous) candidate
|
|
5204
|
+
* wins first, and accepts one only once {@link computeFoldChain} confirms it
|
|
5205
|
+
* actually chains FORWARD to `drillStepIndex` — otherwise an unrelated
|
|
5206
|
+
* earlier step matching the join key by coincidence could hijack the fold.
|
|
5207
|
+
* Returns the resolved `entryIndex` alongside the matched item index so the
|
|
5208
|
+
* caller can build the fold's `chain` starting from the step that ACTUALLY
|
|
5209
|
+
* carries the join key, not from `drillStepIndex` — a chain built from
|
|
5210
|
+
* `drillStepIndex` alone would never include this upstream entry hop, so it
|
|
5211
|
+
* would never be re-executed (header-parameterized) per primary item at
|
|
5212
|
+
* runtime. */
|
|
5213
|
+
function resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, joinFields, primaryStepIndex, drillStepIndex) {
|
|
5214
|
+
for (let entryIndex = drillStepIndex; entryIndex > primaryStepIndex; entryIndex--) {
|
|
5215
|
+
const matched = resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, actions[entryIndex].capture);
|
|
5216
|
+
if (matched === null)
|
|
5217
|
+
continue;
|
|
5218
|
+
if (entryIndex === drillStepIndex)
|
|
5219
|
+
return { entryIndex, primaryMatchedItemIndex: matched };
|
|
5220
|
+
const { chain } = computeFoldChain(actions, entryIndex, []);
|
|
5221
|
+
if (chain.includes(drillStepIndex))
|
|
5222
|
+
return { entryIndex, primaryMatchedItemIndex: matched };
|
|
3860
5223
|
}
|
|
3861
5224
|
return null;
|
|
3862
5225
|
}
|
|
5226
|
+
/**
|
|
5227
|
+
* Compiles a flow-declared {@link FoldReturnSpec.endpointPattern} into a
|
|
5228
|
+
* capture predicate, or a predicate that always returns `false` when `spec`
|
|
5229
|
+
* is `null` or its pattern isn't a valid regex — the same null-safe
|
|
5230
|
+
* try/catch shape {@link buildFoldPlanFromSpec} already applied inline,
|
|
5231
|
+
* shared here so the action-sequence extractors can admit a spec-matched
|
|
5232
|
+
* drill-down capture under the identical rule that later resolves its fold
|
|
5233
|
+
* plan, instead of dropping it before the fold pipeline ever sees it.
|
|
5234
|
+
*/
|
|
5235
|
+
function compileFoldReturnEndpointMatcher(spec) {
|
|
5236
|
+
if (spec === null)
|
|
5237
|
+
return () => false;
|
|
5238
|
+
const endpointRx = (() => {
|
|
5239
|
+
try {
|
|
5240
|
+
return new RegExp(spec.endpointPattern);
|
|
5241
|
+
}
|
|
5242
|
+
catch {
|
|
5243
|
+
return null;
|
|
5244
|
+
}
|
|
5245
|
+
})();
|
|
5246
|
+
if (endpointRx === null)
|
|
5247
|
+
return () => false;
|
|
5248
|
+
return (capture) => endpointRx.test(capture.url);
|
|
5249
|
+
}
|
|
5250
|
+
/**
|
|
5251
|
+
* A capture predicate matching whichever capture actually holds
|
|
5252
|
+
* {@link FoldReturnSpec.resultsPath}'s object array — the flow's own PRIMARY
|
|
5253
|
+
* results source, as opposed to {@link compileFoldReturnEndpointMatcher}'s
|
|
5254
|
+
* `endpointPattern` match (the drill-down). REST's `extractActionSequence`
|
|
5255
|
+
* never needs this: a REST primary is a POST/non-GET and is admitted
|
|
5256
|
+
* unconditionally regardless of `foldReturnSpec`. GraphQL's primary is
|
|
5257
|
+
* always a `query`, and `extractGraphQLActionSequence` drops every
|
|
5258
|
+
* non-mutation capture by default, so without this predicate a declared
|
|
5259
|
+
* `foldReturnSpec` would admit only the drill-down capture and never the
|
|
5260
|
+
* read op whose response the drill-down folds onto — leaving
|
|
5261
|
+
* `buildFoldPlanFromSpec` with no `primaryStepIndex` to resolve against.
|
|
5262
|
+
*/
|
|
5263
|
+
function compileFoldReturnResultsMatcher(spec) {
|
|
5264
|
+
if (spec === null)
|
|
5265
|
+
return () => false;
|
|
5266
|
+
const resultsPath = spec.resultsPath.split(".");
|
|
5267
|
+
return (capture) => objectItemsAtPath(capture.responseBody, resultsPath) !== null;
|
|
5268
|
+
}
|
|
5269
|
+
function buildFoldPlanFromSpec(actions, spec) {
|
|
5270
|
+
const primaryArrayPath = spec.resultsPath.split(".");
|
|
5271
|
+
const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
|
|
5272
|
+
let freshestPlan = null;
|
|
5273
|
+
for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
|
|
5274
|
+
const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
|
|
5275
|
+
if (!primaryItems)
|
|
5276
|
+
continue;
|
|
5277
|
+
for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
|
|
5278
|
+
const drill = actions[drillStepIndex];
|
|
5279
|
+
if (!matchesFoldReturnEndpoint(drill.capture))
|
|
5280
|
+
continue;
|
|
5281
|
+
// Widened to a flat (non-array) object response the same way the
|
|
5282
|
+
// structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
|
|
5283
|
+
// an explicit foldReturn declaration must be able to express a
|
|
5284
|
+
// detail-by-id drill response exactly like the case the heuristic
|
|
5285
|
+
// detects on its own, not just an array field.
|
|
5286
|
+
// Falls through with an empty `[]` path baseline — rather than
|
|
5287
|
+
// bailing here — when the matched drill step's own response holds no
|
|
5288
|
+
// object-array/flat-object candidate, so an intermediate hop that
|
|
5289
|
+
// merely threads a value onward (holding no foldable data itself)
|
|
5290
|
+
// still lets computeFoldChain walk to a later chained step that DOES
|
|
5291
|
+
// hold the real per-item data, exactly as the structural heuristic
|
|
5292
|
+
// now does (see detectDrillDownFoldPlan). Validated after the chain
|
|
5293
|
+
// resolves, below, since `[]` is a valid empty baseline for
|
|
5294
|
+
// computeFoldChain but not a valid final drillArrayPath on its own.
|
|
5295
|
+
const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, primaryStepIndex, drillStepIndex);
|
|
5296
|
+
if (matchResult === null)
|
|
5297
|
+
continue;
|
|
5298
|
+
const { entryIndex, primaryMatchedItemIndex } = matchResult;
|
|
5299
|
+
// Rooted at `entryIndex` — the step whose request ACTUALLY carries the
|
|
5300
|
+
// join key — not `drillStepIndex`, so an upstream entry hop the join
|
|
5301
|
+
// key was only resolvable through (e.g. a header-threaded token) is
|
|
5302
|
+
// itself part of `chain` and gets re-executed (join-parameterized) per
|
|
5303
|
+
// primary item at runtime, same as every structurally-detected chain.
|
|
5304
|
+
// `drillResultsPath`, when given, still targets `drillStepIndex`'s own
|
|
5305
|
+
// response specifically (the endpoint the spec names); it is otherwise
|
|
5306
|
+
// left for computeFoldChain's own forward richness walk to resolve,
|
|
5307
|
+
// exactly as it does for every step beyond the chain's entry.
|
|
5308
|
+
const drillArrayPath = (() => {
|
|
5309
|
+
if (entryIndex !== drillStepIndex) {
|
|
5310
|
+
return (findObjectArrayFieldOrWholeObject(actions[entryIndex].capture.responseBody)?.path ??
|
|
5311
|
+
null);
|
|
5312
|
+
}
|
|
5313
|
+
if (spec.drillResultsPath === undefined) {
|
|
5314
|
+
return findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null;
|
|
5315
|
+
}
|
|
5316
|
+
const path = spec.drillResultsPath.split(".");
|
|
5317
|
+
return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
|
|
5318
|
+
})();
|
|
5319
|
+
const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, entryIndex, drillArrayPath ?? []);
|
|
5320
|
+
// The chain's resolved terminal must actually hold foldable data —
|
|
5321
|
+
// an intermediate drill step with neither its own candidate NOR a
|
|
5322
|
+
// later chained step that resolves one has nothing to merge, so it
|
|
5323
|
+
// is skipped exactly as the pre-chain null check used to skip it.
|
|
5324
|
+
if (!objectItemsAtPath(actions[chainTerminalIndex].capture.responseBody, chainArrayPath)) {
|
|
5325
|
+
continue;
|
|
5326
|
+
}
|
|
5327
|
+
freshestPlan = {
|
|
5328
|
+
primaryStepIndex,
|
|
5329
|
+
primaryArrayPath,
|
|
5330
|
+
targets: [
|
|
5331
|
+
{
|
|
5332
|
+
joinFields: spec.joinFields,
|
|
5333
|
+
drillStepIndex: entryIndex,
|
|
5334
|
+
drillArrayPath: drillArrayPath ?? [],
|
|
5335
|
+
primaryMatchedItemIndex,
|
|
5336
|
+
chain,
|
|
5337
|
+
chainArrayPath,
|
|
5338
|
+
chainTerminalIndex,
|
|
5339
|
+
},
|
|
5340
|
+
],
|
|
5341
|
+
};
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
return freshestPlan;
|
|
5345
|
+
}
|
|
5346
|
+
/**
|
|
5347
|
+
* Unions a flow-declared `foldReturn` spec's drill-down target into the
|
|
5348
|
+
* structurally-detected plan for the SAME primary array (matched by
|
|
5349
|
+
* `primaryStepIndex` and `primaryArrayPath`), so a spec declaring an
|
|
5350
|
+
* independent target the heuristic missed is not silently discarded just
|
|
5351
|
+
* because the heuristic already resolved something for that primary. A spec
|
|
5352
|
+
* whose own primary/drill pair is entirely independent of every structural
|
|
5353
|
+
* plan — its `primaryStepIndex` and its target chains touch no index any
|
|
5354
|
+
* structural plan already consumes — is appended as a brand-new plan
|
|
5355
|
+
* instead. Only a spec whose primary step is itself already consumed by an
|
|
5356
|
+
* unrelated structural plan's own chain (not its own primary) is left alone,
|
|
5357
|
+
* to avoid folding onto a step that plan already depends on. A spec
|
|
5358
|
+
* re-declaring a `drillStepIndex` the heuristic already found (for the SAME
|
|
5359
|
+
* primary) is skipped, not duplicated.
|
|
5360
|
+
*/
|
|
5361
|
+
function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec) {
|
|
5362
|
+
if (foldReturnSpec === null)
|
|
5363
|
+
return [...structuralPlans];
|
|
5364
|
+
const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec);
|
|
5365
|
+
if (specPlan === null)
|
|
5366
|
+
return [...structuralPlans];
|
|
5367
|
+
const samePrimaryPlan = structuralPlans.find((plan) => plan.primaryStepIndex === specPlan.primaryStepIndex &&
|
|
5368
|
+
JSON.stringify(plan.primaryArrayPath) === JSON.stringify(specPlan.primaryArrayPath));
|
|
5369
|
+
if (samePrimaryPlan !== undefined) {
|
|
5370
|
+
return structuralPlans.map((plan) => {
|
|
5371
|
+
if (plan !== samePrimaryPlan)
|
|
5372
|
+
return plan;
|
|
5373
|
+
const existingDrillStepIndexes = new Set(plan.targets.map((target) => target.drillStepIndex));
|
|
5374
|
+
const newTargets = specPlan.targets.filter((target) => !existingDrillStepIndexes.has(target.drillStepIndex));
|
|
5375
|
+
return newTargets.length === 0
|
|
5376
|
+
? plan
|
|
5377
|
+
: { ...plan, targets: [...plan.targets, ...newTargets] };
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
5380
|
+
// Keyed by the (primaryStepIndex, primaryArrayPath) pair, not
|
|
5381
|
+
// primaryStepIndex alone — a structural plan only ever consumed ITS OWN
|
|
5382
|
+
// array on that step, not the whole step. A spec whose resultsPath names
|
|
5383
|
+
// a second, structurally-undetected array on that exact same primary step
|
|
5384
|
+
// has already been proven independent by the samePrimaryPlan lookup above
|
|
5385
|
+
// (its primaryArrayPath differs from every structural plan's), so keying
|
|
5386
|
+
// solely on the step index would wrongly treat it as already consumed and
|
|
5387
|
+
// silently drop it.
|
|
5388
|
+
const consumedIndices = new Set();
|
|
5389
|
+
const consumedPrimarySteps = new Set();
|
|
5390
|
+
for (const plan of structuralPlans) {
|
|
5391
|
+
consumedPrimarySteps.add(`${plan.primaryStepIndex}:${JSON.stringify(plan.primaryArrayPath)}`);
|
|
5392
|
+
for (const target of plan.targets) {
|
|
5393
|
+
for (const chainIndex of target.chain)
|
|
5394
|
+
consumedIndices.add(chainIndex);
|
|
5395
|
+
}
|
|
5396
|
+
}
|
|
5397
|
+
const specConsumesOnlyItsOwnIndices = !consumedIndices.has(specPlan.primaryStepIndex) &&
|
|
5398
|
+
!consumedPrimarySteps.has(`${specPlan.primaryStepIndex}:${JSON.stringify(specPlan.primaryArrayPath)}`) &&
|
|
5399
|
+
specPlan.targets.every((target) => target.chain.every((chainIndex) => !consumedIndices.has(chainIndex)));
|
|
5400
|
+
return specConsumesOnlyItsOwnIndices ? [...structuralPlans, specPlan] : [...structuralPlans];
|
|
5401
|
+
}
|
|
5402
|
+
/**
|
|
5403
|
+
* The single fold-plan entry point: {@link detectDrillDownFoldPlan}'s
|
|
5404
|
+
* structural heuristic first, falling back to a flow-declared `foldReturn`
|
|
5405
|
+
* spec when the heuristic finds nothing. `emitMultiStepExecuteHttp` and
|
|
5406
|
+
* `selectEffectiveResponseBody` MUST both resolve through this rather than
|
|
5407
|
+
* calling the detector directly, or the emitted `executeHttp` and its
|
|
5408
|
+
* inferred schema would describe different calls (see
|
|
5409
|
+
* {@link selectEffectiveResponseBody}'s own docstring).
|
|
5410
|
+
*
|
|
5411
|
+
* A multipart step anywhere in a target's fold chain disqualifies only that
|
|
5412
|
+
* target: the fold loop re-issues EVERY chain step's request per item by
|
|
5413
|
+
* re-keying its rendered JSON request template (not just the immediate drill
|
|
5414
|
+
* step's), and a raw `FormData` upload has no such template to re-key — so
|
|
5415
|
+
* that target falls back to ordinary single-call emission instead of
|
|
5416
|
+
* emitting a broken loop, while any other target on the same primary that
|
|
5417
|
+
* has no multipart step in its chain still folds normally. A plan is dropped
|
|
5418
|
+
* from the returned array only when EVERY target for its primary is
|
|
5419
|
+
* multipart-disqualified, leaving nothing left to fold.
|
|
5420
|
+
*
|
|
5421
|
+
* {@link detectDrillDownFoldPlan} can find more than one independent
|
|
5422
|
+
* primary/drill-down pair in a single action sequence, so this returns every
|
|
5423
|
+
* plan that survives multipart disqualification, letting every downstream
|
|
5424
|
+
* emitter/shape-inference caller fold each of them.
|
|
5425
|
+
*/
|
|
5426
|
+
/**
|
|
5427
|
+
* Every value ACTUALLY threaded from one dependent-drill-down chain hop's
|
|
5428
|
+
* response into a LATER chain hop's own request/headers — the same overlap
|
|
5429
|
+
* `computeFoldChain`'s `dependsOnChain` check already computes to decide a
|
|
5430
|
+
* step belongs in the chain at all — across every fold target
|
|
5431
|
+
* `detectDrillDownFoldPlan` / `buildFoldPlanFromSpec` resolve. These are the
|
|
5432
|
+
* values `indexStateValues` must index as producible state regardless of
|
|
5433
|
+
* `MIN_STATE_VALUE_LENGTH`, so `compileActionSteps` can thread a
|
|
5434
|
+
* chain-produced join value into the next hop's request even when that
|
|
5435
|
+
* value is a short one (e.g. a bare numeric status token).
|
|
5436
|
+
*
|
|
5437
|
+
* Deliberately NOT "every leaf value on a chain hop's response" — a chain
|
|
5438
|
+
* terminal's response commonly ECHOES the primary item's own join field
|
|
5439
|
+
* back (e.g. `sku` on the folded record), and that echoed value is a
|
|
5440
|
+
* PRIMARY-ITEM field the fold loop's own per-item render already threads
|
|
5441
|
+
* literally (see {@link findThreadedJoinFields}'s docstring); sweeping it
|
|
5442
|
+
* into state-threading's single-earliest-origin index would collapse every
|
|
5443
|
+
* item's distinct value onto whichever item's capture was indexed first.
|
|
5444
|
+
* Restricting to values that themselves reappear in a STRICTLY LATER chain
|
|
5445
|
+
* hop's own request — real cross-call threading, not an echo sitting still
|
|
5446
|
+
* in one response — keeps this exact to the shape state-threading is
|
|
5447
|
+
* actually needed for.
|
|
5448
|
+
*
|
|
5449
|
+
* Runs directly off raw actions (not `resolveFoldPlan`, which needs
|
|
5450
|
+
* `isMultipart` — unavailable before `compileActionSteps` has run) since
|
|
5451
|
+
* fold-plan DETECTION depends only on each action's `capture`.
|
|
5452
|
+
*/
|
|
5453
|
+
function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
|
|
5454
|
+
const structuralPlans = detectDrillDownFoldPlan(actions);
|
|
5455
|
+
const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
|
|
5456
|
+
const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
|
|
5457
|
+
const values = new Set();
|
|
5458
|
+
for (const plan of plans) {
|
|
5459
|
+
for (const target of plan.targets) {
|
|
5460
|
+
for (let j = 0; j < target.chain.length; j++) {
|
|
5461
|
+
const priorIndex = target.chain[j];
|
|
5462
|
+
const priorCapture = actions[priorIndex]?.capture;
|
|
5463
|
+
if (!priorCapture)
|
|
5464
|
+
continue;
|
|
5465
|
+
const responseValues = collectResponseLeafValues(priorCapture);
|
|
5466
|
+
const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
|
|
5467
|
+
for (let k = j + 1; k < target.chain.length; k++) {
|
|
5468
|
+
const laterCapture = actions[target.chain[k]]?.capture;
|
|
5469
|
+
if (!laterCapture)
|
|
5470
|
+
continue;
|
|
5471
|
+
const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
|
|
5472
|
+
for (const v of responseValues) {
|
|
5473
|
+
if (!echoedValues.has(v) && laterRequestValues.has(v))
|
|
5474
|
+
values.add(v);
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5480
|
+
return values;
|
|
5481
|
+
}
|
|
5482
|
+
function resolveFoldPlan(actions, foldReturnSpec = null) {
|
|
5483
|
+
const structuralPlans = detectDrillDownFoldPlan(actions);
|
|
5484
|
+
const plans = structuralPlans.length > 0
|
|
5485
|
+
? mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
|
|
5486
|
+
: (() => {
|
|
5487
|
+
if (foldReturnSpec === null)
|
|
5488
|
+
return [];
|
|
5489
|
+
const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec);
|
|
5490
|
+
return specPlan === null ? [] : [specPlan];
|
|
5491
|
+
})();
|
|
5492
|
+
return plans.flatMap((plan) => {
|
|
5493
|
+
const targets = plan.targets.filter((target) => !target.chain.some((chainIndex) => actions[chainIndex].isMultipart));
|
|
5494
|
+
return targets.length === 0 ? [] : [{ ...plan, targets }];
|
|
5495
|
+
});
|
|
5496
|
+
}
|
|
5497
|
+
/** Rebuilds `value` with every occurrence of `target` (compared by object
|
|
5498
|
+
* identity) replaced by `replacement`, spreading every ancestor
|
|
5499
|
+
* array/object level so sibling fields and sibling array elements survive
|
|
5500
|
+
* unchanged. Identity, not a path, is what locates the splice point: a
|
|
5501
|
+
* {@link FoldPlan.primaryArrayPath} carrying an {@link ARRAY_WILDCARD_SEGMENT}
|
|
5502
|
+
* names a whole family of per-group arrays, not one splice-able location, so
|
|
5503
|
+
* only the matched item's own object reference (never cloned by
|
|
5504
|
+
* {@link findAllObjectArrayFields}/{@link objectItemsAtPath}, both of which
|
|
5505
|
+
* only filter) pins down where the fold actually lands, regardless of which
|
|
5506
|
+
* group it came from. */
|
|
5507
|
+
function replaceByReference(value, target, replacement) {
|
|
5508
|
+
if (value === target)
|
|
5509
|
+
return replacement;
|
|
5510
|
+
if (Array.isArray(value))
|
|
5511
|
+
return value.map((v) => replaceByReference(v, target, replacement));
|
|
5512
|
+
if (value !== null && typeof value === "object") {
|
|
5513
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
5514
|
+
k,
|
|
5515
|
+
replaceByReference(v, target, replacement),
|
|
5516
|
+
]));
|
|
5517
|
+
}
|
|
5518
|
+
return value;
|
|
5519
|
+
}
|
|
5520
|
+
/**
|
|
5521
|
+
* The value-level counterpart of the per-item loop-and-merge
|
|
5522
|
+
* `emitMultiStepExecuteHttp` emits for a detected {@link FoldPlan}: merges
|
|
5523
|
+
* the captured drill-down item whose `joinFields` match the single captured
|
|
5524
|
+
* primary sample's matched array item — at `primaryMatchedItemIndex`, the
|
|
5525
|
+
* same item `detectDrillDownFoldPlan` built the join key from, not
|
|
5526
|
+
* necessarily index 0 — falling back to the drill array's first item only
|
|
5527
|
+
* when no drill item matches, so schema inference walks the SAME shape the
|
|
5528
|
+
* folded `executeHttp` actually returns at runtime. Reads both arrays at
|
|
5529
|
+
* the plan's OWN paths rather than re-running `findObjectArrayField`, so a
|
|
5530
|
+
* flow-declared `resultsPath` stays authoritative here exactly as it is in
|
|
5531
|
+
* the emitter. Falls back to the unmerged primary body if either capture no
|
|
5532
|
+
* longer resolves an object array — same drift guard as the emitter's own
|
|
5533
|
+
* `throw` at the analogous point, minus the throw, since shape inference
|
|
5534
|
+
* degrading gracefully is preferable to failing a generate run over it.
|
|
5535
|
+
*/
|
|
5536
|
+
function foldResponseBodyForShapeInference(actionSteps, foldPlan, initialBody = actionSteps[foldPlan.primaryStepIndex].capture.responseBody) {
|
|
5537
|
+
return foldPlan.targets.reduce((body, target) => {
|
|
5538
|
+
const drillBody = actionSteps[target.chainTerminalIndex].capture.responseBody;
|
|
5539
|
+
const primaryItems = objectItemsAtPath(body, foldPlan.primaryArrayPath);
|
|
5540
|
+
const drillItems = objectItemsAtPath(drillBody, target.chainArrayPath);
|
|
5541
|
+
const matchedItem = primaryItems?.[target.primaryMatchedItemIndex];
|
|
5542
|
+
const drillMatch = drillItems?.find((d) => target.joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
|
|
5543
|
+
String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems?.[0];
|
|
5544
|
+
if (!primaryItems || !matchedItem || !drillMatch)
|
|
5545
|
+
return body;
|
|
5546
|
+
return replaceByReference(body, matchedItem, { ...matchedItem, ...drillMatch });
|
|
5547
|
+
}, initialBody);
|
|
5548
|
+
}
|
|
3863
5549
|
/**
|
|
3864
5550
|
* Detects whether a read-only GraphQL primary operation exposes a bounded
|
|
3865
5551
|
* paging signal: a total/count field in the captured response alongside a
|
|
@@ -3945,7 +5631,7 @@ function buildPaginatedGqlExecuteHttpBody(opts) {
|
|
|
3945
5631
|
return ` const baseVariables = ${gqlVariablesExpr};
|
|
3946
5632
|
const PAGE_SIZE = ${pageSize};
|
|
3947
5633
|
// Bounded so a paging bug (a total that never converges) can't loop forever.
|
|
3948
|
-
const MAX_PAGES = 50;
|
|
5634
|
+
const MAX_PAGES = payload.maxPages ?? 50;
|
|
3949
5635
|
const itemsById = new Map<string, unknown>();
|
|
3950
5636
|
let skip = 0;
|
|
3951
5637
|
const page = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${queryConstName}, ${variablesForCall});
|
|
@@ -4008,7 +5694,7 @@ function buildContractChecklist(opts) {
|
|
|
4008
5694
|
].filter((line) => line !== "");
|
|
4009
5695
|
}
|
|
4010
5696
|
function emitContractTs(opts) {
|
|
4011
|
-
const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
|
|
5697
|
+
const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
|
|
4012
5698
|
// This is the CLIENT-level schema — createHttpClient's default, and the
|
|
4013
5699
|
// plugin's caller-facing contract (what executeHttp's return value promises
|
|
4014
5700
|
// its own caller). It does NOT validate any individual call in a multi-step
|
|
@@ -4033,6 +5719,7 @@ function emitContractTs(opts) {
|
|
|
4033
5719
|
// successful return IS a real signal — z.unknown() would be dishonest
|
|
4034
5720
|
// in the other direction, hiding a field the flow can actually promise.
|
|
4035
5721
|
const conditionalFieldNames = gql && gqlQuery ? collectConditionalGraphQLFieldNames(gqlQuery) : undefined;
|
|
5722
|
+
const aggregateUnitBasisFindingsByPath = groupAggregateUnitBasisFindingsByPath(responseBodySamples);
|
|
4036
5723
|
const responseSchemaExpr = omitExecuteHttp && isSubmissionFlow
|
|
4037
5724
|
? `z.object({ verified: z.boolean() })`
|
|
4038
5725
|
: omitExecuteHttp
|
|
@@ -4040,6 +5727,7 @@ function emitContractTs(opts) {
|
|
|
4040
5727
|
: inferZodSchemaFromSamples(responseBodySamples, 0, "", {
|
|
4041
5728
|
conditionalFieldNames,
|
|
4042
5729
|
looseServerResponse: true,
|
|
5730
|
+
aggregateUnitBasisFindingsByPath,
|
|
4043
5731
|
});
|
|
4044
5732
|
// Multi-step flows that include a multipart upload need the binary asset
|
|
4045
5733
|
// on the payload. ApplicantContactSchema (via ApplicantResumeSchema) already
|
|
@@ -4068,6 +5756,12 @@ function emitContractTs(opts) {
|
|
|
4068
5756
|
const basePayloadSchemaExpr = inputBody
|
|
4069
5757
|
? `ApplicantContactSchema`
|
|
4070
5758
|
: `z.object({\n query: z.string().min(1),\n})`;
|
|
5759
|
+
// Only the single-endpoint GraphQL read path (a real primary operation, no
|
|
5760
|
+
// multi-step flow) is a candidate for a paging signal — multiStepBody
|
|
5761
|
+
// already owns its own per-call semantics.
|
|
5762
|
+
const paginationSignal = !multiStepBody && gql && gqlOperationName
|
|
5763
|
+
? detectPaginationSignal(responseBody, gqlVariables)
|
|
5764
|
+
: null;
|
|
4071
5765
|
// Every field source below (the base extend's own keys, form-schema
|
|
4072
5766
|
// discovery, browser-flow splicing, option/raw-option enums, additional
|
|
4073
5767
|
// body keys, and structured keys) is merged into a SINGLE `.extend({...})`
|
|
@@ -4080,6 +5774,13 @@ function emitContractTs(opts) {
|
|
|
4080
5774
|
const addExtendField = (name, line) => {
|
|
4081
5775
|
extendFields.set(name, line);
|
|
4082
5776
|
};
|
|
5777
|
+
// A detected bounded-paging signal means buildPaginatedGqlExecuteHttpBody
|
|
5778
|
+
// will emit a loop bounded by MAX_PAGES — expose that bound as a caller-
|
|
5779
|
+
// overridable payload field, mirroring how PAGE_SIZE is already sourced
|
|
5780
|
+
// from the detected signal.
|
|
5781
|
+
if (paginationSignal) {
|
|
5782
|
+
addExtendField("maxPages", " maxPages: z.number().int().positive().optional(),");
|
|
5783
|
+
}
|
|
4083
5784
|
// The base extend's own keys — submission flows only.
|
|
4084
5785
|
if (inputBody) {
|
|
4085
5786
|
addExtendField("Email", " Email: z.email(),");
|
|
@@ -4240,6 +5941,25 @@ function emitContractTs(opts) {
|
|
|
4240
5941
|
// that collides with the base extend's own Email/ClickUrl/Answers) collapses
|
|
4241
5942
|
// to its last-declared line, rather than becoming a second, dupe-prone
|
|
4242
5943
|
// `.extend()` call chained onto the schema.
|
|
5944
|
+
// A field whose name matches (case-insensitively, the same convention
|
|
5945
|
+
// renderGqlVariablesExpr uses) a declared GraphQL variable that no capture
|
|
5946
|
+
// ever populated has no wiring target in executeHttp — it would replay the
|
|
5947
|
+
// captured frozen value regardless of what the caller sends. Downgrading it
|
|
5948
|
+
// to `.optional()` here, at the single merge point every source funnels
|
|
5949
|
+
// through, keeps the schema honest without special-casing any one source.
|
|
5950
|
+
// Email/ClickUrl/Answers are the public contract every submission-flow
|
|
5951
|
+
// plugin must declare unconditionally (see the basePayloadSchemaExpr
|
|
5952
|
+
// comment above) — a GraphQL mutation that happens to declare an
|
|
5953
|
+
// unpopulated variable with a matching name (e.g. `$email`) must not
|
|
5954
|
+
// downgrade that required base field.
|
|
5955
|
+
const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
|
|
5956
|
+
for (const [fieldName, line] of extendFields) {
|
|
5957
|
+
if (inputBody && baseContractFieldNames.has(fieldName))
|
|
5958
|
+
continue;
|
|
5959
|
+
if (unpopulatedDeclaredVariables.some((name) => name.toLowerCase() === fieldName.toLowerCase())) {
|
|
5960
|
+
extendFields.set(fieldName, line.replace(/,\s*$/, ".optional(),"));
|
|
5961
|
+
}
|
|
5962
|
+
}
|
|
4243
5963
|
const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
|
|
4244
5964
|
const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
|
|
4245
5965
|
// basePayloadSchemaExpr's own Answers field always wraps in
|
|
@@ -4266,6 +5986,12 @@ function emitContractTs(opts) {
|
|
|
4266
5986
|
const caseInsensitiveHeadersImport = hasMultipartStep && !omitExecuteHttp
|
|
4267
5987
|
? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
|
|
4268
5988
|
: "";
|
|
5989
|
+
// emitMultiStepExecuteHttp emits a call to this helper whenever more than
|
|
5990
|
+
// one fold plan resolves — the generated plugin package can't reach into
|
|
5991
|
+
// recon-generate.ts's own module scope, so it must import it separately.
|
|
5992
|
+
const mergeFoldedPrimaryBodiesImport = multiStepBody?.includes("mergeFoldedPrimaryBodies(") === true
|
|
5993
|
+
? `import { mergeFoldedPrimaryBodies } from "${ENGINE_PKG}/lib/merge-folded-primary-bodies";\n`
|
|
5994
|
+
: "";
|
|
4269
5995
|
// Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
|
|
4270
5996
|
// the generated file on first lint:fix.
|
|
4271
5997
|
const headersLiteral = Object.entries(baseHeaders)
|
|
@@ -4315,12 +6041,6 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
4315
6041
|
const gqlVariablesExpr = gqlOperationName
|
|
4316
6042
|
? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
|
|
4317
6043
|
: "{ q: payload.query }";
|
|
4318
|
-
// Only the single-endpoint GraphQL read path (a real primary operation, no
|
|
4319
|
-
// multi-step flow) is a candidate for a paging signal — multiStepBody
|
|
4320
|
-
// already owns its own per-call semantics.
|
|
4321
|
-
const paginationSignal = !multiStepBody && gql && gqlOperationName
|
|
4322
|
-
? detectPaginationSignal(responseBody, gqlVariables)
|
|
4323
|
-
: null;
|
|
4324
6044
|
const executeHttpBody = multiStepBody
|
|
4325
6045
|
? multiStepBody
|
|
4326
6046
|
: paginationSignal
|
|
@@ -4414,7 +6134,7 @@ ${executeHttpBody}
|
|
|
4414
6134
|
|
|
4415
6135
|
${bottleneckImport}import { z } from "zod/v4";
|
|
4416
6136
|
|
|
4417
|
-
${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${multipartBoolImport}${clientImport}
|
|
6137
|
+
${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
|
|
4418
6138
|
import type { BrowserSession } from "${ENGINE_PKG}/scraper/session";
|
|
4419
6139
|
import type { SitePlugin, SitePluginContext, SitePluginResult } from "${ENGINE_PKG}/site-plugin";
|
|
4420
6140
|
import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
|
|
@@ -4432,7 +6152,7 @@ ${internalRequestReferenceBlock}${queryConst}${gqlCacheBlock}${fixtureComments}
|
|
|
4432
6152
|
${pluginDocComment}
|
|
4433
6153
|
export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
4434
6154
|
meta: {
|
|
4435
|
-
siteId: ${JSON.stringify(siteId)}
|
|
6155
|
+
siteId: ${JSON.stringify(siteId)},${displayName !== undefined ? `\n displayName: ${JSON.stringify(displayName)},` : ""}
|
|
4436
6156
|
bodySchema: ${pascal}PayloadSchema,
|
|
4437
6157
|
responseSchema: ${pascal}ResponseSchema,
|
|
4438
6158
|
defaultBaseUrl: ${JSON.stringify(baseUrl)},
|
|
@@ -5002,15 +6722,29 @@ async function main() {
|
|
|
5002
6722
|
return [];
|
|
5003
6723
|
}
|
|
5004
6724
|
})();
|
|
5005
|
-
const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern } = (() => {
|
|
6725
|
+
const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern, displayName, foldReturnSpec, } = (() => {
|
|
6726
|
+
const flowFileContents = (() => {
|
|
6727
|
+
try {
|
|
6728
|
+
return (0, node_fs_1.readFileSync)(flowFile, "utf8");
|
|
6729
|
+
}
|
|
6730
|
+
catch {
|
|
6731
|
+
return null;
|
|
6732
|
+
}
|
|
6733
|
+
})();
|
|
6734
|
+
// Parsed off the raw bytes, independently of the steps shape below, so a
|
|
6735
|
+
// valid `foldReturn` still resolves when the rest of the flow file is
|
|
6736
|
+
// degenerate — the two declarations fail independently.
|
|
6737
|
+
const foldReturnSpec = flowFileContents === null ? null : parseFoldReturnSpec(flowFileContents);
|
|
5006
6738
|
try {
|
|
5007
|
-
const raw = JSON.parse(
|
|
6739
|
+
const raw = flowFileContents === null ? null : JSON.parse(flowFileContents);
|
|
5008
6740
|
if (Array.isArray(raw))
|
|
5009
6741
|
return {
|
|
5010
6742
|
flowSteps: raw,
|
|
5011
6743
|
frameSelector: undefined,
|
|
5012
6744
|
submitEndpointPattern: null,
|
|
5013
6745
|
submitBodyPattern: null,
|
|
6746
|
+
displayName: undefined,
|
|
6747
|
+
foldReturnSpec,
|
|
5014
6748
|
};
|
|
5015
6749
|
if (raw !== null &&
|
|
5016
6750
|
typeof raw === "object" &&
|
|
@@ -5022,6 +6756,8 @@ async function main() {
|
|
|
5022
6756
|
frameSelector: obj.frameSelector,
|
|
5023
6757
|
submitEndpointPattern: obj.submitEndpointPattern ?? null,
|
|
5024
6758
|
submitBodyPattern: obj.submitBodyPattern ?? null,
|
|
6759
|
+
displayName: obj.displayName,
|
|
6760
|
+
foldReturnSpec,
|
|
5025
6761
|
};
|
|
5026
6762
|
}
|
|
5027
6763
|
return {
|
|
@@ -5029,6 +6765,8 @@ async function main() {
|
|
|
5029
6765
|
frameSelector: undefined,
|
|
5030
6766
|
submitEndpointPattern: null,
|
|
5031
6767
|
submitBodyPattern: null,
|
|
6768
|
+
displayName: undefined,
|
|
6769
|
+
foldReturnSpec,
|
|
5032
6770
|
};
|
|
5033
6771
|
}
|
|
5034
6772
|
catch {
|
|
@@ -5037,6 +6775,8 @@ async function main() {
|
|
|
5037
6775
|
frameSelector: undefined,
|
|
5038
6776
|
submitEndpointPattern: null,
|
|
5039
6777
|
submitBodyPattern: null,
|
|
6778
|
+
displayName: undefined,
|
|
6779
|
+
foldReturnSpec,
|
|
5040
6780
|
};
|
|
5041
6781
|
}
|
|
5042
6782
|
})();
|
|
@@ -5105,7 +6845,9 @@ async function main() {
|
|
|
5105
6845
|
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
5106
6846
|
// (further down) read the same computed sequence instead of calling the
|
|
5107
6847
|
// extractor twice.
|
|
5108
|
-
const graphqlActionSequence = gql
|
|
6848
|
+
const graphqlActionSequence = gql
|
|
6849
|
+
? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
|
|
6850
|
+
: [];
|
|
5109
6851
|
const primaryGraphQLOperation = gql && graphqlActionSequence.length === 0
|
|
5110
6852
|
? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
|
|
5111
6853
|
: null;
|
|
@@ -5156,7 +6898,7 @@ async function main() {
|
|
|
5156
6898
|
// heuristic extraction finds.
|
|
5157
6899
|
const patternedHeuristicActionCaptures = gql
|
|
5158
6900
|
? graphqlActionSequence
|
|
5159
|
-
: collapseRedundantPatches(extractActionSequence(captures, submitPatterns));
|
|
6901
|
+
: collapseRedundantPatches(extractActionSequence(captures, submitPatterns, foldReturnSpec));
|
|
5160
6902
|
// The same undercount hazard applies one layer below the manifest: a
|
|
5161
6903
|
// flow-declared submitEndpointPattern that matches only one section's URL
|
|
5162
6904
|
// (the natural way to describe "the button that finishes the wizard")
|
|
@@ -5169,8 +6911,8 @@ async function main() {
|
|
|
5169
6911
|
const unfilteredHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
|
|
5170
6912
|
? patternedHeuristicActionCaptures
|
|
5171
6913
|
: gql
|
|
5172
|
-
? extractGraphQLActionSequence(captures, null)
|
|
5173
|
-
: collapseRedundantPatches(extractActionSequence(captures, null));
|
|
6914
|
+
? extractGraphQLActionSequence(captures, null, foldReturnSpec)
|
|
6915
|
+
: collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
|
|
5174
6916
|
const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
|
|
5175
6917
|
if (patternUndercounts) {
|
|
5176
6918
|
logger.info(`submission selection: ignoring submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) as an undercount of the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s))`);
|
|
@@ -5201,8 +6943,8 @@ async function main() {
|
|
|
5201
6943
|
const rawUnfilteredActionCaptures = submitEndpointPattern === null
|
|
5202
6944
|
? null
|
|
5203
6945
|
: gql
|
|
5204
|
-
? extractGraphQLActionSequence(captures, null)
|
|
5205
|
-
: collapseRedundantPatches(extractActionSequence(captures, null));
|
|
6946
|
+
? extractGraphQLActionSequence(captures, null, foldReturnSpec)
|
|
6947
|
+
: collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
|
|
5206
6948
|
// Form-schema detection runs BEFORE state-indexing so the field-id/option-id
|
|
5207
6949
|
// UUIDs can be shielded from indexing — those UUIDs are stable schema
|
|
5208
6950
|
// anchors that T2/T3 substitution depends on remaining literal in body
|
|
@@ -5250,8 +6992,16 @@ async function main() {
|
|
|
5250
6992
|
];
|
|
5251
6993
|
})();
|
|
5252
6994
|
const actionCaptureIndices = new Set(actionCaptures.map((a) => a.index));
|
|
6995
|
+
// Resolved off raw actionCaptures — fold-plan DETECTION depends only on
|
|
6996
|
+
// each action's capture, so this runs before compileActionSteps/
|
|
6997
|
+
// indexStateValues even exist — so a short numeric join value threaded
|
|
6998
|
+
// through a dependent-drill-down chain hop still gets indexed as
|
|
6999
|
+
// producible state (see collectDependentDrillDownChainValues).
|
|
7000
|
+
const dependentDrillDownChainValues = actionCaptures.length > 1
|
|
7001
|
+
? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
|
|
7002
|
+
: new Set();
|
|
5253
7003
|
const stateIndex = actionCaptures.length > 1
|
|
5254
|
-
? indexStateValues(captures, shieldedUuids, actionCaptureIndices)
|
|
7004
|
+
? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
5255
7005
|
: new Map();
|
|
5256
7006
|
const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
|
|
5257
7007
|
const isSubmissionFlow = actionSteps.length > 1;
|
|
@@ -5363,18 +7113,25 @@ async function main() {
|
|
|
5363
7113
|
const multiStepBody = browserFlowOnly
|
|
5364
7114
|
? undefined
|
|
5365
7115
|
: isSubmissionFlow
|
|
5366
|
-
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
|
|
7116
|
+
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
|
|
5367
7117
|
: undefined;
|
|
5368
7118
|
const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
|
|
5369
7119
|
const headerBindings = collectHeaderBindings(actionSteps);
|
|
5370
7120
|
// Shape inference targets the SAME call executeHttp returns — see
|
|
5371
7121
|
// selectEffectiveResponseBody — so the two surfaces can't describe different calls.
|
|
5372
|
-
const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody);
|
|
7122
|
+
const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody, foldReturnSpec);
|
|
7123
|
+
// A declared foldReturn that resolves to no plan is a silent no-op otherwise
|
|
7124
|
+
// — the flow author gets the discarding selectReturnAction path with nothing
|
|
7125
|
+
// in the output saying their declaration never applied.
|
|
7126
|
+
if (foldReturnSpec !== null && resolveFoldPlan(actionSteps, foldReturnSpec).length === 0) {
|
|
7127
|
+
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
|
|
7128
|
+
}
|
|
5373
7129
|
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|
|
5374
7130
|
if (emit === "config") {
|
|
5375
7131
|
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
5376
7132
|
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
5377
7133
|
siteId,
|
|
7134
|
+
displayName,
|
|
5378
7135
|
baseUrl,
|
|
5379
7136
|
flowSteps,
|
|
5380
7137
|
vocabulary,
|
|
@@ -5408,6 +7165,7 @@ async function main() {
|
|
|
5408
7165
|
});
|
|
5409
7166
|
const contractOpts = {
|
|
5410
7167
|
siteId,
|
|
7168
|
+
displayName,
|
|
5411
7169
|
pascal,
|
|
5412
7170
|
baseUrl,
|
|
5413
7171
|
// G1+G2: only the static headers (no baseUrl/tenant-subdomain references)
|
|
@@ -5445,6 +7203,7 @@ async function main() {
|
|
|
5445
7203
|
discoveredStructuredKeys,
|
|
5446
7204
|
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
5447
7205
|
headerBindings,
|
|
7206
|
+
unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
|
|
5448
7207
|
};
|
|
5449
7208
|
const contractCode = emitContractTs(contractOpts);
|
|
5450
7209
|
// Fails loudly rather than shipping a flow that requires a URL field it
|