@enricai/barnacle 1.12.21 → 1.12.23
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/scripts/recon-generate-multicall-fixture.d.ts +61 -0
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +150 -0
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +58 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +494 -59
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -52,8 +52,16 @@ exports.compileActionSteps = compileActionSteps;
|
|
|
52
52
|
exports.collectHeaderBindings = collectHeaderBindings;
|
|
53
53
|
exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
|
|
54
54
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
55
|
+
exports.findAllObjectArrayFields = findAllObjectArrayFields;
|
|
56
|
+
exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
|
|
57
|
+
exports.collectResponseLeafValues = collectResponseLeafValues;
|
|
58
|
+
exports.getScanPrimaryCandidateGroupsCallCountForTest = getScanPrimaryCandidateGroupsCallCountForTest;
|
|
59
|
+
exports.resetScanPrimaryCandidateGroupsCallCountForTest = resetScanPrimaryCandidateGroupsCallCountForTest;
|
|
55
60
|
exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
|
|
56
61
|
exports.parseFoldReturnSpec = parseFoldReturnSpec;
|
|
62
|
+
exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHeaders;
|
|
63
|
+
exports.getFoldPlanResolutionCallCountForTests = getFoldPlanResolutionCallCountForTests;
|
|
64
|
+
exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCountForTests;
|
|
57
65
|
exports.resolveFoldPlan = resolveFoldPlan;
|
|
58
66
|
exports.buildContractChecklist = buildContractChecklist;
|
|
59
67
|
exports.emitContractTs = emitContractTs;
|
|
@@ -903,15 +911,22 @@ function selectReturnAction(steps) {
|
|
|
903
911
|
* clobbering the other. A plan whose folded body isn't a plain object can't
|
|
904
912
|
* be merged meaningfully, so in that case this falls back to the LAST plan's
|
|
905
913
|
* folded body alone, exactly as before this merge was introduced.
|
|
914
|
+
*
|
|
915
|
+
* The `!isSubmissionFlow` short-circuit only applies once no fold plan
|
|
916
|
+
* resolves: a single-primary read flow (isSubmissionFlow false) with a
|
|
917
|
+
* declared `foldReturn` still needs its primary body folded here, or the
|
|
918
|
+
* inferred response shape would omit every field the single-primary
|
|
919
|
+
* getGql/httpClient emission's own fold-merge loop (see `emitContractTs`)
|
|
920
|
+
* adds at runtime.
|
|
906
921
|
*/
|
|
907
922
|
function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody, foldReturnSpec = null) {
|
|
908
|
-
if (!isSubmissionFlow)
|
|
909
|
-
return replayResponseBody;
|
|
910
923
|
const foldPlans = resolveFoldPlan(actionSteps, foldReturnSpec);
|
|
911
924
|
const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
|
|
912
925
|
if (foldPlans.length <= 1) {
|
|
913
926
|
if (lastFoldPlan)
|
|
914
927
|
return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
|
|
928
|
+
if (!isSubmissionFlow)
|
|
929
|
+
return replayResponseBody;
|
|
915
930
|
return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
|
|
916
931
|
}
|
|
917
932
|
// Plans sharing a primaryStepIndex (independent arrays on one primary
|
|
@@ -3653,6 +3668,55 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
|
|
|
3653
3668
|
}
|
|
3654
3669
|
return out;
|
|
3655
3670
|
}
|
|
3671
|
+
/**
|
|
3672
|
+
* Emits the lines that match a fold target's drill-down chain response
|
|
3673
|
+
* against the loop item and merge the match onto it — the last leg of a
|
|
3674
|
+
* fold, shared by {@link emitMultiStepExecuteHttp}'s per-item loop and
|
|
3675
|
+
* `emitContractTs`'s single-primary getGql/httpClient fold-merge loop, so
|
|
3676
|
+
* both emitters describe the exact same match/merge semantics rather than
|
|
3677
|
+
* two copies that could drift apart.
|
|
3678
|
+
*/
|
|
3679
|
+
function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor) {
|
|
3680
|
+
// An empty chainArrayPath means the terminal step's response IS the
|
|
3681
|
+
// implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
|
|
3682
|
+
// / objectItemsAtPath's flat-object branch): the response is a flat
|
|
3683
|
+
// object at runtime, not an array. There is exactly one candidate, so
|
|
3684
|
+
// no join-field match is needed (or even possible against an array
|
|
3685
|
+
// API) — emit a direct object reference instead of the array
|
|
3686
|
+
// `.find()` machinery the multi-item branch below needs.
|
|
3687
|
+
if (target.chainArrayPath.length === 0) {
|
|
3688
|
+
return [
|
|
3689
|
+
` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`,
|
|
3690
|
+
` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
|
|
3691
|
+
];
|
|
3692
|
+
}
|
|
3693
|
+
const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
|
|
3694
|
+
return [
|
|
3695
|
+
` const foldMatches${suffix} = ${foldMatchesExpr};`,
|
|
3696
|
+
` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
|
|
3697
|
+
.map((f) => {
|
|
3698
|
+
const segments = f.split(".");
|
|
3699
|
+
// The drill-down response is a DIFFERENT payload than the
|
|
3700
|
+
// primary item, so it has no obligation to mirror the
|
|
3701
|
+
// primary item's own nesting for the join key (e.g. a
|
|
3702
|
+
// primary item's `identifiers.sku` is typically echoed
|
|
3703
|
+
// back flat, as `sku`, on the drill response). Try the
|
|
3704
|
+
// full nested path first (optional-chained, since an
|
|
3705
|
+
// intermediate segment may not exist on a flat response),
|
|
3706
|
+
// then fall back to the bare last segment.
|
|
3707
|
+
const lastSegment = segments[segments.length - 1];
|
|
3708
|
+
const optionalBracketAccessor = segments
|
|
3709
|
+
.map((segment) => `?.[${JSON.stringify(segment)}]`)
|
|
3710
|
+
.join("");
|
|
3711
|
+
const matchAccessor = segments.length > 1
|
|
3712
|
+
? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
|
|
3713
|
+
: `m[${JSON.stringify(lastSegment)}]`;
|
|
3714
|
+
return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
|
|
3715
|
+
})
|
|
3716
|
+
.join(" && ")}) ?? foldMatches${suffix}[0];`,
|
|
3717
|
+
` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
|
|
3718
|
+
];
|
|
3719
|
+
}
|
|
3656
3720
|
/** Exported for unit testing — lets tests drive the multipart-upload code path directly
|
|
3657
3721
|
* without going through the full emitContractTs pipeline. */
|
|
3658
3722
|
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) {
|
|
@@ -4228,40 +4292,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4228
4292
|
}
|
|
4229
4293
|
}
|
|
4230
4294
|
const terminalStep = actions[target.chainTerminalIndex];
|
|
4231
|
-
|
|
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
|
-
}
|
|
4295
|
+
lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
|
|
4265
4296
|
}
|
|
4266
4297
|
lines.push(` }`, "");
|
|
4267
4298
|
continue;
|
|
@@ -4591,17 +4622,42 @@ const ARRAY_WILDCARD_SEGMENT = "*";
|
|
|
4591
4622
|
* A path segment for an array index the search descended through (to keep
|
|
4592
4623
|
* looking for a nested candidate array) is the {@link ARRAY_WILDCARD_SEGMENT}
|
|
4593
4624
|
* sentinel, never a literal index — see its docstring. */
|
|
4594
|
-
function
|
|
4625
|
+
function findAllObjectArrayFieldsUncached(value, path = []) {
|
|
4595
4626
|
if (value === null || typeof value !== "object")
|
|
4596
4627
|
return [];
|
|
4597
4628
|
if (Array.isArray(value)) {
|
|
4598
4629
|
const objectItems = value.filter(isObjectArrayItem);
|
|
4599
|
-
const nestedCandidates = objectItems.flatMap((item) =>
|
|
4630
|
+
const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFieldsUncached(item, [...path, ARRAY_WILDCARD_SEGMENT]));
|
|
4600
4631
|
return objectItems.length > 0
|
|
4601
4632
|
? [{ path, items: objectItems }, ...nestedCandidates]
|
|
4602
4633
|
: nestedCandidates;
|
|
4603
4634
|
}
|
|
4604
|
-
return Object.entries(value).flatMap(([key, v]) =>
|
|
4635
|
+
return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFieldsUncached(v, [...path, key]));
|
|
4636
|
+
}
|
|
4637
|
+
/** Every distinct top-level `value` object {@link findAllObjectArrayFields}
|
|
4638
|
+
* is invoked on is scanned repeatedly — once per fold-chain candidate/join
|
|
4639
|
+
* disambiguation that re-derives the same response body — so a per-run,
|
|
4640
|
+
* identity-keyed cache lets a given body's whole-tree scan run at most once
|
|
4641
|
+
* regardless of how many callers re-derive it. Keyed on object identity
|
|
4642
|
+
* (never a serialized path/value pair) because captures/response bodies are
|
|
4643
|
+
* never mutated once produced (see this module's fold-plan investigation
|
|
4644
|
+
* notes), so identity alone is a safe, unconditionally correct cache key. */
|
|
4645
|
+
const objectArrayFieldsCache = new WeakMap();
|
|
4646
|
+
/** Memoized entry point for {@link findAllObjectArrayFieldsUncached} — see
|
|
4647
|
+
* {@link objectArrayFieldsCache}. Only the default top-level `path` is
|
|
4648
|
+
* cached (every real call site invokes with the default); a caller passing
|
|
4649
|
+
* an explicit `path` — recursion within the uncached walk itself — bypasses
|
|
4650
|
+
* the cache and hits the underlying scan directly. */
|
|
4651
|
+
function findAllObjectArrayFields(value, path = []) {
|
|
4652
|
+
if (path.length > 0 || value === null || typeof value !== "object") {
|
|
4653
|
+
return findAllObjectArrayFieldsUncached(value, path);
|
|
4654
|
+
}
|
|
4655
|
+
const cached = objectArrayFieldsCache.get(value);
|
|
4656
|
+
if (cached)
|
|
4657
|
+
return cached;
|
|
4658
|
+
const computed = findAllObjectArrayFieldsUncached(value, path);
|
|
4659
|
+
objectArrayFieldsCache.set(value, computed);
|
|
4660
|
+
return computed;
|
|
4605
4661
|
}
|
|
4606
4662
|
/** The first object-array field by DFS/key order — see
|
|
4607
4663
|
* {@link findAllObjectArrayFields}. Every call site that must disambiguate
|
|
@@ -4625,9 +4681,24 @@ function findObjectArrayField(value, path = []) {
|
|
|
4625
4681
|
* more genuine per-item data than a small real nested object-array; a
|
|
4626
4682
|
* caller that just wants the first real array (the common case) is
|
|
4627
4683
|
* unaffected since it still comes first. */
|
|
4684
|
+
const objectArrayFieldsOrWholeObjectCache = new WeakMap();
|
|
4685
|
+
/** Memoized the same way as {@link findAllObjectArrayFields} (see
|
|
4686
|
+
* {@link objectArrayFieldsCache}) — this is the candidate list
|
|
4687
|
+
* {@link selectDisambiguatedCandidate} and {@link buildFoldPlanFromSpec}
|
|
4688
|
+
* re-derive off the SAME responseBody on every chain hop/disambiguation, so
|
|
4689
|
+
* it is exactly as hot a redundant-recompute site as the underlying scan. */
|
|
4628
4690
|
function findAllObjectArrayFieldsOrWholeObject(value, path = []) {
|
|
4691
|
+
if (path.length > 0 || value === null || typeof value !== "object") {
|
|
4692
|
+
const found = findAllObjectArrayFields(value, path);
|
|
4693
|
+
return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
|
|
4694
|
+
}
|
|
4695
|
+
const cached = objectArrayFieldsOrWholeObjectCache.get(value);
|
|
4696
|
+
if (cached)
|
|
4697
|
+
return cached;
|
|
4629
4698
|
const found = findAllObjectArrayFields(value, path);
|
|
4630
|
-
|
|
4699
|
+
const computed = isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
|
|
4700
|
+
objectArrayFieldsOrWholeObjectCache.set(value, computed);
|
|
4701
|
+
return computed;
|
|
4631
4702
|
}
|
|
4632
4703
|
/** The first candidate from {@link findAllObjectArrayFieldsOrWholeObject} —
|
|
4633
4704
|
* the flat-object-aware counterpart of {@link findObjectArrayField}. */
|
|
@@ -4706,6 +4777,62 @@ function* walkItemFieldPaths(item, path = []) {
|
|
|
4706
4777
|
* order the primary response declares them, not sorted. Returns `[]` when no
|
|
4707
4778
|
* field of the item threads into the request at all.
|
|
4708
4779
|
*/
|
|
4780
|
+
/**
|
|
4781
|
+
* Maps every string/numeric/boolean value seen in any action's request URL
|
|
4782
|
+
* or body (see {@link collectRequestStringValues} — deliberately headers-
|
|
4783
|
+
* excluded, unlike {@link buildRequestValueIndex}, matching the structural
|
|
4784
|
+
* heuristic's own header-blind scan) to the ascending list of action indices
|
|
4785
|
+
* whose request carries it. Built once per {@link detectDrillDownFoldPlan}
|
|
4786
|
+
* call and shared across every primary candidate's scan, so
|
|
4787
|
+
* {@link scanPrimaryCandidateGroups} can jump straight to the indices that
|
|
4788
|
+
* could possibly thread one of a primary array's own item values instead of
|
|
4789
|
+
* walking every later action — the O(actions)-per-primary forward scan that
|
|
4790
|
+
* makes the structural heuristic itself O(actions^2) on a large capture set
|
|
4791
|
+
* dominated by same-shaped primary candidates.
|
|
4792
|
+
*/
|
|
4793
|
+
function buildRequestStringValueIndex(actions) {
|
|
4794
|
+
const index = new Map();
|
|
4795
|
+
for (let i = 0; i < actions.length; i++) {
|
|
4796
|
+
for (const value of collectRequestStringValues(actions[i].capture)) {
|
|
4797
|
+
const indices = index.get(value);
|
|
4798
|
+
if (indices)
|
|
4799
|
+
indices.push(i);
|
|
4800
|
+
else
|
|
4801
|
+
index.set(value, [i]);
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
return index;
|
|
4805
|
+
}
|
|
4806
|
+
/** Every string/numeric/boolean field value present anywhere across `items`
|
|
4807
|
+
* — the set of values whose {@link buildRequestStringValueIndex} entries can
|
|
4808
|
+
* possibly thread out of this primary array, used to prune the candidate
|
|
4809
|
+
* drill indices {@link scanPrimaryCandidateGroups} walks instead of
|
|
4810
|
+
* considering every later action index. */
|
|
4811
|
+
function collectItemsFieldValues(items) {
|
|
4812
|
+
const values = new Set();
|
|
4813
|
+
for (const item of items) {
|
|
4814
|
+
for (const { value } of walkItemFieldPaths(item)) {
|
|
4815
|
+
if (typeof value === "string" && value.length > 0)
|
|
4816
|
+
values.add(value);
|
|
4817
|
+
else if (typeof value === "number" || typeof value === "boolean")
|
|
4818
|
+
values.add(String(value));
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
return values;
|
|
4822
|
+
}
|
|
4823
|
+
/** Ascending, deduped action indices strictly greater than `afterIndex`
|
|
4824
|
+
* whose request carries at least one of `values` — see
|
|
4825
|
+
* {@link collectItemsFieldValues} / {@link buildRequestStringValueIndex}. */
|
|
4826
|
+
function collectCandidateIndicesAscending(requestStringValueIndex, values, afterIndex) {
|
|
4827
|
+
const candidates = new Set();
|
|
4828
|
+
for (const value of values) {
|
|
4829
|
+
for (const index of requestStringValueIndex.get(value) ?? []) {
|
|
4830
|
+
if (index > afterIndex)
|
|
4831
|
+
candidates.add(index);
|
|
4832
|
+
}
|
|
4833
|
+
}
|
|
4834
|
+
return [...candidates].sort((a, b) => a - b);
|
|
4835
|
+
}
|
|
4709
4836
|
function findThreadedJoinFields(item, drillCapture) {
|
|
4710
4837
|
const requestValues = collectRequestStringValues(drillCapture);
|
|
4711
4838
|
if (requestValues.size === 0)
|
|
@@ -4724,8 +4851,22 @@ function findThreadedJoinFields(item, drillCapture) {
|
|
|
4724
4851
|
* join field. Also walks every response HEADER value, mirroring
|
|
4725
4852
|
* {@link collectRequestValuesIncludingHeaders} on the request side, since a
|
|
4726
4853
|
* 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.
|
|
4854
|
+
* (e.g. a `Location` or custom correlation header) as in the body.
|
|
4855
|
+
* Memoized like {@link objectArrayFieldsCache} — {@link computeFoldChain}'s
|
|
4856
|
+
* inner `dependsOnChain` check re-derives this for every chain member on
|
|
4857
|
+
* every outer loop iteration, making it the single hottest redundant-
|
|
4858
|
+
* recompute site in fold-chain resolution (see this module's fold-plan
|
|
4859
|
+
* investigation notes). */
|
|
4860
|
+
const responseLeafValuesCache = new WeakMap();
|
|
4728
4861
|
function collectResponseLeafValues(capture) {
|
|
4862
|
+
const cached = responseLeafValuesCache.get(capture);
|
|
4863
|
+
if (cached)
|
|
4864
|
+
return cached;
|
|
4865
|
+
const computed = collectResponseLeafValuesUncached(capture);
|
|
4866
|
+
responseLeafValuesCache.set(capture, computed);
|
|
4867
|
+
return computed;
|
|
4868
|
+
}
|
|
4869
|
+
function collectResponseLeafValuesUncached(capture) {
|
|
4729
4870
|
const values = new Set();
|
|
4730
4871
|
for (const { value } of walkAllPrimitiveLeaves(capture.responseBody)) {
|
|
4731
4872
|
if (typeof value === "string" && value.length > 0)
|
|
@@ -4885,7 +5026,20 @@ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
|
|
|
4885
5026
|
* one array's target chain is never re-claimed as a fresh target thread of
|
|
4886
5027
|
* a different, independent array on the same primary response.
|
|
4887
5028
|
*/
|
|
4888
|
-
|
|
5029
|
+
let scanPrimaryCandidateGroupsCallCount = 0;
|
|
5030
|
+
/** Test-only instrumentation for asserting `scanPrimaryCandidateGroups`'s
|
|
5031
|
+
* O(actions.length) scan is reused across repeat queries of the same index
|
|
5032
|
+
* (via `detectDrillDownFoldPlan`'s per-index cache) rather than re-run.
|
|
5033
|
+
* Not read by any production path. */
|
|
5034
|
+
function getScanPrimaryCandidateGroupsCallCountForTest() {
|
|
5035
|
+
return scanPrimaryCandidateGroupsCallCount;
|
|
5036
|
+
}
|
|
5037
|
+
/** Test-only counterpart to {@link getScanPrimaryCandidateGroupsCallCountForTest}. */
|
|
5038
|
+
function resetScanPrimaryCandidateGroupsCallCountForTest() {
|
|
5039
|
+
scanPrimaryCandidateGroupsCallCount = 0;
|
|
5040
|
+
}
|
|
5041
|
+
function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices, requestStringValueIndex) {
|
|
5042
|
+
scanPrimaryCandidateGroupsCallCount++;
|
|
4889
5043
|
const primary = actions[primaryIndex];
|
|
4890
5044
|
const primaryCandidates = findAllObjectArrayFields(primary.capture.responseBody);
|
|
4891
5045
|
if (primaryCandidates.length === 0)
|
|
@@ -4899,7 +5053,12 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
|
|
|
4899
5053
|
const consumedIndices = new Set();
|
|
4900
5054
|
for (const primaryArray of primaryCandidates) {
|
|
4901
5055
|
const targets = [];
|
|
4902
|
-
|
|
5056
|
+
// Pruned to the (typically tiny) set of later action indices whose
|
|
5057
|
+
// request could possibly thread one of this array's own item values —
|
|
5058
|
+
// see buildRequestStringValueIndex's docstring — instead of every
|
|
5059
|
+
// index from primaryIndex+1 to the end of actions.
|
|
5060
|
+
const candidateDrillIndices = collectCandidateIndicesAscending(requestStringValueIndex, collectItemsFieldValues(primaryArray.items), primaryIndex);
|
|
5061
|
+
for (const drillIndex of candidateDrillIndices) {
|
|
4903
5062
|
const drill = actions[drillIndex];
|
|
4904
5063
|
if (drill === primary)
|
|
4905
5064
|
continue;
|
|
@@ -4989,11 +5148,40 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
4989
5148
|
// primary's response, not on this later primary's array, so it must
|
|
4990
5149
|
// never be re-claimed as a fresh drill target for a subsequent primary.
|
|
4991
5150
|
const globallyConsumedIndices = new Set();
|
|
5151
|
+
// scanPrimaryCandidateGroups's result for a given index only depends on
|
|
5152
|
+
// `actions` (fixed) and the current contents of `globallyConsumedIndices`,
|
|
5153
|
+
// so it is safe to reuse across repeat queries of the SAME index as long
|
|
5154
|
+
// as the consumed set hasn't grown since it was computed. The freshest-wins
|
|
5155
|
+
// loop below re-queries the same laterIndex once per sibling group on a
|
|
5156
|
+
// re-queried primary, and the outer loop often reaches that very index as
|
|
5157
|
+
// its own primaryIndex shortly after — both hit this cache instead of
|
|
5158
|
+
// repeating the O(actions.length) scan.
|
|
5159
|
+
let consumedVersion = 0;
|
|
5160
|
+
const scanCache = new Map();
|
|
5161
|
+
const scanCacheVersion = new Map();
|
|
5162
|
+
// Built once for the whole detectDrillDownFoldPlan call — see
|
|
5163
|
+
// buildRequestStringValueIndex's docstring.
|
|
5164
|
+
const requestStringValueIndex = buildRequestStringValueIndex(actions);
|
|
5165
|
+
const scanCached = (index) => {
|
|
5166
|
+
const cachedVersion = scanCacheVersion.get(index);
|
|
5167
|
+
if (cachedVersion === consumedVersion)
|
|
5168
|
+
return scanCache.get(index);
|
|
5169
|
+
const result = scanPrimaryCandidateGroups(actions, index, globallyConsumedIndices, requestStringValueIndex);
|
|
5170
|
+
scanCache.set(index, result);
|
|
5171
|
+
scanCacheVersion.set(index, consumedVersion);
|
|
5172
|
+
return result;
|
|
5173
|
+
};
|
|
5174
|
+
const addConsumed = (index) => {
|
|
5175
|
+
if (globallyConsumedIndices.has(index))
|
|
5176
|
+
return;
|
|
5177
|
+
globallyConsumedIndices.add(index);
|
|
5178
|
+
consumedVersion++;
|
|
5179
|
+
};
|
|
4992
5180
|
for (let primaryIndex = 0; primaryIndex < actions.length; primaryIndex++) {
|
|
4993
5181
|
if (globallyConsumedIndices.has(primaryIndex))
|
|
4994
5182
|
continue;
|
|
4995
5183
|
const primary = actions[primaryIndex];
|
|
4996
|
-
const groups =
|
|
5184
|
+
const groups = scanCached(primaryIndex);
|
|
4997
5185
|
if (groups.length === 0)
|
|
4998
5186
|
continue;
|
|
4999
5187
|
const primaryEndpointKey = endpointKey(primary.capture.url);
|
|
@@ -5020,7 +5208,7 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
5020
5208
|
const laterAction = actions[laterIndex];
|
|
5021
5209
|
if (endpointKey(laterAction.capture.url) !== primaryEndpointKey)
|
|
5022
5210
|
continue;
|
|
5023
|
-
const laterGroups =
|
|
5211
|
+
const laterGroups = scanCached(laterIndex);
|
|
5024
5212
|
const laterGroup = laterGroups.find((g) => JSON.stringify(g.primaryArrayPath) === JSON.stringify(freshestGroup.primaryArrayPath));
|
|
5025
5213
|
if (laterGroup === undefined)
|
|
5026
5214
|
continue;
|
|
@@ -5058,10 +5246,10 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
5058
5246
|
// itself is marked consumed once per group pushed (idempotent via
|
|
5059
5247
|
// Set.add), since the step itself is only visited once regardless of
|
|
5060
5248
|
// how many independent array groups it yields.
|
|
5061
|
-
|
|
5249
|
+
addConsumed(freshestIndex);
|
|
5062
5250
|
for (const target of freshestGroup.targets) {
|
|
5063
5251
|
for (const chainIndex of target.chain)
|
|
5064
|
-
|
|
5252
|
+
addConsumed(chainIndex);
|
|
5065
5253
|
}
|
|
5066
5254
|
}
|
|
5067
5255
|
}
|
|
@@ -5170,10 +5358,20 @@ function objectItemsAtPath(body, path) {
|
|
|
5170
5358
|
* through a request HEADER), so matching a spec's `joinFields` against the
|
|
5171
5359
|
* drill capture must search headers even though the structural heuristic
|
|
5172
5360
|
* deliberately doesn't (see {@link collectRequestStringValues}'s docstring). */
|
|
5361
|
+
const requestValuesCache = new WeakMap();
|
|
5362
|
+
/** Memoized like {@link objectArrayFieldsCache} — the same capture's
|
|
5363
|
+
* request/header values are re-derived on every fold-chain candidate that
|
|
5364
|
+
* threads through it. Keyed on `Capture` identity rather than
|
|
5365
|
+
* `responseBody`, since this walks the capture's request side (URL, body,
|
|
5366
|
+
* headers), not its response. */
|
|
5173
5367
|
function collectRequestValuesIncludingHeaders(capture) {
|
|
5368
|
+
const cached = requestValuesCache.get(capture);
|
|
5369
|
+
if (cached)
|
|
5370
|
+
return cached;
|
|
5174
5371
|
const values = collectRequestStringValues(capture);
|
|
5175
5372
|
for (const v of Object.values(capture.requestHeaders))
|
|
5176
5373
|
values.add(v);
|
|
5374
|
+
requestValuesCache.set(capture, values);
|
|
5177
5375
|
return values;
|
|
5178
5376
|
}
|
|
5179
5377
|
/** Finds which of `primaryItems` the drill call actually captured, by
|
|
@@ -5194,6 +5392,64 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
|
|
|
5194
5392
|
}));
|
|
5195
5393
|
return matchedIndex === -1 ? null : matchedIndex;
|
|
5196
5394
|
}
|
|
5395
|
+
/**
|
|
5396
|
+
* Maps every string/numeric/boolean value seen in any action's request (URL,
|
|
5397
|
+
* body, or headers — see {@link collectRequestValuesIncludingHeaders}) to the
|
|
5398
|
+
* ascending list of action indices whose request carries it. Built once per
|
|
5399
|
+
* {@link buildFoldPlanFromSpec} call and shared across every primary/drill
|
|
5400
|
+
* candidate pair, so {@link resolveSpecMatchedPrimaryItemIndexAlongChain}'s
|
|
5401
|
+
* upstream search can jump straight to the (typically tiny) set of indices
|
|
5402
|
+
* that could possibly carry a given primary item's join value instead of
|
|
5403
|
+
* scanning every index between the primary and the drill — the O(actions)-
|
|
5404
|
+
* per-candidate backward walk the reported large-capture-set hang traced to.
|
|
5405
|
+
*/
|
|
5406
|
+
function buildRequestValueIndex(actions) {
|
|
5407
|
+
const index = new Map();
|
|
5408
|
+
for (let i = 0; i < actions.length; i++) {
|
|
5409
|
+
for (const value of collectRequestValuesIncludingHeaders(actions[i].capture)) {
|
|
5410
|
+
const indices = index.get(value);
|
|
5411
|
+
if (indices)
|
|
5412
|
+
indices.push(i);
|
|
5413
|
+
else
|
|
5414
|
+
index.set(value, [i]);
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
return index;
|
|
5418
|
+
}
|
|
5419
|
+
/** Every string/numeric/boolean {@link FoldReturnSpec.joinFields} value
|
|
5420
|
+
* present on any of `primaryItems`, stringified exactly as
|
|
5421
|
+
* {@link resolveSpecMatchedPrimaryItemIndex} compares them — the set of
|
|
5422
|
+
* values whose {@link buildRequestValueIndex} entries can possibly resolve
|
|
5423
|
+
* this primary's join, used to prune the candidate entry indices
|
|
5424
|
+
* {@link resolveSpecMatchedPrimaryItemIndexAlongChain} walks instead of
|
|
5425
|
+
* considering every action index in range. */
|
|
5426
|
+
function collectPrimaryJoinValues(primaryItems, joinFields) {
|
|
5427
|
+
const values = new Set();
|
|
5428
|
+
for (const item of primaryItems) {
|
|
5429
|
+
for (const field of joinFields) {
|
|
5430
|
+
const value = readValueAtPath(item, field.split("."));
|
|
5431
|
+
if (typeof value === "string" && value.length > 0)
|
|
5432
|
+
values.add(value);
|
|
5433
|
+
else if (typeof value === "number" || typeof value === "boolean")
|
|
5434
|
+
values.add(String(value));
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
return values;
|
|
5438
|
+
}
|
|
5439
|
+
/** Descending, deduped action indices strictly greater than
|
|
5440
|
+
* `primaryStepIndex` whose request carries at least one of `joinValues` —
|
|
5441
|
+
* the pruned candidate set {@link resolveSpecMatchedPrimaryItemIndexAlongChain}
|
|
5442
|
+
* walks instead of every index in `(primaryStepIndex, actions.length)`. */
|
|
5443
|
+
function collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex) {
|
|
5444
|
+
const candidates = new Set();
|
|
5445
|
+
for (const value of joinValues) {
|
|
5446
|
+
for (const index of requestValueIndex.get(value) ?? []) {
|
|
5447
|
+
if (index > primaryStepIndex)
|
|
5448
|
+
candidates.add(index);
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
return [...candidates].sort((a, b) => b - a);
|
|
5452
|
+
}
|
|
5197
5453
|
/** Like {@link resolveSpecMatchedPrimaryItemIndex}, but also looks upstream of
|
|
5198
5454
|
* `drillStepIndex` for the join key when `drillStepIndex`'s own request
|
|
5199
5455
|
* doesn't carry it — a `foldReturn` spec's `endpointPattern` naturally names
|
|
@@ -5210,9 +5466,49 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
|
|
|
5210
5466
|
* `drillStepIndex` alone would never include this upstream entry hop, so it
|
|
5211
5467
|
* would never be re-executed (header-parameterized) per primary item at
|
|
5212
5468
|
* runtime. */
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5469
|
+
let resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
|
|
5470
|
+
/** Test-only instrumentation: how many times
|
|
5471
|
+
* {@link resolveSpecMatchedPrimaryItemIndexAlongChain} — the expensive
|
|
5472
|
+
* backward-walk + {@link computeFoldChain} resolution — has actually run
|
|
5473
|
+
* since the last {@link resetFoldPlanResolutionCallCountForTests} call, so a
|
|
5474
|
+
* test can assert `buildFoldPlanFromSpec` pays for it only once per primary
|
|
5475
|
+
* rather than once per matching drill occurrence. */
|
|
5476
|
+
function getFoldPlanResolutionCallCountForTests() {
|
|
5477
|
+
return resolveSpecMatchedPrimaryItemIndexAlongChainCallCount;
|
|
5478
|
+
}
|
|
5479
|
+
function resetFoldPlanResolutionCallCountForTests() {
|
|
5480
|
+
resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
|
|
5481
|
+
}
|
|
5482
|
+
function resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, joinFields, drillStepIndex,
|
|
5483
|
+
// Keyed by entryIndex alone: valid for every call sharing the same
|
|
5484
|
+
// primaryStepIndex/primaryItems/joinFields, since resolveSpecMatchedPrimaryItemIndex's
|
|
5485
|
+
// result for a given entryIndex depends on nothing else. Without this,
|
|
5486
|
+
// buildFoldPlanFromSpec's per-primary candidate loop re-walks the SAME
|
|
5487
|
+
// overlapping entryIndex range from scratch for every matching drill
|
|
5488
|
+
// occurrence it tries — O(candidates * chain length) per primary instead
|
|
5489
|
+
// of O(chain length) total, the combinatorial blowup the reported hang
|
|
5490
|
+
// traced to at large capture-set sizes.
|
|
5491
|
+
matchedItemIndexCache,
|
|
5492
|
+
// Descending, deduped, computed once per primaryStepIndex by
|
|
5493
|
+
// {@link collectCandidateEntryIndicesDescending} — every index whose
|
|
5494
|
+
// request carries at least one of this primary's join values, i.e. a
|
|
5495
|
+
// strict superset of the indices `resolveSpecMatchedPrimaryItemIndex`
|
|
5496
|
+
// could ever match. Walking this instead of every index down to
|
|
5497
|
+
// `primaryStepIndex` is what collapses the backward search from
|
|
5498
|
+
// O(actions) to O(occurrences of the actual join value) per candidate.
|
|
5499
|
+
candidateEntryIndicesDescending) {
|
|
5500
|
+
resolveSpecMatchedPrimaryItemIndexAlongChainCallCount++;
|
|
5501
|
+
for (const entryIndex of candidateEntryIndicesDescending) {
|
|
5502
|
+
if (entryIndex > drillStepIndex)
|
|
5503
|
+
continue;
|
|
5504
|
+
const cached = matchedItemIndexCache.get(entryIndex);
|
|
5505
|
+
const matched = cached !== undefined
|
|
5506
|
+
? cached
|
|
5507
|
+
: (() => {
|
|
5508
|
+
const resolved = resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, actions[entryIndex].capture);
|
|
5509
|
+
matchedItemIndexCache.set(entryIndex, resolved);
|
|
5510
|
+
return resolved;
|
|
5511
|
+
})();
|
|
5216
5512
|
if (matched === null)
|
|
5217
5513
|
continue;
|
|
5218
5514
|
if (entryIndex === drillStepIndex)
|
|
@@ -5269,15 +5565,48 @@ function compileFoldReturnResultsMatcher(spec) {
|
|
|
5269
5565
|
function buildFoldPlanFromSpec(actions, spec) {
|
|
5270
5566
|
const primaryArrayPath = spec.resultsPath.split(".");
|
|
5271
5567
|
const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
|
|
5568
|
+
// Built once and shared across every primaryStepIndex — see
|
|
5569
|
+
// buildRequestValueIndex's docstring. Lets each primary immediately tell
|
|
5570
|
+
// whether ANY action anywhere carries one of its own join values before
|
|
5571
|
+
// paying for anything else, instead of scanning its own drill candidates
|
|
5572
|
+
// one by one only to discover none of them can ever match.
|
|
5573
|
+
const requestValueIndex = buildRequestValueIndex(actions);
|
|
5272
5574
|
let freshestPlan = null;
|
|
5273
5575
|
for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
|
|
5274
5576
|
const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
|
|
5275
5577
|
if (!primaryItems)
|
|
5276
5578
|
continue;
|
|
5579
|
+
const joinValues = collectPrimaryJoinValues(primaryItems, spec.joinFields);
|
|
5580
|
+
const candidateEntryIndicesDescending = collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex);
|
|
5581
|
+
// No action anywhere carries any of this primary's join values, so no
|
|
5582
|
+
// drillStepIndex candidate could ever resolve — skip straight to the
|
|
5583
|
+
// next primary instead of scanning this one's drill candidates (each of
|
|
5584
|
+
// which would only rediscover the same dead end).
|
|
5585
|
+
if (candidateEntryIndicesDescending.length === 0)
|
|
5586
|
+
continue;
|
|
5587
|
+
// Cheap regex-only pass first: collects every endpointPattern-matching
|
|
5588
|
+
// drillStepIndex without paying for the expensive backward-walk +
|
|
5589
|
+
// computeFoldChain resolution below. Scanned from the freshest (highest)
|
|
5590
|
+
// index down so the expensive resolution — tried only on the entries
|
|
5591
|
+
// this loop actually visits — runs on the candidate that would win
|
|
5592
|
+
// ties in the original last-write-wins scan first, falling through to
|
|
5593
|
+
// the next-freshest only when a candidate fails to resolve, instead of
|
|
5594
|
+
// resolving every earlier occurrence just to have it overwritten.
|
|
5595
|
+
const matchingDrillStepIndices = [];
|
|
5277
5596
|
for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
|
|
5597
|
+
if (matchesFoldReturnEndpoint(actions[drillStepIndex].capture)) {
|
|
5598
|
+
matchingDrillStepIndices.push(drillStepIndex);
|
|
5599
|
+
}
|
|
5600
|
+
}
|
|
5601
|
+
// Shared across every matching-drill candidate tried below for THIS
|
|
5602
|
+
// primaryStepIndex — see resolveSpecMatchedPrimaryItemIndexAlongChain's
|
|
5603
|
+
// docstring on why a fresh cache per primary (not per drill candidate)
|
|
5604
|
+
// is what collapses the backward-walk from O(candidates * chain length)
|
|
5605
|
+
// to O(chain length).
|
|
5606
|
+
const matchedItemIndexCache = new Map();
|
|
5607
|
+
for (let i = matchingDrillStepIndices.length - 1; i >= 0; i--) {
|
|
5608
|
+
const drillStepIndex = matchingDrillStepIndices[i];
|
|
5278
5609
|
const drill = actions[drillStepIndex];
|
|
5279
|
-
if (!matchesFoldReturnEndpoint(drill.capture))
|
|
5280
|
-
continue;
|
|
5281
5610
|
// Widened to a flat (non-array) object response the same way the
|
|
5282
5611
|
// structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
|
|
5283
5612
|
// an explicit foldReturn declaration must be able to express a
|
|
@@ -5292,7 +5621,7 @@ function buildFoldPlanFromSpec(actions, spec) {
|
|
|
5292
5621
|
// now does (see detectDrillDownFoldPlan). Validated after the chain
|
|
5293
5622
|
// resolves, below, since `[]` is a valid empty baseline for
|
|
5294
5623
|
// computeFoldChain but not a valid final drillArrayPath on its own.
|
|
5295
|
-
const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields,
|
|
5624
|
+
const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, drillStepIndex, matchedItemIndexCache, candidateEntryIndicesDescending);
|
|
5296
5625
|
if (matchResult === null)
|
|
5297
5626
|
continue;
|
|
5298
5627
|
const { entryIndex, primaryMatchedItemIndex } = matchResult;
|
|
@@ -5339,6 +5668,7 @@ function buildFoldPlanFromSpec(actions, spec) {
|
|
|
5339
5668
|
},
|
|
5340
5669
|
],
|
|
5341
5670
|
};
|
|
5671
|
+
break;
|
|
5342
5672
|
}
|
|
5343
5673
|
}
|
|
5344
5674
|
return freshestPlan;
|
|
@@ -5694,7 +6024,7 @@ function buildContractChecklist(opts) {
|
|
|
5694
6024
|
].filter((line) => line !== "");
|
|
5695
6025
|
}
|
|
5696
6026
|
function emitContractTs(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;
|
|
6027
|
+
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, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
|
|
5698
6028
|
// This is the CLIENT-level schema — createHttpClient's default, and the
|
|
5699
6029
|
// plugin's caller-facing contract (what executeHttp's return value promises
|
|
5700
6030
|
// its own caller). It does NOT validate any individual call in a multi-step
|
|
@@ -5762,6 +6092,22 @@ function emitContractTs(opts) {
|
|
|
5762
6092
|
const paginationSignal = !multiStepBody && gql && gqlOperationName
|
|
5763
6093
|
? detectPaginationSignal(responseBody, gqlVariables)
|
|
5764
6094
|
: null;
|
|
6095
|
+
// A resolved drill-down fold plan on the single-primary getGql/httpClient
|
|
6096
|
+
// hot path (`multiStepBody` unset — see emitMultiStepExecuteHttp for the
|
|
6097
|
+
// multi-step equivalent) folds onto `data` in place, exactly as the
|
|
6098
|
+
// multi-step loop folds onto its own primary var: without this, a
|
|
6099
|
+
// single-primary read flow with a declared `foldReturn` would silently
|
|
6100
|
+
// drop the fold feature the flow author declared (see
|
|
6101
|
+
// recon-generate-foldreturn-regresses-primary-op-and-payload-to-ats-submission-shape.md).
|
|
6102
|
+
// Not attempted alongside `paginationSignal` — a paginated primary's items
|
|
6103
|
+
// span multiple page fetches this branch never issues, so a fold plan
|
|
6104
|
+
// resolving against only the FIRST page's captured sample would be
|
|
6105
|
+
// incomplete; that combination is out of scope here.
|
|
6106
|
+
const singlePrimaryFoldPlans = multiStepBody || paginationSignal ? [] : resolveFoldPlan(actionSteps, foldReturnSpec);
|
|
6107
|
+
// A GraphQL primary with a resolved drill-down fold has no other REST
|
|
6108
|
+
// client to issue the drill request(s) with — getGql only ever speaks
|
|
6109
|
+
// GraphQL to the primary endpoint.
|
|
6110
|
+
const needsFoldHttpClient = gql && singlePrimaryFoldPlans.length > 0;
|
|
5765
6111
|
// Every field source below (the base extend's own keys, form-schema
|
|
5766
6112
|
// discovery, browser-flow splicing, option/raw-option enums, additional
|
|
5767
6113
|
// body keys, and structured keys) is merged into a SINGLE `.extend({...})`
|
|
@@ -6005,7 +6351,7 @@ function emitContractTs(opts) {
|
|
|
6005
6351
|
const clientImport = omitExecuteHttp
|
|
6006
6352
|
? ""
|
|
6007
6353
|
: gql
|
|
6008
|
-
? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";`
|
|
6354
|
+
? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
|
|
6009
6355
|
: `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
|
|
6010
6356
|
const queryConst = !omitExecuteHttp && gql && gqlQuery
|
|
6011
6357
|
? `\n// Lifted verbatim from recon capture. The adjacent response schema is drift-tolerant by construction (dropped __typename, .loose() objects), so this query text is not hand-trimmed.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
|
|
@@ -6031,7 +6377,15 @@ function getGql(baseUrl: string): GqlFn {
|
|
|
6031
6377
|
}
|
|
6032
6378
|
return client;
|
|
6033
6379
|
}
|
|
6380
|
+
${
|
|
6381
|
+
// A GraphQL primary with a resolved drill-down fold has no other REST
|
|
6382
|
+
// client to issue the drill request(s) with — getGql only ever speaks
|
|
6383
|
+
// GraphQL to the primary endpoint.
|
|
6384
|
+
needsFoldHttpClient
|
|
6385
|
+
? `
|
|
6386
|
+
const httpClient = createHttpClient({ schema: z.unknown(), bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
|
|
6034
6387
|
`
|
|
6388
|
+
: ""}`
|
|
6035
6389
|
: `
|
|
6036
6390
|
const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
|
|
6037
6391
|
`;
|
|
@@ -6041,6 +6395,78 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
6041
6395
|
const gqlVariablesExpr = gqlOperationName
|
|
6042
6396
|
? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
|
|
6043
6397
|
: "{ q: payload.query }";
|
|
6398
|
+
/** Builds the `for (const item of foldItems) { ... }` block(s) that fold
|
|
6399
|
+
* every resolved plan's drill-down data onto `dataVarName`'s primary array
|
|
6400
|
+
* — the single-primary counterpart of emitMultiStepExecuteHttp's own
|
|
6401
|
+
* per-item loop, sharing its match/merge tail via
|
|
6402
|
+
* {@link emitFoldMatchAndMergeLines} so the two can't describe different
|
|
6403
|
+
* merge semantics. Chain hops beyond the drill step itself are rendered
|
|
6404
|
+
* directly off each hop's own captured request (no state-threading
|
|
6405
|
+
* pipeline) since a single-primary read flow carries no submitted payload
|
|
6406
|
+
* for those calls to reference. */
|
|
6407
|
+
const buildFoldMergeLines = (dataVarName) => {
|
|
6408
|
+
const lines = [];
|
|
6409
|
+
for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
|
|
6410
|
+
const primaryStep = actionSteps[foldPlan.primaryStepIndex];
|
|
6411
|
+
if (!primaryStep)
|
|
6412
|
+
continue;
|
|
6413
|
+
const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
|
|
6414
|
+
const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
|
|
6415
|
+
const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
|
|
6416
|
+
const foldItemsVar = `foldItems${planSuffix}`;
|
|
6417
|
+
const foldItemsExpr = pathToFoldAccessorExpr(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
|
|
6418
|
+
const itemVar = `item${planSuffix}`;
|
|
6419
|
+
lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
|
|
6420
|
+
for (const [targetIndex, target] of foldPlan.targets.entries()) {
|
|
6421
|
+
const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
|
|
6422
|
+
if (!firstItem) {
|
|
6423
|
+
throw new Error(`emitContractTs: fold plan primary step ${foldPlan.primaryStepIndex} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
|
|
6424
|
+
}
|
|
6425
|
+
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
6426
|
+
const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
6427
|
+
// Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
|
|
6428
|
+
// `parameterize` performs — a plain split/join would also rewrite
|
|
6429
|
+
// unrelated substrings that happen to contain the join value.
|
|
6430
|
+
const parameterizeUrl = (rawUrl) => {
|
|
6431
|
+
const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
|
|
6432
|
+
? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
|
|
6433
|
+
: rawUrl;
|
|
6434
|
+
return target.joinFields.reduce((acc, field) => {
|
|
6435
|
+
const value = readValueAtPath(firstItem, field.split("."));
|
|
6436
|
+
const stringValue = typeof value === "string" && value.length > 0
|
|
6437
|
+
? value
|
|
6438
|
+
: typeof value === "number" || typeof value === "boolean"
|
|
6439
|
+
? String(value)
|
|
6440
|
+
: null;
|
|
6441
|
+
if (stringValue === null)
|
|
6442
|
+
return acc;
|
|
6443
|
+
return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${joinAccessor(field)}}`);
|
|
6444
|
+
}, withBase);
|
|
6445
|
+
};
|
|
6446
|
+
for (const chainIndex of target.chain) {
|
|
6447
|
+
const chainStep = actionSteps[chainIndex];
|
|
6448
|
+
if (!chainStep)
|
|
6449
|
+
continue;
|
|
6450
|
+
const url = parameterizeUrl(chainStep.capture.url);
|
|
6451
|
+
const schemaExpr = inferZodSchema(chainStep.capture.responseBody, 0, "", {
|
|
6452
|
+
looseServerResponse: true,
|
|
6453
|
+
aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([
|
|
6454
|
+
chainStep.capture.responseBody,
|
|
6455
|
+
]),
|
|
6456
|
+
});
|
|
6457
|
+
lines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
|
|
6458
|
+
}
|
|
6459
|
+
const terminalStep = actionSteps[target.chainTerminalIndex];
|
|
6460
|
+
if (!terminalStep)
|
|
6461
|
+
continue;
|
|
6462
|
+
lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
|
|
6463
|
+
}
|
|
6464
|
+
lines.push(` }`, "");
|
|
6465
|
+
}
|
|
6466
|
+
return lines;
|
|
6467
|
+
};
|
|
6468
|
+
const dataFoldMergeLines = buildFoldMergeLines("data");
|
|
6469
|
+
const dataFoldMergeBlock = dataFoldMergeLines.length > 0 ? `${dataFoldMergeLines.join("\n")}\n` : "";
|
|
6044
6470
|
const executeHttpBody = multiStepBody
|
|
6045
6471
|
? multiStepBody
|
|
6046
6472
|
: paginationSignal
|
|
@@ -6053,12 +6479,12 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
6053
6479
|
})
|
|
6054
6480
|
: gql
|
|
6055
6481
|
? ` const data = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${pascal.toUpperCase()}_QUERY, ${gqlVariablesExpr});
|
|
6056
|
-
return { data };`
|
|
6482
|
+
${dataFoldMergeBlock} return { data };`
|
|
6057
6483
|
: ` const data = await httpClient(\`\${context.baseUrl}${endpointPath}\`, {
|
|
6058
6484
|
method: "POST",
|
|
6059
6485
|
body: JSON.stringify({ query: payload.query }),
|
|
6060
6486
|
});
|
|
6061
|
-
return { data };`;
|
|
6487
|
+
${dataFoldMergeBlock} return { data };`;
|
|
6062
6488
|
const fixtureComments = auxFiles.length > 0
|
|
6063
6489
|
? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
|
|
6064
6490
|
auxFiles
|
|
@@ -6848,7 +7274,14 @@ async function main() {
|
|
|
6848
7274
|
const graphqlActionSequence = gql
|
|
6849
7275
|
? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
|
|
6850
7276
|
: [];
|
|
6851
|
-
|
|
7277
|
+
// A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
|
|
7278
|
+
// doc comment) can put 2+ entries in graphqlActionSequence with none of them
|
|
7279
|
+
// an actual `mutation` — a GraphQL-primary query plus its drill-down, not a
|
|
7280
|
+
// transactional multi-step submission. Only a real mutation makes this a
|
|
7281
|
+
// submission flow; an admitted read/drill capture must not, on its own, null
|
|
7282
|
+
// out primaryGraphQLOperation below or flip isSubmissionFlow further down.
|
|
7283
|
+
const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
|
|
7284
|
+
const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
|
|
6852
7285
|
? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
|
|
6853
7286
|
: null;
|
|
6854
7287
|
if (primaryGraphQLOperation && primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
|
|
@@ -7004,7 +7437,7 @@ async function main() {
|
|
|
7004
7437
|
? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
7005
7438
|
: new Map();
|
|
7006
7439
|
const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
|
|
7007
|
-
const isSubmissionFlow = actionSteps.length > 1;
|
|
7440
|
+
const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
|
|
7008
7441
|
// Loud failure for a submitEndpointPattern that under-matches the raw traffic badly
|
|
7009
7442
|
// enough to collapse the flow to the single-endpoint fallback: heuristicActionCaptures
|
|
7010
7443
|
// is the pattern-filtered sequence (used both directly and as rawActionCaptures' floor
|
|
@@ -7195,6 +7628,8 @@ async function main() {
|
|
|
7195
7628
|
isSubmissionFlow,
|
|
7196
7629
|
inputBody,
|
|
7197
7630
|
hasMultipartStep,
|
|
7631
|
+
actionSteps,
|
|
7632
|
+
foldReturnSpec,
|
|
7198
7633
|
discoveredFormFields,
|
|
7199
7634
|
fieldOptionsMap,
|
|
7200
7635
|
discoveredOptionFields,
|