@enricai/barnacle 1.12.29 → 1.12.31
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/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +33 -8
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.d.ts +71 -0
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +182 -0
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +543 -139
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -2890,6 +2890,78 @@ function pathToFoldAccessorExpr(expr, path, depth = 0) {
|
|
|
2890
2890
|
const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
|
|
2891
2891
|
return `${outerExpr}.flatMap((${groupVar}) => ${pathToFoldAccessorExpr(groupVar, after, depth + 1)})`;
|
|
2892
2892
|
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Emits nested `for` loops descending through every {@link
|
|
2895
|
+
* ARRAY_WILDCARD_SEGMENT} in `path`, instead of {@link
|
|
2896
|
+
* pathToFoldAccessorExpr}'s single `.flatMap` chain — a `.flatMap` callback
|
|
2897
|
+
* only ever sees the group it flattens FROM, so the moment it returns the
|
|
2898
|
+
* inner array, the outer (ancestor) object is gone from scope for the rest
|
|
2899
|
+
* of the emitted fold body. A nested loop keeps every intermediate binding
|
|
2900
|
+
* (`g0`, `g1`, ...) addressable inside the innermost loop, so a drill param
|
|
2901
|
+
* that only lives on an ancestor object (e.g. a parent group id) can be read
|
|
2902
|
+
* off that binding instead of being frozen as a literal. `varSuffix` mirrors
|
|
2903
|
+
* the `itemVar`/`foldItemsVar` disambiguation suffix used when multiple fold
|
|
2904
|
+
* plans share one function scope, so ancestor bindings from different plans
|
|
2905
|
+
* can't collide either. Returns the loop's opening/closing lines (for the
|
|
2906
|
+
* caller to splice its own loop-body lines between) plus the ordered
|
|
2907
|
+
* (outer-to-inner) ancestor variable names — NOT including the innermost
|
|
2908
|
+
* item, which the caller names itself via `itemVar`. Also splits the
|
|
2909
|
+
* combined open/close into an ancestor-scope segment
|
|
2910
|
+
* (`ancestorOpenLines`/`ancestorCloseLines`, empty when `path` crosses no
|
|
2911
|
+
* wildcard) and an item-scope segment (`itemOpenLines`/`itemCloseLines`),
|
|
2912
|
+
* so a caller whose per-target chain fetch never reads `itemVar` can splice
|
|
2913
|
+
* that fetch between the two segments — once per ancestor tuple — instead
|
|
2914
|
+
* of inside the item loop. `openLines`/`closeLines` remain the straight
|
|
2915
|
+
* concatenation of the two segments, unchanged, for callers that don't need
|
|
2916
|
+
* the split.
|
|
2917
|
+
*/
|
|
2918
|
+
function pathToFoldLoopLines(expr, path, itemVar, indent, varSuffix = "", depth = 0) {
|
|
2919
|
+
const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
|
|
2920
|
+
if (wildcardIndex === -1) {
|
|
2921
|
+
const finalExpr = `${expr}${pathToAccessor(path, { assertNonNull: false })}`;
|
|
2922
|
+
// At depth 0 with no wildcard crossing at all (the overwhelmingly common
|
|
2923
|
+
// single-level fold), keep emitting the original `const foldItems = ...;`
|
|
2924
|
+
// binding rather than inlining the accessor into the `for` — existing
|
|
2925
|
+
// single-scope folds must emit byte-identical code, not merely
|
|
2926
|
+
// equivalent code, since nothing about a flat fold needs the nested-loop
|
|
2927
|
+
// rewrite in the first place.
|
|
2928
|
+
const itemOpenLines = depth === 0
|
|
2929
|
+
? [
|
|
2930
|
+
`${indent}const foldItems${varSuffix} = ${finalExpr};`,
|
|
2931
|
+
`${indent}for (const ${itemVar} of foldItems${varSuffix}) {`,
|
|
2932
|
+
]
|
|
2933
|
+
: [`${indent}for (const ${itemVar} of ${finalExpr}) {`];
|
|
2934
|
+
const itemCloseLines = [`${indent}}`];
|
|
2935
|
+
return {
|
|
2936
|
+
openLines: itemOpenLines,
|
|
2937
|
+
closeLines: itemCloseLines,
|
|
2938
|
+
ancestorVars: [],
|
|
2939
|
+
ancestorOpenLines: [],
|
|
2940
|
+
itemOpenLines,
|
|
2941
|
+
itemCloseLines,
|
|
2942
|
+
ancestorCloseLines: [],
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
const before = path.slice(0, wildcardIndex);
|
|
2946
|
+
const after = path.slice(wildcardIndex + 1);
|
|
2947
|
+
const groupVar = `g${depth}${varSuffix}`;
|
|
2948
|
+
const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
|
|
2949
|
+
const inner = pathToFoldLoopLines(groupVar, after, itemVar, `${indent} `, varSuffix, depth + 1);
|
|
2950
|
+
const ancestorOpenLines = [
|
|
2951
|
+
`${indent}for (const ${groupVar} of ${outerExpr}) {`,
|
|
2952
|
+
...inner.ancestorOpenLines,
|
|
2953
|
+
];
|
|
2954
|
+
const ancestorCloseLines = [...inner.ancestorCloseLines, `${indent}}`];
|
|
2955
|
+
return {
|
|
2956
|
+
openLines: [...ancestorOpenLines, ...inner.itemOpenLines],
|
|
2957
|
+
closeLines: [...inner.itemCloseLines, ...ancestorCloseLines],
|
|
2958
|
+
ancestorVars: [groupVar, ...inner.ancestorVars],
|
|
2959
|
+
ancestorOpenLines,
|
|
2960
|
+
itemOpenLines: inner.itemOpenLines,
|
|
2961
|
+
itemCloseLines: inner.itemCloseLines,
|
|
2962
|
+
ancestorCloseLines,
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2893
2965
|
/** Suggests a JS-camelCase variable name for a state value path. Falls back
|
|
2894
2966
|
* up the path if the tail is numeric or not a valid JS identifier. */
|
|
2895
2967
|
function pathToVarName(path) {
|
|
@@ -3692,29 +3764,40 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
|
|
|
3692
3764
|
];
|
|
3693
3765
|
}
|
|
3694
3766
|
const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
|
|
3767
|
+
const matchAccessorFor = (f, varName, optionalRoot) => {
|
|
3768
|
+
const segments = f.split(".");
|
|
3769
|
+
// The drill-down response is a DIFFERENT payload than the
|
|
3770
|
+
// primary item, so it has no obligation to mirror the
|
|
3771
|
+
// primary item's own nesting for the join key (e.g. a
|
|
3772
|
+
// primary item's `identifiers.sku` is typically echoed
|
|
3773
|
+
// back flat, as `sku`, on the drill response). Try the
|
|
3774
|
+
// full nested path first (optional-chained, since an
|
|
3775
|
+
// intermediate segment may not exist on a flat response),
|
|
3776
|
+
// then fall back to the bare last segment.
|
|
3777
|
+
const lastSegment = segments[segments.length - 1];
|
|
3778
|
+
const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
|
|
3779
|
+
const optionalBracketAccessor = segments
|
|
3780
|
+
.map((segment) => `?.[${JSON.stringify(segment)}]`)
|
|
3781
|
+
.join("");
|
|
3782
|
+
return segments.length > 1
|
|
3783
|
+
? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
|
|
3784
|
+
: `${varName}${bracket(lastSegment)}`;
|
|
3785
|
+
};
|
|
3786
|
+
const joinCondition = target.joinFields
|
|
3787
|
+
.map((f) => `String(${matchAccessorFor(f, "m", false)}) === String(${joinAccessor(f)})`)
|
|
3788
|
+
.join(" && ");
|
|
3789
|
+
// A sole candidate whose join field(s) aren't present on it at all
|
|
3790
|
+
// (common: many drill endpoints don't echo the request key back onto
|
|
3791
|
+
// the response row) is trusted as-is — there's no sibling it could be
|
|
3792
|
+
// confused with. But a sole candidate that DOES carry the join field
|
|
3793
|
+
// with a different value is a genuine mismatch and must not be grafted
|
|
3794
|
+
// on; two-or-more candidates always require an actual join-key match.
|
|
3795
|
+
const soleCandidateFieldsAbsent = target.joinFields
|
|
3796
|
+
.map((f) => `${matchAccessorFor(f, `foldMatches${suffix}[0]`, true)} === undefined`)
|
|
3797
|
+
.join(" && ");
|
|
3695
3798
|
return [
|
|
3696
3799
|
` const foldMatches${suffix} = ${foldMatchesExpr};`,
|
|
3697
|
-
` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${
|
|
3698
|
-
.map((f) => {
|
|
3699
|
-
const segments = f.split(".");
|
|
3700
|
-
// The drill-down response is a DIFFERENT payload than the
|
|
3701
|
-
// primary item, so it has no obligation to mirror the
|
|
3702
|
-
// primary item's own nesting for the join key (e.g. a
|
|
3703
|
-
// primary item's `identifiers.sku` is typically echoed
|
|
3704
|
-
// back flat, as `sku`, on the drill response). Try the
|
|
3705
|
-
// full nested path first (optional-chained, since an
|
|
3706
|
-
// intermediate segment may not exist on a flat response),
|
|
3707
|
-
// then fall back to the bare last segment.
|
|
3708
|
-
const lastSegment = segments[segments.length - 1];
|
|
3709
|
-
const optionalBracketAccessor = segments
|
|
3710
|
-
.map((segment) => `?.[${JSON.stringify(segment)}]`)
|
|
3711
|
-
.join("");
|
|
3712
|
-
const matchAccessor = segments.length > 1
|
|
3713
|
-
? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
|
|
3714
|
-
: `m[${JSON.stringify(lastSegment)}]`;
|
|
3715
|
-
return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
|
|
3716
|
-
})
|
|
3717
|
-
.join(" && ")}) ?? foldMatches${suffix}[0];`,
|
|
3800
|
+
` const foldMatch${suffix} = foldMatches${suffix}.length === 1 && ${soleCandidateFieldsAbsent} ? foldMatches${suffix}[0] : foldMatches${suffix}.find((m) => ${joinCondition});`,
|
|
3718
3801
|
` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
|
|
3719
3802
|
];
|
|
3720
3803
|
}
|
|
@@ -4187,77 +4270,118 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4187
4270
|
const primaryStep = actions[foldPlan.primaryStepIndex];
|
|
4188
4271
|
// Read at the plan's OWN path rather than re-running the DFS: a
|
|
4189
4272
|
// flow-declared `resultsPath` (see FoldReturnSpec) can name a different
|
|
4190
|
-
// array than findObjectArrayField's first match.
|
|
4191
|
-
|
|
4273
|
+
// array than findObjectArrayField's first match. Ancestor-aware so a
|
|
4274
|
+
// drill param living only on a parent object (e.g. a group id) has a
|
|
4275
|
+
// real object to be read off of, not just the flattened leaf item.
|
|
4276
|
+
const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
|
|
4192
4277
|
const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
|
|
4193
4278
|
// Plan-level suffix mirrors the target-level suffix below: multiple
|
|
4194
4279
|
// loop blocks now sharing the same function scope can't declare
|
|
4195
|
-
// unsuffixed `
|
|
4280
|
+
// unsuffixed `item`/ancestor-binding locals without colliding. The
|
|
4196
4281
|
// overwhelmingly common single-plan case keeps the original
|
|
4197
4282
|
// unsuffixed names.
|
|
4198
4283
|
const planSuffix = foldPlans.length > 1 ? String(matchingPlanIndex) : "";
|
|
4199
|
-
const foldItemsVar = `foldItems${planSuffix}`;
|
|
4200
|
-
const foldItemsExpr = pathToFoldAccessorExpr(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
|
|
4201
4284
|
const itemVar = `item${planSuffix}`;
|
|
4202
|
-
|
|
4285
|
+
// Nested `for` loops (not a `.flatMap`-derived collection) so every
|
|
4286
|
+
// intermediate array's binding stays addressable inside the innermost
|
|
4287
|
+
// loop body — see pathToFoldLoopLines's docstring.
|
|
4288
|
+
const { ancestorOpenLines, itemOpenLines, itemCloseLines, ancestorCloseLines, ancestorVars } = pathToFoldLoopLines(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
|
|
4289
|
+
lines.push(...ancestorOpenLines);
|
|
4290
|
+
// Chain fetches for targets whose params + joinFields never reference
|
|
4291
|
+
// itemVar are spliced here, above the item loop but inside the
|
|
4292
|
+
// ancestor loop(s) — fetched once per ancestor tuple and reused by
|
|
4293
|
+
// every descendant item's join/merge, instead of once per item.
|
|
4294
|
+
const hoistedChainLines = [];
|
|
4295
|
+
// Every target's join/merge — plus any non-hoistable target's own
|
|
4296
|
+
// chain fetch — goes here, spliced inside the item loop.
|
|
4297
|
+
const itemScopedLines = [];
|
|
4203
4298
|
for (const [targetIndex, target] of foldPlan.targets.entries()) {
|
|
4204
4299
|
// `firstItem` decides which captured literal `parameterize` rewrites
|
|
4205
4300
|
// — it must be the item at `primaryMatchedItemIndex`, the one THIS
|
|
4206
4301
|
// target's drill request was actually built from, not always index
|
|
4207
4302
|
// 0, and can differ per target even though every target now shares
|
|
4208
4303
|
// the same runtime loop item.
|
|
4209
|
-
const
|
|
4210
|
-
if (!
|
|
4304
|
+
const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
|
|
4305
|
+
if (!matchedPrimaryItem) {
|
|
4211
4306
|
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`);
|
|
4212
4307
|
}
|
|
4308
|
+
const { item: firstItem, ancestors: firstItemAncestors } = matchedPrimaryItem;
|
|
4309
|
+
// Each ancestor binding (`g0`, `g1`, ...) paired with the design-time
|
|
4310
|
+
// object it holds at runtime, so a threaded field resolved against
|
|
4311
|
+
// that binding can be read off the same object `readValueAtPath`
|
|
4312
|
+
// needs, and the runtime accessor built from the SAME variable name
|
|
4313
|
+
// the emitted nested loop actually declares.
|
|
4314
|
+
const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, firstItemAncestors[idx]]));
|
|
4315
|
+
// Searched innermost-scope-first: the item's own field wins over an
|
|
4316
|
+
// ancestor field of the same name.
|
|
4317
|
+
const threadingScopes = [
|
|
4318
|
+
{ varName: itemVar, obj: firstItem },
|
|
4319
|
+
...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
|
|
4320
|
+
];
|
|
4213
4321
|
// Each target's chain variables and merge result get their own
|
|
4214
4322
|
// suffixed local names so multiple independent targets sharing the
|
|
4215
4323
|
// same loop body can each declare their own locals without
|
|
4216
4324
|
// colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
|
|
4217
4325
|
// single-target case keeps the original unsuffixed names.
|
|
4218
4326
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
4219
|
-
const
|
|
4220
|
-
|
|
4221
|
-
// (URL query params) or as an already-generic `${payload.<field>}`
|
|
4222
|
-
// reference (top-level JSON body keys — see
|
|
4223
|
-
// applyPayloadKeyValueSubstitutions, which payload-ifies every scalar
|
|
4224
|
-
// body key regardless of length, running BEFORE this fold branch ever
|
|
4225
|
-
// sees the value). Both must resolve to the loop item's own field, not
|
|
4226
|
-
// a caller-supplied payload value shared across every iteration.
|
|
4327
|
+
const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
4328
|
+
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
4227
4329
|
// Word-boundary anchored: a plain `.split(value).join(...)` would also
|
|
4228
4330
|
// rewrite unrelated substrings that happen to contain the join value
|
|
4229
4331
|
// (e.g. a "p1" product id colliding with a "/v1/" path segment or a
|
|
4230
4332
|
// "p10" sibling id), corrupting parts of the request the join field
|
|
4231
4333
|
// never touched.
|
|
4232
4334
|
const replaceWholeValue = (haystack, value, replacement) => haystack.replace(new RegExp(`\\b${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), replacement);
|
|
4233
|
-
const parameterize = (text
|
|
4234
|
-
|
|
4235
|
-
//
|
|
4236
|
-
//
|
|
4237
|
-
//
|
|
4238
|
-
//
|
|
4239
|
-
//
|
|
4240
|
-
//
|
|
4241
|
-
//
|
|
4242
|
-
//
|
|
4243
|
-
//
|
|
4244
|
-
//
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
.
|
|
4249
|
-
.
|
|
4250
|
-
|
|
4251
|
-
const
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4335
|
+
const parameterize = (text, chainCapture) => {
|
|
4336
|
+
// A join field can reach the render either as the raw captured
|
|
4337
|
+
// literal (URL query params) or as an already-generic
|
|
4338
|
+
// `${payload.<field>}` reference (top-level JSON body keys — see
|
|
4339
|
+
// applyPayloadKeyValueSubstitutions, which payload-ifies every
|
|
4340
|
+
// scalar body key regardless of length, running BEFORE this fold
|
|
4341
|
+
// branch ever sees the value). Both must resolve to the loop
|
|
4342
|
+
// item's (or ancestor's) own field, not a caller-supplied payload
|
|
4343
|
+
// value shared across every iteration. Widened beyond
|
|
4344
|
+
// `target.joinFields` to every field this specific chain hop's own
|
|
4345
|
+
// captured request actually varies on (via findThreadedJoinFields,
|
|
4346
|
+
// searched across the item AND every ancestor binding) — a param
|
|
4347
|
+
// living only on a parent object is otherwise structurally
|
|
4348
|
+
// invisible and gets frozen as a literal.
|
|
4349
|
+
const threadedFields = dedupeThreadedFields([
|
|
4350
|
+
...target.joinFields.map((field) => ({ varName: itemVar, field })),
|
|
4351
|
+
...findThreadedJoinFields(threadingScopes, chainCapture, actions.map((a) => a.capture)),
|
|
4352
|
+
]);
|
|
4353
|
+
const result = threadedFields.reduce((acc, { varName, field }) => {
|
|
4354
|
+
const replacement = `\${${scopedAccessor(varName, field)}}`;
|
|
4355
|
+
// applyPayloadKeyValueSubstitutions only ever names a payload
|
|
4356
|
+
// accessor after the DRILL REQUEST's own top-level JSON key
|
|
4357
|
+
// (`${payload.sku}`), never after `field`'s dot path into the
|
|
4358
|
+
// PRIMARY ITEM — those are unrelated structures that only
|
|
4359
|
+
// happen to share a leaf name for a top-level join field. A
|
|
4360
|
+
// nested join field (e.g. `identifiers.sku`) must therefore
|
|
4361
|
+
// also match on its bare last segment, or the accessor swap
|
|
4362
|
+
// silently no-ops and leaves an undefined `payload.sku`
|
|
4363
|
+
// reference behind once the literal value itself has already
|
|
4364
|
+
// been replaced by the payload-key-value pass.
|
|
4365
|
+
const lastSegment = field.split(".").pop();
|
|
4366
|
+
const withAccessorSwapped = acc
|
|
4367
|
+
.split(`\${payload.${field}}`)
|
|
4368
|
+
.join(replacement)
|
|
4369
|
+
.split(`\${payload.${lastSegment}}`)
|
|
4370
|
+
.join(replacement);
|
|
4371
|
+
const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
|
|
4372
|
+
const value = readValueAtPath(scopeObj, field.split("."));
|
|
4373
|
+
const stringValue = typeof value === "string" && value.length > 0
|
|
4374
|
+
? value
|
|
4375
|
+
: typeof value === "number" || typeof value === "boolean"
|
|
4376
|
+
? String(value)
|
|
4377
|
+
: null;
|
|
4378
|
+
return stringValue !== null
|
|
4379
|
+
? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
|
|
4380
|
+
: withAccessorSwapped;
|
|
4381
|
+
}, text);
|
|
4382
|
+
assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, result, actions.map((a) => a.capture));
|
|
4383
|
+
return result;
|
|
4384
|
+
};
|
|
4261
4385
|
// Every chain step's response and produces are block-scoped to this
|
|
4262
4386
|
// `for` — they never escape to the rest of the function. That is
|
|
4263
4387
|
// exactly the constraint the previous (now-removed) throw enforced by
|
|
@@ -4267,19 +4391,33 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4267
4391
|
// templates by the pass above, same as it would be for any two
|
|
4268
4392
|
// sequential non-fold steps) resolves them from this narrower scope.
|
|
4269
4393
|
const chainDeclared = new Set();
|
|
4394
|
+
const chainLines = [];
|
|
4395
|
+
// True once any chain step's parameterized url/headers/body actually
|
|
4396
|
+
// ends up referencing `itemVar` — determined off the SAME
|
|
4397
|
+
// `parameterize` substitutions the emitted call itself uses (not a
|
|
4398
|
+
// fresh value scan), so this can never disagree with what the
|
|
4399
|
+
// request literally interpolates. `ancestorVars.length === 0` means
|
|
4400
|
+
// there is no ancestor loop to hoist into in the first place (a
|
|
4401
|
+
// flat, single-level fold), so it is treated as item-scoped too —
|
|
4402
|
+
// existing flat folds must keep emitting byte-identical code.
|
|
4403
|
+
const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
|
|
4404
|
+
let referencesItemVar = ancestorVars.length === 0;
|
|
4270
4405
|
for (const chainIndex of target.chain) {
|
|
4271
4406
|
const chainStep = actions[chainIndex];
|
|
4272
4407
|
const chainRendered = rendered[chainIndex];
|
|
4273
|
-
|
|
4274
|
-
const
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
.
|
|
4279
|
-
|
|
4408
|
+
const paramUrl = parameterize(chainRendered.url, chainStep.capture);
|
|
4409
|
+
const paramHeaders = parameterize(chainRendered.headersExpr, chainStep.capture);
|
|
4410
|
+
const paramBody = parameterize(chainRendered.bodyArg, chainStep.capture);
|
|
4411
|
+
if (itemVarRefPattern.test(paramUrl) ||
|
|
4412
|
+
itemVarRefPattern.test(paramHeaders) ||
|
|
4413
|
+
itemVarRefPattern.test(paramBody)) {
|
|
4414
|
+
referencesItemVar = true;
|
|
4415
|
+
}
|
|
4416
|
+
chainLines.push(` const ${chainStep.varName} = (await httpClient(\`${paramUrl}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
|
|
4417
|
+
const joined = [paramHeaders, paramBody].filter((s) => s !== "").join(" ");
|
|
4280
4418
|
if (joined !== "")
|
|
4281
|
-
|
|
4282
|
-
|
|
4419
|
+
chainLines.push(` ${joined}`);
|
|
4420
|
+
chainLines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
|
|
4283
4421
|
for (const p of chainStep.produces) {
|
|
4284
4422
|
if (p.kind === "header")
|
|
4285
4423
|
continue;
|
|
@@ -4289,13 +4427,20 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4289
4427
|
continue;
|
|
4290
4428
|
chainDeclared.add(p.name);
|
|
4291
4429
|
const assertion = pathToAssertionType(p.path);
|
|
4292
|
-
|
|
4430
|
+
chainLines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
|
|
4293
4431
|
}
|
|
4294
4432
|
}
|
|
4295
4433
|
const terminalStep = actions[target.chainTerminalIndex];
|
|
4296
|
-
|
|
4434
|
+
const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
|
|
4435
|
+
if (referencesItemVar) {
|
|
4436
|
+
itemScopedLines.push(...chainLines, ...matchLines);
|
|
4437
|
+
}
|
|
4438
|
+
else {
|
|
4439
|
+
hoistedChainLines.push(...chainLines);
|
|
4440
|
+
itemScopedLines.push(...matchLines);
|
|
4441
|
+
}
|
|
4297
4442
|
}
|
|
4298
|
-
lines.push(
|
|
4443
|
+
lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
|
|
4299
4444
|
continue;
|
|
4300
4445
|
}
|
|
4301
4446
|
// Every other chain step (already fully emitted, inline, by the fold
|
|
@@ -4714,21 +4859,53 @@ function findObjectArrayFieldOrWholeObject(value, path = []) {
|
|
|
4714
4859
|
* path segment (e.g. `/orders/{id}`) rather than a query param or body
|
|
4715
4860
|
* field. Numeric leaves are included because a join key is just as often a
|
|
4716
4861
|
* numeric id (threaded as a query param string or a JSON body number
|
|
4717
|
-
* literal) as a string one.
|
|
4718
|
-
|
|
4862
|
+
* literal) as a string one.
|
|
4863
|
+
*
|
|
4864
|
+
* `allCaptures`, when passed, gates every query param and body leaf on
|
|
4865
|
+
* whether its OWN value (by param key / body path, via {@link endpointKey}'s
|
|
4866
|
+
* same-endpoint grouping — the same grouping {@link
|
|
4867
|
+
* findFrozenVaryingDrillParams} uses) ever differs on some other capture of
|
|
4868
|
+
* the same endpoint. A value that never varies (e.g. `adults=2`) is left
|
|
4869
|
+
* out, so callers matching by pure value equality (e.g. {@link
|
|
4870
|
+
* findThreadedJoinFields}) can't bind that constant onto an unrelated field
|
|
4871
|
+
* that coincidentally holds the same literal (e.g. a zero-valued `children`
|
|
4872
|
+
* param matching a discount amount that also happens to be `0` in one
|
|
4873
|
+
* capture, then diverges to a fraction in another). Path segments are
|
|
4874
|
+
* exempt from this gate and always pass through — {@link endpointKey}
|
|
4875
|
+
* already fixes the pathname, so a path segment can never be observed to
|
|
4876
|
+
* vary within a matched group, the same rationale {@link
|
|
4877
|
+
* findFrozenVaryingDrillParams} documents for the same exclusion. When no
|
|
4878
|
+
* other capture of this endpoint exists in `allCaptures`, variance can't be
|
|
4879
|
+
* observed either way, so every value is kept (unfiltered, matching the
|
|
4880
|
+
* behavior when `allCaptures` is omitted). */
|
|
4881
|
+
function collectRequestStringValues(capture, allCaptures) {
|
|
4882
|
+
const sameEndpointCaptures = allCaptures
|
|
4883
|
+
? allCaptures.filter((c) => c !== capture && endpointKey(c.url) === endpointKey(capture.url))
|
|
4884
|
+
: [];
|
|
4885
|
+
const varies = (own, others) => !allCaptures || sameEndpointCaptures.length === 0
|
|
4886
|
+
? true
|
|
4887
|
+
: others.some((other) => other !== undefined && other !== own);
|
|
4719
4888
|
const values = new Set();
|
|
4720
4889
|
try {
|
|
4721
4890
|
const url = new URL(capture.url);
|
|
4722
|
-
for (const v of url.searchParams.values())
|
|
4723
|
-
values.add(v);
|
|
4724
4891
|
for (const segment of url.pathname.split("/").filter(Boolean))
|
|
4725
4892
|
values.add(segment);
|
|
4893
|
+
for (const [paramKey, value] of url.searchParams.entries()) {
|
|
4894
|
+
const otherValues = sameEndpointCaptures.map((c) => {
|
|
4895
|
+
try {
|
|
4896
|
+
return new URL(c.url).searchParams.get(paramKey) ?? undefined;
|
|
4897
|
+
}
|
|
4898
|
+
catch {
|
|
4899
|
+
return undefined;
|
|
4900
|
+
}
|
|
4901
|
+
});
|
|
4902
|
+
if (varies(value, otherValues))
|
|
4903
|
+
values.add(value);
|
|
4904
|
+
}
|
|
4726
4905
|
}
|
|
4727
4906
|
catch {
|
|
4728
4907
|
// Relative or malformed URL — no query params or path segments to contribute.
|
|
4729
4908
|
}
|
|
4730
|
-
for (const v of jsonBodyLeafValues(capture.requestPostData) ?? [])
|
|
4731
|
-
values.add(v);
|
|
4732
4909
|
const parsedBody = (() => {
|
|
4733
4910
|
try {
|
|
4734
4911
|
return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
|
|
@@ -4740,13 +4917,136 @@ function collectRequestStringValues(capture) {
|
|
|
4740
4917
|
}
|
|
4741
4918
|
})();
|
|
4742
4919
|
if (parsedBody !== undefined) {
|
|
4743
|
-
for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
4744
|
-
if (
|
|
4745
|
-
|
|
4920
|
+
for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
4921
|
+
if (value === null)
|
|
4922
|
+
continue;
|
|
4923
|
+
const stringValue = String(value);
|
|
4924
|
+
const otherValues = sameEndpointCaptures.map((c) => {
|
|
4925
|
+
try {
|
|
4926
|
+
const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
|
|
4927
|
+
? JSON.parse(c.requestPostData)
|
|
4928
|
+
: undefined;
|
|
4929
|
+
if (otherBody === undefined)
|
|
4930
|
+
return undefined;
|
|
4931
|
+
const otherValue = readValueAtPath(otherBody, path);
|
|
4932
|
+
return otherValue === undefined ? undefined : String(otherValue);
|
|
4933
|
+
}
|
|
4934
|
+
catch {
|
|
4935
|
+
return undefined;
|
|
4936
|
+
}
|
|
4937
|
+
});
|
|
4938
|
+
if (varies(stringValue, otherValues))
|
|
4939
|
+
values.add(stringValue);
|
|
4746
4940
|
}
|
|
4747
4941
|
}
|
|
4748
4942
|
return values;
|
|
4749
4943
|
}
|
|
4944
|
+
/**
|
|
4945
|
+
* Finds every query param or JSON body leaf on `capture`'s own request that
|
|
4946
|
+
* (a) was left as a literal in `renderedText` — never swapped for a
|
|
4947
|
+
* `${...}` accessor by the threading pass above — and (b) took a different
|
|
4948
|
+
* value on some OTHER capture matching the exact same endpoint (same
|
|
4949
|
+
* origin+pathname, via {@link endpointKey}) anywhere in the run. Freezing
|
|
4950
|
+
* such a value bakes ONE capture's drill parameter into every fold
|
|
4951
|
+
* iteration's request, exactly the defect described in
|
|
4952
|
+
* docs/recon-generate-nested-fold-flatmaps-away-the-parent-so-drill-params-freeze.md
|
|
4953
|
+
* (a `packageCode`/`groupId`/`sailDate` triple that provably varies per
|
|
4954
|
+
* cruise, silently frozen because no threaded field explained it). Path
|
|
4955
|
+
* segments are deliberately not checked: {@link endpointKey} requires an
|
|
4956
|
+
* identical pathname to group two captures at all, so no path segment can
|
|
4957
|
+
* ever be observed to vary within a matched group.
|
|
4958
|
+
*/
|
|
4959
|
+
function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
|
|
4960
|
+
const key = endpointKey(capture.url);
|
|
4961
|
+
const sameEndpointCaptures = allCaptures.filter((c) => c !== capture && endpointKey(c.url) === key);
|
|
4962
|
+
if (sameEndpointCaptures.length === 0)
|
|
4963
|
+
return [];
|
|
4964
|
+
const isFrozenLiteral = (value) => value.length > 0 && renderedText.includes(value);
|
|
4965
|
+
const frozen = [];
|
|
4966
|
+
try {
|
|
4967
|
+
const url = new URL(capture.url);
|
|
4968
|
+
for (const [paramKey, value] of url.searchParams.entries()) {
|
|
4969
|
+
if (!isFrozenLiteral(value))
|
|
4970
|
+
continue;
|
|
4971
|
+
const differing = sameEndpointCaptures
|
|
4972
|
+
.map((c) => {
|
|
4973
|
+
try {
|
|
4974
|
+
return new URL(c.url).searchParams.get(paramKey);
|
|
4975
|
+
}
|
|
4976
|
+
catch {
|
|
4977
|
+
return null;
|
|
4978
|
+
}
|
|
4979
|
+
})
|
|
4980
|
+
.find((v) => v !== null && v !== value);
|
|
4981
|
+
if (differing !== undefined) {
|
|
4982
|
+
frozen.push({
|
|
4983
|
+
location: "query parameter",
|
|
4984
|
+
key: paramKey,
|
|
4985
|
+
frozenValue: value,
|
|
4986
|
+
differingValue: differing,
|
|
4987
|
+
});
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
catch {
|
|
4992
|
+
// Relative or malformed URL — no query params to check.
|
|
4993
|
+
}
|
|
4994
|
+
const parsedBody = (() => {
|
|
4995
|
+
try {
|
|
4996
|
+
return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
|
|
4997
|
+
? JSON.parse(capture.requestPostData)
|
|
4998
|
+
: undefined;
|
|
4999
|
+
}
|
|
5000
|
+
catch {
|
|
5001
|
+
return undefined;
|
|
5002
|
+
}
|
|
5003
|
+
})();
|
|
5004
|
+
if (parsedBody !== undefined) {
|
|
5005
|
+
for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
5006
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
5007
|
+
continue;
|
|
5008
|
+
const stringValue = String(value);
|
|
5009
|
+
if (!isFrozenLiteral(stringValue))
|
|
5010
|
+
continue;
|
|
5011
|
+
const fieldPath = path.join(".");
|
|
5012
|
+
const differing = sameEndpointCaptures
|
|
5013
|
+
.map((c) => {
|
|
5014
|
+
try {
|
|
5015
|
+
const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
|
|
5016
|
+
? JSON.parse(c.requestPostData)
|
|
5017
|
+
: undefined;
|
|
5018
|
+
return otherBody === undefined ? undefined : readValueAtPath(otherBody, path);
|
|
5019
|
+
}
|
|
5020
|
+
catch {
|
|
5021
|
+
return undefined;
|
|
5022
|
+
}
|
|
5023
|
+
})
|
|
5024
|
+
.find((v) => v !== undefined && String(v) !== stringValue);
|
|
5025
|
+
if (differing !== undefined) {
|
|
5026
|
+
frozen.push({
|
|
5027
|
+
location: "body field",
|
|
5028
|
+
key: fieldPath,
|
|
5029
|
+
frozenValue: stringValue,
|
|
5030
|
+
differingValue: String(differing),
|
|
5031
|
+
});
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
}
|
|
5035
|
+
return frozen;
|
|
5036
|
+
}
|
|
5037
|
+
/** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
|
|
5038
|
+
* varying literal — shared by {@link parameterizeUrl} (below) and
|
|
5039
|
+
* `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
|
|
5040
|
+
* drift on this guard. */
|
|
5041
|
+
function assertNoFrozenVaryingDrillParams(emitterName, capture, renderedText, allCaptures) {
|
|
5042
|
+
const frozen = findFrozenVaryingDrillParams(capture, renderedText, allCaptures);
|
|
5043
|
+
if (frozen.length === 0)
|
|
5044
|
+
return;
|
|
5045
|
+
const described = frozen
|
|
5046
|
+
.map((f) => `${f.location} "${f.key}" (froze "${f.frozenValue}", also captured as "${f.differingValue}")`)
|
|
5047
|
+
.join("; ");
|
|
5048
|
+
throw new Error(`${emitterName}: drill request to ${endpointKey(capture.url)} would freeze a value that varied across this run's own captures: ${described} — no threaded field (item or ancestor) explains it, so baking in one capture's literal would silently reuse it for every fold iteration; add the missing field to joinFields or make it resolvable from an ancestor binding`);
|
|
5049
|
+
}
|
|
4750
5050
|
/**
|
|
4751
5051
|
* Yields every string/numeric/boolean leaf reachable from `item` by walking nested
|
|
4752
5052
|
* plain objects only (not arrays — a join key is a scalar field of the item
|
|
@@ -4834,15 +5134,48 @@ function collectCandidateIndicesAscending(requestStringValueIndex, values, after
|
|
|
4834
5134
|
}
|
|
4835
5135
|
return [...candidates].sort((a, b) => a - b);
|
|
4836
5136
|
}
|
|
4837
|
-
|
|
4838
|
-
|
|
5137
|
+
/** Drops duplicate `(varName, field)` pairs, keeping the first (innermost-
|
|
5138
|
+
* scope-first, by {@link findThreadedJoinFields}'s scope ordering)
|
|
5139
|
+
* occurrence — the same field can otherwise appear twice when both
|
|
5140
|
+
* `target.joinFields` and the request-value scan resolve to it. */
|
|
5141
|
+
function dedupeThreadedFields(fields) {
|
|
5142
|
+
const seen = new Set();
|
|
5143
|
+
return fields.filter(({ varName, field }) => {
|
|
5144
|
+
const key = `${varName}.${field}`;
|
|
5145
|
+
if (seen.has(key))
|
|
5146
|
+
return false;
|
|
5147
|
+
seen.add(key);
|
|
5148
|
+
return true;
|
|
5149
|
+
});
|
|
5150
|
+
}
|
|
5151
|
+
/** Every per-item field, across `scopes`, whose value also appears somewhere
|
|
5152
|
+
* in `drillCapture`'s own request (URL, body, headers) — the fields a drill
|
|
5153
|
+
* request actually threads out of its per-item scope(s), used both to widen
|
|
5154
|
+
* URL/body parameterization beyond a fold target's own `joinFields` and (via
|
|
5155
|
+
* a single-scope call) to disambiguate which candidate array a chained step
|
|
5156
|
+
* depends on. `scopes` is searched in the given order (innermost fold item
|
|
5157
|
+
* first, then each ancestor binding a nested loop keeps addressable), since
|
|
5158
|
+
* an ancestor field with the same name as an item field must not shadow the
|
|
5159
|
+
* item's own value.
|
|
5160
|
+
*
|
|
5161
|
+
* `allCaptures`, when passed, is forwarded to {@link
|
|
5162
|
+
* collectRequestStringValues} to gate matching on cross-capture variance:
|
|
5163
|
+
* a param whose own value never varies across other captures of the same
|
|
5164
|
+
* endpoint is excluded from the candidate set entirely, so a constant like
|
|
5165
|
+
* `children=0` can't bind to an unrelated item field (a discount amount, a
|
|
5166
|
+
* departure port) purely because both happen to equal the same literal at
|
|
5167
|
+
* generation time. Omitted for the candidate-array disambiguation callers,
|
|
5168
|
+
* where narrowing by variance is a different concern than the URL/body
|
|
5169
|
+
* over-threading this gate exists to prevent. */
|
|
5170
|
+
function findThreadedJoinFields(scopes, drillCapture, allCaptures) {
|
|
5171
|
+
const requestValues = collectRequestStringValues(drillCapture, allCaptures);
|
|
4839
5172
|
if (requestValues.size === 0)
|
|
4840
5173
|
return [];
|
|
4841
|
-
return [...walkItemFieldPaths(
|
|
5174
|
+
return scopes.flatMap(({ varName, obj }) => [...walkItemFieldPaths(obj)]
|
|
4842
5175
|
.filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
|
|
4843
5176
|
(typeof v === "number" && requestValues.has(String(v))) ||
|
|
4844
5177
|
(typeof v === "boolean" && requestValues.has(String(v))))
|
|
4845
|
-
.map(({ path }) => path.join("."));
|
|
5178
|
+
.map(({ path }) => ({ varName, field: path.join(".") })));
|
|
4846
5179
|
}
|
|
4847
5180
|
/** Every string, numeric, and boolean leaf value present anywhere in a response —
|
|
4848
5181
|
* the set a chained drill-down step's request must overlap with for that
|
|
@@ -4918,7 +5251,7 @@ function selectDisambiguatedCandidate(responseBody, capture) {
|
|
|
4918
5251
|
if (candidates.length === 0)
|
|
4919
5252
|
return null;
|
|
4920
5253
|
const requestValues = collectRequestValuesIncludingHeaders(capture);
|
|
4921
|
-
const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields(item, capture).length > 0));
|
|
5254
|
+
const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields([{ varName: "item", obj: item }], capture).length > 0));
|
|
4922
5255
|
if (threaded)
|
|
4923
5256
|
return threaded;
|
|
4924
5257
|
return candidates.reduce((richest, candidate) => {
|
|
@@ -5070,10 +5403,10 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
|
|
|
5070
5403
|
// Every item in THIS candidate array is searched, not just items[0]
|
|
5071
5404
|
// — a flow that only ever drilled into a later item (never the
|
|
5072
5405
|
// first) must still resolve.
|
|
5073
|
-
const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields(item, drill.capture).length > 0);
|
|
5406
|
+
const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields([{ varName: "item", obj: item }], drill.capture).length > 0);
|
|
5074
5407
|
if (primaryMatchedItemIndex === -1)
|
|
5075
5408
|
continue;
|
|
5076
|
-
const joinFields = findThreadedJoinFields(primaryArray.items[primaryMatchedItemIndex], drill.capture);
|
|
5409
|
+
const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
|
|
5077
5410
|
// Widened to a flat (non-array) object response when the drill step has
|
|
5078
5411
|
// no object-array field of its own — see
|
|
5079
5412
|
// findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
|
|
@@ -5341,6 +5674,32 @@ function objectItemsAtPath(body, path) {
|
|
|
5341
5674
|
const items = outer.flatMap((element) => objectItemsAtPath(element, after) ?? []);
|
|
5342
5675
|
return items.length > 0 ? items : null;
|
|
5343
5676
|
}
|
|
5677
|
+
/** Same flattening as {@link objectItemsAtPath}, but pairs each flattened
|
|
5678
|
+
* leaf item with the ordered (outer-to-inner) chain of ancestor objects a
|
|
5679
|
+
* `.flatMap` accessor would discard — one entry per {@link
|
|
5680
|
+
* ARRAY_WILDCARD_SEGMENT} crossed on the way down. Index-aligned with
|
|
5681
|
+
* {@link objectItemsAtPath}'s own output (same DFS/outer-array order), so a
|
|
5682
|
+
* `FoldTarget.primaryMatchedItemIndex` resolves the same leaf item either
|
|
5683
|
+
* way; this variant additionally exposes the ancestor objects a nested-loop
|
|
5684
|
+
* emission ({@link pathToFoldLoopLines}) binds to `g0`/`g1`/... so drill
|
|
5685
|
+
* threading can read a param off an ancestor scope, not only the item. */
|
|
5686
|
+
function objectItemsWithAncestorsAtPath(body, path, ancestors = []) {
|
|
5687
|
+
const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
|
|
5688
|
+
if (wildcardIndex === -1) {
|
|
5689
|
+
const value = readValueAtPath(body, path);
|
|
5690
|
+
if (Array.isArray(value)) {
|
|
5691
|
+
return value.filter(isObjectArrayItem).map((item) => ({ item, ancestors: [...ancestors] }));
|
|
5692
|
+
}
|
|
5693
|
+
return isObjectArrayItem(value) ? [{ item: value, ancestors: [...ancestors] }] : [];
|
|
5694
|
+
}
|
|
5695
|
+
const outer = readValueAtPath(body, path.slice(0, wildcardIndex));
|
|
5696
|
+
if (!Array.isArray(outer))
|
|
5697
|
+
return [];
|
|
5698
|
+
const after = path.slice(wildcardIndex + 1);
|
|
5699
|
+
return outer.flatMap((element) => isObjectArrayItem(element)
|
|
5700
|
+
? objectItemsWithAncestorsAtPath(element, after, [...ancestors, element])
|
|
5701
|
+
: []);
|
|
5702
|
+
}
|
|
5344
5703
|
/**
|
|
5345
5704
|
* Builds a {@link FoldPlan} from a flow-declared {@link FoldReturnSpec}, so a
|
|
5346
5705
|
* site author can express a fold the structural heuristic misses.
|
|
@@ -6605,15 +6964,15 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
6605
6964
|
const gqlVariablesExpr = gqlOperationName
|
|
6606
6965
|
? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
|
|
6607
6966
|
: "{ q: payload.query }";
|
|
6608
|
-
/** Builds the `for (
|
|
6609
|
-
* every resolved plan's drill-down data onto `dataVarName`'s
|
|
6610
|
-
* — the single-primary counterpart of
|
|
6611
|
-
* per-item loop, sharing its match/merge
|
|
6612
|
-
* {@link emitFoldMatchAndMergeLines} so the two can't describe
|
|
6613
|
-
* merge semantics. Chain hops beyond the drill step itself are
|
|
6614
|
-
* directly off each hop's own captured request (no state-
|
|
6615
|
-
* pipeline) since a single-primary read flow carries no
|
|
6616
|
-
* for those calls to reference.
|
|
6967
|
+
/** Builds the nested `for` loop block(s) — see {@link pathToFoldLoopLines}
|
|
6968
|
+
* — that fold every resolved plan's drill-down data onto `dataVarName`'s
|
|
6969
|
+
* primary array — the single-primary counterpart of
|
|
6970
|
+
* emitMultiStepExecuteHttp's own per-item loop, sharing its match/merge
|
|
6971
|
+
* tail via {@link emitFoldMatchAndMergeLines} so the two can't describe
|
|
6972
|
+
* different merge semantics. Chain hops beyond the drill step itself are
|
|
6973
|
+
* rendered directly off each hop's own captured request (no state-
|
|
6974
|
+
* threading pipeline) since a single-primary read flow carries no
|
|
6975
|
+
* submitted payload for those calls to reference.
|
|
6617
6976
|
*
|
|
6618
6977
|
* `itemsOverride`, when given, replaces the `dataVarName`+`primaryArrayPath`
|
|
6619
6978
|
* accessor with a caller-supplied items expression — used by the paginated
|
|
@@ -6623,73 +6982,97 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
6623
6982
|
* response object. `foldPlan.primaryArrayPath` may still declare depth
|
|
6624
6983
|
* BELOW `itemsOverride.level` (a fold plan resolved against a nested array
|
|
6625
6984
|
* inside each paginated item) — the residual suffix beyond `level` is
|
|
6626
|
-
* descended into via the same
|
|
6627
|
-
*
|
|
6628
|
-
*
|
|
6985
|
+
* descended into via the same nested-loop emission the non-override branch
|
|
6986
|
+
* already uses, so no depth the fold plan declares is silently dropped.
|
|
6987
|
+
* Ancestor bindings inside `level` itself have no runtime loop variable
|
|
6988
|
+
* (the paginated merge already flattened across them), so only the
|
|
6989
|
+
* residual's OWN ancestor crossings are addressable for threading here. */
|
|
6629
6990
|
const buildFoldMergeLines = (dataVarName, itemsOverride) => {
|
|
6630
6991
|
const lines = [];
|
|
6631
6992
|
for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
|
|
6632
6993
|
const primaryStep = actionSteps[foldPlan.primaryStepIndex];
|
|
6633
6994
|
if (!primaryStep)
|
|
6634
6995
|
continue;
|
|
6635
|
-
const
|
|
6996
|
+
const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
|
|
6636
6997
|
const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
|
|
6637
6998
|
const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
|
|
6638
|
-
const
|
|
6639
|
-
const
|
|
6999
|
+
const itemVar = `item${planSuffix}`;
|
|
7000
|
+
const residualPath = itemsOverride
|
|
6640
7001
|
? (() => {
|
|
6641
|
-
const {
|
|
7002
|
+
const { level } = itemsOverride;
|
|
6642
7003
|
const levelPrefixesPrimaryArrayPath = level.length <= foldPlan.primaryArrayPath.length &&
|
|
6643
7004
|
level.every((segment, i) => segment === foldPlan.primaryArrayPath[i]);
|
|
6644
7005
|
if (!levelPrefixesPrimaryArrayPath) {
|
|
6645
7006
|
throw new Error(`emitContractTs: fold plan primary array path ${foldPlan.primaryArrayPath.join(".")} no longer extends the paginated collection's own array path ${level.join(".")} — the fold plan and this emitter have drifted out of sync`);
|
|
6646
7007
|
}
|
|
6647
|
-
|
|
6648
|
-
// ARRAY_WILDCARD_SEGMENT whenever it crosses the array boundary
|
|
6649
|
-
// `level` itself sits at (by construction, since `level` names
|
|
6650
|
-
// an array path) — `expr` stands in for that same array, so
|
|
6651
|
-
// pathToFoldAccessorExpr's own flatMap over `expr` consumes it;
|
|
6652
|
-
// prepending a second one here would double-flatten.
|
|
6653
|
-
const residualPath = foldPlan.primaryArrayPath.slice(level.length);
|
|
6654
|
-
if (residualPath.length === 0)
|
|
6655
|
-
return expr;
|
|
6656
|
-
const itemTypeExpr = `${pathAccessTypeExpr(`${pascal}Response`, level)}[number]`;
|
|
6657
|
-
return pathToFoldAccessorExpr(`(${expr} as ${itemTypeExpr}[])`, residualPath);
|
|
7008
|
+
return foldPlan.primaryArrayPath.slice(level.length);
|
|
6658
7009
|
})()
|
|
6659
|
-
:
|
|
6660
|
-
const
|
|
6661
|
-
|
|
7010
|
+
: foldPlan.primaryArrayPath;
|
|
7011
|
+
const { ancestorOpenLines, itemOpenLines, itemCloseLines, ancestorCloseLines, ancestorVars } = itemsOverride
|
|
7012
|
+
? pathToFoldLoopLines(residualPath.length === 0
|
|
7013
|
+
? itemsOverride.expr
|
|
7014
|
+
: `(${itemsOverride.expr} as ${pathAccessTypeExpr(`${pascal}Response`, itemsOverride.level)}[number][])`, residualPath, itemVar, " ", planSuffix)
|
|
7015
|
+
: pathToFoldLoopLines(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
|
|
7016
|
+
// Ancestor bindings within `itemsOverride.level` (if any) have no
|
|
7017
|
+
// runtime loop variable, since the paginated merge already flattened
|
|
7018
|
+
// across them — only the trailing `residualPath.length` ancestor
|
|
7019
|
+
// crossings correspond to `ancestorVars` above, so slice the
|
|
7020
|
+
// design-time ancestor chain to match.
|
|
7021
|
+
const residualAncestorCount = residualPath.filter((s) => s === ARRAY_WILDCARD_SEGMENT).length;
|
|
7022
|
+
lines.push(...ancestorOpenLines);
|
|
7023
|
+
// Chain fetches for targets whose parameterized URL never interpolates
|
|
7024
|
+
// `itemVar` are spliced here, above the item loop but inside the
|
|
7025
|
+
// ancestor loop(s) — fetched once per ancestor tuple and reused by
|
|
7026
|
+
// every descendant item's join/merge, mirroring
|
|
7027
|
+
// emitMultiStepExecuteHttp's identical hoist (see its own comment).
|
|
7028
|
+
const hoistedChainLines = [];
|
|
7029
|
+
// Every target's join/merge — plus any non-hoistable target's own
|
|
7030
|
+
// chain fetch — goes here, spliced inside the item loop.
|
|
7031
|
+
const itemScopedLines = [];
|
|
7032
|
+
const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
|
|
6662
7033
|
for (const [targetIndex, target] of foldPlan.targets.entries()) {
|
|
6663
|
-
const
|
|
6664
|
-
if (!
|
|
7034
|
+
const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
|
|
7035
|
+
if (!matchedPrimaryItem) {
|
|
6665
7036
|
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`);
|
|
6666
7037
|
}
|
|
7038
|
+
const { item: firstItem, ancestors: fullAncestors } = matchedPrimaryItem;
|
|
7039
|
+
const residualAncestors = fullAncestors.slice(fullAncestors.length - residualAncestorCount);
|
|
7040
|
+
const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, residualAncestors[idx]]));
|
|
7041
|
+
const threadingScopes = [
|
|
7042
|
+
{ varName: itemVar, obj: firstItem },
|
|
7043
|
+
...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
|
|
7044
|
+
];
|
|
6667
7045
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
6668
|
-
const
|
|
7046
|
+
const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
7047
|
+
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
6669
7048
|
// Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
|
|
6670
7049
|
// `parameterize` performs — a plain split/join would also rewrite
|
|
6671
7050
|
// unrelated substrings that happen to contain the join value.
|
|
6672
7051
|
//
|
|
6673
7052
|
// Parameterizes off EVERY per-item field this specific chain hop's
|
|
6674
|
-
// own captured request actually varies on (via findThreadedJoinFields
|
|
6675
|
-
//
|
|
6676
|
-
//
|
|
7053
|
+
// own captured request actually varies on (via findThreadedJoinFields,
|
|
7054
|
+
// searched across the item AND every ancestor binding), not just
|
|
7055
|
+
// `target.joinFields` — `target.joinFields` names the field used to
|
|
7056
|
+
// MATCH the drill's RESPONSE back onto the primary item (see
|
|
6677
7057
|
// emitFoldMatchAndMergeLines), which for a spec-declared foldReturn
|
|
6678
7058
|
// can legitimately be a field the request never carries at all (e.g.
|
|
6679
7059
|
// an `id` echoed only in the response, while the request is keyed by
|
|
6680
7060
|
// an unrelated field like a package code). Building the URL from only
|
|
6681
7061
|
// `target.joinFields` in that case would leave it unparameterized —
|
|
6682
|
-
// every item would fetch the SAME first-captured URL.
|
|
7062
|
+
// every item would fetch the SAME first-captured URL. Searching
|
|
7063
|
+
// ancestor scopes too is what lets a param that only lives on a
|
|
7064
|
+
// parent object (e.g. a group id) resolve at all.
|
|
6683
7065
|
const parameterizeUrl = (rawUrl, chainCapture) => {
|
|
6684
7066
|
const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
|
|
6685
7067
|
? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
|
|
6686
7068
|
: rawUrl;
|
|
6687
|
-
const threadedFields =
|
|
6688
|
-
...target.joinFields,
|
|
6689
|
-
...findThreadedJoinFields(
|
|
7069
|
+
const threadedFields = dedupeThreadedFields([
|
|
7070
|
+
...target.joinFields.map((field) => ({ varName: itemVar, field })),
|
|
7071
|
+
...findThreadedJoinFields(threadingScopes, chainCapture, actionSteps.map((s) => s.capture)),
|
|
6690
7072
|
]);
|
|
6691
|
-
|
|
6692
|
-
const
|
|
7073
|
+
const result = threadedFields.reduce((acc, { varName, field }) => {
|
|
7074
|
+
const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
|
|
7075
|
+
const value = readValueAtPath(scopeObj, field.split("."));
|
|
6693
7076
|
const stringValue = typeof value === "string" && value.length > 0
|
|
6694
7077
|
? value
|
|
6695
7078
|
: typeof value === "number" || typeof value === "boolean"
|
|
@@ -6697,28 +7080,49 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
6697
7080
|
: null;
|
|
6698
7081
|
if (stringValue === null)
|
|
6699
7082
|
return acc;
|
|
6700
|
-
return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${
|
|
7083
|
+
return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${scopedAccessor(varName, field)}}`);
|
|
6701
7084
|
}, withBase);
|
|
7085
|
+
assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, result, actionSteps.map((s) => s.capture));
|
|
7086
|
+
return result;
|
|
6702
7087
|
};
|
|
7088
|
+
const chainLines = [];
|
|
7089
|
+
// True once any chain step's parameterized URL actually ends up
|
|
7090
|
+
// referencing `itemVar`, off the SAME `parameterizeUrl` call the
|
|
7091
|
+
// request literally interpolates — see emitMultiStepExecuteHttp's
|
|
7092
|
+
// identical `referencesItemVar` for why this can't disagree with
|
|
7093
|
+
// what's emitted. `ancestorVars.length === 0` means there's no
|
|
7094
|
+
// ancestor loop to hoist into (a flat, single-level fold), so it's
|
|
7095
|
+
// treated as item-scoped too — flat folds must keep emitting
|
|
7096
|
+
// byte-identical code.
|
|
7097
|
+
let referencesItemVar = ancestorVars.length === 0;
|
|
6703
7098
|
for (const chainIndex of target.chain) {
|
|
6704
7099
|
const chainStep = actionSteps[chainIndex];
|
|
6705
7100
|
if (!chainStep)
|
|
6706
7101
|
continue;
|
|
6707
7102
|
const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
|
|
7103
|
+
if (itemVarRefPattern.test(url))
|
|
7104
|
+
referencesItemVar = true;
|
|
6708
7105
|
const schemaExpr = inferZodSchema(chainStep.capture.responseBody, 0, "", {
|
|
6709
7106
|
looseServerResponse: true,
|
|
6710
7107
|
aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([
|
|
6711
7108
|
chainStep.capture.responseBody,
|
|
6712
7109
|
]),
|
|
6713
7110
|
});
|
|
6714
|
-
|
|
7111
|
+
chainLines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
|
|
6715
7112
|
}
|
|
6716
7113
|
const terminalStep = actionSteps[target.chainTerminalIndex];
|
|
6717
7114
|
if (!terminalStep)
|
|
6718
7115
|
continue;
|
|
6719
|
-
|
|
7116
|
+
const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
|
|
7117
|
+
if (referencesItemVar) {
|
|
7118
|
+
itemScopedLines.push(...chainLines, ...matchLines);
|
|
7119
|
+
}
|
|
7120
|
+
else {
|
|
7121
|
+
hoistedChainLines.push(...chainLines);
|
|
7122
|
+
itemScopedLines.push(...matchLines);
|
|
7123
|
+
}
|
|
6720
7124
|
}
|
|
6721
|
-
lines.push(
|
|
7125
|
+
lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
|
|
6722
7126
|
}
|
|
6723
7127
|
return lines;
|
|
6724
7128
|
};
|